Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
420fb8c
wip(plugin-webhooks): declared webhooks read from the registry; seede…
claude Oct 19, 2026
e44c642
test(plugin-webhooks): pin the registry read, the ledger switch and t…
claude Oct 19, 2026
8189a84
wip: liveness ledger re-anchored to the registry read; dogfood proof …
claude Oct 19, 2026
e412cb8
docs+ledger: the registry read named across spec comments, liveness, …
claude Oct 19, 2026
bdad70b
spec(liveness): state counts regenerated; isActive named at the dispa…
claude Oct 19, 2026
723af97
fix(plugin-webhooks): a declared webhook is deployment-wide when the …
claude Oct 19, 2026
e0a6dc9
fix(gates): tenant-audit census regenerated for the seeder's two reti…
claude Oct 19, 2026
6b3042a
test(plugin-webhooks): the fake engine refuses a WHERE combinator it …
claude Oct 19, 2026
820121c
fix(lint,showcase): the dead label/description verdict is carried to …
claude Oct 19, 2026
b378ff5
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
c3b1452
test(dogfood): the R2 harness pins one delivery per task change from …
claude Oct 19, 2026
9bce0aa
test(dogfood): the harness case switches the declaration on, so it ca…
claude Oct 19, 2026
7e580dc
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
fa6a837
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
b4ae8e6
Merge remote-tracking branch 'origin/main' into claude/issue-24529-8b…
claude Oct 19, 2026
206d2bc
Merge remote-tracking branch 'origin/claude/issue-24529-8b0g-webhooks…
claude Oct 19, 2026
f89c0f2
wip: birth marker, shapeGeneration, boot guard rule
claude Oct 19, 2026
6e14afa
wip: ceremony steps 5a-5d, inventory, flips, differ ownership
claude Oct 19, 2026
50a2d34
wip: open the ceremony command, ports for webhooks and NOT NULL
claude Oct 19, 2026
9ea4312
wip: existing ceremony tests follow the new step set; NOT NULL routed…
claude Oct 19, 2026
719ab13
wip: the guard's pins follow the birth marker and shapeGeneration
claude Oct 19, 2026
036a2a1
wip: the birth half in every CLI boot, so a CLI-created fresh store b…
claude Oct 19, 2026
0128f07
test(cli): pin the generation-1 ceremony steps over an 18.0-shaped da…
claude Oct 19, 2026
8e6a33a
test: the tenant column's NOT NULL is the ceremony's, not drift; the …
claude Oct 19, 2026
32d1e22
test(dogfood): R2 and R3 restated — the 18.0 and v17 databases take t…
claude Oct 19, 2026
13446cb
fix(cli): a rotation shard's NOT NULL is reported unsupported, never …
claude Oct 19, 2026
06e0556
docs,qa: the open ceremony and the birth marker in the CLI docs and t…
claude Oct 19, 2026
e0c1d2b
chore(spec): regenerate the api-surface, export-origin, declaration-m…
claude Oct 19, 2026
a0fa816
test(platform-objects,service-settings): sys_secret is deployment-lev…
claude Oct 19, 2026
e7a788d
chore: regenerate the platform-object tenancy census (sys_secret and …
claude Oct 19, 2026
f1264d7
chore: the gates the sweep named — the guard double's findOne predica…
claude Oct 19, 2026
a3064ef
test(cli): fixtures standing in for an earlier release's database car…
claude Oct 19, 2026
d6195b9
Merge origin/main into claude/issue-24581-ceremony-open
claude Oct 19, 2026
0232108
docs(qa): the ceremony item's revision 3 cites its PR
claude Oct 19, 2026
dceaa56
fix(driver-turso): the remote face counts what its schema doors creat…
claude Oct 19, 2026
33b7943
test(runtime): the remote-face guard pin reads driver-turso's source
claude Oct 19, 2026
d28f4cf
chore(changeset): the remote face's schema-sync count
claude Oct 19, 2026
edb2db1
test(dogfood): the upgrade ceremony over the 18.0 fixture is a shared…
claude Oct 19, 2026
1699ec5
Merge origin/main after #24667 (step 4) into the ceremony branch
claude Oct 20, 2026
b5010d9
test(dogfood): ceremoniedFixture boots the 18.0 bytes once, refused, …
claude Oct 20, 2026
0f83a8d
fix(text): no shipped text says the ownership ceremony is closed; an …
claude Oct 20, 2026
9addf42
Merge remote-tracking branch 'origin/claude/issue-24581-ceremony-open…
claude Oct 20, 2026
cda96bf
wip(security): the organization-less grant readers retire — a grant a…
claude Oct 20, 2026
83537b8
wip(test): re-stamp the grant fixtures that relied on an organization…
claude Oct 20, 2026
4ba0dc9
docs(core): the admin-standing surface names the Default Organization…
claude Oct 20, 2026
72a7c4b
wip(test): re-stamp the downstream grant fixtures (rest and in-progre…
claude Oct 20, 2026
3166df2
fix(runtime): the run-lifecycle refusal names the Default Organizatio…
claude Oct 20, 2026
f96aa7d
wip(test): re-stamp the dogfood and remaining downstream grant fixtures
claude Oct 20, 2026
e80cdbe
wip(test): re-stamp the cloud-connection, datasource and settings gra…
claude Oct 20, 2026
1181295
Merge remote-tracking branch 'origin/main' into claude/issue-24583-7a…
claude Oct 20, 2026
d39797e
test: re-stamp the two grant fixtures main brought with an organizati…
claude Oct 20, 2026
b7df995
chore(runtime): re-record the test-typecheck ledger after the re-stam…
claude Oct 20, 2026
7e1eaf1
Merge remote-tracking branch 'origin/main' into claude/issue-24583-7a…
claude Oct 20, 2026
a62067a
Merge remote-tracking branch 'origin/main' into claude/issue-24583-7a…
claude Oct 20, 2026
5c36953
Merge remote-tracking branch 'origin/main' into claude/issue-24583-7a…
claude Oct 20, 2026
21b1726
Merge remote-tracking branch 'origin/main' into claude/issue-24583-7a…
claude Oct 20, 2026
2ba92fc
fix(test,text): the holder-scan double refuses combinators on sys_org…
claude Oct 20, 2026
e9cdb1d
Merge remote-tracking branch 'origin/main' into claude/issue-24583-7a…
claude Oct 20, 2026
8664400
Merge remote-tracking branch 'origin/main' into claude/issue-24583-7a…
claude Oct 20, 2026
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
22 changes: 22 additions & 0 deletions .changeset/24583-7a2-organization-less-grant-readers-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/core': minor
'@objectstack/plugin-security': minor
'@objectstack/runtime': patch
---

feat(core,plugin-security)!: a grant applies only in its own organization — an organization-less grant row confers nothing, and the Default Organization's `admin_full_access` grant is the only platform-admin anchor

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data change this relies on is the operator's ownership ceremony, `os migrate organization-ownership`, which an upgraded database must have passed before it boots. -->

**BREAKING** for a stored grant row that carries no `organization_id` (`NULL`, absent or blank), shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). After the ownership ceremony no such row exists: it attributes every one to an organization and makes the column NOT NULL, a server refuses a database it did not create or certify, and a fresh install that registers the organization object never writes one. So on a certified or fresh database nothing resolves differently.

- **`resolveUserAuthzGrants` / `resolveAuthzContext` (`@objectstack/core`).** A `sys_user_position` or `sys_user_permission_set` row applies only while its organization is the active one. FROM: a row with no organization was "global" and applied in every resolution, including one with no active organization. TO: it applies nowhere, and a resolution with no active organization gets no stored grant at all.
- **Platform-admin standing.** FROM: under `single`, an organization-less `admin_full_access` grant anchored `platform_admin`, beside the Default Organization's. TO: only the Default Organization's grant anchors it (still in every organization, and with none active). Walled postures are unchanged: standing there comes from `OS_PLATFORM_OWNER_EMAIL` alone.
- **`bootstrapPlatformAdmin` (`@objectstack/plugin-security`).** The existing-admin check reads only the Default Organization's grant. When no Default Organization can be named (a walled posture, or none to name), it reads no grant at all. The walled "no owner email declared" error drops its sentence about a legacy unscoped grant row.
- **The delegated-administration gate.** A held position counts toward self-delegation only when its row belongs to the caller's active organization. An organization-less holding, or a caller with no active organization, holds nothing there, which is what the resolver grants. An organization administrator's removal of an organization-less row is refused as "not one of the organization you administer".
- **The run-lifecycle refusal (`@objectstack/runtime`).** Under `single` it now names the Default Organization's `admin_full_access` grant as the remedy, where it named an unscoped one.
- **What a lean composition sees.** A composition that registers no organization object (`sys_organization`, which plugin-auth ships) still stores grants without an organization, and those grants now confer nothing. No shipped composition does this: `os serve` registers the security plugin only together with plugin-auth.

**What an operator does.** Nothing beyond the upgrade itself: run `os migrate organization-ownership` before serving a database that 17.x or an 18.0 pre-release created. The server refuses such a database until you do. If an embedding composes `@objectstack/plugin-security` without plugin-auth, register the organization object so that grants carry an organization.
Original file line number Diff line number Diff line change
Expand Up @@ -267,17 +267,22 @@ const APP = {
};
const LEDGER_FILE = 'com.acme.gated.json';

/** Every test user holds the authoring capability, so a refusal below is the gate's alone. */
/**
* Every test user holds the authoring capability, so a refusal below is the gate's alone:
* the `admin_full_access` grant owned by the Default Organization, which applies with no
* active organization.
*/
function grantRows(): Record<string, any[]> {
return {
sys_organization: [{ id: 'org_default', slug: 'default' }],
sys_user: Object.keys(POSTURES).map((id) => ({ id, email: `${id}@acme.test` })),
sys_member: [],
sys_user_position: [],
sys_position: [],
sys_metadata_activation: [],
sys_position_permission_set: [],
sys_user_permission_set: Object.keys(POSTURES).map((id, i) => ({
id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null,
id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: 'org_default',
})),
sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }],
};
Expand Down
11 changes: 8 additions & 3 deletions packages/cloud-connection/src/cloud-connection-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,24 @@ function makeC(url: string, body?: any) {
};
}

