fix: obfuscate the API key in audit log - #2767
Conversation
There was a problem hiding this comment.
Pull request overview
This PR prevents raw API keys from being persisted to or returned by the audit log system by obfuscating API key values both on the write side (domain events emitted by account service) and on the read side (audit log API responses), including handling of legacy rows that may already contain raw keys.
Changes:
- Introduces audit-log read-side masking for
entity_type == APIKEYby obfuscatingentity_data,previous_entity_data, and theAPIKeyCreatedEventpayload. - Moves API key obfuscation logic into
pkg/account/domain(ObfuscateAPIKey) and updates account APIs to reuse it. - Adds unit tests to ensure domain events and audit log reads never expose raw API keys (including for previously stored audit log JSON).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pkg/auditlog/api/obfuscate_api_key.go | Adds read-side obfuscation for API key audit logs (entity snapshots + created event payload). |
| pkg/auditlog/api/obfuscate_api_key_test.go | Tests masking behavior for entity data and event data, including invalid JSON handling. |
| pkg/auditlog/api/api.go | Applies the new obfuscation step in Get/List audit log endpoints (including admin/history). |
| pkg/account/domain/api_key.go | Adds shared ObfuscateAPIKey helper in the domain layer. |
| pkg/account/domain/api_key_test.go | Tests ObfuscateAPIKey behavior across edge cases and typical key lengths. |
| pkg/account/api/api_key.go | Ensures emitted domain events use obfuscated API key snapshots while CreateAPIKey response still returns raw key. |
| pkg/account/api/api_key_test.go | Adds tests verifying Create/Update API key domain events do not contain raw keys. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| obfuscatedAPIKeyEntity(current), | ||
| obfuscatedAPIKeyEntity(prev), |
There was a problem hiding this comment.
This turns the API key cache invalidation into a no-op.
ExtractAPIKeySecrets pulls the raw secret out of entity_data, and it is the only source for both cacheRefresher.refreshAPIKey and cacheInvalidator, which evict by the raw secret because that is the cache key. They now get 0123....cdef: GetEnvironmentAPIKey returns ErrAPIKeyNotFound, refreshAPIKey reads that as "rotated away" and evicts a nonexistent key, so nothing is logged and the real entries survive.
Effect: disabling a key takes up to a minute instead of applying immediately (L1 TTL 1m, batch apiKeyCacher every minute). Bounded, but silent and untested.
Could the invalidation resolve the secret from event.EntityId instead?
There was a problem hiding this comment.
Confirmed, thanks — this was a real break, not just a slow path. cacheInvalidator has no storage at all (caches + dispatcher only), so resolving the secret from EntityId works in the refresher but not in the invalidator.
Fixed by taking the obfuscation off the domain event entirely and doing it in NewAuditLog instead: the event keeps the raw key, so ExtractAPIKeySecrets and both consumers behave exactly as before, and the key is obfuscated before the audit log row is inserted.
TestCreateAPIKeyDomainEvent / TestUpdateAPIKeyDomainEvent now assert ExtractAPIKeySecrets(event) returns the raw secret, so this cannot regress silently.
|
|
||
| const apiKeyJSONField = "api_key" | ||
|
|
||
| // obfuscateAPIKeys obfuscates the API keys saved before they were obfuscated at the source. |
There was a problem hiding this comment.
Asking rather than requesting: would mask-at-creation plus a backfill be better than masking on read?
The write-side change already keeps the key out of new rows, so this file (91 lines + 188 of tests) exists only for the older ones -- a backfill would cover those instead.
It also looks hard to avoid: AuditLogStorage has no delete method and there is no retention job, and UpdateAPIKeyRequest cannot regenerate a key, so anything left in audit_log stays a live credential.
Masking on read also has to cover every read path as it is added -- two of the four call sites here are already unreachable (ListFeatureHistory pins EntityType to Event_FEATURE).
What do you think?
There was a problem hiding this comment.
Agreed on mask-at-creation, and I moved it there: NewAuditLog now obfuscates every audit log row, and it is the single funnel (the persister plus the two direct writers in account.go), so a new writer cannot skip it. The read path is left only for the rows written before this change, and both paths now share one implementation in pkg/auditlog/domain.
Also dropped the ListFeatureHistory call site you flagged. I kept the ListAdminAuditLogs one: api key events are never admin events today, so it is unreachable as well, but admin_audit_log takes generic audit logs and it is a single line.
On the backfill: we decided not to do it in this PR, so the older rows keep their raw key in the DB and the read path is what protects them. Happy to file a follow-up if you would rather have them scrubbed.
…n event Address review comments on PR #2767: - Keep the raw key in the domain event: ExtractAPIKeySecrets resolves the api key cache key from its entity data, so obfuscating it turned the cache refresh and the cache invalidation into no-ops - Obfuscate in NewAuditLog instead, the single funnel for every audit log row - Share one implementation between the audit log write and read paths - Drop the unreachable ListFeatureHistory call site, which pins the entity type to Event_FEATURE Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes #2766
What this PR does
Obfuscates the API key both when the audit log is created and when it is read, so the raw key is no longer stored in domain events / audit logs, nor returned by the audit log APIs.
Background / Why this PR is needed
The Get/List API key endpoints already obfuscate the key, but
CreateAPIKeyandUpdateAPIKeypublish the API key entity as-is. The raw key therefore ends up inaudit_log.entity_data,previous_entity_dataand theAPIKeyCreatedEvent, and is shown in the audit log detail on the console.Points
obfuscateAPIKeymoved frompkg/account/apitopkg/account/domainasObfuscateAPIKey, so the account and auditlog services share one function. Behavior is unchanged (first 4 +....+ last 4).CreateAPIKeystill returns the raw key in its response, which is the only time its owner can read it. The domain event is built from an obfuscated copy, so the response is untouched.entity_type == APIKEYlogs inGetAuditLog,ListAuditLogs,ListAdminAuditLogsandListFeatureHistory. It is needed for the rows saved before this change. The public API gateway endpoints proxy to this service, so they are covered too.search_keywordcan still match it in the DB.