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
45 changes: 45 additions & 0 deletions .changeset/core-authrefusal-field-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@objectstack/core": minor
---

fix(core): `ResolvedAuthzContext.authRefusal` is removed — a published member nothing ever read (#14273)

**BREAKING** published-type narrowing, shipped as `minor` under the repo's
launch-window convention for breaking changes. `ResolvedAuthzContext` — the
envelope `resolveAuthzContext` answers, exported from `@objectstack/core`'s
root entry — loses its optional `authRefusal?: { reason; message }` member.
Maintainer ruling 2026-09-02 (option A, ADR-0049 enforce-or-remove),
re-affirmed 2026-09-03 as A1 with the carriers a published narrowing owes
once the type was measured as public API: the member was written by the two
posture-conditional API-key refusals (`organization_required` at admission,
`organization_membership_ended` after grants) since #8287 and read by nothing
— zero runtime readers across every transport and consumer in the repo for
its whole life; only test assertions ever looked at it.

What changes:

- `ResolvedAuthzContext` no longer declares `authRefusal`. Code that reads
`ctx.authRefusal` stops compiling (`TS2339`); at runtime the property was
already absent from every resolved context except the two refused ones.
- The two refusals themselves are UNCHANGED: they still fire, still fail
closed (no `userId`, empty grants), and every transport still answers the
generic anonymous `401 UNAUTHENTICATED`. No status code, body or header
moves — a holder of someone else's key learns nothing, exactly as before.
- The refusal REASON is observable on exactly one surface, and it is not the
envelope: the server-side `[security] API key refused (reason) ...` `warn`
line at the decision point (#15256 / 2A), which names the key row id,
principal and organization for the operator. The pins that kept the two
reasons distinguishable through the field now read that line.
- `ApiKeyRefusalReason` and `ApiKeyAdmission` are unchanged — the reason
vocabulary still exists; it just no longer has a copy on the resolved
context.

**Migration.** A consumer that read `ctx.authRefusal` deletes the read; there
is no replacement on the envelope, by design — disclosing the reason to a
caller (option B) was ruled out as a security-boundary question, and the
recorded fallback if a reader ever appears is an audit-side outlet (option C),
never the wire. Fail-closed handling keys on the absent `userId`, as every
in-repo transport already did. An operator who needs the reason reads the
server log line.

<!-- adr-0087: not-required (runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolvedAuthzContext) A published runtime TypeScript interface lost an optional member. No Zod schema, no `packages/spec` declaration, no object definition and no stored representation is touched — `ResolvedAuthzContext` is a plain interface in `packages/core`, projected from no schema and referenced by no metadata surface — so `objectstack migrate meta` has nothing to rewrite and there is no tombstone to mint. The channel that reaches an affected consumer is the compiler at the read site (`TS2339`), which is more precise than a ledger line. The in-repo census (zero runtime readers; the only readers were test assertions, relocated onto the `warnApiKeyRefusal` line) and the workspace typecheck are recorded on the PR. -->
2 changes: 1 addition & 1 deletion .changeset/membership-ended-session-revoke.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ an evaluation cannot.
cause. There is no Zod enum behind the column — it is free `text` — so the field's own
description is the published vocabulary, and that is where the value is declared. The
string deliberately matches the one the API-key arm of the same ruling family already
mints for this event (`authRefusal.reason` in `resolve-authz-context.ts`), so one grep
mints for this event (`ApiKeyRefusalReason` in `resolve-authz-context.ts`), so one grep
finds every place the platform acts on a membership ending.
- **The trigger acts on the ORGANIZATION'S CLAIM, never on the user** (maintainer ruling,
decision batch #49 item 4, option B). A user who still holds another membership is
Expand Down
14 changes: 7 additions & 7 deletions packages/core/src/security/authz-store-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@
* ## Why a THROW, and not a field on the envelope
*
* The alternative was a discriminator field on `ResolvedAuthzContext` — the
* shape `authRefusal` already has. That was rejected on a MEASUREMENT, not a
* preference: `authRefusal` has existed since #8287 and, outside this module
* and its own unit test, has **zero** consumers anywhere in the repo. A
* diagnostic field on this envelope is demonstrably not read by any door. Every
* transport reads `userId` and `systemPermissions`; a new sibling field would
* have to be taught to eight separate call sites before it made a single door
* louder, and would answer the old quiet 403 at every site that was missed.
* shape `authRefusal` had (#8287). That was rejected on a MEASUREMENT, not a
* preference: from #8287 until #14273 removed it, `authRefusal` had **zero**
* consumers anywhere in the repo outside this module and test assertions — a
* reading #14273 acted on by deleting the field. Every transport reads `userId`
* and `systemPermissions`; a new sibling field would have to be taught to eight
* separate call sites before it made a single door louder, and would answer
* the old quiet 403 at every site that was missed.
*
* A field is quiet by default and must be deliberately made loud. A throw is
* loud by default and must be deliberately silenced. On a security surface
Expand Down
64 changes: 56 additions & 8 deletions packages/core/src/security/resolve-authz-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ import { POSTURE_RANK } from './posture-ladder.js';
import { hashApiKey } from './api-key.js';
import type { AuthzPosture } from '@objectstack/spec/security';

/**
* [#14273 A1] The refusal REASON is observable on exactly ONE surface: the
* server-side `warnApiKeyRefusal` line (#15256 / 2A). `ResolvedAuthzContext.
* authRefusal` was the envelope's copy of it and, from #8287 on, had zero
* readers outside test assertions — so the field is gone, and the pins that
* kept the two reasons DISTINGUISHABLE read the line the operator reads.
* ⛔ Not the wire: a caller still gets the generic anonymous 401.
*/
const apiKeyRefusalLines = (spy: ReturnType<typeof vi.spyOn>) =>
spy.mock.calls
.map((c: unknown[]) => c.map(String).join(' '))
.filter((l: string) => l.includes('API key refused'));
const apiKeyRefusalReasons = (spy: ReturnType<typeof vi.spyOn>) =>
apiKeyRefusalLines(spy).map((l: string) => /API key refused \(([a-z_]+)\)/.exec(l)?.[1]);

/**
* Contract test for the SINGLE authorization resolver. Every authorization
* source MUST be honored here — this is the regression net that would have
Expand Down Expand Up @@ -1162,12 +1177,16 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => {
sys_user_permission_set: [],
});

let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); });
afterEach(() => { warnSpy.mockRestore(); });

it('adopts the key organization as the request tenant when membership holds', async () => {
const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_a', role: 'member' }]));
const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' });
expect(ctx.userId).toBe('u1');
expect(ctx.tenantId).toBe('org_a');
expect(ctx.authRefusal).toBeUndefined();
expect(apiKeyRefusalLines(warnSpy)).toHaveLength(0);
});

/**
Expand All @@ -1186,7 +1205,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => {
expect(ctx.userId).toBeUndefined();
expect(ctx.tenantId).toBeUndefined();
expect(ctx.permissions).toEqual([]);
expect(ctx.authRefusal?.reason).toBe('organization_membership_ended');
expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']);
});

it('refuses when the membership row exists but its ADR-0091 window has lapsed', async () => {
Expand All @@ -1195,14 +1214,14 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => {
]));
const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' });
expect(ctx.userId).toBeUndefined();
expect(ctx.authRefusal?.reason).toBe('organization_membership_ended');
expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']);
});

it('the same key under `group` is refused too — the wall is membership-derived there as well', async () => {
const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_other', role: 'member' }]));
const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'group' });
expect(ctx.userId).toBeUndefined();
expect(ctx.authRefusal?.reason).toBe('organization_membership_ended');
expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']);
});

/**
Expand All @@ -1214,7 +1233,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => {
const ql = makeQl(tables([]));
const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'single' });
expect(ctx.userId).toBe('u1');
expect(ctx.authRefusal).toBeUndefined();
expect(apiKeyRefusalLines(warnSpy)).toHaveLength(0);
});

/**
Expand All @@ -1238,7 +1257,7 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => {
tenancyPosture: 'isolated',
});
expect(ctx.userId).toBeUndefined();
expect(ctx.authRefusal?.reason).toBe('organization_required');
expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_required']);
});

/**
Expand All @@ -1247,6 +1266,35 @@ describe('resolveAuthzContext — API-key organization (#8287)', () => {
* how that stays true: a later refactor that re-reads `sys_member` for this
* check turns a free assertion into a per-request cost, silently.
*/
/**
* [#14273 A1] The envelope carries NO refusal field. Pinned by own-property
* on BOTH refusal paths, so a writer that re-adds the member under any name
* or type reddens here — a typed read cannot pin an absence the compiler
* already refuses. The reason lives on the warn line (above) and nowhere on
* the context; the wire never carried it and still does not.
*/
it('[#14273] a post-grant refusal answers an envelope with no `authRefusal` — the reason is server-side only', async () => {
const ql = makeQl(tables([{ user_id: 'u1', organization_id: 'org_other', role: 'member' }]));
const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' });
expect(ctx.userId).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(ctx, 'authRefusal')).toBe(false);
expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']);
});

it('[#14273] an admission refusal answers an envelope with no `authRefusal` either', async () => {
const ql = makeQl({
sys_api_key: [{ key: hashApiKey(raw), revoked: false, user_id: 'u1' }],
sys_user: [{ id: 'u1' }],
sys_member: [{ user_id: 'u1', organization_id: 'org_a', role: 'owner' }],
sys_user_position: [],
sys_user_permission_set: [],
});
const ctx = await resolveAuthzContext({ ql, headers: keyHeaders(), tenancyPosture: 'isolated' });
expect(ctx.userId).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(ctx, 'authRefusal')).toBe(false);
expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_required']);
});

it('costs zero additional queries (sys_member is read once)', async () => {
let memberReads = 0;
const inner = makeQl(tables([{ user_id: 'u1', organization_id: 'org_a', role: 'member' }]));
Expand Down Expand Up @@ -1663,7 +1711,7 @@ describe('[#15409] a session organization claim that no membership backs', () =>
// switch to it instead of being signed out of everything.
expect(ctx.accessible_org_ids).toEqual(['org_beta']);
// ⛔ And it is NOT the API-key refusal: no principal was refused.
expect(ctx.authRefusal).toBeUndefined();
expect(apiKeyRefusalLines(warnSpy)).toHaveLength(0);
});

/**
Expand Down Expand Up @@ -1778,7 +1826,7 @@ describe('[#15409] a session organization claim that no membership backs', () =>
ql, headers: { 'x-api-key': raw }, tenancyPosture: 'isolated',
});
expect(ctx.userId).toBeUndefined();
expect(ctx.authRefusal?.reason).toBe('organization_membership_ended');
expect(apiKeyRefusalReasons(warnSpy)).toEqual(['organization_membership_ended']);
// ⛔ Not degraded into the session's drop: the key is REFUSED, not trimmed.
expect(dropLines()).toHaveLength(0);
});
Expand Down
33 changes: 3 additions & 30 deletions packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,26 +114,6 @@ export interface ResolvedAuthzContext {
* anonymous requests carry no rung.
*/
posture?: AuthzPosture;
/**
* [#8287] Set when an inbound API key was REFUSED — a real, intact
* credential this deployment's tenancy posture cannot admit. The context is
* otherwise EMPTY (no `userId`), so every transport already fails it closed
* to 401 with no change; this field only lets a transport that wants to say
* WHY do so, instead of answering the operator with a bare "unauthenticated"
* for a key they can see is neither revoked nor expired.
*
* ⚠️ `reason` is NOT an `error.code`. The wire vocabulary is closed
* (ADR-0112: `StandardErrorCode ∪ ERROR_CODE_LEDGER`, both in `packages/spec`)
* and a refused credential's standard member is `UNAUTHENTICATED`. This is a
* diagnostic discriminator for the message, deliberately lowercase so it can
* never be mistaken for one.
*
* ⚠️ [#14273 A1] This field has ZERO consumers outside test assertions and is
* REMOVED by that card, in its own PR. The operator exit it was meant to be
* is now {@link warnApiKeyRefusal}'s server-side `warn` line (#15256 / 2A),
* which is why removing it costs nothing. ⛔ Not removed here.
*/
authRefusal?: { reason: ApiKeyRefusalReason; message: string };
}

export interface ResolveAuthzInput {
Expand Down Expand Up @@ -182,9 +162,9 @@ function safeJsonParse<T>(s: string, fallback: T): T {
* is neither revoked nor expired, and a 401 that says only "unauthenticated".
*
* `ResolvedAuthzContext.authRefusal` was that exit and never got a consumer
* (zero readers outside two test assertions); #14273's A1 ruling REMOVES the
* field in its own PR. ⛔ Not removed here — cross-referenced only. This log
* line is the operator exit that field never delivered.
* (zero readers outside test assertions, #8287 through #14273); #14273's A1
* ruling removed the field. This log line is the operator exit that field
* never delivered, and the one place the refusal REASON is observable.
*
* ## What may appear here
*
Expand Down Expand Up @@ -407,7 +387,6 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
userId: admission.userId,
organizationId: admission.organizationId,
});
ctx.authRefusal = { reason: admission.reason, message: admission.message };
return ctx;
}
const keyPrincipal = admission.outcome === 'admitted' ? admission.principal : undefined;
Expand Down Expand Up @@ -492,12 +471,6 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
systemPermissions: [],
org_user_ids: [],
accessible_org_ids: [],
authRefusal: {
reason: 'organization_membership_ended',
message:
'This API key authenticates into an organization its owner is no longer a member of. '
+ 'The key was not revoked — the membership that backed it ended.',
},
};
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/plugin-auth/src/membership-ended-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ import { SystemObjectName } from '@objectstack/spec/system';
*
* It is deliberately the SAME string the API-key arm of this ruling family
* already mints for the same event: `resolve-authz-context.ts` refuses an API
* key whose backing membership ended with
* `authRefusal.reason: 'organization_membership_ended'` (#15256, decision 1A).
* key whose backing membership ended with the `ApiKeyRefusalReason`
* `'organization_membership_ended'` (#15256, decision 1A; a server-side line).
* One grep therefore finds every place the platform acts on a membership
* ending, across both credential kinds — worth more than four saved
* characters, and comfortably inside the column's `maxLength: 64`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ describe('the payload agrees with the ONE authorization authority, set for set',

// [#15136 review — the standing guard's carve-out, measured rather than asserted
// in prose] With NO active organization the resolver projects EVERY membership
// (`resolve-authz-context.ts:815`), so membership-derived names are ADDED to a
// (`resolve-authz-context.ts:788`), so membership-derived names are ADDED to a
// payload that previously carried none of them. That is a behaviour change in
// its own right and the changeset now names it; this pins the shape it names.
it('projects membership names even with no active organization on the session', async () => {
Expand Down Expand Up @@ -297,7 +297,7 @@ describe('the payload agrees with the ONE authorization authority, set for set',
expect(activeOrg, `expected no active org, got ${activeOrg}`).toBeFalsy();

// The resolver projects EVERY membership when no tenant scopes it
// (`resolve-authz-context.ts:815`), so these names are ADDED to a payload
// (`resolve-authz-context.ts:788`), so these names are ADDED to a payload
// that carried none of them before this card. That is the behaviour change
// the changeset's carve-out names.
const positions = (envelope as any)?.user?.positions ?? [];
Expand Down
Loading
Loading