/** The organization the sessions act in, and the one {@link setupAccessEngine}'s grant is owned by. */
const ORG = 'o1';

const sessionAuth = (userId?: string) => ({
api: { getSession: async () => (userId ? { user: { id: userId } } : null) },
api: { getSession: async () => (userId ? { user: { id: userId }, session: { activeOrganizationId: ORG } } : null) },
});

/**
* [#24148] A data engine under which `userId` holds `setup.access` — what
* `/status` serves its full answer behind. Real permission-set rows, read by
* the shared resolver through `find`.
* the shared resolver through `find`. The grant is owned by {@link ORG}, the
* organization the session acts in: a grant applies only in its own
* organization.
*/
const setupAccessEngine = (userId: string) => {
const rows: Record<string, any[]> = {
sys_user: [{ id: userId, email: `${userId}@example.test` }],
sys_user_permission_set: [{ id: 'ups_1', user_id: userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }],
sys_user_permission_set: [{ id: 'ups_1', user_id: userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }],
sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }],
};
return bindCatalogFromTables({ find: async (object: string) => rows[object] ?? [] }, rows);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ const CC = '/api/v1/cloud-connection';
const ORIGIN = 'https://env.example.test';
const CLOUD = 'http://cloud.test';

/** Holds `setup.access`, which the two bind doors demand. */
/** Holds `setup.access`, which the two bind doors demand — in {@link ORG}, the organization the session acts in. */
const ADMIN = 'u_admin';
const ORG = 'o1';
const GRANTS: Record<string, any[]> = {
sys_user_permission_set: [{ id: 'ups_1', user_id: ADMIN, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }],
sys_user_permission_set: [{ id: 'ups_1', user_id: ADMIN, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }],
sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }],
};

