Skip to content

feat: add project membership RBAC and administration - #1532

Draft
Josh Duffney (duffney) wants to merge 3 commits into
duffney-authenticated-delivery-extractionfrom
duffney-extract-project-rbac
Draft

Josh Duffney (duffney) wants to merge 3 commits into
duffney-authenticated-delivery-extractionfrom
duffney-extract-project-rbac

Conversation

@duffney

Copy link
Copy Markdown
Collaborator

Summary

Second layer of the authenticated-delivery -> project-RBAC stack. This draft targets duffney-authenticated-delivery-extraction, not main, and depends on #1531.

  • Enforce fixed reader/contributor/project-admin capabilities using canonical user, membership and durable-operation authority. Platform administration does not bypass project-content membership; project creation grants the creator an audited project-admin membership.
  • Add durable CAS/revision/idempotency operations, explicit pending outcomes, token-capped log-stream leases, capability refresh and stale-query cancellation.
  • Keep enrollment ordinary-user-only: login never promotes or re-enables accounts. First-admin bootstrap, lockout recovery and operation inspection/repair are operator-only, with dry-run defaults and exact identity/database/revision controls; no public/API/Portal/Scope CLI recovery endpoints are added. Existing platform admins can still promote enrolled users.
  • Let project admins manage their own roster and search bounded minimal enrolled-user labels, including users outside their project, without global directory, Secrets or project-lifecycle powers. Preserve self-demotion/removal without a mandatory last-admin invariant.
  • Wire capability-aware Portal administration, CLI parity, global Secrets/agent/model management gates, coordinated auth-mode configuration, migration 031/readiness and reviewed legacy mappings.
  • Disable new report generation across API, CLI, Portal, producers and queue consumer until service authorization is available; stored reports remain project-readable.

This is a reviewable extraction checkpoint, not merge or deployment approval. Remaining isolation/client gaps and acceptance limits are listed below.

Demos

Portal/CLI changes are user-visible. Before/after recordings for this cumulative RBAC layer are not attached; this PR remains draft. Executable Storybook interactions are covered below, but are not claimed as real-browser/MSAL acceptance. The lower layer's delivery-only browser evidence is not upper-layer acceptance.

Before

Not recorded for this extracted RBAC layer.

After

Not recorded for this extracted RBAC layer.

Testing

Results below were executed on the unchanged cumulative checkpoint in this PR. They were not rerun merely for publication.

  • Passed: pnpm --filter shared build

  • Passed: pnpm --filter telemetry --filter db-migrations build

  • Passed: pnpm --filter api --filter cli --filter portal --filter post-processor --filter report-generator build

  • Passed: pnpm --filter db-migrations build

  • Passed — 169 files / 2,225 tests, including API/CLI/Portal, access/artifact Storybook plays and relevant shared/migration/report-worker suites:

    pnpm exec vitest run packages/shared/src/rbac.test.ts packages/shared/src/auth packages/shared/src/codebases packages/shared/src/queue/queue-processor.test.ts packages/db-migrations/src apps/api/src apps/cli/src apps/portal/src apps/workers/post-processor/src/post-processor.test.ts apps/workers/report-generator/src/report-queue-processor.test.ts --reporter=dot
  • Passed — 6 suites / 56 tests, none skipped, using dedicated temporary MongoDB/Redis services with random databases/cache namespaces, signed bearer fixtures and cleanup. Includes actual migration CLI/readiness through 031, creator grants, membership administration/search/self-removal, operator bootstrap/repair, pending recovery and report-state cutover/rerun preservation:

    AUTH_TEST_MONGO_URI=mongodb://127.0.0.1:58636 AUTH_TEST_REDIS_PORT=58637 pnpm exec vitest run --config vitest.integration.config.ts apps/api/src/auth/auth-flow.integration.test.ts apps/api/src/auth/auth-mode.integration.test.ts apps/api/src/auth/admin-operations.integration.test.ts apps/api/src/routes/project-membership.integration.test.ts packages/db-migrations/src/membership-authority.integration.test.ts packages/db-migrations/src/rbac-codebase-constraints.integration.test.ts --reporter=dot
  • Passed: pnpm --filter portal build-storybook

  • Passed: pnpm notice:check

  • Passed: pnpm install --frozen-lockfile --ignore-scripts --offline

  • Passed: docker compose --env-file .env.example --profile auth --profile portal -f docker-compose.yml -f docker-compose.dev.yml config --quiet

  • Passed: docker build --target builder --build-arg NPM_CONFIG_REGISTRY=https://packagefeedproxy.microsoft.io/npm/ --tag scope-rbac-portal-check:4257b230 -f apps/portal/Dockerfile .

  • Passed: sh -n apps/portal/docker-entrypoint.sh

  • Passed: git diff --check and git diff --cached --check before commit.

