sh-mcp/docs/agentforce-auth-decision.md

255 lines
No EOL
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# sh-mcp — Auth Model Decision (DRAFT proposal, supersedes-candidate for D4)
> **Status: DRAFT — pending Adam's sign-off + the mandatory review gates.** Product of the
> multi-model `sh-mcp-agentforce-auth-decision` workflow (2026-07-07): 4-probe research
> (web+code) → 3 competing architectures → 3-lens judge panel → Opus synthesis → adversarial
> security review (returned BLOCK; findings folded in). This does **not** yet supersede decision
> D4 in `agentforce-plan.md` — it becomes committed only after Adam accepts the two gating
> decisions in §7 and the mandatory GPT-4.1 IAM/auth cross-review + `/sh-plan-review` +
> `/sh-security-review` pass. Two research probes returned schema-debug stubs; the crux (G16)
> and the security delta were recovered by the alt-mechanisms probe and the real-code adversarial
> review respectively (which cites live `file:line`), so the conclusion stands on verified ground.
---
## Locked scope — Adam's gating decisions (2026-07-07)
Adam made the two build-shape calls that §7 flagged. They narrow the plan below:
1. **Band-1 (`ops:tasks` + reminders) is DEFERRED — launch is pure service for Ops/Exec.**
Ops and Lauren-Exec agents ship as **pure M2M, Band-0 reads only** (`ops:read`). No
`X-SH-User-Assertion` on the ops/exec path, no `requireActor()` wiring, no Band-1 IDOR
surface at launch. Tasks/reminders come back as a fast follow-on *after* the Phase-D signer
is proven (see note). This is the closest safe realization of Adam's original "just service
auth" instinct.
2. **Per-user finance audit is IN.** The finance agent is **M2M transport + `X-SH-User-Assertion`
for audit attribution**. So the hardened `user-assertion.ts` verifier and the **Phase-D
Salesforce-RunningUser-signer spike are still required — but now gate ONLY finance audit**, not
tasks. If Phase D fails, per-user finance audit is escalated to Adam as an explicit
accept-the-risk call (his stated preference is to have it).
**Consequences for the plan below (read §2–§8 through this lens):**
- **App-client scopes at launch:** `sh-agentforce-ops` → `ops:read`; `sh-agentforce-exec` →
`ops:read`; `sh-agentforce-finance` → `finance:read` (15-min TTL). `ops:tasks`, `gmail:self`,
`calendar:self` are **not granted to any launch client** (`ops:tasks` deferred; Gmail/Calendar
are Band-2, §3, on Lauren's separate per-user Google integration).
- **`user-assertion.ts` is built once but wired ONLY into the finance server** at launch
(`assertionMode: log-only → required` at Phase E). Ops/exec servers do not wrap it.
- **Band-1 work NOT needed at launch:** `requireActor()` in `packages/tasks`/`packages/reminders`
and their exposure on any launch server are **deferred**. The rate-limiter re-key
(`ctx.actor?.sub ?? ctx.sub`, `dispatch.ts:202`) is still made so finance limits per-human;
Ops/Exec Band-0 reads share a per-tier bucket — documented, with an upstream Slack/Agentforce
per-user throttle as the compensating control (review BLOCK-2, now scoped to low-consequence
reads only).
- **Still required regardless (finance assertion + M2M blast-radius controls):** the replay table,
SSM assertion-key write-lock + change alarm, per-tier token-mint anomaly alarm, the retargeted
**audience-drift detector** (ex-group-sync), and the Salesforce-egress IP-allowlist at the API edge.
- **Revocation at launch:** tier kill via `svc:<client_id>` deny-list + secret rotation (all tiers);
per-human kill via the finance assertion `sub` on the finance path; **Ops/Exec Band-0 per-human
kill = SSO deprovisioning + drift alarm** (no AWS-side per-human deny for reads — stated plainly).
- **Bonus:** Phase D proves the *exact* RunningUser-signer capability Band-1 will later need, so
deferring tasks/reminders costs almost nothing to reverse — turning them on becomes a small
follow-on once finance audit is live.
Everything from here down is the full synthesized plan; where it wires Band-1/assertion into the
**ops/exec** path or exposes tasks/reminders at launch, treat that as **deferred** per the above.
---
# sh-mcp — Auth Model Decision: Agent-Level (M2M) Transport + Tool-Band Identity Model (Forwarded User-Assertion for Per-User-Stateful Tools & Finance Audit)
**Status:** Committed plan v2 (supersedes decision D4 in `agentforce-plan.md`; incorporates the adversarial security-review BLOCK/FIX resolutions). Grounded against the built-but-undeployed Phase-2a substrate (`infra/lib/auth-stack.ts`, pool `us-east-1_GsDbGe0pa`, ESSENTIALS plan) and the real Phase-1 tool code (`packages/tasks`, `packages/reminders`, `packages/shared/src/dispatch.ts`). The concrete CDK/`cognito-auth.ts`/`user-assertion.ts` diff still owes the mandatory GPT-4.1 cross-family review and `/sh-security-review` before merge — see §8.
---
## 1. Decision & Rationale
**Yes — pivot to agent-level auth, in a hybrid form, not pure service.** Adam's instinct is correct as the *transport and tier-authorization* boundary: replace the per-user OAuth 2.1 Browser Flow (D4) with the **OAuth 2.0 Client Credentials (M2M) grant**, one confidential Cognito app client per trust tier, so the MCP servers trust the **Salesforce agent identity** (`client_id` == tier == audience). This is a documented Salesforce External Credential + Cognito pattern, it is mostly *deletion* of the substrate we built, and it **resolves gap G16 for authorization**: no human identity ever has to survive the Slack Employee-Agent callout *to decide which tier/scopes are allowed*, because tier authorization is anchored entirely in the `client_id` allow-list + `AllowedOAuthScopes`. The lethal-trifecta invariant is untouched — the boundary was always at the tier/audience, enforced by the `client_id` allow-list, not at the human.
**The correction from v1:** the security review is right that v1's load-bearing claim — "we swap one assumption that blocked *everything* (G16) for a narrower one that blocks *only* per-user finance audit" — was **false**. Per-user identity (`ctx.sub`) is also load-bearing for **`ops:tasks` data isolation**, **reminder targeting**, and **per-user rate-limiting / session caps**, all in the ops/exec tiers. Collapsing `ctx.sub` to a single `svc:<clientId>` per tier would turn tasks into a **cross-user read/delete IDOR** and collapse the abuse cap to one shared bucket — a confidentiality/integrity/availability regression, not an audit-granularity trade. That is a BLOCK and is fixed here.
**The right frame is a per-tool identity-need taxonomy, not a per-tier one.** Every MCP tool falls into one of three bands by *what identity it structurally requires*:
- **Band 0 — Shared/stateless.** No per-user partition, no per-user target. Authorization is *entirely* the Cognito scope + `client_id` allow-list. Examples: `ops:read`, `finance:read`. Safe on a subject-less M2M token; `ctx.sub = 'svc:'+clientId`.
- **Band 1 — Per-user-stateful, internally enforced.** The tool selects a per-user DynamoDB partition or a per-user delivery target that *our own code* enforces. Examples: `ops:tasks` (the `tasks` package, partition key `sub`) and reminders (`reminders` package, `recipient`/schedule-name derived from `sub`). These **cannot** preserve per-user semantics on a subject-less token — there is no external enforcer to fall back on. They **require a verified user identity, fail-closed**, and their partition/target key + rate-limit key derive from that verified identity.
- **Band 2 — Per-user-stateful, externally enforced.** Google (not our code) enforces per-mailbox/-calendar isolation. Examples: `gmail:self`, `calendar:self`. Moved **entirely off** the MCP/M2M path onto a direct per-user Google OAuth integration (§3).
The forwarded **`X-SH-User-Assertion`** (a short-lived, signed, verified JWS minted from Salesforce's own RunningUser context) is the mechanism that carries a verified human identity to Band-1 tools and to the finance audit logger. Its role is **precisely bounded** (see §2 bright line): it **never** grants a scope or selects a tier — that is always the Cognito token. It selects a *within-tier data partition* (Band 1 only) and *attributes audit* (finance). Because Gmail/Calendar are off-path (Band 2), the worst a forged assertion can do is misattribute an audit entry or touch **another employee's tasks/reminders** — never select a mailbox, never escalate scope, never reach finance data. That bounded blast radius is what makes accepting a narrow, *hardened* dependency on the assertion tolerable.
**Net trade, stated honestly:** we swap one assumption that blocked *everything* (G16, for authorization) for (a) tier authorization anchored in Cognito (no human dependency at all), plus (b) a **narrower, hardened dependency on a verified user-assertion** for exactly two purposes — Band-1 per-user data isolation and per-user finance audit — each with an explicit fail-closed fallback. This ships now.
---
## 2. Target Auth Architecture
### End-to-end flow (tier authorization has no human dependency; Band-1 + finance audit carry a verified user-assertion)
1. A Slack user invokes one of three Agentforce Employee Agents (**Seahaven Ops / Seahaven Finance / Lauren Exec**). Agent↔tier is 1:1, unchanged. Which humans can invoke which agent is governed **upstream** in Salesforce/Slack agent-audience config, sourced from the same Google Groups. **That audience mapping is now a load-bearing control** with drift-detection and a documented max staleness (§4, Risk 3).
2. The agent fires an OpenAPI/Apex External Service action whose per-tier **External Credential** uses **OAuth 2.0 Client Credentials**, with a **Named Principal** holding that tier's Cognito `client_id` + secret.
3. Salesforce (the platform, not the human) calls `https://sh-mcp-auth.auth.us-east-1.amazoncognito.com/oauth2/token` with `grant_type=client_credentials`, the tier credential, and `scope=sh-mcp-<tier>/...`. Back-channel, server-to-server. **No browser flow, no Google federation, no user token to lose.**
4. Cognito mints a JWT whose granted scopes are capped by that app client's `AllowedOAuthScopes` (the tier ceiling). Salesforce caches it and presents `Authorization: Bearer <jwt>` to the MCP server (API GW → host, Phase 2b).
5. **On every Agentforce callout (all three tiers)**, immediately before the callout, a Salesforce Apex invocable action running in the Flow's **RunningUser** context mints a short-lived (≤120s) RS256/ES256-signed JWS and attaches it as `X-SH-User-Assertion`. The JWS is **session-bound** (see below): it carries the presenting `client_id` and a hash of the bearer access token, so it cannot be lifted onto a different tier's token or replayed against another request.
6. `CognitoAuthProvider.authenticate()` runs essentially as today: verify signature against pool JWKS, check `iss`, enforce the `client_id` allow-list (the audience boundary — a cross-tier token is a hard 401), `extractScopes()` prefix-strip, apply the finance TTL ceiling + deny-list. In M2M (`principalMode:'client'`) mode it does **not** require a token `sub`; it **unconditionally** sets `AuthContext.sub = 'svc:'+clientId` (never trusting any `sub` the M2M token happens to carry — see NIT resolution, §4).
7. `UserAssertionAuthProvider` (wraps ops **and** finance) verifies the assertion (alg-pinned, session-bound, `aud`-pinned, `jti` single-use) and, on success, sets `AuthContext.actor = {sub,email}`. Dispatch then keys the rate limiter / session cap on `ctx.actor.sub` when present. Band-1 tools read the partition/target key from `ctx.actor.sub` and **fail closed** (`missing_actor_assertion`) if it is absent.
### The bright line — what the assertion may and may not do
| Concern | Decided by | Assertion's role |
|---|---|---|
| **Which tier / which scopes** (finance vs ops vs exec; `finance:read` vs `ops:tasks`) | Cognito M2M token `client_id` + `scope`, capped by `AllowedOAuthScopes`, checked by the `client_id` allow-list | **NONE.** A forged/absent assertion can never grant a scope or cross a tier. |
| **Which per-user data partition / delivery target** (Band-1: `ops:tasks`, reminders) | Verified `ctx.actor.sub` from the assertion | **Authorization-bearing, bounded.** Selects a partition *within the already-authorized ops tier*. Worst-case forgery = touch another employee's tasks/reminders. |
| **Per-user rate-limit / session-cap bucket** | Verified `ctx.actor.sub` (fallback: per-tier `svc:` key with a much lower threshold) | Availability keying. Forgery worst-case = use/evade a rate budget. |
| **Audit attribution** (finance required, ops recorded) | Verified `ctx.actor.sub`/`email` | Attribution only. Forgery worst-case = misattributed audit entry. |
| **Which mailbox/calendar** (Band-2) | **Not on this path at all** — direct per-user Google OAuth (§3) | **NONE.** Structurally unreachable through Cognito. |
### Token / header contents
| Credential | Carries | Role |
|---|---|---|
| Cognito M2M access token (Bearer) | `client_id`, `scope` (resource-server-prefixed), `iss`, `token_use=access`, `iat`, `exp`. (`sub`, if Cognito populates it, is ignored — we never key on it.) | **Tier + scope authorization.** The only credential that decides tier and scope. |
| `X-SH-User-Assertion` (all Agentforce callouts) | `sub` (Google Workspace sub), `email`, `aud` (this server's resource-server id), `client_id` (must equal the presenting token's client_id), `tkn` (SHA-256 of the presented access token), `iat`, `exp≤iat+120s`, `jti` | **Band-1 partition selection + rate-limit keying + audit.** Never grants scope, never selects a tier or mailbox. |
### Per-tier credential model
Three confidential M2M app clients, `generateSecret:true`:
- `sh-agentforce-ops` → `ops:read`, `ops:tasks`
- `sh-agentforce-exec` → `ops:read`, `ops:tasks` (Gmail/Calendar removed — Band 2, §3)
- `sh-agentforce-finance` → `finance:read` (15-min access TTL)
Secrets mirrored to Secrets Manager `sh-mcp/agentforce-<tier>` for rotation-of-record; non-secret config (client ids, token endpoint, scope strings, assertion public-key SSM path) per handbook. **The tier secret now lives in two trust domains** (AWS Secrets Manager + the Salesforce Named Principal) — custody, rotation coupling, and mint-anomaly alarms are mandatory compensating controls (§4, Risk 4/5).
### How each property is handled
- **Trifecta separation — PRESERVED (unchanged).** Three distinct `client_id`s, each with its own `AllowedOAuthScopes` ceiling; the finance client physically cannot mint an ops/gmail token and vice-versa; each server's `client_id` allow-list rejects any other tier's token. No single token or agent session can reach both untrusted Gmail-read and a finance/physical action.
- **Band-1 per-user isolation — PRESERVED via required assertion.** `ops:tasks` and reminders partition/target on `ctx.actor.sub` (verified), fail-closed. Two distinct humans on the Ops agent get two distinct task partitions; neither can list/complete/delete the other's tasks. (This is the BLOCK-1 fix.)
- **Abuse controls — PRESERVED via per-user keying.** The dispatch rate limiter and session cap key on `ctx.actor.sub`, not the tier principal, so one user cannot exhaust another's budget. (This is the BLOCK-2 fix.)
- **Band-2 (Gmail/Calendar) — RE-HOMED off the MCP path** (§3). Not carried by any Cognito token at all.
- **Audit — per-user everywhere the actor is present.** `AuditRecord.sub` = the acting tier (`svc:<client_id>`); when a verified assertion is present, `AuditRecord.actorSub`/`actorEmail` record the human. Finance requires it (fail-closed); ops records it best-effort in `log-only` and required once promoted.
### Revocation — honest per-tier / per-human matrix
The v1 table overstated per-human coverage. Corrected:
| Lever | Scope of effect | Latency | Applies to |
|---|---|---|---|
| Rotate/disable a tier's app-client secret (Cognito **and** Secrets Manager **and** the Salesforce Named Principal, in lockstep) | Whole tier | Immediate on rotation | All tiers |
| `sh-mcp-deny-list` entry `svc:<client_id>` | Whole tier (sub-TTL kill switch) | ≤1 request | All tiers |
| `sh-mcp-deny-list` entry on a user `sub` | One human, **Band-1 + finance only** (paths that carry a verified assertion) | ≤1 request | `ops:tasks`/reminders + `finance:*` |
| **Deprovision the human's SSO identity (Google/Slack)** | One human, all tiers, at the agent-invocation gate | Immediate on offboarding | All tiers — **this is the primary per-human lever for Band-0 `ops:read`/`finance:read`, which carry no per-user AWS-side identity** |
| Update Salesforce/Slack agent-audience config | One human, at the agent-invocation gate | Manual; bounded by the drift-detector's max-staleness alarm | All tiers |
| Short access TTLs (finance 15 min; ops/exec tightened to 15–30 min) | Bounds already-minted tokens (client-credentials has no refresh token, so no refresh-revocation lever) | ≤ TTL | All tiers |
**Stated plainly (FIX-3):** for **Band-0** ops/exec/finance *reads*, there is **no AWS-side per-human revocation** — those tokens don't carry a human. The real-time per-human kill for reads is **SSO deprovisioning** (standard offboarding removes the user from Google/Slack, so they can no longer invoke the agent), backed by the audience-drift-detector alarm. AWS-side per-human deny-list works only for the assertion-carrying Band-1 + finance surface.
---
## 3. Band-2 (Lauren Exec Gmail/Calendar) — Resolved by Removal, Not Degradation
A client-credentials token has no subject, so the MCP server cannot pick the caller's mailbox and Google's per-mailbox enforcement can't engage. This is structural, not fixable in server code. **Resolution: pull `gmail:self` / `calendar:self` out of the Agentforce/MCP path entirely.**
- Remove `gmail:self` / `calendar:self` from the exec app client's scopes **and** from the ops resource server's declared scopes / `KNOWN_SCOPES` / `GROUP_SCOPES` (they become unreachable through Cognito).
- Lauren's mailbox/calendar tools stay on the **existing separate per-user Google OAuth integration** (design.md §2.4/§3) — a direct per-user grant (or a small dedicated Bolt app) that authenticates Lauren directly to Google and **never touches the M2M substrate**. Google enforces isolation; the KMS-encrypted per-`sub` refresh-token store + `dynamodb:LeadingKeys` partitioning live there, on a path that already has a real per-user identity.
- **Why this is the right call, not a punt:** it demotes the Salesforce signing cert from "Gmail-isolation crown jewel" to "Band-1/audit integrity" — a cert compromise can at worst forge *audit entries* or touch *another employee's tasks*, never select the wrong mailbox or escalate scope.
- **Invariant to record:** the exec tier is effectively single-occupant (Lauren, + Adam as admin). The separate integration remains genuinely per-user, so it survives the exec group growing; do **not** substitute a shared-mailbox service account.
**Note on why tasks/reminders are Band-1, not Band-2:** unlike Gmail there is *no external enforcer* — the tasks/reminders partition is our own DynamoDB `LeadingKeys`. The only way to get a per-user partition key without the M2M subject is a verified user identity in the request, i.e. the assertion. That is why Band-1 keeps the assertion *required and authorization-bearing within-tier*, where Band-2 can offload isolation to Google.
---
## 4. Concrete Changes to the Built Phase-2a Substrate
The stack is **synth-only / never deployed** — every change below is a **pre-first-deploy edit to `auth-stack.ts`**, so there is **zero throwaway/migration**. Do all of it before the first `cdk deploy`.
| Component | Action | Detail |
|---|---|---|
| `sh-mcp` user pool (`us-east-1_GsDbGe0pa`), ESSENTIALS | **KEEP** | ESSENTIALS still warranted (M2M access-token customization needs Essentials/Plus if ever used; keep for headroom). |
| Both resource servers `sh-mcp-ops` / `sh-mcp-finance` | **KEEP** | M2M **requires** resource-server custom scopes. **Modify:** drop `gmail:self`/`calendar:self` from `sh-mcp-ops` scopes (Band-2 removal). |
| `mkClient` app clients (×3) | **MODIFY** | `flows: { clientCredentialsGrant: true }`; **drop** `supportedIdentityProviders` (Google), `callbackUrls`, `idTokenValidity`, `refreshTokenValidity`, `enableTokenRevocation`; **drop** `OAuthScope.OPENID`/`EMAIL`; **keep** `generateSecret:true`. Access TTL: finance 15 min; **tighten ops/exec to 15–30 min**. Exec scopes → `ops:read`,`ops:tasks` only. |
| Cognito **UserPoolDomain** | **ADD** | `userPool.addDomain('Domain', { cognitoDomain: { domainPrefix: 'sh-mcp-auth' } })` — required to expose `/oauth2/token`. |
| Per-tier secret mirror | **ADD** | Mirror each generated client secret to Secrets Manager `sh-mcp/agentforce-{ops,exec,finance}`. Document Salesforce-side Named-Principal custody + lockstep rotation (Risk 5). |
| **API Gateway resource policy / WAF — Salesforce egress IP-allowlist** | **ADD** | Restrict the MCP API (Phase 2b) to Salesforce's published egress CIDR ranges as a compensating control for the un-revocable M2M bearer (FIX-5). |
| **Cognito token-mint anomaly alarm** | **ADD** | CloudWatch alarm on per-tier `/oauth2/token` mint rate (CloudTrail/metric) → `sh-mcp-alarms` (FIX-5). |
| Google OIDC IdP (`GoogleIdp`) + `sh-mcp/google-oidc` grant | **REMOVE** | Nothing federates Cognito↔Google for auth anymore. |
| Pre-token Lambda `sh-mcp-pre-token-gen` + V2 trigger + `CognitoInvoke` perm + `PreTokenErrorsAlarm` | **REMOVE** | Suppress-only logic keyed off per-user groups; M2M tokens have no per-user issuance event. `AllowedOAuthScopes` is now the sole scope ceiling. (Retain `scopes.ts` `entitledScopes`/`GROUP_SCOPES` in git history only for a possible D4 revert.) |
| Group-sync Lambda `sh-mcp-group-sync` + 5-min schedule + `GroupSyncMissingAlarm` + `sh-mcp/google-directory-sa` grant | **RETARGET (not delete)** | Repurpose the scaffold as an **audience-drift detector**: keep the Directory read + schedule; **drop** the Cognito-group write path. It compares Google-Group membership (source of truth) against the Salesforce/Slack agent-audience roster and emits a **drift metric + max-staleness metric** → alarms on `sh-mcp-alarms`. This makes the audience↔group mapping a load-bearing, monitored control (FIX-3). If the Salesforce audience cannot be read programmatically, ship the interim: alarm on Google-Group *membership change* + a documented weekly manual reconciliation runbook, with SSO deprovisioning as the real-time kill. |
| 4 Cognito groups (`sh-mcp-ops/-assistant/-finance/-admin`) | **REMOVE** | No group-based entitlement in the MCP path. |
| `sh-mcp-sync-state` DynamoDB table | **KEEP (repurposed)** | Now stores the drift-detector's last-run timestamp + last-seen roster hash to compute staleness. (Was slated for delete in v1; retain for the retargeted detector.) |
| `sh-mcp-deny-list` DynamoDB table | **KEEP + RE-KEY semantics** | PK stays `sub` (string), now stores **either** `svc:<client_id>` (tier kill) **or** a user `sub` (Band-1/finance per-human kill). Code change only, no schema change. |
| `sh-mcp-alarms` SNS topic + CMK log groups | **KEEP** | Reuse for the assertion-anomaly, token-mint-anomaly, and audience-drift alarms. |
| `/sh-mcp/salesforce-assertion/public-key` (SSM String) | **ADD + LOCK DOWN** | Non-secret assertion-verification public key. **Write** restricted to a break-glass admin principal only; the ops **and** finance server roles get **read-only**; an EventBridge rule on `ssm:PutParameter` for this name fires a **change alarm** → `sh-mcp-alarms` (FIX-4). Documented rotation runbook. |
| `sh-mcp-assertion-replay` DynamoDB (PK=`jti`, TTL=`exp`, PAY_PER_REQUEST) | **ADD (from first assertion deploy — Phase D, not E)** | `jti` single-use guard active in `log-only` mode too, so Phase-D never runs without replay protection (FIX-4). |
| `packages/shared/src/cognito-auth.ts` | **MODIFY (surgical)** | Add `principalMode: 'user' \| 'client'` to `CognitoAuthConfig`. In `'client'` mode: **unconditionally** set `AuthContext.sub = 'svc:'+clientId` — do **not** merely skip the `sub`-required throw; never read a token-provided `sub` (Cognito M2M tokens historically carry `sub == client_id`, so a naive skip would leak the raw client id into the `sub` namespace — NIT-7). All else — allow-list, `extractScopes`, finance TTL ceiling, deny-list — **unchanged**. Return contract `{sub,scopes,aud}` preserved. *(Auth-surface change → gates on GPT-4.1 review, §8.)* |
| `packages/shared/src/user-assertion.ts` | **ADD (hardened)** | `verifyUserAssertion()` + `UserAssertionAuthProvider` decorator around the existing `AuthProvider` interface. Verification: (1) **algorithm pinned** to an explicit `{algorithms:['RS256']}` (or `['ES256']`) allowlist — reject `none`/`HS256`/alg-confusion; (2) signature vs the SSM public key; (3) exact-match `iss`; (4) **`aud`-pin** to this server's resource-server id; (5) **session-binding** — `client_id` must equal the presenting Cognito token's `client_id`, and `tkn` must equal `SHA-256(presented access token)`; (6) `exp≤iat+120s`; (7) **`jti` single-use** vs `sh-mcp-assertion-replay`. New `AuthErrorCode`: `missing_actor_assertion`. (FIX-4.) |
| `AuthContext` (`types.ts`), `AuditRecord` (`audit.ts`) | **MODIFY (additive)** | `AuthContext.actor?: {sub,email}`; `AuditRecord.actorSub?`/`actorEmail?`. No existing field removed. |
| `packages/shared/src/dispatch.ts` (limiter/session key) | **MODIFY** | Change the rate-limit/session-cap key from `ctx.sub` (line 202) to `ctx.actor?.sub ?? ctx.sub`. When an actor is present (all Agentforce callouts), buckets are per-human; the `svc:` fallback only applies to actor-less calls and must run under a **much lower per-tier threshold** (BLOCK-2). Audit `sub` at line 216 unchanged; `actorSub`/`actorEmail` added alongside. |
| `packages/tasks/src/tools.ts` + `packages/reminders/src/tools.ts` (Band-1) | **MODIFY** | Replace every partition/target `sub: ctx.sub` with `sub: requireActor(ctx).sub` — a helper that throws `missing_actor_assertion` if `ctx.actor` is absent. Tasks partition and reminder recipient/schedule-name now derive from the **verified human**, fail-closed. (BLOCK-1.) |
| `servers/*/src/auth.ts` `buildOpsAuthProvider`/`buildFinanceAuthProvider` | **MODIFY** | **Both** ops/exec **and** finance now wrap `CognitoAuthProvider(principalMode:'client')` in `UserAssertionAuthProvider`, behind a per-tier `assertionMode`. **Ops/exec:** `assertionMode` starts `log-only` for Band-0 reads but Band-1 tools (`ops:tasks`/reminders) call `requireActor()` and hard-fail without a verified actor regardless of mode. **Finance:** `assertionMode` default `log-only`, promoted to `required` in Phase E. `AuthProvider` interface itself untouched → no handler-signature changes. |
---
## 5. Phased Build Steps (ordered, each with an exit criterion)
**Phase A — Substrate rework (pre-first-deploy, no AWS calls).**
Edit `auth-stack.ts` per §4 (grant type, domain, drop IdP/pre-token/groups, **retarget** group-sync→drift-detector, secret mirrors, TTLs, SSM key lockdown + change alarm, replay table, token-mint + drift alarms, WAF/API egress allowlist). *Exit:* `cdk synth` clean; snapshot diff shows only the intended keep/modify/remove/retarget set; no `authorizationCodeGrant`, no Google IdP, no pre-token trigger remain; the SSM assertion-key parameter is write-locked to the break-glass principal and read-only to both server roles.
**Phase B — Shared-core changes.**
`principalMode` in `cognito-auth.ts` (unconditional `svc:`+clientId); additive `AuthContext.actor`/`AuditRecord.actorSub`; dispatch limiter re-keyed to `actor.sub ?? sub`; `user-assertion.ts` verifier (alg-pin, session-bind, `aud`-pin, `jti`); Band-1 `requireActor()` in tasks + reminders. *Exit:* `npx tsc --noEmit` + lint pass. New/updated unit tests all green:
- **cognito-auth:** M2M token with `sub == clientId` still yields `ctx.sub === 'svc:'+clientId` (never the raw client id); cross-tier `client_id` → 401; finance TTL ceiling still fires; deny-list fires on `svc:<clientId>`.
- **isolation (BLOCK-1):** two distinct actor subs cannot see/complete/delete each other's tasks; a call with no `ctx.actor` to any `ops:tasks`/reminder tool → `missing_actor_assertion` (fail-closed).
- **abuse (BLOCK-2):** two distinct actor subs get independent rate/session buckets; one cannot consume the other's budget; actor-less fallback uses the lower per-tier threshold.
- **assertion (FIX-4):** `none`/`HS256`-signed assertion rejected; `aud`-mismatch rejected; `client_id`- or `tkn`-mismatch (lifted onto another token) rejected; replayed `jti` rejected; `exp>120s` rejected.
**Phase C — Deploy substrate + wire ops/finance (client-credentials, no assertion yet).**
Deploy the reworked stack; configure the three Salesforce External Credentials (Named Principal, client-credentials). **Empirically verify the M2M access-token shape** (does Cognito populate `sub`? `aud`?) — the D4 spike verified the auth-code shape; do the same here and confirm the `svc:` mapping holds against the real token (NIT-7). *Exit:* each agent obtains a token from `/oauth2/token`; ops/finance servers accept their own tier token and 401 a cross-tier token; finance token >15 min TTL is rejected; a `svc:<client_id>` deny-list entry blocks a tier within one request; Band-0 reads (`ops:read`/`finance:read`) succeed with `ctx.sub='svc:<clientId>'`; the token-mint anomaly + audience-drift alarms are live; the API egress IP-allowlist rejects a non-Salesforce source.
**Phase D — SPIKE: "Salesforce RunningUser-at-callout" (gates BOTH Band-1 tasks/reminders AND per-user finance audit).**
Build a minimal Apex invocable action, invoked from an Agentforce **Employee-Agent-in-Slack** flow, that reads `UserInfo.getUserId()`/RunningUser, signs the session-bound JWS (`sub,email,aud,client_id,tkn,iat,exp,jti`), and attaches `X-SH-User-Assertion` to the External Service callout. Deploy the **replay table from the start** so `log-only` mode still enforces `jti` single-use. Verify across **≥2 distinct Slack users** on the finance server (`assertionMode:log-only`) that each receives a correctly-signed assertion naming the **true invoking human**, with `aud`/`client_id`/`tkn`/`jti` all verified. **Also verify a system/autonomous invocation is distinguishable** — an autonomous run must *not* yield a human `sub` that could masquerade as an interactive user (QUESTION-6): confirm `UserInfo.getUserId()` in a non-interactive context returns an identifiable service/automated user id, and have the verifier treat any non-provisioned/service sub as `missing_actor_assertion` for Band-1/finance-required paths. *Exit (pass):* ≥2 users → ≥2 distinct correct human subs; autonomous invocation is distinguishable and cannot masquerade; session-binding + replay verified. **Named fallback (fail):**
- **Band-1 (`ops:tasks`/reminders):** **DISABLED on the Agentforce path** (removed from the ops/exec app-client scopes) — they cannot run safely without a verified per-user identity, and a shared partition is an unacceptable IDOR. Flag to Adam.
- **Finance:** stays pure client-credentials — per-tier audit + best-effort **untrusted** correlation header + out-of-band Slack-log correlation; per-user finance audit documented as **waived pending an alternate mechanism** and escalated to Adam as an explicit **accept-the-risk** decision (not a soft degrade — the plan itself calls losing named finance attribution a failure of Sea Haven's financial-audit obligation).
**Phase E — Promote per-user enforcement (only if Phase D passed).**
Flip finance `assertionMode:'required'` (fail-closed `missing_actor_assertion`); confirm Band-1 ops/exec tools are running `requireActor()` fail-closed; re-key deny-list to also accept the assertion `sub`. *Exit:* every `finance:*` call logs `actorSub`/`actorEmail`; every `ops:tasks`/reminder call is attributed to a verified human; a missing/expired/replayed/`aud`-mismatched/`tkn`-mismatched assertion is rejected on those paths; a per-`sub` deny-list entry blocks that human on the Band-1/finance surface while the tier stays up.
**Phase F — Lauren Exec Gmail/Calendar on the separate per-user path (Band-2).**
Confirm the exec agent carries only `ops:read`/`ops:tasks`; stand up / verify the direct per-user Google integration for mailbox/calendar (out of MCP path). *Exit:* Lauren's mailbox tools work via the direct integration; no `gmail:self`/`calendar:self` scope is reachable through any Cognito client.
---
## 6. Tool-Band Inventory (build reference)
| Scope / tool | Band | Identity source | Fail-closed? | Notes |
|---|---|---|---|---|
| `ops:read` (org data reads) | 0 | Cognito scope; `ctx.sub='svc:ops'` | n/a (no per-user state) | Rate-limited per-actor when assertion present, else lower per-tier bucket |
| `finance:read` (QBO reads) | 0 | Cognito scope; `ctx.sub='svc:finance'` | n/a | 15-min TTL; audit `actor` recorded (Phase E: required) |
| `ops:tasks` (tasks: create/list/complete/delete) | **1** | Verified `ctx.actor.sub` → DynamoDB partition | **Yes** (`missing_actor_assertion`) | BLOCK-1 fix; partition ABAC `LeadingKeys` |
| reminders (create/target) | **1** | Verified `ctx.actor.sub` → recipient/schedule-name | **Yes** | Default recipient = verified human; cross-user targeting still requires the ops scope + is audited |
| `gmail:self`, `calendar:self` | **2** | Direct per-user Google OAuth (off-path) | n/a | Not reachable via Cognito at all (§3) |
---
## 7. Risks & Open Questions (need a live test or Adam's decision)
1. **RunningUser-at-callout (Phase D) now gates two features, not one.** Whether Agentforce invokes the Apex action in the true interactive user's context or a system context is unproven — and it now gates **Band-1 tasks/reminders** as well as per-user finance audit. Clean fail-closed fallbacks exist for both (Phase D). A perfectly-verified assertion proves provenance from our signer, **not** that its `sub` is the real human — Phase D's exit criterion therefore requires ≥2 distinct correct subs **and** that an autonomous/system invocation is distinguishable and cannot masquerade (QUESTION-6).
2. **Entitlement widening: `ops:tasks` now reachable by all Ops-agent users.** Deleting the suppress-only pre-token Lambda means the ops app-client grants `ops:tasks` to everyone on the Ops agent (previously group-sync stripped it for non-assistants). Under Band-1 per-user partitioning this is *safe* (each user only ever touches their own partition), but it is a **product decision** — everyone now gets personal tasks/reminders through the agent. *Adam's call* whether that is desired; if not, re-introduce an entitlement filter (but note there is no longer a Cognito-group mechanism to hang it on — it would live in Salesforce audience config or a per-sub allowlist).
3. **ops/exec per-human revocation is upstream-only for Band-0.** For `ops:read`/`finance:read`, the real-time per-human kill is **SSO deprovisioning**, backed by the audience-drift-detector's staleness alarm; there is no AWS-side per-human deny for reads. The audience↔Google-Group mapping is a **load-bearing, monitored control** (§4 retargeted detector). Documented max staleness required; Adam to confirm the acceptable window.
4. **Is per-user finance audit worth the Salesforce-side build** (Apex signer, CKM cert, session-binding, replay table)? — *Adam's call.* Recommendation: build it — named accountability on financial flows is a standing obligation. If per-tier finance audit + Slack correlation is acceptable, skip Phase-E finance promotion (Band-1 tasks/reminders still need Phase D regardless).
5. **Signing-cert + tier-secret custody.** The CKM signing cert and the per-tier Cognito **client secrets stored in Salesforce** both live outside AWS. Blast radius is bounded — a cert compromise forges *audit entries* or touches *another employee's tasks* (never mailbox/scope/finance-data, because Gmail is off-path and scope is Cognito-only); a Salesforce compromise can mint a whole tier's token (including `finance:read` = QBO-wide read) until rotated. Both **must** be added to the `/sh-security-review` sensitive-surface list with rotation runbooks (§8). Compensating controls: short TTLs, lockstep rotation, token-mint anomaly alarm, Salesforce-egress IP-allowlist at the API edge.
6. **Leaked tier secret impersonates a whole tier** until rotated, and client-credentials has **no per-token kill switch** (no refresh token, `enableTokenRevocation` dropped). Mitigated by short TTLs + lockstep secret rotation + the `svc:<client_id>` deny-list + the egress IP-allowlist.
7. **Second per-user path persists** (Lauren's Google integration, Band-2) — per-user auth isn't eliminated platform-wide, only from tier authorization. Must be built/maintained regardless.
8. **Departure from the MCP authorization spec's resource-owner default** — a conscious, documented exception; future MCP tooling/auditors will assume per-user OAuth.
---
## 8. Handbook Obligations Triggered
- **Mandatory GPT-4.1 cross-family review (IAM/auth) — run separately via the orchestrator on the concrete diff, NOT in this workflow.** Required because the change touches the auth surface: `cognito-auth.ts` (`principalMode`, unconditional `svc:` subject), the grant-type/IdP/trigger/SSM-policy IAM changes in `auth-stack.ts`, the new `user-assertion.ts` verifier, and the dispatch limiter re-key + Band-1 `requireActor()`. **The build must not merge until this review passes on the real diff.** Run: `python3 ~/Documents/repositories/orchestrator/run.py "Review this diff for breaking changes: <auth-stack.ts + cognito-auth.ts + user-assertion.ts + dispatch.ts + tasks/tools.ts + reminders/tools.ts>"` (confirm `cross_reviewer` route with `--route-only`).
- **`/sh-security-review`** (mandatory for authn/authz + IaC/IAM changes) before push. Add **both** the Salesforce CKM signing cert **and** the per-tier Cognito client secrets stored in Salesforce to the sensitive-surface list, each with a rotation runbook. Resolve every confirmed critical/high or suppress with written justification.
- **`/sh-plan-review`** on this multi-phase plan before building (adversarial GPT-4.1 gate).
- **Confluence "AWS Architecture Map" (id 1540098, IT space)** — update the sh-mcp Mermaid subgraph: remove Google IdP / pre-token / Cognito groups; show M2M client-credentials flow, the **retargeted audience-drift detector**, the assertion path (Band-1 + finance) with the replay table, the SSM key-lockdown, and the separate Lauren Google integration (Band-2). Do it in the same conversation as the change; flag as outstanding if Confluence is unreachable.
- **`project_sh_mcp` memory** — record the D4→agent-level pivot; G16 resolved-for-authorization; the **tool-band taxonomy** (Band-0 shared / Band-1 assertion-required / Band-2 off-path); the BLOCK-1/BLOCK-2 resolution (tasks/reminders + rate-limit now per-actor, fail-closed); Gmail moved off-path; the assertion hardening (alg-pin, session-bind, SSM lockdown, `jti`-from-Phase-D); the revocation matrix (Band-0 = SSO-deprovision + drift alarm); and the Phase-D spike outcome.
- **README** (`sh-mcp`) — update architecture/data-flow/config to the M2M + tool-band model in the same commit.
- **Doc updates:** `docs/design.md` (§2 auth model + §7.3 abuse-control keying); `docs/agentforce-plan.md` (revise **decision D4** with a dated decision-log entry recording this supersession, the G16 disposition, and the tool-band model); `docs/runbook-auth-deploy.md` (new deploy/rotation steps: M2M secrets + lockstep Salesforce rotation, `sh-mcp-auth` domain, assertion public-key rotation + SSM write-lock/break-glass, replay table, audience-drift reconciliation runbook, Salesforce-egress IP-allowlist).
---
**Bottom line for Adam:** your pivot is right — adopt agent-level Client Credentials as the tier/authorization boundary for all three tiers; it kills G16 for authorization and is mostly deletion of what we built. The one correction from the security review: **`ops:tasks`, reminders, and per-user rate-limiting are also per-user-load-bearing**, so we can't collapse them to a shared tier principal without opening a cross-user IDOR and a shared abuse bucket. The fix is a **tool-band model**: shared reads ride the subject-less token; tasks/reminders **require** a verified (and now hardened — alg-pinned, session-bound, replay-guarded) user-assertion and fail closed; Gmail/Calendar move **off** the MCP path so Google enforces isolation. Tier/scope authorization never depends on the assertion — only within-tier data partitioning and audit do, with bounded blast radius and explicit fail-closed fallbacks. That's the hybrid, and it's buildable now.