From 6f99ce500ad3a6421eb37f4f5479402af8d352a4 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 14:05:19 -0400 Subject: [PATCH 1/2] feat(identity): present per-agent workload token on platform callouts (#444 item 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-identity L1: when an agent runs with WORKLOAD_IDENTITY_MODE=k8s_sa, agent-builder projects an audience-bound, kubelet-rotated ServiceAccount token into a file. Forge now presents it as the X-Workload-Token header on every platform-authenticated callout, so the platform's §19.13 per-agent entitlement check can bind the call to the agent's workload identity (an agent bound to svc-runbooks can no longer fetch svc-security's token). This unblocks agent-builder flipping WORKLOAD_ENTITLEMENT_ENFORCE on. - New forge-core/runtime helper WorkloadToken() / StampWorkloadToken(): reads the token FRESH per request from INITIALIZ_WORKLOAD_TOKEN_PATH (default /var/run/secrets/initializ.ai/workload/token), NEVER cached (kubelet rotates in place; caching is the no_token failure class). Gated on WORKLOAD_IDENTITY_MODE=k8s_sa; header omitted (not sent empty) when inactive, matching the Org-Id/Workspace-Id tenancy contract. - Stamped at all five platform-callout sites: MCP platform token + authorize resolvers, remote session store, admission checker, PDP. - forge does not mint — the audience (initializ:platform-token-endpoint) is baked into the projected token by the kubelet. Tests: helper unit tests (fresh-read/no-cache, mode gate, missing/empty file, omit-when-inactive) + wire-level assertions on the platform token endpoint (header present when active, absent when inactive). Docs: tenancy.md platform-callouts section + environment-variables.md. Scope: item 1 of the #444 epic (workload-token presentation). Items 2-7 (PDP/audit identity fields, typ discipline, ChainContext, egress-proxy chain, SPIRE, release) remain. Claude-Session: https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ --- docs/reference/environment-variables.md | 2 + docs/security/tenancy.md | 8 +- forge-cli/runtime/admission_engine.go | 3 + forge-cli/runtime/pdp_resolver.go | 3 + forge-core/mcp/platform_token.go | 10 ++ forge-core/mcp/platform_token_test.go | 56 +++++++++++ forge-core/runtime/remote_session_store.go | 3 + forge-core/runtime/workload_token.go | 81 ++++++++++++++++ forge-core/runtime/workload_token_test.go | 104 +++++++++++++++++++++ 9 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 forge-core/runtime/workload_token.go create mode 100644 forge-core/runtime/workload_token_test.go diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 349175db..d27c69b3 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -32,6 +32,8 @@ order: 3 | `FORGE_AGENT_ID` | Agent identifier for audit entity identity (falls back to `agent_id` in YAML) | | `FORGE_ORG_ID` | Organization identifier for tenancy stamping / audit | | `FORGE_PASSPHRASE` | Passphrase for encrypted secrets file | +| `WORKLOAD_IDENTITY_MODE` | Set to `k8s_sa` to present the per-agent workload token (`X-Workload-Token`) on platform callouts (agent-identity L1). Unset/other → not presented. See [Tenancy → platform callouts](../security/tenancy.md#outbound-propagation-platform-callouts) | +| `INITIALIZ_WORKLOAD_TOKEN_PATH` | Path to the projected ServiceAccount token file read (fresh, uncached) when `WORKLOAD_IDENTITY_MODE=k8s_sa`. Default `/var/run/secrets/initializ.ai/workload/token` | ## Audit diff --git a/docs/security/tenancy.md b/docs/security/tenancy.md index c6ade241..88641a90 100644 --- a/docs/security/tenancy.md +++ b/docs/security/tenancy.md @@ -82,7 +82,13 @@ Auto-propagation is NOT built into the egress proxy. The agent only propagates t ## Outbound propagation (platform callouts) -Every Forge→**platform** HTTP callout — admission (`FORGE_ADMISSION_URL`), the remote session store, and the MCP platform token / authorize endpoints (`type: platform` / `type: user`) — sends `Org-Id` + `Workspace-Id` headers (from `FORGE_ORG_ID` / `FORGE_WORKSPACE_ID`) alongside `Authorization: Bearer ${FORGE_PLATFORM_TOKEN}`. This is a hard contract, not best-effort: the platform verifies a **per-org** HS256 token and needs `Org-Id` to select the signing secret *before* it can validate the bearer — omitting it returns `401 "missing org-id header"`. Note the header spelling here is `Org-Id` / `Workspace-Id` (the platform-callout convention), distinct from the inbound `X-Forge-Org-ID` / `X-Forge-Workspace-ID` request-override headers above. +Every Forge→**platform** HTTP callout — admission (`FORGE_ADMISSION_URL`), the remote session store, the PDP (`POST /pdp/decide`), and the MCP platform token / authorize endpoints (`type: platform` / `type: user`) — sends `Org-Id` + `Workspace-Id` headers (from `FORGE_ORG_ID` / `FORGE_WORKSPACE_ID`) alongside `Authorization: Bearer ${FORGE_PLATFORM_TOKEN}`. This is a hard contract, not best-effort: the platform verifies a **per-org** HS256 token and needs `Org-Id` to select the signing secret *before* it can validate the bearer — omitting it returns `401 "missing org-id header"`. Note the header spelling here is `Org-Id` / `Workspace-Id` (the platform-callout convention), distinct from the inbound `X-Forge-Org-ID` / `X-Forge-Workspace-ID` request-override headers above. + +### Workload-identity presentation — `X-Workload-Token` (agent-identity L1) + +When an agent is deployed with **`WORKLOAD_IDENTITY_MODE=k8s_sa`**, the platform (agent-builder) provisions a per-agent Kubernetes ServiceAccount and projects an audience-bound, kubelet-rotated SA token into a file (default `/var/run/secrets/initializ.ai/workload/token`, override via `INITIALIZ_WORKLOAD_TOKEN_PATH`; audience `initializ:platform-token-endpoint`). Every platform callout above **additionally** sends that token as the **`X-Workload-Token`** header, so the platform's per-agent entitlement check can bind the call to the agent's workload identity — e.g. an agent bound to `svc-runbooks` can no longer fetch `svc-security`'s token. + +The token is read **fresh from the file on every request and never cached** — the kubelet rotates the file in place, so a cached value goes stale and is rejected (the `no_token` failure class). Like the tenancy headers, `X-Workload-Token` is **omitted entirely** (never sent empty) when workload identity is inactive: no `WORKLOAD_IDENTITY_MODE=k8s_sa`, or no readable token file (the normal case for self-hosted / non-Kubernetes deploys). Presentation is additive and never blocks a callout. ## Backwards compatibility diff --git a/forge-cli/runtime/admission_engine.go b/forge-cli/runtime/admission_engine.go index 34dbe0b2..0c3f75c6 100644 --- a/forge-cli/runtime/admission_engine.go +++ b/forge-cli/runtime/admission_engine.go @@ -200,6 +200,9 @@ func (c *PlatformAdmissionChecker) fetchDecision(ctx context.Context) coreruntim if c.workspaceID != "" { req.Header.Set("Workspace-Id", c.workspaceID) } + // Present the per-agent workload token (agent-identity L1, #444); read + // fresh per request, omitted when workload identity is not active. + coreruntime.StampWorkloadToken(req.Header) resp, err := c.client.Do(req) if err != nil { diff --git a/forge-cli/runtime/pdp_resolver.go b/forge-cli/runtime/pdp_resolver.go index 1df3f193..a629732b 100644 --- a/forge-cli/runtime/pdp_resolver.go +++ b/forge-cli/runtime/pdp_resolver.go @@ -204,6 +204,9 @@ func (p *pdpResolver) Resolve(ctx context.Context, hctx *coreruntime.HookContext if p.workspaceID != "" { req.Header.Set("Workspace-Id", p.workspaceID) } + // Present the per-agent workload token (agent-identity L1, #444); read + // fresh per request, omitted when workload identity is not active. + coreruntime.StampWorkloadToken(req.Header) var doErr error resp, doErr = p.client.Do(req) diff --git a/forge-core/mcp/platform_token.go b/forge-core/mcp/platform_token.go index 6b235afb..540b6a2c 100644 --- a/forge-core/mcp/platform_token.go +++ b/forge-core/mcp/platform_token.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/initializ/forge/forge-core/runtime" ) // Platform token resolver — the managed half of the resolver seam (design @@ -154,6 +156,11 @@ func doPlatformTokenRequest(ctx context.Context, client *http.Client, rawEndpoin if ws := os.Getenv("FORGE_WORKSPACE_ID"); ws != "" { req.Header.Set("Workspace-Id", ws) } + // Present the per-agent workload token (agent-identity L1, #444) so the + // platform's §19.13 entitlement check can bind this token fetch to the + // agent's workload identity. Read fresh per request; omitted when workload + // identity is not active. + runtime.StampWorkloadToken(req.Header) if client == nil { client = &http.Client{Timeout: 15 * time.Second} @@ -221,6 +228,9 @@ func FetchAuthorizeURL(ctx context.Context, client *http.Client, rawEndpoint, ra if ws := os.Getenv("FORGE_WORKSPACE_ID"); ws != "" { req.Header.Set("Workspace-Id", ws) } + // Present the per-agent workload token (agent-identity L1, #444); read + // fresh per request, omitted when workload identity is not active. + runtime.StampWorkloadToken(req.Header) if client == nil { client = &http.Client{Timeout: 15 * time.Second} } diff --git a/forge-core/mcp/platform_token_test.go b/forge-core/mcp/platform_token_test.go index beb0fef2..f12fdddf 100644 --- a/forge-core/mcp/platform_token_test.go +++ b/forge-core/mcp/platform_token_test.go @@ -6,9 +6,12 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "time" + "github.com/initializ/forge/forge-core/runtime" "github.com/initializ/forge/forge-core/types" ) @@ -77,6 +80,59 @@ func TestPlatformTokenSource_FetchCacheAndIgnoreRefresh(t *testing.T) { } } +// Agent-identity L1 (#444 item 1): when workload identity is active the token +// fetch carries X-Workload-Token = the projected SA token, so the platform's +// §19.13 entitlement check can bind the fetch to the agent's workload identity. +func TestPlatformTokenSource_PresentsWorkloadToken(t *testing.T) { + var gotWorkload string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotWorkload = r.Header.Get(runtime.HeaderWorkloadToken) + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "at-1", "expires_in": 3600}) + })) + t.Cleanup(srv.Close) + + tokenPath := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenPath, []byte("workload-jwt-1\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + t.Setenv(runtime.EnvWorkloadIdentityMode, runtime.WorkloadIdentityModeK8sSA) + t.Setenv(runtime.EnvWorkloadTokenPath, tokenPath) + + src := newPlatformTokenSource(PlatformSourceConfig{ + TokenEndpoint: srv.URL, AgentIdentity: "agent-cred-1", + Ref: "mcp.atlassian", HTTPClient: srv.Client(), + }) + if _, err := src.Token(context.Background()); err != nil { + t.Fatalf("token: %v", err) + } + if gotWorkload != "workload-jwt-1" { + t.Errorf("%s = %q, want workload-jwt-1", runtime.HeaderWorkloadToken, gotWorkload) + } +} + +// Without workload identity active, the token fetch must NOT carry the header +// (self-hosted / non-k8s_sa deploys). +func TestPlatformTokenSource_OmitsWorkloadTokenWhenInactive(t *testing.T) { + var present bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, present = r.Header[http.CanonicalHeaderKey(runtime.HeaderWorkloadToken)] + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "at-1", "expires_in": 3600}) + })) + t.Cleanup(srv.Close) + t.Setenv(runtime.EnvWorkloadIdentityMode, "") // not a workload-identity deploy + + src := newPlatformTokenSource(PlatformSourceConfig{ + TokenEndpoint: srv.URL, AgentIdentity: "agent-cred-1", + Ref: "mcp.atlassian", HTTPClient: srv.Client(), + }) + if _, err := src.Token(context.Background()); err != nil { + t.Fatalf("token: %v", err) + } + if present { + t.Errorf("%s must be omitted when workload identity is inactive", runtime.HeaderWorkloadToken) + } +} + // Expiry triggers a re-fetch (the skew makes a short-TTL token immediately // stale). func TestPlatformTokenSource_RefetchOnExpiry(t *testing.T) { diff --git a/forge-core/runtime/remote_session_store.go b/forge-core/runtime/remote_session_store.go index d393d219..8c951ff9 100644 --- a/forge-core/runtime/remote_session_store.go +++ b/forge-core/runtime/remote_session_store.go @@ -261,6 +261,9 @@ func (r *RemoteSessionStore) setHeaders(req *http.Request) { if r.workspaceID != "" { req.Header.Set("Workspace-Id", r.workspaceID) } + // Present the per-agent workload token (agent-identity L1, #444); read + // fresh per request, omitted when workload identity is not active. + StampWorkloadToken(req.Header) } func (r *RemoteSessionStore) remember(taskID, version string, data *SessionData) { diff --git a/forge-core/runtime/workload_token.go b/forge-core/runtime/workload_token.go new file mode 100644 index 00000000..ca4dfc57 --- /dev/null +++ b/forge-core/runtime/workload_token.go @@ -0,0 +1,81 @@ +package runtime + +import ( + "net/http" + "os" + "strings" +) + +// Agent workload-identity presentation (agent-identity L1, issue #444, item 1). +// +// When an agent is deployed with WORKLOAD_IDENTITY_MODE=k8s_sa, agent-builder +// provisions a per-agent Kubernetes ServiceAccount and projects an +// audience-bound, kubelet-rotated SA token into a file. Forge presents that +// token on platform-authenticated callouts via the X-Workload-Token header so +// the platform's per-agent entitlement check (§19.13) can bind the call to the +// agent's workload identity — e.g. an agent bound to svc-runbooks can no longer +// fetch svc-security's token. +// +// The token's audience (initializ:platform-token-endpoint) is baked in by the +// kubelet projection; forge only reads and forwards the file, it does not mint. +const ( + // EnvWorkloadIdentityMode gates presentation. Only "k8s_sa" is handled + // today; SPIRE (attested:workload) is a later phase (#444 item 6). + EnvWorkloadIdentityMode = "WORKLOAD_IDENTITY_MODE" + + // EnvWorkloadTokenPath overrides the projected-token file location. + EnvWorkloadTokenPath = "INITIALIZ_WORKLOAD_TOKEN_PATH" + + // DefaultWorkloadTokenPath is where agent-builder projects the token. + DefaultWorkloadTokenPath = "/var/run/secrets/initializ.ai/workload/token" + + // HeaderWorkloadToken carries the projected SA token to the platform. + HeaderWorkloadToken = "X-Workload-Token" + + // WorkloadIdentityModeK8sSA is the k8s ServiceAccount-token mode. + WorkloadIdentityModeK8sSA = "k8s_sa" +) + +// WorkloadToken reads the projected ServiceAccount token FRESH from the +// configured path and returns it, or "" when workload identity is not active +// or no token is available. +// +// It is read on every call and never cached: the kubelet rotates the file in +// place, so a cached value goes stale and the platform's TokenReview rejects it +// (the "no_token" failure class the L1 contract warns about). +// +// Returns "" (header omitted downstream) when: +// - WORKLOAD_IDENTITY_MODE != "k8s_sa" (not a workload-identity deployment), or +// - the token file is absent, unreadable, or empty. +// +// The empty case is the normal path for non-k8s_sa deployments; presentation is +// additive and never blocks a callout. +func WorkloadToken() string { + if os.Getenv(EnvWorkloadIdentityMode) != WorkloadIdentityModeK8sSA { + return "" + } + path := os.Getenv(EnvWorkloadTokenPath) + if path == "" { + path = DefaultWorkloadTokenPath + } + b, err := os.ReadFile(path) + if err != nil { + return "" + } + // Projected token files carry a trailing newline; trim so the header value + // is the bare JWT. + return strings.TrimSpace(string(b)) +} + +// StampWorkloadToken sets the X-Workload-Token header from the freshly-read +// projected SA token. When no token is available it leaves the header unset — +// matching the tenancy-header contract (Org-Id/Workspace-Id are omitted rather +// than sent empty), so the platform distinguishes "unset" from "empty". +// +// Call this at every platform-authenticated callout, right after the +// Authorization + tenancy headers are stamped. +func StampWorkloadToken(h http.Header) { + if tok := WorkloadToken(); tok != "" { + h.Set(HeaderWorkloadToken, tok) + } +} diff --git a/forge-core/runtime/workload_token_test.go b/forge-core/runtime/workload_token_test.go new file mode 100644 index 00000000..ea901e95 --- /dev/null +++ b/forge-core/runtime/workload_token_test.go @@ -0,0 +1,104 @@ +package runtime + +import ( + "net/http" + "os" + "path/filepath" + "testing" +) + +// Regression tests for agent-identity L1 workload-token presentation +// (#444 item 1): the token is read FRESH per call from the projected file, +// never cached, gated on WORKLOAD_IDENTITY_MODE=k8s_sa, and the header is +// omitted (not sent empty) when unavailable. + +func writeTokenFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + return path +} + +func TestWorkloadToken_ReadsFreshWhenK8sSAModeAndFilePresent(t *testing.T) { + path := writeTokenFile(t, "jwt-abc\n") // projected files carry a trailing newline + t.Setenv(EnvWorkloadIdentityMode, WorkloadIdentityModeK8sSA) + t.Setenv(EnvWorkloadTokenPath, path) + + if got := WorkloadToken(); got != "jwt-abc" { + t.Errorf("WorkloadToken() = %q, want %q (trailing newline trimmed)", got, "jwt-abc") + } +} + +func TestWorkloadToken_EmptyWhenModeNotK8sSA(t *testing.T) { + path := writeTokenFile(t, "jwt-abc") + t.Setenv(EnvWorkloadTokenPath, path) + // Mode unset — not a workload-identity deployment. + t.Setenv(EnvWorkloadIdentityMode, "") + if got := WorkloadToken(); got != "" { + t.Errorf("WorkloadToken() = %q, want \"\" when mode != k8s_sa (must not read the file)", got) + } + // A different (future SPIRE) mode is also not handled by this path. + t.Setenv(EnvWorkloadIdentityMode, "attested_workload") + if got := WorkloadToken(); got != "" { + t.Errorf("WorkloadToken() = %q, want \"\" for non-k8s_sa mode", got) + } +} + +func TestWorkloadToken_EmptyWhenFileMissing(t *testing.T) { + t.Setenv(EnvWorkloadIdentityMode, WorkloadIdentityModeK8sSA) + t.Setenv(EnvWorkloadTokenPath, filepath.Join(t.TempDir(), "does-not-exist")) + if got := WorkloadToken(); got != "" { + t.Errorf("WorkloadToken() = %q, want \"\" when the token file is absent", got) + } +} + +func TestWorkloadToken_EmptyWhenFileEmpty(t *testing.T) { + t.Setenv(EnvWorkloadIdentityMode, WorkloadIdentityModeK8sSA) + t.Setenv(EnvWorkloadTokenPath, writeTokenFile(t, " \n")) + if got := WorkloadToken(); got != "" { + t.Errorf("WorkloadToken() = %q, want \"\" for a whitespace-only file", got) + } +} + +func TestWorkloadToken_ReadsFreshOnEachCall_NoCaching(t *testing.T) { + // The kubelet rotates the projected file in place; a cached value goes + // stale and the platform's TokenReview rejects it. Rewriting the file + // between calls must be observed immediately. + path := writeTokenFile(t, "jwt-v1") + t.Setenv(EnvWorkloadIdentityMode, WorkloadIdentityModeK8sSA) + t.Setenv(EnvWorkloadTokenPath, path) + + if got := WorkloadToken(); got != "jwt-v1" { + t.Fatalf("first read = %q, want jwt-v1", got) + } + if err := os.WriteFile(path, []byte("jwt-v2"), 0o600); err != nil { + t.Fatalf("rotate token: %v", err) + } + if got := WorkloadToken(); got != "jwt-v2" { + t.Errorf("second read = %q, want jwt-v2 — token must be read fresh, never cached", got) + } +} + +func TestStampWorkloadToken_SetsHeaderWhenAvailable(t *testing.T) { + t.Setenv(EnvWorkloadIdentityMode, WorkloadIdentityModeK8sSA) + t.Setenv(EnvWorkloadTokenPath, writeTokenFile(t, "jwt-xyz\n")) + + h := http.Header{} + StampWorkloadToken(h) + if got := h.Get(HeaderWorkloadToken); got != "jwt-xyz" { + t.Errorf("%s = %q, want jwt-xyz", HeaderWorkloadToken, got) + } +} + +func TestStampWorkloadToken_OmitsHeaderWhenUnavailable(t *testing.T) { + // Non-k8s_sa deployment: the header must be ABSENT, not present-and-empty, + // matching the Org-Id/Workspace-Id tenancy-header contract. + t.Setenv(EnvWorkloadIdentityMode, "") + h := http.Header{} + StampWorkloadToken(h) + if _, present := h[http.CanonicalHeaderKey(HeaderWorkloadToken)]; present { + t.Errorf("%s must be omitted entirely when no workload token is available", HeaderWorkloadToken) + } +} From a986bb47ca2914d8a6594b34d3cb583d4ffa3d3a Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 15:52:14 -0400 Subject: [PATCH 2/2] review(identity): bound token read + per-site wire pins (#445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the #445 review's two change requests: 1. Bound the projected-token read: os.Open + io.LimitReader capped at 8 KiB (a JWT is ~1 KB). An oversized/misconfigured file now fails closed to "" instead of loading an arbitrarily large header value. Tests: oversized file → "", token exactly at the cap still reads. 2. Per-site wire pins so the N-of-N stamp surface can't silently lose a site in a refactor. Added "header present when active" assertions at the four sites that lacked one: admission + PDP (forge-cli), remote session store, and the authorize-URL resolver (forge-core). The platform-token endpoint already had present/absent wire tests. golangci-lint clean; runtime/mcp/cli-runtime suites pass. Claude-Session: https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ --- .../workload_token_presentation_test.go | 62 +++++++++++++++++++ forge-core/mcp/platform_authorize_test.go | 31 ++++++++++ .../remote_session_store_workload_test.go | 34 ++++++++++ forge-core/runtime/workload_token.go | 17 ++++- forge-core/runtime/workload_token_test.go | 25 ++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 forge-cli/runtime/workload_token_presentation_test.go create mode 100644 forge-core/runtime/remote_session_store_workload_test.go diff --git a/forge-cli/runtime/workload_token_presentation_test.go b/forge-cli/runtime/workload_token_presentation_test.go new file mode 100644 index 00000000..408da9b0 --- /dev/null +++ b/forge-cli/runtime/workload_token_presentation_test.go @@ -0,0 +1,62 @@ +package runtime + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + coreruntime "github.com/initializ/forge/forge-core/runtime" +) + +// Per-site wire pins for X-Workload-Token (agent-identity L1, #444, PR #445 +// review): each platform callout is an N-of-N surface, so a future refactor +// dropping StampWorkloadToken at one site must fail a test HERE, not just the +// shared-helper unit tests. These cover the two forge-cli callouts (admission, +// PDP); the platform-token, authorize-URL, and remote-session sites are pinned +// in forge-core. + +// activateWorkloadToken points the reader at a temp token file in k8s_sa mode +// and returns the token value the server should observe. +func activateWorkloadToken(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte("wl-token\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + t.Setenv(coreruntime.EnvWorkloadIdentityMode, coreruntime.WorkloadIdentityModeK8sSA) + t.Setenv(coreruntime.EnvWorkloadTokenPath, path) + return "wl-token" +} + +func TestPlatformAdmissionChecker_PresentsWorkloadToken(t *testing.T) { + want := activateWorkloadToken(t) + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get(coreruntime.HeaderWorkloadToken) + _, _ = w.Write([]byte(`{"decision":"admit"}`)) + })) + defer srv.Close() + + NewPlatformAdmissionChecker(srv.URL, "ag", "org-7", "ws-3", "tok", nil).Admit(context.Background()) + if got != want { + t.Errorf("admission callout %s = %q, want %q", coreruntime.HeaderWorkloadToken, got, want) + } +} + +func TestPDPResolver_PresentsWorkloadToken(t *testing.T) { + want := activateWorkloadToken(t) + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get(coreruntime.HeaderWorkloadToken) + writeEnvelope(w, `{"decision":"allow","reason":"ok","policy_version":1}`) + })) + defer srv.Close() + + testResolver(srv.URL).Resolve(context.Background(), hctx("svc__op", `{}`)) + if got != want { + t.Errorf("PDP callout %s = %q, want %q", coreruntime.HeaderWorkloadToken, got, want) + } +} diff --git a/forge-core/mcp/platform_authorize_test.go b/forge-core/mcp/platform_authorize_test.go index 298151c8..652f8719 100644 --- a/forge-core/mcp/platform_authorize_test.go +++ b/forge-core/mcp/platform_authorize_test.go @@ -6,7 +6,11 @@ import ( "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" + + "github.com/initializ/forge/forge-core/runtime" ) func TestFetchAuthorizeURL(t *testing.T) { @@ -36,6 +40,33 @@ func TestFetchAuthorizeURL(t *testing.T) { } }) + t.Run("presents workload token when active", func(t *testing.T) { + // Per-site wire pin (agent-identity L1, #444, PR #445 review): the + // authorize-URL callout also carries X-Workload-Token. The SA token + // goes to the PLATFORM (which returns the third-party consent URL), + // never to the MCP server. + tokPath := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokPath, []byte("wl-authz\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + t.Setenv(runtime.EnvWorkloadIdentityMode, runtime.WorkloadIdentityModeK8sSA) + t.Setenv(runtime.EnvWorkloadTokenPath, tokPath) + + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get(runtime.HeaderWorkloadToken) + _ = json.NewEncoder(w).Encode(map[string]any{"authorize_url": "https://idp.example/authorize"}) + })) + defer srv.Close() + + if _, err := FetchAuthorizeURL(context.Background(), srv.Client(), srv.URL, "agent-cred", "mcp.atlassian", "alice@corp.com"); err != nil { + t.Fatalf("FetchAuthorizeURL: %v", err) + } + if got != "wl-authz" { + t.Errorf("authorize callout %s = %q, want wl-authz", runtime.HeaderWorkloadToken, got) + } + }) + t.Run("empty subject is ErrNoToken", func(t *testing.T) { if _, err := FetchAuthorizeURL(context.Background(), http.DefaultClient, "https://x", "id", "ref", ""); !errors.Is(err, ErrNoToken) { t.Fatalf("empty subject err = %v, want ErrNoToken", err) diff --git a/forge-core/runtime/remote_session_store_workload_test.go b/forge-core/runtime/remote_session_store_workload_test.go new file mode 100644 index 00000000..709b6e28 --- /dev/null +++ b/forge-core/runtime/remote_session_store_workload_test.go @@ -0,0 +1,34 @@ +package runtime + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// Per-site wire pin for X-Workload-Token on the remote session store +// (agent-identity L1, #444, PR #445 review). setHeaders is shared across +// Load/Save/Delete, so pinning one path guards them all. +func TestRemoteSessionStore_PresentsWorkloadToken(t *testing.T) { + tokPath := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokPath, []byte("wl-sess\n"), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } + t.Setenv(EnvWorkloadIdentityMode, WorkloadIdentityModeK8sSA) + t.Setenv(EnvWorkloadTokenPath, tokPath) + + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get(HeaderWorkloadToken) + w.WriteHeader(http.StatusNotFound) // Load treats 404 as "no session" (nil, nil) + })) + defer srv.Close() + + // Load stamps setHeaders before the GET; the 404 return is irrelevant. + _, _ = newTestRemoteStore(srv.URL).Load("task-x") + if got != "wl-sess" { + t.Errorf("session-store callout %s = %q, want wl-sess", HeaderWorkloadToken, got) + } +} diff --git a/forge-core/runtime/workload_token.go b/forge-core/runtime/workload_token.go index ca4dfc57..980d7a25 100644 --- a/forge-core/runtime/workload_token.go +++ b/forge-core/runtime/workload_token.go @@ -1,6 +1,7 @@ package runtime import ( + "io" "net/http" "os" "strings" @@ -34,6 +35,12 @@ const ( // WorkloadIdentityModeK8sSA is the k8s ServiceAccount-token mode. WorkloadIdentityModeK8sSA = "k8s_sa" + + // maxWorkloadTokenBytes bounds the projected-token read. A projected JWT + // is ~1 KB; the path is operator-controlled, so cap the read and fail + // closed to "" on an oversized/misconfigured file rather than loading an + // arbitrarily large value into an outbound header. + maxWorkloadTokenBytes = 8 << 10 // 8 KiB ) // WorkloadToken reads the projected ServiceAccount token FRESH from the @@ -58,10 +65,18 @@ func WorkloadToken() string { if path == "" { path = DefaultWorkloadTokenPath } - b, err := os.ReadFile(path) + f, err := os.Open(path) if err != nil { return "" } + defer func() { _ = f.Close() }() + // Bounded read: one byte past the cap so an oversized file is detectable + // and rejected (fail closed to "") rather than streamed wholesale into the + // header. + b, err := io.ReadAll(io.LimitReader(f, maxWorkloadTokenBytes+1)) + if err != nil || len(b) > maxWorkloadTokenBytes { + return "" + } // Projected token files carry a trailing newline; trim so the header value // is the bare JWT. return strings.TrimSpace(string(b)) diff --git a/forge-core/runtime/workload_token_test.go b/forge-core/runtime/workload_token_test.go index ea901e95..f1ec4ba1 100644 --- a/forge-core/runtime/workload_token_test.go +++ b/forge-core/runtime/workload_token_test.go @@ -4,6 +4,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "testing" ) @@ -81,6 +82,30 @@ func TestWorkloadToken_ReadsFreshOnEachCall_NoCaching(t *testing.T) { } } +func TestWorkloadToken_EmptyWhenFileOversized(t *testing.T) { + // The path is operator-controlled; a misconfigured/oversized file must + // fail closed to "" rather than load an arbitrarily large header value. + big := make([]byte, maxWorkloadTokenBytes+1) + for i := range big { + big[i] = 'a' + } + t.Setenv(EnvWorkloadIdentityMode, WorkloadIdentityModeK8sSA) + t.Setenv(EnvWorkloadTokenPath, writeTokenFile(t, string(big))) + if got := WorkloadToken(); got != "" { + t.Errorf("WorkloadToken() len=%d, want \"\" — oversized file must fail closed", len(got)) + } +} + +func TestWorkloadToken_ReadsTokenAtCapBoundary(t *testing.T) { + // A token exactly at the cap is still read (off-by-one guard). + tok := strings.Repeat("a", maxWorkloadTokenBytes) + t.Setenv(EnvWorkloadIdentityMode, WorkloadIdentityModeK8sSA) + t.Setenv(EnvWorkloadTokenPath, writeTokenFile(t, tok)) + if got := WorkloadToken(); got != tok { + t.Errorf("WorkloadToken() len=%d, want %d (a token exactly at the cap must still read)", len(got), len(tok)) + } +} + func TestStampWorkloadToken_SetsHeaderWhenAvailable(t *testing.T) { t.Setenv(EnvWorkloadIdentityMode, WorkloadIdentityModeK8sSA) t.Setenv(EnvWorkloadTokenPath, writeTokenFile(t, "jwt-xyz\n"))