Skip to content

feat(audit): emit channel invoker identity in auth_verify - #525

Merged
initializ-mk merged 1 commit into
mainfrom
feat/channel-invoker-audit
Sep 24, 2026
Merged

initializ-mk merged 1 commit into
mainfrom
feat/channel-invoker-audit

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

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 — makeAuthAuditCallback carried an explicit "NO PII — never email" contract, and the OnAuth notify 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 trusted X-Forge-Channel-* headers, gated on the same IsRuntimeInternal() trust marker the graft uses (external callers can't spoof them):

Field Meaning Slack Telegram WhatsApp Teams
email Verified identity's email, when present (also covers OIDC) ✓ — — —
channel Originating adapter slack telegram whatsapp msteams
channel_user Platform-native user id U08… numeric id phone (msisdn) AAD id
channel_email Resolved profile email ✓ users.info — — (follow-up)

Slack, 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-compatible fields[] additions — no AuditSchemaVersion bump.

Design decisions (confirmed)

  • Scope: emit email whenever the verified identity carries one (not only channel-originated).
  • Target: enrich the existing auth_verify event (not a new event type).
  • Coverage: Slack + Telegram + WhatsApp now; Teams email as a follow-up.

Tests

  • TestAuthAudit_EmitsChannelInvoker — slack (email) / telegram (numeric id) / whatsapp (msisdn).
  • TestAuthAudit_ChannelHeadersIgnoredForNonLoopback — a non-internal (OIDC) identity cannot inject a spoofed invoker.
  • Updated the success + former no-PII tests to the new "email is recorded; claims/secrets/token-bytes never are" policy.
  • gofmt, go build, runtime + surface tests, golangci-lint (0 issues), make sync-knowledge all green.

Docs

  • docs/security/audit-logging.md — corrected a previously-inaccurate claim that the graft overwrites fields.user_id/email (it happens after the audit notify), added the invoker field table + example + a jq query.
  • .claude/skills/forge.md knowledge skill (synced copy included).

Follow-up

  • Teams (msteams) email resolution via a Graph /users/{id} UPN/mail lookup, analogous to Slack's resolveUserEmail.

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 initializ-mk left a comment

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.

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 under id.IsRuntimeInternal() — the same trust marker as applyChannelOnBehalfOf — so an external caller's X-Forge-Channel-* headers are ignored. TestAuthAudit_ChannelHeadersIgnoredForNonLoopback proves it (an OIDC identity's attacker@evil.example header is dropped).
  • id.Email is 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

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.

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.

@initializ-mk
initializ-mk merged commit c051a27 into main Sep 24, 2026
15 checks passed
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