Skip to content

feat(hub): delegated tokens for platform providers behind a flag - #653

Merged
mjudeikis merged 1 commit into
mainfrom
sec/3.3-delegated-tokens-for-platform-providers
Sep 6, 2026
Merged

feat(hub): delegated tokens for platform providers behind a flag#653
mjudeikis merged 1 commit into
mainfrom
sec/3.3-delegated-tokens-for-platform-providers

Conversation

@mjudeikis

Copy link
Copy Markdown
Contributor

Stacked on #635 — must merge after it. Base is sec/1.2-delegated-tokens-for-org-providers; the diff shown here is only step C on top of it.

Security remediation plan item 3.3 (step C of item 1.2, finding 2).

Problem

#635 stopped forwarding the caller's hub bearer to org-owned providers, and pinned platform providers as unchanged (TestBackendProxyPlatformProviderStillReceivesCallerBearer). So pkg/hub/providers/proxy.go still hands a platform provider the caller's own Authorization header.

That header is a full hub credential. Under A-1 (docs/hub-proxy-workspace-access.md) it reaches every workspace the user is a member of, plus every hub REST endpoint they can call. A platform provider needs far less — it acts in one workspace, on the caller's behalf. The gap is not hypothetical retention-wise: providers/agents holds the caller's token for the lifetime of a detached run (api/run.go HubToken, resumed on context.WithoutCancel), providers/app-studio holds it for a sandbox's lifetime (assistant_run_sandbox_core.go id identity) and re-forwards it verbatim to the hub MCP aggregate and to other providers' action routes (api/llm.go, api/integrations.go), and providers/databricks caches clients built from it for 10 minutes (tenant/client.go). A bug or compromise in any of them acts as the user everywhere, not in the one workspace the request was made in.

Change

Flag. --provider-delegated-tokens=off|platform|all plus --provider-delegated-tokens-exclude (pkg/hub/options.go, cmd/faros-hub/main.go). off is today's behaviour and this release's default; the doc comment and flag help both say the next release defaults to platform. Parsed once at startup before any expensive init, so a typo fails in milliseconds rather than after kcp boots.

Policy type. pkg/hub/providers/proxy_delegation.goDelegationMode, ParseDelegationMode, DelegationPolicy.DelegatesPlatform(name), and DefaultDelegationExclude. Kept separate from the proxy so the audit verdict behind the exclusion list lives next to the list itself. Zero value is off, so a proxy built without SetDelegationPolicy (every test, and any embedder) behaves exactly as before.

Proxy. The platform branch of ServeHTTP reuses step A's delegatedAuthorization before building the reverse proxy, and the Director deletes Authorization after setHeaders and sets the delegated bearer — the same two lines, in the same order, as serveOverEdge. delegatedAuthorization was generalised: the org-ownership check now only applies when prov.OrgUUID != "" (a platform provider has no owning org, so the token is minted in the caller's org), and a caller with no resolvable org is refused. Everything else is unchanged, including the 403/503 split. The UI proxy is excluded structurally (!p.fallbackForSPA), so asset loads cannot start 403ing when the flag is turned on.

Workspace scope. The delegated account is minted in the workspace the request targets (X-Faros-Workspace, verified against the caller's membership by the resolver before it becomes X-Faros-Tenant). Org-scope requests with no workspace header are refused with 403, as in step A. I checked whether platform providers legitimately receive org-scope requests today and concluded they do not: portalkit's tenantHeaders() sends both headers, stores/tenant.ts normalises organization-only mode to workspaceUUID = null, and ProviderFrame.vue gates accessAllowed on a workspace being selected. Minting in the org workspace is not an option regardless — O-10 seals org workspaces and the hub's SA proxy path (serveServiceAccount) refuses a token whose cluster claim is one.

Nothing under providers/ or provider-sdk/ changed except a new test file.

Compatibility

Flag default is off — no deployment changes behaviour on upgrade. Org-owned providers are unaffected either way: they always get the delegated token, as of #635.

Rollout order. Merge #635 → ship this with off → run platform in staging and confirm no provider 403s → flip production to platform → next release changes the default, at which point off remains available as an escape hatch.

What each provider needs. Per-consumer audit; the full table with file references is in docs/provider-scoping.md. The recurring answer is "nothing", because no provider parses the caller's bearer as a JWT (a repo-wide search for ParseUnverified / jwt.Parse / ParseSigned under providers/ and provider-sdk/ finds only tokens that are not the caller's) and every provider takes identity from X-Faros-User, which the hub still sends unchanged.

