Skip to content
21 changes: 21 additions & 0 deletions .changeset/banned-user-credentials-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/core': patch
'@objectstack/platform-objects': patch
'@objectstack/plugin-auth': patch
---

fix(core)!: a banned user's credentials are refused while the ban holds, at every door that accepts an API key

Clause-②: yes (narrowing) — while a user's ban holds, a request authenticated by an API key that user owns is answered as a key that does not exist (the generic `401 UNAUTHENTICATED`), at every door that accepts a key: the REST data door, the MCP door, and every other door that resolves a key through the shared key-admission seam. The widening half: `@objectstack/core` exports the one ban predicate, `isUserBanInForce` (with `USER_BAN_FIELDS` and the `UserBanFields` type), which the key-admission seam and plugin-auth's session-shaped refusals now share.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and nothing at rest changes: the ban columns are read where a key becomes its owner, and no key row is revoked or rewritten. -->

**BREAKING** accept-set narrowing at every key-admitting door (`@objectstack/core`): a banned user's API keys are refused while the ban holds.

- **The rule.** A ban holds every credential of the user. Banning a user already ends their sessions and refuses their sign-in; their API keys now answer as a key that does not exist for as long as the ban holds. A ban with an expiry stops binding once the expiry has passed, exactly as sign-in does — no sign-in is needed to lapse it.
- **Not a revocation.** No key row is revoked or rewritten. Lifting the ban (or letting it expire) admits the same keys again, with nothing reissued, each still judged by its own lifecycle: a key left unused past the inactivity window during the ban answers as inactive (`API_KEY_INACTIVE`), and its remedy is a new key.
- **One anonymous entry.** A key refused for its owner (a ban) or for the tenancy posture (no organization under `isolated`) resolves to the same entry as a key that does not exist, on every composition: the guest's grants on a face that serves the guest, nothing elsewhere. It never falls through to a session.
- **Who moves.** An integration running on a key whose owner is banned gets `401` until the ban is lifted or expires.

**The remedy.** Lift the ban, or mint a replacement key under a user who is not banned.
5 changes: 3 additions & 2 deletions content/docs/permissions/administrator-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ only on reorgs. Why the tree matters:
| **Import** (toolbar) | Bulk onboarding from CSV/Excel | Wizard with column mapping and dry-run preview; up to 500 rows per batch. Pick the sign-in policy: no password (first sign-in via OTP/magic/reset link), send invitations, or one-time temporary passwords |

