@wtfalch/authz-store 0.2.0 → 0.3.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 +335 -5
- package/dist/activations.d.ts +60 -0
- package/dist/activations.js +516 -0
- package/dist/alerts.d.ts +86 -0
- package/dist/alerts.js +132 -0
- package/dist/assignments.d.ts +71 -0
- package/dist/assignments.js +103 -0
- package/dist/audit.d.ts +122 -0
- package/dist/audit.js +194 -0
- package/dist/binding.d.ts +17 -0
- package/dist/binding.js +8 -0
- package/dist/boot.d.ts +54 -0
- package/dist/boot.js +143 -0
- package/dist/bootstrap.d.ts +28 -0
- package/dist/bootstrap.js +83 -0
- package/dist/break-glass.d.ts +61 -0
- package/dist/break-glass.js +247 -0
- package/dist/credentials.d.ts +94 -0
- package/dist/credentials.js +230 -0
- package/dist/denial.d.ts +9 -0
- package/dist/denial.js +49 -0
- package/dist/erase.d.ts +58 -0
- package/dist/erase.js +108 -0
- package/dist/events.d.ts +77 -0
- package/dist/events.js +107 -0
- package/dist/export.d.ts +161 -0
- package/dist/export.js +293 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/install-owner.d.ts +25 -0
- package/dist/install-owner.js +159 -0
- package/dist/invitations.d.ts +160 -0
- package/dist/invitations.js +685 -0
- package/dist/membership-rows.d.ts +206 -0
- package/dist/membership-rows.js +271 -0
- package/dist/memberships.d.ts +87 -0
- package/dist/memberships.js +272 -0
- package/dist/migrate.js +15 -4
- package/dist/nesting.d.ts +124 -0
- package/dist/nesting.js +515 -0
- package/dist/owners.d.ts +6 -1
- package/dist/owners.js +7 -3
- package/dist/person-records.d.ts +186 -0
- package/dist/person-records.js +263 -0
- package/dist/platform.d.ts +20 -0
- package/dist/platform.js +65 -0
- package/dist/policy-access.d.ts +260 -0
- package/dist/policy-access.js +348 -0
- package/dist/policy-resources.d.ts +5 -0
- package/dist/policy-resources.js +46 -0
- package/dist/policy-schema.d.ts +445 -0
- package/dist/policy-schema.js +63 -0
- package/dist/policy.d.ts +64 -0
- package/dist/policy.js +65 -0
- package/dist/propagate.d.ts +43 -0
- package/dist/propagate.js +47 -0
- package/dist/reconcile.d.ts +78 -0
- package/dist/reconcile.js +94 -0
- package/dist/resource-access.d.ts +344 -0
- package/dist/resource-access.js +656 -0
- package/dist/role-keys.d.ts +9 -0
- package/dist/role-keys.js +9 -0
- package/dist/roles.d.ts +36 -0
- package/dist/roles.js +191 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +8 -1
- package/dist/startup.d.ts +57 -0
- package/dist/startup.js +113 -0
- package/dist/tenants.d.ts +213 -0
- package/dist/tenants.js +808 -0
- package/dist/tree-writes.d.ts +65 -0
- package/dist/tree-writes.js +201 -0
- package/dist/tree.d.ts +272 -0
- package/dist/tree.js +565 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +15 -0
- package/migrations/0003_product_tenant_kind.sql +14 -0
- package/migrations/0004_credential_keys_issued_id.sql +33 -0
- package/migrations/0005_activations.sql +71 -0
- package/migrations/0006_erase_person.sql +134 -0
- package/package.json +9 -4
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { ResourcePrincipal } from '@wtfalch/authz';
|
|
2
|
+
import { type PolicyBinding } from './owners.js';
|
|
3
|
+
import type { PeopleDirectory } from './person-records.js';
|
|
4
|
+
import type { DbOrTx } from './scoped.js';
|
|
5
|
+
/**
|
|
6
|
+
* Row-level helpers, ported (authz#83 wave 10b) from `@wtfalch/people` 0.2.3's
|
|
7
|
+
* `membership.js` (`grantMembership`, `removeMembership`, `changeRole`,
|
|
8
|
+
* `guardStaysHeld`) and `roster.js` (`membersOf`), typed TypeScript with
|
|
9
|
+
* people's own comments kept. `@wtfalch/people` depends on this package, so
|
|
10
|
+
* this package cannot import it back without a cycle (decision, William
|
|
11
|
+
* 2026-09-29); a later issue in the people repo makes it reuse this copy
|
|
12
|
+
* instead of carrying its own. Every name here is exported under one distinct
|
|
13
|
+
* from the policy layer's (`src/memberships.ts`, which keeps Boule's own
|
|
14
|
+
* names for the same five operations) because this file's names would
|
|
15
|
+
* otherwise collide with those on the same package surface.
|
|
16
|
+
*
|
|
17
|
+
* `identityWhere` is not re-declared here: people's own copy
|
|
18
|
+
* (`and(eq(tenantId), eq(principalClass), eq(principalId))`) tests the exact
|
|
19
|
+
* same three columns as `./assignments.js`'s (`and(eq(tenantId),
|
|
20
|
+
* eq(principalId), eq(principalClass))`), just written in a different
|
|
21
|
+
* argument order that `and()` does not care about — so this file imports
|
|
22
|
+
* that one rather than keeping a second copy.
|
|
23
|
+
*/
|
|
24
|
+
/** The host's already-bound audit writer, plus who to credit the change to. Optional: a caller with no audit trail wired up yet still gets a working membership change. Mirrors `@wtfalch/people`'s `AuditOptions`, without that package's `@wtfalch/audit` dependency — this store has none. */
|
|
25
|
+
export interface MembershipRowAuditActor {
|
|
26
|
+
readonly class: string;
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly display: string;
|
|
29
|
+
}
|
|
30
|
+
export interface MembershipRowAuditEvent {
|
|
31
|
+
readonly action: string;
|
|
32
|
+
readonly actor: MembershipRowAuditActor;
|
|
33
|
+
readonly target: {
|
|
34
|
+
readonly type: string;
|
|
35
|
+
readonly id: string;
|
|
36
|
+
};
|
|
37
|
+
readonly tenantId: string;
|
|
38
|
+
readonly after?: unknown;
|
|
39
|
+
}
|
|
40
|
+
export interface MembershipRowAuditOptions {
|
|
41
|
+
readonly writer: (event: MembershipRowAuditEvent, db: DbOrTx) => Promise<void>;
|
|
42
|
+
readonly actor: MembershipRowAuditActor;
|
|
43
|
+
}
|
|
44
|
+
export interface InsertMembershipRowInput {
|
|
45
|
+
readonly tenantId: string;
|
|
46
|
+
/** Denormalised onto the row, same as the three hosts do (D5: the switcher reads one index range). */
|
|
47
|
+
readonly tenantName: string;
|
|
48
|
+
readonly principal: ResourcePrincipal;
|
|
49
|
+
/** Defaults to `'direct'`. `'inherited'` is the tenant tree giving it — a host writes that itself, this package does not compute a tree. */
|
|
50
|
+
readonly source?: string;
|
|
51
|
+
readonly viaTenantId?: string | null;
|
|
52
|
+
readonly grantedBy?: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Runs once the `memberships` row insert succeeds, before the audit
|
|
55
|
+
* write, on the same `db` this call received — pass a transaction to
|
|
56
|
+
* `insertMembershipRow` and close over it here for this to be atomic with
|
|
57
|
+
* the row insert. Lets a host attach the role assignment, tenant-tree
|
|
58
|
+
* propagation and participation-policy write it does today (`src/memberships.ts`'s
|
|
59
|
+
* policy layer) to this grant without this file importing any of it. Not
|
|
60
|
+
* called when the row already existed. A throw here propagates, so a
|
|
61
|
+
* caller inside its own transaction rolls back the row insert too.
|
|
62
|
+
*/
|
|
63
|
+
readonly onGranted?: () => Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
export type InsertMembershipRowResult = {
|
|
66
|
+
readonly ok: true;
|
|
67
|
+
} | {
|
|
68
|
+
readonly ok: false;
|
|
69
|
+
readonly reason: 'already_member';
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Adds one membership row. This is deliberately narrower than a host's own
|
|
73
|
+
* `grantMembership`: role assignment (`writePrimaryAssignment`), the
|
|
74
|
+
* role-boundary refusal checks (`roleAssignmentRefusal`), tenant-tree
|
|
75
|
+
* propagation and participation-policy writes are `src/memberships.ts`'s
|
|
76
|
+
* policy layer, not `memberships` table rows, and out of this row helper's
|
|
77
|
+
* scope (people's own README: "Out of scope: ... permission checks"). The
|
|
78
|
+
* caller checks whether the acting principal may grant this membership
|
|
79
|
+
* before calling this, and attaches that policy work via `input.onGranted`
|
|
80
|
+
* rather than this file reimplementing it.
|
|
81
|
+
*/
|
|
82
|
+
export declare function insertMembershipRow(db: DbOrTx, input: InsertMembershipRowInput, audit?: MembershipRowAuditOptions): Promise<InsertMembershipRowResult>;
|
|
83
|
+
export interface DeleteMembershipRowInput {
|
|
84
|
+
readonly tenantId: string;
|
|
85
|
+
readonly principal: ResourcePrincipal;
|
|
86
|
+
/**
|
|
87
|
+
* Runs once the `memberships` row delete succeeds, before the audit
|
|
88
|
+
* write, on the same `db` this call received. Symmetric with
|
|
89
|
+
* `InsertMembershipRowInput.onGranted` — lets a host end break-glass
|
|
90
|
+
* sessions, propagate the removal across a tenant tree, or clean up its
|
|
91
|
+
* own `authz_assignments` rows without this file importing any of that.
|
|
92
|
+
* Not called when there was no row to remove. A throw here propagates.
|
|
93
|
+
*/
|
|
94
|
+
readonly onRemoved?: () => Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
export type DeleteMembershipRowResult = {
|
|
97
|
+
readonly ok: true;
|
|
98
|
+
} | {
|
|
99
|
+
readonly ok: false;
|
|
100
|
+
readonly reason: 'not_found';
|
|
101
|
+
};
|
|
102
|
+
/** Removes one membership row. Call `guardStaysHeldRow` first when the removed principal might hold a guarded role — this function does not know what roles a principal holds. */
|
|
103
|
+
export declare function deleteMembershipRow(db: DbOrTx, input: DeleteMembershipRowInput, audit?: MembershipRowAuditOptions): Promise<DeleteMembershipRowResult>;
|
|
104
|
+
export interface ChangeRoleRowInput {
|
|
105
|
+
readonly tenantId: string;
|
|
106
|
+
readonly principal: ResourcePrincipal;
|
|
107
|
+
readonly binding: PolicyBinding;
|
|
108
|
+
/**
|
|
109
|
+
* The `authz_roles.id` to make `principal`'s new primary assignment for
|
|
110
|
+
* `binding`. Resolved from a role key, compiled and refusal-checked by
|
|
111
|
+
* the caller's own policy layer before calling this — this file does not
|
|
112
|
+
* evaluate a role definition, the same separation `insertMembershipRow`
|
|
113
|
+
* keeps.
|
|
114
|
+
*/
|
|
115
|
+
readonly roleId: string;
|
|
116
|
+
readonly expiresAt?: Date | null;
|
|
117
|
+
readonly createdBy?: string | null;
|
|
118
|
+
}
|
|
119
|
+
export type ChangeRoleRowResult = {
|
|
120
|
+
readonly ok: true;
|
|
121
|
+
} | {
|
|
122
|
+
readonly ok: false;
|
|
123
|
+
readonly reason: 'not_member';
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Replaces whatever primary assignment `principal` holds for `binding`'s
|
|
127
|
+
* `applicationId` with one pointing at `roleId`: the row-level half of a
|
|
128
|
+
* host's own `changeRole`. Resolving `roleId`, the refusal checks (guard
|
|
129
|
+
* coverage, self-promotion, custom-role tenant boundary, break-glass),
|
|
130
|
+
* ending sessions a role change should end, and tenant-tree propagation all
|
|
131
|
+
* stay the caller's — the same policy-layer line `insertMembershipRow`/
|
|
132
|
+
* `deleteMembershipRow` already draw. Call `guardStaysHeldRow` first when
|
|
133
|
+
* replacing a role that might be the last independent owner.
|
|
134
|
+
*
|
|
135
|
+
* A single `INSERT ... ON CONFLICT DO UPDATE`, not a delete then an insert:
|
|
136
|
+
* `authz_assignments`' own `authz_assignments_primary_idx` is a partial
|
|
137
|
+
* unique index on `(tenant_id, application_id, principal_class,
|
|
138
|
+
* principal_id) WHERE primary_assignment` — **not** scoped by
|
|
139
|
+
* `platform_id`, so a principal has at most one primary assignment per
|
|
140
|
+
* `(tenantId, applicationId)`, no matter which `platformId` it carries.
|
|
141
|
+
* Conflicting on those exact columns keeps this atomic (one statement, no
|
|
142
|
+
* transaction required for correctness) and matches that invariant exactly
|
|
143
|
+
* — a `platformId`-scoped delete would miss an existing row written for a
|
|
144
|
+
* different `platformId` of the same `applicationId` and then fail the
|
|
145
|
+
* insert with an unhandled `unique_violation`.
|
|
146
|
+
*
|
|
147
|
+
* `boundary`/`scope` are hard-coded to `{ kind: 'platform' }`/
|
|
148
|
+
* `{ kind: 'organisation' }` — the whole-platform, whole-organisation shape
|
|
149
|
+
* a host's own `primaryAssignment()` helper writes for a primary
|
|
150
|
+
* assignment; a scoped or boundary-limited assignment is not a "primary
|
|
151
|
+
* assignment" in that same sense, and stays the host's own `authz_assignments`
|
|
152
|
+
* write.
|
|
153
|
+
*/
|
|
154
|
+
export declare function changeRoleRow(db: DbOrTx, input: ChangeRoleRowInput, audit?: MembershipRowAuditOptions): Promise<ChangeRoleRowResult>;
|
|
155
|
+
/**
|
|
156
|
+
* Whether removing `principal`'s membership (or changing it away from a
|
|
157
|
+
* guarded role) would leave a protected role with no independent recovery
|
|
158
|
+
* owner. Ported from manage's `guardStaysHeld`, with one change: it takes
|
|
159
|
+
* `guards` — the union of `role.guards` across whatever roles are being
|
|
160
|
+
* revoked — instead of a list of role definitions, because role definitions
|
|
161
|
+
* are the policy layer's, not this row-level file's. The caller (which
|
|
162
|
+
* already resolved the roles to compute a refusal reason) reduces them to
|
|
163
|
+
* this set the same way the original did: `new Set(roles.flatMap((r) =>
|
|
164
|
+
* r.guards))`.
|
|
165
|
+
*/
|
|
166
|
+
export declare function guardStaysHeldRow(db: DbOrTx, binding: PolicyBinding, tenantId: string, principal: ResourcePrincipal, guards: ReadonlySet<string>): Promise<boolean>;
|
|
167
|
+
export interface MembershipRosterRow {
|
|
168
|
+
readonly principalId: string;
|
|
169
|
+
readonly principalClass: string;
|
|
170
|
+
readonly source: string;
|
|
171
|
+
readonly display: string;
|
|
172
|
+
readonly email: string | null;
|
|
173
|
+
readonly viaTenantName: string | null;
|
|
174
|
+
readonly joinedAt: Date;
|
|
175
|
+
readonly lastSeenAt: Date | null;
|
|
176
|
+
readonly role: string | null;
|
|
177
|
+
readonly roleLabel: string | null;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Every member of one tenant, newest membership first is not assumed here —
|
|
181
|
+
* callers that want an order sort by `joinedAt` themselves; this returns in
|
|
182
|
+
* principal order, which is what makes the query itself stable to test
|
|
183
|
+
* against.
|
|
184
|
+
*
|
|
185
|
+
* The caller checks `members:read` before calling this — see
|
|
186
|
+
* `src/memberships.ts`'s `membersOf` — this function does not check it
|
|
187
|
+
* itself, the same separation people's own store kept from its
|
|
188
|
+
* `authorization.ts`.
|
|
189
|
+
*
|
|
190
|
+
* Pass `roles` (the same `PolicyBinding` `guardStaysHeldRow` takes) to
|
|
191
|
+
* resolve `role`/`roleLabel` from each member's primary assignment for that
|
|
192
|
+
* application/platform. Omit it and every row's `role`/`roleLabel` is
|
|
193
|
+
* `null` — today's v1 shape, unchanged.
|
|
194
|
+
*
|
|
195
|
+
* People's `roster.js` joined its own `profiles` table (a `left join`: a
|
|
196
|
+
* member with no profile row is kept, with null display fields) to resolve
|
|
197
|
+
* `displayName`/`email`/`lastSeenAt`. This store has no `profiles` table of
|
|
198
|
+
* its own, so that join becomes a `PeopleDirectory` lookup afterwards —
|
|
199
|
+
* `person-records.ts`'s wave 10a port — over every human principal id this
|
|
200
|
+
* query found; a human with no directory entry keeps the same left-join
|
|
201
|
+
* shape people's SQL gave it: the row stays, `displayName`/`email`/
|
|
202
|
+
* `lastSeenAt` come back `null`. Non-human members never reach the
|
|
203
|
+
* directory at all, the same as people's join, which only ever matched
|
|
204
|
+
* `profiles` for `principalClass = 'human'`.
|
|
205
|
+
*/
|
|
206
|
+
export declare function membersOfRow(db: DbOrTx, tenantId: string, directory: PeopleDirectory, roles?: PolicyBinding): Promise<readonly MembershipRosterRow[]>;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { and, asc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { identityWhere } from './assignments.js';
|
|
3
|
+
import { ownerCoverage, ownerSeatCovered, } from './owners.js';
|
|
4
|
+
import { policyAssignments, policyRoles } from './policy-schema.js';
|
|
5
|
+
import { credentials, memberships, tenants } from './schema.js';
|
|
6
|
+
/**
|
|
7
|
+
* Adds one membership row. This is deliberately narrower than a host's own
|
|
8
|
+
* `grantMembership`: role assignment (`writePrimaryAssignment`), the
|
|
9
|
+
* role-boundary refusal checks (`roleAssignmentRefusal`), tenant-tree
|
|
10
|
+
* propagation and participation-policy writes are `src/memberships.ts`'s
|
|
11
|
+
* policy layer, not `memberships` table rows, and out of this row helper's
|
|
12
|
+
* scope (people's own README: "Out of scope: ... permission checks"). The
|
|
13
|
+
* caller checks whether the acting principal may grant this membership
|
|
14
|
+
* before calling this, and attaches that policy work via `input.onGranted`
|
|
15
|
+
* rather than this file reimplementing it.
|
|
16
|
+
*/
|
|
17
|
+
export async function insertMembershipRow(db, input, audit) {
|
|
18
|
+
const [existing] = await db
|
|
19
|
+
.select({ principalId: memberships.principalId })
|
|
20
|
+
.from(memberships)
|
|
21
|
+
.where(identityWhere(input.tenantId, input.principal))
|
|
22
|
+
.limit(1);
|
|
23
|
+
if (existing)
|
|
24
|
+
return { ok: false, reason: 'already_member' };
|
|
25
|
+
const source = input.source ?? 'direct';
|
|
26
|
+
await db.insert(memberships).values({
|
|
27
|
+
tenantId: input.tenantId,
|
|
28
|
+
principalId: input.principal.id,
|
|
29
|
+
principalClass: input.principal.class,
|
|
30
|
+
source,
|
|
31
|
+
viaTenantId: input.viaTenantId ?? null,
|
|
32
|
+
tenantName: input.tenantName,
|
|
33
|
+
grantedBy: input.grantedBy ?? null,
|
|
34
|
+
});
|
|
35
|
+
if (input.onGranted)
|
|
36
|
+
await input.onGranted();
|
|
37
|
+
if (audit) {
|
|
38
|
+
await audit.writer({
|
|
39
|
+
action: 'membership.created',
|
|
40
|
+
actor: audit.actor,
|
|
41
|
+
target: { type: 'membership', id: input.principal.id },
|
|
42
|
+
tenantId: input.tenantId,
|
|
43
|
+
after: { principalClass: input.principal.class, source },
|
|
44
|
+
}, db);
|
|
45
|
+
}
|
|
46
|
+
return { ok: true };
|
|
47
|
+
}
|
|
48
|
+
/** Removes one membership row. Call `guardStaysHeldRow` first when the removed principal might hold a guarded role — this function does not know what roles a principal holds. */
|
|
49
|
+
export async function deleteMembershipRow(db, input, audit) {
|
|
50
|
+
const [existing] = await db
|
|
51
|
+
.select({ principalId: memberships.principalId })
|
|
52
|
+
.from(memberships)
|
|
53
|
+
.where(identityWhere(input.tenantId, input.principal))
|
|
54
|
+
.limit(1);
|
|
55
|
+
if (!existing)
|
|
56
|
+
return { ok: false, reason: 'not_found' };
|
|
57
|
+
await db.delete(memberships).where(identityWhere(input.tenantId, input.principal));
|
|
58
|
+
if (input.onRemoved)
|
|
59
|
+
await input.onRemoved();
|
|
60
|
+
if (audit) {
|
|
61
|
+
await audit.writer({
|
|
62
|
+
action: 'membership.ended',
|
|
63
|
+
actor: audit.actor,
|
|
64
|
+
target: { type: 'membership', id: input.principal.id },
|
|
65
|
+
tenantId: input.tenantId,
|
|
66
|
+
}, db);
|
|
67
|
+
}
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Replaces whatever primary assignment `principal` holds for `binding`'s
|
|
72
|
+
* `applicationId` with one pointing at `roleId`: the row-level half of a
|
|
73
|
+
* host's own `changeRole`. Resolving `roleId`, the refusal checks (guard
|
|
74
|
+
* coverage, self-promotion, custom-role tenant boundary, break-glass),
|
|
75
|
+
* ending sessions a role change should end, and tenant-tree propagation all
|
|
76
|
+
* stay the caller's — the same policy-layer line `insertMembershipRow`/
|
|
77
|
+
* `deleteMembershipRow` already draw. Call `guardStaysHeldRow` first when
|
|
78
|
+
* replacing a role that might be the last independent owner.
|
|
79
|
+
*
|
|
80
|
+
* A single `INSERT ... ON CONFLICT DO UPDATE`, not a delete then an insert:
|
|
81
|
+
* `authz_assignments`' own `authz_assignments_primary_idx` is a partial
|
|
82
|
+
* unique index on `(tenant_id, application_id, principal_class,
|
|
83
|
+
* principal_id) WHERE primary_assignment` — **not** scoped by
|
|
84
|
+
* `platform_id`, so a principal has at most one primary assignment per
|
|
85
|
+
* `(tenantId, applicationId)`, no matter which `platformId` it carries.
|
|
86
|
+
* Conflicting on those exact columns keeps this atomic (one statement, no
|
|
87
|
+
* transaction required for correctness) and matches that invariant exactly
|
|
88
|
+
* — a `platformId`-scoped delete would miss an existing row written for a
|
|
89
|
+
* different `platformId` of the same `applicationId` and then fail the
|
|
90
|
+
* insert with an unhandled `unique_violation`.
|
|
91
|
+
*
|
|
92
|
+
* `boundary`/`scope` are hard-coded to `{ kind: 'platform' }`/
|
|
93
|
+
* `{ kind: 'organisation' }` — the whole-platform, whole-organisation shape
|
|
94
|
+
* a host's own `primaryAssignment()` helper writes for a primary
|
|
95
|
+
* assignment; a scoped or boundary-limited assignment is not a "primary
|
|
96
|
+
* assignment" in that same sense, and stays the host's own `authz_assignments`
|
|
97
|
+
* write.
|
|
98
|
+
*/
|
|
99
|
+
export async function changeRoleRow(db, input, audit) {
|
|
100
|
+
const [existing] = await db
|
|
101
|
+
.select({ principalId: memberships.principalId })
|
|
102
|
+
.from(memberships)
|
|
103
|
+
.where(identityWhere(input.tenantId, input.principal))
|
|
104
|
+
.limit(1);
|
|
105
|
+
if (!existing)
|
|
106
|
+
return { ok: false, reason: 'not_member' };
|
|
107
|
+
await db
|
|
108
|
+
.insert(policyAssignments)
|
|
109
|
+
.values({
|
|
110
|
+
tenantId: input.tenantId,
|
|
111
|
+
applicationId: input.binding.applicationId,
|
|
112
|
+
platformId: input.binding.platformId,
|
|
113
|
+
roleId: input.roleId,
|
|
114
|
+
principalId: input.principal.id,
|
|
115
|
+
principalClass: input.principal.class,
|
|
116
|
+
boundary: { kind: 'platform' },
|
|
117
|
+
scope: { kind: 'organisation' },
|
|
118
|
+
primary: true,
|
|
119
|
+
expiresAt: input.expiresAt ?? null,
|
|
120
|
+
createdBy: input.createdBy ?? null,
|
|
121
|
+
})
|
|
122
|
+
.onConflictDoUpdate({
|
|
123
|
+
target: [
|
|
124
|
+
policyAssignments.tenantId,
|
|
125
|
+
policyAssignments.applicationId,
|
|
126
|
+
policyAssignments.principalClass,
|
|
127
|
+
policyAssignments.principalId,
|
|
128
|
+
],
|
|
129
|
+
targetWhere: eq(policyAssignments.primary, true),
|
|
130
|
+
set: {
|
|
131
|
+
platformId: input.binding.platformId,
|
|
132
|
+
roleId: input.roleId,
|
|
133
|
+
boundary: { kind: 'platform' },
|
|
134
|
+
scope: { kind: 'organisation' },
|
|
135
|
+
expiresAt: input.expiresAt ?? null,
|
|
136
|
+
createdBy: input.createdBy ?? null,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
if (audit) {
|
|
140
|
+
await audit.writer({
|
|
141
|
+
action: 'membership.role_changed',
|
|
142
|
+
actor: audit.actor,
|
|
143
|
+
target: { type: 'membership', id: input.principal.id },
|
|
144
|
+
tenantId: input.tenantId,
|
|
145
|
+
after: { roleId: input.roleId },
|
|
146
|
+
}, db);
|
|
147
|
+
}
|
|
148
|
+
return { ok: true };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Whether removing `principal`'s membership (or changing it away from a
|
|
152
|
+
* guarded role) would leave a protected role with no independent recovery
|
|
153
|
+
* owner. Ported from manage's `guardStaysHeld`, with one change: it takes
|
|
154
|
+
* `guards` — the union of `role.guards` across whatever roles are being
|
|
155
|
+
* revoked — instead of a list of role definitions, because role definitions
|
|
156
|
+
* are the policy layer's, not this row-level file's. The caller (which
|
|
157
|
+
* already resolved the roles to compute a refusal reason) reduces them to
|
|
158
|
+
* this set the same way the original did: `new Set(roles.flatMap((r) =>
|
|
159
|
+
* r.guards))`.
|
|
160
|
+
*/
|
|
161
|
+
export async function guardStaysHeldRow(db, binding, tenantId, principal, guards) {
|
|
162
|
+
if (guards.size === 0)
|
|
163
|
+
return true;
|
|
164
|
+
const options = {
|
|
165
|
+
excludePrincipalId: principal.id,
|
|
166
|
+
excludePrincipalClass: principal.class,
|
|
167
|
+
};
|
|
168
|
+
const coverage = await ownerCoverage(db, binding, tenantId, options);
|
|
169
|
+
return guards.has('members:grant-owner') ? ownerSeatCovered(coverage) : coverage.directOwners > 0;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A name for a non-human member once no credential row explains it either —
|
|
173
|
+
* a dangling reference, not the normal case, since every credential is
|
|
174
|
+
* minted with a `name`. Still never a raw principal id: a UUID says nothing
|
|
175
|
+
* a person can act on. Ported from manage's `memberships.ts`.
|
|
176
|
+
*/
|
|
177
|
+
function fallbackCredentialLabel(principalClass) {
|
|
178
|
+
switch (principalClass) {
|
|
179
|
+
case 'api_key':
|
|
180
|
+
return 'An API key no longer on record';
|
|
181
|
+
case 'agent':
|
|
182
|
+
return 'An agent no longer on record';
|
|
183
|
+
case 'service':
|
|
184
|
+
return 'A service no longer on record';
|
|
185
|
+
default:
|
|
186
|
+
return 'A credential no longer on record';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Every member of one tenant, newest membership first is not assumed here —
|
|
191
|
+
* callers that want an order sort by `joinedAt` themselves; this returns in
|
|
192
|
+
* principal order, which is what makes the query itself stable to test
|
|
193
|
+
* against.
|
|
194
|
+
*
|
|
195
|
+
* The caller checks `members:read` before calling this — see
|
|
196
|
+
* `src/memberships.ts`'s `membersOf` — this function does not check it
|
|
197
|
+
* itself, the same separation people's own store kept from its
|
|
198
|
+
* `authorization.ts`.
|
|
199
|
+
*
|
|
200
|
+
* Pass `roles` (the same `PolicyBinding` `guardStaysHeldRow` takes) to
|
|
201
|
+
* resolve `role`/`roleLabel` from each member's primary assignment for that
|
|
202
|
+
* application/platform. Omit it and every row's `role`/`roleLabel` is
|
|
203
|
+
* `null` — today's v1 shape, unchanged.
|
|
204
|
+
*
|
|
205
|
+
* People's `roster.js` joined its own `profiles` table (a `left join`: a
|
|
206
|
+
* member with no profile row is kept, with null display fields) to resolve
|
|
207
|
+
* `displayName`/`email`/`lastSeenAt`. This store has no `profiles` table of
|
|
208
|
+
* its own, so that join becomes a `PeopleDirectory` lookup afterwards —
|
|
209
|
+
* `person-records.ts`'s wave 10a port — over every human principal id this
|
|
210
|
+
* query found; a human with no directory entry keeps the same left-join
|
|
211
|
+
* shape people's SQL gave it: the row stays, `displayName`/`email`/
|
|
212
|
+
* `lastSeenAt` come back `null`. Non-human members never reach the
|
|
213
|
+
* directory at all, the same as people's join, which only ever matched
|
|
214
|
+
* `profiles` for `principalClass = 'human'`.
|
|
215
|
+
*/
|
|
216
|
+
export async function membersOfRow(db, tenantId, directory, roles) {
|
|
217
|
+
// No `roles` given: force the assignment join to match nothing, so every
|
|
218
|
+
// row's role/roleLabel comes back `null` without a second query shape to
|
|
219
|
+
// maintain.
|
|
220
|
+
const roleMatch = roles
|
|
221
|
+
? and(eq(policyAssignments.applicationId, roles.applicationId), eq(policyAssignments.platformId, roles.platformId))
|
|
222
|
+
: sql `false`;
|
|
223
|
+
const rows = await db
|
|
224
|
+
.select({
|
|
225
|
+
principalId: memberships.principalId,
|
|
226
|
+
principalClass: memberships.principalClass,
|
|
227
|
+
source: memberships.source,
|
|
228
|
+
viaTenantId: memberships.viaTenantId,
|
|
229
|
+
joinedAt: memberships.createdAt,
|
|
230
|
+
// A non-human member's name is the name its own credential was minted
|
|
231
|
+
// with, not the principal id `display` falls back to next — a raw
|
|
232
|
+
// UUID where a name belongs.
|
|
233
|
+
credentialName: credentials.name,
|
|
234
|
+
role: policyRoles.key,
|
|
235
|
+
roleLabel: policyRoles.name,
|
|
236
|
+
})
|
|
237
|
+
.from(memberships)
|
|
238
|
+
.leftJoin(credentials, and(eq(memberships.principalClass, credentials.kind), sql `${memberships.principalId} = ${credentials.id}::text`))
|
|
239
|
+
.leftJoin(policyAssignments, and(eq(policyAssignments.tenantId, memberships.tenantId), eq(policyAssignments.principalId, memberships.principalId), eq(policyAssignments.principalClass, memberships.principalClass), eq(policyAssignments.primary, true), or(isNull(policyAssignments.expiresAt), sql `${policyAssignments.expiresAt} > now()`), roleMatch))
|
|
240
|
+
.leftJoin(policyRoles, eq(policyRoles.id, policyAssignments.roleId))
|
|
241
|
+
.where(eq(memberships.tenantId, tenantId))
|
|
242
|
+
.orderBy(asc(memberships.principalId), asc(memberships.principalClass));
|
|
243
|
+
const humanIds = [
|
|
244
|
+
...new Set(rows.filter((r) => r.principalClass === 'human').map((r) => r.principalId)),
|
|
245
|
+
];
|
|
246
|
+
const directoryRows = await directory.people(humanIds);
|
|
247
|
+
const viaTenantIds = [
|
|
248
|
+
...new Set(rows.map((r) => r.viaTenantId).filter((id) => id !== null)),
|
|
249
|
+
];
|
|
250
|
+
const viaTenantNames = viaTenantIds.length
|
|
251
|
+
? new Map((await db
|
|
252
|
+
.select({ id: tenants.id, name: tenants.name })
|
|
253
|
+
.from(tenants)
|
|
254
|
+
.where(inArray(tenants.id, viaTenantIds))).map((t) => [t.id, t.name]))
|
|
255
|
+
: new Map();
|
|
256
|
+
return rows.map(({ credentialName, viaTenantId, ...r }) => {
|
|
257
|
+
const profile = r.principalClass === 'human' ? directoryRows.get(r.principalId) : undefined;
|
|
258
|
+
return {
|
|
259
|
+
...r,
|
|
260
|
+
display: profile?.displayName ??
|
|
261
|
+
profile?.email ??
|
|
262
|
+
credentialName ??
|
|
263
|
+
fallbackCredentialLabel(r.principalClass),
|
|
264
|
+
email: profile?.email ?? null,
|
|
265
|
+
lastSeenAt: profile?.lastSeenAt ?? null,
|
|
266
|
+
viaTenantName: viaTenantId
|
|
267
|
+
? (viaTenantNames.get(viaTenantId) ?? 'a parent organisation')
|
|
268
|
+
: null,
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ActorClass, ResourcePrincipal, ResourceRole } from '@wtfalch/authz';
|
|
2
|
+
import type { OwnerActivityLookup } from './install-owner.js';
|
|
3
|
+
import type { PolicyBinding } from './owners.js';
|
|
4
|
+
import type { PeopleDirectory } from './person-records.js';
|
|
5
|
+
import type { DbOrTx, Scope } from './scoped.js';
|
|
6
|
+
import { type Access, type Result } from './types.js';
|
|
7
|
+
/**
|
|
8
|
+
* The policy layer (authz#83 wave 10b), moved from Boule's own
|
|
9
|
+
* `src/lib/authz/memberships.ts` (407 lines): `membersOf`, `selfStanding`,
|
|
10
|
+
* `guardStaysHeld`, `grantMembership`, `changeRole` and `removeMembership`.
|
|
11
|
+
* The row-level work each of the last four delegates to now lives in
|
|
12
|
+
* `src/membership-rows.ts`, ported from `@wtfalch/people` 0.2.3 in the same
|
|
13
|
+
* wave (`insertMembershipRow`, `deleteMembershipRow`, `changeRoleRow`,
|
|
14
|
+
* `guardStaysHeldRow`, `membersOfRow`) rather than imported from that
|
|
15
|
+
* package — `@wtfalch/people` depends on this one, so importing it back
|
|
16
|
+
* would be a cycle. Boule's `PEOPLE_BINDING`, built from its own
|
|
17
|
+
* `APPLICATION_ID`/`PLATFORM_ID` constants, becomes `access.binding`
|
|
18
|
+
* everywhere, the same as every other moved module.
|
|
19
|
+
*/
|
|
20
|
+
export interface Member {
|
|
21
|
+
readonly principalId: string;
|
|
22
|
+
readonly principalClass: string;
|
|
23
|
+
readonly role: string;
|
|
24
|
+
readonly roleLabel: string;
|
|
25
|
+
readonly source: string;
|
|
26
|
+
readonly display: string;
|
|
27
|
+
readonly email: string | null;
|
|
28
|
+
/** Where this membership came from — `null` for `source === 'direct'`,
|
|
29
|
+
* the parent organisation's name otherwise. The register's "From" column
|
|
30
|
+
* reads this the same way the person page's front does. */
|
|
31
|
+
readonly viaTenantName: string | null;
|
|
32
|
+
readonly joinedAt: Date;
|
|
33
|
+
readonly lastSeenAt: Date | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The roster read, delegated to this package's own `membersOfRow` (same
|
|
37
|
+
* query shape this file used to run itself, ported into `membership-rows.ts`
|
|
38
|
+
* from `@wtfalch/people`'s `membersOf`). Its `role`/`roleLabel` come back
|
|
39
|
+
* `null` where this file's own `primaryRoleKey` subquery and `names` lookup
|
|
40
|
+
* used to read `''`/`'Unavailable role'` for "no current primary
|
|
41
|
+
* assignment" — mapped back to those exact values here so every caller of
|
|
42
|
+
* `Member` keeps reading the same two fallbacks it always has.
|
|
43
|
+
*/
|
|
44
|
+
export declare function membersOf(access: Access, db: DbOrTx, directory: PeopleDirectory): Promise<readonly Member[] | null>;
|
|
45
|
+
export interface SelfStanding {
|
|
46
|
+
readonly joinedAt: Date;
|
|
47
|
+
/** `'direct'` for an ordinary membership, otherwise the tenant tree gave it. */
|
|
48
|
+
readonly source: string;
|
|
49
|
+
/** `null` for `source === 'direct'`; the parent organisation's name otherwise. */
|
|
50
|
+
readonly viaTenantName: string | null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The viewer's own membership record in this organisation -- when they
|
|
54
|
+
* joined, and whether their standing here came from this organisation
|
|
55
|
+
* directly or from a parent. The organisation's home page is gated by
|
|
56
|
+
* requiring tenant membership alone (D2: "which one this is, and what the
|
|
57
|
+
* person looking at it is to it"), not by a permission, and reading your
|
|
58
|
+
* own row is not a further permission decision either -- the same
|
|
59
|
+
* reasoning that lets `access.role` itself go unchecked.
|
|
60
|
+
*
|
|
61
|
+
* **`null` when there is no row to read**, chiefly a break-glass session:
|
|
62
|
+
* `loadAccess` widens `Access` for an operator's support session without
|
|
63
|
+
* inserting a `memberships` row, so there is nothing here for that case --
|
|
64
|
+
* not a refusal, an absence, and a break-glass banner already explains why.
|
|
65
|
+
*/
|
|
66
|
+
export declare function selfStanding(access: Access, db: DbOrTx): Promise<SelfStanding | null>;
|
|
67
|
+
export interface GrantMembershipOptions {
|
|
68
|
+
readonly principalId: string;
|
|
69
|
+
readonly principalClass?: ActorClass;
|
|
70
|
+
readonly role: string;
|
|
71
|
+
}
|
|
72
|
+
export interface ChangeRoleOptions extends GrantMembershipOptions {
|
|
73
|
+
}
|
|
74
|
+
export interface RemoveMembershipOptions {
|
|
75
|
+
readonly principalId: string;
|
|
76
|
+
readonly principalClass?: ActorClass;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Protected roles cannot lose their last independent recovery owner.
|
|
80
|
+
* Delegated to `membership-rows.ts`'s `guardStaysHeldRow`, which runs the
|
|
81
|
+
* exact same coverage count this file used to run itself via `./owners.js`'
|
|
82
|
+
* `ownerCoverage`/`ownerSeatCovered`.
|
|
83
|
+
*/
|
|
84
|
+
export declare function guardStaysHeld(tx: DbOrTx, binding: PolicyBinding, tenantId: string, roles: readonly ResourceRole[], principal: ResourcePrincipal): Promise<boolean>;
|
|
85
|
+
export declare function grantMembership(access: Access, db: DbOrTx, scopeColumn: Scope['column'], profiles: Pick<OwnerActivityLookup, 'personKnown'>, options: GrantMembershipOptions): Promise<Result<void>>;
|
|
86
|
+
export declare function changeRole(access: Access, db: DbOrTx, scopeColumn: Scope['column'], options: ChangeRoleOptions): Promise<Result<void>>;
|
|
87
|
+
export declare function removeMembership(access: Access, db: DbOrTx, scopeColumn: Scope['column'], options: RemoveMembershipOptions): Promise<Result<void>>;
|