Nonblocking output included existing React/nested-button warnings, stale Browserslist data, bundle-size warnings and Docker's heuristic warnings about public VITE_AUTH_* build arguments. No credentials were supplied as build arguments. No lint, cumulative RBAC real-browser/MSAL, production nginx/runtime, Cosmos, replica-set, mixed-version rollout or worker/benchmark execution acceptance is claimed.

Documentation and compatibility

Updated docs/architecture/auth-rbac.md, overview.md, app-design.md, codebases.md, db-migrations.md, post-processing.md, token-manager.md, authenticated-media.md, docs/research/realtime-data-flow.md, environment examples/reference and generated OpenAPI/CLI snapshots.

Coordinated cutover required: deploy matching API/shared/Portal capability bundles; strict validation intentionally rejects mixed role bundles. SCOPE_AUTH_ENABLED defaults on and controls API enforcement and Portal sign-in together. Explicit off permits unrestricted trusted local/test application access, not a recovery path; project scoping remains enforced, identity administration stays unavailable and report generation stays disabled. Remove retired login-bootstrap settings and use genuine enrollment plus explicit operator bootstrap. Apply migration 031 before protected traffic, stop old report producers/consumers and anonymous API replicas, and review legacy membership mappings. Migration 031 fails pending/generating reports and retains memberships/operation evidence on rollback.

Migration 032/codebase slug-ref uniqueness is intentionally absent/deferred to #1521. Existing global/project-local indexes and genuine historical ledgers are preserved; uniqueness is not silently weakened or falsely marked applied. Native Mongo success does not establish Cosmos compatibility.

Known original gaps and release gates retained for review:

  • apps/api/src/routes/task-prompts.ts:51-59 recent prompt-generation context and :268 candidate feature extraction still lack project filters in secondary queries. Central route authorization does not scope these downstream queries automatically. These original isolation gaps are not fixed by extraction.
  • Some existing API clients still lack scoped/bearer delivery, including the stored-report EventSource in apps/cli/src/commands/report.ts:272 and unsigned API-backed worker codebase/prompt/skill/resource/extension/MCP clients in packages/shared/src. Service/OBO issuance remains deferred; protected calls fail closed rather than obtaining an anonymous exemption. General authenticated benchmark/worker compatibility is not established.
  • No cumulative upper-layer browser/MSAL acceptance is claimed; lower delivery-only browser results do not prove RBAC behavior. Production/prefix nginx, other browser engines, worker restart/idle expiry and real capture/upload retain their separate acceptance limits.
  • Cosmos identity/membership uniqueness and authority consistency, stale-secondary/multi-replica visibility, RU cost, production <=60-second total freshness, reviewed production legacy mappings and mixed-version cutover remain release gates. No deployment or worker/benchmark run was performed.

Checklist

  • If Portal features changed, keep CLI capabilities in sync.
  • If Portal components changed, update their Storybook stories.
  • If database changes require a migration, include up() / down() and keep it CosmosDB-compatible.
    • Migration 031 and safe evidence-retaining down() are included and native Mongo tests pass; actual Cosmos acceptance remains a release gate.
  • If dependencies changed, update the lockfile and regenerate NOTICE / NOTICE-REVIEW.txt with pnpm notice as needed.
    • This layer adds the workspace shared dependency; lockfile updated and pnpm notice:check passed. External parser dependency/notices belong to the preceding delivery layer.
  • Video showing the behavior before the suggested change
  • Video showing the behavior after the suggested change

