@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.
Files changed (81) hide show
  1. package/README.md +335 -5
  2. package/dist/activations.d.ts +60 -0
  3. package/dist/activations.js +516 -0
  4. package/dist/alerts.d.ts +86 -0
  5. package/dist/alerts.js +132 -0
  6. package/dist/assignments.d.ts +71 -0
  7. package/dist/assignments.js +103 -0
  8. package/dist/audit.d.ts +122 -0
  9. package/dist/audit.js +194 -0
  10. package/dist/binding.d.ts +17 -0
  11. package/dist/binding.js +8 -0
  12. package/dist/boot.d.ts +54 -0
  13. package/dist/boot.js +143 -0
  14. package/dist/bootstrap.d.ts +28 -0
  15. package/dist/bootstrap.js +83 -0
  16. package/dist/break-glass.d.ts +61 -0
  17. package/dist/break-glass.js +247 -0
  18. package/dist/credentials.d.ts +94 -0
  19. package/dist/credentials.js +230 -0
  20. package/dist/denial.d.ts +9 -0
  21. package/dist/denial.js +49 -0
  22. package/dist/erase.d.ts +58 -0
  23. package/dist/erase.js +108 -0
  24. package/dist/events.d.ts +77 -0
  25. package/dist/events.js +107 -0
  26. package/dist/export.d.ts +161 -0
  27. package/dist/export.js +293 -0
  28. package/dist/index.d.ts +33 -1
  29. package/dist/index.js +33 -1
  30. package/dist/install-owner.d.ts +25 -0
  31. package/dist/install-owner.js +159 -0
  32. package/dist/invitations.d.ts +160 -0
  33. package/dist/invitations.js +685 -0
  34. package/dist/membership-rows.d.ts +206 -0
  35. package/dist/membership-rows.js +271 -0
  36. package/dist/memberships.d.ts +87 -0
  37. package/dist/memberships.js +272 -0
  38. package/dist/migrate.js +15 -4
  39. package/dist/nesting.d.ts +124 -0
  40. package/dist/nesting.js +515 -0
  41. package/dist/owners.d.ts +6 -1
  42. package/dist/owners.js +7 -3
  43. package/dist/person-records.d.ts +186 -0
  44. package/dist/person-records.js +263 -0
  45. package/dist/platform.d.ts +20 -0
  46. package/dist/platform.js +65 -0
  47. package/dist/policy-access.d.ts +260 -0
  48. package/dist/policy-access.js +348 -0
  49. package/dist/policy-resources.d.ts +5 -0
  50. package/dist/policy-resources.js +46 -0
  51. package/dist/policy-schema.d.ts +445 -0
  52. package/dist/policy-schema.js +63 -0
  53. package/dist/policy.d.ts +64 -0
  54. package/dist/policy.js +65 -0
  55. package/dist/propagate.d.ts +43 -0
  56. package/dist/propagate.js +47 -0
  57. package/dist/reconcile.d.ts +78 -0
  58. package/dist/reconcile.js +94 -0
  59. package/dist/resource-access.d.ts +344 -0
  60. package/dist/resource-access.js +656 -0
  61. package/dist/role-keys.d.ts +9 -0
  62. package/dist/role-keys.js +9 -0
  63. package/dist/roles.d.ts +36 -0
  64. package/dist/roles.js +191 -0
  65. package/dist/schema.d.ts +18 -1
  66. package/dist/schema.js +8 -1
  67. package/dist/startup.d.ts +57 -0
  68. package/dist/startup.js +113 -0
  69. package/dist/tenants.d.ts +213 -0
  70. package/dist/tenants.js +808 -0
  71. package/dist/tree-writes.d.ts +65 -0
  72. package/dist/tree-writes.js +201 -0
  73. package/dist/tree.d.ts +272 -0
  74. package/dist/tree.js +565 -0
  75. package/dist/types.d.ts +87 -0
  76. package/dist/types.js +15 -0
  77. package/migrations/0003_product_tenant_kind.sql +14 -0
  78. package/migrations/0004_credential_keys_issued_id.sql +33 -0
  79. package/migrations/0005_activations.sql +71 -0
  80. package/migrations/0006_erase_person.sql +134 -0
  81. 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>>;