diff --git a/.changeset/tenant-header-edge-contract-5279.md b/.changeset/tenant-header-edge-contract-5279.md new file mode 100644 index 000000000..3ba15b941 --- /dev/null +++ b/.changeset/tenant-header-edge-contract-5279.md @@ -0,0 +1,41 @@ +--- +'@object-ui/auth': patch +--- + +Document the `X-Tenant-ID` edge contract that `createAuthenticatedFetch` stamps, and the +unstamped-first-request window in which it is not sent (objectui#5279). Documentation +only — no behaviour changes. + +The header had no written contract anywhere, and the shape of the missing information was +actively misleading: its only non-CORS consumer lives in the **cloud** repository, so a +search confined to this repo and the framework (`objectstack`) returns zero readers and +reads as "nothing consumes this stamp". #5279 was filed on exactly that reading, and was +held until a cloud-side reading came back non-empty. Without the contract written down, +the next person to grep reaches the same false conclusion and deletes a live routing +input. + +`packages/auth/README.md` gains "The `X-Tenant-ID` edge contract": what the header means +(a routing hint carrying the better-auth `activeOrganizationId` — not an identity claim, +not an authorization input, not what scopes rows), who stamps it and under exactly which +condition, who reads it, and what a reader may and may not assume. The framework half is +stated as a negative with its pin — `resolveAuthzContext` takes `tenantId` from the +API-key principal or `session.activeOrganizationId` and from no header — alongside +`plugin-sharing`'s record that trusting `x-tenant-id` as identity *was* a vulnerability. +The configuration half is quoted from the contract this package can actually resolve, +`TenantRoutingConfigSchema` in `@objectstack/spec/cloud`, where `X-Tenant-ID` is the +default of a configurable `tenantHeaderName` and `header` ranks second of six +identification sources behind `subdomain`. + +The unstamped-first-request gap gets its own section: `ActiveOrganizationStorage` is +filled only after `AuthProvider`'s async `getSession` -> `listOrganizations` -> +`getActiveOrganization` chain resolves, so early-boot requests carry no tenant header at +all. What a reader observes is documented as **absent, never present-and-empty**, with the +five situations that open the window and the instruction to fall through to the next +identification source rather than fail closed. The gap is recorded, deliberately not +closed: the cloud readers observe today's behaviour, so changing when the header first +appears is its own decision. + +Three cases in `createAuthenticatedFetch.test.tsx` pin the statements the prose makes +about the wire — no active organization means no header at all, the stamp is not gated on +`/api/` the way `Authorization` is, and the active organization overwrites a caller-set +`X-Tenant-ID` — so the documentation cannot drift away from the behaviour unnoticed. diff --git a/packages/auth/README.md b/packages/auth/README.md index ca4e100ff..653cad42e 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -137,6 +137,154 @@ const authedFetch = createAuthenticatedFetch(); const apiProviderFetch = createAuthenticatedFetch({ sameOriginOnly: true }); ``` +## The `X-Tenant-ID` edge contract + +`createAuthenticatedFetch` stamps `X-Tenant-ID` on the requests it wraps. This section is +that header's contract: what it means, who stamps it, who reads it, what a reader may +assume, and the one window in which it is not sent at all. + +It is written down because **a grep cannot answer any of those questions from this +repository.** The header's only non-CORS consumer lives in the **cloud** repository, so a +search confined to this repo and the framework (`objectstack`) finds zero readers and +reads as *"nothing consumes this stamp"*. That reading is what +[objectui#5279](https://github.com/objectstack-ai/objectui/issues/5279) was filed on, and +it is false. Do not act on it. + +### What it means + +A **routing hint** for the hosting edge — *this request belongs to tenant ``*: + +```http +X-Tenant-ID: +``` + +The value is the active **organization** id, the same id the session carries as +`session.activeOrganizationId`. The header is **not** an identity claim, **not** an +authorization input, and **not** what scopes rows. + +### Who stamps it + +| | | +| --- | --- | +| Stamped by | `createAuthenticatedFetch` ([`src/createAuthenticatedFetch.ts`](src/createAuthenticatedFetch.ts)) | +| Value read from | `ActiveOrganizationStorage` — `localStorage`, key `auth-active-organization-id` | +| Condition | that storage holds a non-empty value | +| *Not* conditioned on | the URL being an `/api/` call. `Authorization` and `Accept-Language` are; this is not | +| Suppressed by | `createAuthenticatedFetch({ sameOriginOnly: true })` for cross-origin URLs — it returns before any header work | +| Precedence | overwrites an `X-Tenant-ID` the caller passed in `init.headers` | + +`ActiveOrganizationStorage` has exactly one writer, [`AuthProvider`](src/AuthProvider.tsx): + +| Event | Effect | +| --- | --- | +| `refreshOrganizations` — after `getSession` → `listOrganizations` → `getActiveOrganization` resolves (including the ADR-0081 single-membership repair) | set | +| `switchOrganization` — the org switcher | set, or clear when the server returns no org | +| `deleteOrganization` / `leaveOrganization`, when the active org is the one going away | clear | +| sign-out | clear | + +### Who reads it + +**The cloud edge — yes.** The non-test readers are recorded on objectui#5279: +`packages/service-tenant/src/tenant-context.ts` (header resolution) and +`packages/tenant-router/src/spec/turso-multi-tenant.zod.ts`, both in the `cloud` +repository. That repository is not readable from this one, so those paths are cited as the +recorded reading rather than re-derived here. + +**Its configuration contract, though, IS readable from here**, because this package +depends on `@objectstack/spec`. `TenantRoutingConfigSchema` (`@objectstack/spec/cloud`) is +what configures a tenant resolver; parsing an empty config on the version this package +resolves today (17.1.0) yields: + +```text +enabled: false +identificationSources: ["subdomain", "header", "jwt_claim"] +tenantHeaderName: "X-Tenant-ID" +jwtOrganizationClaim: "organizationId" +``` + +Two consequences matter to a client author: + +- **The header name is configurable.** `X-Tenant-ID` is its default, not a constant. +- **The header is one of six identification sources** (`subdomain`, `custom_domain`, + `header`, `jwt_claim`, `session`, `default`), and in the default precedence it ranks + **second, behind `subdomain`**. On a subdomain-routed deployment it is not the thing + that picks the tenant, and a deployment may leave it out of `identificationSources` + entirely. + +**The framework (`objectstack`) — no.** `resolveAuthzContext` +(`packages/core/src/security/resolve-authz-context.ts`) derives `tenantId` from the +API-key principal or from `session.activeOrganizationId`, and from no header; +`packages/verify/src/harness.org-context.test.ts` pins it — *"`session.activeOrganizationId` +is the ONE field `resolveAuthzContext` reads into `tenantId`"*. Environment and kernel +routing read the hostname and `X-Environment-Id`. The framework's only other mentions are +the CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS`, whose comment describes +`X-Tenant-ID` / `X-Environment-Id` as what routes "a request to its environment") and +`plugin-sharing`, which records that trusting `x-tenant-id` as identity **was a +vulnerability**: its secure default stopped reading identity from headers because doing so +let a client forge attribution and enumerate or revoke other users' links. + +### What a reader may assume + +A reader **may**: + +- use it to select the tenant database or environment to route to, subject to its own + `identificationSources` precedence; +- treat it as a hint that may be absent, stale, or contradicted by the session. + +A reader **may not**: + +- treat it as authenticated identity, or as an authorization decision. It is + client-controlled — any caller can send any value, and `plugin-sharing` is the recorded + precedent for what happens to a server that trusts it; +- assume the row scoping it sees downstream came from this header. It did not: the + framework scopes from the session; +- assume the header is present. See below. + +### The unstamped-first-request gap + +The stamp reads storage that `AuthProvider` fills only **after** `getSession` → +`listOrganizations` → `getActiveOrganization` resolves. Every request that leaves +before that chain completes carries **no** `X-Tenant-ID` at all. + +The window opens in five situations: + +1. a browser that has never signed in, or whose site data was cleared, including a private + window; +2. the page load in which the user signs in — from the sign-in response until the + organization chain resolves; +3. after sign-out, which clears the storage, until a new organization resolves; +4. outside a browser (SSR, tests, a worker), where `localStorage` is unavailable and the + storage falls back to a per-process in-memory value that starts empty on every cold + start; +5. a browser where `localStorage` exists but **rejects writes** (Safari private browsing, + quota exhaustion). There the window never closes — the in-memory fallback is written + but never read back, because `get()` only falls back when reading itself *throws*, and + a rejected write leaves reads working and returning `null`. Reported on objectui#5279 + as a separate defect; it is not fixed here. + +Case 1 is not theoretical in this codebase: `app-shell`'s `MetadataProvider` issues its +eager `app` metadata fetch inside exactly this window, which is why its first-boot cache +scope needed its own reasoning +([`packages/app-shell/src/providers/MetadataProvider.tsx`](../app-shell/src/providers/MetadataProvider.tsx), +pinned by `MetadataProvider.firstBootOrgScope.test.tsx`). + +**What a reader observes in the window:** the header is **absent**, never +present-and-empty. A resolver must fall through to its next configured identification +source — `subdomain` already outranks it by default, with `jwt_claim`, `session` and +`defaultTenantId` behind it — rather than fail closed on the absence. It must also not +cache a routing decision taken inside the window as *the* tenant for the session: the very +next request will normally carry the header. + +**Why the gap does not corrupt data scoping.** A response computed inside the window is +still computed for the right tenant, because the framework takes `tenantId` from the +session rather than from the header. The gap is a *routing-input* gap, not a scoping gap. +This is the same reasoning objectui#5243 relied on when it relabelled a metadata cache +entry that had been written in the window. + +**Closing the gap is a separate decision, not an omission.** The cloud readers observe the +current behaviour, so changing when the header first appears changes what they see. If it +should be closed, it needs its own card. + ## Server Feature Flags (`GET /auth/config`) `createAuthClient().getConfig()` fetches the server's public auth configuration. The diff --git a/packages/auth/src/__tests__/createAuthenticatedFetch.test.tsx b/packages/auth/src/__tests__/createAuthenticatedFetch.test.tsx index 75d6ceb36..1adc9b480 100644 --- a/packages/auth/src/__tests__/createAuthenticatedFetch.test.tsx +++ b/packages/auth/src/__tests__/createAuthenticatedFetch.test.tsx @@ -111,4 +111,51 @@ describe('createAuthenticatedFetch', () => { await createAuthenticatedFetch()('https://third-party.example.com/api/x'); expect(calls[0].headers.get('Authorization')).toBe('Bearer tok123'); }); + + // ── X-Tenant-ID: the edge contract, pinned (#5279) ──────────────────── + // + // The header IS consumed — by the cloud edge, in a repository this one + // cannot see. These cases pin the three statements the README's "The + // `X-Tenant-ID` edge contract" section makes about what leaves the browser, + // so the prose cannot drift away from the wire without a red test. + + it('sends NO tenant header at all when no organization is active (the unstamped-first-request gap)', async () => { + // The gap itself, on the wire. `AuthProvider` fills + // `ActiveOrganizationStorage` only after its async organization chain + // resolves, so every request before that looks like this one. The + // distinction a cloud-side resolver depends on is ABSENT vs + // present-and-empty: an empty-string header would make the resolver see a + // tenant id of "" instead of falling through to its next identification + // source. + expect(ActiveOrganizationStorage.get()).toBeNull(); + const calls = stubFetch(); + await createAuthenticatedFetch()(API_URL); + expect(calls[0].headers.get('X-Tenant-ID')).toBeNull(); + expect(calls[0].headers.has('X-Tenant-ID')).toBe(false); + }); + + it('stamps the tenant header on non-API URLs too — recorded, not endorsed (#5279)', async () => { + // Unlike `Authorization` and `Accept-Language`, the tenant stamp is not + // gated on `isApiCall`. This case exists so that asymmetry is VISIBLE: + // it records what ships today, it does not bless it, and the question of + // whether the stamp should be gated is reported on #5279 for triage. If + // that is answered by gating the stamp, this expectation changes with the + // fix — deliberately, rather than silently. + ActiveOrganizationStorage.set('org-42'); + const calls = stubFetch(); + await createAuthenticatedFetch()('http://localhost/static/logo.png'); + expect(calls[0].headers.get('X-Tenant-ID')).toBe('org-42'); + // The contrast that makes the asymmetry the point of this case: + expect(calls[0].headers.get('Authorization')).toBeNull(); + }); + + it('the active organization wins over an X-Tenant-ID the caller supplied', async () => { + // `headers.set` overwrites. Stated in the README as the header's + // precedence rule, because a caller reading its own value back off the + // request would otherwise be surprised. + ActiveOrganizationStorage.set('org-42'); + const calls = stubFetch(); + await createAuthenticatedFetch()(API_URL, { headers: { 'X-Tenant-ID': 'org-caller' } }); + expect(calls[0].headers.get('X-Tenant-ID')).toBe('org-42'); + }); }); diff --git a/packages/auth/src/createAuthenticatedFetch.ts b/packages/auth/src/createAuthenticatedFetch.ts index 50ed652b1..8ee42dc4d 100644 --- a/packages/auth/src/createAuthenticatedFetch.ts +++ b/packages/auth/src/createAuthenticatedFetch.ts @@ -22,8 +22,21 @@ export interface AuthenticatedAdapterOptions { const ACTIVE_ORG_STORAGE_KEY = 'auth-active-organization-id'; /** - * Get/set the active organization ID for tenant-scoped API requests. - * Used by createAuthenticatedFetch to inject X-Tenant-ID header. + * Get/set the active organization id that {@link createAuthenticatedFetch} + * stamps as `X-Tenant-ID`. + * + * `AuthProvider` is the only writer, at four moments: `refreshOrganizations` + * sets it once the `getSession` -> `listOrganizations` -> + * `getActiveOrganization` chain resolves (including the ADR-0081 + * single-membership repair), `switchOrganization` sets or clears it, + * `deleteOrganization` / `leaveOrganization` clear it when the active org is + * the one going away, and sign-out clears it. + * + * Because the first of those is asynchronous, this reads EMPTY for the first + * stretch of a boot, and every request that leaves in that window carries no + * tenant header at all. That window is a documented part of the header's + * contract, not an accident to paper over — see the "unstamped-first-request + * gap" section of this package's README (objectui#5279). */ export const ActiveOrganizationStorage = { _memoryValue: null as string | null, @@ -82,7 +95,9 @@ function isCrossOrigin(url: string): boolean { /** * Creates an authenticated fetch wrapper that injects the Bearer token * from localStorage into every request to the ObjectStack API. - * Also injects X-Tenant-ID header when an active organization is set. + * Also injects X-Tenant-ID header when an active organization is set — see the + * "The `X-Tenant-ID` edge contract" section of this package's README for what + * that header means, who reads it, and the window in which it is not sent. * * @example * ```ts @@ -111,7 +126,43 @@ export function createAuthenticatedFetch( if (token && isApiCall) { headers.set('Authorization', `Bearer ${token}`); } - // Inject tenant header for multi-tenant routing + // ── `X-Tenant-ID` — the edge routing contract (objectui#5279) ──────── + // + // WHAT IT MEANS. A routing hint for the hosting edge: "this request belongs + // to tenant ". The value is the better-auth `activeOrganizationId` the + // session already carries. It is NOT an identity claim, NOT an + // authorization input, and NOT what scopes rows. + // + // DO NOT DELETE THIS ON THE STRENGTH OF A GREP. The framework + // (`objectstack`) does not read it: `resolveAuthzContext` takes `tenantId` + // from the API-key principal or `session.activeOrganizationId` and never + // from a header, and environment routing reads the hostname and + // `X-Environment-Id`. So a search confined to this repo plus the framework + // finds zero consumers and reads as "dead stamp" — which is the false + // premise objectui#5279 was filed on. The consumer is in the CLOUD repo, + // which neither checkout contains: `service-tenant`'s `tenant-context.ts` + // resolves the header, and `tenant-router`'s `turso-multi-tenant.zod.ts` + // configures that resolution. The configuration contract IS readable from + // here — `TenantRoutingConfigSchema` in `@objectstack/spec/cloud` defaults + // `tenantHeaderName` to `X-Tenant-ID` and ranks `header` SECOND of six + // identification sources, behind `subdomain`. + // + // THE UNSTAMPED-FIRST-REQUEST GAP. `ActiveOrganizationStorage` is filled + // only after AuthProvider's async organization chain resolves, so early + // requests go out with the header ABSENT — never present-and-empty. A + // reader must fall through to its next identification source rather than + // fail closed. Nothing about row visibility rides on this: the framework + // scopes from the session, so a response computed inside the window is + // still computed for the right tenant. + // + // Not gated on `isApiCall`, unlike `Authorization` and `Accept-Language` + // above — recorded as the behaviour that ships, not endorsed; the + // asymmetry is reported on objectui#5279 for triage to route. A wrapper + // built with `sameOriginOnly` short-circuits every cross-origin request + // before this line. + // + // Full contract, including what a reader may and may not assume: this + // package's README, "The `X-Tenant-ID` edge contract". const activeOrgId = ActiveOrganizationStorage.get(); if (activeOrgId) { headers.set('X-Tenant-ID', activeOrgId);