sh-mcp/docs/g16-spike-design.md

212 lines
16 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.

# G16 Spike — Does an Agentforce Employee-Agent-in-Slack callout forward the *invoking user's* identity?
**Status:** Spike design, ready to run (2026-07-07). Unblocked by the 2026-07-07 resolution of the
Agentforce org-wide execution wall (Salesforce backend misconfig, fixed). This is the critical-path
experiment the `/sh-plan-review` gate (GPT-4.1) demanded be run **before** committing to the M2M pivot
in `docs/agentforce-auth-decision.md`. Throwaway kit — not production, not gated by the IAM/auth
cross-review.
---
## 0. Why this settles the whole auth-model question
The auth-model decision (`docs/agentforce-auth-decision.md`) hinges on **one unproven fact (G16)**:
when a Slack user talks to an Agentforce **Employee Agent** and the agent fires an External Service
callout, does the outbound request carry **that human's** identity, or a single **service** identity?
- The 0a spike already **proved the per-user OAuth chain** (Named Credential → Per-User Browser-Flow
External Credential → Cognito → endpoint delivers a valid per-user access JWT). **But every 0a probe
hit was a *direct* External Service test as *one* user** (logs show the same `sub`
`34f8a4e8-…` and the `sfdc_stack_depth` header) — **never an actual agent invocation**, because the
execution wall threw before any agent callout fired.
- So the *only* open question is the **agent-invocation hop**: per-user or service. That is now
directly testable. Its answer decides the architecture (see §7):
- **PASS (per-user forwarded)** → keep the simpler, already-proven **per-user D4**; shelve the M2M
pivot and its Apex-signer machinery.
- **FAIL (service identity)** → execute the **M2M + tool-band** plan.
Run this before deleting any per-user substrate.
---
## 1. Hypotheses (pre-registered)
- **H-PASS** — an agent callout invoked by user *U* arrives at the probe with `sub` == *U*'s own
consented identity; **≥2 distinct users → ≥2 distinct correct subs**.
- **H-FAIL** — all users' agent callouts arrive with the **same** principal (a service/Named identity),
i.e. user identity is lost inside the agent orchestration.
- **H-HEADER** — identity is **not** in the bearer token but **is** in a Salesforce-added request header
(a per-user id / signed assertion). Would open a *third* architecture option (service transport + a
trustworthy SF user header, simpler than the custom Apex-signer).
- **H-PARTIAL** — per-user, but `sub` is a Salesforce/Cognito-internal id, **not the Google Workspace
`sub`** (the CR-2 concern) — per-user still viable, add a `sub`↔Workspace validation step.
## 2. Decision matrix (decide the action BEFORE running, to avoid post-hoc rationalizing)
| Observed at the probe across ≥2 distinct invoking users | Verdict | Architecture decision |
|---|---|---|
| Distinct, correct per-user `sub` each time | **G16 PASS** | Keep **per-user D4** (proven + simpler). Shelve `agentforce-auth-decision.md`; finance audit falls out for free (real `sub`). |
| Same `sub` / same principal for every user | **G16 FAIL** | Execute the **M2M + tool-band** plan; the Apex-signer becomes the finance-audit mechanism. |
| Per-user identity present, but in a **header** not the token | **PASS-via-header** | New option: service transport + verified SF user header. Re-evaluate both docs. |
| Per-user `sub`, but not the Google Workspace `sub` | **PARTIAL** | Per-user viable; add `sub`↔Workspace mapping/validation (CR-2) before build. |
| Principal reverts to "Configured" / 401 after ~1h | (orthogonal) | Not a G16 verdict — token-survival bug; fixed by `offline_access` (controlled pre-spike, §4). |
## 3. The kit (reuse the live 0a kit — verified present 2026-07-07)
| Piece | Value |
|---|---|
| Cognito pool | `us-east-1_GsDbGe0pa` (`sh-mcp-0a-spike`, ESSENTIALS), IdP = **Cognito-native** |
| Hosted-UI domain / token endpoint | `sh-mcp-0a-spike-19568` → `https://sh-mcp-0a-spike-19568.auth.us-east-1.amazoncognito.com` |
| App client | `7kavkhm97etk00bfk7sjua2rfq` — flow `code`; scopes `openid email sh-mcp-ops/ops:read sh-mcp-ops/ops:tasks`; **no `offline_access`** (fix in §4) |
| Probe API | `ex1v1x1hqc` → `https://ex1v1x1hqc.execute-api.us-east-1.amazonaws.com/probe` |
| Probe Lambda | `sh-mcp-0a-spike-probe` (py3.12) — already logs `PROBE_RESULT {claims_of_interest, all_claims, seen_header_keys}` |
| Salesforce | **Prod `speed-speed-957`** (Adam's choice 2026-07-07 — no data to lose there, and it's the real Enterprise Grid, so the result is definitive; see venue note below). Objects `sh_mcp_0a`: Auth Provider / External Credential (Per-User) / Named Credential; External Service `shMcp0aProbe` → operation `identityProbe`. The prod callback URL is already on the app client. |
**Venue = prod `speed-speed-957` (definitive).** The Gemini-Q caveat (plan §0.3) was that a *free* Slack
workspace may forward identity differently than production Enterprise Grid + real licenses, so a sandbox
result would need a Grid re-confirm. Running in the **prod org on the real Grid removes that caveat** — a
prod PASS *or* FAIL is definitive. Requirement to keep it clean: wire the agent action to the **External
Service operation `identityProbe` directly (or via Flow), NOT an Apex `@InvocableMethod`**, so the "no Apex
authoring on an active prod org" gotcha never applies. If an Apex wrapper is unavoidable, deploy it via
metadata/change set (don't author in the prod Setup UI).
## 4. Pre-spike changes — AWS side **DONE 2026-07-07**
1. **Token survival past 1h — resolved at the AWS layer (no `offline_access` needed).** Cognito
**rejects `offline_access`** as a scope (`ScopeDoesNotExistException`); it issues a refresh token
automatically for the authorization-code grant. The app client already has `RefreshTokenValidity=30d`,
`ALLOW_REFRESH_TOKEN_AUTH`, and token revocation on — so the refresh token IS returned to Salesforce.
The 0a "reverts after 1h" symptom is therefore **Salesforce-side** (the Per-User External Credential
must store + use that refresh token), **not** an AWS gap. Verified by test **E3**; if it still reverts,
the fix is in the SF External Credential config, not here.
2. **Probe enhanced + deployed.** `sh-mcp-0a-spike-probe` now logs **full header names+values**
(`seen_headers`, with the bearer redacted to length only) and echoes an **`X-Probe-Tag`** header — so
a Salesforce-injected identity header (H-HEADER) can't hide and every invocation is correlatable to a
user. Smoke-tested green (custom headers captured, bearer shown as `Bearer <redacted len=N>`).
3. **Two labelled test users ready.** Both Cognito-native users have **known permanent passwords**, and
because each **username == its `sub`**, the mapping is self-checking (sign in as X → probe must show
`sub` X). Credentials handed to Adam separately (§5 / the summary).
4. **(Pass-2, optional, deferred) Re-federate the pool to Google** to confirm the arriving `sub` is the
real Workspace `sub` (settles H-PARTIAL / CR-2). Not needed for the primary PASS/FAIL — two distinct
Cognito-native users already discriminate per-user vs service.
## 4b. Salesforce build from scratch (prod `speed-speed-957`)
Assumes **no** `sh_mcp_0a` objects exist yet. Values to paste:
| Field | Value |
|---|---|
| Consumer Key (Cognito app client) | `2oqnqep996kvrh9u12jr774til` (confidential client `sh-mcp-0a-spike-sf`) |
| Consumer Secret | `t2vhiu7nsjvrecm65v12hhmfqiblac03rrv2rbggb680ktuqvu9` |
| Authorize URL | `https://sh-mcp-0a-spike-19568.auth.us-east-1.amazoncognito.com/oauth2/authorize` |
| Token URL | `https://sh-mcp-0a-spike-19568.auth.us-east-1.amazoncognito.com/oauth2/token` |
| User Info URL | `https://sh-mcp-0a-spike-19568.auth.us-east-1.amazoncognito.com/oauth2/userInfo` |
| Token Issuer (for validation) | `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_GsDbGe0pa` |
| Scopes | `openid email sh-mcp-ops/ops:read sh-mcp-ops/ops:tasks` |
| MCP base URL (Named Credential) | `https://ex1v1x1hqc.execute-api.us-east-1.amazonaws.com` |
| OpenAPI schema for the External Service | `docs/g16-probe.openapi.json` (path `/probe`, operation `identityProbe`) |
> **Cognito wrinkle:** the OAuth endpoints live on the hosted-UI **domain** (`…-19568.auth…`), but the
> token **issuer** is the `cognito-idp…/<poolId>` URL. Enter the explicit authorize/token/userinfo URLs
> above (don't rely on OIDC discovery, which points at the issuer host, not the domain host).
**A. Auth. Provider** — Setup → Auth. Providers → New. Type **OpenID Connect**; Name/URL-Suffix `sh_mcp_0a`;
Consumer Key/Secret + Authorize/Token/UserInfo URLs + Default Scopes from the table; Token Issuer as above.
Save, then **copy the generated Callback URL** — it should be
`https://speed-speed-957.my.salesforce.com/services/authcallback/sh_mcp_0a` (already registered on the
Cognito client). **If it differs, send it to me and I'll add it to Cognito.**
**B. External Credential** — Setup → Named Credentials → **External Credentials** → New. Label/Name
`sh_mcp_0a`; Authentication Protocol **OAuth 2.0**; Authentication Flow Type **Browser Flow** (reference the
`sh_mcp_0a` Auth Provider); add the scopes. Add a **Principal** with **Identity Type = Per User**.
**C. Named Credential** — Setup → Named Credentials → **Named Credentials** → New. Label/Name `sh_mcp_0a`;
URL `https://ex1v1x1hqc.execute-api.us-east-1.amazonaws.com`; External Credential `sh_mcp_0a`; enable
**Generate Authorization Header**.
**D. Permission Set** — create/choose a PS → **External Credential Principal Access** → add
`sh_mcp_0a — Per User`. Assign it to both test users. (Without this, users can't use the credential.)
**E. External Service** — Setup → **External Services** → New → From API Specification. Named Credential
`sh_mcp_0a`; paste `docs/g16-probe.openapi.json`. Confirm it registers operation **`identityProbe`**. This
creates the invocable action for Agentforce/Flow.
**F. Agent** — Agent Builder → open/create an **Employee Agent** (Slack). Add a **Topic + Action** that
calls the `identityProbe` External Service operation (keep the default **"Agent Router"** subagent).
Activate and **connect to Slack**.
**G. Per-user consent** — each user: personal **Settings → Authentication Settings for External Systems**
(or the External Credential principal → **Authenticate**) → at the Cognito hosted-UI login, sign in as
**User A** (user 1) / **User B** (user 2). Principal must read **Authenticated**.
## 5. Procedure (ordered; each step has a check)
**Setup**
- **S1.** Apply §4.1–§4.2. *Check:* `curl -H "Authorization: Bearer <hand-minted token>"` the `/probe`
endpoint → `PROBE_RESULT` shows the decoded claims + full headers + your `X-Probe-Tag`.
- **S2.** Ensure **two distinct test identities**, each able to (a) log into the pool as a distinct
Cognito user (distinct `sub`) and (b) invoke the Slack agent as a distinct Slack/Salesforce user:
create Cognito user **B** (user **A** = the existing `34f8a4e8-…`); a 2nd sandbox Salesforce user
mapped to a 2nd Slack user. *Check:* each can independently complete the per-user Browser-Flow consent
for the `sh_mcp_0a` External Credential (principal shows **Authenticated**).
- **S3.** In the sandbox, wire `shMcp0aProbe.identityProbe` as an **Agentforce action** on a deployed
**Employee Agent** in Slack; **keep the default "Agent Router" subagent** (deleting it breaks compile —
memory gotcha). *Check:* the agent is live in the Slack workspace and lists the probe action.
**Experiment**
- **E1 (User A).** In Slack, prompt the agent so it invokes the probe action (e.g. "run the identity
probe"). *Capture:* the `PROBE_RESULT` log line + the agent's returned body.
- **E2 (User B).** Same prompt, as User B. *Capture:* same.
- **E3 (token survival).** Re-run E1 **>1h** after A's consent → confirm the callout still succeeds
(validates `offline_access`).
- **E4 (autonomous, if feasible).** Trigger the same action from a **non-interactive** context
(scheduled/automated) → record whether the arriving principal is **distinguishable** from a human
(review QUESTION-6: a service context must not masquerade as a human).
**Read-out** — pull the results in one shot:
```
aws logs tail /aws/lambda/sh-mcp-0a-spike-probe --region us-east-1 --since 1h --format short \
| grep PROBE_RESULT
```
Tabulate per invocation: invoking user, `sub`, `client_id`, `token_use`, `scope`, any user-identifying
header, principal-still-Authenticated-after-1h. Compare against §2.
## 6. What we're measuring (the observables the probe already emits)
`sub` (the identity signal — access token has **no `aud`, no `email`**, memory-confirmed), `client_id`,
`token_use`, `scope`, `iss`, `iat`/`exp`; **all request headers** (post-§4.2) to catch header-borne
identity; whether the per-user principal survived >1h.
## 7a. Run outcome (2026-07-09) — programmatic Agent-API attempt, INCONCLUSIVE (leans FAIL)
Drove this entirely via SF CLI + AWS CLI (no browser, no 2nd human), using the Agent API instead of Slack:
- **Provisioning works** (`/agentforce/bootstrap/nameduser` → `sfap_api` JWT → `einstein/ai-agent/v1`). Built an
activated agent (`sh_mcp_g16c`, id `0XxhR0000000UnhSAE`) **purely via Metadata API** because CLI
`agent publish`/`agent create` and `aiAuthoringBundle` deploy are all broken on this trial org.
- **Wall:** the planner returns `result: []` — narrates running the probe but never executes the Apex
(Task-DML sink + unauth beacon + per-user callout all confirm zero execution). The fix — `GenAiFunction`
`input/output/schema.json` — **server-errors on deploy** (`ErrorId 44568769-691223`). Same degraded-runtime
signature as the broken publish/create APIs.
- **Direct callout still carries the real per-user `sub`** (baseline good), and found a **live token-refresh
bug**: the Per-User External Credential served a **44h-expired** token (test E3, live).
- **Verdict: LEANS FAIL (service identity)** on architecture (agent mandates `default_agent_user`; human rides
as context vars only), but the decisive `UserInfo`/`Task.CreatedById` observable was never captured because
the action wouldn't fire. Committed M2M plan already assumes FAIL. **Definitive confirm still needs the Slack
path (§4b F–G, 2 real users) OR a SF-support fix for GenAiFunction schema deploy on this org.** Instrumentation
is deployed and ready (`IdentityProbeAction` logs `PROBE_RUNAS`, inserts `G16RUNAS` Task, beacons UserInfo).
## 7. Outcome → next action (closes the loop with the plan)
- **PASS** → update `project_sh_mcp` memory + mark `agentforce-auth-decision.md` **superseded/shelved**;
proceed on **per-user D4**; the M2M pivot is unnecessary.
- **FAIL** → **that doc becomes the committed plan**; run its Phase A–F (which the `/sh-plan-review`
FIXes still apply to: provisioning order, rollback branch, cross-review gating, docs).
- **PASS-via-header / PARTIAL** → record the third option / CR-2 follow-up; re-decide.
- Either way: write the result + the exact probe evidence into `project_sh_mcp` memory the same day.
## 8. Time-box, ownership, teardown
- **Time-box:** 1–2 days; most cost is the Salesforce agent + 2nd-user setup, not AWS.
- **I can do:** the §4 AWS-side changes (add `offline_access`, enhance the probe) and the read-out.
**Adam/hands-on:** the Salesforce sandbox agent wiring, the two real Slack test users, the consents,
and the Enterprise-Grid re-confirm on a PASS.
- **Teardown:** re-authored as **`docs/g16-spike-teardown.sh`** (`bash docs/g16-spike-teardown.sh --yes`)
— tears down the Cognito pool/domain/users, the HTTP API, and the probe Lambda/role/log group; the
Salesforce `sh_mcp_0a` objects are a manual delete in both orgs. Keep the kit live until the verdict is
recorded in `project_sh_mcp` memory.