Expand Down Expand Up @@ -95,7 +96,7 @@ async function mount(mode: Mode, discovery: Discovery) {
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
};
const hooks = new Map<string, any>();
const auth = { api: { getSession: async () => ({ user: { id: ADMIN }, session: { id: 'sess_admin' } }) } };
const auth = { api: { getSession: async () => ({ user: { id: ADMIN }, session: { id: 'sess_admin', activeOrganizationId: ORG } }) } };
const objectql = bindCatalogFromTables({ find: async (object: string) => GRANTS[object] ?? [] }, GRANTS);
const protocol = discovery === 'real'
? discoveryProducer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,22 @@ import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
const CC = '/api/v1/cloud-connection';
const ORIGIN = 'https://env.example.test';

/** Holds `setup.access` through an unscoped grant (the platform-admin shape). */
/** Holds `setup.access` through the Default Organization's `admin_full_access` grant (the platform-admin shape: it applies with no active organization). */
const ADMIN = 'u_setup_admin';
/** Holds `setup.access` through a grant scoped to the organization its session is active in (the organization-admin shape). */
const ORG_ADMIN = 'u_org_admin';
/** Signed in, holds a permission set, and the set carries no `setup.access`. */
/** Signed in, holds a permission set in the organization its session is active in, and the set carries no `setup.access`. */
const MEMBER = 'u_member';
/** Holds `setup.access`, but the ADR-0069 policy holds the session. */
/** Holds `setup.access` (the platform-admin shape, as {@link ADMIN}), but the ADR-0069 policy holds the session. */
const HELD_ADMIN = 'u_held_admin';

