Documentation
¶
Overview ¶
Package audit provides append-only audit logging for security-relevant events. The audit log is append-only at the database level (the vault_app role has INSERT and SELECT only on the audit schema, no UPDATE or DELETE). Sensitive metadata keys (passwords, tokens, secrets) are automatically scrubbed before storage. Batching is supported for high-throughput deployments.
Index ¶
- Constants
- func AlertRule(eventType string) (alert.Rule, bool)
- func AlertedEventTypes() []string
- func Severity(eventType string) int
- type Logger
- func (l *Logger) AfterCloseTotal() int64
- func (l *Logger) Close(ctx context.Context) error
- func (l *Logger) DroppedTotal() int64
- func (l *Logger) Flush(ctx context.Context) error
- func (l *Logger) Log(ctx context.Context, eventType string, ...) error
- func (l *Logger) QuarantinedTotal() int64
- func (l *Logger) SetDetector(d *alert.Detector)
- type Retention
Constants ¶
const ( // LoginSuccess records a successful user authentication. LoginSuccess = "login_success" // LoginFailure records a failed login attempt (wrong password, locked account, etc.). LoginFailure = "login_failure" // LoginNewCountry records a successful login from a country this user has not // been seen logging in from before (and only when they already had at least // one recorded country — a first-ever login seeds silently). Metadata carries // the ISO alpha-2 country code only, never the IP: the notice is derived from // coarse IP-registration data locally and reduced to country granularity // before anything is stored (docs/PRIVACY.md P4, data minimisation). LoginNewCountry = "login_new_country" // Registration records a new user account creation. Registration = "registration" // TokenRefresh records a refresh token exchange for a new access token. TokenRefresh = "token_refresh" // TokenRevoke records an explicit refresh token revocation (logout). TokenRevoke = "token_revoke" // TokenMinted records a token signed for a caller-asserted subject via // POST /mint. vault42 never authenticated that subject. The signature is // indistinguishable from any other issued token, so this event is the only // attribution of who asked. It is critical: under a non-zero flush interval // the buffer can drop it, which is worse than dropping a password_change. TokenMinted = "token_minted" // PasswordChange records a user-initiated password change. PasswordChange = "password_change" // PasswordReset records a password reset via email token. PasswordReset = "password_reset" // AccountErased records a GDPR account erasure (self-service or admin). The // real email is masked in the audit metadata; it survives only in the // encrypted account-recovery escrow log. AccountErased = "account_erased" // ConsentGranted records an opt-in to a consent-based processing purpose. // Art. 7(1) puts the burden of demonstrating consent on the controller, so // the grant is logged with its source; the record itself lives on the // encrypted identity profile. ConsentGranted = "consent_granted" // ConsentWithdrawn records a withdrawal of consent (Art. 7(3)). Withdrawal // must be as easy as granting, so this is also emitted by the unauthenticated // one-click unsubscribe path. ConsentWithdrawn = "consent_withdrawn" // TwoFASetup records TOTP or WebAuthn credential enrollment. TwoFASetup = "2fa_setup" // TwoFAVerify records a two-factor authentication verification attempt. TwoFAVerify = "2fa_verify" // DeviceTrust records a new device being trusted after login. DeviceTrust = "device_trust" // SessionRevoke records an explicit session revocation by user or admin. SessionRevoke = "session_revoke" // ClientAuth records a client credentials grant authentication. ClientAuth = "client_auth" // KMSUnwrap records a KEK envelope-unwrap request via POST /kms/unwrap. // Metadata carries the KEK kid and outcome only — never key material. KMSUnwrap = "kms_unwrap" // RateLimit records a rate limit trigger event. RateLimit = "rate_limit" // FingerprintAnomaly records a fingerprint mismatch on token refresh. FingerprintAnomaly = "fingerprint_anomaly" // DPoPBindingMismatch records a refused rotation of a DPoP-bound refresh // family: the caller presented the refresh cookie but did not prove // possession of the key the family was bound to (RFC 9449 §5). Metadata // carries the family's expected thumbprint and whether any proof arrived, // never the thumbprint the caller chose. It is the signal that a refresh // cookie is in use by something other than the browser it was issued to, // and it is deliberately NOT in isCriticalEvent: the caller decides how // many of these to generate, and a synchronous write per attempt would be // a lever on the audit path. FingerprintAnomaly, the control it sits // beside, is buffered for the same reason. DPoPBindingMismatch = "dpop_binding_mismatch" // OAuth2Authorize records an OAuth2 authorization redirect initiation. OAuth2Authorize = "oauth2_authorize" // OAuth2Callback records an OAuth2 callback processing result. OAuth2Callback = "oauth2_callback" // AdminAction records an administrative CLI action. AdminAction = "admin_action" // DataExport records a user exporting their personal data (GDPR Articles 15/20). DataExport = "data_export" // AuthenticatorCloned records a WebAuthn sign-counter regression: the // credential's private key answered from two places, which is the one signal // that says a hardware authenticator has been copied rather than a session // stolen. It is emitted by the containment path, which revokes every refresh // family for the user and refuses the assertion. // // It is its own class rather than a token_revoke carrying a reason, which is // what it used to be. A revoke on logout and a revoke because a key is in two // places are the same action and nothing like the same event, and once the // severity is a property of the class the two cannot share one: the logout is // routine and this is the most severe thing WebAuthn can report. AuthenticatorCloned = "authenticator_cloned" // HoneypotTrigger records a trap credential being used in honeypot mode. HoneypotTrigger = "honeypot_trigger" // HoneypotAlert records a webhook dispatch in honeypot mode. HoneypotAlert = "honeypot_alert" // AdminLogin records a successful admin gateway login. AdminLogin = "admin_login" // AdminLoginFailure records a failed admin gateway login attempt. AdminLoginFailure = "admin_login_failure" // AdminLogout records an admin gateway session logout. AdminLogout = "admin_logout" // AdminSessionRevoke records an admin revoking sessions. AdminSessionRevoke = "admin_session_revoke" // AdminUserLock records an admin locking a user account. AdminUserLock = "admin_user_lock" // AdminUserUnlock records an admin unlocking a user account. AdminUserUnlock = "admin_user_unlock" // AdminUserDelete records an admin deleting a user account. AdminUserDelete = "admin_user_delete" // AdminUserResetRequired records an admin imposing a forced password reset // on a user account: the stored password stops signing that account in and // the account holder is mailed a reset link on the next attempt. AdminUserResetRequired = "admin_user_reset_required" // AdminUserResetCleared records an admin lifting a forced password reset, // returning the account to the ordinary password gate. It shares the // admin_user_reset_ prefix with the event above so one filter reads the // whole lifecycle of the flag. AdminUserResetCleared = "admin_user_reset_cleared" // AdminKeyRotate records an admin rotating a signing key. AdminKeyRotate = "admin_key_rotate" // AdminKeyRevoke records an admin revoking a signing key. AdminKeyRevoke = "admin_key_revoke" // AdminClientCreate records an admin creating a service client. AdminClientCreate = "admin_client_create" // AdminClientRevoke records an admin revoking a service client. AdminClientRevoke = "admin_client_revoke" // AdminClientRotate records an admin rotating a client secret. AdminClientRotate = "admin_client_rotate" // AdminConfigChange records an admin changing a config value. AdminConfigChange = "admin_config_change" // AdminAccountCreate records an admin creating a new admin account. AdminAccountCreate = "admin_account_create" // AdminAccountRevoke records an admin revoking another admin account. AdminAccountRevoke = "admin_account_revoke" // AdminLockout records an admin account being locked due to too many failed logins. AdminLockout = "admin_lockout" // AdminAuthzDenied records an admin-plane RBAC permission denial: an // authenticated admin was refused a route because their role lacks the // required permission. The decision is enforced regardless of this record; // the record is the trail a privilege-boundary probe leaves behind. AdminAuthzDenied = "admin_authz_denied" // AdminSessionRejected records an admin-plane session-token rejection: a // request was refused before authentication because its bearer token or // session failed a validity check (missing or malformed Authorization // header, an unknown, revoked or expired session, or a session whose admin // no longer exists). The reason is carried in the metadata. The decision is // enforced regardless of this record; the record is the trail a session // replay or bogus-token probe leaves behind. AdminSessionRejected = "admin_session_rejected" // SvcDocPut records a service document being created or replaced. SvcDocPut = "svcdoc_put" // SvcDocGet records a service document being read. SvcDocGet = "svcdoc_get" // SvcDocDelete records a service document being deleted. SvcDocDelete = "svcdoc_delete" )
Event type constants for audit logging. Each constant represents a security-relevant action that is recorded in the append-only audit log.
const ( // SeverityRoutine is the ordinary operation of the product. The row exists // because the trail has to be complete, not because anything is wrong. SeverityRoutine = 0 // SeverityNotable is security-relevant and expected during normal use: a // control engaged, or an authentication step completed. SeverityNotable = 25 // SeverityElevated is an event touching a credential, a session or personal // data. Individually unremarkable; in a run, the shape of an attack. SeverityElevated = 50 // SeveritySerious is a privilege, key or identity boundary being exercised // or probed. A single one deserves a look. SeveritySerious = 75 // SeverityCritical is an event that is, on its own, evidence that something // is wrong. There is no legitimate traffic in this band. SeverityCritical = 100 )
The scale. Five bands, because an operator setting a threshold has to be able to say what they are asking for in words, and because a scale with more resolution than the judgements behind it invites arithmetic nobody can defend.
The bands are cumulative in the sense a filter needs: every row scoring at least Elevated is at least as interesting as every other row scoring Elevated, whatever class it came from. That property is the entire purpose of the table.
const AdminKillswitchTriggered = "admin:killswitch_triggered"
AdminKillswitchTriggered is the event the admin gateway writes when a non-loopback connection reaches it.
It is declared here, apart from the vocabulary block, because the gateway writes that row straight to the repository rather than through Logger.Log: it holds an AuditRepository and no logger. Putting the string in the vocabulary block would fail the dead-vocabulary gate in tests/spec, which requires every constant there to reach Logger.Log's second argument from a production path. Declaring it here instead gives the number one home without claiming the event is on the logged path -- and it is not, which also means it raises no alert. That is the one severity-critical class alerting does not see, and it is named as remaining work rather than papered over.
const SweepInterval = 6 * time.Hour
SweepInterval is how often the retention sweeper runs. Retention horizons are measured in days, so sweeping more often than daily buys nothing; sweeping exactly daily would pin the purge to whenever the process last restarted.
const SweepMaxBatches = 20
SweepMaxBatches bounds one tick.
CleanupLocked deletes at most one batch per call, so a sweep loops. The loop needs a ceiling for the same reason the postgres cache reaper has one: a tick that keeps going until the table is empty is a tick with no end, and the remainder is not urgent — the next tick picks it up. At the repository's batch size this is 40 000 rows per tick, four times a day.
Variables ¶
This section is empty.
Functions ¶
func AlertRule ¶ added in v1.0.3
AlertRule returns the detection watching an event class, if any. It is exported so tests/compliance can assert the register's claims about which classes are watched and which of them are breach-relevant, without this package exposing a mutable table.
func AlertedEventTypes ¶ added in v1.0.3
func AlertedEventTypes() []string
AlertedEventTypes returns every event class a rule watches.
func Severity ¶ added in v1.0.3
Severity returns the score for an event class.
An unscored class reads as notable rather than as routine, so a new event type is visible to a review filter on the day it is added rather than invisible until someone remembers the table. The tests/spec gate is what stops that default from ever being load-bearing.
Types ¶
type Logger ¶
type Logger struct {
// contains filtered or unexported fields
}
Logger handles audit event logging with optional batching. When flushEvery is greater than zero, entries are buffered in memory and flushed periodically. Sensitive metadata keys are automatically scrubbed before storage.
func NewLogger ¶
func NewLogger(repo repository.AuditRepository, flushEvery time.Duration) *Logger
NewLogger creates an audit logger backed by the given repository. If flushEvery is greater than zero, batch mode is enabled and a background goroutine periodically flushes buffered entries.
func NewLoggerWithBufferSize ¶
func NewLoggerWithBufferSize(repo repository.AuditRepository, flushEvery time.Duration, bufferSize int) *Logger
NewLoggerWithBufferSize creates an audit logger with a configurable buffer cap. If bufferSize is <= 0, the default of 1000 is used.
func (*Logger) AfterCloseTotal ¶ added in v1.0.3
AfterCloseTotal returns how many entries were logged after Close and therefore written synchronously rather than buffered.
Non-zero means something outlived the logger: a background job still running when shutdown reached it. That is a shutdown-ordering fact worth having a number for, because the alternative — the behavior this replaced — was a silent append to a buffer nothing would flush.
func (*Logger) Close ¶
Close flushes remaining entries and stops the batch loop. Safe to call multiple times.
func (*Logger) DroppedTotal ¶
DroppedTotal returns the number of times an event met a full buffer.
It counts occurrences, not losses, and the difference matters to anyone reading it as an alert: a critical event that meets a full buffer increments this counter and is then written synchronously anyway, so the figure is an upper bound on what was actually lost rather than the loss itself. Reading it as "events missing from the audit trail" over-reports by the number of critical events that arrived under buffer pressure.
It also sums two conditions that need different responses, which is why the scrape does not use it: /metrics reports vault_audit_buffer_full_total and vault_audit_events_dropped_total separately. This total stays a per-Logger figure for callers holding one.
func (*Logger) Log ¶
func (l *Logger) Log(ctx context.Context, eventType string, userID, clientID, ip, ua, fpHash, deviceID string, metadata map[string]interface{}) error
Log writes an audit entry. In batch mode, the entry is buffered until the next flush interval. In immediate mode, it is written directly to the repository. Metadata is scrubbed of sensitive keys before storage.
After Close the buffer is dead — the flush loop has exited and nothing will drain it again — so a closed logger writes straight through to the store and reports what happens, rather than appending to a slice no one will read. A caller can still be running at that point: notifyNewCountry writes from the deferwork pool, and however carefully shutdown is ordered the ordering is a property of one caller's defer stack, which is not the thing that should decide whether an audit row survives. The risk score is not a parameter. It was one, and every production call site passed an integer literal, so the parameter offered a choice nobody made and the same event class ended up carrying four different numbers. Deriving it from the class is what turns risk_score >= N into a predicate; severity.go argues why leaving an override in would have left it a curiosity.
func (*Logger) QuarantinedTotal ¶ added in v1.0.3
QuarantinedTotal returns the number of entries the store refused individually while it was demonstrably reachable, and which were therefore discarded instead of retried.
func (*Logger) SetDetector ¶ added in v1.0.3
SetDetector installs the detector this logger raises alerts through.
A setter rather than a constructor argument because NewLogger and NewLoggerWithBufferSize have a hundred callers across the tree and the detector is a startup wiring decision made in one of them. The pointer is held atomically because Log reads it from every request goroutine; a nil detector is inert, which is the state every unit test that builds a logger is in.
type Retention ¶ added in v0.9.0
type Retention struct {
// contains filtered or unexported fields
}
Retention purges audit entries past their retention horizon.
Art. 5(1)(e) allows personal data to be kept only as long as it is needed for the purpose it was collected for. Audit entries carry user IDs, IP addresses, user agents and fingerprint hashes, and were the one store with no expiry: a manual `vault cleanup-audit` existed, but nothing ran it, so in a deployment nobody hand-tended, security logs accumulated indefinitely.
Erasure does not cover this. Art. 17(3)(b)/(e) lets security records outlive an erasure request, so audit entries are deliberately exempt from the account cascade — which is precisely why they need a time-based purge of their own.
func NewRetention ¶ added in v0.9.0
func NewRetention(repo repository.AuditRepository, period time.Duration) *Retention
NewRetention builds a sweeper. A period of zero disables it, which is the default: an operator who has not chosen a horizon should not have one silently chosen for them, and deleting security logs is not a safe default.
func (*Retention) Done ¶ added in v0.9.2
func (r *Retention) Done() <-chan struct{}
Done is closed once the sweep loop has exited, whether it ended via Stop or via its context being canceled. Without it there is no way to know the sweeper has actually stopped: Stop and cancel both only *request* an exit, and a caller that closes the database pool on their return can still race a sweep that is mid-DELETE.
The channel never closes if Start was not called — a sweeper that was never running has nothing to wait for.
func (*Retention) Enabled ¶ added in v0.9.0
Enabled reports whether a retention horizon is configured.
func (*Retention) Start ¶ added in v0.9.0
Start runs the sweeper until Stop is called. It sweeps once immediately: a process that restarts more often than the interval would otherwise never reach a tick and the purge would never happen.
func (*Retention) Stop ¶ added in v0.9.0
func (r *Retention) Stop()
Stop terminates the sweep loop and blocks until it has actually exited.
The wait is the point. The sole caller is `defer auditRetention.Stop()` in main, which returns straight into the deferred close of the database pool — so a Stop that only *asked* the loop to finish could return while a sweep was still inside its DELETE, and the pool would be torn out from under it. Waiting for the loop to exit is what makes "the sweeper does not outlive shutdown" true rather than merely intended.
Safe to call more than once, and safe on a sweeper that was never started: an unstarted loop closes nothing, so there is nothing to wait for.
func (*Retention) Sweep ¶ added in v0.9.0
Sweep deletes every audit entry older than the retention horizon and returns how many rows went.
Serialized across replicas: the underlying cleanup takes an ACCESS EXCLUSIVE lock on the audit table (it disables the append-only trigger to delete), so only one replica may sweep at a time. A replica that does not get the lock returns what it has and tries again next tick — the work is idempotent, so there is nothing to catch up on.
It loops, because one call deletes at most repository.AuditCleanupBatch rows. Holding that exclusive lock over an unbounded DELETE blocked every audit insert for the length of the whole purge, and a failed login is a critical event written synchronously on the request path even when the buffer is full.
A sweep that did work leaves an entry in the log it swept. See recordPurge.