Skip to content

fix: obfuscate the API key in audit log - #2767

Merged
hvn2k1 merged 2 commits into
mainfrom
audit-log-apikey
Aug 14, 2026
Merged

fix: obfuscate the API key in audit log#2767
hvn2k1 merged 2 commits into
mainfrom
audit-log-apikey

Conversation

@hvn2k1

@hvn2k1 hvn2k1 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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 CreateAPIKey and UpdateAPIKey publish the API key entity as-is. The raw key therefore ends up in audit_log.entity_data, previous_entity_data and the APIKeyCreatedEvent, and is shown in the audit log detail on the console.

Points

  • obfuscateAPIKey moved from pkg/account/api to pkg/account/domain as ObfuscateAPIKey, so the account and auditlog services share one function. Behavior is unchanged (first 4 + .... + last 4).
  • CreateAPIKey still 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.
  • The read side masks entity_type == APIKEY logs in GetAuditLog, ListAuditLogs, ListAdminAuditLogs and ListFeatureHistory. It is needed for the rows saved before this change. The public API gateway endpoints proxy to this service, so they are covered too.
  • When the entity data or the event data cannot be parsed, it is dropped instead of being returned as-is, so a raw key never leaks through the fallback path.
  • Not included: a backfill for the rows already saved. They still hold the raw key at rest, and search_keyword can still match it in the DB.
  • The entity data fields of API key logs come back alphabetically sorted, because the JSON is decoded into a map and re-encoded. The console parses the JSON before rendering, so the diff view is unaffected.

Copilot AI lite review requested due to automatic review settings August 10, 2026 03:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 == APIKEY by obfuscating entity_data, previous_entity_data, and the APIKeyCreatedEvent payload.
  • 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.

Comment thread pkg/account/domain/api_key.go
@hvn2k1
hvn2k1 marked this pull request as ready for review August 10, 2026 03:59
Comment thread pkg/account/api/api_key.go Outdated
Comment on lines +373 to +374
obfuscatedAPIKeyEntity(current),
obfuscatedAPIKeyEntity(prev),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed as 12b4ef6.

Comment thread pkg/auditlog/api/obfuscate_api_key.go Outdated

const apiKeyJSONField = "api_key"

// obfuscateAPIKeys obfuscates the API keys saved before they were obfuscated at the source.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed as 12b4ef6.

…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>
@hvn2k1
hvn2k1 requested a review from t-kikuc August 13, 2026 08:57

@t-kikuc t-kikuc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you!

@hvn2k1
hvn2k1 merged commit 161bf4c into main Aug 14, 2026
11 checks passed
@hvn2k1
hvn2k1 deleted the audit-log-apikey branch August 14, 2026 06:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: obfuscate the API key in audit log

3 participants