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
4 changes: 2 additions & 2 deletions .changeset/23430-grant-permission-set-id-retired.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/plugin-security': minor
'@objectstack/cli': minor
Expand All @@ -18,13 +18,13 @@

- `sys_user_permission_set.permission_set_id` → `permission_set`, the machine name of the set the grant holds. A seed row or data-door write that still carries `permission_set_id` is refused with `400 INVALID_FIELD`. Fix: delete the key and name the set, `{ user_id, permission_set: '<set name>', organization_id }`.
- The unique index (`user_id`, `permission_set_id`, `organization_id`) and the (`permission_set_id`) index → one unique index on (`user_id`, `permission_set`, `organization_id`), declared `unique: 'organization'`: its organization key part is NULL-safe (ADR-0120 D3), so it refuses a second grant of one set to one user in one organization AND a second platform-wide grant (organization NULL), for every writer and every write path — batches, multi-row updates, concurrent writes and raw SQL included. The data doors answer `409 UNIQUE_VIOLATION` (the engine throws `DuplicateRecordError`, `DUPLICATE_RECORD`). Fix: keep the grant that exists; to change it, update that row.
- The boot-time grant-name backfill (`runOneTimeGrantPermissionSetNameBackfill`, the `sys_migration` entry it recorded at `kernel:bootstrapped`) → `os migrate organization-ownership --apply`. Its verified rewrite names every grant from the physical `permission_set_id` column by raw SQL, verifies each name against the security catalog, and its `column-drops` step then drops the column through the schema-drift ceremony (schema sync is additive, so an undeclared column stays until it is dropped). Fix: run the ceremony on every database upgraded from a release before the `permission_set` column existed, then restart the server: the boot's organization-administrator sweep revokes, by name, a newly named grant whose pair no longer qualifies, and a server left running across `--apply` serves it until then.
- The boot-time grant-name backfill (`runOneTimeGrantPermissionSetNameBackfill`, the `sys_migration` entry it recorded at `kernel:bootstrapped`) → the v18 ceremony: plan it with `os migrate organization-ownership --out plan.json`, review the file it writes, then run `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`. Its verified rewrite names every grant from the physical `permission_set_id` column by raw SQL, verifies each name against the security catalog, and its `column-drops` step then drops the column through the schema-drift ceremony (schema sync is additive, so an undeclared column stays until it is dropped). Fix: run the ceremony on every database upgraded from a release before the `permission_set` column existed, then restart the server: the boot's organization-administrator sweep revokes, by name, a newly named grant whose pair no longer qualifies, and a server left running across `--apply` serves it until then.

On a 17.x database every grant names nothing until `--apply` names it, so between the first v18 boot and `--apply` no grant confers anything, the platform administrator's included.

What else changes:

- `os migrate organization-ownership --plan` reports a `grants` block on `sys_user_permission_set`, keyed like the new index (a NULL organization keyed with NULL):
- The plan run, `os migrate organization-ownership --out plan.json`, reports a `grants` block on `sys_user_permission_set`, keyed like the new index (a NULL organization keyed with NULL):
- `duplicates`: groups of named grants holding one set for one user in one organization. `--apply`'s `column-drops` step does not drop `permission_set_id` while one group remains.
- `rewriteCollisions`: legacy grants whose derived name their user already holds in the same organization. `--apply`'s rewrite names none of them and blocks, naming both ids.
- On a database without the `permission_set` column it counts zero and names the remedy (boot this release once), instead of refusing the plan.
Expand Down
15 changes: 15 additions & 0 deletions .changeset/23513-grant-remedy-plan-file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-security': patch
'@objectstack/spec': patch
---

fix(plugin-security,spec): the unnamed-grant boot line and the `ups-permission-set-id-column-retired` upgrade entry name the reviewed-plan ceremony, `--out plan.json` then `--apply --backup-taken --plan-file plan.json`, instead of a bare `--apply` the CLI refuses (ADR-0131 D10)

Clause-②: no

`os migrate organization-ownership --apply` refuses a run without `--plan-file`, the file a plan run wrote and the operator reviewed. Two remedy texts still told the operator to run `--apply` alone, which answers `plan-file-required` and writes nothing:

