Skip to content

Per-user attribution, audit trails, tenant content policy, and gated retention#4

Merged
CMGS merged 21 commits into
mainfrom
feat/audit-billing
Jul 15, 2026
Merged

Per-user attribution, audit trails, tenant content policy, and gated retention#4
CMGS merged 21 commits into
mainfrom
feat/audit-billing

Conversation

@CMGS

@CMGS CMGS commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Per-user attribution, end to end: every ledger row now carries user_id, request_id, and created_at_epoch_secs. The effective user is the key's owner (authoritative — one key, one user) else request metadata (x-gw-user header, OpenAI user, Anthropic metadata.user_id), resolved once on AkInfo so REST, realtime (connect-time hint), and batch (per-item user persisted with the item for fleet drainers) cannot diverge.
  • Per-user billing: GET /admin/usage/users?user=&since=&until= rolls up per-(user, model) cost over a billing period (format=csv export with formula-injection-neutralized fields); tenants[].user_daily_token_quota adds a soft per-user daily budget, checked at admission and consumed at billing on every surface.
  • Security-event stream: blocklist / regex-recognizer / DLP / moderation hits are recorded without prompt text, queryable at GET /admin/audit/events (tenant-scoped). Tenants can override the global policy whole: blocklist_action (block|flag|shadow), named regex_rules with 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.
  • Admin-operation audit: key CRUD, config publish, and reload recorded with actor, scope, target, and source IP at GET /admin/audit/ops. The source IP roots at the TCP peer; proxy headers are trusted only with trust_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.
  • Gated content retention: tenants[].retention = none | redacted | full, sealed at rest (XChaCha20-Poly1305 under GW_CONTENT_KEY). Retention owns its redaction — redacted rows and keyless full downgrades never persist raw PII/secrets even with inline DLP off. Expired rows are purged hourly; read back via GET /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, the Moderation resolution (one fail-open posture), AuditSourceIp extractor, SecurityEvent::record, ScanCounts, row_mapper!.

Testing

  • cargo fmt --check clean
  • cargo clippy --all-targets -- -D warnings clean
  • cargo test: 307 passed, 0 failed
  • Postgres-gated tests (distributed batch attribution round-trip) run where GW_TEST_PG_URL is set.

CMGS added 21 commits July 15, 2026 12:28
…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.
…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.
- 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.
…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.
@CMGS CMGS merged commit d1fcfb0 into main Jul 15, 2026
7 checks passed
@CMGS CMGS deleted the feat/audit-billing branch July 15, 2026 15:01
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.

1 participant