@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,186 @@
1
+ import type { DbOrTx } from './scoped.js';
2
+ import { type Access } from './types.js';
3
+ /**
4
+ * The grants compiler (authz#83 wave 10a), moved from Boule's own
5
+ * `src/lib/authz/grants.ts`: `personRecordFor`, the person page's one read
6
+ * of every grant a human principal holds in one organisation, with its
7
+ * provenance and the teams they participate in, and `teamRecordFor`, the
8
+ * team page's own read of what one team is, its place in the hierarchy, who
9
+ * is in it, and what app access is scoped to it. `src/grants.ts` is a
10
+ * different file already: the `authz_grants` table and `guestGrants`, grants
11
+ * stored per person rather than compiled from a role. This file does not
12
+ * touch that one, on purpose (decision 1).
13
+ *
14
+ * **Fresh, scoped queries, not `access.policyState`.** `loadPolicyState`
15
+ * already pulls this tenant's whole assignment and membership tables into
16
+ * every request's `Access` (see `policy-access.ts`), but leaning on that here
17
+ * would make this reader's cost a fact about what else the request happened
18
+ * to load rather than about what it asked for. `authz_assignments_principal_idx`
19
+ * and `memberships`'s own primary key both cover `(tenant_id, principal_class,
20
+ * principal_id)`, so asking by principal directly is the same one-row-range
21
+ * lookup other readers already use, and it stays true if `loadPolicyState` is
22
+ * ever changed to stop loading everything.
23
+ *
24
+ * **`null`/`[]` is a list convention; this returns one record.** Every other
25
+ * reader answers "not yours to read" with `null` and "nothing here" with
26
+ * `[]`, because both are lists. A single record has no empty-list shape to
27
+ * fall back on, so this returns a small discriminated result instead of
28
+ * overloading `null` to mean two different refusals — a caller that cannot
29
+ * tell "you may not see this" from "this does not exist" has not been told
30
+ * anything.
31
+ *
32
+ * **`profiles` is a host port, `PeopleDirectory` (decision 3).** The store
33
+ * has no `profiles` table of its own; a host's own people directory answers
34
+ * the two questions this file used to ask Boule's `profiles` table directly.
35
+ */
36
+ /** What a host must supply about its people, for both grant provenance and a person's own display fields. Reused for both `resolveActorNames` and `personRecordFor`'s own profile lookup, rather than a second port for the same table. */
37
+ export interface PeopleDirectory {
38
+ people(ids: readonly string[]): Promise<ReadonlyMap<string, {
39
+ readonly displayName: string | null;
40
+ readonly email: string | null;
41
+ readonly lastSeenAt: Date | null;
42
+ }>>;
43
+ }
44
+ export interface GrantPermission {
45
+ readonly permission: string;
46
+ readonly label: string;
47
+ readonly relation: string;
48
+ }
49
+ export interface PersonGrant {
50
+ readonly id: string;
51
+ readonly roleKey: string;
52
+ readonly roleLabel: string;
53
+ /** The organisation-role grant every member holds exactly one of, shown on the front as "standing". */
54
+ readonly primary: boolean;
55
+ /** Never a raw principal id — resolved to a name, a credential's name, or a truthful sentence. */
56
+ readonly grantedBy: string;
57
+ readonly grantedAt: Date;
58
+ readonly expiresAt: Date | null;
59
+ readonly source: string;
60
+ readonly viaTenantName: string | null;
61
+ readonly permissions: readonly GrantPermission[];
62
+ }
63
+ export interface PersonTeam {
64
+ readonly teamId: string;
65
+ readonly teamName: string;
66
+ readonly parentTeamName: string | null;
67
+ }
68
+ export interface PersonRecord {
69
+ readonly principalId: string;
70
+ readonly display: string;
71
+ readonly email: string | null;
72
+ readonly tenantName: string;
73
+ /** The primary grant's role label — the front's standing pill. */
74
+ readonly standing: string;
75
+ readonly source: string;
76
+ readonly viaTenantName: string | null;
77
+ readonly joinedAt: Date;
78
+ readonly lastSeenAt: Date | null;
79
+ readonly grants: readonly PersonGrant[];
80
+ readonly teams: readonly PersonTeam[];
81
+ /**
82
+ * How many of this person's team memberships were dropped by the
83
+ * `teams.people:read` filter below `teams`, not because they do not
84
+ * exist. A viewer who may see a team but not its people is told so, never
85
+ * handed a `teams` list that reads identically to "this person is in no
86
+ * teams at all".
87
+ */
88
+ readonly hiddenTeams: number;
89
+ }
90
+ export type PersonRecordResult = {
91
+ readonly ok: true;
92
+ readonly person: PersonRecord;
93
+ } | {
94
+ readonly ok: false;
95
+ readonly reason: 'no_permission' | 'not_found';
96
+ };
97
+ export declare function personRecordFor(access: Access, db: DbOrTx, directory: PeopleDirectory, personId: string): Promise<PersonRecordResult>;
98
+ /**
99
+ * The team page's own read: what one team is, its place in the hierarchy,
100
+ * who is in it, and what app access is scoped to it -- the same three kinds
101
+ * of fact `personRecordFor` resolves for a person, read for a team instead.
102
+ *
103
+ * **Gated per team, not per organisation, and that is the whole reason this
104
+ * is a separate function rather than a second branch of `personRecordFor`.**
105
+ * `members:read` (what gates the person page) has no scope narrower than the
106
+ * organisation in a host's own resource catalogue; `teams:read` genuinely
107
+ * does, so two administrators in the same organisation can legitimately see
108
+ * different teams. A resource overview already filters its own `teams` list
109
+ * the same way; this is that same check, asked about one team a caller
110
+ * already has an id for rather than every team at once.
111
+ *
112
+ * **A refused team and a nonexistent one both read `not_found`, unlike
113
+ * `personRecordFor`'s `no_permission`/`not_found` split.** That split is
114
+ * safe there because it is unreachable in practice: the person page's own
115
+ * outer gate already demands the exact organisation-wide `members:read` this
116
+ * reader checks again, so the "no permission" branch can never actually be
117
+ * taken. Here the two are genuinely different, reachable requests -- a
118
+ * viewer scoped to Team A can call this function for Team B by typing its
119
+ * id in the URL -- and a reader that told the two apart would confirm to
120
+ * that viewer that Team B exists at all. Collapsing both into `not_found` is
121
+ * the same anti-enumeration posture `personRecordFor` already keeps for a
122
+ * cross-tenant or malformed id; it just has a real case to earn it here.
123
+ *
124
+ * **`peopleReadable` names `teams.people:read`'s own refusal, kept apart
125
+ * from whether the team genuinely has zero members.** A viewer who can read
126
+ * this team but not its people must see something different from a team
127
+ * with no people in it -- the exact defect the person page's own
128
+ * `hiddenTeams` exists to fix, mirrored here at the single-team granularity
129
+ * instead of counted across many. `hiddenChildTeams` is the same shape one
130
+ * level down the hierarchy.
131
+ */
132
+ export interface TeamGrant {
133
+ readonly id: string;
134
+ readonly roleLabel: string;
135
+ /** Never a raw principal id -- resolved to a name, a credential's name, or a truthful sentence, the same as `PersonGrant.grantedBy`. */
136
+ readonly grantedBy: string;
137
+ readonly grantedAt: Date;
138
+ readonly expiresAt: Date | null;
139
+ readonly source: string;
140
+ readonly viaTenantName: string | null;
141
+ readonly permissions: readonly GrantPermission[];
142
+ }
143
+ /**
144
+ * A team's own parent, told apart three ways rather than folded into one
145
+ * nullable field: `root` (no parent), `named` (a parent this viewer can also
146
+ * read), and `hidden` (a parent exists but `teams:read` refuses it to this
147
+ * viewer). Conflating `hidden` into `root` would print "no parent" for a
148
+ * team that has one, exactly the silent-zero shape this file's readers
149
+ * otherwise refuse to produce.
150
+ */
151
+ export type TeamParent = {
152
+ readonly kind: 'root';
153
+ } | {
154
+ readonly kind: 'named';
155
+ readonly id: string;
156
+ readonly name: string;
157
+ } | {
158
+ readonly kind: 'hidden';
159
+ };
160
+ export interface TeamRecord {
161
+ readonly id: string;
162
+ readonly name: string;
163
+ readonly parent: TeamParent;
164
+ readonly createdAt: Date;
165
+ readonly canAddPeople: boolean;
166
+ readonly canRemovePeople: boolean;
167
+ /** `teams.people:read` for this specific team; see the function docblock. */
168
+ readonly peopleReadable: boolean;
169
+ /** Principal ids only -- names are the caller's own membership lookup. Empty whenever `peopleReadable` is false. */
170
+ readonly people: readonly string[];
171
+ readonly childTeams: readonly {
172
+ readonly id: string;
173
+ readonly name: string;
174
+ }[];
175
+ /** Child teams that exist but this viewer cannot read -- the same shape as `peopleReadable`, one level down the tree. */
176
+ readonly hiddenChildTeams: number;
177
+ readonly access: readonly TeamGrant[];
178
+ }
179
+ export type TeamRecordResult = {
180
+ readonly ok: true;
181
+ readonly team: TeamRecord;
182
+ } | {
183
+ readonly ok: false;
184
+ readonly reason: 'not_found';
185
+ };
186
+ export declare function teamRecordFor(access: Access, db: DbOrTx, directory: PeopleDirectory, teamId: string): Promise<TeamRecordResult>;
@@ -0,0 +1,263 @@
1
+ import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
2
+ import { permitsRead } from './policy-access.js';
3
+ import { policyAssignments, policyRoles } from './policy-schema.js';
4
+ import { accessTeamMembers, accessTeams } from './resource-schema.js';
5
+ import { credentials, memberships } from './schema.js';
6
+ import { tenantById } from './tenants.js';
7
+ import { isUuid } from './types.js';
8
+ /** `grantedBy`/`createdBy` name only who acted, never why they still can; resolve it truthfully or say so. */
9
+ async function resolveActorNames(db, ids, directory) {
10
+ if (ids.length === 0)
11
+ return new Map();
12
+ const [people, creds] = await Promise.all([
13
+ directory.people(ids),
14
+ db
15
+ .select({ id: credentials.id, name: credentials.name })
16
+ .from(credentials)
17
+ // `credentials.id` is a uuid column; `ids` holds arbitrary principal
18
+ // ids (a real Zitadel subject id is a string, not uuid-shaped), so the
19
+ // comparison casts the *column* to text -- the same direction other
20
+ // files' own `sql`${memberships.principalId} = ${credentials.id}::text``
21
+ // joins cast, and never the other way around. Parsing an arbitrary
22
+ // string as uuid instead throws Postgres's `22P02 invalid input syntax
23
+ // for type uuid`, which took Boule's whole Server Component down with
24
+ // it.
25
+ .where(inArray(sql `${credentials.id}::text`, ids)),
26
+ ]);
27
+ const names = new Map();
28
+ for (const [id, p] of people)
29
+ names.set(id, p.displayName ?? p.email ?? 'A person on this platform');
30
+ for (const c of creds)
31
+ if (!names.has(c.id))
32
+ names.set(c.id, `${c.name} (a credential)`);
33
+ return names;
34
+ }
35
+ function actorDisplay(id, names) {
36
+ if (!id)
37
+ return 'Set automatically, not by a person';
38
+ return names.get(id) ?? 'Someone no longer on this platform';
39
+ }
40
+ export async function personRecordFor(access, db, directory, personId) {
41
+ if (!(await permitsRead(access, db, 'members:read')))
42
+ return { ok: false, reason: 'no_permission' };
43
+ const [membershipRow] = await db
44
+ .select({
45
+ source: memberships.source,
46
+ viaTenantId: memberships.viaTenantId,
47
+ grantedBy: memberships.grantedBy,
48
+ createdAt: memberships.createdAt,
49
+ })
50
+ .from(memberships)
51
+ .where(and(eq(memberships.tenantId, access.tenant.id), eq(memberships.principalId, personId), eq(memberships.principalClass, 'human')))
52
+ .limit(1);
53
+ if (!membershipRow)
54
+ return { ok: false, reason: 'not_found' };
55
+ // Boule's own query ran an inner join against `profiles` here, so a member
56
+ // with no profile row was excluded from the result entirely. With the
57
+ // `PeopleDirectory` port there is no join to run; the same exclusion is
58
+ // this lookup coming up empty for `personId`.
59
+ const directoryRows = await directory.people([personId]);
60
+ const profile = directoryRows.get(personId);
61
+ if (!profile)
62
+ return { ok: false, reason: 'not_found' };
63
+ const assignmentRows = await db
64
+ .select({
65
+ id: policyAssignments.id,
66
+ source: policyAssignments.source,
67
+ viaTenantId: policyAssignments.viaTenantId,
68
+ primary: policyAssignments.primary,
69
+ expiresAt: policyAssignments.expiresAt,
70
+ createdAt: policyAssignments.createdAt,
71
+ createdBy: policyAssignments.createdBy,
72
+ roleKey: policyRoles.key,
73
+ roleLabel: policyRoles.name,
74
+ entries: policyRoles.entries,
75
+ })
76
+ .from(policyAssignments)
77
+ .innerJoin(policyRoles, eq(policyRoles.id, policyAssignments.roleId))
78
+ .where(and(eq(policyAssignments.tenantId, access.tenant.id), eq(policyAssignments.applicationId, access.binding.applicationId), eq(policyAssignments.platformId, access.binding.platformId), eq(policyAssignments.principalId, personId), eq(policyAssignments.principalClass, 'human')))
79
+ .orderBy(desc(policyAssignments.primary), asc(policyRoles.name));
80
+ const allTeamMemberRows = await db
81
+ .select({ teamId: accessTeamMembers.teamId })
82
+ .from(accessTeamMembers)
83
+ .where(and(eq(accessTeamMembers.tenantId, access.tenant.id), eq(accessTeamMembers.principalId, personId), eq(accessTeamMembers.principalClass, 'human')));
84
+ // Reading who is in a team is `teams.people:read`, checked per team the
85
+ // same way a resource overview filters its own `participants` — this
86
+ // reader does not get to skip that gate just because it reached the fact
87
+ // from a person rather than from a team. `access.authority` is the same
88
+ // scoped evaluator a request's own `Access` already carries.
89
+ const teamMemberRows = allTeamMemberRows.filter((row) => access.authority.allows('teams.people:read', {
90
+ applicationId: access.binding.applicationId,
91
+ platformId: access.binding.platformId,
92
+ organisationId: access.tenant.id,
93
+ type: 'team',
94
+ id: row.teamId,
95
+ teamId: row.teamId,
96
+ }));
97
+ // One small, tenant-bounded (max 1,000 teams) fetch to resolve names and
98
+ // one parent hop each, rather than a query per team.
99
+ const teamRows = teamMemberRows.length
100
+ ? await db.select().from(accessTeams).where(eq(accessTeams.tenantId, access.tenant.id))
101
+ : [];
102
+ const teamById = new Map(teamRows.map((t) => [t.id, t]));
103
+ const actorIds = [
104
+ ...new Set([membershipRow.grantedBy, ...assignmentRows.map((a) => a.createdBy)].filter((id) => !!id)),
105
+ ];
106
+ const actorNames = await resolveActorNames(db, actorIds, directory);
107
+ const viaTenantIds = [
108
+ ...new Set([membershipRow.viaTenantId, ...assignmentRows.map((a) => a.viaTenantId)].filter((id) => !!id)),
109
+ ];
110
+ const viaTenantNamePairs = await Promise.all(viaTenantIds.map(async (id) => [id, (await tenantById(db, id))?.name ?? null]));
111
+ const viaTenantNames = new Map(viaTenantNamePairs);
112
+ const viaName = (id) => id ? (viaTenantNames.get(id) ?? 'a parent organisation') : null;
113
+ const primaryRow = assignmentRows.find((a) => a.primary);
114
+ return {
115
+ ok: true,
116
+ person: {
117
+ principalId: personId,
118
+ display: profile.displayName ?? profile.email ?? 'A person on this platform',
119
+ email: profile.email,
120
+ tenantName: access.tenant.name,
121
+ standing: primaryRow?.roleLabel ?? 'No standing role',
122
+ source: membershipRow.source,
123
+ viaTenantName: viaName(membershipRow.viaTenantId),
124
+ joinedAt: membershipRow.createdAt,
125
+ lastSeenAt: profile.lastSeenAt,
126
+ grants: assignmentRows.map((a) => ({
127
+ id: a.id,
128
+ roleKey: a.roleKey,
129
+ roleLabel: a.roleLabel,
130
+ primary: a.primary,
131
+ grantedBy: actorDisplay(a.createdBy, actorNames),
132
+ grantedAt: a.createdAt,
133
+ expiresAt: a.expiresAt,
134
+ source: a.source,
135
+ viaTenantName: viaName(a.viaTenantId),
136
+ permissions: a.entries.map((entry) => ({
137
+ permission: entry.permission,
138
+ label: access.binding.catalogue[entry.permission]?.label ?? entry.permission,
139
+ relation: entry.relation,
140
+ })),
141
+ })),
142
+ teams: teamMemberRows.map((row) => {
143
+ const team = teamById.get(row.teamId);
144
+ const parent = team?.parentId ? teamById.get(team.parentId) : undefined;
145
+ return {
146
+ teamId: row.teamId,
147
+ teamName: team?.name ?? 'A team that no longer exists',
148
+ parentTeamName: parent?.name ?? null,
149
+ };
150
+ }),
151
+ hiddenTeams: allTeamMemberRows.length - teamMemberRows.length,
152
+ },
153
+ };
154
+ }
155
+ function teamTarget(access, teamId) {
156
+ return {
157
+ applicationId: access.binding.applicationId,
158
+ platformId: access.binding.platformId,
159
+ organisationId: access.tenant.id,
160
+ type: 'team',
161
+ id: teamId,
162
+ teamId,
163
+ };
164
+ }
165
+ export async function teamRecordFor(access, db, directory, teamId) {
166
+ // The id comes from the URL and `access_teams.id` is a uuid column, so a
167
+ // malformed id would raise 22P02 and render a 500. It names no team.
168
+ if (!isUuid(teamId))
169
+ return { ok: false, reason: 'not_found' };
170
+ const [row] = await db
171
+ .select()
172
+ .from(accessTeams)
173
+ .where(and(eq(accessTeams.tenantId, access.tenant.id), eq(accessTeams.id, teamId)))
174
+ .limit(1);
175
+ if (!row || row.archivedAt)
176
+ return { ok: false, reason: 'not_found' };
177
+ if (!access.authority.allows('teams:read', teamTarget(access, row.id)))
178
+ return { ok: false, reason: 'not_found' };
179
+ const peopleReadable = access.authority.allows('teams.people:read', teamTarget(access, row.id));
180
+ // `siblingTeams`: one small, tenant-bounded (max 1,000 teams) fetch,
181
+ // reused for both the parent lookup and the child list -- the same trade
182
+ // `personRecordFor` makes for a person's own teams, rather than a query
183
+ // per relationship.
184
+ const [memberRows, siblingTeams, assignmentRows] = await Promise.all([
185
+ peopleReadable
186
+ ? db
187
+ .select({ principalId: accessTeamMembers.principalId })
188
+ .from(accessTeamMembers)
189
+ .where(and(eq(accessTeamMembers.tenantId, access.tenant.id), eq(accessTeamMembers.teamId, row.id)))
190
+ : Promise.resolve([]),
191
+ db
192
+ .select()
193
+ .from(accessTeams)
194
+ .where(and(eq(accessTeams.tenantId, access.tenant.id), isNull(accessTeams.archivedAt))),
195
+ db
196
+ .select({
197
+ id: policyAssignments.id,
198
+ source: policyAssignments.source,
199
+ viaTenantId: policyAssignments.viaTenantId,
200
+ expiresAt: policyAssignments.expiresAt,
201
+ createdAt: policyAssignments.createdAt,
202
+ createdBy: policyAssignments.createdBy,
203
+ roleLabel: policyRoles.name,
204
+ entries: policyRoles.entries,
205
+ })
206
+ .from(policyAssignments)
207
+ .innerJoin(policyRoles, eq(policyRoles.id, policyAssignments.roleId))
208
+ .where(and(eq(policyAssignments.tenantId, access.tenant.id), eq(policyAssignments.applicationId, access.binding.applicationId), eq(policyAssignments.platformId, access.binding.platformId), eq(policyAssignments.teamId, row.id)))
209
+ .orderBy(asc(policyRoles.name)),
210
+ ]);
211
+ let parent;
212
+ if (!row.parentId) {
213
+ parent = { kind: 'root' };
214
+ }
215
+ else {
216
+ const parentRow = siblingTeams.find((t) => t.id === row.parentId);
217
+ parent =
218
+ parentRow && access.authority.allows('teams:read', teamTarget(access, parentRow.id))
219
+ ? { kind: 'named', id: parentRow.id, name: parentRow.name }
220
+ : { kind: 'hidden' };
221
+ }
222
+ const children = siblingTeams.filter((t) => t.parentId === row.id);
223
+ const visibleChildren = children.filter((t) => access.authority.allows('teams:read', teamTarget(access, t.id)));
224
+ const actorIds = [
225
+ ...new Set(assignmentRows.map((a) => a.createdBy).filter((id) => !!id)),
226
+ ];
227
+ const actorNames = await resolveActorNames(db, actorIds, directory);
228
+ const viaTenantIds = [
229
+ ...new Set(assignmentRows.map((a) => a.viaTenantId).filter((id) => !!id)),
230
+ ];
231
+ const viaTenantNamePairs = await Promise.all(viaTenantIds.map(async (id) => [id, (await tenantById(db, id))?.name ?? null]));
232
+ const viaTenantNames = new Map(viaTenantNamePairs);
233
+ const viaName = (id) => id ? (viaTenantNames.get(id) ?? 'a parent organisation') : null;
234
+ return {
235
+ ok: true,
236
+ team: {
237
+ id: row.id,
238
+ name: row.name,
239
+ parent,
240
+ createdAt: row.createdAt,
241
+ canAddPeople: access.authority.allows('teams.people:add', teamTarget(access, row.id)),
242
+ canRemovePeople: access.authority.allows('teams.people:remove', teamTarget(access, row.id)),
243
+ peopleReadable,
244
+ people: memberRows.map((m) => m.principalId),
245
+ childTeams: visibleChildren.map((t) => ({ id: t.id, name: t.name })),
246
+ hiddenChildTeams: children.length - visibleChildren.length,
247
+ access: assignmentRows.map((a) => ({
248
+ id: a.id,
249
+ roleLabel: a.roleLabel,
250
+ grantedBy: actorDisplay(a.createdBy, actorNames),
251
+ grantedAt: a.createdAt,
252
+ expiresAt: a.expiresAt,
253
+ source: a.source,
254
+ viaTenantName: viaName(a.viaTenantId),
255
+ permissions: a.entries.map((entry) => ({
256
+ permission: entry.permission,
257
+ label: access.binding.catalogue[entry.permission]?.label ?? entry.permission,
258
+ relation: entry.relation,
259
+ })),
260
+ })),
261
+ },
262
+ };
263
+ }
@@ -0,0 +1,20 @@
1
+ import type { DbOrTx, Scope } from './scoped.js';
2
+ import { type Access, type Result } from './types.js';
3
+ /**
4
+ * The operator tenant's current freeze flag, or null for someone who may not
5
+ * switch it (the console then does not draw the switch). A read is never
6
+ * affected by the freeze itself.
7
+ */
8
+ export declare function platformFrozen(access: Access, db: DbOrTx): Promise<boolean | null>;
9
+ /**
10
+ * The platform write freeze (0.9.0, SCOPED-ACCESS.md "Write freeze"), gated on
11
+ * `platform:freeze`. That permission survives the freeze, so the operator who
12
+ * froze the platform can always unfreeze it.
13
+ *
14
+ * Setting the value it already has writes nothing: no row update, no event.
15
+ * A change writes `platform.frozen` or `platform.unfrozen` as a platform row
16
+ * (tenant_id null) in the same transaction as the update.
17
+ */
18
+ export declare function setPlatformFrozen(access: Access, db: DbOrTx, scopeColumn: Scope['column'], frozen: boolean): Promise<Result<{
19
+ changed: boolean;
20
+ }>>;
@@ -0,0 +1,65 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { record } from './audit.js';
3
+ import { permitsPlatform, refreshAccess } from './policy-access.js';
4
+ import { tenants } from './schema.js';
5
+ import { done, refused } from './types.js';
6
+ /**
7
+ * The operator tenant's current freeze flag, or null for someone who may not
8
+ * switch it (the console then does not draw the switch). A read is never
9
+ * affected by the freeze itself.
10
+ */
11
+ export async function platformFrozen(access, db) {
12
+ if (!permitsPlatform(access, 'platform:freeze'))
13
+ return null;
14
+ const [operator] = await db
15
+ .select({ frozen: tenants.frozen })
16
+ .from(tenants)
17
+ .where(eq(tenants.kind, 'operator'))
18
+ .limit(1);
19
+ return operator?.frozen ?? false;
20
+ }
21
+ /**
22
+ * The platform write freeze (0.9.0, SCOPED-ACCESS.md "Write freeze"), gated on
23
+ * `platform:freeze`. That permission survives the freeze, so the operator who
24
+ * froze the platform can always unfreeze it.
25
+ *
26
+ * Setting the value it already has writes nothing: no row update, no event.
27
+ * A change writes `platform.frozen` or `platform.unfrozen` as a platform row
28
+ * (tenant_id null) in the same transaction as the update.
29
+ */
30
+ export async function setPlatformFrozen(access, db, scopeColumn, frozen) {
31
+ if (access.context === 'break_glass') {
32
+ return refused('break_glass', 'A break-glass session never freezes the platform.');
33
+ }
34
+ if (!permitsPlatform(access, 'platform:freeze')) {
35
+ return refused('no_permission', 'You do not have permission to freeze the platform.');
36
+ }
37
+ return db.transaction(async (tx) => {
38
+ const fresh = await refreshAccess(tx, db, scopeColumn, access);
39
+ if (!fresh || fresh.context === 'break_glass' || !permitsPlatform(fresh, 'platform:freeze')) {
40
+ return refused('no_permission', 'You do not have permission to freeze the platform.');
41
+ }
42
+ const [operator] = await tx
43
+ .select({ id: tenants.id, frozen: tenants.frozen })
44
+ .from(tenants)
45
+ .where(eq(tenants.kind, 'operator'))
46
+ .for('update');
47
+ if (!operator)
48
+ return refused('tenant_not_found', 'There is no operator organisation.');
49
+ if (operator.frozen === frozen)
50
+ return done({ changed: false }, []);
51
+ await tx
52
+ .update(tenants)
53
+ .set({ frozen, updatedAt: new Date() })
54
+ .where(eq(tenants.id, operator.id));
55
+ await record(tx, fresh, {
56
+ action: frozen ? 'platform.frozen' : 'platform.unfrozen',
57
+ tenantId: null,
58
+ targetType: 'tenant',
59
+ targetId: operator.id,
60
+ before: { frozen: operator.frozen },
61
+ after: { frozen },
62
+ });
63
+ return done({ changed: true }, []);
64
+ });
65
+ }