- `@objectstack/plugin-security`: the `kernel:bootstrapped` catalog reference report's error line for grants that name no permission set (`permission_set` empty).
- `@objectstack/spec`: the `replacement` of the ADR-0087 entry `ups-permission-set-id-column-retired`, the row the upgrade guide prints for the retired `sys_user_permission_set.permission_set_id`.

Both now name the two steps: plan first, `os migrate organization-ownership --out plan.json`, review the file it writes, then `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`, which executes that plan; restart afterwards. The ceremony itself is unchanged: its rewrite names each legacy grant from the retired id, and its column-drops step drops the two retired indexes and then the column. Only the text changes; the line's level, its fields and the entry's `id` are as they were.
Original file line number Diff line number Diff line change
Expand Up @@ -1979,7 +1979,7 @@ describe('ADR-0131 D10 --apply — the grant\'s retired id column (C7b, seat ans
expect(idColumn()).toEqual([]);
});

it('a second platform-wide grant (organization NULL) is a duplicate too: GROUP BY keys NULLs together, unlike the index', async () => {
it('a second platform-wide grant (organization NULL) is a duplicate too: GROUP BY keys NULLs together, as the index\'s NULL-safe organization key part does', async () => {
await onlyTemplates(`('tpl_none', NULL, 'crm.none', 'en-US', 'x', 0, 'package')`, async () => {
db.exec(`
CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, user_id TEXT,
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/utils/organization-ownership-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ export interface GrantRewritePlan {
reasons: Record<UnnamedReason, string[]>;
}

/** The unique key of a grant; a NULL organization is one key, as the write door keys it. */
/**
* The unique key of a grant; a NULL organization is one key, as the grant's
* `unique: 'organization'` index keys it (its organization key part is
* NULL-safe, ADR-0120 D3).
*/
export function grantKey(userId: string | null, name: string, organizationId: string | null): string {
return JSON.stringify([userId, name, organizationId]);
}
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/utils/organization-ownership-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,9 @@ export interface MetadataConflict {
/**
* `sys_user_permission_set` (C7b): the populations the unique index on
* (`user_id`, `permission_set`, `organization_id`) refuses, which the ceremony
* reports and never deletes. A NULL organization is keyed with NULL, as the
* write door keys it.
* reports and never deletes. A NULL organization is keyed with NULL, as that
* index keys it: it is declared `unique: 'organization'`, so its organization
* key part is NULL-safe (ADR-0120 D3).
*/
export interface GrantKeyPlan {
/** `absent` on a 17.x database this release has not booted against yet: nothing can be counted. */
Expand Down
18 changes: 9 additions & 9 deletions packages/plugins/plugin-auth/src/grant-set-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
/**
* [ADR-0131 D4] How this package's grant readers read which permission set a
* `sys_user_permission_set` grant holds: BY NAME, from its `permission_set`
* column — the reference the grant keeps once ADR-0131 C8 drops
* column — the grant's one reference since ADR-0131 C7b retired
* `permission_set_id`.
*
* Every grant writer stores the name and nothing else (this package's
* self-registration grant among them); `plugin-security` refuses a non-system
* grant naming a set the security catalog does not hold, and a one-time
* backfill names the grants written before the column existed. Internal: not
* re-exported from the package entry.
* grant naming a set the security catalog does not hold. No boot names a
* grant. Internal: not re-exported from the package entry.
*
* A grant whose name is `NULL` or blank names no set. That is a grant written
* before the column existed on an upgraded deployment's first boot, before the
* backfill (`kernel:bootstrapped`) names it, or one the backfill could not name
* (an id with no set row, or a set row of another organization). Such a grant
* confers nothing through a by-name reader here; each reader states what it
* does with one in the direction that fails closed.
* before the column existed, on a database upgraded from such a release, until
* the v18 ceremony's verified rewrite (`os migrate organization-ownership
* --apply`, ADR-0131 D10) names it from the retired id, or one the rewrite does
* not name (it lists each, with the reason). Such a grant confers nothing
* through a by-name reader here; each reader states what it does with one in
* the direction that fails closed.
*/

/** The grant's NAME reference to its permission set (ADR-0131 D4). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,10 +433,11 @@ function logReport(report: CatalogReferenceReport, logger?: CatalogReferenceRepo
logError(
logger,
`[security] ${unnamed.count} ${GRANT_OBJECT} grant(s) name no permission set (${GRANT_SET_NAME_FIELD} is ` +
'empty), so each confers nothing (ADR-0131 D4). Nothing was changed. Fix: run the v18 ceremony, ' +
'os migrate organization-ownership --apply, which names each legacy grant from its retired id and lists ' +
'the ones it cannot name; then restart. Granting the set again by name before it runs makes a second ' +
'grant the ceremony will report, not name.',
'empty), so each confers nothing (ADR-0131 D4). Nothing was changed. Fix: run the v18 ceremony. Plan it ' +
'first, os migrate organization-ownership --out plan.json, and review the file it writes; then run ' +
'os migrate organization-ownership --apply --backup-taken --plan-file plan.json, which executes that plan: ' +
'it names each legacy grant from its retired id and lists the ones it cannot name. Then restart. Granting ' +
'the set again by name before it runs makes a second grant the ceremony will report, not name.',
{ count: unnamed.count, organizations: unnamed.organizations },
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ export const entry: SemanticMigration = {
replacement:
'`permission_set`, the machine name of the permission set the grant holds (ADR-0131 D4). Delete '
+ 'the key from any authored `sys_user_permission_set` seed row or data-door write that still '
+ 'carries it, and name the set instead. Existing databases: run the v18 ceremony, '
+ '`os migrate organization-ownership --apply`, whose verified rewrite names every grant from the '
+ 'column and whose column-drops step then drops the two retired indexes and then the column; '
+ 'restart the server afterwards',
+ 'carries it, and name the set instead. Existing databases: run the v18 ceremony. Plan it first, '
+ '`os migrate organization-ownership --out plan.json`, and review the file it writes; then '
+ '`os migrate organization-ownership --apply --backup-taken --plan-file plan.json` executes that plan: its verified '
+ 'rewrite names every grant from the column and its column-drops step then drops the two retired '
+ 'indexes and then the column. Restart the server afterwards',
reason:
'ADR-0131 D4 makes every reference to a catalog item a name, and D3 retired the permission-set '
+ 'catalog rows the id pointed at. Since then no writer fills the column and no reader confers '
Expand Down
4 changes: 0 additions & 4 deletions scripts/check-durability-degradation-log-level.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -408,10 +408,6 @@ const DURABILITY_CRITICAL_CALLEES = new Map([
'persistLedgerDecisionRow',
"A one-time membership decision (the ADR-0093 D6 backfill, or the default organization's owner bind) was carried out but its `sys_migration` record was never written. Every log line reads clean and the memberships it wrote stand, while the record that makes the decision ONE-TIME is absent, so the next boot decides it again — re-deciding membership for users whose membership was already decided at creation (ADR-0093 D7).",
],
[
'persistGrantNameBackfillRecord',
"The one-time grant-name backfill (ADR-0131 D4) named the grants it could, but its `sys_migration` verdict row was never written. Every log line reads clean and the names it wrote stand, while the record that makes the pass ONE-TIME is absent, so every later boot scans the whole grant table again and re-reports the grants it cannot name.",
],
[
'runWideningAlters',
"The widening ALTER never ran — the MySQL column keeps its legacy zero-precision type (`TIMESTAMP` for a `Field.datetime`, `TIME` for a `Field.time`) while the object stays registered and served, so every subsequent write silently drops the milliseconds the canonical storage form promises are always present: a `TIMESTAMP` truncates them, and a `TIME(0)` ROUNDS a fractional literal, changing the wall clock it was asked to store. Reads come back looking clean because the value that was stored is the value that is returned, and nothing else reports the column is still un-widened (#9609).",
Expand Down
1 change: 0 additions & 1 deletion scripts/measure-durability-swallow-family.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,6 @@ const WRITE_SHAPED_CALLEES = new Map([
['persistPackageCommitRow', 'gate-vocabulary'],
['persistSeedTenancyReceiptRow', 'gate-vocabulary'],
['persistLedgerDecisionRow', 'gate-vocabulary'],
['persistGrantNameBackfillRecord', 'gate-vocabulary'],
['recordLog', 'gate-vocabulary'],
['runWideningAlters', 'gate-vocabulary'],
['applyConfigPatch', 'gate-vocabulary'],
Expand Down
Loading