Skip to content

cimd.allowed_domains is deployment-wide on a multi-tenant AS; the tenant is unknown when CIMD resolves #286

Description

@rsharath

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 hookfunc(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

  1. Defer the publisher decision until after the principal resolves (accepting the pre-fetch loss), then
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions