fix(contracts): broker calls act as the calling token's deployment, not a scanned provider (#501) - #520
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #501.
The defect
Brokered-contract routes authenticate the caller with an
hct_*contract token whose principal carriesmetadata.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
assertProviderAllowedguarantees for anything installed since spec 004. They diverge in the legacyproviderConflictstate: 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
callerDeploymentIdas 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 declaredprovides∩ 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.POST /api/contracts/backup/prepareprepareContractBackup(caller)POST /api/contracts/backup/finalizefinalizeContractBackup(caller)GET /api/contracts/backup/status/:jobIdassertContractProvider(caller, 'backup@1')(new)POST /api/contracts/restore/indexpublishRestoreIndex(caller, entries)GET /api/contracts/restore/requestspollRestoreRequests(caller)POST /api/contracts/restore/requests/:id/claimclaimRestoreRequest(caller, id)POST /api/contracts/restore/requests/:id/completecompleteRestoreRequest(caller, id, outcome, reason?)Downstream acts as the caller.
publishRestoreIndexandpollRestoreRequestsread and write the caller's own index and queue; claim and complete assert the record's recordedproviderDeploymentIdmatches 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:
assertContractProvider.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_PROVIDERThe 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-levelcodeofNOT_CONTRACT_PROVIDER, which discloses nothing about the contract's state either way.Two deliberate consequences:
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 409RESTORE_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 nodeploymentIdat 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
findConsentedRestoreProviderDeploymentsurvives, for exactly two callers:listProviderRestoreSourcesandgetProviderRestoreSource. 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 wrapperfindConsentedRestoreProviderhad no callers left and is gone;brokerActivitynever used either.Tests
packages/server/src/__tests__/deployments/contract-broker-caller.test.ts(11) — service level, both contracts. TheproviderConflictstate is built by subclassingRealDeploymentServiceto drop onlyassertProviderAllowed(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: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;routeis exported for this). A table with one row per broker route asserts each handler lands the principal'sdeploymentIdin 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 inAPI.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.{ 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 aNOT_CONTRACT_PROVIDERrow; no other spec artifact touched.🤖 Generated with Claude Code
https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh