Skip to content

fix(contracts): broker calls act as the calling token's deployment, not a scanned provider (#501) - #520

Merged
pofallon merged 1 commit into
mainfrom
fix/501-contract-broker-caller-identity
Sep 22, 2026
Merged

pofallon merged 1 commit into
mainfrom
fix/501-contract-broker-caller-identity

Conversation

@pofallon

Copy link
Copy Markdown
Contributor

Closes #501.

The defect

Brokered-contract routes authenticate the caller with an hct_* contract token whose principal carries metadata.deploymentId (services/auth/contract-tokens.ts). The service methods behind them never read it — they re-derived "the provider" by scanning every deployment for one holding the relevant consented grant.

Those two answers agree only while a host has exactly one provider, which assertProviderAllowed guarantees for anything installed since spec 004. They diverge in the legacy providerConflict state: two installs both declaring the same provider role, a shape that predates the one-provider-per-host guard and is surfaced as a warning rather than auto-resolved. There both installs hold a valid token carrying the same capability, and the scan resolved to whichever it found first — so B's token could publish into, poll, claim and complete A's work.

Not reachable through any supported install flow today, and backup@1's broker has had this shape since before spec 008 — pre-existing, not introduced by the restore work.

The fix

Stop resolving "the" provider on the broker path. Verify the caller is a consented provider and act as that deployment — correct with one provider and with two, and it removes the singleton lookup from the broker path rather than guarding it.

Every broker method takes callerDeploymentId as an explicit first argument, read off the authenticated principal by the route handler (the service layer has no request scope and should not acquire one), then resolves that deployment and asserts it is a consented provider of that contract. Consent is read the way every other privileged path reads it — readActiveGrantedContracts: the active release's declared provides ∩ the refs recorded at consent time — so #496's frozen privileges and a withdrawn consent both still govern, and a provider role an upgrade declared but nobody consented to grants nothing.

Route Service method
POST /api/contracts/backup/prepare prepareContractBackup(caller)
POST /api/contracts/backup/finalize finalizeContractBackup(caller)
GET /api/contracts/backup/status/:jobId assertContractProvider(caller, 'backup@1') (new)
POST /api/contracts/restore/index publishRestoreIndex(caller, entries)
GET /api/contracts/restore/requests pollRestoreRequests(caller)
POST /api/contracts/restore/requests/:id/claim claimRestoreRequest(caller, id)
POST /api/contracts/restore/requests/:id/complete completeRestoreRequest(caller, id, outcome, reason?)

Downstream acts as the caller. publishRestoreIndex and pollRestoreRequests read and write the caller's own index and queue; claim and complete assert the record's recorded providerDeploymentId matches the caller, so knowing a request id is not enough to touch another provider's request — which is what makes that field load-bearing rather than decorative.

Two adjacent call sites came along for the same reason:

  • The prepare-job status poll had no provider check at all. It has no state of its own to scope, but it is still a broker route, so it asserts the caller via the new assertContractProvider.
  • The deploy job's mid-restore "is my provider still there?" check asked the singleton question about a named provider (parsed.providerDeploymentId). On a two-provider host that could answer "gone" about a provider still installed and still consented; it now asks about that request's own provider.

Why 403 / NOT_CONTRACT_PROVIDER

The token authenticated, and the middleware already checked it carries this contract's capability — so this is not 401, and not "you lack a capability". What fails is narrower: you are not the provider you are claiming to be. ForbiddenError (403) with a top-level code of NOT_CONTRACT_PROVIDER, which discloses nothing about the contract's state either way.

Two deliberate consequences:

  • A request addressed to a different provider stays 404 RESTORE_REQUEST_NOT_FOUND. The caller is a provider, so the refusal is about the request, and a provider has no business learning another's queue exists. (This also absorbs the old FR-038 "consent revoked between creation and claim" case — now refused at 403 before the record is read, so even less is disclosed.)
  • publishRestoreIndex's old 409 RESTORE_NOT_ACCEPTED ("no consented provider is installed") is subsumed: on a host with no provider, no caller can be one. Same fact, stated about the caller. A principal carrying no deploymentId at all (an operator key, a dashboard session) is refused in the handler, before the service is reached — never defaulted to some provider, which is the whole of this issue.

The singleton helper

findConsentedRestoreProviderDeployment survives, for exactly two callers: listProviderRestoreSources and getProviderRestoreSource. Both answer an operator's install-time question ("is a provider installed, and what can it offer this install?") where a host-wide singleton is the question being asked and no credential is being scoped. Its doc now says so, and says the broker path deliberately does not come through it. The id-only wrapper findConsentedRestoreProvider had no callers left and is gone; brokerActivity never used either.

Tests

packages/server/src/__tests__/deployments/contract-broker-caller.test.ts (11) — service level, both contracts. The providerConflict state is built by subclassing RealDeploymentService to drop only assertProviderAllowed (the guard the state predates; everything else — manifests, consent, the stores — goes through the real path), then asserted to be conflicting via the rollup before anything else runs. Each assertion pairs "A cannot" with "B still can", so it fails deterministically regardless of deployment-map order:

  • each provider publishes into its own index; neither overwrites the other
  • a poll returns only the calling provider's pending requests
  • A cannot claim/complete B's request; B still can
  • a non-provider acceptor, an unknown id, an empty id, and a declared-but-unconsented provider role are all refused
  • the single-provider happy path unchanged for both contracts