const ORG = 'org_a';
/** The Default Organization — the owner of the one `admin_full_access` grant that anchors platform standing. */
const DEFAULT_ORG = 'org_default';
const SESSIONS: Record<string, { activeOrganizationId?: string; authGate?: { code: string; message: string } }> = {
[ADMIN]: {},
[ORG_ADMIN]: { activeOrganizationId: ORG },
[MEMBER]: {},
[MEMBER]: { activeOrganizationId: ORG },
[HELD_ADMIN]: { authGate: { code: 'PASSWORD_EXPIRED', message: 'You must change your password before continuing.' } },
};

Expand All @@ -61,18 +63,20 @@ const ADMIN_KEY = 'osk_setup_admin_key_24148';
function grantRows(): Record<string, any[]> {
return {
sys_user: Object.keys(SESSIONS).map((id) => ({ id, email: `${id}@example.test` })),
sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ORG, slug: 'org-a' }],
sys_member: [{ id: 'mem_1', user_id: ORG_ADMIN, organization_id: ORG, role: 'admin' }],
sys_user_position: [],
sys_position: [],
sys_metadata_activation: [],
sys_position_permission_set: [],
sys_user_permission_set: [
{ id: 'ups_admin', user_id: ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: null },
{ id: 'ups_admin', user_id: ADMIN, permission_set_id: 'ps_full', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG },
{ id: 'ups_org_admin', user_id: ORG_ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: ORG },
{ id: 'ups_held', user_id: HELD_ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: null },
{ id: 'ups_member', user_id: MEMBER, permission_set_id: 'ps_member', permission_set: 'member_basic', organization_id: null },
{ id: 'ups_held', user_id: HELD_ADMIN, permission_set_id: 'ps_full', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG },
{ id: 'ups_member', user_id: MEMBER, permission_set_id: 'ps_member', permission_set: 'member_basic', organization_id: ORG },
],
sys_permission_set: [
{ id: 'ps_full', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] },
{ id: 'ps_setup', name: 'setup_admin', system_permissions: ['setup.access'] },
{ id: 'ps_member', name: 'member_basic', system_permissions: ['export_data'] },
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,18 @@ function makeRawApp() {
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
};
}
const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId } } : null) } });
/** The organization the sessions act in, and the one {@link ADMIN_GRANTS}' grant is owned by. */
const ORG = 'o1';
const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId }, session: { activeOrganizationId: ORG } } : null) } });
/**
* [#24148] The suites below sign in as `admin`, who must hold `setup.access` to
* reach the connection-changing doors (their subject is the credential, not the
* permission — that is `cloud-connection-setup-access.pin.test.ts`). Real
* permission-set rows, read by the shared resolver through `find`.
* permission-set rows, read by the shared resolver through `find`; the grant is
* owned by {@link ORG}, the organization the session acts in.
*/
const ADMIN_GRANTS: Record<string, any[]> = {
sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }],
sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }],
sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }],
};
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,13 @@ async function mountCloudConnection(opts: {
const hooks = new Map<string, any>();
const auth = {
api: {
getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: {} } : null),
// The session acts in `o1`, the organization the grant below is owned by.
getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: { activeOrganizationId: 'o1' } } : null),
},
};
const grantRows: Record<string, any[]> = opts.userId && opts.setupAccess !== false
? {
sys_user_permission_set: [{ id: 'ups_1', user_id: opts.userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }],
sys_user_permission_set: [{ id: 'ups_1', user_id: opts.userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: 'o1' }],
sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }],
}
: {};
Expand Down
Loading
Loading