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
70 changes: 70 additions & 0 deletions .changeset/per-organization-rbac-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
"@objectstack/plugin-security": minor
"@objectstack/plugin-sharing": minor
"@objectstack/core": patch
---

Materialize the RBAC catalog **per organization**, so a walled deployment can
administer positions, permission sets and sharing rules again (#10103).

On a walled deployment (`group` / `isolated`) every principal — an organization
owner and a platform admin alike — listed **zero** positions, permission sets
and sharing rules while the tables held rows. Nothing could be bound through
Setup, and a declared `hierarchy-security` could never be armed by an operator
however loudly an app declared it.

Every row in those three tables was organization-less. plugin-security's Layer 0
composes a strict `organization_id = :tenant` for a walled posture and the
middleware ANDs it into the read AST over the driver's
`(organization_id = :tenant OR organization_id IS NULL)`; the conjunction of the
two is the strict equality alone, so the driver's null arm was annihilated on
every authenticated read.

**The wall is not changed, at either layer.** The rows get an owner instead:

- `bootstrapDeclaredPositions`, `bootstrapBuiltinRoles`,
`bootstrapDeclaredPermissions` (plugin-security) and
`bootstrapDeclaredSharingRules` (plugin-sharing) upsert by
`(name, organization_id)` and run **one pass per organization** under a walled
posture — the framework built-ins (`platform_admin`, `org_*`, `everyone`,
`guest`) included, matching `sys_user_position`, which is already
per-organization, and matching both objects' own `unique: 'organization'` name
index.
- Seeding also fires on **organization creation**, not only at `kernel:ready`, so
a tenant created after startup does not administer an empty catalog until the
next restart.
- `single` posture is **unchanged**: exactly one organization-less pass, which is
the correct shape there.

An organization-less row is now invalid state under a walled posture. Nothing is
reaped — grants (`sys_user_position`, `sys_position_permission_set`,
`sys_user_permission_set`, `sys_record_share`) point at these rows by id, so
deleting them would revoke standing access with no signal at the moment of loss.
Instead a per-organization pass that meets pre-fix organization-less rows for
names it seeds **says so loudly**, naming the rows and the remedy, and still
creates that organization's own copies. The failure this closes is the silent
no-op: a tenant-threaded pass that sees the old row through the driver's
compatibility arm, reads the name as already represented, and creates nothing
while reporting success.

Two enforcement-plane reads are scoped in the same change, because the exposure
they carry only exists once per-organization copies exist:

- `resolveUserAuthzContext`'s position name-sweep (`@objectstack/core`) resolved
`sys_position` by name across **every** organization, so the junction read
behind it collected another organization's `everyone` binding — a cross-organization
grant bleed, and an O(organizations) read on the per-request path. It is now
threaded through the driver's tenant chokepoint, keeping per-request resolution
O(the caller's own organization's catalog).
- plugin-security's permission-set `dbLoader` resolved sets by name unscoped,
with a `limit` equal to the number of names — correct while one row existed per
name, a truncation the moment copies exist. It is now scoped to the caller's
organization and its bound widened.

Boot reconciliation is O(changed declarations): each pass reads what its
organization already has and writes only where a declaration actually differs, so
the common boot performs no writes at all. Steady state rides the
organization-creation hook.

Cross-links #10119 / PR #10422, whose criteria-sweep scoping makes per-organization
sharing rules cheaper than the unscoped sweep they replace.
45 changes: 42 additions & 3 deletions packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,25 @@ function safeJsonParse<T>(s: string, fallback: T): T {
try { return JSON.parse(s) as T; } catch { return fallback; }
}

async function tryFind(ql: any, object: string, where: any, limit = 100): Promise<any[]> {
async function tryFind(
ql: any,
object: string,
where: any,
limit = 100,
/**
* Resolve inside ONE organization. Threaded into the execution context, so
* the read routes through `SqlDriver.applyTenantScope` — the governed
* chokepoint — rather than being re-implemented here as a bare equality.
* Omitted keeps the pre-existing installation-wide read, which is what the
* user-keyed reads above want (they are already narrowed by `user_id`) and
* what a `single`-posture deployment wants everywhere.
*/
organizationId?: string,
): Promise<any[]> {
if (!ql || typeof ql.find !== 'function') return [];
try {
let rows = await ql.find(object, { where, limit, context: { isSystem: true } } as any);
const context = organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true };
let rows = await ql.find(object, { where, limit, context } as any);
if (rows && (rows as any).value) rows = (rows as any).value;
return Array.isArray(rows) ? rows : [];
} catch {
Expand Down Expand Up @@ -462,7 +477,31 @@ export async function resolveUserAuthzGrants(
// with no `sys_position` row at all (`org_owner`, a membership-derived
// role) has no flag to read and is untouched.
if (grants.positions.length > 0) {
const positionRows = await tryFind(ql, 'sys_position', { name: { $in: grants.positions } }, 100);
// [#10103] Scoped to the CALLER's organization. `sys_position` spells
// its name index `unique: 'organization'` and its rows are materialized
// per organization, so several organizations hold a row named
// `everyone` (and one named after every declared position). Swept by
// name alone, this read returned EVERY organization's rows, and the
// junction read below then collected another organization's bindings —
// a cross-organization grant bleed, measured reachable from one tenant's
// resolution to another tenant's `everyone` binding. It also made the
// sweep O(organizations) on a table that is read on every request.
//
// Scoped by threading the organization into the context rather than by
// adding an `organization_id` predicate here: the driver's
// `applyTenantScope` is the one governed spelling of this wall, and a
// bare equality written at this call site would be a second, ungoverned
// implementation of it — the exact shape that produced the defect this
// card repairs. Per-request cost stays O(the caller's own organization's
// catalog).
//
// Limit raised with it: the cap has to admit this organization's rows
// alongside any organization-less ones the driver's compatibility arm
// still returns, or a caller silently loses positions. Those
// organization-less rows stay REACHABLE on purpose — they are not
// reaped, and grants point at them by row id, so dropping them here
// would revoke standing access silently.
const positionRows = await tryFind(ql, 'sys_position', { name: { $in: grants.positions } }, 200, tenantId);
const deactivatedNames = new Set<string>(
positionRows.filter((r) => !isRowActive(r)).map((r) => r.name).filter(Boolean),
);
Expand Down
78 changes: 61 additions & 17 deletions packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,35 @@
* `sys_member.role` for the org_* roles and the unscoped `admin_full_access`
* grant for platform_admin — are NEVER changed by this seed.
*
* Idempotent upsert-by-name, no prune. Rows are stamped `managed_by = 'platform'`
* (A4 #2920 unified vocab; formerly 'system') so tenants can see (but not
* repurpose) them. Runs on `kernel:ready` alongside the platform-admin and
* declared-role bootstraps.
* Idempotent upsert by `(name, organization_id)`, no prune. Rows are stamped
* `managed_by = 'platform'` (A4 #2920 unified vocab; formerly 'system') so
* tenants can see (but not repurpose) them. Runs on `kernel:ready` alongside the
* platform-admin and declared-role bootstraps, and again for each organization
* as it is created.
*
* ## Per organization under a walled posture
*
* The built-in names are seeded PER ORGANIZATION, copies and all. That is the
* ruled reading of what these rows are: `sys_position` spells its name index
* `unique: 'organization'`, `sys_user_position` assignments are already
* per-organization, and a walled tenant that cannot SEE `everyone` cannot bind
* anything to it. What is not copied is the SOURCE OF TRUTH behind the names —
* `sys_member.role` for the org_* roles and the unscoped `admin_full_access`
* grant for `platform_admin` — exactly as before: this seed remains a catalog
* projection, so per-organization copies of the catalog change no derivation.
*
* A `single`-posture deployment keeps exactly one organization-less pass. See
* `per-organization-catalog.ts` for the doctrine and for the loud guard that
* stands in place of a reap.
*/

import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA, EVERYONE_POSITION, GUEST_POSITION } from '@objectstack/spec';
import {
resolveOwnOrganizationRow,
rowMatchesDeclaration,
seedCtx,
warnPreFixOrganizationLessRows,
} from './per-organization-catalog.js';

/**
* [ADR-0090 D5/D9] Audience anchors seeded alongside the identity names.
Expand All @@ -39,29 +61,32 @@ const AUDIENCE_ANCHOR_METADATA: Record<string, { label: string; description: str
},
};

const SYSTEM_CTX = { isSystem: true };

function genId(prefix: string): string {
const rand = Math.random().toString(36).slice(2, 10);
const ts = Date.now().toString(36);
return `${prefix}_${ts}${rand}`;
}

async function tryFind(ql: any, object: string, where: any, limit = 100): Promise<any[]> {
async function tryFind(ql: any, object: string, where: any, limit = 100, organizationId?: string): Promise<any[]> {
try {
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX });
const rows = await ql.find(object, { where, limit }, { context: seedCtx(organizationId) });
return Array.isArray(rows) ? rows : [];
} catch { return []; }
}
async function tryInsert(ql: any, object: string, data: any): Promise<any | null> {
try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch { return null; }
async function tryInsert(ql: any, object: string, data: any, organizationId?: string): Promise<any | null> {
try { return await ql.insert(object, data, { context: seedCtx(organizationId) }); } catch { return null; }
}
async function tryUpdate(ql: any, object: string, data: any): Promise<boolean> {
try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; }
async function tryUpdate(ql: any, object: string, data: any, organizationId?: string): Promise<boolean> {
try { await ql.update(object, data, { context: seedCtx(organizationId) }); return true; } catch { return false; }
}

interface SeedOptions {
logger?: { info: (m: string, meta?: Record<string, any>) => void; warn: (m: string, meta?: Record<string, any>) => void };
/**
* Seed THIS organization's copies. Omitted = the `single`-posture pass, the
* one place an organization-less catalog row is the correct shape.
*/
organizationId?: string;
}

export async function bootstrapBuiltinRoles(
Expand All @@ -71,8 +96,11 @@ export async function bootstrapBuiltinRoles(
if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') {
return { seeded: 0, updated: 0 };
}
const organizationId = options.organizationId;
let seeded = 0;
let updated = 0;
let unchanged = 0;
const residue: string[] = [];
const rows: Array<[string, { label: string; description: string }]> = [
...BUILTIN_IDENTITY_NAMES.map((n) => [n, BUILTIN_IDENTITY_METADATA[n]] as [string, { label: string; description: string }]),
...Object.entries(AUDIENCE_ANCHOR_METADATA),
Expand All @@ -82,16 +110,32 @@ export async function bootstrapBuiltinRoles(
// PLATFORM-shipped (formerly stamped 'system'). Re-upserted every boot, so
// legacy 'system' rows self-heal to 'platform' on the next kernel:ready.
const fields = { label: meta.label, description: meta.description, managed_by: 'platform' };
const existing = await tryFind(ql, 'sys_position', { name }, 1);
if (existing[0]?.id) {
if (await tryUpdate(ql, 'sys_position', { id: existing[0].id, ...fields })) updated += 1;
// Limit 5, not 1: a tenant-scoped read passes through `applyTenantScope`,
// whose compatibility arm returns organization-less rows alongside this
// organization's own. Asking for one row would hand back whichever the
// driver ordered first — and taking a pre-fix organization-less row as
// "already seeded" is exactly the silent no-op this pass must not perform.
const existing = await tryFind(ql, 'sys_position', { name }, 5, organizationId);
const { own, organizationLessResidue } = resolveOwnOrganizationRow(existing, organizationId);
if (organizationLessResidue) residue.push(name);
if (own?.id) {
// O(changed declarations): an unchanged row costs no write at all.
if (rowMatchesDeclaration(own, fields)) { unchanged += 1; continue; }
if (await tryUpdate(ql, 'sys_position', { id: own.id, ...fields }, organizationId)) updated += 1;
} else {
const created = await tryInsert(ql, 'sys_position', {
id: genId('position'), name, ...fields, active: true, is_default: false,
});
}, organizationId);
if (created) seeded += 1;
}
}
options.logger?.info?.('[security] built-in identity names + audience anchors seeded into sys_position', { seeded, updated, total: rows.length });
if (organizationId) {
warnPreFixOrganizationLessRows(options.logger, 'sys_position', residue, organizationId);
}
if (seeded + updated > 0) {
options.logger?.info?.('[security] built-in identity names + audience anchors seeded into sys_position', {
seeded, updated, unchanged, total: rows.length, ...(organizationId ? { organization: organizationId } : {}),
});
}
return { seeded, updated };
}
Loading
Loading