diff --git a/CLAUDE.md b/CLAUDE.md index e28305f..7b38670 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,13 @@ Multi-module Go workspace with three modules: - `forge-cli/` — CLI commands, TUI wizard, runtime - `forge-plugins/` — Channel plugins (telegram, slack), markdown converter +## Platform-integration contracts (used by initializ AIP) + +- **Tenancy headers**: every forge→platform HTTP callout (admission, remote session store, MCP platform token resolver) MUST send `Org-Id` + `Workspace-Id` from `FORGE_ORG_ID`/`FORGE_WORKSPACE_ID` env alongside `Authorization: Bearer ${FORGE_PLATFORM_TOKEN}` — the platform verifies a PER-ORG HS256 token and needs Org-Id to pick the signing secret BEFORE it can validate the bearer (omitting it → 401 "missing org-id header"). Pattern lives in `admission_loader.go` / `remote_session_store.go` / `mcp/platform_token.go`. +- **MCP auth types** (`auth.type`): `oauth`/`bearer`/`static` + managed `platform` (agent-principal token from `ForgeConfig.Platform.token_endpoint`) and `user` (delegated, lazy — cannot be `required:true`). The platform owns the op→identity split; forge only reads per-server config. +- **DEFER approval is FULLY BUILT & enforced** (`forge-core/security/deferpolicy/`, Slack Block-Kit delivery in `forge-plugins/channels/slack/approvals.go`): `security.defer.tools.{to: channel:slack:#x, approvers, timeout}` — keyed by the RUNTIME tool name, which for MCP is **`__`**. Guardrails `approvalGates` are parsed but NOT enforced — use `security.defer.*`. +- **MCP OAuth discovery/DCR** (`forge-core/mcp/oauth_discovery.go`, #316/#320): RFC 9728→8414→7591. `wellKnown()` must INSERT the well-known segment for path-qualified issuers (Atlassian `auth.atlassian.com/`) — replacing the path resolves the wrong AS. + ## Pre-Commit Requirements **Always run before committing:** diff --git a/forge-core/mcp/platform_token.go b/forge-core/mcp/platform_token.go index c787715..ec7990f 100644 --- a/forge-core/mcp/platform_token.go +++ b/forge-core/mcp/platform_token.go @@ -98,6 +98,19 @@ func (p *platformTokenSource) fetch(ctx context.Context) (string, time.Duration, req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+identity) + // Tenancy headers, exactly as the admission + remote-session-store + // clients send them: the platform verifies a PER-ORG HS256 token, so it + // needs Org-Id to select the signing secret BEFORE it can validate the + // bearer (without this the endpoint 401s "missing org-id header"), and + // Workspace-Id to authorize the request against the entry (entitlement). + // Read from the standard FORGE_ORG_ID / FORGE_WORKSPACE_ID env the + // platform always injects; omitted when empty (standalone/dev). + if org := os.Getenv("FORGE_ORG_ID"); org != "" { + req.Header.Set("Org-Id", org) + } + if ws := os.Getenv("FORGE_WORKSPACE_ID"); ws != "" { + req.Header.Set("Workspace-Id", ws) + } client := p.client if client == nil { diff --git a/forge-core/mcp/platform_token_test.go b/forge-core/mcp/platform_token_test.go index 8d15f32..bdd73e6 100644 --- a/forge-core/mcp/platform_token_test.go +++ b/forge-core/mcp/platform_token_test.go @@ -16,9 +16,19 @@ import ( // server ref, and can emit a (forbidden) refresh_token. func newFakeResolver(t *testing.T, expiresIn int, includeRefresh bool) (*httptest.Server, *int) { t.Helper() + t.Setenv("FORGE_ORG_ID", "org-42") + t.Setenv("FORGE_WORKSPACE_ID", "ws-7") hits := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ + // Tenancy headers must ride along so the platform can select the + // per-org HS256 secret + authorize the workspace (the "missing + // org-id header" 401 fix). + if r.Header.Get("Org-Id") != "org-42" || r.Header.Get("Workspace-Id") != "ws-7" { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "missing org-id header"}) + return + } if r.Header.Get("Authorization") != "Bearer agent-cred-1" { w.WriteHeader(http.StatusUnauthorized) return