@github-actions github-actions Bot added type: documentation Documentation additions, corrections, and improvements. area: worker Coding agent worker runtime, task execution, and lifecycle. area: cli Scope command-line interface and terminal workflows. type: dependencies Dependency additions, removals, and version updates. area: infrastructure Cloud resources, hosting, networking, and shared infrastructure. language: javascript Work involving JavaScript code, tooling, or dependencies. area: portal Scope web portal, pages, and user-facing components. area: post-processing ATIF generation and the run-output post-processing pipeline. area: reporting Generation and delivery of benchmark reports. topic: security Security protections, vulnerabilities, and risk reduction. topic: testing Test coverage, test infrastructure, and validation quality. topic: ui Portal, visual presentation, layout, and interface components. labels Oct 21, 2026
@duffney
Josh Duffney (duffney) added this pull request to stack #1533 October 21, 2026 20:25
@github-actions

Copy link
Copy Markdown

Test Results (Node.js 22)

test: Run #343

Tests 📝 Passed ✅ Failed ❌ Skipped ⏭️ Pending ⏳ Other ❓ Flaky 🍂 Duration ⏱️
3773 3772 1 0 0 0 0 1m24s

❌ Some tests failed!


Name Failure Message
❌ Access story interactions > SpecificProjectPendingOperatorGuidance renders and play passes Unable to find a label with the text of: Project

Ignored nodes: comments, script, style
<body>
<div>
<a
data-discover="true"
href="/access?projectId=11111111-1111-4111-8111-111111111111"
>
Open project access
</a>
<main
class="mx-auto max-w-5xl space-y-6 p-6"
>
<header
class="flex flex-wrap items-start justify-between gap-3"
>
<div>
<h1
class="text-2xl font-semibold"
>
Access administration
</h1>
<p
class="mt-1 text-sm text-muted-foreground"
>
Manage who has access to your projects and what they can do.
</p>
</div>
<div
class="flex flex-wrap gap-2"
>
<a
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
data-discover="true"
href="/access"
>
All project access
</a>
<a
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
data-discover="true"
href="/projects"
>
Your projects
</a>
</div>
</header>
<div
class="max-w-xl space-y-2"
>
<div>
<p
class="text-sm text-muted-foreground"
>
Project
</p>
<p
class="text-lg font-semibold"
>
Scope benchmarks
</p>
</div>
</div>
<section
class="overflow-hidden rounded-lg border"
>
<div
class="flex flex-wrap items-center justify-between gap-3 border-b bg-muted/30 p-4"
>
<h2
class="flex items-center gap-2 font-semibold"
>
<svg
aria-hidden="true"
class="lucide lucide-users h-4 w-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"
/>
<circle
cx="9"
cy="7"
r="4"
/>
<path
d="M22 21v-2a4 4 0 0 0-3-3.87"
/>
<path
d="M16 3.13a4 4 0 0 1 0 7.75"
/>
</svg>
Project members
<div
class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-secondary text-secondary-foreground"
>
2
</div>
</h2>
<button
class="inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-9 rounded-md px-3"
>
<svg
aria-hidden="true"
class="lucide lucide-plus mr-2 h-4 w-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="M12 5v14"
/>
</svg>
Add people
</button>
</div>
<ul
aria-label="Project members"
class="divide-y"
>
<li
class="flex flex-wrap items-center justify-between gap-4 p-4"
>
<div
class="flex min-w-0 items-center gap-3"
title="Scope ID: 22222222-2222-4222-8222-222222222222"
>
<span
aria-hidden="true"
class="flex h-9 w-9 shrink-0 items-center ju...

Github Test Reporter

@duffney

Copy link
Copy Markdown
Collaborator Author

After a new user is logged in, but isn't assigned to any projects they get the following screen. A better experience would be to allow the login and just show an empty projects page.

Image

Enforce fixed project capabilities and canonical authorization across API, CLI and Portal. Add durable access operations, operator-only bootstrap and repair, project-admin membership discovery, migration 031 and coordinated report shutdown/auth mode.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4257b230-1926-4101-9c45-498e16e5fa51
Cover deferred all-project navigation so story interactions cannot query the scoped view before React commits the route transition.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4257b230-1926-4101-9c45-498e16e5fa51
@duffney
Josh Duffney (duffney) force-pushed the duffney-extract-project-rbac branch from aca7e51 to a248e76 Compare October 22, 2026 15:06
@github-actions

