mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-07 16:18:58 +00:00
255 lines
No EOL
40 KiB
Markdown
255 lines
No EOL
40 KiB
Markdown
# 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. |