cimd.allowed_domains is a single deployment-wide set, but every other authorization decision in ZeroID is scoped to (account_id, project_id). On a multi-tenant deployment it is therefore not a per-customer control at all, and one consequence is already shipped: #285's escape hatch is unusable on the hosted product.
What is actually deployment-wide
domainAllowed takes no tenant:
func (s *CIMDService) domainAllowed(host string) bool {
if len(s.allowedDomains) == 0 { return true }
_, ok := s.allowedDomains[strings.ToLower(host)]
return ok
}
CIMDConfig hangs off Config (config.go:43), is read once in NewServer (server.go:361), and there is no tenant dimension anywhere in the type or its consumers. So a host approved for one customer is approved for every customer on that deployment.
That is fine for a single-tenant install. On Studio/AuthN, where accounts are customers, it means the "primary production hardening lever" cannot express the policy a customer would actually want, and enabling it on anyone's behalf widens what every other tenant accepts.
The shipped consequence
#285 declines RFC 6749 §4.1.2.1 error redirects for self-asserted (CIMD) clients, on the grounds that their redirect_uris are attacker-chosen while allowed_domains is empty — and offers "set cimd.allowed_domains" as the way to restore them, because vetting the publishing hosts restores the assumption the rule rests on.
On a multi-tenant deployment that hatch is all-or-nothing: restore redirects for everyone, accepting one customer's publishers on behalf of all, or for nobody. So in practice CIMD clients on the hosted product never get error redirects, and #285's docs currently imply otherwise. Correcting that wording is a small follow-up; the design question is this issue.
Why a per-tenant allowlist is not a drop-in
At the point CIMD resolution runs, the tenant is unknown.
#285 hoisted client resolution to step 3.5, before principal resolution, precisely so a validated redirect_uri exists early. The tenant arrives with the principal at step 4:
- a registered client's row carries
account_id / project_id (domain/token.go:163-164), so its tenant is knowable at 3.5 — though GetPublicByClientID looks up on client_id alone, with no tenant filter;
- a CIMD client has no row.
synthesizeCIMDClient sets no tenant fields, and cannot: nothing in the request names one.
At the decision point (internal/service/cimd.go:336) the entire scope is ctx and clientID:
func (s *CIMDService) ResolveClient(ctx context.Context, clientID string) (*domain.OAuthClient, error) {
...
if !s.domainAllowed(u.Hostname()) {
and nothing puts tenancy in that context — the only tenant-in-context mechanism, internal/middleware/apikey.go:38, is not mounted on the public routes, and could not help anyway since it presumes the caller already authenticated.
The ordering constraint is inherent, not a plumbing gap
This is the part worth settling before anyone writes code, because it collapses most of the option space.
Suppose we added a tenant parameter. Where would the value come from?
| Source |
Trustworthy? |
| A request parameter or header |
No — attacker-controlled. An attacker claims Acme's tenant to get Acme's allow-list. |
| The CIMD document |
No — they published it. |
| The registry row |
N/A — a CIMD client has no row; that is what CIMD means. |
| The authenticated principal |
Yes — and available only after authentication. |
So "is this publisher acceptable for THIS customer" is a question that can only be answered once the principal is resolved. That is what the question means, not a limitation of any particular design.
Consequence: deferring the check is a precondition, not one option among several. The apparent alternatives are really just different ways of making a trustworthy tenant available earlier, and each pays for it:
| Approach |
How a tenant becomes available |
Cost |
| Defer the policy check past principal resolution |
the resolved principal |
the document is fetched before the host is judged, so a disallowed publisher still causes one guarded outbound request — today the check runs before the fetch and prevents it |
Per-tenant issuer / hostname (acct-x.as.example.com) |
the Host header, pre-auth |
iss is the AS's identity and the anchor of the discovery document (RFC 8414 §3); per-tenant issuers multiply discovery and change every token's iss. Platform-level, not a CIMD fix. |
| Resolve the principal first |
the resolved principal |
reintroduces exactly the ordering #279 fixed: access_denied fires with no validated redirect_uri |
Only once a tenant is available does the second question arise — where the policy lives:
- A config list, extended with a tenant dimension. Wrong home for it:
zeroid.yaml is a file an operator edits at deploy time, while "what Acme's admin chose" belongs to product state.
- A deployer hook —
func(tenant, host) bool — answered by AuthN from per-account settings. Same division ZeroID already uses for questions it structurally cannot answer: PrincipalResolver (how do you authenticate?) and TrustedServiceValidator (who is an internal caller?).
A hook is the better home, but a hook alone buys nothing: wired at today's call site its only honest signature is func(host) bool, which is the config list expressed as a callback — same information, same granularity, no per-customer capability. An earlier revision of this issue listed it as an independent option; that was wrong.
So the shape of any real fix
- Defer the publisher decision until after the principal resolves (accepting the pre-fetch loss), then
- hand that decision to a deployer hook carrying
(account_id, project_id, host).
Step 1 without step 2 leaves customer policy in an operator's YAML. Step 2 without step 1 is decoration.
Prior art, for calibration
The obvious reference implementation ships no allowlist mechanism at all — one tenant-level toggle, set independently per tenant, no inheritance. Worth noting because their tenant is the customer: their per-tenant granularity is the thing we lack, and they get it for free by being multi-tenant at the deployment boundary rather than inside one deployment. They also require a confidential client's jwks_uri to be same-origin with the CIMD URL, which is the constraint already proposed on #264.
So "no allowlist, but per-customer enablement" is a coherent posture. "A global allowlist on a multi-tenant deployment" is not.
Adjacent, worth its own decision
GetPublicByClientID / GetByClientID filter on client_id only, while the row is tenant-scoped. A client_id is an AS-global identifier in OAuth, so this is defensible, but it means client→tenant is 1:1 by data rather than enforced by the lookup — and any future per-tenant client policy has to decide whether that holds.
cimd.allowed_domainsis a single deployment-wide set, but every other authorization decision in ZeroID is scoped to(account_id, project_id). On a multi-tenant deployment it is therefore not a per-customer control at all, and one consequence is already shipped: #285's escape hatch is unusable on the hosted product.What is actually deployment-wide
domainAllowedtakes no tenant:CIMDConfighangs offConfig(config.go:43), is read once inNewServer(server.go:361), and there is no tenant dimension anywhere in the type or its consumers. So a host approved for one customer is approved for every customer on that deployment.That is fine for a single-tenant install. On Studio/AuthN, where accounts are customers, it means the "primary production hardening lever" cannot express the policy a customer would actually want, and enabling it on anyone's behalf widens what every other tenant accepts.
The shipped consequence
#285 declines RFC 6749 §4.1.2.1 error redirects for self-asserted (CIMD) clients, on the grounds that their
redirect_urisare attacker-chosen whileallowed_domainsis empty — and offers "setcimd.allowed_domains" as the way to restore them, because vetting the publishing hosts restores the assumption the rule rests on.On a multi-tenant deployment that hatch is all-or-nothing: restore redirects for everyone, accepting one customer's publishers on behalf of all, or for nobody. So in practice CIMD clients on the hosted product never get error redirects, and #285's docs currently imply otherwise. Correcting that wording is a small follow-up; the design question is this issue.
Why a per-tenant allowlist is not a drop-in
At the point CIMD resolution runs, the tenant is unknown.
#285 hoisted client resolution to step 3.5, before principal resolution, precisely so a validated
redirect_uriexists early. The tenant arrives with the principal at step 4:account_id/project_id(domain/token.go:163-164), so its tenant is knowable at 3.5 — thoughGetPublicByClientIDlooks up onclient_idalone, with no tenant filter;synthesizeCIMDClientsets no tenant fields, and cannot: nothing in the request names one.At the decision point (
internal/service/cimd.go:336) the entire scope isctxandclientID:and nothing puts tenancy in that context — the only tenant-in-context mechanism,
internal/middleware/apikey.go:38, is not mounted on the public routes, and could not help anyway since it presumes the caller already authenticated.The ordering constraint is inherent, not a plumbing gap
This is the part worth settling before anyone writes code, because it collapses most of the option space.
Suppose we added a tenant parameter. Where would the value come from?
So "is this publisher acceptable for THIS customer" is a question that can only be answered once the principal is resolved. That is what the question means, not a limitation of any particular design.
Consequence: deferring the check is a precondition, not one option among several. The apparent alternatives are really just different ways of making a trustworthy tenant available earlier, and each pays for it:
acct-x.as.example.com)Hostheader, pre-authissis the AS's identity and the anchor of the discovery document (RFC 8414 §3); per-tenant issuers multiply discovery and change every token'siss. Platform-level, not a CIMD fix.access_deniedfires with no validatedredirect_uriOnly once a tenant is available does the second question arise — where the policy lives:
zeroid.yamlis a file an operator edits at deploy time, while "what Acme's admin chose" belongs to product state.func(tenant, host) bool— answered by AuthN from per-account settings. Same division ZeroID already uses for questions it structurally cannot answer:PrincipalResolver(how do you authenticate?) andTrustedServiceValidator(who is an internal caller?).A hook is the better home, but a hook alone buys nothing: wired at today's call site its only honest signature is
func(host) bool, which is the config list expressed as a callback — same information, same granularity, no per-customer capability. An earlier revision of this issue listed it as an independent option; that was wrong.So the shape of any real fix
(account_id, project_id, host).Step 1 without step 2 leaves customer policy in an operator's YAML. Step 2 without step 1 is decoration.
Prior art, for calibration
The obvious reference implementation ships no allowlist mechanism at all — one tenant-level toggle, set independently per tenant, no inheritance. Worth noting because their tenant is the customer: their per-tenant granularity is the thing we lack, and they get it for free by being multi-tenant at the deployment boundary rather than inside one deployment. They also require a confidential client's
jwks_urito be same-origin with the CIMD URL, which is the constraint already proposed on #264.So "no allowlist, but per-customer enablement" is a coherent posture. "A global allowlist on a multi-tenant deployment" is not.
Adjacent, worth its own decision
GetPublicByClientID/GetByClientIDfilter onclient_idonly, while the row is tenant-scoped. Aclient_idis an AS-global identifier in OAuth, so this is defensible, but it means client→tenant is 1:1 by data rather than enforced by the lookup — and any future per-tenant client policy has to decide whether that holds.