Day-to-day lifecycle actions live on each user's row menu and record header:
**Ban / Unban** (blocks sign-in), **Unlock Account** (clears a brute-force
**Ban / Unban** (blocks sign-in, ends the user's sessions, and refuses their API keys
while the ban holds), **Unlock Account** (clears a brute-force
lockout early, at both the password and two-factor stages), **Set Password**
(also mints one-time temporary passwords),
and **Impersonate User** (see [Step 4](#step-4--verify)). *(Rough edge today:
Expand Down Expand Up @@ -180,7 +181,7 @@ Full model: [Permission Sets](/docs/permissions/permission-sets).
| I need to… | Do this |
|:--|:--|
| Onboard a new employee | Invite/Create the user → Business Unit Member row → assign their position ([2](#step-2--add-people-users)→[3](#step-3--assign-positions)) |
| Offboard someone | **Ban User** (blocks sign-in immediately) — then remove assignments at leisure |
| Offboard someone | **Ban User** (blocks sign-in and the user's API keys immediately) — then remove assignments at leisure |
| "I can't sign in" | User record → is it banned? locked? **Unlock Account** or **Set Password** (temporary, must-change) |
| "Why can't I see X?" | Studio → Access → **Explain access** for that user + object |
| Someone changed departments | Update their Business Unit Member row; re-anchor their User Position rows |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1253,8 +1253,8 @@ All endpoints are available under `/api/v1/auth/*`:
- `POST /api/v1/auth/admin/set-user-password` - Set/reset a user's password (also provisions a credential for SSO-onboarded users)
- `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `auto` (default, per-row invite-or-temporary) / `invite` / `temporary` / `none` password policy; ≤500 rows, dry-run supported)
- `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early
- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions)
- `POST /api/v1/auth/admin/unban-user` - Lift a ban, restoring the account's ability to sign in
- `POST /api/v1/auth/admin/ban-user` - Ban a user (blocks sign-in and revokes live sessions; the user's API keys are refused at every door while the ban holds, and a ban with an expiry stops binding once it expires. The keys are not revoked)
- `POST /api/v1/auth/admin/unban-user` - Lift a ban, restoring the account's ability to sign in and its API keys (each key is still judged by its own lifecycle: a key left unused past the inactivity window during the ban answers as inactive, and needs replacing)

#### Organization Membership (requires `plugins.organization`; platform-admin gated)

Expand Down
296 changes: 296 additions & 0 deletions packages/core/src/security/api-key-banned-owner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A banned user's credentials are refused while the ban holds — pinned at the
* shared key-admission seam (`resolveApiKeyAdmission`, reached by every door
* through `resolveAuthzContext`), where an API key becomes its owner.
*
* - an owner whose ban holds → the key is `refused` (`owner_banned`): no
* principal, no fall-through to the session path, and the resolved context
* is the one a key that does not exist resolves to;
* - a ban with an expiry binds until it expires and not after — the one
* predicate (`isUserBanInForce`) the session refusal shares;
* - not a revocation: nothing is written while the ban holds, and the same
* key is admitted again once it is lifted;
* - the key's own lifecycle still answers first;
* - an owner read that throws is the branded store outage; a store with no
* `sys_user` table holds no ban.
*/

import { describe, it, expect, vi } from 'vitest';
import { resolveAuthzContext } from './resolve-authz-context.js';
import { hashApiKey, resolveApiKeyAdmission, resolveApiKeyPrincipal } from './api-key.js';
import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js';
import { isUserBanInForce } from './user-ban.js';
import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js';

const KEY = 'osk_banned_owner_pin_key';
const MADE_UP_KEY = 'osk_no_row_holds_this_key';
const OWNER = 'u_key_owner';
const COOKIE_USER = 'u_cookie_holder';
const NOW = Date.parse('2026-11-05T12:00:00.000Z');

function makeQl(owner: Record<string, unknown> | null = { id: OWNER }, keyRow: Record<string, unknown> = {}) {
const tables: Record<string, any[]> = {
sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]', ...keyRow }],
sys_member: [],
sys_user: [...(owner ? [owner] : []), { id: COOKIE_USER }],
};
const reads: Array<{ object: string; opts: any }> = [];
const update = vi.fn(async () => ({}));
return {
tables,
reads,
update,
ql: {
update,
async find(object: string, opts: any) {
reads.push({ object, opts });
const where = opts?.where ?? {};
const rows = (tables[object] ?? []).filter((r) =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`);
return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v;
}),
);
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
},
},
};
}

const headers = (key = KEY, extra: Record<string, string> = {}) => new Headers({ 'x-api-key': key, ...extra });
const admit = (ql: any, key = KEY, nowMs = NOW) =>
resolveApiKeyAdmission(ql, headers(key), nowMs, undefined, undefined, undefined);

describe('isUserBanInForce — the one ban predicate', () => {
it('a set ban flag with no expiry holds', () => {
expect(isUserBanInForce({ banned: true }, NOW)).toBe(true);
expect(isUserBanInForce({ banned: 1, ban_expires: null }, NOW)).toBe(true);
});

it('a row without the ban flag is not banned, whatever its expiry says', () => {
expect(isUserBanInForce({ banned: false, ban_expires: new Date(NOW + 60_000) }, NOW)).toBe(false);
expect(isUserBanInForce({ banned: null }, NOW)).toBe(false);
expect(isUserBanInForce({}, NOW)).toBe(false);
expect(isUserBanInForce(undefined, NOW)).toBe(false);
});

it('a ban with an expiry holds until the expiry has passed, in either column spelling', () => {
expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW + 1).toISOString() }, NOW)).toBe(true);
expect(isUserBanInForce({ banned: true, banExpires: new Date(NOW + 1) }, NOW)).toBe(true);
// At the instant itself the ban still holds — the sign-in check lifts it only strictly after.
expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW) }, NOW)).toBe(true);
expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW - 1).toISOString() }, NOW)).toBe(false);
expect(isUserBanInForce({ banned: true, banExpires: NOW - 1 }, NOW)).toBe(false);
});

it('an expiry that is not an instant does not lift the ban', () => {
expect(isUserBanInForce({ banned: true, ban_expires: 'not-a-date' }, NOW)).toBe(true);
expect(isUserBanInForce({ banned: true, ban_expires: { at: NOW - 1 } }, NOW)).toBe(true);
});
});

describe('resolveApiKeyAdmission — a banned owner\'s key is refused while the ban holds', () => {
it('refuses the key of an owner whose ban holds, with no principal', async () => {
const { ql } = makeQl({ id: OWNER, banned: true });
const admission = await admit(ql);
expect(admission).toMatchObject({ outcome: 'refused', reason: 'owner_banned', keyId: 'key_1', userId: OWNER });
expect(admission).not.toHaveProperty('principal');
expect(await resolveApiKeyPrincipal(ql, headers(), NOW, undefined, undefined, undefined)).toBeUndefined();
});

it('admits the key of an owner who is not banned', async () => {
const { ql } = makeQl({ id: OWNER, banned: false });
expect((await admit(ql)).outcome).toBe('admitted');
});

it('reads the owner\'s ban columns by id, as the system', async () => {
const { ql, reads } = makeQl({ id: OWNER, banned: false });
await admit(ql);
const ownerReads = reads.filter((r) => r.object === 'sys_user');
expect(ownerReads).toHaveLength(1);
expect(ownerReads[0].opts).toMatchObject({
where: { id: OWNER },
fields: ['id', 'banned', 'ban_expires'],
limit: 1,
context: { isSystem: true },
});
});

it('a ban with an expiry binds until it expires, and not after', async () => {
const expires = new Date(NOW + 60_000).toISOString();
const { ql } = makeQl({ id: OWNER, banned: true, ban_expires: expires });
expect((await admit(ql, KEY, NOW)).outcome).toBe('refused');
expect((await admit(ql, KEY, NOW + 60_001)).outcome).toBe('admitted');
});

it('is not a revocation: nothing is written while the ban holds, and lifting it admits the same key', async () => {
const { ql, tables, update } = makeQl({ id: OWNER, banned: true });
const keyRowBefore = { ...tables.sys_api_key[0] };
expect((await admit(ql)).outcome).toBe('refused');
expect(update).not.toHaveBeenCalled();
expect(tables.sys_api_key[0]).toEqual(keyRowBefore);

tables.sys_user[0].banned = false;
expect((await admit(ql)).outcome).toBe('admitted');
});

it('the key\'s own lifecycle answers first: a revoked key of a banned owner is refused as revoked', async () => {
const { ql } = makeQl({ id: OWNER, banned: true }, { revoked: true });
expect((await admit(ql)).outcome).toBe('lifecycle');
});

it('a key whose owner row is absent keeps today\'s answer — no row holds no ban', async () => {
const { ql } = makeQl(null);
expect((await admit(ql)).outcome).toBe('admitted');
});

it('an owner read that throws is the branded store outage, never an admission', async () => {
const { ql } = makeQl({ id: OWNER, banned: true });
const find = ql.find.bind(ql);
ql.find = async (object: string, opts: any) => {
if (object === 'sys_user') throw new Error('connection reset');
return find(object, opts);
};
const err = await admit(ql).catch((e) => e);
expect(isAuthzStoreUnavailableError(err)).toBe(true);
expect(err.status).toBe(503);
expect(err.code).toBe('SERVICE_UNAVAILABLE');
});

it('a store with no sys_user table holds no ban', async () => {
const { ql } = makeQl({ id: OWNER, banned: true });
const find = ql.find.bind(ql);
ql.find = async (object: string, opts: any) => {
if (object === 'sys_user') throw new Error('no such table: sys_user');
return find(object, opts);
};
expect((await admit(ql)).outcome).toBe('admitted');
});
});

describe('resolveAuthzContext — a banned owner\'s key resolves as a key that does not exist', () => {
const resolve = (ql: any, key: string, getSession?: (h: any) => Promise<any>) =>
resolveAuthzContext({
ql,
headers: headers(key),
nowMs: NOW,
userAuthGates: undefined,
apiKeyLifecycle: undefined,
...(getSession ? { getSession } : {}),
});

it('resolves no principal, and the same context a made-up key resolves', async () => {
const { ql } = makeQl({ id: OWNER, banned: true });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const banned = await resolve(ql, KEY);
const madeUp = await resolve(ql, MADE_UP_KEY);
expect(banned.userId).toBeUndefined();
expect(banned.credential).toBeUndefined();
expect(banned).toEqual(madeUp);
// The reason is said server-side only.
expect(warn.mock.calls.flat().join('\n')).toContain('owner_banned');
} finally {
warn.mockRestore();
}
});

it('never falls through to a session sent beside the key', async () => {
const { ql } = makeQl({ id: OWNER, banned: true });
const getSession = vi.fn(async () => ({ user: { id: COOKIE_USER }, session: { userId: COOKIE_USER } }));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const authz = await resolve(ql, KEY, getSession);
expect(authz.userId).toBeUndefined();
expect(getSession).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
}
});

it('control: a non-banned owner\'s key resolves the owner, credential api_key', async () => {
const { ql } = makeQl({ id: OWNER, banned: false });
const authz = await resolve(ql, KEY);
expect(authz.userId).toBe(OWNER);
expect(authz.credential).toBe('api_key');
});

it('control: a session is not judged by this seam — a cookie with no key resolves as before', async () => {
const { ql } = makeQl({ id: OWNER, banned: true });
const getSession = vi.fn(async () => ({ user: { id: COOKIE_USER }, session: { userId: COOKIE_USER } }));
const authz = await resolveAuthzContext({
ql,
headers: new Headers(),
nowMs: NOW,
userAuthGates: undefined,
apiKeyLifecycle: undefined,
getSession,
});
expect(authz.userId).toBe(COOKIE_USER);
expect(authz.credential).toBe('session');
});
});

describe('a refused key gets the anonymous entry an unknown key gets, on a face that serves the guest', () => {
const GUEST_SET = 'public_catalog_reader';
const ORG = 'org_only';

/** A guest anchor that grants something, in the one organization the guest entry derives. */
function guestServingQl(keyRow: Record<string, unknown>, owner: Record<string, unknown>) {
const tables: Record<string, any[]> = {
sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]', ...keyRow }],
sys_user: [owner],
sys_member: [],
sys_organization: [{ id: ORG }],
sys_metadata_activation: [],
};
const registered = new Set(Object.keys(tables));
const ql = {
registry: { getObject: (name: string) => (registered.has(name) ? { name } : undefined) },
async find(object: string, opts: any) {
const where = opts?.where ?? {};
const rows = (tables[object] ?? []).filter((r) =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`);
return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v;
}),
);
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
},
};
bindStaticSecurityCatalog(ql, {
positions: [{ name: 'guest', permissionSets: [GUEST_SET] }],
permissions: [{ name: GUEST_SET, tabPermissions: { storefront: 'visible' } }],
});
return ql;
}

const onGuestFace = (ql: any, key: string) =>
resolveAuthzContext({
ql,
headers: headers(key),
nowMs: NOW,
guestEntry: true,
userAuthGates: undefined,
apiKeyLifecycle: undefined,
getSession: async () => undefined,
});

const quietly = async <T>(fn: () => Promise<T>): Promise<T> => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try { return await fn(); } finally { warn.mockRestore(); }
};

it('a banned owner\'s key and an unknown key get the same guest entry', async () => {
const ql = guestServingQl({}, { id: OWNER, banned: true });
const banned = await quietly(() => onGuestFace(ql, KEY));
const unknown = await onGuestFace(ql, MADE_UP_KEY);
// Non-vacuous: the guest anchor really grants something here.
expect(unknown.permissions).toEqual([GUEST_SET]);
expect(unknown.tenantId).toBe(ORG);
expect(banned.userId).toBeUndefined();
expect(banned).toEqual(unknown);
});
});
Loading
Loading