You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The api-server, relayer, and indexer all authenticate to Canton with the same OAuth credentials (CANTON_AUTH_CLIENT_ID / CANTON_AUTH_CLIENT_SECRET). The resulting JWT sub claim becomes their Canton user_id, so Canton sees a single user with the union of all rights any service needs. That same user appears to be shared with ChainSafe's autopilot app, since both operate the same daml-autopilot::1220... party.
A quick framing note up front: the daml-autopilot::1220... thing is a Canton party (on-chain identity with signing keys). The Canton user is a separate concept — a ledger user-management record, mapped from the JWT sub claim. Today we share both. This issue is about splitting the user (auth identity), not the party (on-chain identity). Services would still act as the same parties they do today.
Both rights are wildcards. The user can act as any party and read as any party on the participant.
Note on per-user CanActAs grants: an earlier model granted CanActAs <userParty> per registered user via Client.GrantActAsParty (pkg/cantonsdk/identity/client.go:368). Those callers were removed in commit 766f169 when external parties + interactive submission replaced the older internal-party custodial model. The method still exists on the interface but has no production callers — CanExecuteAsAnyParty covers what those grants used to do.
Why this is a problem
Privilege: the indexer never submits commands but currently has CanExecuteAsAnyParty. The relayer only ever acts as a single party (the bridge operator) but currently has wildcard any-party rights. Neither needs what it has.
Blast radius: if CANTON_AUTH_CLIENT_SECRET leaks, all four services (api-server, relayer, indexer, autopilot) are compromised at once. One revocation, four outages.
Auditability: Canton ledger logs user_id per command. Today every command from the middleware and autopilot reads as the same user_id — there is no way to tell from the ledger whether a transfer was submitted by the api-server's custodial flow, the relayer, or autopilot.
Independent rotation / SLOs: rotating the shared secret today requires coordinating with the autopilot team. Per-user rate limits and metrics in Canton apply to the union of all four services.
Proposed split
Four dedicated OAuth clients → four dedicated Canton users, each with the minimum-necessary rights:
OAuth client
Granted Canton rights
Rationale
canton-mw-api-server
CanExecuteAsAnyParty, CanReadAsAnyParty
Submits commands acting as any external user party (interactive submission for transfers). Needs the wildcard execute claim.
canton-mw-relayer
CanActAs <relayer_party> only
Relayer only ever submits as the bridge operator party (cfg.OperatorParty). Single-party scope. No any-party rights needed.
canton-mw-indexer
CanReadAsAnyParty only
Indexer is read-only — it never submits commands, only subscribes via GetUpdates. PR #234 needs FiltersForAnyParty which requires CanReadAsAnyParty.
canton-autopilot
unchanged
Existing user, stays as-is. Just no longer shared with middleware.
Per-service evidence
api-server — submits commands acting as user parties (external parties via interactive submission):
User parties are allocated dynamically per registration; they're not knowable at provisioning time, so the wildcard execute right is the operational mechanism today.
Baseline: run ListUserRights against the current shared user on devnet + mainnet and share output, so we can confirm the assumption above matches reality.
Provision three new Auth0 OAuth clients (devnet + mainnet pairs each):
canton-mw-api-server
canton-mw-relayer
canton-mw-indexer
Register a Canton user for each (user_id = whatever sub claim the OAuth client emits).
Grant rights per the table above — using the same UserManagementService.GrantUserRights pattern that grant-any-party-rights.go uses today. We're happy to extend that script with a --rights flag to support tighter per-service grants if useful.
Provision secrets into the deployment env (per-service env vars, e.g. CANTON_AUTH_CLIENT_ID_API, CANTON_AUTH_CLIENT_ID_RELAYER, CANTON_AUTH_CLIENT_ID_INDEXER).
Confirm the autopilot user is unchanged and untouched.
Follow-up middleware work (out of scope for infra)
These are tracked separately and depend on infra confirming feasibility:
Rename CANTON_AUTH_CLIENT_ID env vars to per-service variants in middleware deploy configs.
Add fail-fast startup checks that each service's user actually has the rights it needs. Especially relevant for the indexer — once PR feat(indexer): support external token indexing via FiltersForAnyParty #234 lands, a misconfigured user silently misses events instead of failing loudly. Such a check would catch the failure at boot.
Extend grant-any-party-rights.go with a --rights flag for per-service grants, instead of always granting both CanExecuteAsAnyParty + CanReadAsAnyParty.
Remove the now-unused Client.GrantActAsParty method (and its IdentityAdmin interface declaration) since callers were dropped in 766f169.
Trigger
This came up while reviewing PR #234 (feat(indexer): support external token indexing via FiltersForAnyParty), which depends on CanReadAsAnyParty being available to the indexer's user. Today that works "for free" because the api-server already has the right via the shared user — but that coincidence is exactly the problem this issue is trying to fix.
Background
The api-server, relayer, and indexer all authenticate to Canton with the same OAuth credentials (
CANTON_AUTH_CLIENT_ID/CANTON_AUTH_CLIENT_SECRET). The resulting JWTsubclaim becomes their Cantonuser_id, so Canton sees a single user with the union of all rights any service needs. That same user appears to be shared with ChainSafe's autopilot app, since both operate the samedaml-autopilot::1220...party.A quick framing note up front: the
daml-autopilot::1220...thing is a Canton party (on-chain identity with signing keys). The Canton user is a separate concept — a ledger user-management record, mapped from the JWTsubclaim. Today we share both. This issue is about splitting the user (auth identity), not the party (on-chain identity). Services would still act as the same parties they do today.Same
CANTON_AUTH_CLIENT_IDis wired into:pkg/config/defaults/config.api-server.{docker,local-devnet,mainnet}.yamlpkg/config/defaults/config.relayer.{docker,local-devnet,mainnet}.yamlpkg/config/defaults/config.indexer.{docker,local-devnet}.yamlSame
client_id→ same JWTsub→ same Cantonuser_id.Rights the shared user currently has
Based on what's granted in code (infra: please run
ListUserRightsagainst prod and devnet ledgers to confirm the live state):CanExecuteAsAnyParty— granted viascripts/remote/grant-any-party-rights.go:170-178CanReadAsAnyParty— granted viascripts/remote/grant-any-party-rights.go:182-187Both rights are wildcards. The user can act as any party and read as any party on the participant.
Why this is a problem
CanExecuteAsAnyParty. The relayer only ever acts as a single party (the bridge operator) but currently has wildcard any-party rights. Neither needs what it has.CANTON_AUTH_CLIENT_SECRETleaks, all four services (api-server, relayer, indexer, autopilot) are compromised at once. One revocation, four outages.user_idper command. Today every command from the middleware and autopilot reads as the sameuser_id— there is no way to tell from the ledger whether a transfer was submitted by the api-server's custodial flow, the relayer, or autopilot.Proposed split
Four dedicated OAuth clients → four dedicated Canton users, each with the minimum-necessary rights:
canton-mw-api-serverCanExecuteAsAnyParty,CanReadAsAnyPartycanton-mw-relayerCanActAs <relayer_party>onlycfg.OperatorParty). Single-party scope. No any-party rights needed.canton-mw-indexerCanReadAsAnyPartyonlyGetUpdates. PR #234 needsFiltersForAnyPartywhich requiresCanReadAsAnyParty.canton-autopilotPer-service evidence
api-server — submits commands acting as user parties (external parties via interactive submission):
pkg/cantonsdk/token/client.go:199,:255,:504—ActAs: [<userParty>]on transfer/mint/burn submissionspkg/cantonsdk/identity/client.go:294—ActAs: [<userParty>]for the user-allocation flowUser parties are allocated dynamically per registration; they're not knowable at provisioning time, so the wildcard execute right is the operational mechanism today.
relayer — only ever submits as
cfg.OperatorParty:pkg/cantonsdk/bridge/client.go:216, 278, 336, 384, 436— everyCommands.ActAsreference isc.cfg.OperatorParty. On mainnet that'sdaml-autopilot::1220..., on local devnet it'sBridgeIssuer. Single party every time.indexer — read-only stream, no submissions:
pkg/cantonsdk/streaming/client.go:124-137—GetUpdatessubscription only. Never callsSubmitAndWaitForTransactionor any command-submission gRPC.Asks to infra
ListUserRightsagainst the current shared user on devnet + mainnet and share output, so we can confirm the assumption above matches reality.canton-mw-api-servercanton-mw-relayercanton-mw-indexersubclaim the OAuth client emits).UserManagementService.GrantUserRightspattern thatgrant-any-party-rights.gouses today. We're happy to extend that script with a--rightsflag to support tighter per-service grants if useful.CANTON_AUTH_CLIENT_ID_API,CANTON_AUTH_CLIENT_ID_RELAYER,CANTON_AUTH_CLIENT_ID_INDEXER).Follow-up middleware work (out of scope for infra)
These are tracked separately and depend on infra confirming feasibility:
CANTON_AUTH_CLIENT_IDenv vars to per-service variants in middleware deploy configs.grant-any-party-rights.gowith a--rightsflag for per-service grants, instead of always granting bothCanExecuteAsAnyParty+CanReadAsAnyParty.Client.GrantActAsPartymethod (and itsIdentityAdmininterface declaration) since callers were dropped in766f169.Trigger
This came up while reviewing PR #234 (
feat(indexer): support external token indexing via FiltersForAnyParty), which depends onCanReadAsAnyPartybeing available to the indexer's user. Today that works "for free" because the api-server already has the right via the shared user — but that coincidence is exactly the problem this issue is trying to fix.