diff --git a/cmd/migrate.go b/cmd/migrate.go index e3def59f..7ba79444 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -121,7 +121,7 @@ func init() { migrateUpCmd.Flags().StringVarP(&dsn, "database-path", "d", "./notary.db", "A DSN for connecting to the database. Also accepts a path to a file, and will assume that the database is SQLite.") migrateDownCmd.Flags().StringVarP(&dsn, "database-path", "d", "./notary.db", "A DSN for connecting to the database. Also accepts a path to a file, and will assume that the database is SQLite.") migrateStatusCmd.Flags().StringVarP(&dsn, "database-path", "d", "./notary.db", "A DSN for connecting to the database. Also accepts a path to a file, and will assume that the database is SQLite.") - + if err := migrateUpCmd.MarkFlagRequired("database-path"); err != nil { log.Fatalf("Error marking database-path flag as required: %v", err) } diff --git a/cmd/start.go b/cmd/start.go index 51da7fda..4057f72f 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -28,14 +28,14 @@ https://canonical-notary.readthedocs-hosted.com/en/latest/reference/config_file/ if err != nil { log.Fatalf("couldn't create app context: %s", err) } - l := appContext.Logger + l := appContext.SystemLogger // Initialize the database connection db, err := db.NewDatabase(&db.DatabaseOpts{ - DatabasePath: appContext.DBPath, + DatabasePath: appContext.DBPath, ApplyMigrations: appContext.ApplyMigrations, - Backend: appContext.EncryptionBackend, - Logger: appContext.Logger, + Backend: appContext.EncryptionBackend, + Logger: appContext.SystemLogger, }) if err != nil { l.Fatal("couldn't initialize database", zap.Error(err)) @@ -49,7 +49,8 @@ https://canonical-notary.readthedocs-hosted.com/en/latest/reference/config_file/ Database: db, ExternalHostname: appContext.ExternalHostname, EnablePebbleNotifications: appContext.PebbleNotificationsEnabled, - Logger: appContext.Logger, + SystemLogger: appContext.SystemLogger, + AuditLogger: appContext.AuditLogger, PublicConfig: appContext.PublicConfig, }) if err != nil { @@ -82,7 +83,7 @@ func init() { startCmd.Flags().StringVarP(&configFilePath, "config", "c", "", "path to the configuration file") startCmd.Flags().BoolP("migrate-database", "m", false, "automatically apply database migrations if needed (use with caution)") - + err := startCmd.MarkFlagRequired("config") if err != nil { log.Fatalf("couldn't mark flag required: %s", err) diff --git a/internal/config/config.go b/internal/config/config.go index 8cbac6e6..733e39ed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,13 +36,20 @@ func CreateAppContext(cmdFlags *pflag.FlagSet, configFilePath string) (*NotaryAp return nil, err } - // initialize logger - logger, err := initializeLogger(cfg.Sub("logging")) + // initialize system logger + systemLogger, err := initializeLogger(cfg.Sub("logging.system")) if err != nil { - return nil, fmt.Errorf("couldn't initialize logger: %w", err) + return nil, fmt.Errorf("couldn't initialize system logger: %w", err) + } + + // initialize audit logger + // Audit logs are always at INFO level + auditLogger, err := initializeAuditLogger(cfg.Sub("logging.audit")) + if err != nil { + return nil, fmt.Errorf("couldn't initialize audit logger: %w", err) } // initialize encryption backend - backendType, backend, err := initializeEncryptionBackend(cfg.Sub("encryption_backend"), logger) + backendType, backend, err := initializeEncryptionBackend(cfg.Sub("encryption_backend"), systemLogger) if err != nil { return nil, fmt.Errorf("couldn't initialize encryption backend: %w", err) } @@ -55,7 +62,8 @@ func CreateAppContext(cmdFlags *pflag.FlagSet, configFilePath string) (*NotaryAp appContext.TLSCertificate = cert appContext.TLSPrivateKey = key - appContext.Logger = logger + appContext.SystemLogger = systemLogger + appContext.AuditLogger = auditLogger appContext.EncryptionBackend = backend appContext.EncryptionBackendType = backendType appContext.PublicConfig = &PublicConfigData{ @@ -85,6 +93,7 @@ func initializeServerConfig(cmdFlags *pflag.FlagSet, configFilePath string) (*vi v.SetDefault("external_hostname", "localhost") v.SetDefault("logging.system.level", "debug") v.SetDefault("logging.system.output", "stdout") + v.SetDefault("logging.audit.output", "stdout") if configFilePath == "" { return nil, errors.New("config file path not provided") @@ -209,17 +218,50 @@ func initializeEncryptionBackend(encryptionCfg *viper.Viper, logger *zap.Logger) } } -// initializeLogger creates and configures a logger based on the configuration. +// initializeLogger creates and configures a logger based on the provided configuration. +// cfg is the logger configuration subsection (e.g., logging.system). +// output can be "stdout", "stderr", or a file path. func initializeLogger(cfg *viper.Viper) (*zap.Logger, error) { + if cfg == nil { + return nil, fmt.Errorf("logger configuration is not defined") + } + zapConfig := zap.NewProductionConfig() - logLevel, err := zapcore.ParseLevel(cfg.GetString("system.level")) + logLevel, err := zapcore.ParseLevel(cfg.GetString("level")) if err != nil { return nil, fmt.Errorf("invalid log level: %w", err) } - - zapConfig.OutputPaths = []string{cfg.GetString("system.output")} zapConfig.Level.SetLevel(logLevel) + + output := cfg.GetString("output") + zapConfig.OutputPaths = []string{output} + + zapConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + + logger, err := zapConfig.Build() + if err != nil { + return nil, err + } + + return logger, nil +} + +// initializeAuditLogger creates an audit logger that always logs at INFO level, regardless of config. +// cfg is the logger configuration subsection (e.g., logging.audit). +// output can be "stdout", "stderr", or a file path. +func initializeAuditLogger(cfg *viper.Viper) (*zap.Logger, error) { + if cfg == nil { + return nil, fmt.Errorf("logger configuration is not defined") + } + + zapConfig := zap.NewProductionConfig() + // Force INFO level for audit logs + zapConfig.Level.SetLevel(zapcore.InfoLevel) + + output := cfg.GetString("output") + zapConfig.OutputPaths = []string{output} + zapConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder logger, err := zapConfig.Build() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ebc3475d..2e43db33 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -34,7 +34,8 @@ func TestValidConfig(t *testing.T) { DBPath: "./notary.db", Port: 8000, PebbleNotificationsEnabled: false, - Logger: nil, + SystemLogger: nil, + AuditLogger: nil, EncryptionBackend: encryption_backend.NoEncryptionBackend{}, EncryptionBackendType: config.EncryptionBackendTypeNone, }}, // This case tests the expected default values for missing fields are filled correctly @@ -51,7 +52,8 @@ func TestValidConfig(t *testing.T) { DBPath: "./notary.db", Port: 8000, PebbleNotificationsEnabled: false, - Logger: nil, + SystemLogger: nil, + AuditLogger: nil, EncryptionBackend: encryption_backend.NoEncryptionBackend{}, EncryptionBackendType: config.EncryptionBackendTypeNone, }}, // This case tests that the variables from the yaml are correctly copied to the final config @@ -67,7 +69,7 @@ func TestValidConfig(t *testing.T) { t.Errorf("ValidateConfig(%q) = %v, want nil", "config.yaml", err) return } - if !cmp.Equal(gotCfg, tc.wantCfg, cmpopts.IgnoreFields(config.NotaryAppContext{}, "Logger")) { + if !cmp.Equal(gotCfg, tc.wantCfg, cmpopts.IgnoreFields(config.NotaryAppContext{}, "SystemLogger", "AuditLogger")) { t.Errorf("ValidateConfig returned unexpected diff (-want+got):\n%v", cmp.Diff(tc.wantCfg, gotCfg)) } }) diff --git a/internal/config/types.go b/internal/config/types.go index e2718ae3..6d260113 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -13,73 +13,6 @@ const ( EncryptionBackendTypeNone = "none" ) -// VaultBackendConfigYaml BackendConfig for Vault-specific fields. -type VaultBackendConfigYaml struct { - Endpoint string `yaml:"endpoint"` - Mount string `yaml:"mount"` - KeyName string `yaml:"key_name"` - Token string `yaml:"token"` - AppRoleID string `yaml:"approle_role_id"` - AppRoleSecretID string `yaml:"approle_secret_id"` - TlsCaCertificate string `yaml:"tls_ca_cert,omitempty"` // Optional path to a CA file for Vault TLS verification - TlsSkipVerify bool `yaml:"tls_skip_verify,omitempty"` // Optional flag to skip TLS verification -} - -// PKCS11BackendConfigYaml BackendConfig for PKCS11-specific fields. -type PKCS11BackendConfigYaml struct { - LibPath string `yaml:"lib_path"` - KeyID uint16 `yaml:"aes_encryption_key_id"` - Pin string `yaml:"pin"` -} - -// NamedBackendConfigYaml represents a single named backend configuration -type NamedBackendConfigYaml struct { - PKCS11 *PKCS11BackendConfigYaml `yaml:"pkcs11,omitempty"` - Vault *VaultBackendConfigYaml `yaml:"vault,omitempty"` -} - -type EncryptionBackendConfigYaml map[string]NamedBackendConfigYaml - -type SystemLoggingConfigYaml struct { - Level string `yaml:"level"` - Output string `yaml:"output"` -} - -type LoggingConfigYaml struct { - System SystemLoggingConfigYaml `yaml:"system"` -} - -type ConfigYAML struct { - KeyPath string `yaml:"key_path"` - CertPath string `yaml:"cert_path"` - ExternalHostname string `yaml:"external_hostname"` - DBPath string `yaml:"db_path"` - Port int `yaml:"port"` - PebbleNotifications bool `yaml:"pebble_notifications"` - Logging LoggingConfigYaml `yaml:"logging"` - EncryptionBackend EncryptionBackendConfigYaml `yaml:"encryption_backend"` -} - -type LoggingLevel string - -const ( - Debug LoggingLevel = "debug" - Info LoggingLevel = "info" - Warn LoggingLevel = "warn" - Error LoggingLevel = "error" - Fatal LoggingLevel = "fatal" - Panic LoggingLevel = "panic" -) - -type SystemLoggingOptions struct { - Level LoggingLevel - Output string -} - -type LoggerOptions struct { - System SystemLoggingOptions -} - // PublicConfigData contains non-sensitive configuration fields that are safe to expose type PublicConfigData struct { Port int @@ -90,8 +23,6 @@ type PublicConfigData struct { } type NotaryAppContext struct { - // The YAML configuration file content - Config *ConfigYAML PublicConfig *PublicConfigData // TLSPrivateKey and Certificate for the webserver and the listener port @@ -111,8 +42,9 @@ type NotaryAppContext struct { // Send pebble notifications if enabled. Read more at github.com/canonical/pebble PebbleNotificationsEnabled bool - // Options for the logger - Logger *zap.Logger + // Options for the loggers + SystemLogger *zap.Logger + AuditLogger *zap.Logger // Encryption backend to be used for encrypting and decrypting sensitive data EncryptionBackendType diff --git a/internal/db/db_init.go b/internal/db/db_init.go index d1354688..86d97967 100644 --- a/internal/db/db_init.go +++ b/internal/db/db_init.go @@ -46,7 +46,7 @@ func NewDatabase(dbOpts *DatabaseOpts) (*Database, error) { if err != nil { return nil, err } - if version < 1 { + if version < 1 { if dbOpts.ApplyMigrations { goose.SetBaseFS(migrations.EmbedMigrations) if err := goose.Up(sqlConnection, ".", goose.WithNoColor(true)); err != nil { diff --git a/internal/db/types.go b/internal/db/types.go index 2b8339fb..cae9335c 100644 --- a/internal/db/types.go +++ b/internal/db/types.go @@ -7,19 +7,19 @@ import ( ) type DatabaseOpts struct { - DatabasePath string + DatabasePath string ApplyMigrations bool - Backend encryption_backend.EncryptionBackend - Logger *zap.Logger + Backend encryption_backend.EncryptionBackend + Logger *zap.Logger } // Database is the object used to communicate with the established repository. type Database struct { - Conn *sqlair.DB - stmts *Statements + Conn *sqlair.DB + stmts *Statements EncryptionKey []byte - JWTSecret []byte + JWTSecret []byte } const CAMaxExpiryYears = 1 diff --git a/internal/logging/audit_logger.go b/internal/logging/audit_logger.go new file mode 100644 index 00000000..a116a726 --- /dev/null +++ b/internal/logging/audit_logger.go @@ -0,0 +1,420 @@ +package logging + +import ( + "fmt" + + "go.uber.org/zap" +) + +// AuditLogger provides structured logging for security audit events. +type AuditLogger struct { + logger *zap.Logger +} + +// NewAuditLogger creates a new audit logger with a named logger for easy filtering. +func NewAuditLogger(logger *zap.Logger) *AuditLogger { + return &AuditLogger{ + logger: logger.Named("audit"), + } +} + +// Authentication Events + +// LoginSuccess logs a successful user authentication. +func (a *AuditLogger) LoginSuccess(username string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityInfo} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", fmt.Sprintf("authn_login_success:%s", username)), + zap.String("username", username), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Info(fmt.Sprintf("User %s login successfully", username), fields...) +} + +// LoginFailed logs a failed authentication attempt. +func (a *AuditLogger) LoginFailed(username string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityWarn} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", fmt.Sprintf("authn_login_fail:%s", username)), + zap.String("username", username), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Warn(fmt.Sprintf("User %s login failed", username), fields...) +} + +// TokenCreated logs when a JWT authentication token is created. +func (a *AuditLogger) TokenCreated(username string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityInfo} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", fmt.Sprintf("authn_token_created:%s", username)), + zap.String("username", username), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Info(fmt.Sprintf("A token has been created for %s", username), fields...) +} + +// PasswordChanged logs when a user's password is successfully changed. +func (a *AuditLogger) PasswordChanged(username string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityInfo} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", fmt.Sprintf("authn_password_change:%s", username)), + zap.String("username", username), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Info(fmt.Sprintf("User %s has successfully changed their password", username), fields...) +} + +// PasswordChangeFailed logs when a password change attempt fails. +func (a *AuditLogger) PasswordChangeFailed(username string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityCritical} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", fmt.Sprintf("authn_password_change_fail:%s", username)), + zap.String("username", username), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Error(fmt.Sprintf("User %s failed to change their password", username), fields...) +} + +// Certificate Events + +// CertificateRequested logs when a certificate signing request is created. +func (a *AuditLogger) CertificateRequested(csrID string, caID int, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityInfo} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "cert_requested"), + zap.String("csr_id", csrID), + zap.Int("ca_id", caID), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Info("Certificate signing request created", fields...) +} + +// CertificateIssued logs when a certificate is successfully issued. +func (a *AuditLogger) CertificateIssued(csrID string, caID int, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityInfo} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "cert_issued"), + zap.String("csr_id", csrID), + zap.Int("ca_id", caID), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Info("Certificate issued", fields...) +} + +// CertificateRejected logs when a certificate request is rejected. +func (a *AuditLogger) CertificateRejected(csrID string, caID int, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityWarn} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "cert_rejected"), + zap.String("csr_id", csrID), + zap.Int("ca_id", caID), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Warn("Certificate request rejected", fields...) +} + +// Certificate Authority Events + +// CACreated logs when a new certificate authority is created. +func (a *AuditLogger) CACreated(caID int, commonName string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityInfo} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "ca_created"), + zap.Int("ca_id", caID), + zap.String("common_name", commonName), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Info("Certificate Authority created", fields...) +} + +// CADeleted logs when a certificate authority is deleted. +func (a *AuditLogger) CADeleted(caID int, commonName string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityWarn} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "ca_deleted"), + zap.Int("ca_id", caID), + zap.String("common_name", commonName), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Warn("Certificate Authority deleted", fields...) +} + +// CAUpdated logs when a certificate authority enabled status is changed. +func (a *AuditLogger) CAUpdated(caID string, enabled bool, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityWarn} + for _, opt := range opts { + opt(ctx) + } + + status := "disabled" + if enabled { + status = "enabled" + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "ca_updated"), + zap.String("ca_id", caID), + zap.String("status", status), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Warn("Certificate Authority updated", fields...) +} + +// CACertificateUploaded logs when a CA certificate chain is uploaded. +func (a *AuditLogger) CACertificateUploaded(caID string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityInfo} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "ca_cert_uploaded"), + zap.String("ca_id", caID), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Info("Certificate uploaded to Certificate Authority", fields...) +} + +// CACertificateRevoked logs when a CA certificate is revoked. +func (a *AuditLogger) CACertificateRevoked(caID string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityWarn} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "ca_cert_revoked"), + zap.String("ca_id", caID), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Warn("Certificate Authority certificate revoked", fields...) +} + +// User Management Events + +// UserCreated logs when a new user account is created. +func (a *AuditLogger) UserCreated(username string, roleID int, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityWarn} + for _, opt := range opts { + opt(ctx) + } + + roleName := fmt.Sprintf("role_%d", roleID) + if roleID == 1 { + roleName = "admin" + } else if roleID == 2 { + roleName = "user" + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", fmt.Sprintf("user_created:%s,%s", username, roleName)), + zap.String("username", username), + zap.Int("role_id", roleID), + zap.String("role_name", roleName), + } + fields = append(fields, ctx.toZapFields()...) + + description := fmt.Sprintf("User account %s created with role %s", username, roleName) + if ctx.actor != "" { + description = fmt.Sprintf("User %s created user %s with role %s", ctx.actor, username, roleName) + } + a.logger.Warn(description, fields...) +} + +// UserDeleted logs when a user account is deleted. +func (a *AuditLogger) UserDeleted(username string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityWarn} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "user_deleted"), + zap.String("username", username), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Warn("User account deleted", fields...) +} + +// UserUpdated logs when a user account is updated (e.g., password changed). +func (a *AuditLogger) UserUpdated(username, updateType string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityWarn} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", fmt.Sprintf("user_updated:%s,%s", username, updateType)), + zap.String("username", username), + zap.String("update_type", updateType), + } + fields = append(fields, ctx.toZapFields()...) + + description := fmt.Sprintf("User %s updated with %s", username, updateType) + if ctx.actor != "" { + description = fmt.Sprintf("User %s updated user %s with %s", ctx.actor, username, updateType) + } + a.logger.Warn(description, fields...) +} + +// Access Control Events + +// AccessDenied logs when a user is denied access to a resource. +func (a *AuditLogger) AccessDenied(username, resource, action string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityCritical} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", fmt.Sprintf("authz_fail:%s,%s", username, resource)), + zap.String("username", username), + zap.String("resource", resource), + zap.String("action", action), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Error("Access denied", fields...) +} + +// UnauthorizedAccess logs when an unauthorized access attempt is detected. +func (a *AuditLogger) UnauthorizedAccess(opts ...AuditOption) { + ctx := &auditContext{severity: SeverityCritical} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "authz_fail"), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Error("Unauthorized access attempt", fields...) +} + +// API Action Events + +// APIAction logs any action performed against the API. +func (a *AuditLogger) APIAction(action string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityInfo} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "audit"), + zap.String("event", "api_action"), + zap.String("action", action), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Info("API action performed", fields...) +} + +// CSR and Certificate Request lifecycle events (deletions and revocations) + +// CertificateRequestDeleted logs when a CSR is deleted. +func (a *AuditLogger) CertificateRequestDeleted(csrID string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityWarn} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "cert_request_deleted"), + zap.String("csr_id", csrID), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Warn("Certificate request deleted", fields...) +} + +// CertificateRevoked logs when a certificate (for a CSR) is revoked. +func (a *AuditLogger) CertificateRevoked(csrID string, opts ...AuditOption) { + ctx := &auditContext{severity: SeverityWarn} + for _, opt := range opts { + opt(ctx) + } + + fields := []zap.Field{ + zap.String("type", "security"), + zap.String("event", "cert_revoked"), + zap.String("csr_id", csrID), + } + fields = append(fields, ctx.toZapFields()...) + + a.logger.Warn("Certificate revoked", fields...) +} diff --git a/internal/logging/audit_options.go b/internal/logging/audit_options.go new file mode 100644 index 00000000..63970b92 --- /dev/null +++ b/internal/logging/audit_options.go @@ -0,0 +1,109 @@ +package logging + +import ( + "net/http" + + "go.uber.org/zap" +) + +// AuditOption is a functional option for adding context to audit log events. +type AuditOption func(*auditContext) + +// SecuritySeverity represents the severity level for audit events using standard log levels +type SecuritySeverity string + +const ( + SeverityDebug SecuritySeverity = "DEBUG" + SeverityInfo SecuritySeverity = "INFO" + SeverityWarn SecuritySeverity = "WARN" + SeverityError SecuritySeverity = "ERROR" + SeverityCritical SecuritySeverity = "CRITICAL" +) + +// auditContext holds optional contextual information for audit events. +type auditContext struct { + actor string + ipAddress string + reason string + userAgent string + path string + method string + resourceType string + resourceID string + severity SecuritySeverity +} + +// WithActor specifies who performed the action (e.g., username, email). +func WithActor(actor string) AuditOption { + return func(ctx *auditContext) { + ctx.actor = actor + } +} + +// WithReason specifies the reason for an action (typically used for failures). +func WithReason(reason string) AuditOption { + return func(ctx *auditContext) { + ctx.reason = reason + } +} + +// WithResourceType specifies the type of resource being acted upon (e.g., "certificate", "user", "ca"). +func WithResourceType(resourceType string) AuditOption { + return func(ctx *auditContext) { + ctx.resourceType = resourceType + } +} + +// WithResourceID specifies the ID of the resource being acted upon. +func WithResourceID(id string) AuditOption { + return func(ctx *auditContext) { + ctx.resourceID = id + } +} + +// WithRequest is a convenience function that extracts multiple fields from an HTTP request. +// It captures: remote IP, user agent, path, and method. Kept simple by design. +func WithRequest(r *http.Request) AuditOption { + return func(ctx *auditContext) { + ctx.ipAddress = r.RemoteAddr + ctx.userAgent = r.UserAgent() + ctx.path = r.URL.Path + ctx.method = r.Method + } +} + +// toZapFields converts the audit context into zap fields. +// Only non-empty fields are included in the output. +func (ctx *auditContext) toZapFields() []zap.Field { + fields := []zap.Field{} + + if ctx.actor != "" { + fields = append(fields, zap.String("actor", ctx.actor)) + } + if ctx.ipAddress != "" { + fields = append(fields, zap.String("ip_address", ctx.ipAddress)) + } + if ctx.reason != "" { + fields = append(fields, zap.String("reason", ctx.reason)) + } + if ctx.userAgent != "" { + fields = append(fields, zap.String("user_agent", ctx.userAgent)) + } + if ctx.path != "" { + fields = append(fields, zap.String("path", ctx.path)) + } + if ctx.method != "" { + fields = append(fields, zap.String("method", ctx.method)) + } + if ctx.resourceType != "" { + fields = append(fields, zap.String("resource_type", ctx.resourceType)) + } + if ctx.resourceID != "" { + fields = append(fields, zap.String("resource_id", ctx.resourceID)) + } + if ctx.severity != "" { + fields = append(fields, zap.String("severity", string(ctx.severity))) + } + + return fields +} diff --git a/internal/server/audit_middleware_test.go b/internal/server/audit_middleware_test.go new file mode 100644 index 00000000..995bd672 --- /dev/null +++ b/internal/server/audit_middleware_test.go @@ -0,0 +1,142 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + tu "github.com/canonical/notary/internal/testutils" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +func findStringField(entry observer.LoggedEntry, key string) string { + for _, f := range entry.Context { + if f.Key == key { + switch f.Type { + case zapcore.StringType: + return f.String + } + } + } + return "" +} + +func TestAuditMiddleware_LogsFailureAndReason(t *testing.T) { + ts, logs := tu.MustPrepareServer(t) + _ = logs.TakeAll() + + req, err := http.NewRequest("GET", ts.URL+"/api/v1/certificate_requests", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + res, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected %d, got %d", http.StatusUnauthorized, res.StatusCode) + } + + entries := logs.TakeAll() + var haveAuthzFail, haveAPIFailed bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + switch findStringField(e, "event") { + case "authz_fail": + haveAuthzFail = true + case "api_action": + if findStringField(e, "action") == "GET certificate_requests (failed)" { + haveAPIFailed = true + } + } + } + if !haveAuthzFail { + t.Fatalf("expected UnauthorizedAccess audit entry (event=authz_fail)") + } + if !haveAPIFailed { + t.Fatalf("expected APIAction failure audit entry for GET certificate_requests") + } +} + +func TestAuditMiddleware_LogsSuccessfulRead(t *testing.T) { + ts, logs := tu.MustPrepareServer(t) + + createBody := map[string]any{ + "email": "admin@example.com", + "password": "Admin123", + "role_id": 0, + } + payload, _ := json.Marshal(createBody) + req, err := http.NewRequest("POST", ts.URL+"/api/v1/accounts", bytes.NewReader(payload)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + res, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + if res.StatusCode != http.StatusCreated { + t.Fatalf("expected %d, got %d", http.StatusCreated, res.StatusCode) + } + + loginBody := map[string]any{ + "email": "admin@example.com", + "password": "Admin123", + } + loginPayload, _ := json.Marshal(loginBody) + req, err = http.NewRequest("POST", ts.URL+"/login", bytes.NewReader(loginPayload)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + res, err = ts.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, res.StatusCode) + } + var loginResp struct { + Result struct { + Token string `json:"token"` + } + } + if err := json.NewDecoder(res.Body).Decode(&loginResp); err != nil { + t.Fatalf("decode login response: %v", err) + } + + _ = logs.TakeAll() + + req, err = http.NewRequest("GET", ts.URL+"/api/v1/certificate_requests", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+loginResp.Result.Token) + res, err = ts.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, res.StatusCode) + } + + entries := logs.TakeAll() + var haveAPISuccess bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "api_action" && findStringField(e, "action") == "GET certificate_requests" { + haveAPISuccess = true + break + } + } + if !haveAPISuccess { + t.Fatalf("expected APIAction success audit entry for GET certificate_requests") + } +} diff --git a/internal/server/authorization_test.go b/internal/server/authorization_test.go index 0cbef5ba..26d5ebad 100644 --- a/internal/server/authorization_test.go +++ b/internal/server/authorization_test.go @@ -10,7 +10,7 @@ import ( ) func TestAuthorizationNoAuth(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) client := ts.Client() testCases := []struct { @@ -47,7 +47,7 @@ func TestAuthorizationNoAuth(t *testing.T) { } func TestAuthorizationAdminAuthorized(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") tu.MustPrepareAccount(t, ts, "whatever@canonical.com", tu.RoleCertificateManager, adminToken) client := ts.Client() @@ -91,7 +91,7 @@ func TestAuthorizationAdminAuthorized(t *testing.T) { } func TestAuthorizationAdminUnAuthorized(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") nonAdminToken := tu.MustPrepareAccount(t, ts, "whatever@canonical.com", tu.RoleCertificateManager, adminToken) client := ts.Client() @@ -111,7 +111,7 @@ func TestAuthorizationAdminUnAuthorized(t *testing.T) { } func TestAuthorizationCertificateManagerAuthorized(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, logs := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") certManagerToken := tu.MustPrepareAccount(t, ts, "testuser@canonical.com", tu.RoleCertificateManager, adminToken) client := ts.Client() @@ -167,6 +167,9 @@ func TestAuthorizationCertificateManagerAuthorized(t *testing.T) { } for _, tC := range testCases { t.Run(tC.desc, func(t *testing.T) { + if tC.desc == "certificate manager can change self password with /me" { + _ = logs.TakeAll() + } req, err := http.NewRequest(tC.method, ts.URL+tC.path, strings.NewReader(tC.data)) if err != nil { t.Fatal(err) @@ -179,12 +182,33 @@ func TestAuthorizationCertificateManagerAuthorized(t *testing.T) { if res.StatusCode != tC.status { t.Errorf("expected status code %d, got %d", tC.status, res.StatusCode) } + if tC.desc == "certificate manager can change self password with /me" { + entries := logs.TakeAll() + var havePwdChanged, haveUserUpdated bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + switch findStringField(e, "event") { + case "authn_password_change:testuser@canonical.com": + havePwdChanged = true + case "user_updated:testuser@canonical.com,password_change": + haveUserUpdated = true + } + } + if !havePwdChanged { + t.Errorf("expected PasswordChanged audit entry for self change") + } + if !haveUserUpdated { + t.Errorf("expected UserUpdated audit entry for self change") + } + } }) } } func TestAuthorizationCertificateManagerUnauthorized(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, logs := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") certManagerToken := tu.MustPrepareAccount(t, ts, "whatever@canonical.com", tu.RoleCertificateManager, adminToken) client := ts.Client() @@ -220,6 +244,7 @@ func TestAuthorizationCertificateManagerUnauthorized(t *testing.T) { } for _, tC := range testCases { t.Run(tC.desc, func(t *testing.T) { + _ = logs.TakeAll() req, err := http.NewRequest(tC.method, ts.URL+tC.path, strings.NewReader(tC.data)) if err != nil { t.Fatal(err) @@ -232,12 +257,28 @@ func TestAuthorizationCertificateManagerUnauthorized(t *testing.T) { if res.StatusCode != tC.status { t.Errorf("expected status code %d, got %d", tC.status, res.StatusCode) } + if tC.status == http.StatusForbidden { + entries := logs.TakeAll() + var haveAuthzFail bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if strings.HasPrefix(findStringField(e, "event"), "authz_fail:") { + haveAuthzFail = true + break + } + } + if !haveAuthzFail { + t.Errorf("expected audit authz_fail for %s %s", tC.method, tC.path) + } + } }) } } func TestAuthorizationCertificateRequestorAuthorized(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") certRequestorToken := tu.MustPrepareAccount(t, ts, "testuser@canonical.com", tu.RoleCertificateRequestor, adminToken) client := ts.Client() @@ -315,7 +356,7 @@ func TestAuthorizationCertificateRequestorAuthorized(t *testing.T) { } func TestAuthorizationCertificateRequestorUnauthorized(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") certRequestorToken := tu.MustPrepareAccount(t, ts, "testuser@canonical.com", tu.RoleCertificateRequestor, adminToken) client := ts.Client() @@ -436,7 +477,7 @@ func TestAuthorizationCertificateRequestorUnauthorized(t *testing.T) { } func TestAuthorizationReadOnlyAuthorized(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") readOnlyToken := tu.MustPrepareAccount(t, ts, "testuser@canonical.com", tu.RoleReadOnly, adminToken) client := ts.Client() @@ -531,7 +572,7 @@ func TestAuthorizationReadOnlyAuthorized(t *testing.T) { } func TestAuthorizationReadOnlyUnauthorized(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") readOnlyToken := tu.MustPrepareAccount(t, ts, "testuser@canonical.com", tu.RoleReadOnly, adminToken) client := ts.Client() diff --git a/internal/server/handlers_accounts.go b/internal/server/handlers_accounts.go index 06dc65e8..0c03e6ef 100644 --- a/internal/server/handlers_accounts.go +++ b/internal/server/handlers_accounts.go @@ -10,6 +10,7 @@ import ( "strconv" "github.com/canonical/notary/internal/db" + "github.com/canonical/notary/internal/logging" ) type CreateAccountParams struct { @@ -84,7 +85,7 @@ func ListAccounts(env *HandlerConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { accounts, err := env.DB.ListUsers() if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } accountsResponse := make([]GetAccountResponse, len(accounts)) @@ -97,7 +98,7 @@ func ListAccounts(env *HandlerConfig) http.HandlerFunc { } err = writeResponse(w, accountsResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -113,24 +114,24 @@ func GetAccount(env *HandlerConfig) http.HandlerFunc { if id == "me" { claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) if headerErr != nil { - writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.Logger) + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) } account, err = env.DB.GetUser(db.ByEmail(claims.Email)) } else { var idNum int64 idNum, err = strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } account, err = env.DB.GetUser(db.ByUserID(idNum)) } if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } accountResponse := GetAccountResponse{ @@ -140,7 +141,7 @@ func GetAccount(env *HandlerConfig) http.HandlerFunc { } err = writeResponse(w, accountResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -151,27 +152,40 @@ func CreateAccount(env *HandlerConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var createAccountParams CreateAccountParams if err := json.NewDecoder(r.Body).Decode(&createAccountParams); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) return } valid, err := createAccountParams.IsValid() if !valid { - writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.Logger) + writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.SystemLogger) return } newUserID, err := env.DB.CreateUser(createAccountParams.Email, createAccountParams.Password, db.RoleID(createAccountParams.RoleID)) if err != nil { if errors.Is(err, db.ErrAlreadyExists) { - writeError(w, http.StatusBadRequest, "account with given email already exists", err, env.Logger) + writeError(w, http.StatusBadRequest, "account with given email already exists", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + var actor string + claims, claimsErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if claimsErr == nil { + actor = claims.Email + } + + opts := []logging.AuditOption{logging.WithRequest(r)} + if actor != "" { + opts = append(opts, logging.WithActor(actor)) + } + env.AuditLogger.UserCreated(createAccountParams.Email, int(createAccountParams.RoleID), opts...) + successResponse := CreateSuccessResponse{Message: "success", ID: newUserID} err = writeResponse(w, successResponse, http.StatusCreated) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -184,36 +198,49 @@ func DeleteAccount(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idInt, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } account, err := env.DB.GetUser(db.ByUserID(idInt)) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } if account.RoleID == db.RoleID(RoleAdmin) { err = errors.New("deleting an Admin account is not allowed") - writeError(w, http.StatusBadRequest, "deleting an Admin account is not allowed.", err, env.Logger) + writeError(w, http.StatusBadRequest, "deleting an Admin account is not allowed.", err, env.SystemLogger) return } + + claims, err := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", err, env.SystemLogger) + return + } + err = env.DB.DeleteUser(db.ByUserID(idInt)) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.UserDeleted(account.Email, + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusAccepted) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -225,33 +252,75 @@ func ChangeAccountPassword(env *HandlerConfig) http.HandlerFunc { var idNum int64 idInt, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } idNum = idInt + + targetAccount, err := env.DB.GetUser(db.ByUserID(idNum)) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) + return + } + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) + return + } + + claims, err := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", err, env.SystemLogger) + return + } + var changeAccountParams ChangeAccountParams if err := json.NewDecoder(r.Body).Decode(&changeAccountParams); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) return } valid, err := changeAccountParams.IsValid() if !valid { - writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.Logger) + env.AuditLogger.PasswordChangeFailed(targetAccount.Email, + logging.WithActor(claims.Email), + logging.WithRequest(r), + logging.WithReason(err.Error()), + ) + writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.SystemLogger) return } err = env.DB.UpdateUserPassword(db.ByUserID(idNum), changeAccountParams.Password) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + env.AuditLogger.PasswordChangeFailed(targetAccount.Email, + logging.WithActor(claims.Email), + logging.WithRequest(r), + logging.WithReason("user not found"), + ) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + env.AuditLogger.PasswordChangeFailed(targetAccount.Email, + logging.WithActor(claims.Email), + logging.WithRequest(r), + logging.WithReason("database error"), + ) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.PasswordChanged(targetAccount.Email, + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + env.AuditLogger.UserUpdated(targetAccount.Email, "password_change", + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusCreated) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -262,38 +331,54 @@ func ChangeMyPassword(env *HandlerConfig) http.HandlerFunc { var idNum int64 claims, err := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) if err != nil { - writeError(w, http.StatusUnauthorized, "Unauthorized", err, env.Logger) + writeError(w, http.StatusUnauthorized, "Unauthorized", err, env.SystemLogger) return } account, err := env.DB.GetUser(db.ByEmail(claims.Email)) if err != nil { - writeError(w, http.StatusUnauthorized, "Unauthorized", err, env.Logger) + writeError(w, http.StatusUnauthorized, "Unauthorized", err, env.SystemLogger) return } idNum = account.ID var changeAccountParams ChangeAccountParams if err := json.NewDecoder(r.Body).Decode(&changeAccountParams); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) return } valid, err := changeAccountParams.IsValid() if !valid { - writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.Logger) + env.AuditLogger.PasswordChangeFailed(account.Email, + logging.WithRequest(r), + logging.WithReason(err.Error()), + ) + writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.SystemLogger) return } err = env.DB.UpdateUserPassword(db.ByUserID(idNum), changeAccountParams.Password) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + env.AuditLogger.PasswordChangeFailed(account.Email, + logging.WithRequest(r), + logging.WithReason("user not found"), + ) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + env.AuditLogger.PasswordChangeFailed(account.Email, + logging.WithRequest(r), + logging.WithReason("database error"), + ) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.PasswordChanged(account.Email, logging.WithRequest(r)) + env.AuditLogger.UserUpdated(account.Email, "password_change", logging.WithRequest(r)) + successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusCreated) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } diff --git a/internal/server/handlers_accounts_test.go b/internal/server/handlers_accounts_test.go index 6604c035..6e3c82e6 100644 --- a/internal/server/handlers_accounts_test.go +++ b/internal/server/handlers_accounts_test.go @@ -11,7 +11,7 @@ import ( // The order of the tests is important, as some tests depend on // the state of the server after previous tests. func TestAccountsEndToEnd(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, logs := tu.MustPrepareServer(t) client := ts.Client() adminToken := tu.MustPrepareAccount(t, ts, "testadmin@canonical.com", tu.RoleAdmin, "") nonAdminToken := tu.MustPrepareAccount(t, ts, "whatever@canonical.com", tu.RoleCertificateManager, adminToken) @@ -52,6 +52,7 @@ func TestAccountsEndToEnd(t *testing.T) { }) t.Run("3. Create account", func(t *testing.T) { + _ = logs.TakeAll() createAccountParams := &tu.CreateAccountParams{ Email: "nopass@canonical.com", Password: "myPassword123!", @@ -67,6 +68,21 @@ func TestAccountsEndToEnd(t *testing.T) { if response.Error != "" { t.Fatalf("unexpected error :%q", response.Error) } + + entries := logs.TakeAll() + var haveUserCreated bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == ("user_created:" + createAccountParams.Email + ",admin") { + haveUserCreated = true + break + } + } + if !haveUserCreated { + t.Fatalf("expected UserCreated audit entry for %s", createAccountParams.Email) + } }) t.Run("4. Get account", func(t *testing.T) { @@ -105,6 +121,7 @@ func TestAccountsEndToEnd(t *testing.T) { }) t.Run("6. Change account password - success", func(t *testing.T) { + _ = logs.TakeAll() changeAccountPasswordParams := &tu.ChangeAccountPasswordParams{ Password: "newPassword1", } @@ -118,6 +135,26 @@ func TestAccountsEndToEnd(t *testing.T) { if response.Error != "" { t.Fatalf("unexpected error :%q", response.Error) } + + entries := logs.TakeAll() + var havePwdChanged, haveUserUpdated bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + switch findStringField(e, "event") { + case "authn_password_change:testadmin@canonical.com": + havePwdChanged = true + case "user_updated:testadmin@canonical.com,password_change": + haveUserUpdated = true + } + } + if !havePwdChanged { + t.Fatalf("expected PasswordChanged audit entry") + } + if !haveUserUpdated { + t.Fatalf("expected UserUpdated audit entry for password_change") + } }) t.Run("7. Change account password - no user", func(t *testing.T) { @@ -137,6 +174,7 @@ func TestAccountsEndToEnd(t *testing.T) { }) t.Run("8. Delete account - success", func(t *testing.T) { + _ = logs.TakeAll() statusCode, response, err := tu.DeleteAccount(ts.URL, client, adminToken, 2) if err != nil { t.Fatalf("couldn't delete account: %s", err) @@ -147,6 +185,21 @@ func TestAccountsEndToEnd(t *testing.T) { if response.Error != "" { t.Fatalf("expected error %q, got %q", "", response.Error) } + + entries := logs.TakeAll() + var haveUserDeleted bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "user_deleted" && findStringField(e, "username") == "whatever@canonical.com" { + haveUserDeleted = true + break + } + } + if !haveUserDeleted { + t.Fatalf("expected UserDeleted audit entry for whatever@canonical.com") + } }) t.Run("9. Delete account - no user", func(t *testing.T) { @@ -186,7 +239,7 @@ func TestAccountsEndToEnd(t *testing.T) { } func TestCreateAccountInvalidInputs(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) client := ts.Client() adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") @@ -292,7 +345,7 @@ func TestCreateAccountInvalidInputs(t *testing.T) { } func TestChangeAccountPasswordInvalidInputs(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) client := ts.Client() adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") diff --git a/internal/server/handlers_certificate_authorities.go b/internal/server/handlers_certificate_authorities.go index 82d0b1ad..858a8bed 100644 --- a/internal/server/handlers_certificate_authorities.go +++ b/internal/server/handlers_certificate_authorities.go @@ -17,11 +17,25 @@ import ( "time" "github.com/canonical/notary/internal/db" + "github.com/canonical/notary/internal/logging" "go.uber.org/zap" ) const nextUpdateYears = 1 +// extractCommonName extracts the CN from a certificate PEM string, returns "unknown" if it fails +func extractCommonName(certPEM string) string { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return "unknown" + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "unknown" + } + return cert.Subject.CommonName +} + type CertificateAuthority struct { ID int64 `json:"id"` Enabled bool `json:"enabled"` @@ -222,7 +236,7 @@ func ListCertificateAuthorities(env *HandlerConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cas, err := env.DB.ListDenormalizedCertificateAuthorities() if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } caResponse := make([]CertificateAuthority, len(cas)) @@ -238,7 +252,7 @@ func ListCertificateAuthorities(env *HandlerConfig) http.HandlerFunc { } err = writeResponse(w, caResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -250,22 +264,22 @@ func CreateCertificateAuthority(env *HandlerConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var params CreateCertificateAuthorityParams if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) return } valid, err := params.IsValid() if !valid { - writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.Logger) + writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.SystemLogger) return } claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) if headerErr != nil { - writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.Logger) + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) return } csrPEM, privPEM, crlPEM, certPEM, err := createCertificateAuthority(params) if err != nil { - writeError(w, http.StatusInternalServerError, "Failed to create certificate authority", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Failed to create certificate authority", err, env.SystemLogger) return } var newCAID int64 @@ -275,14 +289,19 @@ func CreateCertificateAuthority(env *HandlerConfig) http.HandlerFunc { newCAID, err = env.DB.CreateCertificateAuthority(strings.TrimSpace(csrPEM), strings.TrimSpace(privPEM), "", "", claims.ID) } if err != nil { - writeError(w, http.StatusInternalServerError, "Failed to create certificate authority", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Failed to create certificate authority", err, env.SystemLogger) return } + env.AuditLogger.CACreated(int(newCAID), params.CommonName, + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + successResponse := CreateSuccessResponse{Message: "Certificate Authority created successfully", ID: newCAID} err = writeResponse(w, successResponse, http.StatusCreated) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -295,17 +314,17 @@ func GetCertificateAuthority(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } ca, err := env.DB.GetDenormalizedCertificateAuthority(db.ByCertificateAuthorityDenormalizedID(idNum)) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } caResponse := CertificateAuthority{ @@ -319,7 +338,7 @@ func GetCertificateAuthority(env *HandlerConfig) http.HandlerFunc { err = writeResponse(w, caResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -332,29 +351,40 @@ func UpdateCertificateAuthority(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } var params UpdateCertificateAuthorityParams if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) + return + } + + claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if headerErr != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) return } err = env.DB.UpdateCertificateAuthorityEnabledStatus(db.ByCertificateAuthorityID(idNum), params.Enabled) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + env.AuditLogger.CAUpdated(id, params.Enabled, + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -367,23 +397,45 @@ func DeleteCertificateAuthority(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) + return + } + + claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if headerErr != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) + return + } + + ca, err := env.DB.GetDenormalizedCertificateAuthority(db.ByCertificateAuthorityDenormalizedID(idNum)) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) + return + } + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } err = env.DB.DeleteCertificateAuthority(db.ByCertificateAuthorityID(idNum)) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.CADeleted(int(idNum), extractCommonName(ca.CertificateChain), + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -396,32 +448,44 @@ func PostCertificateAuthorityCertificate(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } var UploadCertificateToCertificateAuthorityParams UploadCertificateToCertificateAuthorityParams if err := json.NewDecoder(r.Body).Decode(&UploadCertificateToCertificateAuthorityParams); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) return } valid, err := UploadCertificateToCertificateAuthorityParams.IsValid() if !valid { - writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.Logger) + writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.SystemLogger) + return + } + + claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if headerErr != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) return } err = env.DB.UpdateCertificateAuthorityCertificate(db.ByCertificateAuthorityDenormalizedID(idNum), UploadCertificateToCertificateAuthorityParams.CertificateChain) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.CACertificateUploaded(id, + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + err = writeResponse(w, SuccessResponse{Message: "success"}, http.StatusCreated) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -435,43 +499,43 @@ func SignCertificateAuthority(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } var signCertificateAuthorityParams SignCertificateAuthorityParams if err := json.NewDecoder(r.Body).Decode(&signCertificateAuthorityParams); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) return } caIDInt, err := strconv.ParseInt(signCertificateAuthorityParams.CertificateAuthorityID, 10, 64) if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } caToBeSigned, err := env.DB.GetCertificateAuthority(db.ByCertificateAuthorityID(idNum)) if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } err = env.DB.SignCertificateRequest(db.ByCSRID(caToBeSigned.CSRID), db.ByCertificateAuthorityDenormalizedID(caIDInt), env.ExternalHostname) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } if env.SendPebbleNotifications { err := SendPebbleNotification(CertificateUpdate, idNum) if err != nil { - env.Logger.Warn("pebble notify failed", zap.Error(err)) + env.SystemLogger.Warn("pebble notify failed", zap.Error(err)) } } successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusAccepted) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -484,24 +548,24 @@ func GetCertificateAuthorityCRL(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } ca, err := env.DB.GetDenormalizedCertificateAuthority(db.ByCertificateAuthorityDenormalizedID(idNum)) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } crlResponse := CRL{CRL: ca.CRL} err = writeResponse(w, crlResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -515,39 +579,52 @@ func RevokeCertificateAuthorityCertificate(env *HandlerConfig) http.HandlerFunc id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) + return + } + + claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if headerErr != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) return } + ca, err := env.DB.GetCertificateAuthority(db.ByCertificateAuthorityID(idNum)) if err != nil { - env.Logger.Info("could not get certificate authority", zap.Error(err)) + env.SystemLogger.Info("could not get certificate authority", zap.Error(err)) if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } err = env.DB.RevokeCertificate(db.ByCSRID(ca.CSRID)) if err != nil { - env.Logger.Warn("could not revoke certificate", zap.Error(err)) + env.SystemLogger.Warn("could not revoke certificate", zap.Error(err)) if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.CACertificateRevoked(id, + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + if env.SendPebbleNotifications { err := SendPebbleNotification(CertificateUpdate, idNum) if err != nil { - env.Logger.Warn("pebble notify failed", zap.Error(err)) + env.SystemLogger.Warn("pebble notify failed", zap.Error(err)) } } successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusAccepted) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } diff --git a/internal/server/handlers_certificate_authorities_test.go b/internal/server/handlers_certificate_authorities_test.go index b2e6c451..c363bddd 100644 --- a/internal/server/handlers_certificate_authorities_test.go +++ b/internal/server/handlers_certificate_authorities_test.go @@ -15,7 +15,7 @@ import ( // The order of the tests is important, as some tests depend on the state of the server after previous tests. func TestSelfSignedCertificateAuthorityEndToEnd(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, logs := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") client := ts.Client() @@ -36,6 +36,7 @@ func TestSelfSignedCertificateAuthorityEndToEnd(t *testing.T) { }) t.Run("2. Create self signed certificate authority", func(t *testing.T) { + _ = logs.TakeAll() createCertificatAuthorityParams := tu.CreateCertificateAuthorityParams{ SelfSigned: true, @@ -58,6 +59,21 @@ func TestSelfSignedCertificateAuthorityEndToEnd(t *testing.T) { if createCAResponse.Error != "" { t.Fatalf("expected success, got %s", createCAResponse.Error) } + + entries := logs.TakeAll() + var haveCACreated bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "ca_created" { + haveCACreated = true + break + } + } + if !haveCACreated { + t.Fatalf("expected CACreated audit entry") + } }) t.Run("3. Get all CA's - 1 should be there and enabled", func(t *testing.T) { @@ -156,6 +172,7 @@ func TestSelfSignedCertificateAuthorityEndToEnd(t *testing.T) { }) t.Run("7. Sign the intermediate CA's CSR", func(t *testing.T) { + _ = logs.TakeAll() signedCert := tu.SignCSR(IntermediateCACSR) statusCode, uploadCertificateResponse, err := tu.UploadCertificateToCertificateAuthority(ts.URL, client, adminToken, 2, server.UploadCertificateToCertificateAuthorityParams{CertificateChain: signedCert + tu.SelfSignedCACertificate}) if err != nil { @@ -167,6 +184,21 @@ func TestSelfSignedCertificateAuthorityEndToEnd(t *testing.T) { if uploadCertificateResponse.Error != "" { t.Fatalf("expected success, got %s", uploadCertificateResponse.Error) } + + entries := logs.TakeAll() + var haveCertUploaded bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "ca_cert_uploaded" { + haveCertUploaded = true + break + } + } + if !haveCertUploaded { + t.Fatalf("expected CACertificateUploaded audit entry") + } }) t.Run("8. Get all CA's - 2 should be there and both enabled", func(t *testing.T) { statusCode, listCAsResponse, err := tu.ListCertificateAuthorities(ts.URL, client, adminToken) @@ -223,6 +255,7 @@ func TestSelfSignedCertificateAuthorityEndToEnd(t *testing.T) { } }) t.Run("11. Delete first CA", func(t *testing.T) { + _ = logs.TakeAll() statusCode, err := tu.DeleteCertificateAuthority(ts.URL, client, adminToken, 1) if err != nil { t.Fatal("expected no error, got: ", err) @@ -230,6 +263,20 @@ func TestSelfSignedCertificateAuthorityEndToEnd(t *testing.T) { if statusCode != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, statusCode) } + entries := logs.TakeAll() + var haveCADeleted bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "ca_deleted" { + haveCADeleted = true + break + } + } + if !haveCADeleted { + t.Fatalf("expected CADeleted audit entry") + } }) t.Run("12. Get all CA's - 1 enabled should be there", func(t *testing.T) { statusCode, listCAsResponse, err := tu.ListCertificateAuthorities(ts.URL, client, adminToken) @@ -252,7 +299,7 @@ func TestSelfSignedCertificateAuthorityEndToEnd(t *testing.T) { } func TestCreateCertificateAuthorityInvalidInputs(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") client := ts.Client() @@ -341,7 +388,7 @@ func TestCreateCertificateAuthorityInvalidInputs(t *testing.T) { } func TestUploadCertificateToCertificateAuthorityInvalidInputs(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") client := ts.Client() @@ -415,7 +462,7 @@ invalid } func TestSignCertificatesEndToEnd(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, logs := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") client := ts.Client() @@ -611,6 +658,34 @@ func TestSignCertificatesEndToEnd(t *testing.T) { t.Fatalf("expected second CA to have a chain with 2 certificates") } }) + + t.Run("10. Update CA enabled status and assert audit", func(t *testing.T) { + _ = logs.TakeAll() + statusCode, updateResp, err := tu.UpdateCertificateAuthority(ts.URL, client, adminToken, 1, tu.UpdateCertificateAuthorityParams{Status: "active"}) + if err != nil { + t.Fatal(err) + } + if statusCode != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, statusCode) + } + if updateResp.Error != "" { + t.Fatalf("expected success, got %s", updateResp.Error) + } + entries := logs.TakeAll() + var haveCAUpdated bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "ca_updated" { + haveCAUpdated = true + break + } + } + if !haveCAUpdated { + t.Fatalf("expected CAUpdated audit entry") + } + }) t.Run("10. Create 2nd CSR's", func(t *testing.T) { createCertificateRequestRequest := tu.CreateCertificateRequestParams{CSR: tu.StrawberryCSR} statusCode, createCertResponse, err := tu.CreateCertificateRequest(ts.URL, client, adminToken, createCertificateRequestRequest) @@ -673,7 +748,7 @@ func TestSignCertificatesEndToEnd(t *testing.T) { t.Fatalf("expected 2 certificates, got %d", len(listCSRsResponse.Result)) } if listCSRsResponse.Result[0].Status != "Active" { - t.Fatalf("expected first csr to be active, got %s", listCSRsResponse.Result[3].Status) + t.Fatalf("expected first csr to be active, got %s", listCSRsResponse.Result[0].Status) } if strings.Count(listCSRsResponse.Result[0].CertificateChain, "BEGIN CERTIFICATE") != 2 { t.Fatalf("expected first csr to have a chain with 2 certificates") @@ -688,7 +763,7 @@ func TestSignCertificatesEndToEnd(t *testing.T) { } func TestUnsuccessfulRequestsMadeToCACSRs(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") client := ts.Client() @@ -819,7 +894,7 @@ func TestUnsuccessfulRequestsMadeToCACSRs(t *testing.T) { } func TestCertificateRevocationListsEndToEnd(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, logs := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") client := ts.Client() @@ -1074,6 +1149,7 @@ func TestCertificateRevocationListsEndToEnd(t *testing.T) { }) t.Run("10. Revoke Intermediate CA", func(t *testing.T) { + _ = logs.TakeAll() statusCode, response, err := tu.RevokeCertificateAuthority(ts.URL, client, adminToken, 2) if err != nil { t.Fatalf("expected no error, got: %s", err) @@ -1084,6 +1160,20 @@ func TestCertificateRevocationListsEndToEnd(t *testing.T) { if response.Error != "" { t.Fatalf("expected success, got %s", response.Error) } + entries := logs.TakeAll() + var haveCARevoked bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "ca_cert_revoked" { + haveCARevoked = true + break + } + } + if !haveCARevoked { + t.Fatalf("expected CACertificateRevoked audit entry") + } statusCode, cas, err := tu.ListCertificateAuthorities(ts.URL, client, adminToken) if statusCode != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, statusCode) diff --git a/internal/server/handlers_certificate_requests.go b/internal/server/handlers_certificate_requests.go index 0838abb4..b10a2b21 100644 --- a/internal/server/handlers_certificate_requests.go +++ b/internal/server/handlers_certificate_requests.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/canonical/notary/internal/db" + "github.com/canonical/notary/internal/logging" "go.uber.org/zap" ) @@ -72,7 +73,7 @@ func ListCertificateRequests(env *HandlerConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) if headerErr != nil { - writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.Logger) + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) return } @@ -86,7 +87,7 @@ func ListCertificateRequests(env *HandlerConfig) http.HandlerFunc { csrs, err = env.DB.ListCertificateRequestWithCertificatesWithoutCAS(filter) if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } @@ -96,10 +97,10 @@ func ListCertificateRequests(env *HandlerConfig) http.HandlerFunc { user, err := env.DB.GetUser(db.ByUserID(csr.UserID)) if err != nil { if errors.Is(err, db.ErrNotFound) { - env.Logger.Warn("user not found for certificate request", zap.Int64("user_id", csr.UserID)) + env.SystemLogger.Warn("user not found for certificate request", zap.Int64("user_id", csr.UserID)) email = "unknown" } else { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } } else { @@ -115,7 +116,7 @@ func ListCertificateRequests(env *HandlerConfig) http.HandlerFunc { } err = writeResponse(w, certificateRequestsResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -126,38 +127,44 @@ func CreateCertificateRequest(env *HandlerConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var createCertificateRequestParams CreateCertificateRequestParams if err := json.NewDecoder(r.Body).Decode(&createCertificateRequestParams); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) return } valid, err := createCertificateRequestParams.IsValid() if !valid { - writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.Logger) + writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.SystemLogger) return } claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) if headerErr != nil { - writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.Logger) + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) return } newCSRID, err := env.DB.CreateCertificateRequest(createCertificateRequestParams.CSR, claims.ID) if err != nil { if errors.Is(err, db.ErrAlreadyExists) { - writeError(w, http.StatusBadRequest, "given csr already recorded", err, env.Logger) + writeError(w, http.StatusBadRequest, "given csr already recorded", err, env.SystemLogger) return } if errors.Is(err, db.ErrInvalidCertificateRequest) { - writeError(w, http.StatusBadRequest, "csr validation failed", err, env.Logger) + writeError(w, http.StatusBadRequest, "csr validation failed", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.CertificateRequested(strconv.FormatInt(newCSRID, 10), 0, + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + successResponse := CreateSuccessResponse{Message: "success", ID: newCSRID} err = writeResponse(w, successResponse, http.StatusCreated) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -169,40 +176,40 @@ func GetCertificateRequest(env *HandlerConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) if headerErr != nil { - writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.Logger) + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) return } id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } csr, err := env.DB.GetCertificateRequestAndChain(db.ByCSRID(idNum)) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } // Restrict access to certificate requestors' own requests if claims.RoleID == RoleCertificateRequestor && claims.ID != csr.UserID { - writeError(w, http.StatusForbidden, "Access denied", fmt.Errorf("user does not have permission to access this certificate request"), env.Logger) + writeError(w, http.StatusForbidden, "Access denied", fmt.Errorf("user does not have permission to access this certificate request"), env.SystemLogger) return } _, err = env.DB.GetCertificateAuthority(db.ByCertificateAuthorityCSRID(csr.CSR_ID)) if rowFound(err) { - writeError(w, http.StatusNotFound, "Not Found", fmt.Errorf("not found"), env.Logger) + writeError(w, http.StatusNotFound, "Not Found", fmt.Errorf("not found"), env.SystemLogger) return } if realError(err) { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } @@ -210,10 +217,10 @@ func GetCertificateRequest(env *HandlerConfig) http.HandlerFunc { user, err := env.DB.GetUser(db.ByUserID(csr.UserID)) if err != nil { if errors.Is(err, db.ErrNotFound) { - env.Logger.Warn("user not found for certificate request", zap.Int64("user_id", csr.UserID)) + env.SystemLogger.Warn("user not found for certificate request", zap.Int64("user_id", csr.UserID)) email = "unknown" } else { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } } else { @@ -230,7 +237,7 @@ func GetCertificateRequest(env *HandlerConfig) http.HandlerFunc { err = writeResponse(w, certificateRequestResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -243,31 +250,44 @@ func DeleteCertificateRequest(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if headerErr != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) + return + } + _, err = env.DB.GetCertificateAuthority(db.ByCertificateAuthorityCSRID(idNum)) if rowFound(err) { - writeError(w, http.StatusNotFound, "Not Found", fmt.Errorf("not found"), env.Logger) + writeError(w, http.StatusNotFound, "Not Found", fmt.Errorf("not found"), env.SystemLogger) return } if realError(err) { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } err = env.DB.DeleteCertificateRequest(db.ByCSRID(idNum)) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.CertificateRequestDeleted(id, + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusAccepted) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -279,49 +299,62 @@ func PostCertificateRequestCertificate(env *HandlerConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var createCertificateParams CreateCertificateParams if err := json.NewDecoder(r.Body).Decode(&createCertificateParams); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) return } valid, err := createCertificateParams.IsValid() if !valid { - writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.Logger) + writeError(w, http.StatusBadRequest, fmt.Errorf("Invalid request: %s", err).Error(), err, env.SystemLogger) return } id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) + return + } + + claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if headerErr != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) return } + _, err = env.DB.GetCertificateAuthority(db.ByCertificateAuthorityCSRID(idNum)) if rowFound(err) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } if realError(err) { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } newCertID, err := env.DB.AddCertificateChainToCertificateRequest(db.ByCSRID(idNum), createCertificateParams.CertificateChain) if err != nil { if errors.Is(err, db.ErrNotFound) || errors.Is(err, db.ErrInvalidCertificate) { - writeError(w, http.StatusBadRequest, "Bad Request", err, env.Logger) + writeError(w, http.StatusBadRequest, "Bad Request", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.CertificateIssued(id, 0, + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + if env.SendPebbleNotifications { err := SendPebbleNotification(CertificateUpdate, idNum) if err != nil { - env.Logger.Warn("pebble notify failed", zap.Error(err)) + env.SystemLogger.Warn("pebble notify failed", zap.Error(err)) } } successResponse := CreateSuccessResponse{Message: "success", ID: newCertID} err = writeResponse(w, successResponse, http.StatusCreated) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -334,38 +367,52 @@ func RejectCertificateRequest(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } + + claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if headerErr != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) + return + } + _, err = env.DB.GetCertificateAuthority(db.ByCertificateAuthorityCSRID(idNum)) if rowFound(err) { err = fmt.Errorf("certificate request %d not found", idNum) - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } if realError(err) { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } err = env.DB.RejectCertificateRequest(db.ByCSRID(idNum)) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.CertificateRejected(id, 0, + logging.WithActor(claims.Email), + logging.WithRequest(r), + logging.WithReason("rejected by administrator"), + ) + if env.SendPebbleNotifications { err := SendPebbleNotification(CertificateUpdate, idNum) if err != nil { - env.Logger.Warn("pebble notify failed", zap.Error(err)) + env.SystemLogger.Warn("pebble notify failed", zap.Error(err)) } } successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusAccepted) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -378,37 +425,37 @@ func DeleteCertificate(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } _, err = env.DB.GetCertificateAuthority(db.ByCertificateAuthorityCSRID(idNum)) if rowFound(err) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } if realError(err) { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } err = env.DB.DeleteCertificateRequest(db.ByCSRID(idNum)) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusBadRequest, "Bad Request", err, env.Logger) + writeError(w, http.StatusBadRequest, "Bad Request", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } if env.SendPebbleNotifications { err := SendPebbleNotification(CertificateUpdate, idNum) if err != nil { - env.Logger.Warn("pebble notify failed", zap.Error(err)) + env.SystemLogger.Warn("pebble notify failed", zap.Error(err)) } } successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -422,37 +469,50 @@ func RevokeCertificate(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusBadRequest, "Invalid ID", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid ID", err, env.SystemLogger) return } + + claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret) + if headerErr != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized", headerErr, env.SystemLogger) + return + } + _, err = env.DB.GetCertificateAuthority(db.ByCertificateAuthorityCSRID(idNum)) if rowFound(err) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } if realError(err) { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } err = env.DB.RevokeCertificate(db.ByCSRID(idNum)) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } + + env.AuditLogger.CertificateRevoked(id, + logging.WithActor(claims.Email), + logging.WithRequest(r), + ) + if env.SendPebbleNotifications { err := SendPebbleNotification(CertificateUpdate, idNum) if err != nil { - env.Logger.Warn("pebble notify failed", zap.Error(err)) + env.SystemLogger.Warn("pebble notify failed", zap.Error(err)) } } successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusAccepted) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } @@ -466,48 +526,48 @@ func SignCertificateRequest(env *HandlerConfig) http.HandlerFunc { id := r.PathValue("id") idNum, err := strconv.ParseInt(id, 10, 64) if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } var signCertificateRequestParams SignCertificateRequestParams if err := json.NewDecoder(r.Body).Decode(&signCertificateRequestParams); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) return } _, err = env.DB.GetCertificateAuthority(db.ByCertificateAuthorityCSRID(idNum)) if rowFound(err) { err = fmt.Errorf("certificate authority %d not found", idNum) - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } if realError(err) { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } caIDInt, err := strconv.ParseInt(signCertificateRequestParams.CertificateAuthorityID, 10, 64) if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } err = env.DB.SignCertificateRequest(db.ByCSRID(idNum), db.ByCertificateAuthorityDenormalizedID(caIDInt), env.ExternalHostname) if err != nil { if errors.Is(err, db.ErrNotFound) { - writeError(w, http.StatusNotFound, "Not Found", err, env.Logger) + writeError(w, http.StatusNotFound, "Not Found", err, env.SystemLogger) return } - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } if env.SendPebbleNotifications { err := SendPebbleNotification(CertificateUpdate, idNum) if err != nil { - env.Logger.Warn("pebble notify failed", zap.Error(err)) + env.SystemLogger.Warn("pebble notify failed", zap.Error(err)) } } successResponse := SuccessResponse{Message: "success"} err = writeResponse(w, successResponse, http.StatusAccepted) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } diff --git a/internal/server/handlers_certificate_requests_test.go b/internal/server/handlers_certificate_requests_test.go index bf75b421..b96dbafa 100644 --- a/internal/server/handlers_certificate_requests_test.go +++ b/internal/server/handlers_certificate_requests_test.go @@ -12,7 +12,7 @@ import ( // The order of the tests is important, as some tests depend on the // state of the server after previous tests. func TestCertificateRequestsEndToEnd(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, logs := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "testadmin@canonical.com", tu.RoleAdmin, "") client := ts.Client() @@ -33,6 +33,7 @@ func TestCertificateRequestsEndToEnd(t *testing.T) { }) t.Run("2. Create certificate request", func(t *testing.T) { + _ = logs.TakeAll() createCertificateRequestRequest := tu.CreateCertificateRequestParams{ CSR: tu.AppleCSR, @@ -47,6 +48,20 @@ func TestCertificateRequestsEndToEnd(t *testing.T) { if createCertResponse.Error != "" { t.Fatalf("expected no error, got %s", createCertResponse.Error) } + entries := logs.TakeAll() + var haveRequested bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "cert_requested" { + haveRequested = true + break + } + } + if !haveRequested { + t.Fatalf("expected CertificateRequested audit entry") + } }) t.Run("3. List certificate requests - 1 Certificate", func(t *testing.T) { @@ -186,6 +201,7 @@ func TestCertificateRequestsEndToEnd(t *testing.T) { }) t.Run("10. Delete certificate request 1", func(t *testing.T) { + _ = logs.TakeAll() statusCode, err := tu.DeleteCertificateRequest(ts.URL, client, adminToken, 1) if err != nil { t.Fatal(err) @@ -193,6 +209,20 @@ func TestCertificateRequestsEndToEnd(t *testing.T) { if statusCode != http.StatusAccepted { t.Fatalf("expected status %d, got %d", http.StatusAccepted, statusCode) } + entries := logs.TakeAll() + var haveDeleted bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "cert_request_deleted" { + haveDeleted = true + break + } + } + if !haveDeleted { + t.Fatalf("expected CertificateRequestDeleted audit entry") + } }) t.Run("11. List certificate requests - 1 Certificate", func(t *testing.T) { @@ -211,7 +241,8 @@ func TestCertificateRequestsEndToEnd(t *testing.T) { } }) - t.Run("12. Delete certificate request 2", func(t *testing.T) { + t.Run("12. Delete certificate request 2 and assert revoke audit", func(t *testing.T) { + _ = logs.TakeAll() statusCode, err := tu.DeleteCertificateRequest(ts.URL, client, adminToken, 2) if err != nil { t.Fatal(err) @@ -219,12 +250,26 @@ func TestCertificateRequestsEndToEnd(t *testing.T) { if statusCode != http.StatusAccepted { t.Fatalf("expected status %d, got %d", http.StatusAccepted, statusCode) } + entries := logs.TakeAll() + var haveCertDeleted bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "cert_request_deleted" { + haveCertDeleted = true + break + } + } + if !haveCertDeleted { + t.Fatalf("expected CertificateRequestDeleted audit entry") + } }) } // TestListCertificateRequestsRequestorRole tests that a certificate requestor can only view their own requests. func TestListCertificateRequestsRequestorRole(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "testadmin@canonical.com", tu.RoleAdmin, "") client := ts.Client() @@ -293,7 +338,7 @@ func TestListCertificateRequestsRequestorRole(t *testing.T) { // The order of the tests is important, as some tests depend on the // state of the server after previous tests. func TestCertificatesEndToEnd(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, logs := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") client := ts.Client() @@ -314,6 +359,7 @@ func TestCertificatesEndToEnd(t *testing.T) { }) t.Run("2. Create Certificate", func(t *testing.T) { + _ = logs.TakeAll() createCertificateRequest := tu.CreateCertificateParams{ Certificate: fmt.Sprintf("%s\n%s", tu.ExampleCSRCertificate, tu.ExampleCSRIssuerCertificate), } @@ -327,6 +373,20 @@ func TestCertificatesEndToEnd(t *testing.T) { if createCertResponse.Error != "" { t.Fatalf("expected no error, got %s", createCertResponse.Error) } + entries := logs.TakeAll() + var haveIssued bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "cert_issued" { + haveIssued = true + break + } + } + if !haveIssued { + t.Fatalf("expected CertificateIssued audit entry") + } }) t.Run("3. Get Certificate", func(t *testing.T) { @@ -346,6 +406,7 @@ func TestCertificatesEndToEnd(t *testing.T) { }) t.Run("4. Reject Certificate", func(t *testing.T) { + _ = logs.TakeAll() statusCode, err := tu.RejectCertificate(ts.URL, client, adminToken, 1) if err != nil { t.Fatal(err) @@ -353,6 +414,20 @@ func TestCertificatesEndToEnd(t *testing.T) { if statusCode != http.StatusAccepted { t.Fatalf("expected status %d, got %d", http.StatusAccepted, statusCode) } + entries := logs.TakeAll() + var haveRejected bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "cert_rejected" { + haveRejected = true + break + } + } + if !haveRejected { + t.Fatalf("expected CertificateRejected audit entry") + } }) t.Run("5. Get Certificate", func(t *testing.T) { @@ -371,7 +446,8 @@ func TestCertificatesEndToEnd(t *testing.T) { } }) - t.Run("6. Delete Certificate", func(t *testing.T) { + t.Run("6. Delete Certificate (revocation)", func(t *testing.T) { + _ = logs.TakeAll() statusCode, err := tu.DeleteCertificateRequest(ts.URL, client, adminToken, 1) if err != nil { t.Fatal(err) @@ -379,6 +455,21 @@ func TestCertificatesEndToEnd(t *testing.T) { if statusCode != http.StatusAccepted { t.Fatalf("expected status %d, got %d", http.StatusAccepted, statusCode) } + entries := logs.TakeAll() + var haveDeletedOrRevoked bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + ev := findStringField(e, "event") + if ev == "cert_request_deleted" || ev == "cert_revoked" { + haveDeletedOrRevoked = true + break + } + } + if !haveDeletedOrRevoked { + t.Fatalf("expected CertificateRequestDeleted or CertificateRevoked audit entry") + } }) t.Run("7. Get Certificate", func(t *testing.T) { @@ -397,7 +488,7 @@ func TestCertificatesEndToEnd(t *testing.T) { } func TestCreateCertificateRequestInvalidInputs(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") client := ts.Client() @@ -452,7 +543,7 @@ MIIBVwIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAuQ== } func TestCreateCertificateInvalidInputs(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") client := ts.Client() diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index 9f3c021e..b51da98a 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -23,7 +23,7 @@ func GetConfigContent(env *HandlerConfig) http.HandlerFunc { } err := writeResponse(w, configContent, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } diff --git a/internal/server/handlers_config_test.go b/internal/server/handlers_config_test.go index deb4e0dc..27e299ae 100644 --- a/internal/server/handlers_config_test.go +++ b/internal/server/handlers_config_test.go @@ -42,7 +42,7 @@ func getConfig(url string, client *http.Client, token string) (int, *GetConfigRe } func TestConfigEndToEnd(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, logs := tu.MustPrepareServer(t) adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") nonAdminToken := tu.MustPrepareAccount(t, ts, "whatever@canonical.com", tu.RoleCertificateManager, adminToken) client := ts.Client() @@ -61,6 +61,7 @@ func TestConfigEndToEnd(t *testing.T) { }) t.Run("2. Get config - admin token", func(t *testing.T) { + _ = logs.TakeAll() statusCode, response, err := getConfig(ts.URL, client, adminToken) if err != nil { t.Fatalf("couldn't get config: %s", err) @@ -84,6 +85,21 @@ func TestConfigEndToEnd(t *testing.T) { if response.Result.EncryptionBackendType == "" { t.Fatalf("expected encryption backend type to be set, got %q", response.Result.EncryptionBackendType) } + + entries := logs.TakeAll() + var haveAPISuccess bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "api_action" && findStringField(e, "action") == "GET config" { + haveAPISuccess = true + break + } + } + if !haveAPISuccess { + t.Fatalf("expected APIAction success audit entry for GET config") + } }) t.Run("3. Get config - non-admin token", func(t *testing.T) { diff --git a/internal/server/handlers_login.go b/internal/server/handlers_login.go index 8f863098..4715aa29 100644 --- a/internal/server/handlers_login.go +++ b/internal/server/handlers_login.go @@ -8,6 +8,7 @@ import ( "github.com/canonical/notary/internal/db" "github.com/canonical/notary/internal/hashing" + "github.com/canonical/notary/internal/logging" "github.com/golang-jwt/jwt/v5" ) @@ -54,23 +55,23 @@ func Login(env *HandlerConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var loginParams LoginParams if err := json.NewDecoder(r.Body).Decode(&loginParams); err != nil { - writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.Logger) + writeError(w, http.StatusBadRequest, "Invalid JSON format", err, env.SystemLogger) return } if loginParams.Email == "" { err := errors.New("email is required") - writeError(w, http.StatusBadRequest, "Email is required", err, env.Logger) + writeError(w, http.StatusBadRequest, "Email is required", err, env.SystemLogger) return } if loginParams.Password == "" { err := errors.New("password is required") - writeError(w, http.StatusBadRequest, "Password is required", err, env.Logger) + writeError(w, http.StatusBadRequest, "Password is required", err, env.SystemLogger) return } userAccount, err := env.DB.GetUser(db.ByEmail(loginParams.Email)) if err != nil { if !errors.Is(err, db.ErrNotFound) && !errors.Is(err, db.ErrInvalidFilter) { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } } @@ -79,12 +80,16 @@ func Login(env *HandlerConfig) http.HandlerFunc { hashedPassword = userAccount.HashedPassword } if err := hashing.CompareHashAndPassword(hashedPassword, loginParams.Password); err != nil { - writeError(w, http.StatusUnauthorized, "The email or password is incorrect", err, env.Logger) + env.AuditLogger.LoginFailed(loginParams.Email, + logging.WithRequest(r), + logging.WithReason("invalid credentials"), + ) + writeError(w, http.StatusUnauthorized, "The email or password is incorrect", err, env.SystemLogger) return } jwt, err := generateJWT(userAccount.ID, userAccount.Email, env.JWTSecret, RoleID(userAccount.RoleID)) if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, env.SystemLogger) return } loginResponse := LoginResponse{ @@ -92,8 +97,11 @@ func Login(env *HandlerConfig) http.HandlerFunc { } err = writeResponse(w, loginResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } + + env.AuditLogger.LoginSuccess(userAccount.Email, logging.WithRequest(r)) + env.AuditLogger.TokenCreated(userAccount.Email, logging.WithRequest(r)) } } diff --git a/internal/server/handlers_login_test.go b/internal/server/handlers_login_test.go index e5a7e370..98b7cd3b 100644 --- a/internal/server/handlers_login_test.go +++ b/internal/server/handlers_login_test.go @@ -9,7 +9,7 @@ import ( ) func TestLoginEndToEnd(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, logs := tu.MustPrepareServer(t) client := ts.Client() t.Run("Create admin user", func(t *testing.T) { @@ -28,6 +28,7 @@ func TestLoginEndToEnd(t *testing.T) { }) t.Run("Login success", func(t *testing.T) { + _ = logs.TakeAll() adminUser := &tu.LoginParams{ Email: "testadmin@canonical.com", Password: "Admin123", @@ -51,6 +52,26 @@ func TestLoginEndToEnd(t *testing.T) { t.Fatalf("expected email %q, got %q", "testadmin@canonical.com", claims["email"]) } } + + entries := logs.TakeAll() + var haveLoginSuccess, haveTokenCreated bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + switch findStringField(e, "event") { + case "authn_login_success:testadmin@canonical.com": + haveLoginSuccess = true + case "authn_token_created:testadmin@canonical.com": + haveTokenCreated = true + } + } + if !haveLoginSuccess { + t.Fatalf("expected LoginSuccess audit entry") + } + if !haveTokenCreated { + t.Fatalf("expected TokenCreated audit entry") + } }) t.Run("Login failure missing email", func(t *testing.T) { @@ -87,7 +108,8 @@ func TestLoginEndToEnd(t *testing.T) { } }) - t.Run("Login failure invalid password", func(t *testing.T) { + t.Run("Login failure invalid password (with audit)", func(t *testing.T) { + _ = logs.TakeAll() invalidUser := &tu.LoginParams{ Email: "testadmin@canonical.com", Password: "a-wrong-password", @@ -99,10 +121,23 @@ func TestLoginEndToEnd(t *testing.T) { if statusCode != http.StatusUnauthorized { t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, statusCode) } - if loginResponse.Error != "The email or password is incorrect" { t.Fatalf("expected error %q, got %q", "The email or password is incorrect", loginResponse.Error) } + entries := logs.TakeAll() + var haveLoginFailed bool + for _, e := range entries { + if e.LoggerName != "audit" { + continue + } + if findStringField(e, "event") == "authn_login_fail:testadmin@canonical.com" && findStringField(e, "reason") == "invalid credentials" { + haveLoginFailed = true + break + } + } + if !haveLoginFailed { + t.Fatalf("expected LoginFailed audit entry with reason 'invalid credentials'") + } }) t.Run("Login failure invalid email", func(t *testing.T) { diff --git a/internal/server/handlers_status.go b/internal/server/handlers_status.go index 32930668..a1dafbb5 100644 --- a/internal/server/handlers_status.go +++ b/internal/server/handlers_status.go @@ -17,7 +17,7 @@ func GetStatus(env *HandlerConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { numUsers, err := env.DB.NumUsers() if err != nil { - writeError(w, http.StatusInternalServerError, "couldn't generate status", err, env.Logger) + writeError(w, http.StatusInternalServerError, "couldn't generate status", err, env.SystemLogger) return } statusResponse := StatusResponse{ @@ -26,7 +26,7 @@ func GetStatus(env *HandlerConfig) http.HandlerFunc { } err = writeResponse(w, statusResponse, http.StatusOK) if err != nil { - writeError(w, http.StatusInternalServerError, "internal error", err, env.Logger) + writeError(w, http.StatusInternalServerError, "internal error", err, env.SystemLogger) return } } diff --git a/internal/server/handlers_status_test.go b/internal/server/handlers_status_test.go index f2308049..c684f1e4 100644 --- a/internal/server/handlers_status_test.go +++ b/internal/server/handlers_status_test.go @@ -9,7 +9,7 @@ import ( ) func TestStatus(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) client := ts.Client() t.Run("status not initialized", func(t *testing.T) { diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 8648406a..f4d355bc 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/canonical/notary/internal/db" + "github.com/canonical/notary/internal/logging" "github.com/canonical/notary/internal/metrics" "github.com/golang-jwt/jwt/v5" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -21,12 +22,12 @@ const ( MAX_KILOBYTES = 100 ) - // The middlewareContext type helps middleware receive and pass along information through the middleware chain. type middlewareContext struct { responseStatusCode int jwtSecret []byte - logger *zap.Logger + systemLogger *zap.Logger + auditLogger *logging.AuditLogger } // createMiddlewareStack chains the given middleware functions to wrap the api. @@ -89,7 +90,7 @@ func loggingMiddleware(ctx *middlewareContext) middleware { // Suppress logging for static files if !strings.HasPrefix(r.URL.Path, "/_next") { - ctx.logger.Info("Request", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Int("status_code", clonedWriter.statusCode), zap.String("status_text", http.StatusText(clonedWriter.statusCode))) + ctx.systemLogger.Info("HTTP request completed", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Int("status_code", clonedWriter.statusCode), zap.String("status_text", http.StatusText(clonedWriter.statusCode))) } ctx.responseStatusCode = clonedWriter.statusCode @@ -97,23 +98,119 @@ func loggingMiddleware(ctx *middlewareContext) middleware { } } -func requirePermission(permission string, jwtSecret []byte, handler http.HandlerFunc, logger *zap.Logger) http.HandlerFunc { +// auditLoggingMiddleware logs API requests to the audit log. +// It logs all failed requests, and also successful read-only (GET/HEAD) requests. +func auditLoggingMiddleware(ctx *middlewareContext) middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + var actor string + claims, err := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), ctx.jwtSecret) + if err == nil { + actor = claims.Email + } + + action := buildActionDescription(r.Method, r.URL.Path) + resourceID := extractResourceID(r.URL.Path) + resourceType := extractResourceType(r.URL.Path) + + opts := []logging.AuditOption{logging.WithRequest(r)} + if actor != "" { + opts = append(opts, logging.WithActor(actor)) + } + if resourceID != "" { + opts = append(opts, logging.WithResourceID(resourceID)) + } + if resourceType != "" { + opts = append(opts, logging.WithResourceType(resourceType)) + } + + if ctx.responseStatusCode >= 400 { + opts = append(opts, logging.WithReason(fmt.Sprintf("HTTP %d: %s", ctx.responseStatusCode, http.StatusText(ctx.responseStatusCode)))) + ctx.auditLogger.APIAction(action+" (failed)", opts...) + } + if ctx.responseStatusCode < 400 && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + ctx.auditLogger.APIAction(action, opts...) + } + }) + } +} + +// buildActionDescription returns a minimal deterministic description from HTTP method and path. +// It returns "METHOD path" where the leading slash is trimmed from the path. +// Examples: +// - GET /certificate_requests -> "GET certificate_requests" +// - POST /users -> "POST users" +// - DELETE /certificate_authorities/5 -> "DELETE certificate_authorities/5" +func buildActionDescription(method, path string) string { + // Minimal, deterministic: "METHOD path-without-leading-slash" + cleanPath := strings.Trim(path, "/") + if cleanPath == "" { + return method + } + return method + " " + cleanPath +} + +// extractResourceID extracts the resource ID from the URL path if present. +// Examples: +// - /users/123 -> "123" +// - /certificate_authorities/5 -> "5" +func extractResourceID(path string) string { + // Expect formats like: /{resource}/{id} or /{resource}/{id}/{subresource} + cleanPath := strings.Trim(path, "/") + parts := strings.Split(cleanPath, "/") + if len(parts) > 1 { + if _, err := strconv.ParseInt(parts[1], 10, 64); err == nil { + return parts[1] + } + } + return "" +} + +// extractResourceType returns the first path segment as the resource type. +// No singularization is performed. +// Examples: +// - /users -> "users" +// - /certificate_requests/123 -> "certificate_requests" +func extractResourceType(path string) string { + cleanPath := strings.Trim(path, "/") + parts := strings.Split(cleanPath, "/") + if len(parts) > 0 && parts[0] != "" { + return parts[0] + } + return "" +} + +func requirePermission(permission string, jwtSecret []byte, handler http.HandlerFunc, systemLogger *zap.Logger, auditLogger *logging.AuditLogger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims, err := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), jwtSecret) if err != nil { - writeError(w, http.StatusUnauthorized, "Unauthorized", err, logger) + auditLogger.UnauthorizedAccess( + logging.WithRequest(r), + logging.WithReason("invalid or missing JWT token"), + ) + writeError(w, http.StatusUnauthorized, "Unauthorized", err, systemLogger) return } roleID := claims.RoleID permissions, ok := PermissionsByRole[roleID] if !ok { - writeError(w, http.StatusForbidden, "forbidden: unknown role", errors.New("role not found"), logger) + auditLogger.UnauthorizedAccess( + logging.WithActor(claims.Email), + logging.WithRequest(r), + logging.WithReason("unknown role"), + ) + writeError(w, http.StatusForbidden, "forbidden: unknown role", errors.New("role not found"), systemLogger) return } if !hasPermission(permissions, permission) { - writeError(w, http.StatusForbidden, "forbidden: insufficient permissions", errors.New("missing permission"), logger) + auditLogger.AccessDenied(claims.Email, r.URL.Path, permission, + logging.WithRequest(r), + logging.WithReason("insufficient permissions"), + ) + writeError(w, http.StatusForbidden, "forbidden: insufficient permissions", errors.New("missing permission"), systemLogger) return } @@ -130,30 +227,36 @@ func hasPermission(userPermissions []string, required string) bool { return false } -func requirePermissionOrFirstUser(permission string, jwtSecret []byte, db *db.Database, handler http.HandlerFunc, logger *zap.Logger) http.HandlerFunc { +func requirePermissionOrFirstUser(permission string, jwtSecret []byte, db *db.Database, handler http.HandlerFunc, systemLogger *zap.Logger, auditLogger *logging.AuditLogger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { numUsers, err := db.NumUsers() if err != nil { - writeError(w, http.StatusInternalServerError, "Internal Error", err, logger) + writeError(w, http.StatusInternalServerError, "Internal Error", err, systemLogger) return } - // If no users exist, allow the request through (initial setup case) if numUsers == 0 { handler(w, r) return } - // Otherwise validate permissions claims, err := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), jwtSecret) if err != nil { - writeError(w, http.StatusUnauthorized, "Unauthorized", err, logger) + auditLogger.UnauthorizedAccess( + logging.WithRequest(r), + logging.WithReason("invalid or missing JWT token"), + ) + writeError(w, http.StatusUnauthorized, "Unauthorized", err, systemLogger) return } permissions, ok := PermissionsByRole[claims.RoleID] if !ok || !hasPermission(permissions, permission) { - writeError(w, http.StatusForbidden, "forbidden: insufficient permissions", errors.New("missing required permission"), logger) + auditLogger.AccessDenied(claims.Email, r.URL.Path, permission, + logging.WithRequest(r), + logging.WithReason("insufficient permissions"), + ) + writeError(w, http.StatusForbidden, "forbidden: insufficient permissions", errors.New("missing required permission"), systemLogger) return } diff --git a/internal/server/router.go b/internal/server/router.go index f101fc14..3d0f0585 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -12,47 +12,49 @@ import ( // then builds and returns it for a server to consume func NewRouter(config *HandlerConfig) http.Handler { apiV1Router := http.NewServeMux() - apiV1Router.HandleFunc("GET /certificate_requests", requirePermission(PermListCertificateRequests, config.JWTSecret, ListCertificateRequests(config), config.Logger)) - apiV1Router.HandleFunc("POST /certificate_requests", requirePermission(PermCreateCertificateRequest, config.JWTSecret, CreateCertificateRequest(config), config.Logger)) - apiV1Router.HandleFunc("GET /certificate_requests/{id}", requirePermission(PermReadCertificateRequest, config.JWTSecret, GetCertificateRequest(config), config.Logger)) - apiV1Router.HandleFunc("DELETE /certificate_requests/{id}", requirePermission(PermDeleteCertificateRequest, config.JWTSecret, DeleteCertificateRequest(config), config.Logger)) - apiV1Router.HandleFunc("POST /certificate_requests/{id}/reject", requirePermission(PermRejectCertificateRequest, config.JWTSecret, RejectCertificateRequest(config), config.Logger)) - apiV1Router.HandleFunc("POST /certificate_requests/{id}/sign", requirePermission(PermSignCertificateRequest, config.JWTSecret, SignCertificateRequest(config), config.Logger)) - apiV1Router.HandleFunc("POST /certificate_requests/{id}/certificate", requirePermission(PermCreateCertificateRequestCertificate, config.JWTSecret, PostCertificateRequestCertificate(config), config.Logger)) - apiV1Router.HandleFunc("DELETE /certificate_requests/{id}/certificate", requirePermission(PermDeleteCertificateRequestCertificate, config.JWTSecret, DeleteCertificate(config), config.Logger)) - apiV1Router.HandleFunc("POST /certificate_requests/{id}/certificate/revoke", requirePermission(PermRevokeCertificateRequestCertificate, config.JWTSecret, RevokeCertificate(config), config.Logger)) + apiV1Router.HandleFunc("GET /certificate_requests", requirePermission(PermListCertificateRequests, config.JWTSecret, ListCertificateRequests(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /certificate_requests", requirePermission(PermCreateCertificateRequest, config.JWTSecret, CreateCertificateRequest(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("GET /certificate_requests/{id}", requirePermission(PermReadCertificateRequest, config.JWTSecret, GetCertificateRequest(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("DELETE /certificate_requests/{id}", requirePermission(PermDeleteCertificateRequest, config.JWTSecret, DeleteCertificateRequest(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /certificate_requests/{id}/reject", requirePermission(PermRejectCertificateRequest, config.JWTSecret, RejectCertificateRequest(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /certificate_requests/{id}/sign", requirePermission(PermSignCertificateRequest, config.JWTSecret, SignCertificateRequest(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /certificate_requests/{id}/certificate", requirePermission(PermCreateCertificateRequestCertificate, config.JWTSecret, PostCertificateRequestCertificate(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("DELETE /certificate_requests/{id}/certificate", requirePermission(PermDeleteCertificateRequestCertificate, config.JWTSecret, DeleteCertificate(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /certificate_requests/{id}/certificate/revoke", requirePermission(PermRevokeCertificateRequestCertificate, config.JWTSecret, RevokeCertificate(config), config.SystemLogger, config.AuditLogger)) - apiV1Router.HandleFunc("GET /certificate_authorities", requirePermission(PermListCertificateAuthorities, config.JWTSecret, ListCertificateAuthorities(config), config.Logger)) - apiV1Router.HandleFunc("POST /certificate_authorities", requirePermission(PermCreateCertificateAuthority, config.JWTSecret, CreateCertificateAuthority(config), config.Logger)) - apiV1Router.HandleFunc("GET /certificate_authorities/{id}", requirePermission(PermReadCertificateAuthority, config.JWTSecret, GetCertificateAuthority(config), config.Logger)) - apiV1Router.HandleFunc("PUT /certificate_authorities/{id}", requirePermission(PermUpdateCertificateAuthority, config.JWTSecret, UpdateCertificateAuthority(config), config.Logger)) - apiV1Router.HandleFunc("DELETE /certificate_authorities/{id}", requirePermission(PermDeleteCertificateAuthority, config.JWTSecret, DeleteCertificateAuthority(config), config.Logger)) - apiV1Router.HandleFunc("POST /certificate_authorities/{id}/sign", requirePermission(PermSignCertificateAuthorityCertificate, config.JWTSecret, SignCertificateAuthority(config), config.Logger)) - apiV1Router.HandleFunc("POST /certificate_authorities/{id}/certificate", requirePermission(PermCreateCertificateAuthorityCertificate, config.JWTSecret, PostCertificateAuthorityCertificate(config), config.Logger)) + apiV1Router.HandleFunc("GET /certificate_authorities", requirePermission(PermListCertificateAuthorities, config.JWTSecret, ListCertificateAuthorities(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /certificate_authorities", requirePermission(PermCreateCertificateAuthority, config.JWTSecret, CreateCertificateAuthority(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("GET /certificate_authorities/{id}", requirePermission(PermReadCertificateAuthority, config.JWTSecret, GetCertificateAuthority(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("PUT /certificate_authorities/{id}", requirePermission(PermUpdateCertificateAuthority, config.JWTSecret, UpdateCertificateAuthority(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("DELETE /certificate_authorities/{id}", requirePermission(PermDeleteCertificateAuthority, config.JWTSecret, DeleteCertificateAuthority(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /certificate_authorities/{id}/sign", requirePermission(PermSignCertificateAuthorityCertificate, config.JWTSecret, SignCertificateAuthority(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /certificate_authorities/{id}/certificate", requirePermission(PermCreateCertificateAuthorityCertificate, config.JWTSecret, PostCertificateAuthorityCertificate(config), config.SystemLogger, config.AuditLogger)) apiV1Router.HandleFunc("GET /certificate_authorities/{id}/crl", GetCertificateAuthorityCRL(config)) - apiV1Router.HandleFunc("POST /certificate_authorities/{id}/revoke", requirePermission(PermRevokeCertificateAuthorityCertificate, config.JWTSecret, RevokeCertificateAuthorityCertificate(config), config.Logger)) + apiV1Router.HandleFunc("POST /certificate_authorities/{id}/revoke", requirePermission(PermRevokeCertificateAuthorityCertificate, config.JWTSecret, RevokeCertificateAuthorityCertificate(config), config.SystemLogger, config.AuditLogger)) - apiV1Router.HandleFunc("GET /accounts", requirePermission(PermListUsers, config.JWTSecret, ListAccounts(config), config.Logger)) - apiV1Router.HandleFunc("POST /accounts", requirePermissionOrFirstUser(PermCreateUser, config.JWTSecret, config.DB, CreateAccount(config), config.Logger)) - apiV1Router.HandleFunc("GET /accounts/{id}", requirePermission(PermReadUser, config.JWTSecret, GetAccount(config), config.Logger)) - apiV1Router.HandleFunc("DELETE /accounts/{id}", requirePermission(PermDeleteUser, config.JWTSecret, DeleteAccount(config), config.Logger)) - apiV1Router.HandleFunc("POST /accounts/{id}/change_password", requirePermission(PermUpdateUserPassword, config.JWTSecret, ChangeAccountPassword(config), config.Logger)) - apiV1Router.HandleFunc("POST /accounts/me/change_password", requirePermission(PermUpdateMyPassword, config.JWTSecret, ChangeMyPassword(config), config.Logger)) + apiV1Router.HandleFunc("GET /accounts", requirePermission(PermListUsers, config.JWTSecret, ListAccounts(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /accounts", requirePermissionOrFirstUser(PermCreateUser, config.JWTSecret, config.DB, CreateAccount(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("GET /accounts/{id}", requirePermission(PermReadUser, config.JWTSecret, GetAccount(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("DELETE /accounts/{id}", requirePermission(PermDeleteUser, config.JWTSecret, DeleteAccount(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /accounts/{id}/change_password", requirePermission(PermUpdateUserPassword, config.JWTSecret, ChangeAccountPassword(config), config.SystemLogger, config.AuditLogger)) + apiV1Router.HandleFunc("POST /accounts/me/change_password", requirePermission(PermUpdateMyPassword, config.JWTSecret, ChangeMyPassword(config), config.SystemLogger, config.AuditLogger)) - apiV1Router.HandleFunc("GET /config", requirePermission(PermReadConfig, config.JWTSecret, GetConfigContent(config), config.Logger)) + apiV1Router.HandleFunc("GET /config", requirePermission(PermReadConfig, config.JWTSecret, GetConfigContent(config), config.SystemLogger, config.AuditLogger)) - m := metrics.NewMetricsSubsystem(config.DB, config.Logger) + m := metrics.NewMetricsSubsystem(config.DB, config.SystemLogger) frontendHandler, err := newFrontendFileServer() if err != nil { - config.Logger.Fatal("Failed to create frontend file server", zap.Error(err)) + config.SystemLogger.Fatal("Failed to create frontend file server", zap.Error(err)) } ctx := middlewareContext{ - jwtSecret: config.JWTSecret, - logger: config.Logger, + jwtSecret: config.JWTSecret, + systemLogger: config.SystemLogger, + auditLogger: config.AuditLogger, } apiMiddlewareStack := createMiddlewareStack( - limitRequestSize(MAX_KILOBYTES, config.Logger), + limitRequestSize(MAX_KILOBYTES, config.SystemLogger), metricsMiddleware(m), + auditLoggingMiddleware(&ctx), loggingMiddleware(&ctx), ) metricsMiddlewareStack := createMiddlewareStack( diff --git a/internal/server/server.go b/internal/server/server.go index 17fb7b03..3f36a8c2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,13 +9,15 @@ import ( "github.com/canonical/notary/internal/config" "github.com/canonical/notary/internal/db" + "github.com/canonical/notary/internal/logging" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) type HandlerConfig struct { DB *db.Database - Logger *zap.Logger + SystemLogger *zap.Logger + AuditLogger *logging.AuditLogger ExternalHostname string JWTSecret []byte SendPebbleNotifications bool @@ -28,16 +30,17 @@ func New(opts *ServerOpts) (*Server, error) { if err != nil { return nil, err } - stdErrLog, err := zap.NewStdLogAt(opts.Logger, zapcore.ErrorLevel) + stdErrLog, err := zap.NewStdLogAt(opts.SystemLogger, zapcore.ErrorLevel) if err != nil { return nil, fmt.Errorf("failed to create logger for http server: %w", err) } - + cfg := &HandlerConfig{} cfg.SendPebbleNotifications = opts.EnablePebbleNotifications cfg.JWTSecret = opts.Database.JWTSecret cfg.ExternalHostname = opts.ExternalHostname - cfg.Logger = opts.Logger + cfg.SystemLogger = opts.SystemLogger + cfg.AuditLogger = logging.NewAuditLogger(opts.AuditLogger) cfg.PublicConfig = *opts.PublicConfig cfg.DB = opts.Database diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a9b940aa..a56150b5 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -24,7 +24,8 @@ func TestNewSuccess(t *testing.T) { Database: db, ExternalHostname: "example.com", EnablePebbleNotifications: false, - Logger: l, + SystemLogger: l, + AuditLogger: l, PublicConfig: &tu.PublicConfig, }) if err != nil { @@ -48,7 +49,7 @@ func TestInvalidKeyFailure(t *testing.T) { TLSPrivateKey: []byte{}, ExternalHostname: "example.com", EnablePebbleNotifications: false, - Logger: l, + SystemLogger: l, PublicConfig: &tu.PublicConfig, }) if err == nil { @@ -57,7 +58,7 @@ func TestInvalidKeyFailure(t *testing.T) { } func TestRequestOverload(t *testing.T) { - ts := tu.MustPrepareServer(t) + ts, _ := tu.MustPrepareServer(t) client := ts.Client() adminToken := tu.MustPrepareAccount(t, ts, "admin@canonical.com", tu.RoleAdmin, "") diff --git a/internal/server/types.go b/internal/server/types.go index 694a2bcf..95d303db 100644 --- a/internal/server/types.go +++ b/internal/server/types.go @@ -24,7 +24,8 @@ type ServerOpts struct { // Database object to run SQL queries on Database *db.Database - Logger *zap.Logger + SystemLogger *zap.Logger // For operational/system logs + AuditLogger *zap.Logger // For audit/compliance logs } type Server struct { @@ -33,4 +34,4 @@ type Server struct { type middleware func(http.Handler) http.Handler -type NotificationKey int \ No newline at end of file +type NotificationKey int diff --git a/internal/server/utils.go b/internal/server/utils.go index 43c5ce7c..cdf415a0 100644 --- a/internal/server/utils.go +++ b/internal/server/utils.go @@ -9,6 +9,7 @@ import ( "fmt" "os/exec" ) + const ( CertificateUpdate NotificationKey = 1 ) @@ -53,4 +54,3 @@ func generateSKI(priv *rsa.PrivateKey) []byte { hash := sha1.Sum(spki.SubjectPublicKey.Bytes) return hash[:] } - diff --git a/internal/testutils/db_test_utils.go b/internal/testutils/db_test_utils.go index 0ac44a71..f9940ba6 100644 --- a/internal/testutils/db_test_utils.go +++ b/internal/testutils/db_test_utils.go @@ -16,7 +16,7 @@ import ( func MustPrepareEmptyDB(t *testing.T) *db.Database { t.Helper() - tempDir := t.TempDir() + tempDir := t.TempDir() sqlConnection, err := sql.Open("sqlite3", filepath.Join(tempDir, "db.sqlite3")) if err != nil { t.Fatalf("Couldn't create temporary database: %s", err) diff --git a/internal/testutils/server_test_utils.go b/internal/testutils/server_test_utils.go index b6ab8aa4..68961510 100644 --- a/internal/testutils/server_test_utils.go +++ b/internal/testutils/server_test_utils.go @@ -15,12 +15,20 @@ import ( "time" "github.com/canonical/notary/internal/server" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) -func MustPrepareServer(t *testing.T) *httptest.Server { +// MustPrepareServer starts a test server and returns it along with observed audit logs. +func MustPrepareServer(t *testing.T) (*httptest.Server, *observer.ObservedLogs) { t.Helper() db := MustPrepareEmptyDB(t) + // Attach observed audit logger + core, logs := observer.New(zapcore.InfoLevel) + auditZap := zap.New(core) + srv, err := server.New(&server.ServerOpts{ Port: 8000, TLSCertificate: []byte(TestServerCertificate), @@ -28,7 +36,8 @@ func MustPrepareServer(t *testing.T) *httptest.Server { Database: db, ExternalHostname: "example.com", EnablePebbleNotifications: false, - Logger: logger, + SystemLogger: logger, + AuditLogger: auditZap, PublicConfig: &PublicConfig, }) if err != nil { @@ -38,7 +47,7 @@ func MustPrepareServer(t *testing.T) *httptest.Server { t.Cleanup(func() { testServer.Close() }) - return testServer + return testServer, logs } func MustPrepareAccount(t *testing.T, ts *httptest.Server, email string, roleID RoleID, token string) string { @@ -527,7 +536,11 @@ type UpdateCertificateAuthorityResponse struct { } func UpdateCertificateAuthority(url string, client *http.Client, token string, id int, status UpdateCertificateAuthorityParams) (int, *UpdateCertificateAuthorityResponse, error) { - reqData, err := json.Marshal(status) + enabled := status.Status == "active" + payload := struct { + Enabled bool `json:"enabled"` + }{Enabled: enabled} + reqData, err := json.Marshal(payload) if err != nil { return 0, nil, err }