Per-user attribution, audit trails, tenant content policy, and gated retention#4
Merged
Conversation
…dmin-op audit Add owner to keys and a user_id/request_id/created_at dimension to the ledger (key.owner authoritative, else request metadata: x-gw-user / OpenAI user / Anthropic metadata.user_id). Per-user usage query + /admin/usage/users. A content-safety event stream (no prompt text) with /admin/audit/events, and an admin-operation audit trail with /admin/audit/ops. Keystore list paginated.
…min query helpers, comment trim)
…gex+secret recognizers SecurityConf gains block/flag/shadow action tiers, named regex recognizers, and API-key/secret detection; it now lives per-tenant (TenantConf.security wins over global). The pre-stage records every rule that fires to the security-event stream and denies only on a block-action hit.
… billing precision Per-tenant retention (none/redacted/full) stores prompts/responses in a request_content table, sealed at rest with XChaCha20-Poly1305 under GW_CONTENT_KEY (full refuses to store raw without a key). An hourly purge task deletes expired content. Billing rows carry an 'estimated' flag (aborted-stream estimates), and /admin/usage/users gains CSV export.
Add a pluggable Moderator trait (AllowModerator default, wired via OnlineHandler::with_moderator, enabled per-tenant by security.moderate with a fail-open/closed posture) and a per-user daily token budget (soft cap keyed by end user, TenantConf.user_daily_token_quota), enforced on REST and realtime. SIEM/OTLP export is the existing access-log + security-event + admin-audit streams.
…azy content capture, shared join)
- moderation & content retention now cover non-chat surfaces (plugins::inbound_text walks message + parts + raw + typed), closing a Responses/embeddings moderation fail-open. - realtime frames run the full policy (regex + blocklist_action) via realtime_frame_scan, not just the blocklist — WebSocket is not a bypass; per-rule hits recorded. - audit source_ip prefers x-real-ip / rightmost XFF (leftmost is client-spoofable). - CSV export neutralizes spreadsheet formula injection. - /admin/keys pagination filters tenant scope in the store, before paging. - reject ':' in tenant names (per-user budget key aliasing).
…ents The realtime/WebSocket surface read ak.owner directly, so a shared (ownerless) key attributed every turn to an empty user: per-user cost tracking lost all realtime usage, the per-user daily token budget never fired (a caller could evade the cap by using /v1/realtime), and security events recorded an empty user. Thread the connect-time x-gw-user hint through the realtime path with owner-first resolution, matching the REST effective_user_id.
… realtime
effective_user_id (REST) treated Some("") as a set owner and returned
empty, while realtime filtered an empty owner and fell back to the hint.
An empty string is never an identity; collapse both onto one AkInfo
method that treats empty owner as absent, so the two surfaces cannot
diverge.
…n, and config-publish audit gaps - Realtime DLP now honors detect_secrets: dlp_redact_realtime_frame masked PII only, so a secret in a WS frame reached the model even with detect_secrets on. It now redacts both PII and secrets like REST. - Retention owns its redaction: a keyless 'full' downgrade (and the 'redacted' level) stored plaintext when the tenant forwarded traffic with DLP off. redact_retained always strips PII+secrets for stored text. - Config-publish audit: a published version is the fleet source of truth even if the local reload fails; audit it after publish with the reload outcome instead of only on the success branch.
…ill and budget correctly A batch dropped the submitter's user: BatchItem carried only messages and the reconstructed GatewayRequest left user_id empty, so a shared-key batch attributed every item's ledger row to "" and the empty user made the per-user budget a no-op — a per-user cap was evadable via /v1/batches. BatchItem now carries the resolved user (body `user` else `x-gw-user`), persisted in batch_items.user_id so a fleet drainer on another instance still attributes and budgets each item. Owner still overrides at billing.
… only when configured source_ip trusted x-real-ip/x-forwarded-for unconditionally, so a client reaching /admin/* directly could forge the audit source IP. It now roots at the real socket peer (served with connect-info) and reads forwarded headers only when the new trust_proxy_headers flag is set — i.e. a trusted proxy fronts the gateway. Default off: the audit records the unspoofable peer.
…dit source-IP trust
…reload; record inbound realtime DLP events - Realtime bypassed the external moderator: REST runs it in the pre-stage but /v1/realtime only ran blocklist/regex/DLP. A moderated tenant (wired via with_moderator) could be bypassed over the WebSocket. Both realtime paths now call the moderator on inbound frames (new moderate_text seam). - Admin audit source IP is now frozen at request entry, before any config reload, so the op that flips trust_proxy_headers is audited under the policy in effect when it arrived — it can't trust its own forged header. - Inbound realtime DLP redactions are recorded as security events (REST parity); per-delta outbound redactions stay unrecorded (too hot) and are now documented as the one exception.
The outbound realtime DLP exemption was broader than documented: every upstream frame was redacted without any security event, while the doc claimed only per-token deltas were exempt. Sum the turn's outbound redactions and record one 'dlp' event at the turn boundary (response.done) — the redaction still applies to every frame, only its audit is aggregated so a store write per streamed token stays off the hot path. Doc reconciled.
A turn dropped by an upstream disconnect never hits its response.done boundary, so its accumulated outbound-redaction count was never audited. Flush it on loop exit, alongside the reserve refund — the redaction already applied per frame; this only completes the best-effort audit.
…ath allocs, comment trim)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
user_id,request_id, andcreated_at_epoch_secs. The effective user is the key'sowner(authoritative — one key, one user) else request metadata (x-gw-userheader, OpenAIuser, Anthropicmetadata.user_id), resolved once onAkInfoso REST, realtime (connect-time hint), and batch (per-itemuserpersisted with the item for fleet drainers) cannot diverge.GET /admin/usage/users?user=&since=&until=rolls up per-(user, model) cost over a billing period (format=csvexport with formula-injection-neutralized fields);tenants[].user_daily_token_quotaadds a soft per-user daily budget, checked at admission and consumed at billing on every surface.GET /admin/audit/events(tenant-scoped). Tenants can override the global policy whole:blocklist_action(block|flag|shadow), namedregex_ruleswith per-rule actions,detect_secrets(credential masking), and an external moderation seam (moderate,moderation_fail_open). The realtime WebSocket runs the identical inbound chain, so it is not a bypass; outbound realtime DLP audit is summed per turn and flushed even on aborted turns.GET /admin/audit/ops. The source IP roots at the TCP peer; proxy headers are trusted only withtrust_proxy_headers: true, and the IP is resolved at request entry so an op that flips the trust setting is audited under the pre-mutation policy.tenants[].retention=none | redacted | full, sealed at rest (XChaCha20-Poly1305 underGW_CONTENT_KEY). Retention owns its redaction —redactedrows and keylessfulldowngrades never persist raw PII/secrets even with inline DLP off. Expired rows are purged hourly; read back viaGET /admin/audit/content/{request_id}(tenant-scoped, unsealed when the key is present).Review
Converged through two full four-lens review rounds (reuse / simplification / efficiency / altitude) plus a focused delta pass, ending at zero findings. Shared seams extracted along the way:
rt_inbound_policy(one inbound content chain for both WS paths),user_hint, theModerationresolution (one fail-open posture),AuditSourceIpextractor,SecurityEvent::record,ScanCounts,row_mapper!.Testing
cargo fmt --checkcleancargo clippy --all-targets -- -D warningscleancargo test: 307 passed, 0 failedGW_TEST_PG_URLis set.