Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .changeset/api-key-carries-organization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
---
"@objectstack/platform-objects": minor
"@objectstack/plugin-auth": minor
"@objectstack/core": minor
"@objectstack/runtime": minor
---

feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287)

<!-- adr-0087: not-required (no-migration-prescription) One additive column on
an `isSystem` object declaring `protection: { lock: 'full' }`, which tenants
cannot author, so there is no consumer metadata to migrate and nothing
authorable is renamed, retired or tombstoned — no conversion to register. The
behavioural change is that a minted key now carries an organization, that a key
which cannot carry one is refused under the posture where it could never read
anything, and that an ex-member's key stops authenticating. -->

On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could
read **nothing at all**. `sys_api_key` carried no organization column, so key
authentication established a user but no active organization — and the
`isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with
no active organization matches no row. Every organization-scoped read answered
`200` with `total 0` while the console went on offering minting, so a tenant
admin could mint a valid-looking secret and discover only at call time that it
read nothing. (There was no cross-tenant leak — the failure was in the other
direction.)

**The column was absent by an inherited rule, not by oversight.**
`resolveInjectedSystemColumns` injects `organization_id` into every registered
object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that
flag — even though better-auth's `apiKey` plugin is not loaded and the table is
hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7
extension-field registration to stay consistent. The read side, by contrast,
was **already wired**: `resolveApiKeyPrincipal` already read an organization
into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a
column no mint path ever wrote.

**What changes**

- `sys_api_key` declares `active_organization_id` (+ index, and the column is
shown in the "My Keys" and "All" list views, because the card's complaint was
a credential whose reach its owner could not see).
- `POST /api/v1/keys` **inherits** the caller's active organization — there is
deliberately no org parameter and no cross-org key — and **re-checks the
caller's `sys_member` membership at mint time**, honouring ADR-0091 validity
windows. Under a walled posture it refuses (400) rather than minting a key
with no organization, and refuses (403) for an organization the caller is not
a member of. The mint response echoes the organization the key is pinned to.
- The verifier reads **one spelling** (Prime Directive #12): the
`row.organization_id ?? row.organizationId` chain it used to carry was a
consumer-side tolerance for a producer that did not exist.
- An **ex-member's key fails closed at verify time** — no principal, not a
degrade to a user-only principal, which would resurrect the same
`200 + total 0` silent-empty. Checked at verify rather than by revoking on
membership loss, because membership ends through many paths (better-auth org
endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and
a hook must catch every one or it silently misses. It costs **zero extra
queries**: the resolver has already read `sys_member` for this user.
- **Pre-existing org-less keys are never backfilled** — that would silently
upgrade credentials minted under a different promise. They keep working under
`single` (no wall) and under `group` (whose wall derives from the owner's
memberships independently of the active organization, so they already work
there), and are **refused under `isolated`**, where they are provably dead
today.

**The column is deliberately named `active_organization_id`, not
`organization_id`** — the `sys_session` spelling, for the same concept: the
organization a credential makes *active*. `objectHasOrgIdField` tests for the
literal `organization_id`, and Layer 0 exempts objects without it, so the other
name would have made `sys_api_key` itself org-walled. Both walled postures
exclude NULL, so every pre-existing org-less row would have vanished from its
**own owner's** "My Keys" list while, under `group`, continuing to
authenticate — a live credential nobody could see or revoke, which is a fresh
instance of the very class this change removes.
140 changes: 139 additions & 1 deletion packages/core/src/security/api-key.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
parseScopes,
isExpired,
resolveApiKeyPrincipal,
resolveApiKeyAdmission,
effectiveTenancyPosture,
} from './api-key.js';

/** In-memory sys_api_key store exposing the `find` shape the verifier uses. */
Expand DownExpand Up@@ -69,7 +71,11 @@ describe('resolveApiKeyPrincipal (shared verifier)', () => {
it('resolves a valid key to its principal (x-api-key)', async () => {
const raw = 'osk_valid';
const ql = makeQl([
{ key: hashApiKey(raw), revoked: false, user_id: 'u1', organization_id: 'org1', scopes: '["read"]', expires_at: FUTURE },
// [#8287] Re-spelled from `organization_id`: this fixture merely USED the
// alias the verifier no longer reads, and its assertion — a valid key
// resolves to owner + tenant + scopes — is unchanged and still reads a
// value the mint path really produces.
{ key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org1', scopes: '["read"]', expires_at: FUTURE },
]);
const p = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw });
expect(p).toEqual({ userId: 'u1', tenantId: 'org1', scopes: ['read'] });
Expand DownExpand Up@@ -103,3 +109,135 @@ describe('resolveApiKeyPrincipal (shared verifier)', () => {
expect(await resolveApiKeyPrincipal({}, { 'x-api-key': 'osk_x' })).toBeUndefined();
});
});

// ── [#8287] Organization on the key ────────────────────────────────────────

/**
* The card: under `OS_TENANCY_POSTURE=isolated` a minted key read NOTHING —
* `200 + total 0` on every org-scoped object — because `sys_api_key` carried no
* organization at all, and the `isolated` Layer 0 wall is
* `organization_id = activeOrganizationId`. With no active organization, no row
* can match. These tests pin both halves of the fix: the principal now carries
* the organization the key was minted against, and a key that carries none is
* REFUSED under the one posture where it is provably dead, instead of
* authenticating into a silent-empty.
*/
describe('resolveApiKeyAdmission — organization (#8287)', () => {
const raw = 'osk_org_probe';
// The posture is an INPUT now, resolved by the transport from the kernel's
// `tenancy` service — never read from the environment here. `effectiveTenancyPosture`
// is what performs that reconciliation; these tests exercise both it and the
// admission behaviour it feeds.

it('reads the organization off the row and carries it as tenantId', async () => {
const ql = makeQl([
{ key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org_a' },
]);
const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw });
expect(admission.outcome).toBe('admitted');
expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBe('org_a');
});

/**
* The canonical-spelling pin (PD #12). The verifier used to read
* `row.organization_id ?? row.organizationId` — a consumer-side alias chain
* for a producer that did not exist. The mint path now writes exactly one
* spelling, so the verifier reads exactly one: a row carrying only the OLD
* names resolves to NO organization, which is the honest answer for a row no
* mint path ever wrote.
*/
it('reads ONE spelling — the retired organization_id aliases do not resolve', async () => {
const ql = makeQl([
{ key: hashApiKey(raw), revoked: false, user_id: 'u1', organization_id: 'org_a', organizationId: 'org_a' },
]);
const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw });
// `single` (the default posture here) admits an org-less key, so this
// asserts the SPELLING, not the refusal.
expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBeUndefined();
});

it('admits an org-less key under `single` — there is no wall to fail', async () => {
const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]);
const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'single');
expect(admission.outcome).toBe('admitted');
});

/**
* `group`'s wall is `organization_id IN accessible_org_ids`, and that set is
* derived from the owner's `sys_member` rows INDEPENDENTLY of the active
* organization — so an org-less key already reads the union of its owner's
* organizations there. Refusing it would break working deployments for no
* security gain, which is why the refusal is posture-conditional rather than
* "no org ⇒ no key".
*/
it('admits an org-less key under `group` — it already works there', async () => {
const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]);
const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'group');
expect(admission.outcome).toBe('admitted');
});

it('REFUSES an org-less key under `isolated` — the posture where it is provably dead', async () => {
const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]);
const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated');
expect(admission.outcome).toBe('refused');
expect(admission.outcome === 'refused' && admission.reason).toBe('organization_required');
// The message is the operator-facing half of "loud at call time": it must
// name the posture and the remedy, not merely deny.
expect(admission.outcome === 'refused' && admission.message).toMatch(/isolated/);
});

/**
* The posture must be the ENFORCED one, not the requested one. ADR-0093 D4/D5:
* a deployment that asks for `isolated` without the enterprise organizations
* runtime runs with NO wall, and `tenancy.isolationActive` is how the service
* says so. Reading `OS_TENANCY_POSTURE` instead would refuse org-less keys on
* a deployment that has no wall at all.
*/
it('effectiveTenancyPosture reads the ENFORCED posture from the tenancy service', () => {
expect(effectiveTenancyPosture({ posture: 'isolated' })).toBe('isolated');
expect(effectiveTenancyPosture({ posture: 'multi' })).toBe('isolated'); // legacy alias
expect(effectiveTenancyPosture({ posture: 'group' })).toBe('group');
// No posture field: fall back to the boolean the service exposes.
expect(effectiveTenancyPosture({ isolationActive: true })).toBe('isolated');
expect(effectiveTenancyPosture({ isolationActive: false })).toBe('single');
// No service at all ⇒ undefined ⇒ callers apply no posture-conditional refusal.
expect(effectiveTenancyPosture(undefined)).toBeUndefined();
});

/**
* An unknown posture must NOT refuse. This is a question about the DEPLOYMENT,
* not about the credential: refusing here would break every org-less key on a
* `single` deployment whose transport has not been wired.
*/
it('admits an org-less key when the posture is unknown (no tenancy service)', async () => {
const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]);
const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), undefined);
expect(admission.outcome).toBe('admitted');
});

it('an ORG-STAMPED key is admitted under `isolated` — the fix, not just the refusal', async () => {
const ql = makeQl([
{ key: hashApiKey(raw), revoked: false, user_id: 'u1', active_organization_id: 'org_a' },
]);
const admission = await resolveApiKeyAdmission(ql, { 'x-api-key': raw }, Date.now(), 'isolated');
expect(admission.outcome).toBe('admitted');
expect(admission.outcome === 'admitted' && admission.principal.tenantId).toBe('org_a');
});

/**
* A refusal must stay distinguishable from "no key" — that distinction is
* the whole point of the admission type. `resolveApiKeyPrincipal` collapses
* both to `undefined` so every pre-existing caller keeps failing closed.
*/
it('resolveApiKeyPrincipal collapses a refusal to undefined (fail-closed for old callers)', async () => {
const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]);
const principal = await resolveApiKeyPrincipal(ql, { 'x-api-key': raw }, Date.now(), 'isolated');
expect(principal).toBeUndefined();
});

it('an absent key is `none`, never a refusal', async () => {
const ql = makeQl([{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }]);
const admission = await resolveApiKeyAdmission(ql, {}, Date.now(), 'isolated');
expect(admission.outcome).toBe('none');
});
});
Loading
Loading