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
11 changes: 11 additions & 0 deletions .changeset/unify-authz-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/rest": patch
"@objectstack/runtime": patch
"@objectstack/core": patch
---

fix(security): single-source the request authorization resolver — REST no longer drops sys_user_role

The REST server and the runtime dispatcher each carried their own copy of the request → ExecutionContext identity/role resolver, and they drifted on a security path. The REST copy silently omitted `sys_user_role` (so custom roles granted via the ADR-0057 D4 platform-RBAC path did not apply over REST), `sys_role_permission_set`, the `owner→org_owner` membership normalization, the platform-admin derivation, and the `ai_seat` synthesis — fail-closed (legitimate access denied), not an escalation.

Both entry points now delegate to a single shared resolver, `resolveAuthzContext` in `@objectstack/core/security` (joining the API-key verifier that already lived there). A contract test locks every authorization source and a lint gate (`check:authz-resolver`) prevents a future duplicate resolver or a dropped delegation.
7 changes: 7 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ jobs:
- name: Doc/skill authoring guard
run: pnpm check:doc-authoring

# Authorization resolution must stay single-sourced (resolveAuthzContext,
# @objectstack/core). Guards against a duplicate resolver copy drifting on a
# security path (the REST-vs-dispatcher sys_user_role drift) and against an
# entry point silently dropping the delegation.
- name: Single authz resolver guard
run: pnpm check:authz-resolver

typecheck:
name: TypeScript Type Check
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"objectui:refresh": "bash scripts/bump-objectui.sh && bash scripts/build-console.sh",
"objectui:clean": "rm -rf packages/console/dist .cache/objectui-*",
"lint": "eslint . --no-inline-config",
"check:doc-authoring": "node scripts/check-doc-authoring.mjs"
"check:doc-authoring": "node scripts/check-doc-authoring.mjs",
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs"
},
"keywords": [
"objectstack",
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,11 @@ export {
type GeneratedApiKey,
type ApiKeyPrincipal,
} from './api-key.js';

export {
resolveAuthzContext,
resolveLocalizationContext,
type ResolvedAuthzContext,
type ResolveAuthzInput,
type ResolveLocalizationInput,
} from './resolve-authz-context.js';
111 changes: 111 additions & 0 deletions packages/core/src/security/resolve-authz-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { resolveAuthzContext } from './resolve-authz-context.js';

/**
* Contract test for the SINGLE authorization resolver. Every authorization
* source MUST be honored here — this is the regression net that would have
* caught the REST-vs-dispatcher drift (the REST copy had silently dropped
* sys_user_role / sys_role_permission_set / platform_admin / ai_seat).
*/

// Minimal in-memory ObjectQL: find(object, { where }) with `===` + `$in` match.
function makeQl(tables: Record<string, any[]>) {
return {
async find(object: string, opts: any) {
const rows = tables[object] ?? [];
const where = opts?.where ?? {};
return rows.filter((r) =>
Object.entries(where).every(([k, v]) => {
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]);
return r[k] === v;
}),
);
},
};
}
const session = (userId: string, opts: { email?: string; org?: string } = {}) =>
async () => ({ user: { id: userId, email: opts.email }, session: { activeOrganizationId: opts.org ?? null } });
const H = () => new Headers();

describe('resolveAuthzContext — single source of truth', () => {
it('resolves a custom role granted via sys_user_role (the REST-drift bug)', async () => {
const ql = makeQl({
sys_user: [{ id: 'u1', email: 'ada@x.com' }],
sys_member: [],
sys_user_role: [{ user_id: 'u1', role: 'contributor', organization_id: null }],
sys_user_permission_set: [],
});
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') });
expect(ctx.roles).toContain('contributor');
});

it('normalizes sys_member org roles (owner -> org_owner)', async () => {
const ql = makeQl({
sys_user: [{ id: 'u1' }],
sys_member: [{ user_id: 'u1', role: 'owner', organization_id: 'o1' }],
sys_user_role: [],
sys_user_permission_set: [],
});
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) });
expect(ctx.roles).toContain('org_owner');
});

it('resolves role-bound permission sets (sys_role_permission_set)', async () => {
const ql = makeQl({
sys_user: [{ id: 'u1' }],
sys_member: [],
sys_user_role: [{ user_id: 'u1', role: 'contributor', organization_id: null }],
sys_user_permission_set: [],
sys_role: [{ id: 'r1', name: 'contributor' }],
sys_role_permission_set: [{ role_id: 'r1', permission_set_id: 'ps1' }],
sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }],
});
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') });
expect(ctx.permissions).toContain('contributor_ps');
expect(ctx.systemPermissions).toContain('cap_x');
});

it('derives platform_admin from an UNSCOPED admin_full_access user grant', async () => {
const ql = makeQl({
sys_user: [{ id: 'u1' }],
sys_member: [],
sys_user_role: [],
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: null }],
sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }],
});
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') });
expect(ctx.roles).toContain('platform_admin');
});

it('does NOT derive platform_admin from an ORG-scoped admin_full_access grant', async () => {
const ql = makeQl({
sys_user: [{ id: 'u1' }],
sys_member: [],
sys_user_role: [],
sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', organization_id: 'o1' }],
sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }],
});
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) });
expect(ctx.roles).not.toContain('platform_admin');
});

it('synthesizes ai_seat from sys_user.ai_access (sqlite integer 1)', async () => {
const ql = makeQl({
sys_user: [{ id: 'u1', ai_access: 1 }],
sys_member: [],
sys_user_role: [],
sys_user_permission_set: [],
});
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1') });
expect(ctx.permissions).toContain('ai_seat');
});

it('anonymous (no session, no api key) → empty context', async () => {
const ctx = await resolveAuthzContext({ ql: makeQl({}), headers: H(), getSession: async () => undefined });
expect(ctx.userId).toBeUndefined();
expect(ctx.roles).toEqual([]);
expect(ctx.permissions).toEqual([]);
});
});
Loading