Copy link
Copy Markdown

Test Results (Node.js 22)

test: Run #374

Tests 📝 Passed ✅ Failed ❌ Skipped ⏭️ Pending ⏳ Other ❓ Flaky 🍂 Duration ⏱️
3778 3778 0 0 0 0 0 1m55s

🎉 All tests passed!

Github Test Reporter

@duffney

Josh Duffney (duffney) commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Review findings

I reviewed a248e76 against the RBAC spec (#1429) and ran it locally with auth on (Compose with the auth and copilot profiles) to confirm the runtime issues. Findings in priority order. Blocking means I think it should be fixed before merge.

Pri Status Area Finding
P0 🚫 Blocking Workers / Judge Worker and judge API calls are unsigned, so most benchmark runs fail with auth on
P1 🚫 Blocking API AI task-prompt endpoints read other projects' data
P1 Non-blocking Auth / Ops ≤60 s revocation/outage bound isn't demonstrated
P2 Non-blocking API Public /about discloses environment, build and workers
P2 Non-blocking Portal UI Portal shows mutation controls to readers
P2 Non-blocking CLI CLI report logs streams without a bearer

P0 · 🚫 Blocking · Workers / Judge: Worker and judge API calls are unsigned, so most benchmark runs fail with auth on

The worker content clients, the judge's criteria provider and the agent-registration job call protected routes with no Authorization header, so they get 401. Only prompt-only runs with no criteria still work.

Local repro:

Run Uses Result
e352ef93 prompt only ✅ succeeded
273fdec3 MCP server time ❌ [McpServerClient] GET /api/v1/mcp/servers/time?projectId=… 401
aed003a5 criterion says_hello ❌ [RestApiCriteriaProvider] GET /api/v1/criteria?projectId=… 401, then judge HTTP 500

Unsigned callers:

Suggested fix:

Option A: job-scoped run token (recommended). This is the same model as GitLab's CI_JOB_TOKEN and GitHub Actions' GITHUB_TOKEN. For the full design, security checks and cryptography, see the Option A deep dive.

  • When a worker starts a run, it presents its own service credential and the runId to the API once, and gets back a run token. The scheduler, not the API, dispatches runs, so this one call is how the API learns about the run.
  • The token is bound to runId, projectId and the run's submitter, and names the worker service as the actor. The judge gets one the same way.
  • It's accepted only while the run is processing, and only on the content routes the worker and judge use: MCP, skills, extensions, resources, prompts, codebases and criteria.
  • On each call, the API rechecks that the submitter is still enabled and still a member of the project, so a submitter who's removed or demoted is rejected.
  • The token stops working as soon as the run finishes, so there's nothing to refresh during long runs.
  • This needs the submitter's user ID recorded on the request.

Option B: token exchange. This is the on-behalf-of design the spec describes, along the lines of RFC 8693.

  • Each service has its own credential. Before content reads, it exchanges that credential plus the runId for a short-lived (≤5 min) token for the submitter.
  • It meets the same requirements, but needs repeated exchanges to keep the token fresh during long runs.
  • Option A probably fits better, because runs are already bounded jobs with a clear start and end.

Entra's own on-behalf-of flow doesn't fit either way: it needs the user's live token, and a queued run doesn't have one.

Either way:

  • Registration: register-agent.sh gets its own narrow service credential. Agent registration isn't user data, so it doesn't need a user token.
  • Client plumbing: add a shared bearer provider in packages/shared, used by every client above.
  • Tests:
    • A token for run A can't read project B.
    • A token is rejected once its run finishes, or once the submitter is demoted.
    • MCP and criteria runs pass with auth on.

If this doesn't land in this PR: keep auth off anywhere benchmarks run, and update auth-rbac.md to say these runs fail.


P1 · 🚫 Blocking · API: AI task-prompt endpoints read other projects' data

  • task-prompts.ts:51-66 queries recent prompt text without projectId.
  • :263-280 loads prompt-feature definitions without projectId, feeds them to extraction and attaches the results to the current prompt.

Other projects' data can end up in LLM input and output.

Fix: filter by the authorized projectId / taskPrompt.projectId, and add a two-project regression test.

P1 · Non-blocking · Auth / Ops: ≤60 s revocation/outage bound isn't demonstrated

The mechanics are in place:

Cosmos and multi-replica behavior haven't been validated. Measure end to end from mutation or outage before claiming the bound.


P2 · Non-blocking · API: Public /about discloses environment, build and workers

system.ts:54-86 returns available workers, buildTime, environment and a commit-derived version on a public route.

Fix: return only the product name, API version and docs URLs, and move the worker catalog and diagnostics behind auth.

P2 · Non-blocking · Portal UI: Portal shows mutation controls to readers

  • ProfileDetail.tsx:246-282 always renders Edit, New Version and Delete.
  • RunDetail.tsx gates retry, pause, cancel, resume and priority on run state only.
  • AccessShell.tsx:47 only blocks paths ending in /new, not /new-version.

The API still denies these mutations.

Fix: derive capabilities from the selected-project role, gate the controls and routes, and add reader/contributor tests.

P2 · Non-blocking · CLI: CLI report logs streams without a bearer

report.ts:264-274 preflights with bearer-aware apiFetch, then opens a raw EventSource with no token, so stored-report logs fail with auth on.

Fix: use bearer-aware fetch streaming, as the Portal now does.

Task-prompt generation and feature extraction read prompts and feature
definitions across all projects, sending other projects' data to the LLM
and persisting foreign features on the current prompt.

- task-prompts/generate filters recent prompts by the authorized projectId
- task-prompts/:id/extract-features uses only the prompt's project features
- prompt-features generate-prompt/extract-from-text and
  criteria/generate-prompt require projectId and always filter by it
- criteria/mdp task-prompt lookup is scoped to its project
- Portal AI calls now send the active projectId (previously 400)
- Add unit and two-project integration regression tests
- Regenerate OpenAPI snapshot/spec; document the rule in app-design.md

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e871494e-c9b6-4a6f-8665-96a0b6336218
@duffney

Copy link
Copy Markdown
Collaborator Author

P1 · 🚫 Blocking · API: AI task-prompt endpoints read other projects' data (comment)

Fixed in b743953.

Changes

  • POST /task-prompts/generate loads recent prompts only from the authorized projectId.
  • POST /task-prompts/:id/extract-features loads feature definitions only from taskPrompt.projectId, so features from other projects are never sent to the LLM or saved on the prompt.
  • These AI helpers now require ?projectId= and always filter by it: prompt-features/generate-prompt, prompt-features/extract-from-text and criteria/generate-prompt. The criteria/mdp task-prompt lookup is also scoped to its project.
  • The Portal now sends the active project on these three AI calls; before this they were rejected with 400 project_required.
  • The rule is documented under the by-id invariant in app-design.md.

Regression tests

  • endpoints.test.ts: 6 unit tests. All 6 fail when the route fix is reverted.
  • task-prompts.integration.test.ts: two projects in a real Mongo, with real authorization and a user who is a member of project A only. It checks that the LLM receives only project A data, that the saved features belong only to project A, that project B returns 404 and that generate without a projectId returns 400.

Validation

  • pnpm exec vitest run apps/api apps/portal/src/lib: 1210 passed.
  • Integration tests (task-prompts and project-membership): 10 passed.
  • API and Portal typechecks are clean, and the OpenAPI snapshot and spec were regenerated.
  • Local end-to-end test against the real API, with a mock LLM recording its input:
    • Before the fix, project B's prompt and feature text appeared 8 times in project A's LLM input, and B's feature was saved on A's prompt.
    • After the fix, B's text appeared 0 times and only A's feature was saved.

@github-actions

Copy link
Copy Markdown

Test Results (Node.js 22)

test: Run #389

Tests 📝 Passed ✅ Failed ❌ Skipped ⏭️ Pending ⏳ Other ❓ Flaky 🍂 Duration ⏱️
3784 3784 0 0 0 0 0 1m56s

🎉 All tests passed!

Github Test Reporter

@duffney

Josh Duffney (duffney) commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

P0 follow-up: how a job-scoped run token (Option A) would work

This expands on Option A in the P0 finding. It's a design write-up; nothing is implemented yet.

The short version

  • A run token is a random, opaque bearer string. The API trusts nothing written inside it. On every call it looks the token up and re-checks live state: whether the run is still executing, whether the submitter is still enabled and a member, and whether the route and project are allowed.
  • The token dies with the run. Revocation comes from the run state machine, not from an expiry time, so long runs don't need any refresh.
  • Nothing is encrypted. The token is random, and only its SHA-256 hash is stored, so it's hashed, not encrypted. Confidentiality in transit depends on TLS.

Flow

sequenceDiagram
    participant W as Worker
    participant API as Scope API
    participant DB as Mongo / Cosmos
    participant J as Judge
    participant A as Agent under test
    W->>DB: claim run: queued→processing, set new run.claimId
    W->>API: POST /api/v1/runs/:requestId/token (service credential + runId, claimId)
    API->>DB: verify run is processing under that claim, submitter enabled and a member
    API->>DB: store run.tokenHash = SHA-256(token)
    API-->>W: scope_rt_<requestId>_<32 random bytes> (no-store)
    Note over W: token held in worker memory only
    W->>API: GET content routes (MCP, skills, extensions, resources, prompts, codebases) with bearer
    W->>A: spawn agent
    W->>J: POST /evaluate with run token
    J->>API: GET /api/v1/criteria with the same bearer
    W->>DB: run processing→done
    Note over API: next call with the token is rejected
Loading
  1. Submit records submittedBy.userId on the request. It isn't recorded today, so this is a prerequisite.
  2. Claim. The worker claims the run in Mongo, as it does today, and also sets a fresh run.claimId. The scheduler never calls the API, so the API first learns about the run at the next step.
  3. Exchange. Before spawning the agent, the worker presents its service credential plus requestId, runId and claimId.
    • The API checks that the run is processing under that claim and that the submitter is enabled and still a member.
    • It issues at most one token per claim; a second request returns 409.
  4. Use. Every content client sends the token as a bearer. The judge receives it in /evaluate and uses it for the criteria fetch.
  5. End. Done, cancelled, reaped or re-claimed: the next call fails the run-state check. There's nothing to revoke explicitly.

How each call is secured

Per-call check Stops
Point read of the request by the requestId in the token prefix, then constant-time compare of SHA-256(token) with run.tokenHash Forged or guessed tokens (256-bit random). A database leak doesn't yield usable tokens
run.status === processing and run.claimId unchanged Replay after the run ends, after pause/resume or after a re-claim
Submitter exists, isn't disabled, and has no pending admin operation A disabled submitter's runs stop on the next call
Existing projectRequest() and requireProject(submitterId, projectId, "content:read") A removed or demoted submitter. Uses the live membership read already in authorization.ts
req.authorizedProjectId === request.projectId Cross-project reads, including by UUID or through reference params that point into another project
Route flagged runToken: true in api-route.ts, method GET/HEAD only Writes, admin and membership routes, /users/me, run submission from inside a run
Separate principal (req.runToken, never req.auth) Mixing with human sessions: no enrollment, no user cache, no SSE stream leases

Not stopped: someone who steals a live token can use it until the run ends, because it's a bearer token. The damage is limited to read-only content in one project, within the submitter's current rights. Binding the token to its sender (DPoP or mTLS) would close this, but needs in-cluster TLS first.

Cryptography

Treatment
Token 32 bytes from crypto.randomBytes, formatted scope_rt_<requestId>_<base64url>. The prefix lets secret scanning and log redaction catch it; the requestId allows a cheap Cosmos point read. Not encrypted
At rest Only SHA-256(token) is stored, inline on the request doc. A fast hash is right for high-entropy random secrets; bcrypt and argon2 are for passwords (OWASP). This is hashing, not encryption
Signing keys None. A signed JWT was considered and rejected, see below
In transit Relies entirely on TLS. Service-to-API calls inside the cluster are plain HTTP today, so in-cluster TLS (Cilium WireGuard, a mesh, or internal HTTPS) is a separate hardening item. It's needed for any bearer, including users' Entra tokens after the ingress
Logs Never logged. Redact scope_rt_ in API and worker logs; responses are Cache-Control: no-store

Why opaque and not a signed JWT? Every call has to re-check run state and membership in Mongo anyway, because permissions embedded in a token can't be trusted. So a JWT's statelessness buys nothing. It would add a signing key, rotation and a second trust root next to Entra, and a JWT is signed, not encrypted. Others reach the same conclusion:

  • GitLab CI_JOB_TOKEN is "valid only while the job is running", and stays tied to job state even in its new JWT (glcbt-) format.
  • GitHub Actions GITHUB_TOKEN is minted per job, limited to the repository, and expires when the job finishes.

Data changes (Cosmos-friendly)

  • RequestDocument.submittedBy: { userId, at }, set at all request insert sites and on resubmit. It's immutable afterwards.
  • run.claimId (set at every claim), plus run.tokenHash and run.tokenIssuedAt inline on the request doc. The lookup is a point read by _id, so no new index or TTL is needed.
    • A separate collection would need TTL, which Cosmos only supports on _ts. That makes TTL fine for cleanup but never the security boundary.
  • Requests without a submitter (legacy, or created with auth off) fail closed when auth is on: 409 submitter_unknown. There's no automatic backfill, because guessing would grant someone else's access.
  • Auth off: workers detect this from /api/v1/version and skip the exchange, so unsigned calls keep working as they do today.

The service credential behind the exchange

Only the exchange step needs a service identity:

  • Now: a per-service key (INTERNAL_API_KEY_<NAME> plus the service name), as in the spec's Question E. It comes from Key Vault via External Secrets on AKS, and from Lowkey Vault or .env in Compose. Mount it as a file.
  • Later, on AKS: Kubernetes projected service-account tokens with audience scope-api, validated by TokenReview or the cluster's OIDC issuer. No static secret, and bound to the pod.
  • register-agent.sh and the model scanners don't touch user data. They get their own narrow service principals (agents:register, models:sync) on the same mechanism, with no run token involved. That also fixes the registration 401 that currently blocks worker startup.

Code touch points

  • middleware.ts: detect the scope_rt_ prefix before Entra verification, set req.runToken, and leave req.auth unset.
  • authorization.ts:
    • decide() gets a new first branch for run tokens. It must skip the JWT-exp lease parse, which would throw on an opaque token.
    • routePolicy() needs a "service" policy for the exchange route.
  • api-route.ts: add a runToken flag per route, which also appears in OpenAPI security.
  • packages/shared: one shared bearer provider used by every worker content client and the judge.
  • Judge: build the criteria provider per evaluation. The current per-project singleton cache would otherwise reuse data fetched under another run's authority.

What's left outside Option A

  • No in-cluster TLS yet (above).
  • Stolen live tokens remain usable until the run ends (above).

@cedricvidal

Cedric Vidal (cedricvidal) commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Replying to @duffney's job-scoped run-token proposal:

The worker claims the run in Mongo, as it does today, and also sets a fresh run.claimId.

The job-scoped token approach makes sense. Could you clarify what additional scenario run.claimId is intended to handle?

My understanding is that a run attempt moves forward from queued to processing to done. If a worker is considered dead, that attempt is marked failed, and a retry creates a new attempt with a new run._id. Queue messages also appear to be rejected when their runId no longer matches the current run.

Would checking requestId + runId + status=processing, together with atomic single-token issuance, provide the same protection? Is there a same-attempt reclaim or ownership-transfer scenario where the runId remains unchanged and a separate claim ID is needed?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: cli Scope command-line interface and terminal workflows. area: infrastructure Cloud resources, hosting, networking, and shared infrastructure. area: portal Scope web portal, pages, and user-facing components. area: post-processing ATIF generation and the run-output post-processing pipeline. area: reporting Generation and delivery of benchmark reports. area: worker Coding agent worker runtime, task execution, and lifecycle. language: javascript Work involving JavaScript code, tooling, or dependencies. topic: security Security protections, vulnerabilities, and risk reduction. topic: testing Test coverage, test infrastructure, and validation quality. topic: ui Portal, visual presentation, layout, and interface components. type: dependencies Dependency additions, removals, and version updates. type: documentation Documentation additions, corrections, and improvements.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants