@wtfalch/authz-store 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -18
- package/dist/activations.js +6 -5
- package/dist/binding.d.ts +16 -1
- package/dist/boot.d.ts +6 -4
- package/dist/boot.js +47 -5
- package/dist/erase.d.ts +1 -1
- package/dist/erase.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/memberships.js +4 -0
- package/dist/policy-entry.d.ts +1 -1
- package/dist/policy-resources.js +17 -5
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +1 -0
- package/dist/role-keys.d.ts +2 -1
- package/dist/role-keys.js +2 -1
- package/dist/roles.d.ts +9 -1
- package/dist/roles.js +16 -3
- package/dist/schema.d.ts +34 -0
- package/dist/schema.js +7 -0
- package/dist/tenants.d.ts +2 -2
- package/dist/tenants.js +2 -2
- package/migrations/0007_event_display_columns.sql +52 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,10 +15,24 @@ package rather than a subpath on the engine.
|
|
|
15
15
|
|
|
16
16
|
## Status
|
|
17
17
|
|
|
18
|
-
**Published
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
**Published at 0.4.0; host adoption is incomplete.** Checked on 2026-09-30
|
|
19
|
+
against the npm registry (latest 0.4.0, shasum
|
|
20
|
+
`9305f37cc20d79a469cbaacde80990e295ebc2df`) and the repository manifest.
|
|
21
|
+
The registry exports both the main entry and the client-safe `./policy` entry.
|
|
22
|
+
Extraction waves 1–11 are implemented in this package; publishing issues
|
|
23
|
+
[#123](https://github.com/wtfalch/authz/issues/123) and
|
|
24
|
+
[#128](https://github.com/wtfalch/authz/issues/128) are closed.
|
|
25
|
+
|
|
26
|
+
Current host pins and remaining adoption work are recorded in the
|
|
27
|
+
[shared persistence map](../../docs/shared-persistence-map.md#current-status-2026-09-30).
|
|
28
|
+
A manifest pin establishes a source dependency, not a deployment or full
|
|
29
|
+
adoption. Archon pins 0.4.0 and has merged wave 1, with a local access-loader
|
|
30
|
+
exception pending [#130](https://github.com/wtfalch/authz/issues/130). Boule
|
|
31
|
+
pins 0.2.1, OTF `web/` pins 0.2.0, and the people package pins 0.2.1.
|
|
32
|
+
|
|
33
|
+
The following wave notes describe the extraction steps in order. References
|
|
34
|
+
to modules being host-side at a particular wave describe that step, not the
|
|
35
|
+
current adoption status of every host.
|
|
22
36
|
|
|
23
37
|
Moved so far: credential secrets, the `ScopedDb` seam, every table definition,
|
|
24
38
|
`ownerCoverage`, wave 1 (authz#83): `StoreBinding` (the `applicationId`,
|
|
@@ -367,8 +381,9 @@ published an hour before this fix and no persistent database had applied
|
|
|
367
381
|
0006 yet, so this is a same-file rename, not a new numbered migration.
|
|
368
382
|
`src/erase.ts` calls `authz_erase_person`.
|
|
369
383
|
|
|
370
|
-
|
|
371
|
-
|
|
384
|
+
Extraction is complete through wave 11. Remaining host adoption and the
|
|
385
|
+
resource-resolution extension are tracked in
|
|
386
|
+
https://github.com/wtfalch/authz/issues/83 and the current-status map above.
|
|
372
387
|
|
|
373
388
|
New here, not moved: `authz_grants`, grants stored per person rather than
|
|
374
389
|
compiled from a role, and `guestGrants`, which reads a tenant's guest grants
|
|
@@ -435,18 +450,28 @@ app owns:
|
|
|
435
450
|
list that app's offered permissions.
|
|
436
451
|
- The foreign key from `break_glass_sessions.operator_id` to `profiles`.
|
|
437
452
|
|
|
438
|
-
The baseline is for a new database.
|
|
439
|
-
|
|
440
|
-
baseline
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
453
|
+
The baseline is for a new database. An existing host must verify its schema
|
|
454
|
+
and migration history before adopting it. Its reconciliation brings the
|
|
455
|
+
schema to the baseline before recording `0001_baseline.sql` in
|
|
456
|
+
`authz_store_migrations`; it must preserve runtime-role privileges and the
|
|
457
|
+
host-specific constraints above. The historical map records OTF's missing
|
|
458
|
+
`authz_roles.updated_by` and `'activation'` assignment source; verify these
|
|
459
|
+
against the host being migrated rather than assuming that snapshot is current.
|
|
460
|
+
|
|
461
|
+
Version 0.4.0 creates `authz_erase_person`, keeping host erasure separate.
|
|
462
|
+
A host whose database already recorded an older `0006_erase_person.sql` must
|
|
463
|
+
check which function was installed: changing a dependency pin does not replay
|
|
464
|
+
an applied migration. Reconcile with a new additive host migration and retain
|
|
465
|
+
host-owned profile/reporting scrubs. Boule's upgrade prerequisite is tracked
|
|
466
|
+
in [boule#163](https://github.com/wtfalch/boule/issues/163). No deployed migration
|
|
467
|
+
history was inspected for this status update.
|
|
468
|
+
|
|
469
|
+
Hosts pass their application id, platform id and catalogue through
|
|
470
|
+
`StoreBinding`/`StorePolicy`; the store does not import host constants.
|
|
471
|
+
|
|
472
|
+
The remaining work is adoption of the extracted modules, host resource-resolution
|
|
473
|
+
support and each host's verified reconciliation. Follow the current-status map
|
|
474
|
+
and host issues; the historical line count is not a current migration estimate.
|
|
450
475
|
|
|
451
476
|
## Why it is not part of `@wtfalch/authz`
|
|
452
477
|
|
|
@@ -465,3 +490,43 @@ framework-neutral package cannot import it, so that import is dropped here. A
|
|
|
465
490
|
host that wants the guard re-exports these functions through its own
|
|
466
491
|
`server-only` module. `generateCredentialSecret` reaching a client bundle is the
|
|
467
492
|
thing worth preventing.
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
## Host resource lookups
|
|
496
|
+
|
|
497
|
+
`StoreBinding.resolveResource` extends `policyResource`, `loadAccess` and `refreshAccess`
|
|
498
|
+
with authoritative metadata for host-owned resource types (authz#130). The callback receives
|
|
499
|
+
the caller's database transaction and an immutable target containing `applicationId`,
|
|
500
|
+
`platformId`, `organisationId`, `type` and `id`. It returns an `AccessResource` or `undefined`:
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
const policy = definePolicy(catalogueData, {
|
|
504
|
+
applicationId: APPLICATION_ID,
|
|
505
|
+
platformId: PLATFORM_ID,
|
|
506
|
+
resolveResource: async (tx, target) => {
|
|
507
|
+
if (target.type !== 'files.file') return undefined;
|
|
508
|
+
// This host function queries its own table using BOTH organisationId and id.
|
|
509
|
+
const row = await findFile(tx, target.organisationId, target.id);
|
|
510
|
+
if (!row) return undefined;
|
|
511
|
+
return {
|
|
512
|
+
...target,
|
|
513
|
+
teamId: row.teamId,
|
|
514
|
+
owner: { id: row.ownerId, class: row.ownerClass },
|
|
515
|
+
};
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
This callback is trusted server configuration. The host must query authoritative rows
|
|
521
|
+
in the supplied transaction, constrain them to the requested organisation and resource,
|
|
522
|
+
and return `undefined` for missing resources or optional modules. The store rejects a
|
|
523
|
+
result with a different application, platform, organisation, type or id. Lookup errors
|
|
524
|
+
propagate. An unresolved resource cannot establish the team containment needed to
|
|
525
|
+
delegate a team-scoped grant to one resource. Core `team` and `audit`
|
|
526
|
+
lookups keep their store checks and never fall through to the callback.
|
|
527
|
+
|
|
528
|
+
`definePolicy` retains the callback on the binding, so `refreshAccess` uses it when
|
|
529
|
+
re-reading credential lineage before a mutation. Keep callbacks and their database
|
|
530
|
+
imports in server modules; the `./policy` export remains safe to import for client
|
|
531
|
+
catalogue labels. This API requires the next store release; published 0.4.0 does not
|
|
532
|
+
include it. Host adoption and release remain separate steps under authz#83.
|
package/dist/activations.js
CHANGED
|
@@ -108,7 +108,8 @@ function entryBoundary(catalogue, permission, tenantId) {
|
|
|
108
108
|
* calling `compileRoleGrants`: eligibility only needs to know a permission is
|
|
109
109
|
* *named*, not perform a full grant compile, and a standing custom role can
|
|
110
110
|
* never contain a non-assignable permission in the first place (creating one
|
|
111
|
-
* would already have failed `roleAssignmentRefusal
|
|
111
|
+
* would already have failed `roleAssignmentRefusal`: a custom role has no
|
|
112
|
+
* guards and is not keyed `owner`), so nothing here can
|
|
112
113
|
* throw the way minting a fresh role below can.
|
|
113
114
|
*/
|
|
114
115
|
async function eligibleElevatedPermissions(tx, binding, tenantId, elevated, principal) {
|
|
@@ -231,10 +232,10 @@ export async function requestActivation(access, db, scopeColumn, policy, options
|
|
|
231
232
|
}
|
|
232
233
|
if (refusal !== null) {
|
|
233
234
|
// compileRoleGrants throws for any entry whose permission is
|
|
234
|
-
// `assignable: false` on a
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
235
|
+
// `assignable: false` on a role that is neither keyed `owner` nor
|
|
236
|
+
// guarded by an owner-only permission for an `ownerGuarded` entry. An
|
|
237
|
+
// activation's minted role is neither, so a permission like that
|
|
238
|
+
// cannot be minted into one.
|
|
238
239
|
return refused('invalid_role', 'One or more of these permissions cannot yet be granted through a time-boxed activation.');
|
|
239
240
|
}
|
|
240
241
|
await tx.insert(policyRoles).values({
|
package/dist/binding.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import type { ResourceCatalogue, ResourcePermission } from '@wtfalch/authz';
|
|
1
|
+
import type { AccessResource, ResourceCatalogue, ResourcePermission } from '@wtfalch/authz';
|
|
2
2
|
import type { PolicyBinding } from './owners.js';
|
|
3
|
+
import type { DbOrTx } from './scoped.js';
|
|
4
|
+
/** Exact tenant resource identity requested by the shared access loader. */
|
|
5
|
+
export type ResourceTarget = Pick<AccessResource, 'applicationId' | 'platformId' | 'type' | 'id'> & {
|
|
6
|
+
readonly organisationId: string;
|
|
7
|
+
};
|
|
8
|
+
/** A trusted host lookup, using the caller's transaction and tenant predicate. */
|
|
9
|
+
export type HostResourceResolver = (tx: DbOrTx, target: ResourceTarget) => Promise<AccessResource | undefined>;
|
|
3
10
|
/**
|
|
4
11
|
* `PolicyBinding` (`applicationId`, `platformId`) plus the host's own resource catalogue. The
|
|
5
12
|
* store has no catalogue of its own — each host defines its own permissions — so every moved
|
|
@@ -8,6 +15,14 @@ import type { PolicyBinding } from './owners.js';
|
|
|
8
15
|
*/
|
|
9
16
|
export interface StoreBinding extends PolicyBinding {
|
|
10
17
|
readonly catalogue: ResourceCatalogue;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve host-owned resource types for delegation and credential lineage. Return undefined
|
|
20
|
+
* for an absent module or resource. Query authoritative rows in `tx`, constrained to the
|
|
21
|
+
* requested organisation and id. Team and audit resources remain store-owned. A result
|
|
22
|
+
* whose application, platform, organisation, type or id differs from the target is rejected.
|
|
23
|
+
* Lookup errors propagate; they never confer access.
|
|
24
|
+
*/
|
|
25
|
+
readonly resolveResource?: HostResourceResolver;
|
|
11
26
|
/**
|
|
12
27
|
* The host's own audit event names, beyond `@wtfalch/authz`'s core events (0.4.0). A host
|
|
13
28
|
* records its own events (archon: `flag.changed`, `cms.published`, `key.rotated`; files/ai/
|
package/dist/boot.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type TenantKind } from '@wtfalch/authz';
|
|
2
2
|
import type { StoreBinding } from './binding.js';
|
|
3
3
|
import type { StorePolicy } from './policy.js';
|
|
4
4
|
import type { DbOrTx } from './scoped.js';
|
|
@@ -35,9 +35,11 @@ export declare function ensureBuiltInRoles(tx: DbOrTx, policy: StorePolicy, tena
|
|
|
35
35
|
* exist from before this change shipped) without duplicating or throwing.
|
|
36
36
|
*
|
|
37
37
|
* Stored `built_in: true`, not the `false` `starterRoles()` itself declares,
|
|
38
|
-
* for the reasons the host's own boot.ts documents
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
38
|
+
* for the reasons the host's own boot.ts documents. `built_in` does not let a
|
|
39
|
+
* role hold a locked power: `@wtfalch/authz`'s `compileRoleGrants` lets a
|
|
40
|
+
* non-`assignable` entry onto the role keyed `owner`, or, for an
|
|
41
|
+
* `ownerGuarded` permission, onto a role guarded by an owner-only permission,
|
|
42
|
+
* whatever the row's `built_in` flag says.
|
|
41
43
|
*/
|
|
42
44
|
export declare function seedStarterRoles(tx: DbOrTx, policy: StorePolicy, tenant: {
|
|
43
45
|
id: string;
|
package/dist/boot.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { compileRoleGrants, resourceRoleSchema, } from '@wtfalch/authz';
|
|
2
3
|
import { and, eq } from 'drizzle-orm';
|
|
4
|
+
import { primaryAssignment } from './assignments.js';
|
|
3
5
|
import { writeServiceEvent } from './audit.js';
|
|
4
6
|
import { policyRole, requirePolicySchema } from './policy-access.js';
|
|
5
7
|
import { policyRoles } from './policy-schema.js';
|
|
@@ -27,14 +29,49 @@ export const content = (role) => canonical({
|
|
|
27
29
|
label: role.label,
|
|
28
30
|
description: role.description,
|
|
29
31
|
});
|
|
32
|
+
/**
|
|
33
|
+
* A stored role that does not compile makes `loadPolicyState` throw for every access check in the
|
|
34
|
+
* tenant, and an unchanged revision is never rewritten, so refuse such a template before anything
|
|
35
|
+
* is written. The schema is checked first, then each entry alone so the error can name the
|
|
36
|
+
* permission.
|
|
37
|
+
*/
|
|
38
|
+
function assertTemplateCompiles(policy, template) {
|
|
39
|
+
const parsed = resourceRoleSchema.safeParse({ id: 'template', ...template });
|
|
40
|
+
if (!parsed.success)
|
|
41
|
+
throw new Error(`Role template ${template.key} is invalid: ${parsed.error.issues
|
|
42
|
+
.map((i) => `${i.path.join('.') || '(role)'}: ${i.message}`)
|
|
43
|
+
.join('; ')}`);
|
|
44
|
+
const role = parsed.data;
|
|
45
|
+
const assignment = primaryAssignment(role, { id: 'template', class: 'human' });
|
|
46
|
+
for (const entry of role.entries) {
|
|
47
|
+
try {
|
|
48
|
+
compileRoleGrants({ ...role, entries: [entry] }, assignment, policy.catalogue);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
const p = Object.hasOwn(policy.catalogue, entry.permission)
|
|
52
|
+
? policy.catalogue[entry.permission]
|
|
53
|
+
: undefined;
|
|
54
|
+
const shapeOk = p?.scopes.includes(entry.scope.kind) &&
|
|
55
|
+
p.boundaries.includes(entry.boundary.kind) &&
|
|
56
|
+
p.relations.includes(entry.relation);
|
|
57
|
+
throw new Error(p && shapeOk && !p.assignable
|
|
58
|
+
? `Role template ${template.key} cannot hold ${entry.permission}: it is a locked (non-assignable) permission this role may not hold. Mark it ownerGuarded in the catalogue and guard the role with an owner-only permission (non-assignable, not ownerGuarded).`
|
|
59
|
+
: `Role template ${template.key} has an entry for ${entry.permission} that does not fit the catalogue: unknown permission, or a scope, boundary or relation it does not allow.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
30
63
|
/** Called in the tenant creation/deploy transaction while its tenant row is locked. */
|
|
31
64
|
export async function ensureBuiltInRoles(tx, policy, tenant) {
|
|
65
|
+
// Validate every template before the first write, so a bad one never leaves a partial write.
|
|
66
|
+
const templates = policy.builtInRoles(tenant.id, tenant.kind);
|
|
67
|
+
for (const template of templates)
|
|
68
|
+
assertTemplateCompiles(policy, template);
|
|
32
69
|
const rows = await tx
|
|
33
70
|
.select()
|
|
34
71
|
.from(policyRoles)
|
|
35
72
|
.where(and(eq(policyRoles.tenantId, tenant.id), eq(policyRoles.applicationId, policy.applicationId), eq(policyRoles.platformId, policy.platformId)));
|
|
36
73
|
const result = [];
|
|
37
|
-
for (const template of
|
|
74
|
+
for (const template of templates) {
|
|
38
75
|
const existing = rows.find((r) => r.key === template.key);
|
|
39
76
|
let status = 'unchanged';
|
|
40
77
|
if (existing) {
|
|
@@ -99,12 +136,17 @@ export async function ensureBuiltInRoles(tx, policy, tenant) {
|
|
|
99
136
|
* exist from before this change shipped) without duplicating or throwing.
|
|
100
137
|
*
|
|
101
138
|
* Stored `built_in: true`, not the `false` `starterRoles()` itself declares,
|
|
102
|
-
* for the reasons the host's own boot.ts documents
|
|
103
|
-
*
|
|
104
|
-
* `
|
|
139
|
+
* for the reasons the host's own boot.ts documents. `built_in` does not let a
|
|
140
|
+
* role hold a locked power: `@wtfalch/authz`'s `compileRoleGrants` lets a
|
|
141
|
+
* non-`assignable` entry onto the role keyed `owner`, or, for an
|
|
142
|
+
* `ownerGuarded` permission, onto a role guarded by an owner-only permission,
|
|
143
|
+
* whatever the row's `built_in` flag says.
|
|
105
144
|
*/
|
|
106
145
|
export async function seedStarterRoles(tx, policy, tenant) {
|
|
107
|
-
const
|
|
146
|
+
const templates = policy.starterRoles(tenant.id, tenant.kind);
|
|
147
|
+
for (const template of templates)
|
|
148
|
+
assertTemplateCompiles(policy, template);
|
|
149
|
+
const rows = templates.map((template) => ({
|
|
108
150
|
id: randomUUID(),
|
|
109
151
|
tenantId: tenant.id,
|
|
110
152
|
applicationId: policy.applicationId,
|
package/dist/erase.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface ErasureHost {
|
|
|
39
39
|
*
|
|
40
40
|
* Gated on the operator tenant's own `people:erase`: operator scope,
|
|
41
41
|
* sensitive and unassignable, the same shape `owners:install` has, so only
|
|
42
|
-
* the operator tenant
|
|
42
|
+
* the role keyed `owner` in the operator tenant can hold it. A caller reaches this through its
|
|
43
43
|
* own permission check first, which is what a host wires up to write the
|
|
44
44
|
* denied `person.erased` row on a refusal; this function's own checks are
|
|
45
45
|
* the second net, the same as `installOwner`'s.
|
package/dist/erase.js
CHANGED
|
@@ -53,7 +53,7 @@ function rowsOf(result) {
|
|
|
53
53
|
*
|
|
54
54
|
* Gated on the operator tenant's own `people:erase`: operator scope,
|
|
55
55
|
* sensitive and unassignable, the same shape `owners:install` has, so only
|
|
56
|
-
* the operator tenant
|
|
56
|
+
* the role keyed `owner` in the operator tenant can hold it. A caller reaches this through its
|
|
57
57
|
* own permission check first, which is what a host wires up to write the
|
|
58
58
|
* denied `person.erased` row on a refusal; this function's own checks are
|
|
59
59
|
* the second net, the same as `installOwner`'s.
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { attachProposals, authzEvents, breakGlassSessions, credentials, invitati
|
|
|
6
6
|
export { ownerCoverage, ownerSeatCovered, principalIsReachable, type OwnerCoverage, type OwnerCoverageOptions, type PolicyBinding, } from './owners.js';
|
|
7
7
|
export { migrateStore } from './migrate.js';
|
|
8
8
|
export { guestGrants, policyGrants, type GrantStatus, type GuestGrant } from './grants.js';
|
|
9
|
-
export { permissionOf, type StoreBinding } from './binding.js';
|
|
9
|
+
export { permissionOf, type StoreBinding, type HostResourceResolver, type ResourceTarget, } from './binding.js';
|
|
10
10
|
export { isCustomRoleKey } from './role-keys.js';
|
|
11
11
|
export { type Access, type BreakGlassGrant, type Context, type Principal, type Result, type TenantRow, done, isUuid, refused, tenantTag, } from './types.js';
|
|
12
12
|
export { policyResource } from './policy-resources.js';
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ export { attachProposals, authzEvents, breakGlassSessions, credentials, invitati
|
|
|
6
6
|
export { ownerCoverage, ownerSeatCovered, principalIsReachable, } from './owners.js';
|
|
7
7
|
export { migrateStore } from './migrate.js';
|
|
8
8
|
export { guestGrants, policyGrants } from './grants.js';
|
|
9
|
-
export { permissionOf } from './binding.js';
|
|
9
|
+
export { permissionOf, } from './binding.js';
|
|
10
10
|
export { isCustomRoleKey } from './role-keys.js';
|
|
11
11
|
export { done, isUuid, refused, tenantTag, } from './types.js';
|
|
12
12
|
export { policyResource } from './policy-resources.js';
|
package/dist/memberships.js
CHANGED
|
@@ -176,6 +176,10 @@ export async function changeRole(access, db, scopeColumn, options) {
|
|
|
176
176
|
const reason = roleAssignmentRefusal(fresh, role, primaryAssignment(role, principal, previous?.expiresAt?.getTime()), self ? null : 'members:grant');
|
|
177
177
|
if (reason)
|
|
178
178
|
return refused(self ? 'self_promotion' : reason, 'You cannot delegate that role.');
|
|
179
|
+
// A self-change skips the `members:grant` operation (and with it the guard check) above, but a
|
|
180
|
+
// guarded role must still only reach someone who holds every guard.
|
|
181
|
+
if (self && role.guards.some((guard) => !permits(fresh, guard)))
|
|
182
|
+
return refused('self_promotion', 'You cannot delegate that role.');
|
|
179
183
|
if (before &&
|
|
180
184
|
before.id !== role.id &&
|
|
181
185
|
!(await guardStaysHeld(tx, fresh.binding, fresh.tenant.id, [policyRole(before)], principal)))
|
package/dist/policy-entry.d.ts
CHANGED
|
@@ -8,4 +8,4 @@
|
|
|
8
8
|
* `scripts/tests/package-contract.test.mjs`.
|
|
9
9
|
*/
|
|
10
10
|
export { definePolicy, type PolicyData, type PolicyRoleTemplate, type StorePolicy, } from './policy.js';
|
|
11
|
-
export type { StoreBinding } from './binding.js';
|
|
11
|
+
export type { StoreBinding, HostResourceResolver, ResourceTarget } from './binding.js';
|
package/dist/policy-resources.js
CHANGED
|
@@ -4,14 +4,16 @@ import { authzEvents } from './schema.js';
|
|
|
4
4
|
import { isUuid } from './types.js';
|
|
5
5
|
/** Authoritative resource metadata, shared by delegation and credential lineage evaluation. */
|
|
6
6
|
export async function policyResource(tx, binding, organisationId, type, id) {
|
|
7
|
-
const base = {
|
|
7
|
+
const base = Object.freeze({
|
|
8
8
|
applicationId: binding.applicationId,
|
|
9
9
|
platformId: binding.platformId,
|
|
10
10
|
organisationId,
|
|
11
11
|
type,
|
|
12
12
|
id,
|
|
13
|
-
};
|
|
14
|
-
if (type === 'team'
|
|
13
|
+
});
|
|
14
|
+
if (type === 'team') {
|
|
15
|
+
if (!isUuid(id))
|
|
16
|
+
return undefined;
|
|
15
17
|
const [row] = await tx
|
|
16
18
|
.select({ id: accessTeams.id })
|
|
17
19
|
.from(accessTeams)
|
|
@@ -19,7 +21,9 @@ export async function policyResource(tx, binding, organisationId, type, id) {
|
|
|
19
21
|
.limit(1);
|
|
20
22
|
return row ? { ...base, teamId: id } : undefined;
|
|
21
23
|
}
|
|
22
|
-
if (type === 'audit'
|
|
24
|
+
if (type === 'audit') {
|
|
25
|
+
if (!/^\d{1,15}$/.test(id))
|
|
26
|
+
return undefined;
|
|
23
27
|
const [row] = await tx
|
|
24
28
|
.select({
|
|
25
29
|
teamId: authzEvents.teamId,
|
|
@@ -42,5 +46,13 @@ export async function policyResource(tx, binding, organisationId, type, id) {
|
|
|
42
46
|
: {}),
|
|
43
47
|
};
|
|
44
48
|
}
|
|
45
|
-
|
|
49
|
+
const resource = await binding.resolveResource?.(tx, base);
|
|
50
|
+
if (!resource ||
|
|
51
|
+
resource.applicationId !== base.applicationId ||
|
|
52
|
+
resource.platformId !== base.platformId ||
|
|
53
|
+
resource.organisationId !== base.organisationId ||
|
|
54
|
+
resource.type !== base.type ||
|
|
55
|
+
resource.id !== base.id)
|
|
56
|
+
return undefined;
|
|
57
|
+
return resource;
|
|
46
58
|
}
|
package/dist/policy.d.ts
CHANGED
package/dist/policy.js
CHANGED
|
@@ -59,6 +59,7 @@ export function definePolicy(data, binding) {
|
|
|
59
59
|
platformId: binding.platformId,
|
|
60
60
|
catalogue,
|
|
61
61
|
hostEvents: binding.hostEvents,
|
|
62
|
+
resolveResource: binding.resolveResource,
|
|
62
63
|
defaultCeiling: data.defaultCeiling,
|
|
63
64
|
maxDepth: data.maxDepth,
|
|
64
65
|
elevated: new Set(data.elevated),
|
package/dist/role-keys.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Custom role keys always start with `c_` (enforced at creation, the host's `createRole`); every
|
|
3
3
|
* other key — including a starter role's, whether or not its row is still `built_in` — names an
|
|
4
|
-
* app-defined role.
|
|
4
|
+
* app-defined role. `built_in` says nothing about which permissions a role may hold: that is
|
|
5
|
+
* the role key `owner` or an owner-only guard (`compileRoleGrants`). Several modules need "is this key one this app controls" rather than "is this
|
|
5
6
|
* row currently marked built-in", because a starter role's `built_in` flag can differ between an
|
|
6
7
|
* old tenant (`true`) and a new one (`false`) while the key means the same thing in both (ADR
|
|
7
8
|
* 0016). No imports, so any module can depend on it without risking a cycle.
|
package/dist/role-keys.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Custom role keys always start with `c_` (enforced at creation, the host's `createRole`); every
|
|
3
3
|
* other key — including a starter role's, whether or not its row is still `built_in` — names an
|
|
4
|
-
* app-defined role.
|
|
4
|
+
* app-defined role. `built_in` says nothing about which permissions a role may hold: that is
|
|
5
|
+
* the role key `owner` or an owner-only guard (`compileRoleGrants`). Several modules need "is this key one this app controls" rather than "is this
|
|
5
6
|
* row currently marked built-in", because a starter role's `built_in` flag can differ between an
|
|
6
7
|
* old tenant (`true`) and a new one (`false`) while the key means the same thing in both (ADR
|
|
7
8
|
* 0016). No imports, so any module can depend on it without risking a cycle.
|
package/dist/roles.d.ts
CHANGED
|
@@ -11,13 +11,19 @@ export interface EditableRole extends ResourceRole {
|
|
|
11
11
|
readonly editable: boolean;
|
|
12
12
|
}
|
|
13
13
|
export declare function rolesForEditor(access: Access): Promise<EditableRole[]>;
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Definition edits must cover both its full scope and every existing recipient/scope, and the
|
|
16
|
+
* editor must hold every guard of the role (an owner-guarded role can only be edited by someone
|
|
17
|
+
* the guards admit, i.e. an owner). Callers check the role both before and after an edit.
|
|
18
|
+
*/
|
|
15
19
|
export declare function canEditDefinition(access: Access, role: ResourceRole): boolean;
|
|
16
20
|
export interface CreateRoleOptions {
|
|
17
21
|
readonly key: string;
|
|
18
22
|
readonly name: string;
|
|
19
23
|
readonly description?: string;
|
|
20
24
|
readonly entries: ResourceRole['entries'];
|
|
25
|
+
/** Catalogue permissions the editor must hold to assign or edit the role; default none. */
|
|
26
|
+
readonly guards?: readonly string[];
|
|
21
27
|
}
|
|
22
28
|
export interface UpdateRoleOptions {
|
|
23
29
|
readonly key: string;
|
|
@@ -25,6 +31,8 @@ export interface UpdateRoleOptions {
|
|
|
25
31
|
readonly name?: string;
|
|
26
32
|
readonly description?: string;
|
|
27
33
|
readonly entries?: ResourceRole['entries'];
|
|
34
|
+
/** Replaces the guards; the editor must hold every old and every new guard. */
|
|
35
|
+
readonly guards?: readonly string[];
|
|
28
36
|
}
|
|
29
37
|
export interface DeleteRoleOptions {
|
|
30
38
|
readonly key: string;
|
package/dist/roles.js
CHANGED
|
@@ -31,14 +31,21 @@ export async function rolesForEditor(access) {
|
|
|
31
31
|
editable: !role.builtIn && permits(access, 'roles:update') && canEditDefinition(access, role),
|
|
32
32
|
}));
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Definition edits must cover both its full scope and every existing recipient/scope, and the
|
|
36
|
+
* editor must hold every guard of the role (an owner-guarded role can only be edited by someone
|
|
37
|
+
* the guards admit, i.e. an owner). Callers check the role both before and after an edit.
|
|
38
|
+
*/
|
|
35
39
|
export function canEditDefinition(access, role) {
|
|
40
|
+
if (role.guards.some((guard) => !permits(access, guard)))
|
|
41
|
+
return false;
|
|
36
42
|
if (roleAssignmentRefusal(access, role, primaryAssignment(role, { id: 'prospective-person', class: 'human' }), null))
|
|
37
43
|
return false;
|
|
38
44
|
return access.policyState.assignmentRows
|
|
39
45
|
.filter((a) => a.roleId === role.id && (!a.expiresAt || a.expiresAt.getTime() > Date.now()))
|
|
40
46
|
.every((a) => !roleAssignmentRefusal(access, role, policyAssignment(a), null));
|
|
41
47
|
}
|
|
48
|
+
const knownGuards = (access, guards) => guards.every((g) => Object.hasOwn(access.binding.catalogue, g));
|
|
42
49
|
const invalid = () => refused('invalid_role', 'Choose valid operations, boundaries and scopes within your authority.');
|
|
43
50
|
export async function createRole(access, db, scopeColumn, options) {
|
|
44
51
|
if (access.context === 'break_glass')
|
|
@@ -49,6 +56,9 @@ export async function createRole(access, db, scopeColumn, options) {
|
|
|
49
56
|
return refused('no_permission', 'You cannot create roles here.');
|
|
50
57
|
if (!/^c_[a-z0-9_-]{1,61}$/.test(options.key))
|
|
51
58
|
return refused('invalid_key', 'Choose a custom role key starting with c_.');
|
|
59
|
+
const guards = [...new Set(options.guards ?? [])];
|
|
60
|
+
if (!knownGuards(fresh, guards))
|
|
61
|
+
return invalid();
|
|
52
62
|
const candidate = resourceRoleSchema.safeParse({
|
|
53
63
|
id: randomUUID(),
|
|
54
64
|
key: options.key,
|
|
@@ -60,7 +70,7 @@ export async function createRole(access, db, scopeColumn, options) {
|
|
|
60
70
|
revision: 1,
|
|
61
71
|
builtIn: false,
|
|
62
72
|
entries: options.entries,
|
|
63
|
-
guards
|
|
73
|
+
guards,
|
|
64
74
|
});
|
|
65
75
|
if (!candidate.success || !canEditDefinition(fresh, candidate.data))
|
|
66
76
|
return invalid();
|
|
@@ -84,7 +94,7 @@ export async function createRole(access, db, scopeColumn, options) {
|
|
|
84
94
|
revision: role.revision,
|
|
85
95
|
builtIn: false,
|
|
86
96
|
entries: role.entries,
|
|
87
|
-
guards:
|
|
97
|
+
guards: role.guards,
|
|
88
98
|
createdBy: fresh.actor.id,
|
|
89
99
|
});
|
|
90
100
|
await record(tx, fresh, {
|
|
@@ -116,9 +126,11 @@ export async function updateRole(access, db, scopeColumn, options) {
|
|
|
116
126
|
label: options.name ?? before.label,
|
|
117
127
|
description: options.description ?? before.description,
|
|
118
128
|
entries: options.entries ?? before.entries,
|
|
129
|
+
guards: options.guards ? [...new Set(options.guards)] : before.guards,
|
|
119
130
|
revision: before.revision + 1,
|
|
120
131
|
});
|
|
121
132
|
if (!parsed.success ||
|
|
133
|
+
!knownGuards(fresh, parsed.data.guards) ||
|
|
122
134
|
!canEditDefinition(fresh, before) ||
|
|
123
135
|
!canEditDefinition(fresh, parsed.data))
|
|
124
136
|
return invalid();
|
|
@@ -129,6 +141,7 @@ export async function updateRole(access, db, scopeColumn, options) {
|
|
|
129
141
|
name: after.label,
|
|
130
142
|
description: after.description,
|
|
131
143
|
entries: after.entries,
|
|
144
|
+
guards: after.guards,
|
|
132
145
|
revision: after.revision,
|
|
133
146
|
updatedAt: new Date(),
|
|
134
147
|
// ADR 0016's Aged-and-Independent approver rule reads this to exclude
|
package/dist/schema.d.ts
CHANGED
|
@@ -1457,6 +1457,23 @@ export declare const authzEvents: import("drizzle-orm/pg-core").PgTableWithColum
|
|
|
1457
1457
|
identity: undefined;
|
|
1458
1458
|
generated: undefined;
|
|
1459
1459
|
}, {}, {}>;
|
|
1460
|
+
tenantDisplay: import("drizzle-orm/pg-core").PgColumn<{
|
|
1461
|
+
name: "tenant_display";
|
|
1462
|
+
tableName: "authz_events";
|
|
1463
|
+
dataType: "string";
|
|
1464
|
+
columnType: "PgText";
|
|
1465
|
+
data: string;
|
|
1466
|
+
driverParam: string;
|
|
1467
|
+
notNull: false;
|
|
1468
|
+
hasDefault: false;
|
|
1469
|
+
isPrimaryKey: false;
|
|
1470
|
+
isAutoincrement: false;
|
|
1471
|
+
hasRuntimeDefault: false;
|
|
1472
|
+
enumValues: [string, ...string[]];
|
|
1473
|
+
baseColumn: never;
|
|
1474
|
+
identity: undefined;
|
|
1475
|
+
generated: undefined;
|
|
1476
|
+
}, {}, {}>;
|
|
1460
1477
|
teamId: import("drizzle-orm/pg-core").PgColumn<{
|
|
1461
1478
|
name: "team_id";
|
|
1462
1479
|
tableName: "authz_events";
|
|
@@ -1610,6 +1627,23 @@ export declare const authzEvents: import("drizzle-orm/pg-core").PgTableWithColum
|
|
|
1610
1627
|
identity: undefined;
|
|
1611
1628
|
generated: undefined;
|
|
1612
1629
|
}, {}, {}>;
|
|
1630
|
+
targetDisplay: import("drizzle-orm/pg-core").PgColumn<{
|
|
1631
|
+
name: "target_display";
|
|
1632
|
+
tableName: "authz_events";
|
|
1633
|
+
dataType: "string";
|
|
1634
|
+
columnType: "PgText";
|
|
1635
|
+
data: string;
|
|
1636
|
+
driverParam: string;
|
|
1637
|
+
notNull: false;
|
|
1638
|
+
hasDefault: false;
|
|
1639
|
+
isPrimaryKey: false;
|
|
1640
|
+
isAutoincrement: false;
|
|
1641
|
+
hasRuntimeDefault: false;
|
|
1642
|
+
enumValues: [string, ...string[]];
|
|
1643
|
+
baseColumn: never;
|
|
1644
|
+
identity: undefined;
|
|
1645
|
+
generated: undefined;
|
|
1646
|
+
}, {}, {}>;
|
|
1613
1647
|
outcome: import("drizzle-orm/pg-core").PgColumn<{
|
|
1614
1648
|
name: "outcome";
|
|
1615
1649
|
tableName: "authz_events";
|
package/dist/schema.js
CHANGED
|
@@ -169,6 +169,10 @@ export const authzEvents = pgTable('authz_events', {
|
|
|
169
169
|
id: bigint('id', { mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(),
|
|
170
170
|
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
171
171
|
tenantId: uuid('tenant_id'),
|
|
172
|
+
// What the tenant was CALLED when this happened, beside its id so the row still reads once
|
|
173
|
+
// the organisation is closed. Null on a row written before this column existed, or when
|
|
174
|
+
// tenantId itself is null. Never rewritten once set (authz_events_guard enforces it).
|
|
175
|
+
tenantDisplay: text('tenant_display'),
|
|
172
176
|
teamId: uuid('team_id'),
|
|
173
177
|
subjectId: text('subject_id'),
|
|
174
178
|
subjectClass: text('subject_class'),
|
|
@@ -178,6 +182,9 @@ export const authzEvents = pgTable('authz_events', {
|
|
|
178
182
|
action: text('action').notNull(),
|
|
179
183
|
targetType: text('target_type').notNull(),
|
|
180
184
|
targetId: text('target_id').notNull(),
|
|
185
|
+
// Same as tenantDisplay, for the target: what it was CALLED when this happened. Null on a row
|
|
186
|
+
// written before this column existed.
|
|
187
|
+
targetDisplay: text('target_display'),
|
|
181
188
|
outcome: text('outcome').notNull(),
|
|
182
189
|
context: text('context').notNull(),
|
|
183
190
|
sessionId: text('session_id'),
|
package/dist/tenants.d.ts
CHANGED
|
@@ -204,8 +204,8 @@ export declare function createTenantAsOperator(operatorAccess: Access, db: DbOrT
|
|
|
204
204
|
export declare function deleteJustCreatedTenant(operatorAccess: Access, db: DbOrTx, scopeColumn: Scope['column'], tenantId: string): Promise<Result<void>>;
|
|
205
205
|
/**
|
|
206
206
|
* Archives a tenant from inside it, by whoever holds `tenant:delete` there
|
|
207
|
-
* (the owner: the permission is not assignable, so no
|
|
208
|
-
*
|
|
207
|
+
* (the owner: the permission is not assignable and not owner-guarded, so no
|
|
208
|
+
* role other than the one keyed `owner` can carry it). Refused while the tenant holds children (D19's fifth rule):
|
|
209
209
|
* an archived parent's own state says nothing about the organisations
|
|
210
210
|
* hanging off it, so those must be detached or archived on purpose first,
|
|
211
211
|
* not silently along for the ride.
|
package/dist/tenants.js
CHANGED
|
@@ -761,8 +761,8 @@ export async function deleteJustCreatedTenant(operatorAccess, db, scopeColumn, t
|
|
|
761
761
|
}
|
|
762
762
|
/**
|
|
763
763
|
* Archives a tenant from inside it, by whoever holds `tenant:delete` there
|
|
764
|
-
* (the owner: the permission is not assignable, so no
|
|
765
|
-
*
|
|
764
|
+
* (the owner: the permission is not assignable and not owner-guarded, so no
|
|
765
|
+
* role other than the one keyed `owner` can carry it). Refused while the tenant holds children (D19's fifth rule):
|
|
766
766
|
* an archived parent's own state says nothing about the organisations
|
|
767
767
|
* hanging off it, so those must be detached or archived on purpose first,
|
|
768
768
|
* not silently along for the ride.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- authz_events gains target_display and tenant_display, matching how
|
|
2
|
+
-- actor_display already works: written once by whoever signs the row, never
|
|
3
|
+
-- rewritten afterward. See https://github.com/wtfalch/authz/issues/69.
|
|
4
|
+
ALTER TABLE "authz_events" ADD COLUMN "tenant_display" text;
|
|
5
|
+
--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "authz_events" ADD COLUMN "target_display" text;
|
|
7
|
+
--> statement-breakpoint
|
|
8
|
+
ALTER TABLE "authz_events" ADD CONSTRAINT "authz_events_tenant_display_check"
|
|
9
|
+
CHECK (((tenant_display IS NULL) OR ((length(tenant_display) >= 1) AND (length(tenant_display) <= 256))));
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
ALTER TABLE "authz_events" ADD CONSTRAINT "authz_events_target_display_check"
|
|
12
|
+
CHECK (((target_display IS NULL) OR ((length(target_display) >= 1) AND (length(target_display) <= 256))));
|
|
13
|
+
--> statement-breakpoint
|
|
14
|
+
-- Extend the append-only guard: target_display and tenant_display join the immutable set (they are
|
|
15
|
+
-- never rewritten, unlike actor_display/before/after/erased_at, which an erasure may still touch).
|
|
16
|
+
CREATE OR REPLACE FUNCTION authz_events_guard()
|
|
17
|
+
RETURNS trigger
|
|
18
|
+
LANGUAGE plpgsql
|
|
19
|
+
AS $function$
|
|
20
|
+
BEGIN
|
|
21
|
+
IF TG_OP = 'DELETE' THEN
|
|
22
|
+
RAISE EXCEPTION 'authz_events is append-only: delete refused';
|
|
23
|
+
ELSIF TG_OP = 'TRUNCATE' THEN
|
|
24
|
+
RAISE EXCEPTION 'authz_events is append-only: truncate refused';
|
|
25
|
+
ELSIF TG_OP = 'UPDATE' THEN
|
|
26
|
+
IF NEW."id" IS DISTINCT FROM OLD."id"
|
|
27
|
+
OR NEW."occurred_at" IS DISTINCT FROM OLD."occurred_at"
|
|
28
|
+
OR NEW."tenant_id" IS DISTINCT FROM OLD."tenant_id"
|
|
29
|
+
OR NEW."tenant_display" IS DISTINCT FROM OLD."tenant_display"
|
|
30
|
+
OR NEW."actor_class" IS DISTINCT FROM OLD."actor_class"
|
|
31
|
+
OR NEW."actor_id" IS DISTINCT FROM OLD."actor_id"
|
|
32
|
+
OR NEW."action" IS DISTINCT FROM OLD."action"
|
|
33
|
+
OR NEW."target_type" IS DISTINCT FROM OLD."target_type"
|
|
34
|
+
OR NEW."target_id" IS DISTINCT FROM OLD."target_id"
|
|
35
|
+
OR NEW."target_display" IS DISTINCT FROM OLD."target_display"
|
|
36
|
+
OR NEW."outcome" IS DISTINCT FROM OLD."outcome"
|
|
37
|
+
OR NEW."context" IS DISTINCT FROM OLD."context"
|
|
38
|
+
OR NEW."session_id" IS DISTINCT FROM OLD."session_id"
|
|
39
|
+
OR NEW."reason" IS DISTINCT FROM OLD."reason"
|
|
40
|
+
OR NEW."reference" IS DISTINCT FROM OLD."reference"
|
|
41
|
+
OR NEW."request_id" IS DISTINCT FROM OLD."request_id"
|
|
42
|
+
OR NEW."ip" IS DISTINCT FROM OLD."ip"
|
|
43
|
+
OR NEW."user_agent" IS DISTINCT FROM OLD."user_agent"
|
|
44
|
+
OR NEW."tenant_visible" IS DISTINCT FROM OLD."tenant_visible"
|
|
45
|
+
OR NEW."schema_version" IS DISTINCT FROM OLD."schema_version"
|
|
46
|
+
THEN
|
|
47
|
+
RAISE EXCEPTION 'authz_events is append-only: only actor_display, before, after and erased_at may change';
|
|
48
|
+
END IF;
|
|
49
|
+
END IF;
|
|
50
|
+
RETURN NEW;
|
|
51
|
+
END
|
|
52
|
+
$function$;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/authz-store",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Persistence and lifecycle for @wtfalch/authz: the storage a host would otherwise write itself.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"./package.json": "./package.json"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
-
"@wtfalch/authz": "^0.
|
|
23
|
+
"@wtfalch/authz": "^0.17.0",
|
|
24
24
|
"@wtfalch/db": "0.4.0",
|
|
25
25
|
"drizzle-orm": ">=0.39.3 <1.0.0",
|
|
26
26
|
"zod": "^4.1.13"
|