Consumer Bearer use Verdict
provider-sdk/tenantaccess NewClient {hub}/clusters/{id} client Works. Also never fed a caller bearer today — its four callers pass a reconciler-minted SA token.
providers/infrastructure/dataplane/identity.go + authorizer.go SSAR on <resource>/exec; caller-scoped instance GET as the access gate Works. SSAR asks what this credential can do, so the delegated SA's workspace role is evaluated. No username/groups supplied; X-Faros-User is carried but ignored by the authorizer.
providers/infrastructure/tenant/, providers/code/tenant/ {hub}/clusters/{X-Faros-Cluster} dynamic + authorization clients, BearerToken: token Works. Opaque credential; the provider's own kubeconfig credentials are deliberately dropped from the config.
providers/edges — tunnel, k8s subresource, services/{name}/proxy TokenReview → SAR (verb: proxy) via the APIExport VW for the addressed cluster Works. A delegated token authenticates in the workspace that minted it (= the one addressed), keeps its groups, and its binding passes the SAR. Already exercised in production shape: the org-provider tunnel carries delegated tokens as of #635.
providers/edges — SSH, spec.sshUserMapping: identity TokenReview'd username becomes the Linux login name (fetchSSHCredentials) Breaks. Would resolve to system:serviceaccount:default:faros-du-<hash>. edges is in DefaultDelegationExclude with a comment; fixing it means sourcing that identity from X-Faros-User.
providers/app-studio GraphQL as caller; one raw {hub}/clusters/{id} DELETE; re-forwards the bearer to the hub MCP aggregate, provider action routes, infra data plane Works. All hub surfaces that accept an SA token; each hop re-sends X-Faros-*.
providers/agents (tenant/graphql.go) {hub}/graphql/{cluster}; forwards to edges MCP + infra data plane Works. Its TokenReview/SAR path is the /s2s endpoint, which is not hub-proxied and is untouched.
providers/databricks {hub}/clusters/{id} client + SSAR per action Works. The Databricks PAT is a workspace Secret, unrelated to the caller's bearer.
providers/kuery, providers/quickstart Echo token length / fingerprint only Works. Neither uses it as a credential.
Hub GraphQL gateway /graphql/{cluster} (pkg/hub/graphql.go) Token passed to the gateway, which dials {front-proxy}/clusters/{cluster} as the caller Works. kcp pins an SA token to its own cluster claim — the workspace the account was minted in.

Two behaviour changes to expect under platform. A delegated token is pinned by kcp to one workspace, so a provider that was following the caller's bearer into a different workspace of theirs now gets a 403 — that is the point, but it is the shape any breakage will take. And the credential those three providers retain past the request expires in ten minutes instead of being a live hub token.

Tests

  • pkg/hub/providers/proxy_delegation_test.go (new): off (unset policy, explicit off, off-with-exclusions) forwards the caller's bearer and mints nothing; platform/all swap it, never leak callerBearer, keep X-Faros-User/X-Faros-Tenant, and ask the issuer for exactly (org, ws, user, provider) once; the exclusion list is honoured under platform, ignored under all, matched exactly and trimmed; fail-closed for no issuer (503), mint error (503), org-scope caller with no workspace (403), unresolved caller (403), each asserting the upstream was never hit; anonymous probes still reach the provider with no credential; the UI proxy ignores the policy; plus table tests for ParseDelegationMode and DelegatesPlatform.
  • pkg/hub/providers/proxy_edge_test.go: comment on TestBackendProxyPlatformProviderStillReceivesCallerBearer updated — it now pins the default configuration rather than "step C is future work". Assertions unchanged.
  • providers/edges/internal/tunnel/auth_test.go (new — the package had no coverage of auth.go): a delegated SA token from the tenant workspace resolves to a usable identity and passes the proxy SAR on a Service the caller may reach, with both reviews going to the tenant VW rather than the provider's workspace and the SA's real groups kept; SAR denial and unauthenticated tokens are refused (no SAR issued in the latter); parseServiceAccountToken shape table showing a bound delegated token is deliberately not a foreign SA (so it is reviewed in the workspace that minted it) while the legacy secret-backed shape is; foreign-SA re-qualification to system:kcp:serviceaccount:{home}:default:{name} with groups dropped; and a foreign token resolving to a human is refused.

Commands, all green: GOWORK=off go build ./..., GOWORK=off go vet ./pkg/..., GOWORK=off go test -count=1 ./pkg/hub/... ./pkg/server/..., cd providers/edges && GOWORK=off go build ./... && go vet ./... && go test -count=1 ./..., GOWORK=off make fix-lint && GOWORK=off make lint (0 issues).

🤖 Generated with Claude Code