packages/server/src/__tests__/deployments/contract-broker-routes.test.ts (15) — route level, driven through the real router (the auth middleware substitutes a wildcard system principal when auth is disabled, which the test env always is, so an HTTP-level request could never present a contract principal; route is exported for this). A table with one row per broker route asserts each handler lands the principal's deploymentId in the service call and refuses a principal without one — a missed route is the whole bug again — plus a guard that the table covers every key in API.contracts.

Revert-proof: with the enforcement reverted behind the new signatures (the singleton scan restored, the routes passing no caller), 21 of the 26 new tests fail. The 5 that pass are the ones that should: the two single-provider happy paths, the two-providers-may-both-announce-a-backup case, the declared-but-unconsented refusal (the old code read consent too), and the API-surface table guard.

Suite: 1423 server (was 1397) + 377 web + 286 CLI, all green, plus typecheck / lint / typecheck / build.

Deliberately left

  • backup@1's broker state stays host-wide. Its work is host-wide (every accepting app's hooks) and two providers share one run record. That is the conflict the rollup already warns about, not something this fix resolves; what it settles is that each call is authorised as the deployment that made it. Noted in a test.
  • The status poll does not check job ownership. It asserts the caller is the backup provider, but a provider could still poll an arbitrary job id and learn its status. The response is { jobId, status } only, and with one provider per host there is no second provider to hide it from.
  • specs/008-restore-provider/contracts/api.md's error table gains a NOT_CONTRACT_PROVIDER row; no other spec artifact touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh

…ot a scanned provider (#501)

Brokered-contract routes authenticate the caller with an `hct_*` contract
token whose principal carries `metadata.deploymentId`, and the service
methods behind them never read it. They re-derived "the provider" by
scanning every deployment for one holding the relevant consented grant.
The two answers agree only while a host has exactly one provider — which
`assertProviderAllowed` guarantees for anything installed since spec 004
— and disagree in the legacy `providerConflict` state (two installs both
declaring the same provider role, surfaced as a warning rather than
auto-resolved). There, both hold a valid token with the same capability
and the scan resolved to whichever it found first, so B's token could
publish into, poll, claim and complete A's work.

Stop resolving "the" provider on the broker path. Every broker method now
takes `callerDeploymentId` as an explicit first argument — read off the
authenticated principal by the route handler, since the service layer has
no request scope and should not acquire one — resolves that deployment,
asserts it is a consented provider of that contract
(`readActiveGrantedContracts`: the active release's `provides` ∩ the refs
recorded at consent time, so #496's frozen privileges and a withdrawn
consent both still govern), and acts as it. Correct with one provider and
with two, and it removes the singleton lookup from the broker path rather
than guarding it.

- `prepareContractBackup` / `finalizeContractBackup` / a new
  `assertContractProvider` (the prepare-job status poll, which has no
  state of its own to scope but must still not answer a non-provider).
- `publishRestoreIndex` / `pollRestoreRequests` / `claimRestoreRequest` /
  `completeRestoreRequest`, each scoped to the caller's own index and
  queue. Claim and complete now assert the record's own
  `providerDeploymentId` matches the caller, so knowing a request id is
  not enough to touch another provider's request.
- A non-provider caller is refused `FORBIDDEN` / `NOT_CONTRACT_PROVIDER`
  (403): the credential is real and the middleware already checked it
  carries the contract's capability, so this is "you are not the provider
  you are claiming to be", not "you lack a capability". A request
  addressed to a different provider stays 404
  `RESTORE_REQUEST_NOT_FOUND` — a provider has no business learning
  another's queue exists. `publishRestoreIndex`'s old 409
  `RESTORE_NOT_ACCEPTED` is subsumed: on a host with no provider, no
  caller can be one.
- The deploy job's mid-restore "is my provider still there?" check asked
  the singleton question about a named provider; it now asks about that
  request's own `providerDeploymentId`, which a two-provider host would
  have answered wrongly.

The singleton helper survives for the two install-time candidate readers
(`listProviderRestoreSources`, `getProviderRestoreSource`), where a
host-wide "is a provider installed?" is the question actually being asked
and no credential is being scoped; the id-only wrapper had no callers left
and is gone.

Regression tests: the `providerConflict` state built by subclassing the
service to drop only `assertProviderAllowed` (the guard that state
predates), covering cross-provider publish/poll/claim/complete; a
non-provider and an unknown id refused; a declared-but-unconsented
provider role refused; and a route-level table enumerating all seven
broker routes so a handler that forgets to thread its caller fails.
Reverting the enforcement fails 21 of the 26 new tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh
@pofallon
pofallon merged commit 46b3499 into main Sep 22, 2026
3 checks passed
@pofallon
pofallon deleted the fix/501-contract-broker-caller-identity branch September 22, 2026 04:11
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.

Contract broker resolves the caller via singleton provider lookup, not the calling token's deploymentId (backup@1 and restore@1)

1 participant