|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A banned user's credentials are refused while the ban holds — pinned at the |
| 5 | + * shared key-admission seam (`resolveApiKeyAdmission`, reached by every door |
| 6 | + * through `resolveAuthzContext`), where an API key becomes its owner. |
| 7 | + * |
| 8 | + * - an owner whose ban holds → the key is `refused` (`owner_banned`): no |
| 9 | + * principal, no fall-through to the session path, and the resolved context |
| 10 | + * is the one a key that does not exist resolves to; |
| 11 | + * - a ban with an expiry binds until it expires and not after — the one |
| 12 | + * predicate (`isUserBanInForce`) the session refusal shares; |
| 13 | + * - not a revocation: nothing is written while the ban holds, and the same |
| 14 | + * key is admitted again once it is lifted; |
| 15 | + * - the key's own lifecycle still answers first; |
| 16 | + * - an owner read that throws is the branded store outage; a store with no |
| 17 | + * `sys_user` table holds no ban. |
| 18 | + */ |
| 19 | + |
| 20 | +import { describe, it, expect, vi } from 'vitest'; |
| 21 | +import { resolveAuthzContext } from './resolve-authz-context.js'; |
| 22 | +import { hashApiKey, resolveApiKeyAdmission, resolveApiKeyPrincipal } from './api-key.js'; |
| 23 | +import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js'; |
| 24 | +import { isUserBanInForce } from './user-ban.js'; |
| 25 | +import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js'; |
| 26 | + |
| 27 | +const KEY = 'osk_banned_owner_pin_key'; |
| 28 | +const MADE_UP_KEY = 'osk_no_row_holds_this_key'; |
| 29 | +const OWNER = 'u_key_owner'; |
| 30 | +const COOKIE_USER = 'u_cookie_holder'; |
| 31 | +const NOW = Date.parse('2026-11-05T12:00:00.000Z'); |
| 32 | + |
| 33 | +function makeQl(owner: Record<string, unknown> | null = { id: OWNER }, keyRow: Record<string, unknown> = {}) { |
| 34 | + const tables: Record<string, any[]> = { |
| 35 | + sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]', ...keyRow }], |
| 36 | + sys_member: [], |
| 37 | + sys_user: [...(owner ? [owner] : []), { id: COOKIE_USER }], |
| 38 | + }; |
| 39 | + const reads: Array<{ object: string; opts: any }> = []; |
| 40 | + const update = vi.fn(async () => ({})); |
| 41 | + return { |
| 42 | + tables, |
| 43 | + reads, |
| 44 | + update, |
| 45 | + ql: { |
| 46 | + update, |
| 47 | + async find(object: string, opts: any) { |
| 48 | + reads.push({ object, opts }); |
| 49 | + const where = opts?.where ?? {}; |
| 50 | + const rows = (tables[object] ?? []).filter((r) => |
| 51 | + Object.entries(where).every(([k, v]) => { |
| 52 | + if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`); |
| 53 | + return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v; |
| 54 | + }), |
| 55 | + ); |
| 56 | + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; |
| 57 | + }, |
| 58 | + }, |
| 59 | + }; |
| 60 | +} |
| 61 | + |
| 62 | +const headers = (key = KEY, extra: Record<string, string> = {}) => new Headers({ 'x-api-key': key, ...extra }); |
| 63 | +const admit = (ql: any, key = KEY, nowMs = NOW) => |
| 64 | + resolveApiKeyAdmission(ql, headers(key), nowMs, undefined, undefined, undefined); |
| 65 | + |
| 66 | +describe('isUserBanInForce — the one ban predicate', () => { |
| 67 | + it('a set ban flag with no expiry holds', () => { |
| 68 | + expect(isUserBanInForce({ banned: true }, NOW)).toBe(true); |
| 69 | + expect(isUserBanInForce({ banned: 1, ban_expires: null }, NOW)).toBe(true); |
| 70 | + }); |
| 71 | + |
| 72 | + it('a row without the ban flag is not banned, whatever its expiry says', () => { |
| 73 | + expect(isUserBanInForce({ banned: false, ban_expires: new Date(NOW + 60_000) }, NOW)).toBe(false); |
| 74 | + expect(isUserBanInForce({ banned: null }, NOW)).toBe(false); |
| 75 | + expect(isUserBanInForce({}, NOW)).toBe(false); |
| 76 | + expect(isUserBanInForce(undefined, NOW)).toBe(false); |
| 77 | + }); |
| 78 | + |
| 79 | + it('a ban with an expiry holds until the expiry has passed, in either column spelling', () => { |
| 80 | + expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW + 1).toISOString() }, NOW)).toBe(true); |
| 81 | + expect(isUserBanInForce({ banned: true, banExpires: new Date(NOW + 1) }, NOW)).toBe(true); |
| 82 | + // At the instant itself the ban still holds — the sign-in check lifts it only strictly after. |
| 83 | + expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW) }, NOW)).toBe(true); |
| 84 | + expect(isUserBanInForce({ banned: true, ban_expires: new Date(NOW - 1).toISOString() }, NOW)).toBe(false); |
| 85 | + expect(isUserBanInForce({ banned: true, banExpires: NOW - 1 }, NOW)).toBe(false); |
| 86 | + }); |
| 87 | + |
| 88 | + it('an expiry that is not an instant does not lift the ban', () => { |
| 89 | + expect(isUserBanInForce({ banned: true, ban_expires: 'not-a-date' }, NOW)).toBe(true); |
| 90 | + expect(isUserBanInForce({ banned: true, ban_expires: { at: NOW - 1 } }, NOW)).toBe(true); |
| 91 | + }); |
| 92 | +}); |
| 93 | + |
| 94 | +describe('resolveApiKeyAdmission — a banned owner\'s key is refused while the ban holds', () => { |
| 95 | + it('refuses the key of an owner whose ban holds, with no principal', async () => { |
| 96 | + const { ql } = makeQl({ id: OWNER, banned: true }); |
| 97 | + const admission = await admit(ql); |
| 98 | + expect(admission).toMatchObject({ outcome: 'refused', reason: 'owner_banned', keyId: 'key_1', userId: OWNER }); |
| 99 | + expect(admission).not.toHaveProperty('principal'); |
| 100 | + expect(await resolveApiKeyPrincipal(ql, headers(), NOW, undefined, undefined, undefined)).toBeUndefined(); |
| 101 | + }); |
| 102 | + |
| 103 | + it('admits the key of an owner who is not banned', async () => { |
| 104 | + const { ql } = makeQl({ id: OWNER, banned: false }); |
| 105 | + expect((await admit(ql)).outcome).toBe('admitted'); |
| 106 | + }); |
| 107 | + |
| 108 | + it('reads the owner\'s ban columns by id, as the system', async () => { |
| 109 | + const { ql, reads } = makeQl({ id: OWNER, banned: false }); |
| 110 | + await admit(ql); |
| 111 | + const ownerReads = reads.filter((r) => r.object === 'sys_user'); |
| 112 | + expect(ownerReads).toHaveLength(1); |
| 113 | + expect(ownerReads[0].opts).toMatchObject({ |
| 114 | + where: { id: OWNER }, |
| 115 | + fields: ['id', 'banned', 'ban_expires'], |
| 116 | + limit: 1, |
| 117 | + context: { isSystem: true }, |
| 118 | + }); |
| 119 | + }); |
| 120 | + |
| 121 | + it('a ban with an expiry binds until it expires, and not after', async () => { |
| 122 | + const expires = new Date(NOW + 60_000).toISOString(); |
| 123 | + const { ql } = makeQl({ id: OWNER, banned: true, ban_expires: expires }); |
| 124 | + expect((await admit(ql, KEY, NOW)).outcome).toBe('refused'); |
| 125 | + expect((await admit(ql, KEY, NOW + 60_001)).outcome).toBe('admitted'); |
| 126 | + }); |
| 127 | + |
| 128 | + it('is not a revocation: nothing is written while the ban holds, and lifting it admits the same key', async () => { |
| 129 | + const { ql, tables, update } = makeQl({ id: OWNER, banned: true }); |
| 130 | + const keyRowBefore = { ...tables.sys_api_key[0] }; |
| 131 | + expect((await admit(ql)).outcome).toBe('refused'); |
| 132 | + expect(update).not.toHaveBeenCalled(); |
| 133 | + expect(tables.sys_api_key[0]).toEqual(keyRowBefore); |
| 134 | + |
| 135 | + tables.sys_user[0].banned = false; |
| 136 | + expect((await admit(ql)).outcome).toBe('admitted'); |
| 137 | + }); |
| 138 | + |
| 139 | + it('the key\'s own lifecycle answers first: a revoked key of a banned owner is refused as revoked', async () => { |
| 140 | + const { ql } = makeQl({ id: OWNER, banned: true }, { revoked: true }); |
| 141 | + expect((await admit(ql)).outcome).toBe('lifecycle'); |
| 142 | + }); |
| 143 | + |
| 144 | + it('a key whose owner row is absent keeps today\'s answer — no row holds no ban', async () => { |
| 145 | + const { ql } = makeQl(null); |
| 146 | + expect((await admit(ql)).outcome).toBe('admitted'); |
| 147 | + }); |
| 148 | + |
| 149 | + it('an owner read that throws is the branded store outage, never an admission', async () => { |
| 150 | + const { ql } = makeQl({ id: OWNER, banned: true }); |
| 151 | + const find = ql.find.bind(ql); |
| 152 | + ql.find = async (object: string, opts: any) => { |
| 153 | + if (object === 'sys_user') throw new Error('connection reset'); |
| 154 | + return find(object, opts); |
| 155 | + }; |
| 156 | + const err = await admit(ql).catch((e) => e); |
| 157 | + expect(isAuthzStoreUnavailableError(err)).toBe(true); |
| 158 | + expect(err.status).toBe(503); |
| 159 | + expect(err.code).toBe('SERVICE_UNAVAILABLE'); |
| 160 | + }); |
| 161 | + |
| 162 | + it('a store with no sys_user table holds no ban', async () => { |
| 163 | + const { ql } = makeQl({ id: OWNER, banned: true }); |
| 164 | + const find = ql.find.bind(ql); |
| 165 | + ql.find = async (object: string, opts: any) => { |
| 166 | + if (object === 'sys_user') throw new Error('no such table: sys_user'); |
| 167 | + return find(object, opts); |
| 168 | + }; |
| 169 | + expect((await admit(ql)).outcome).toBe('admitted'); |
| 170 | + }); |
| 171 | +}); |
| 172 | + |
| 173 | +describe('resolveAuthzContext — a banned owner\'s key resolves as a key that does not exist', () => { |
| 174 | + const resolve = (ql: any, key: string, getSession?: (h: any) => Promise<any>) => |
| 175 | + resolveAuthzContext({ |
| 176 | + ql, |
| 177 | + headers: headers(key), |
| 178 | + nowMs: NOW, |
| 179 | + userAuthGates: undefined, |
| 180 | + apiKeyLifecycle: undefined, |
| 181 | + ...(getSession ? { getSession } : {}), |
| 182 | + }); |
| 183 | + |
| 184 | + it('resolves no principal, and the same context a made-up key resolves', async () => { |
| 185 | + const { ql } = makeQl({ id: OWNER, banned: true }); |
| 186 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 187 | + try { |
| 188 | + const banned = await resolve(ql, KEY); |
| 189 | + const madeUp = await resolve(ql, MADE_UP_KEY); |
| 190 | + expect(banned.userId).toBeUndefined(); |
| 191 | + expect(banned.credential).toBeUndefined(); |
| 192 | + expect(banned).toEqual(madeUp); |
| 193 | + // The reason is said server-side only. |
| 194 | + expect(warn.mock.calls.flat().join('\n')).toContain('owner_banned'); |
| 195 | + } finally { |
| 196 | + warn.mockRestore(); |
| 197 | + } |
| 198 | + }); |
| 199 | + |
| 200 | + it('never falls through to a session sent beside the key', async () => { |
| 201 | + const { ql } = makeQl({ id: OWNER, banned: true }); |
| 202 | + const getSession = vi.fn(async () => ({ user: { id: COOKIE_USER }, session: { userId: COOKIE_USER } })); |
| 203 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 204 | + try { |
| 205 | + const authz = await resolve(ql, KEY, getSession); |
| 206 | + expect(authz.userId).toBeUndefined(); |
| 207 | + expect(getSession).not.toHaveBeenCalled(); |
| 208 | + } finally { |
| 209 | + warn.mockRestore(); |
| 210 | + } |
| 211 | + }); |
| 212 | + |
| 213 | + it('control: a non-banned owner\'s key resolves the owner, credential api_key', async () => { |
| 214 | + const { ql } = makeQl({ id: OWNER, banned: false }); |
| 215 | + const authz = await resolve(ql, KEY); |
| 216 | + expect(authz.userId).toBe(OWNER); |
| 217 | + expect(authz.credential).toBe('api_key'); |
| 218 | + }); |
| 219 | + |
| 220 | + it('control: a session is not judged by this seam — a cookie with no key resolves as before', async () => { |
| 221 | + const { ql } = makeQl({ id: OWNER, banned: true }); |
| 222 | + const getSession = vi.fn(async () => ({ user: { id: COOKIE_USER }, session: { userId: COOKIE_USER } })); |
| 223 | + const authz = await resolveAuthzContext({ |
| 224 | + ql, |
| 225 | + headers: new Headers(), |
| 226 | + nowMs: NOW, |
| 227 | + userAuthGates: undefined, |
| 228 | + apiKeyLifecycle: undefined, |
| 229 | + getSession, |
| 230 | + }); |
| 231 | + expect(authz.userId).toBe(COOKIE_USER); |
| 232 | + expect(authz.credential).toBe('session'); |
| 233 | + }); |
| 234 | +}); |
| 235 | + |
| 236 | +describe('a refused key gets the anonymous entry an unknown key gets, on a face that serves the guest', () => { |
| 237 | + const GUEST_SET = 'public_catalog_reader'; |
| 238 | + const ORG = 'org_only'; |
| 239 | + |
| 240 | + /** A guest anchor that grants something, in the one organization the guest entry derives. */ |
| 241 | + function guestServingQl(keyRow: Record<string, unknown>, owner: Record<string, unknown>) { |
| 242 | + const tables: Record<string, any[]> = { |
| 243 | + sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]', ...keyRow }], |
| 244 | + sys_user: [owner], |
| 245 | + sys_member: [], |
| 246 | + sys_organization: [{ id: ORG }], |
| 247 | + sys_metadata_activation: [], |
| 248 | + }; |
| 249 | + const registered = new Set(Object.keys(tables)); |
| 250 | + const ql = { |
| 251 | + registry: { getObject: (name: string) => (registered.has(name) ? { name } : undefined) }, |
| 252 | + async find(object: string, opts: any) { |
| 253 | + const where = opts?.where ?? {}; |
| 254 | + const rows = (tables[object] ?? []).filter((r) => |
| 255 | + Object.entries(where).every(([k, v]) => { |
| 256 | + if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`); |
| 257 | + return v && typeof v === 'object' && '$in' in (v as any) ? (v as any).$in.includes(r[k]) : r[k] === v; |
| 258 | + }), |
| 259 | + ); |
| 260 | + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; |
| 261 | + }, |
| 262 | + }; |
| 263 | + bindStaticSecurityCatalog(ql, { |
| 264 | + positions: [{ name: 'guest', permissionSets: [GUEST_SET] }], |
| 265 | + permissions: [{ name: GUEST_SET, tabPermissions: { storefront: 'visible' } }], |
| 266 | + }); |
| 267 | + return ql; |
| 268 | + } |
| 269 | + |
| 270 | + const onGuestFace = (ql: any, key: string) => |
| 271 | + resolveAuthzContext({ |
| 272 | + ql, |
| 273 | + headers: headers(key), |
| 274 | + nowMs: NOW, |
| 275 | + guestEntry: true, |
| 276 | + userAuthGates: undefined, |
| 277 | + apiKeyLifecycle: undefined, |
| 278 | + getSession: async () => undefined, |
| 279 | + }); |
| 280 | + |
| 281 | + const quietly = async <T>(fn: () => Promise<T>): Promise<T> => { |
| 282 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 283 | + try { return await fn(); } finally { warn.mockRestore(); } |
| 284 | + }; |
| 285 | + |
| 286 | + it('a banned owner\'s key and an unknown key get the same guest entry', async () => { |
| 287 | + const ql = guestServingQl({}, { id: OWNER, banned: true }); |
| 288 | + const banned = await quietly(() => onGuestFace(ql, KEY)); |
| 289 | + const unknown = await onGuestFace(ql, MADE_UP_KEY); |
| 290 | + // Non-vacuous: the guest anchor really grants something here. |
| 291 | + expect(unknown.permissions).toEqual([GUEST_SET]); |
| 292 | + expect(unknown.tenantId).toBe(ORG); |
| 293 | + expect(banned.userId).toBeUndefined(); |
| 294 | + expect(banned).toEqual(unknown); |
| 295 | + }); |
| 296 | +}); |
0 commit comments