40 KiB
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-decisionworkflow (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 inagentforce-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-reviewpass. 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 livefile: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:
- 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). NoX-SH-User-Assertionon the ops/exec path, norequireActor()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. - Per-user finance audit is IN. The finance agent is M2M transport +
X-SH-User-Assertionfor audit attribution. So the hardeneduser-assertion.tsverifier 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:selfare not granted to any launch client (ops:tasksdeferred; Gmail/Calendar are Band-2, §3, on Lauren's separate per-user Google integration). user-assertion.tsis built once but wired ONLY into the finance server at launch (assertionMode: log-only → requiredat Phase E). Ops/exec servers do not wrap it.- Band-1 work NOT needed at launch:
requireActor()inpackages/tasks/packages/remindersand 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 assertionsubon 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_idallow-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(thetaskspackage, partition keysub) and reminders (reminderspackage,recipient/schedule-name derived fromsub). 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)
- 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).
- 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. - Salesforce (the platform, not the human) calls
https://sh-mcp-auth.auth.us-east-1.amazoncognito.com/oauth2/tokenwithgrant_type=client_credentials, the tier credential, andscope=sh-mcp-<tier>/.... Back-channel, server-to-server. No browser flow, no Google federation, no user token to lose. - Cognito mints a JWT whose granted scopes are capped by that app client's
AllowedOAuthScopes(the tier ceiling). Salesforce caches it and presentsAuthorization: Bearer <jwt>to the MCP server (API GW → host, Phase 2b). - 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 presentingclient_idand a hash of the bearer access token, so it cannot be lifted onto a different tier's token or replayed against another request. CognitoAuthProvider.authenticate()runs essentially as today: verify signature against pool JWKS, checkiss, enforce theclient_idallow-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 tokensub; it unconditionally setsAuthContext.sub = 'svc:'+clientId(never trusting anysubthe M2M token happens to carry — see NIT resolution, §4).UserAssertionAuthProvider(wraps ops and finance) verifies the assertion (alg-pinned, session-bound,aud-pinned,jtisingle-use) and, on success, setsAuthContext.actor = {sub,email}. Dispatch then keys the rate limiter / session cap onctx.actor.subwhen present. Band-1 tools read the partition/target key fromctx.actor.suband 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:taskssh-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_ids, each with its ownAllowedOAuthScopesceiling; the finance client physically cannot mint an ops/gmail token and vice-versa; each server'sclient_idallow-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:tasksand reminders partition/target onctx.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/actorEmailrecord the human. Finance requires it (fail-closed); ops records it best-effort inlog-onlyand 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:selffrom 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-
subrefresh-token store +dynamodb:LeadingKeyspartitioning 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 == clientIdstill yieldsctx.sub === 'svc:'+clientId(never the raw client id); cross-tierclient_id→ 401; finance TTL ceiling still fires; deny-list fires onsvc:<clientId>. - isolation (BLOCK-1): two distinct actor subs cannot see/complete/delete each other's tasks; a call with no
ctx.actorto anyops: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- ortkn-mismatch (lifted onto another token) rejected; replayedjtirejected;exp>120srejected.
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)
- 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
subis 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). - Entitlement widening:
ops:tasksnow reachable by all Ops-agent users. Deleting the suppress-only pre-token Lambda means the ops app-client grantsops:tasksto 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). - 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. - 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).
- 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-reviewsensitive-surface list with rotation runbooks (§8). Compensating controls: short TTLs, lockstep rotation, token-mint anomaly alarm, Salesforce-egress IP-allowlist at the API edge. - Leaked tier secret impersonates a whole tier until rotated, and client-credentials has no per-token kill switch (no refresh token,
enableTokenRevocationdropped). Mitigated by short TTLs + lockstep secret rotation + thesvc:<client_id>deny-list + the egress IP-allowlist. - 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.
- 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, unconditionalsvc:subject), the grant-type/IdP/trigger/SSM-policy IAM changes inauth-stack.ts, the newuser-assertion.tsverifier, and the dispatch limiter re-key + Band-1requireActor(). 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>"(confirmcross_reviewerroute 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-reviewon 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_mcpmemory — 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-authdomain, 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.