@mjudeikis
mjudeikis force-pushed the sec/1.2-delegated-tokens-for-org-providers branch from a2d6321 to 56decbf Compare September 5, 2026 15:31
@mjudeikis
mjudeikis requested a review from cwilhit as a code owner September 5, 2026 15:31
@mjudeikis
mjudeikis force-pushed the sec/3.3-delegated-tokens-for-platform-providers branch from 9185098 to 5bd477d Compare September 5, 2026 15:31
@mjudeikis
mjudeikis force-pushed the sec/1.2-delegated-tokens-for-org-providers branch from 56decbf to a960edd Compare September 6, 2026 09:05
@mjudeikis
mjudeikis force-pushed the sec/3.3-delegated-tokens-for-platform-providers branch from 5bd477d to 0795a5d Compare September 6, 2026 09:07
@mjudeikis
mjudeikis force-pushed the sec/1.2-delegated-tokens-for-org-providers branch from a960edd to ed3d19d Compare September 6, 2026 09:43
@mjudeikis
mjudeikis force-pushed the sec/3.3-delegated-tokens-for-platform-providers branch from 0795a5d to f603781 Compare September 6, 2026 10:08
@mjudeikis
mjudeikis changed the base branch from sec/1.2-delegated-tokens-for-org-providers to main September 6, 2026 11:48
@mjudeikis
mjudeikis force-pushed the sec/3.3-delegated-tokens-for-platform-providers branch from f603781 to 1f0b8a4 Compare September 6, 2026 11:49
Step C of remediation item 1.2, plan item 3.3. Step A stopped forwarding
the caller's hub bearer to org-owned providers; platform providers still
received it. That bearer reaches every workspace the user belongs to and
every hub REST endpoint they can call, so a bug in any first-party
provider acts as the user everywhere, not in the one workspace the
request was made in.

Platform providers can now receive the same delegated ServiceAccount
token — minted in the caller's current workspace, ten minutes, bound to
the role they hold there — behind --provider-delegated-tokens=off|
platform|all. Default off this release; the doc comment and the flag help
say the next release defaults to platform, so deployments can verify
first. Fail-closed semantics match step A exactly: unresolved caller or no
workspace selection is 403, missing issuer or mint failure is 503, and no
path falls back to forwarding the bearer. Anonymous health probes still
carry no credential.

Every consumer of the forwarded bearer was audited (verdict table in
docs/provider-scoping.md). No provider parses the caller's token as a
JWT; identity comes from X-Faros-User, which is unchanged, and the bearer
is only ever an opaque credential for {hub}/clusters/{id}, /graphql/
{cluster}, or an SSAR. The one genuine break is the edges SSH data plane
with spec.sshUserMapping=identity, which turns the TokenReview'd username
into the Linux login name; edges is excluded by name via
--provider-delegated-tokens-exclude rather than left half-working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 6, 2026 13:25
@mjudeikis
mjudeikis force-pushed the sec/3.3-delegated-tokens-for-platform-providers branch from 1f0b8a4 to d581ea6 Compare September 6, 2026 13:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The change is well-contained behind a default-off flag with solid tests and fail-closed behavior; only a minor test assertion should be cleaned up.

Pull request overview

This PR adds an opt-in (flagged) mechanism for the hub backend proxy to swap a caller’s full hub bearer token for a short-lived, workspace-scoped delegated ServiceAccount token when proxying to platform providers, reducing blast radius while keeping the current default behavior unchanged.

Changes:

  • Introduces --provider-delegated-tokens=off|platform|all and --provider-delegated-tokens-exclude wiring (defaults to off, with default exclusion for edges).
  • Extends the backend provider proxy to apply delegated-token substitution to platform providers when the delegation policy selects them (fail-closed; UI proxy unaffected).
  • Adds targeted tests for delegation policy behavior and delegated-token handling in the edges tunnel auth path, plus documentation updates describing the model and rollout.
File summaries
File Description
providers/edges/internal/tunnel/auth_test.go Adds tests covering how delegated SA tokens are authenticated/authorized by edges tunnel auth.
pkg/hub/server.go Parses delegation mode early and wires the delegation policy into the backend proxy at startup.
pkg/hub/providers/proxy.go Applies delegation policy to platform-provider backend proxying (swap Authorization when selected).
pkg/hub/providers/proxy_edge.go Generalizes delegated-token minting logic for both org-owned providers and delegated platform providers.
pkg/hub/providers/proxy_edge_test.go Updates comment to reflect that platform delegation is now behind an opt-in flag.
pkg/hub/providers/proxy_delegation.go Adds delegation mode/policy types, parsing, and default exclusion list.
pkg/hub/providers/proxy_delegation_test.go Adds comprehensive tests for delegation modes, exclusions, fail-closed behavior, and UI proxy non-interference.
pkg/hub/options.go Adds new Options fields and defaults for delegated-token behavior and exclusions.
docs/provider-scoping.md Documents delegated-token behavior, modes, failure semantics, and per-provider audit notes.
docs/hub-proxy-workspace-access.md Documents delegated tokens in the hub proxy access model and references provider-scoping details.
cmd/faros-hub/main.go Exposes the new delegated-token flags and help text in the hub CLI.
Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +179 to +183
// Both reviews go to the tenant config (the APIExport VW for the addressed
// cluster), never to the provider's own workspace.
if strings.Contains(rec.tokenReviewPaths[0], "provider-workspace") {
t.Errorf("TokenReview path %q, want the tenant VW", rec.tokenReviewPaths[0])
}
@mjudeikis
mjudeikis merged commit 2562bbf into main Sep 6, 2026
59 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants