Skip to content

Commit 62583ff

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24865-template-read-organization
Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
2 parents bb37f9c + d606fc1 commit 62583ff

120 files changed

Lines changed: 2812 additions & 1195 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/runtime': patch
5+
---
6+
7+
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
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- 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. -->
12+
13+
**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.
14+
15+
- **`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.
16+
- **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.
17+
- **`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.
18+
- **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".
19+
- **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.
20+
- **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.
21+
22+
**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.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/metadata-core': patch
5+
---
6+
7+
fix(lint)!: `os lint` and the react-page props check report the retired list-view `filterableFields` by name, and the readers that still treated it as a field position go
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (already-registered view-list-filterable-fields-removed, list-view-filterable-fields-retired) The key's retirement, its D2 conversion and its D3 entry are already registered by the change that tombstoned it, which shipped in 18.0.0. This change adds no key, conversion or stored shape: it makes `os lint` and the react-page props check give the verdict `os validate` and `os build` already give the list-view key, with the tombstone's own prescription. -->
12+
13+
**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
`@objectstack/spec` 18 retired the list view's `filterableFields` with a `retiredKey()` tombstone, so `os validate` and `os build` refuse it at the parse. Two authoring checks still read it as a live field position:
16+
17+
- **`os lint`** never parses: it judges the normalized stack, and the conversion that strips the key is retired from the load path. On a JSON, YAML or raw-object source the key reached the list-view field-reference rule as written. A valid field name passed `os lint`, and a stale one was reported as an unknown field (`list-view-field-unknown` / `list-view-field-dotted`), never as the retirement.
18+
- **The react-page props check** read `<ListView filterableFields={[…]}>` as a field-bearing prop. A react page's source is parsed against no list-view schema, so `os validate`, `os build` and `os lint` passed the prop whenever its names resolved.
19+
20+
Both now refuse the key by name, as an `error`, whatever it names:
21+
22+
- a list view (every rung: an object's `listViews`, a `defineView` container's `list` / `listViews`, the flattened list overlay and the view item record) reports `list-view-key-retired` at `….filterableFields`, whether or not its object binding resolves;
23+
- `<ListView filterableFields>` on a react page reports `react-prop-retired`.
24+
25+
The message names the retirement and the hint is the tombstone's own "Delete the key" sentence, read from `ListViewSchema` rather than retyped. The new rule id is exported from the package root as `LIST_VIEW_KEY_RETIRED`.
26+
27+
### FROM → TO
28+
29+
| before | after |
30+
| --- | --- |
31+
| `filterableFields: ['status']` on a list view in a non-`defineStack` source: `os lint` passed | `os lint` fails with `list-view-key-retired` |
32+
| `filterableFields: ['ghost']` on such a list view: `list-view-field-unknown` | `list-view-key-retired` |
33+
| `<ListView filterableFields={['status']} />` on a react page: passed every authoring command | `react-prop-retired` |
34+
35+
**The one-line fix:** delete `filterableFields` (the key or the prop). For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }`; the Filter panel offers every field the user may read. `os migrate meta --from 17` lists the edits for existing metadata sources; a react page's source is yours to edit by hand.
36+
37+
### The residue readers that go
38+
39+
- **`@objectstack/lint`**: the list-view field-reference rule no longer walks `filterableFields` as a filter position (its severity and dotted-path maps), the field-consumer rule no longer counts it as a behavioural read, and `os explain list-view-field-unknown` / `list-view-field-dotted` no longer name it as a checked position.
40+
- **`@objectstack/metadata-protocol`**: `computeViewReferenceDiagnostics` no longer walks `filterableFields`, the same deletion its retired sibling `tabs` had. A stored body has the key stripped by the conversion replay before it is served, and the write door refuses it.
41+
- **`@objectstack/metadata-core`**: the ADR-0106 field-security scrub no longer classifies `filterableFields` as a list-view name list. A body that still carries the key goes the unclassified way and fails closed: a list view whose `filterableFields` names a field the caller may not read is dropped whole, where the scrub used to remove the name.
42+
43+
⛔ **Untouched:** `userFilters` and `searchableFields` keep their meaning and their checks.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix(platform-objects): Setup no longer offers Ban with Unban, Revoke with Restore, or Unlock on every account, whatever the record's state
6+
7+
Clause-②: no (an action's offering: `visible` is judged in the console, and no server door reads it, so no accept set or served shape moves; if the dev measures a door that does, the dev stops, and the seat re-declares)
8+
9+
Five row and record-header actions judged only the caller's standing, so Setup offered both halves of each pair on every row. Each now also reads the record's state, in the form `sys_oauth_application`'s Disable / Enable pair already uses:
10+
11+
- `sys_user` (Setup › Users): *Ban User* is offered when `banned` is not true, *Unban User* when it is. A timed ban past `ban_expires` still reads banned until better-auth clears it at the user's next sign-in, so Unban stays offered for as long as the row says banned.
12+
- `sys_user`: *Unlock Account* is offered when `locked_until` is set, or when the user has two-factor enabled. Unlock also clears the second-factor lock, which lives on `sys_two_factor` and not on the user row, so a two-factor user keeps the button. An expired `locked_until` keeps it too: the failed-attempt counter stays at the threshold until Unlock or a successful sign-in resets it.
13+
- `sys_api_key` (Setup › API Keys): *Revoke API Key* is offered on a live key, *Restore API Key* on a revoked one.
14+
15+
The `sys_user` actions keep their platform-admin standing (`current_user.isPlatformAdmin == true`) first. A row that does not carry the field offers neither action of a pair. The console asks for every field an object action's predicate reads, so the default Users list carries `banned` without showing it.
16+
17+
Unchanged: labels, texts, targets and params, and what the ban, unban, unlock and key routes accept and answer. Who may restore a key that a forced password reset revoked is still decided by the server.

‎packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,17 +267,22 @@ const APP = {
267267
};
268268
const LEDGER_FILE = 'com.acme.gated.json';
269269

270-
/** Every test user holds the authoring capability, so a refusal below is the gate's alone. */
270+
/**
271+
* Every test user holds the authoring capability, so a refusal below is the gate's alone:
272+
* the `admin_full_access` grant owned by the Default Organization, which applies with no
273+
* active organization.
274+
*/
271275
function grantRows(): Record<string, any[]> {
272276
return {
277+
sys_organization: [{ id: 'org_default', slug: 'default' }],
273278
sys_user: Object.keys(POSTURES).map((id) => ({ id, email: `${id}@acme.test` })),
274279
sys_member: [],
275280
sys_user_position: [],
276281
sys_position: [],
277282
sys_metadata_activation: [],
278283
sys_position_permission_set: [],
279284
sys_user_permission_set: Object.keys(POSTURES).map((id, i) => ({
280-
id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null,
285+
id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: 'org_default',
281286
})),
282287
sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }],
283288
};

‎packages/cloud-connection/src/cloud-connection-plugin.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,24 @@ function makeC(url: string, body?: any) {
5656
};
5757
}
5858

59+
/** The organization the sessions act in, and the one {@link setupAccessEngine}'s grant is owned by. */
60+
const ORG = 'o1';
61+
5962
const sessionAuth = (userId?: string) => ({
60-
api: { getSession: async () => (userId ? { user: { id: userId } } : null) },
63+
api: { getSession: async () => (userId ? { user: { id: userId }, session: { activeOrganizationId: ORG } } : null) },
6164
});
6265

6366
/**
6467
* [#24148] A data engine under which `userId` holds `setup.access` — what
6568
* `/status` serves its full answer behind. Real permission-set rows, read by
66-
* the shared resolver through `find`.
69+
* the shared resolver through `find`. The grant is owned by {@link ORG}, the
70+
* organization the session acts in: a grant applies only in its own
71+
* organization.
6772
*/
6873
const setupAccessEngine = (userId: string) => {
6974
const rows: Record<string, any[]> = {
7075
sys_user: [{ id: userId, email: `${userId}@example.test` }],
71-
sys_user_permission_set: [{ id: 'ups_1', user_id: userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }],
76+
sys_user_permission_set: [{ id: 'ups_1', user_id: userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }],
7277
sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }],
7378
};
7479
return bindCatalogFromTables({ find: async (object: string) => rows[object] ?? [] }, rows);

‎packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ const CC = '/api/v1/cloud-connection';
3535
const ORIGIN = 'https://env.example.test';
3636
const CLOUD = 'http://cloud.test';
3737

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

@@ -95,7 +96,7 @@ async function mount(mode: Mode, discovery: Discovery) {
9596
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
9697
};
9798
const hooks = new Map<string, any>();
98-
const auth = { api: { getSession: async () => ({ user: { id: ADMIN }, session: { id: 'sess_admin' } }) } };
99+
const auth = { api: { getSession: async () => ({ user: { id: ADMIN }, session: { id: 'sess_admin', activeOrganizationId: ORG } }) } };
99100
const objectql = bindCatalogFromTables({ find: async (object: string) => GRANTS[object] ?? [] }, GRANTS);
100101
const protocol = discovery === 'real'
101102
? discoveryProducer()

‎packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,22 @@ import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
3838
const CC = '/api/v1/cloud-connection';
3939
const ORIGIN = 'https://env.example.test';
4040

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

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

@@ -61,18 +63,20 @@ const ADMIN_KEY = 'osk_setup_admin_key_24148';
6163
function grantRows(): Record<string, any[]> {
6264
return {
6365
sys_user: Object.keys(SESSIONS).map((id) => ({ id, email: `${id}@example.test` })),
66+
sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ORG, slug: 'org-a' }],
6467
sys_member: [{ id: 'mem_1', user_id: ORG_ADMIN, organization_id: ORG, role: 'admin' }],
6568
sys_user_position: [],
6669
sys_position: [],
6770
sys_metadata_activation: [],
6871
sys_position_permission_set: [],
6972
sys_user_permission_set: [
70-
{ id: 'ups_admin', user_id: ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: null },
73+
{ id: 'ups_admin', user_id: ADMIN, permission_set_id: 'ps_full', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG },
7174
{ id: 'ups_org_admin', user_id: ORG_ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: ORG },
72-
{ id: 'ups_held', user_id: HELD_ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: null },
73-
{ id: 'ups_member', user_id: MEMBER, permission_set_id: 'ps_member', permission_set: 'member_basic', organization_id: null },
75+
{ id: 'ups_held', user_id: HELD_ADMIN, permission_set_id: 'ps_full', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG },
76+
{ id: 'ups_member', user_id: MEMBER, permission_set_id: 'ps_member', permission_set: 'member_basic', organization_id: ORG },
7477
],
7578
sys_permission_set: [
79+
{ id: 'ps_full', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] },
7680
{ id: 'ps_setup', name: 'setup_admin', system_permissions: ['setup.access'] },
7781
{ id: 'ps_member', name: 'member_basic', system_permissions: ['export_data'] },
7882
],

‎packages/cloud-connection/src/connection-credential-store.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,18 @@ function makeRawApp() {
5555
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
5656
};
5757
}
58-
const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId } } : null) } });
58+
/** The organization the sessions act in, and the one {@link ADMIN_GRANTS}' grant is owned by. */
59+
const ORG = 'o1';
60+
const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId }, session: { activeOrganizationId: ORG } } : null) } });
5961
/**
6062
* [#24148] The suites below sign in as `admin`, who must hold `setup.access` to
6163
* reach the connection-changing doors (their subject is the credential, not the
6264
* permission — that is `cloud-connection-setup-access.pin.test.ts`). Real
63-
* permission-set rows, read by the shared resolver through `find`.
65+
* permission-set rows, read by the shared resolver through `find`; the grant is
66+
* owned by {@link ORG}, the organization the session acts in.
6467
*/
6568
const ADMIN_GRANTS: Record<string, any[]> = {
66-
sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }],
69+
sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }],
6770
sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }],
6871
};
6972
/**

‎packages/cloud-connection/src/error-envelope.conformance.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,13 @@ async function mountCloudConnection(opts: {
189189
const hooks = new Map<string, any>();
190190
const auth = {
191191
api: {
192-
getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: {} } : null),
192+
// The session acts in `o1`, the organization the grant below is owned by.
193+
getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: { activeOrganizationId: 'o1' } } : null),
193194
},
194195
};
195196
const grantRows: Record<string, any[]> = opts.userId && opts.setupAccess !== false
196197
? {
197-
sys_user_permission_set: [{ id: 'ups_1', user_id: opts.userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }],
198+
sys_user_permission_set: [{ id: 'ups_1', user_id: opts.userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: 'o1' }],
198199
sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }],
199200
}
200201
: {};

0 commit comments

Comments
 (0)