From 94584659be08c7fd760c6f4953700123a1df8a08 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 23 Sep 2026 20:59:21 -0400 Subject: [PATCH] feat(audit): emit channel invoker identity in auth_verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .claude/skills/forge.md | 2 +- docs/security/audit-logging.md | 39 +++++++- forge-cli/internal/surface/knowledge/forge.md | 2 +- forge-cli/runtime/auth_audit_test.go | 98 +++++++++++++++++-- forge-cli/runtime/runner.go | 39 +++++++- 5 files changed, 167 insertions(+), 13 deletions(-) diff --git a/.claude/skills/forge.md b/.claude/skills/forge.md index 303d769b..48575002 100644 --- a/.claude/skills/forge.md +++ b/.claude/skills/forge.md @@ -1186,7 +1186,7 @@ when OTel tracing is enabled (OTel v1 / Phase 4 / #105). Both use | `AuditScheduleComplete` | `schedule_complete` | Cron task finished | | `AuditScheduleSkip` | `schedule_skip` | Cron task skipped (e.g. agent busy) | | `AuditScheduleModify` | `schedule_modify` | Schedule mutated at runtime | -| `EventAuthVerify` | `auth_verify` | Inbound request authenticated (`provider`, `user_id`, `org_id`, `token_kind`) | +| `EventAuthVerify` | `auth_verify` | Inbound request authenticated (`provider`, `user_id`, `org_id`, `token_kind`; `email` when the identity carries one). **Channel invoker:** for a channel-originated request the transport credential is the loopback token (`provider:internal`/`user_id:forge-internal`, recorded truthfully) and the human sender is stamped as `channel`/`channel_user`/`channel_email` from the `X-Forge-Channel*` headers — honored only for the runtime-internal identity (same trust gate as `applyChannelOnBehalfOf`). Slack/Teams resolve `channel_email`; Telegram (numeric id) & WhatsApp (msisdn) carry `channel_user` only | | `EventAuthFail` | `auth_fail` | Inbound request rejected (`reason`, `token_kind`) | | `EventMCPServerStarted` | `mcp_server_started` | MCP server handshake succeeded | | `EventMCPServerFailed` | `mcp_server_failed` | MCP server dial / handshake failed | diff --git a/docs/security/audit-logging.md b/docs/security/audit-logging.md index c1dc3756..7b8119ff 100644 --- a/docs/security/audit-logging.md +++ b/docs/security/audit-logging.md @@ -27,7 +27,7 @@ All runtime security events are emitted as structured NDJSON to stderr with corr | `context_compressed` | [Context compression](../core-concepts/context-compression.md) shrank content before it reached the LLM. Carries `fields.seam` (`tool_output` from the AfterToolExec hook / `request` from the client wrapper), `fields.tool`, `tokens_before` / `tokens_after` / `saved_tokens`, plus running totals `total_saved_tokens` / `total_compressions` / `total_expansions` so any single event shows the cumulative picture. Token figures are tokenizer estimates; billed truth stays in `llm_call.input_tokens`. | | `context_expanded` | The model retrieved offloaded content via the `context_expand` tool. Carries `fields.hash`, `hit` (`false` = expired/evicted), `bytes`, the producing `tool`, `candidates` (top keep-pattern tokens mined from the retrieved content, ≤5 — lets a platform consuming the audit stream aggregate [learning](../core-concepts/context-compression.md#the-learning-loop) fleet-wide, immune to pod restarts), and the same running totals — expansions are the cost side auditors net against savings. | | `context_pattern_suggested` | The [compression learning loop](../core-concepts/context-compression.md#the-learning-loop) surfaced a `keep_patterns` candidate: a domain-state token retrieved via `context_expand` in 3+ distinct expansions that the keep floor does not already protect. Fired once per pattern. Carries `fields.pattern`, `expansions`, `tools` (array). Review via `forge compression suggestions`. | -| `auth_verify` | Inbound request authenticated successfully (with `provider`, `user_id`, `org_id`, `token_kind`). Carries the invocation `correlation_id` (minted at ingress, before auth — see below) and, for orchestrator-dispatched calls, `workflow_execution_id` — so it groups with the task events that follow it in the same request. **Channel-originated tasks (#356):** when the request arrives through a channel adapter (Slack/Telegram/…), the runtime grafts the human sender onto the identity — `fields.user_id` / `fields.email` carry the channel user (from the adapter's `X-Forge-Channel-User` / `-Email` / `-Channel` headers) and the source is marked `channel:`, so a tool call is attributed to the person who asked, not the bot. This graft is bound to a runtime-internal marker and never honored from an external caller. | +| `auth_verify` | Inbound request authenticated successfully (with `provider`, `user_id`, `org_id`, `token_kind`). Carries the invocation `correlation_id` (minted at ingress, before auth — see below) and, for orchestrator-dispatched calls, `workflow_execution_id` — so it groups with the task events that follow it in the same request. **Channel-originated tasks:** when the request arrives through a channel adapter (Slack/Telegram/WhatsApp/…), the transport credential is the runtime loopback token, so `fields.provider` / `fields.user_id` record it truthfully (`internal` / `forge-internal`); the human who sent the message is recorded alongside in `fields.channel` / `fields.channel_user` / `fields.channel_email` (from the adapter's `X-Forge-Channel` / `-User` / `-Email` headers). These invoker fields are honored **only** for the runtime-internal transport identity and are never accepted from an external caller. (Downstream the runtime *also* grafts that human onto the request identity — `Source: channel:` — so the agent run and delegated MCP tools act as the person who asked; see [Authentication](authentication.md).) | | `mcp_auth_required` | A delegated (`auth.type: user`) MCP call parked awaiting the user's consent (#330). Carries `server`, `subject`, `deadline`/`timeout_ms`, and the parked call's `correlation_id` / `task_id` / `seq` so it attributes to the invocation that triggered it (#366). | | `mcp_auth_resolved` | The parked call's consent arrived (or the wait was canceled) and it resumed (#330). Carries `server`, `subject`, `wait_ms`. Emitted **once**, attributed to the parked invocation (#366). | | `mcp_auth_timeout` | No consent within the window; the parked MCP call fails `no_token` (#330). Carries `server`, `subject`, `wait_ms`, `decision`. | @@ -258,7 +258,42 @@ Every inbound request to `/tasks` emits exactly one of `auth_verify` or `auth_fa `user_id` is the canonical identifier the verifier returned (ARN for AWS, JWT `sub` for OIDC/IAP/AAD). `org_id` is the AWS account, Entra tenant GUID, or -OIDC `tid`/`org_id`-mapped claim depending on the provider. +OIDC `tid`/`org_id`-mapped claim depending on the provider. `email` is stamped +whenever the verified identity carries one, so the audit records **who** +authenticated. + +**Channel invoker (end-user on-behalf-of).** When a request arrives through a +channel adapter, the transport credential is the runtime's per-process loopback +token — so `provider":"internal"` and `user_id":"forge-internal"` are recorded +truthfully — and the human who typed the message is asserted by the in-pod +channel router via the `X-Forge-Channel` / `-User` / `-Email` headers. Those are +recorded as dedicated `fields` on `auth_verify` (honored **only** when the +transport identity is the runtime loopback identity, so an external caller +cannot spoof them): + +| Field | Meaning | Slack | Telegram | WhatsApp | Teams | +|-------|---------|-------|----------|----------|-------| +| `channel` | Originating adapter | `slack` | `telegram` | `whatsapp` | `msteams` | +| `channel_user` | Platform-native user id | `U08ABC…` | numeric id | phone (msisdn) | AAD object id | +| `channel_email` | Resolved profile email (when the platform has one) | ✓ (`users.info`) | — | — | ✓ (planned, Graph lookup) | + +```json +{ + "ts":"2026-09-23T10:15:02Z", + "event":"auth_verify", + "fields":{ + "method":"POST","path":"/tasks/send", + "provider":"internal","user_id":"forge-internal","token_kind":"opaque", + "channel":"slack","channel_user":"U08ABC123","channel_email":"bob@example.com" + } +} +``` + +Query all invocations by a given human across channels: + +```bash +jq -r 'select(.event=="auth_verify" and .fields.channel) | "\(.fields.channel)\t\(.fields.channel_email // .fields.channel_user)"' forge.log | sort | uniq -c +``` **Failed authentication:** diff --git a/forge-cli/internal/surface/knowledge/forge.md b/forge-cli/internal/surface/knowledge/forge.md index 303d769b..48575002 100644 --- a/forge-cli/internal/surface/knowledge/forge.md +++ b/forge-cli/internal/surface/knowledge/forge.md @@ -1186,7 +1186,7 @@ when OTel tracing is enabled (OTel v1 / Phase 4 / #105). Both use | `AuditScheduleComplete` | `schedule_complete` | Cron task finished | | `AuditScheduleSkip` | `schedule_skip` | Cron task skipped (e.g. agent busy) | | `AuditScheduleModify` | `schedule_modify` | Schedule mutated at runtime | -| `EventAuthVerify` | `auth_verify` | Inbound request authenticated (`provider`, `user_id`, `org_id`, `token_kind`) | +| `EventAuthVerify` | `auth_verify` | Inbound request authenticated (`provider`, `user_id`, `org_id`, `token_kind`; `email` when the identity carries one). **Channel invoker:** for a channel-originated request the transport credential is the loopback token (`provider:internal`/`user_id:forge-internal`, recorded truthfully) and the human sender is stamped as `channel`/`channel_user`/`channel_email` from the `X-Forge-Channel*` headers — honored only for the runtime-internal identity (same trust gate as `applyChannelOnBehalfOf`). Slack/Teams resolve `channel_email`; Telegram (numeric id) & WhatsApp (msisdn) carry `channel_user` only | | `EventAuthFail` | `auth_fail` | Inbound request rejected (`reason`, `token_kind`) | | `EventMCPServerStarted` | `mcp_server_started` | MCP server handshake succeeded | | `EventMCPServerFailed` | `mcp_server_failed` | MCP server dial / handshake failed | diff --git a/forge-cli/runtime/auth_audit_test.go b/forge-cli/runtime/auth_audit_test.go index 7e72b76a..3fd30298 100644 --- a/forge-cli/runtime/auth_audit_test.go +++ b/forge-cli/runtime/auth_audit_test.go @@ -47,7 +47,7 @@ func TestAuthAudit_EmitsAuthVerifyOnSuccess(t *testing.T) { req := httptest.NewRequest("POST", "/tasks", nil) id := &auth.Identity{ UserID: "alice-123", - Email: "alice@example.com", // must NOT leak into the event + Email: "alice@example.com", // recorded — audit must capture WHO authenticated OrgID: "tenant-1", Groups: []string{"a", "b", "c"}, Source: "oidc", @@ -79,6 +79,87 @@ func TestAuthAudit_EmitsAuthVerifyOnSuccess(t *testing.T) { if ev.Fields["token_kind"] != "jwt" { t.Errorf("token_kind = %v, want jwt", ev.Fields["token_kind"]) } + // The invoker's email is recorded so the audit answers "who authenticated?". + if ev.Fields["email"] != "alice@example.com" { + t.Errorf("email = %v, want alice@example.com", ev.Fields["email"]) + } +} + +// TestAuthAudit_EmitsChannelInvoker verifies that for a channel-originated +// request — where the transport credential is the runtime loopback token and +// the human is asserted via X-Forge-Channel-* headers — auth_verify records +// both the transport credential (provider/user_id) AND the channel invoker. +func TestAuthAudit_EmitsChannelInvoker(t *testing.T) { + cases := []struct { + name string + channel string + user, email string + wantEmailPresent bool + }{ + // Slack/Teams resolve a profile email. + {"slack with email", "slack", "U08ABC", "bob@example.com", true}, + // Telegram carries only a numeric id; WhatsApp only a phone number. + {"telegram id only", "telegram", "987654321", "", false}, + {"whatsapp number only", "whatsapp", "14155550123", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + cb := makeAuthAuditCallback(coreruntime.NewAuditLogger(&buf)) + + req := httptest.NewRequest("POST", "/tasks", nil) + req.Header.Set("X-Forge-Channel", tc.channel) + req.Header.Set("X-Forge-Channel-User", tc.user) + if tc.email != "" { + req.Header.Set("X-Forge-Channel-Email", tc.email) + } + // The transport identity is the runtime loopback token — the same + // trust marker applyChannelOnBehalfOf gates the graft on. + id := auth.MarkRuntimeInternal(auth.Identity{UserID: "forge-internal", Source: "internal"}) + + cb(req, &id, nil, "opaque") + + ev := captureAudit(t, &buf)[0] + if ev.Fields["channel"] != tc.channel { + t.Errorf("channel = %v, want %q", ev.Fields["channel"], tc.channel) + } + if ev.Fields["channel_user"] != tc.user { + t.Errorf("channel_user = %v, want %q", ev.Fields["channel_user"], tc.user) + } + if tc.wantEmailPresent { + if ev.Fields["channel_email"] != tc.email { + t.Errorf("channel_email = %v, want %q", ev.Fields["channel_email"], tc.email) + } + } else if _, ok := ev.Fields["channel_email"]; ok { + t.Errorf("channel_email should be absent when the channel has no email, got %v", ev.Fields["channel_email"]) + } + // Transport credential still recorded truthfully. + if ev.Fields["user_id"] != "forge-internal" || ev.Fields["provider"] != "internal" { + t.Errorf("transport credential not recorded: provider=%v user_id=%v", ev.Fields["provider"], ev.Fields["user_id"]) + } + }) + } +} + +// TestAuthAudit_ChannelHeadersIgnoredForNonLoopback ensures a non-internal +// identity (e.g. a real OIDC caller) cannot inject a spoofed channel invoker +// via X-Forge-Channel-* headers — the fields are gated on IsRuntimeInternal. +func TestAuthAudit_ChannelHeadersIgnoredForNonLoopback(t *testing.T) { + var buf bytes.Buffer + cb := makeAuthAuditCallback(coreruntime.NewAuditLogger(&buf)) + + req := httptest.NewRequest("POST", "/tasks", nil) + req.Header.Set("X-Forge-Channel", "slack") + req.Header.Set("X-Forge-Channel-Email", "attacker@evil.example") + + cb(req, &auth.Identity{UserID: "u", Source: "oidc"}, nil, "jwt") + + ev := captureAudit(t, &buf)[0] + for _, k := range []string{"channel", "channel_user", "channel_email"} { + if _, ok := ev.Fields[k]; ok { + t.Errorf("field %q must be ignored for a non-loopback identity, got %v", k, ev.Fields[k]) + } + } } func TestAuthAudit_EmitsAuthFailOnError(t *testing.T) { @@ -123,16 +204,17 @@ func TestAuthAudit_EmitsAuthFailOnError(t *testing.T) { } } -func TestAuthAudit_NoPIIInPayload(t *testing.T) { - // Strong negative test: even if the Identity carries an email and - // rich Claims, the emitted audit line MUST NOT include them. +func TestAuthAudit_NoClaimsOrSecretsInPayload(t *testing.T) { + // The identity's email IS recorded (audit must capture who authenticated), + // but the raw Claims bag — which can carry arbitrary sensitive payloads + // (SSN, secrets) — MUST NEVER be emitted. var buf bytes.Buffer cb := makeAuthAuditCallback(coreruntime.NewAuditLogger(&buf)) req := httptest.NewRequest("POST", "/tasks", nil) id := &auth.Identity{ UserID: "u", - Email: "secret@hr.example.com", + Email: "user@example.com", Claims: map[string]any{ "ssn": "999-99-9999", "secret": "deadbeef", @@ -143,10 +225,8 @@ func TestAuthAudit_NoPIIInPayload(t *testing.T) { line := buf.String() forbidden := []string{ - "secret@hr.example.com", "999-99-9999", "deadbeef", - "\"email\"", "\"claims\"", } for _, f := range forbidden { @@ -154,6 +234,10 @@ func TestAuthAudit_NoPIIInPayload(t *testing.T) { t.Errorf("audit line leaked %q:\n%s", f, line) } } + // Email is intentionally present now. + if !strings.Contains(line, "user@example.com") { + t.Errorf("expected email to be recorded in the auth_verify event:\n%s", line) + } } func TestAuthAudit_NilLoggerReturnsNilCallback(t *testing.T) { diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go index 1c7fa814..00857835 100644 --- a/forge-cli/runtime/runner.go +++ b/forge-cli/runtime/runner.go @@ -3506,11 +3506,25 @@ func (r *Runner) resolveAuth(auditLogger *coreruntime.AuditLogger) (auth.Middlew // makeAuthAuditCallback returns the OnAuth callback that emits structured // auth_verify / auth_fail audit events. // -// Fields emitted (NO PII — never email, claims, token bytes, or secrets): +// Fields emitted (never claims, token bytes, or secrets): // -// auth_verify: { provider, user_id, org_id, groups_count, token_kind, method, path, remote_addr } +// auth_verify: { provider, user_id, org_id, groups_count, token_kind, method, path, remote_addr, +// email?, channel?, channel_user?, channel_email? } // auth_fail: { reason, token_kind, method, path, remote_addr } // +// End-user (invoker) identity on auth_verify: +// - `email` is stamped whenever the verified Identity carries one (e.g. an +// OIDC user), so the audit records WHO authenticated. +// - For a CHANNEL request the transport credential is the runtime's loopback +// token (provider=internal, user_id=forge-internal) — recorded truthfully — +// and the HUMAN who typed the message is asserted by the in-pod channel +// router via the X-Forge-Channel-* headers. When the verified identity is +// the loopback identity (IsRuntimeInternal — the SAME trust gate as +// applyChannelOnBehalfOf, so external callers cannot spoof it), those +// headers are recorded as `channel` (adapter), `channel_user` (platform id: +// Slack Uxxx / Telegram numeric id / WhatsApp msisdn / Teams AAD id) and +// `channel_email` (Slack/Teams profile email; empty for Telegram/WhatsApp). +// // Reason codes for auth_fail: // // missing_token → no Authorization header @@ -3555,6 +3569,27 @@ func makeAuthAuditCallback(auditLogger *coreruntime.AuditLogger) func(*http.Requ "path": req.URL.Path, "remote_addr": req.RemoteAddr, } + // Record WHO authenticated when the identity carries an email. + if id.Email != "" { + fields["email"] = id.Email + } + // Channel on-behalf-of invoker: the transport credential is the + // runtime loopback token, but the human who sent the message is + // asserted by the in-pod channel router via X-Forge-Channel-*. + // Honored ONLY for the loopback identity (same trust marker as + // applyChannelOnBehalfOf) so the headers can't be spoofed by an + // external caller. + if id.IsRuntimeInternal() { + if ch := strings.TrimSpace(req.Header.Get("X-Forge-Channel")); ch != "" { + fields["channel"] = ch + } + if cu := strings.TrimSpace(req.Header.Get("X-Forge-Channel-User")); cu != "" { + fields["channel_user"] = cu + } + if ce := strings.TrimSpace(req.Header.Get("X-Forge-Channel-Email")); ce != "" { + fields["channel_email"] = ce + } + } auditLogger.EmitFromContext(req.Context(), coreruntime.AuditEvent{ Event: coreruntime.EventAuthVerify, CorrelationID: correlationID,