Repository navigation
feat(audit): emit channel invoker identity in auth_verify - #525
Conversation
When a request arrives through a channel adapter (Slack/Telegram/
WhatsApp/Teams), forge authenticates the transport with its per-process
loopback token — so the auth audit recorded "forge-internal", not the
human who actually invoked the call. Record that human.
The auth_verify OnAuth callback fires on the transport credential BEFORE
the on-behalf-of graft (which intentionally records the token truthfully),
so the invoker is added as dedicated fields sourced from the same trusted
X-Forge-Channel* headers the graft uses, gated on the runtime-internal
marker (IsRuntimeInternal) so an external caller cannot spoof them:
- email — stamped whenever the verified identity carries one
- channel — originating adapter (slack / telegram / whatsapp / msteams)
- channel_user — platform-native id (Slack Uxxx / Telegram numeric id /
WhatsApp msisdn / Teams AAD id)
- channel_email — resolved profile email (Slack via users.info; Teams later)
Slack, Telegram, and WhatsApp work immediately — their sender identity
already reaches the runtime via ChannelEvent + the X-Forge-Channel*
headers. Teams currently carries only the AAD object id; resolving its
email needs a Graph /users/{id} lookup (follow-up).
Schema-compatible fields[] additions (no AuditSchemaVersion bump).
Tests: TestAuthAudit_EmitsChannelInvoker (slack/telegram/whatsapp),
TestAuthAudit_ChannelHeadersIgnoredForNonLoopback (anti-spoof), and the
success/no-secret contract tests updated to the new email-is-recorded
policy. Docs: audit-logging.md (corrected the stale graft claim + invoker
field table) and the forge.md knowledge skill (synced).
initializ-mk
left a comment
There was a problem hiding this comment.
Self-review — verified against source ✅
Reviewed with the spoofing/PII angle in focus. Correct and well-tested; posting as a COMMENT (own PR) with one governance note.
Security — correct:
- Channel invoker fields (
channel/channel_user/channel_email) are recorded ONLY underid.IsRuntimeInternal()— the same trust marker asapplyChannelOnBehalfOf— so an external caller'sX-Forge-Channel-*headers are ignored.TestAuthAudit_ChannelHeadersIgnoredForNonLoopbackproves it (an OIDC identity'sattacker@evil.exampleheader is dropped). id.Emailis unconditional but sourced from the VERIFIED identity, not a header — not spoofable.- No log injection (values are JSON-encoded into the NDJSON audit; sources trusted regardless). Transport credential still recorded truthfully; invoker is additive; schema-compatible.
Tests genuine (emit: slack/telegram/whatsapp; spoof-rejection; the former no-PII test correctly reframed to no-claims/secrets). Docs correction (graft runs after the audit notify) is accurate.
One governance note inline.
Nit (non-blocking): commit authored as MK vs initializ-mk (email/no-attribution fine).
| fields["channel"] = ch | ||
| } | ||
| if cu := strings.TrimSpace(req.Header.Get("X-Forge-Channel-User")); cu != "" { | ||
| fields["channel_user"] = cu |
There was a problem hiding this comment.
Note — LOW (governance, not a bug): the auth audit is now PII-bearing. This deliberately reverses the prior explicit "NO PII — never email" contract (fine — confirmed decision). Worth an explicit callout that beyond email, channel_user records raw phone numbers for WhatsApp (msisdn) and Slack/Teams profile emails — so the security-audit NDJSON now contains emails AND phone numbers. Please make sure audit retention, access controls, and any privacy/DPA documentation reflect that (the phone-number case is more sensitive than email and easy to overlook under "record the invoker"). No code change needed — just ensuring the PII posture is a conscious, documented part of the audit log's handling.
Problem
When a request comes in from a messaging channel (Slack/Telegram/WhatsApp/Teams), forge authenticates the transport with its per-process loopback token. So the auth audit recorded
provider:internal/user_id:forge-internal— the bot, not the human who actually invoked the call. We need to know who that was, in the audit for auth.What this does
The on-behalf-of identity already flows end-to-end (
ChannelEvent.UserEmail→X-Forge-Channel-*headers →applyChannelOnBehalfOf). The gap was purely the audit emission —makeAuthAuditCallbackcarried an explicit "NO PII — never email" contract, and theOnAuthnotify fires on the transport credential before the human graft (by design, so the token is recorded truthfully).This records the invoker as dedicated fields on
auth_verify, sourced from the same trustedX-Forge-Channel-*headers, gated on the sameIsRuntimeInternal()trust marker the graft uses (external callers can't spoof them):emailchannelslacktelegramwhatsappmsteamschannel_userU08…channel_emailusers.infoSlack, Telegram, and WhatsApp work immediately. Teams currently carries only the AAD object id — resolving its email needs a Graph
/users/{id}lookup, done as a fast follow.The transport credential is still recorded truthfully (
provider:internal/user_id:forge-internal); the invoker is additive. Schema-compatiblefields[]additions — noAuditSchemaVersionbump.Design decisions (confirmed)
emailwhenever the verified identity carries one (not only channel-originated).auth_verifyevent (not a new event type).Tests
TestAuthAudit_EmitsChannelInvoker— slack (email) / telegram (numeric id) / whatsapp (msisdn).TestAuthAudit_ChannelHeadersIgnoredForNonLoopback— a non-internal (OIDC) identity cannot inject a spoofed invoker.go build, runtime + surface tests,golangci-lint(0 issues),make sync-knowledgeall green.Docs
docs/security/audit-logging.md— corrected a previously-inaccurate claim that the graft overwritesfields.user_id/email(it happens after the audit notify), added the invoker field table + example + a jq query..claude/skills/forge.mdknowledge skill (synced copy included).Follow-up
msteams) email resolution via a Graph/users/{id}UPN/mail lookup, analogous to Slack'sresolveUserEmail.