@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,656 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { compileRoleGrants, resourceAccess, resourceScopeSchema, } from '@wtfalch/authz';
|
|
3
|
+
import { and, desc, eq, inArray, lt, ne, or, sql } from 'drizzle-orm';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { record } from './audit.js';
|
|
6
|
+
import { guardStaysHeld } from './memberships.js';
|
|
7
|
+
import { auditedReadGate, loadAccess, policyRole, refreshAccess, restriction, } from './policy-access.js';
|
|
8
|
+
import { policyResource } from './policy-resources.js';
|
|
9
|
+
import { policyAssignments, policyRoles } from './policy-schema.js';
|
|
10
|
+
import { accessTeamMembers, accessTeams } from './resource-schema.js';
|
|
11
|
+
import { createRole, deleteRole, updateRole } from './roles.js';
|
|
12
|
+
import { authzEvents } from './schema.js';
|
|
13
|
+
import { done, refused, tenantTag } from './types.js';
|
|
14
|
+
/**
|
|
15
|
+
* Wave 11a (authz#83), moved from Boule's own `src/lib/authz/resource-access.ts` (814 lines):
|
|
16
|
+
* `resourceOverview`, `saveAppRole`, `assignAppRole`, `removeAppAssignment`, `deleteAppRole`,
|
|
17
|
+
* `saveTeam`, `archiveTeam`, `changeTeamParticipant`, `scopedAuditLog`, and the shared
|
|
18
|
+
* `resourceAccessFor` request-time evaluator both Boule and archon read teams, app roles and the
|
|
19
|
+
* scoped audit log through. Boule's copy was the newest of the two hosts' (archon and otf never
|
|
20
|
+
* grew `resourceOverview`'s `canReadPeople` field); kept here.
|
|
21
|
+
*
|
|
22
|
+
* `APPLICATION_ID`/`PLATFORM_ID`/`catalogue` (Boule's own `policy-catalogue.ts`) become
|
|
23
|
+
* `access.binding`, the same as every other moved module. Every exported function now takes
|
|
24
|
+
* `db: DbOrTx` and `scopeColumn: Scope['column']` explicitly — Boule's `db` was its own root
|
|
25
|
+
* handle, imported directly and defaulted onto `resourceAccessFor`'s second parameter; the store
|
|
26
|
+
* has no database of its own to default to. `resourceAccessFor` itself reloads access from the
|
|
27
|
+
* database on every call (Boule's own choice, not a redundancy introduced here): the request-time
|
|
28
|
+
* evaluator it returns is deliberately re-read fresh rather than trusted from whatever `Access`
|
|
29
|
+
* the caller already held, so it takes the same `tx`/`db`/`scopeColumn` triple `loadAccess` does.
|
|
30
|
+
*
|
|
31
|
+
* `appRolePermissionsSchema` was a module-level zod schema closed over Boule's own `catalogue`
|
|
32
|
+
* constant; since the store has no catalogue of its own, it is now a function of
|
|
33
|
+
* `ResourceCatalogue` (`access.binding.catalogue`), and so is the private `roleInputSchema` that
|
|
34
|
+
* embeds it. Every validation rule inside both is unchanged. `assignmentInput` and `teamInput`
|
|
35
|
+
* needed no catalogue and stay plain module-level schemas.
|
|
36
|
+
*
|
|
37
|
+
* Boule's `import { db } from '@/lib/db/root'` and `import 'server-only'` are gone; its `asc`,
|
|
38
|
+
* `memberships` (table), `tenants` (table), `TenantRow` and `policyAssignment` imports were
|
|
39
|
+
* already unused in the original file — five names in all — and are dropped rather than carried
|
|
40
|
+
* over.
|
|
41
|
+
*/
|
|
42
|
+
const appRolePermissionsSchema = (catalogue) => z
|
|
43
|
+
.array(z
|
|
44
|
+
.object({
|
|
45
|
+
permission: z
|
|
46
|
+
.string()
|
|
47
|
+
.refine((key) => Object.hasOwn(catalogue, key), 'Unknown app permission'),
|
|
48
|
+
relation: z.enum(['any', 'owner', 'actor', 'subject']),
|
|
49
|
+
})
|
|
50
|
+
.strict())
|
|
51
|
+
.max(100)
|
|
52
|
+
.superRefine((entries, ctx) => {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (seen.has(entry.permission) ||
|
|
56
|
+
!catalogue[entry.permission]?.relations.includes(entry.relation))
|
|
57
|
+
ctx.addIssue({
|
|
58
|
+
code: 'custom',
|
|
59
|
+
message: `Invalid or duplicate choice for ${entry.permission}`,
|
|
60
|
+
});
|
|
61
|
+
seen.add(entry.permission);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const roleInputSchema = (catalogue) => z
|
|
65
|
+
.object({
|
|
66
|
+
id: z.uuid().optional(),
|
|
67
|
+
revision: z.number().int().positive().optional(),
|
|
68
|
+
name: z.string().trim().min(1).max(100),
|
|
69
|
+
description: z.string().trim().max(1000),
|
|
70
|
+
permissions: appRolePermissionsSchema(catalogue),
|
|
71
|
+
})
|
|
72
|
+
.strict();
|
|
73
|
+
const assignmentInput = z
|
|
74
|
+
.object({
|
|
75
|
+
roleId: z.uuid(),
|
|
76
|
+
principalId: z.string().min(1).max(256).nullable(),
|
|
77
|
+
teamId: z.uuid().nullable(),
|
|
78
|
+
recipientDescendants: z.boolean(),
|
|
79
|
+
scope: resourceScopeSchema,
|
|
80
|
+
expiresAt: z.iso.datetime().nullable(),
|
|
81
|
+
})
|
|
82
|
+
.strict()
|
|
83
|
+
.refine((v) => (v.principalId === null) !== (v.teamId === null), 'Choose a person or a team')
|
|
84
|
+
.refine((v) => !v.recipientDescendants || v.teamId !== null, 'Child participants require a team');
|
|
85
|
+
const asPrincipal = (access) => ({
|
|
86
|
+
id: access.actor.id,
|
|
87
|
+
class: access.actor.class,
|
|
88
|
+
});
|
|
89
|
+
const target = (access, type, id, teamId = null) => ({
|
|
90
|
+
applicationId: access.binding.applicationId,
|
|
91
|
+
platformId: access.binding.platformId,
|
|
92
|
+
organisationId: access.tenant.id,
|
|
93
|
+
type,
|
|
94
|
+
id,
|
|
95
|
+
teamId,
|
|
96
|
+
});
|
|
97
|
+
/** Every surface consumes the same request-time evaluator and canonical stored assignments. */
|
|
98
|
+
export async function resourceAccessFor(access, tx, db, scopeColumn) {
|
|
99
|
+
const fresh = await loadAccess(tx, access.binding, db, scopeColumn, access.actor, access.tenant.id);
|
|
100
|
+
if (!fresh)
|
|
101
|
+
throw new Error('Organisation participation ended');
|
|
102
|
+
const state = fresh.policyState;
|
|
103
|
+
const foreignRoles = await tx
|
|
104
|
+
.select({ id: policyRoles.id })
|
|
105
|
+
.from(policyRoles)
|
|
106
|
+
.where(and(eq(policyRoles.tenantId, access.tenant.id), ne(policyRoles.applicationId, access.binding.applicationId)))
|
|
107
|
+
.limit(1);
|
|
108
|
+
return {
|
|
109
|
+
foreignPolicy: foreignRoles.length > 0,
|
|
110
|
+
evaluator: fresh.authority,
|
|
111
|
+
teams: state.teams.filter((t) => t.tenantId === access.tenant.id),
|
|
112
|
+
participants: state.participants.filter((t) => t.tenantId === access.tenant.id),
|
|
113
|
+
roleRows: state.roleRows.map((r) => ({
|
|
114
|
+
...r,
|
|
115
|
+
permissions: r.entries.map(({ permission, relation }) => ({ permission, relation })),
|
|
116
|
+
})),
|
|
117
|
+
assignmentRows: state.assignmentRows,
|
|
118
|
+
grants: state.grants,
|
|
119
|
+
identities: new Map(state.memberRows
|
|
120
|
+
.filter((m) => m.principalClass === 'human')
|
|
121
|
+
.map((m) => [m.principalId, { id: m.principalId, class: 'human' }])),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
async function writer(access, db, scopeColumn, run) {
|
|
125
|
+
if (access.actor.class !== 'human' || access.context === 'break_glass')
|
|
126
|
+
return refused('context', 'Use your own signed-in account to change access.');
|
|
127
|
+
return db.transaction(async (tx) => {
|
|
128
|
+
const fresh = await refreshAccess(tx, db, scopeColumn, access);
|
|
129
|
+
if (!fresh || fresh.readOnly)
|
|
130
|
+
return refused('state', 'This organisation is not open for changes.');
|
|
131
|
+
return run(tx, fresh, await resourceAccessFor(fresh, tx, db, scopeColumn));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const noPermission = () => refused('no_permission', 'Your current roles do not allow this operation in that scope.');
|
|
135
|
+
const auditJson = (value) => JSON.parse(JSON.stringify(value));
|
|
136
|
+
export async function resourceOverview(access, db, scopeColumn) {
|
|
137
|
+
const state = await resourceAccessFor(access, db, db, scopeColumn);
|
|
138
|
+
const canReadRoles = state.evaluator.allows('roles:read', target(access, 'role', access.tenant.id));
|
|
139
|
+
const visibleTeams = state.teams.filter((team) => !team.archivedAt &&
|
|
140
|
+
state.evaluator.allows('teams:read', target(access, 'team', team.id, team.id)));
|
|
141
|
+
return {
|
|
142
|
+
teams: visibleTeams.map((team) => ({
|
|
143
|
+
...team,
|
|
144
|
+
parentId: visibleTeams.some((t) => t.id === team.parentId) ? team.parentId : null,
|
|
145
|
+
canUpdate: state.evaluator.allows('teams:update', target(access, 'team', team.id, team.id)),
|
|
146
|
+
canCreateChild: state.evaluator.allows('teams:create', target(access, 'team', team.id, team.id)),
|
|
147
|
+
canAddPeople: state.evaluator.allows('teams.people:add', target(access, 'team', team.id, team.id)),
|
|
148
|
+
canRemovePeople: state.evaluator.allows('teams.people:remove', target(access, 'team', team.id, team.id)),
|
|
149
|
+
canArchive: state.evaluator.allows('teams:archive', target(access, 'team', team.id, team.id)),
|
|
150
|
+
canReadPeople: state.evaluator.allows('teams.people:read', target(access, 'team', team.id, team.id)),
|
|
151
|
+
})),
|
|
152
|
+
participants: state.participants.filter((p) => state.evaluator.allows('teams.people:read', target(access, 'team', p.teamId, p.teamId))),
|
|
153
|
+
roles: canReadRoles ? state.roleRows : [],
|
|
154
|
+
assignments: canReadRoles ? state.assignmentRows : [],
|
|
155
|
+
canReadRoles,
|
|
156
|
+
canReadTeams: state.evaluator.clauses('teams:read').length > 0,
|
|
157
|
+
canCreateTeam: state.evaluator.allows('teams:create', target(access, 'team', access.tenant.id)),
|
|
158
|
+
canCreateRole: state.evaluator.allows('roles:create', target(access, 'role', access.tenant.id)),
|
|
159
|
+
canAssign: state.evaluator.allows('roles:assign', target(access, 'role', access.tenant.id)),
|
|
160
|
+
canUnassign: state.evaluator.allows('roles:unassign', target(access, 'role', access.tenant.id)),
|
|
161
|
+
canDeleteRoles: state.evaluator.allows('roles:delete', target(access, 'role', access.tenant.id)),
|
|
162
|
+
canEditTeams: state.evaluator.allows('teams:update', target(access, 'team', access.tenant.id)),
|
|
163
|
+
canEditRoles: state.evaluator.allows('roles:update', target(access, 'role', access.tenant.id)),
|
|
164
|
+
canReadAudit: state.evaluator.clauses('platform.audit:read').length > 0,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export async function saveAppRole(access, db, scopeColumn, input) {
|
|
168
|
+
const parsed = roleInputSchema(access.binding.catalogue).safeParse(input);
|
|
169
|
+
if (!parsed.success)
|
|
170
|
+
return refused('invalid_role', 'Choose valid operations and relations.');
|
|
171
|
+
const options = parsed.data;
|
|
172
|
+
const current = await loadAccess(db, access.binding, db, scopeColumn, access.actor, access.tenant.id);
|
|
173
|
+
if (!current)
|
|
174
|
+
return noPermission();
|
|
175
|
+
const previous = options.id
|
|
176
|
+
? current.policyState.roleRows.find((r) => r.id === options.id)
|
|
177
|
+
: undefined;
|
|
178
|
+
if (options.id && !previous)
|
|
179
|
+
return refused('missing', 'This role no longer exists.');
|
|
180
|
+
if (previous &&
|
|
181
|
+
new Set(previous.entries.map((e) => e.permission)).size !== previous.entries.length)
|
|
182
|
+
return refused('complex_role', 'Edit this role through the complete policy API so every boundary and scope is preserved.');
|
|
183
|
+
const entries = options.permissions.map((p) => ({
|
|
184
|
+
permission: p.permission,
|
|
185
|
+
relation: p.relation,
|
|
186
|
+
boundary: previous?.entries.find((e) => e.permission === p.permission)?.boundary ?? {
|
|
187
|
+
kind: 'organisation',
|
|
188
|
+
organisationId: access.tenant.id,
|
|
189
|
+
},
|
|
190
|
+
scope: previous?.entries.find((e) => e.permission === p.permission)?.scope ?? {
|
|
191
|
+
kind: 'organisation',
|
|
192
|
+
},
|
|
193
|
+
}));
|
|
194
|
+
if (previous) {
|
|
195
|
+
const result = await updateRole(access, db, scopeColumn, {
|
|
196
|
+
key: previous.key,
|
|
197
|
+
revision: options.revision ?? 0,
|
|
198
|
+
name: options.name,
|
|
199
|
+
description: options.description || options.name,
|
|
200
|
+
entries,
|
|
201
|
+
});
|
|
202
|
+
return result.ok ? done({ id: previous.id }, result.tags) : result;
|
|
203
|
+
}
|
|
204
|
+
const key = `c_${randomUUID().replaceAll('-', '')}`;
|
|
205
|
+
const result = await createRole(access, db, scopeColumn, {
|
|
206
|
+
key,
|
|
207
|
+
name: options.name,
|
|
208
|
+
description: options.description || options.name,
|
|
209
|
+
entries,
|
|
210
|
+
});
|
|
211
|
+
if (!result.ok)
|
|
212
|
+
return result;
|
|
213
|
+
const [created] = await db
|
|
214
|
+
.select({ id: policyRoles.id })
|
|
215
|
+
.from(policyRoles)
|
|
216
|
+
.where(and(eq(policyRoles.tenantId, access.tenant.id), eq(policyRoles.key, key)));
|
|
217
|
+
if (!created)
|
|
218
|
+
throw new Error('Created role missing');
|
|
219
|
+
return done(created, result.tags);
|
|
220
|
+
}
|
|
221
|
+
function buildAssignmentGrants(access, assignment, role) {
|
|
222
|
+
try {
|
|
223
|
+
return compileRoleGrants(policyRole(role), {
|
|
224
|
+
id: assignment.id ?? 'new',
|
|
225
|
+
roleId: role.id,
|
|
226
|
+
applicationId: access.binding.applicationId,
|
|
227
|
+
platformId: access.binding.platformId,
|
|
228
|
+
organisationId: access.tenant.id,
|
|
229
|
+
boundary: assignment.boundary,
|
|
230
|
+
scope: assignment.scope,
|
|
231
|
+
recipient: assignment.principalId
|
|
232
|
+
? {
|
|
233
|
+
kind: 'principal',
|
|
234
|
+
principal: { id: assignment.principalId, class: assignment.principalClass },
|
|
235
|
+
}
|
|
236
|
+
: {
|
|
237
|
+
kind: 'team',
|
|
238
|
+
teamId: assignment.teamId,
|
|
239
|
+
descendants: assignment.recipientDescendants,
|
|
240
|
+
},
|
|
241
|
+
...(assignment.expiresAt ? { expiresAt: assignment.expiresAt.getTime() } : {}),
|
|
242
|
+
}, access.binding.catalogue);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export async function assignAppRole(access, db, scopeColumn, input) {
|
|
249
|
+
const parsed = assignmentInput.safeParse(input);
|
|
250
|
+
if (!parsed.success)
|
|
251
|
+
return refused('invalid_assignment', 'Choose a valid role, recipient, scope and expiry.');
|
|
252
|
+
const options = parsed.data;
|
|
253
|
+
return writer(access, db, scopeColumn, async (tx, fresh, state) => {
|
|
254
|
+
if (!state.evaluator.allows('roles:assign', target(fresh, 'role', fresh.tenant.id)))
|
|
255
|
+
return noPermission();
|
|
256
|
+
const role = state.roleRows.find((r) => r.id === options.roleId);
|
|
257
|
+
if (!role)
|
|
258
|
+
return refused('role', 'This role is unavailable.');
|
|
259
|
+
if (options.principalId && state.identities.get(options.principalId)?.class !== 'human')
|
|
260
|
+
return refused('person', 'Choose someone who belongs to this organisation.');
|
|
261
|
+
const activeTeam = (id) => state.teams.some((t) => t.id === id && !t.archivedAt);
|
|
262
|
+
if ((options.teamId && !activeTeam(options.teamId)) ||
|
|
263
|
+
(options.scope.kind === 'team' && !activeTeam(options.scope.teamId)))
|
|
264
|
+
return refused('team', 'Choose an active team in this organisation.');
|
|
265
|
+
const expiresAt = options.expiresAt ? new Date(options.expiresAt) : null;
|
|
266
|
+
if (expiresAt && expiresAt.getTime() <= Date.now())
|
|
267
|
+
return refused('expiry', 'Choose a future expiry.');
|
|
268
|
+
if (role.guards.some((p) => !state.evaluator.allows(p, target(fresh, fresh.binding.catalogue[p]?.resourceType ?? 'unknown', fresh.tenant.id))))
|
|
269
|
+
return noPermission();
|
|
270
|
+
const candidate = {
|
|
271
|
+
...options,
|
|
272
|
+
expiresAt,
|
|
273
|
+
tenantId: fresh.tenant.id,
|
|
274
|
+
applicationId: fresh.binding.applicationId,
|
|
275
|
+
platformId: fresh.binding.platformId,
|
|
276
|
+
principalClass: options.principalId ? 'human' : null,
|
|
277
|
+
boundary: { kind: 'organisation', organisationId: fresh.tenant.id },
|
|
278
|
+
};
|
|
279
|
+
const grants = buildAssignmentGrants(fresh, candidate, role);
|
|
280
|
+
if (!grants)
|
|
281
|
+
return refused('grant', 'The assignment is invalid.');
|
|
282
|
+
let resource;
|
|
283
|
+
if (options.scope.kind === 'resource') {
|
|
284
|
+
resource = await lookupResource(tx, fresh, options.scope.resourceType, options.scope.resourceId);
|
|
285
|
+
if (!resource)
|
|
286
|
+
return refused('resource', 'The selected resource is unavailable.');
|
|
287
|
+
}
|
|
288
|
+
if (grants.length === 0 || !grants.every((g) => state.evaluator.canDelegate(g, resource)))
|
|
289
|
+
return refused('delegation', 'You cannot grant these operations with that data scope.');
|
|
290
|
+
if (state.assignmentRows.length >= 1000)
|
|
291
|
+
return refused('limit', 'This organisation already has 1,000 app role assignments.');
|
|
292
|
+
const [row] = await tx
|
|
293
|
+
.insert(policyAssignments)
|
|
294
|
+
.values({ ...candidate, createdBy: fresh.actor.id })
|
|
295
|
+
.returning({ id: policyAssignments.id });
|
|
296
|
+
if (!row)
|
|
297
|
+
throw new Error('Assignment write returned no row');
|
|
298
|
+
await record(tx, fresh, {
|
|
299
|
+
action: 'membership.role_changed',
|
|
300
|
+
tenantId: fresh.tenant.id,
|
|
301
|
+
targetType: 'app_role_assignment',
|
|
302
|
+
targetId: row.id,
|
|
303
|
+
after: auditJson(candidate),
|
|
304
|
+
});
|
|
305
|
+
return done(row, [tenantTag(fresh.tenant.id)]);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
export async function removeAppAssignment(access, db, scopeColumn, id) {
|
|
309
|
+
if (!z.uuid().safeParse(id).success)
|
|
310
|
+
return refused('id', 'Invalid assignment.');
|
|
311
|
+
return writer(access, db, scopeColumn, async (tx, fresh, state) => {
|
|
312
|
+
if (!state.evaluator.allows('roles:unassign', target(fresh, 'role', fresh.tenant.id)))
|
|
313
|
+
return noPermission();
|
|
314
|
+
const row = state.assignmentRows.find((a) => a.id === id);
|
|
315
|
+
if (!row)
|
|
316
|
+
return refused('missing', 'This assignment no longer exists.');
|
|
317
|
+
if (row.source === 'inherited')
|
|
318
|
+
return refused('inherited', 'Manage this assignment at its source organisation.');
|
|
319
|
+
const role = state.roleRows.find((r) => r.id === row.roleId);
|
|
320
|
+
if (!role)
|
|
321
|
+
return refused('role', 'The role no longer exists.');
|
|
322
|
+
if (role.guards.some((p) => !state.evaluator.allows(p, target(fresh, fresh.binding.catalogue[p]?.resourceType ?? 'unknown', fresh.tenant.id))))
|
|
323
|
+
return noPermission();
|
|
324
|
+
if (row.principalId &&
|
|
325
|
+
row.principalClass &&
|
|
326
|
+
!(await guardStaysHeld(tx, fresh.binding, fresh.tenant.id, [policyRole(role)], {
|
|
327
|
+
id: row.principalId,
|
|
328
|
+
class: row.principalClass,
|
|
329
|
+
})))
|
|
330
|
+
return refused('last_owner', 'Appoint another independent owner first.');
|
|
331
|
+
const grants = state.grants.filter((g) => g.id.startsWith(`${id}:`));
|
|
332
|
+
if (!(await Promise.all(grants.map((g) => delegable(tx, fresh, state, g)))).every(Boolean))
|
|
333
|
+
return refused('delegation', 'This assignment is outside your delegation authority.');
|
|
334
|
+
await tx
|
|
335
|
+
.delete(policyAssignments)
|
|
336
|
+
.where(and(eq(policyAssignments.tenantId, fresh.tenant.id), eq(policyAssignments.id, id)));
|
|
337
|
+
await record(tx, fresh, {
|
|
338
|
+
action: 'membership.ended',
|
|
339
|
+
tenantId: fresh.tenant.id,
|
|
340
|
+
targetType: 'app_role_assignment',
|
|
341
|
+
targetId: id,
|
|
342
|
+
before: auditJson(row),
|
|
343
|
+
});
|
|
344
|
+
return done(undefined, [tenantTag(fresh.tenant.id)]);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
export async function deleteAppRole(access, db, scopeColumn, id) {
|
|
348
|
+
const current = await loadAccess(db, access.binding, db, scopeColumn, access.actor, access.tenant.id);
|
|
349
|
+
const role = current?.policyState.roleRows.find((r) => r.id === id);
|
|
350
|
+
if (!role)
|
|
351
|
+
return refused('missing', 'This role no longer exists.');
|
|
352
|
+
return deleteRole(access, db, scopeColumn, { key: role.key });
|
|
353
|
+
}
|
|
354
|
+
const teamInput = z
|
|
355
|
+
.object({
|
|
356
|
+
id: z.uuid().optional(),
|
|
357
|
+
name: z.string().trim().min(1).max(100),
|
|
358
|
+
parentId: z.uuid().nullable().optional(),
|
|
359
|
+
})
|
|
360
|
+
.strict();
|
|
361
|
+
export async function saveTeam(access, db, scopeColumn, input) {
|
|
362
|
+
const parsed = teamInput.safeParse(input);
|
|
363
|
+
if (!parsed.success)
|
|
364
|
+
return refused('invalid_team', 'Choose a name and a valid parent team.');
|
|
365
|
+
return writer(access, db, scopeColumn, async (tx, fresh, state) => {
|
|
366
|
+
const previous = parsed.data.id
|
|
367
|
+
? state.teams.find((t) => t.id === parsed.data.id && !t.archivedAt)
|
|
368
|
+
: undefined;
|
|
369
|
+
const options = {
|
|
370
|
+
...parsed.data,
|
|
371
|
+
parentId: parsed.data.parentId === undefined ? (previous?.parentId ?? null) : parsed.data.parentId,
|
|
372
|
+
};
|
|
373
|
+
if (options.id && !previous)
|
|
374
|
+
return refused('team', 'This team is unavailable.');
|
|
375
|
+
if (options.parentId && !state.teams.some((t) => t.id === options.parentId && !t.archivedAt))
|
|
376
|
+
return refused('parent', 'Choose an active parent team in this organisation.');
|
|
377
|
+
const location = previous?.id ?? options.parentId;
|
|
378
|
+
if (!state.evaluator.allows(previous ? 'teams:update' : 'teams:create', target(fresh, 'team', location ?? fresh.tenant.id, location)))
|
|
379
|
+
return noPermission();
|
|
380
|
+
const moving = previous && previous.parentId !== options.parentId;
|
|
381
|
+
if (moving) {
|
|
382
|
+
if (state.foreignPolicy)
|
|
383
|
+
return refused('foreign_policy', 'A connected application must validate changes to this shared team hierarchy.');
|
|
384
|
+
if (!state.evaluator.allows('teams:update', target(fresh, 'team', fresh.tenant.id)) ||
|
|
385
|
+
!state.evaluator.allows('roles:assign', target(fresh, 'role', fresh.tenant.id)) ||
|
|
386
|
+
!state.grants
|
|
387
|
+
.filter((g) => g.recipient.kind === 'team' ||
|
|
388
|
+
[g.scope, ...(g.constraints ?? [])].some((s) => s.kind !== 'organisation'))
|
|
389
|
+
.every((g) => (g.expiresAt !== undefined && g.expiresAt <= Date.now()) ||
|
|
390
|
+
state.evaluator.canDelegate({
|
|
391
|
+
...g,
|
|
392
|
+
scope: { kind: 'organisation' },
|
|
393
|
+
constraints: [],
|
|
394
|
+
relation: 'any',
|
|
395
|
+
})))
|
|
396
|
+
return refused('delegation', 'Moving teams requires organisation-wide administration and authority over the affected access policies.');
|
|
397
|
+
}
|
|
398
|
+
// Validate the complete prospective tree, including descendants of a moved team.
|
|
399
|
+
const nextId = previous?.id ?? randomUUID();
|
|
400
|
+
const nextTeams = [
|
|
401
|
+
...state.teams
|
|
402
|
+
.filter((t) => t.id !== nextId)
|
|
403
|
+
.map((t) => ({ id: t.id, parentId: t.parentId, organisationId: t.tenantId })),
|
|
404
|
+
{ id: nextId, parentId: options.parentId, organisationId: fresh.tenant.id },
|
|
405
|
+
];
|
|
406
|
+
if (nextTeams.length > 1000)
|
|
407
|
+
return refused('limit', 'This organisation already has 1,000 teams.');
|
|
408
|
+
try {
|
|
409
|
+
resourceAccess({
|
|
410
|
+
applicationId: fresh.binding.applicationId,
|
|
411
|
+
platformId: fresh.binding.platformId,
|
|
412
|
+
organisations: [{ id: fresh.tenant.id, restrictions: restriction(fresh.tenant) }],
|
|
413
|
+
platformRestrictions: {
|
|
414
|
+
kind: 'operator',
|
|
415
|
+
state: 'active',
|
|
416
|
+
ceiling: [],
|
|
417
|
+
selfDenied: [],
|
|
418
|
+
denied: [],
|
|
419
|
+
support: null,
|
|
420
|
+
frozen: fresh.policyState.tenantRows.find((t) => t.kind === 'operator')?.frozen ?? false,
|
|
421
|
+
},
|
|
422
|
+
principal: asPrincipal(fresh),
|
|
423
|
+
catalogue: fresh.binding.catalogue,
|
|
424
|
+
teams: nextTeams,
|
|
425
|
+
memberships: [],
|
|
426
|
+
grants: [],
|
|
427
|
+
now: Date.now(),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return refused('hierarchy', 'A team cannot contain itself or create a hierarchy deeper than 64 teams.');
|
|
432
|
+
}
|
|
433
|
+
if (previous)
|
|
434
|
+
await tx
|
|
435
|
+
.update(accessTeams)
|
|
436
|
+
.set({ name: options.name, parentId: options.parentId })
|
|
437
|
+
.where(and(eq(accessTeams.tenantId, fresh.tenant.id), eq(accessTeams.id, previous.id)));
|
|
438
|
+
else
|
|
439
|
+
await tx.insert(accessTeams).values({
|
|
440
|
+
id: nextId,
|
|
441
|
+
tenantId: fresh.tenant.id,
|
|
442
|
+
name: options.name,
|
|
443
|
+
parentId: options.parentId,
|
|
444
|
+
});
|
|
445
|
+
await record(tx, fresh, {
|
|
446
|
+
action: 'tenant.settings_changed',
|
|
447
|
+
tenantId: fresh.tenant.id,
|
|
448
|
+
teamId: nextId,
|
|
449
|
+
targetType: 'team',
|
|
450
|
+
targetId: nextId,
|
|
451
|
+
before: previous ? { name: previous.name, parentId: previous.parentId } : null,
|
|
452
|
+
after: { name: options.name, parentId: options.parentId },
|
|
453
|
+
});
|
|
454
|
+
return done({ id: nextId }, [tenantTag(fresh.tenant.id)]);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
export async function archiveTeam(access, db, scopeColumn, id) {
|
|
458
|
+
if (!z.uuid().safeParse(id).success)
|
|
459
|
+
return refused('id', 'Invalid team.');
|
|
460
|
+
return writer(access, db, scopeColumn, async (tx, fresh, state) => {
|
|
461
|
+
if (state.foreignPolicy)
|
|
462
|
+
return refused('foreign_policy', 'A connected application must validate changes to this shared team hierarchy.');
|
|
463
|
+
const team = state.teams.find((t) => t.id === id && !t.archivedAt);
|
|
464
|
+
if (!team)
|
|
465
|
+
return refused('team', 'This team is unavailable.');
|
|
466
|
+
if (!state.evaluator.allows('teams:archive', target(fresh, 'team', id, id)))
|
|
467
|
+
return noPermission();
|
|
468
|
+
const inUse = state.roleRows.some((r) => r.entries.some((e) => (e.scope.kind === 'team' && e.scope.teamId === id) ||
|
|
469
|
+
(e.scope.kind === 'resource' &&
|
|
470
|
+
e.scope.resourceType === 'team' &&
|
|
471
|
+
e.scope.resourceId === id))) ||
|
|
472
|
+
state.teams.some((t) => t.parentId === id && !t.archivedAt) ||
|
|
473
|
+
state.participants.some((p) => p.teamId === id) ||
|
|
474
|
+
state.assignmentRows.some((a) => a.teamId === id ||
|
|
475
|
+
(a.scope.kind === 'team' && a.scope.teamId === id) ||
|
|
476
|
+
(a.scope.kind === 'resource' &&
|
|
477
|
+
a.scope.resourceType === 'team' &&
|
|
478
|
+
a.scope.resourceId === id));
|
|
479
|
+
if (inUse)
|
|
480
|
+
return refused('in_use', 'Move child teams and remove this team’s participants and assignments before archiving it.');
|
|
481
|
+
await tx
|
|
482
|
+
.update(accessTeams)
|
|
483
|
+
.set({ archivedAt: new Date() })
|
|
484
|
+
.where(and(eq(accessTeams.tenantId, fresh.tenant.id), eq(accessTeams.id, id)));
|
|
485
|
+
await record(tx, fresh, {
|
|
486
|
+
action: 'tenant.settings_changed',
|
|
487
|
+
tenantId: fresh.tenant.id,
|
|
488
|
+
teamId: id,
|
|
489
|
+
targetType: 'team',
|
|
490
|
+
targetId: id,
|
|
491
|
+
after: { archived: true },
|
|
492
|
+
});
|
|
493
|
+
return done(undefined, [tenantTag(fresh.tenant.id)]);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
export async function changeTeamParticipant(access, db, scopeColumn, input) {
|
|
497
|
+
const parsed = z
|
|
498
|
+
.object({ teamId: z.uuid(), principalId: z.string().min(1).max(256), remove: z.boolean() })
|
|
499
|
+
.strict()
|
|
500
|
+
.safeParse(input);
|
|
501
|
+
if (!parsed.success)
|
|
502
|
+
return refused('input', 'Choose a team and a person.');
|
|
503
|
+
const options = parsed.data;
|
|
504
|
+
return writer(access, db, scopeColumn, async (tx, fresh, state) => {
|
|
505
|
+
if (state.foreignPolicy)
|
|
506
|
+
return refused('foreign_policy', 'A connected application must validate changes to this team’s participants.');
|
|
507
|
+
const person = state.identities.get(options.principalId);
|
|
508
|
+
if (!person ||
|
|
509
|
+
person.class !== 'human' ||
|
|
510
|
+
!state.teams.some((t) => t.id === options.teamId && !t.archivedAt))
|
|
511
|
+
return refused('person', 'Choose an existing person and an active team in this organisation.');
|
|
512
|
+
if (!state.evaluator.allows(options.remove ? 'teams.people:remove' : 'teams.people:add', target(fresh, 'team', options.teamId, options.teamId)))
|
|
513
|
+
return noPermission();
|
|
514
|
+
// Participation changes must cover every assignment whose scope or recipients change.
|
|
515
|
+
const under = (teamId, root) => {
|
|
516
|
+
let cursor = teamId;
|
|
517
|
+
while (cursor !== null) {
|
|
518
|
+
if (cursor === root)
|
|
519
|
+
return true;
|
|
520
|
+
cursor = state.teams.find((t) => t.id === cursor)?.parentId ?? null;
|
|
521
|
+
}
|
|
522
|
+
return false;
|
|
523
|
+
};
|
|
524
|
+
for (const g of state.grants) {
|
|
525
|
+
if (g.expiresAt !== undefined && g.expiresAt <= Date.now())
|
|
526
|
+
continue;
|
|
527
|
+
const receives = g.recipient.kind === 'team'
|
|
528
|
+
? g.recipient.descendants
|
|
529
|
+
? under(options.teamId, g.recipient.teamId)
|
|
530
|
+
: g.recipient.teamId === options.teamId
|
|
531
|
+
: g.recipient.principal.id === person.id &&
|
|
532
|
+
g.recipient.principal.class === person.class &&
|
|
533
|
+
[g.scope, ...(g.constraints ?? [])].some((s) => s.kind === 'own_teams');
|
|
534
|
+
if (!receives)
|
|
535
|
+
continue;
|
|
536
|
+
const proposed = {
|
|
537
|
+
...g,
|
|
538
|
+
recipient: { kind: 'principal', principal: person },
|
|
539
|
+
constraints: g.constraints?.map((s) => s.kind === 'own_teams'
|
|
540
|
+
? { kind: 'team', teamId: options.teamId, descendants: s.descendants }
|
|
541
|
+
: s),
|
|
542
|
+
scope: g.scope.kind === 'own_teams'
|
|
543
|
+
? { kind: 'team', teamId: options.teamId, descendants: g.scope.descendants }
|
|
544
|
+
: g.scope,
|
|
545
|
+
};
|
|
546
|
+
if (!(await delegable(tx, fresh, state, proposed)))
|
|
547
|
+
return refused('delegation', 'This team carries access outside your delegation authority.');
|
|
548
|
+
}
|
|
549
|
+
if (options.remove)
|
|
550
|
+
await tx
|
|
551
|
+
.delete(accessTeamMembers)
|
|
552
|
+
.where(and(eq(accessTeamMembers.tenantId, fresh.tenant.id), eq(accessTeamMembers.teamId, options.teamId), eq(accessTeamMembers.principalId, person.id), eq(accessTeamMembers.principalClass, person.class)));
|
|
553
|
+
else
|
|
554
|
+
await tx
|
|
555
|
+
.insert(accessTeamMembers)
|
|
556
|
+
.values({
|
|
557
|
+
tenantId: fresh.tenant.id,
|
|
558
|
+
teamId: options.teamId,
|
|
559
|
+
principalId: person.id,
|
|
560
|
+
principalClass: person.class,
|
|
561
|
+
})
|
|
562
|
+
.onConflictDoNothing();
|
|
563
|
+
await record(tx, fresh, {
|
|
564
|
+
action: options.remove ? 'membership.ended' : 'membership.created',
|
|
565
|
+
tenantId: fresh.tenant.id,
|
|
566
|
+
teamId: options.teamId,
|
|
567
|
+
subject: person,
|
|
568
|
+
targetType: 'team_participant',
|
|
569
|
+
targetId: person.id,
|
|
570
|
+
after: { teamId: options.teamId, removed: options.remove },
|
|
571
|
+
});
|
|
572
|
+
return done(undefined, [tenantTag(fresh.tenant.id)]);
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
async function delegable(tx, access, state, grant) {
|
|
576
|
+
if (grant.expiresAt !== undefined && grant.expiresAt <= Date.now())
|
|
577
|
+
return true;
|
|
578
|
+
const scope = [grant.scope, ...(grant.constraints ?? [])].find((s) => s.kind === 'resource');
|
|
579
|
+
const resource = scope?.kind === 'resource'
|
|
580
|
+
? await lookupResource(tx, access, scope.resourceType, scope.resourceId)
|
|
581
|
+
: undefined;
|
|
582
|
+
return state.evaluator.canDelegate(grant, resource);
|
|
583
|
+
}
|
|
584
|
+
const lookupResource = (tx, access, type, id) => policyResource(tx, access.binding, access.tenant.id, type, id);
|
|
585
|
+
/** Location AND relation stay paired for each grant, then clauses are ORed inside the tenant boundary. */
|
|
586
|
+
function auditClause(access, clause) {
|
|
587
|
+
if (clause.applicationId !== access.binding.applicationId ||
|
|
588
|
+
clause.platformId !== access.binding.platformId ||
|
|
589
|
+
clause.resourceType !== 'audit' ||
|
|
590
|
+
clause.organisationId !== access.tenant.id)
|
|
591
|
+
return sql `false`;
|
|
592
|
+
const filters = [eq(authzEvents.tenantId, clause.organisationId)];
|
|
593
|
+
if (clause.teamIds !== null)
|
|
594
|
+
filters.push(clause.teamIds.length ? inArray(authzEvents.teamId, [...clause.teamIds]) : sql `false`);
|
|
595
|
+
if (clause.resourceId !== undefined)
|
|
596
|
+
filters.push(/^\d{1,15}$/.test(clause.resourceId)
|
|
597
|
+
? eq(authzEvents.id, Number(clause.resourceId))
|
|
598
|
+
: sql `false`);
|
|
599
|
+
if (clause.relation === 'actor')
|
|
600
|
+
filters.push(eq(authzEvents.actorId, access.actor.id), eq(authzEvents.actorClass, access.actor.class));
|
|
601
|
+
else if (clause.relation === 'subject')
|
|
602
|
+
filters.push(eq(authzEvents.subjectId, access.actor.id), eq(authzEvents.subjectClass, access.actor.class));
|
|
603
|
+
else if (clause.relation !== 'any')
|
|
604
|
+
filters.push(sql `false`);
|
|
605
|
+
return and(...filters) ?? sql `true`;
|
|
606
|
+
}
|
|
607
|
+
export async function scopedAuditLog(access, db, scopeColumn, options = {}) {
|
|
608
|
+
const { evaluator } = await resourceAccessFor(access, db, db, scopeColumn);
|
|
609
|
+
const clauses = evaluator.clauses('platform.audit:read');
|
|
610
|
+
if (!(await auditedReadGate(access, db, 'platform.audit:read', clauses.length > 0)))
|
|
611
|
+
return null;
|
|
612
|
+
if (options.id !== undefined && !/^\d{1,15}$/.test(options.id))
|
|
613
|
+
return null;
|
|
614
|
+
const limit = Number.isFinite(options.limit)
|
|
615
|
+
? Math.max(1, Math.min(100, Math.floor(options.limit ?? 50)))
|
|
616
|
+
: 50;
|
|
617
|
+
const conditions = [
|
|
618
|
+
eq(authzEvents.tenantId, access.tenant.id),
|
|
619
|
+
eq(authzEvents.tenantVisible, true),
|
|
620
|
+
or(...clauses.map((c) => auditClause(access, c))) ?? sql `false`,
|
|
621
|
+
];
|
|
622
|
+
if (options.id)
|
|
623
|
+
conditions.push(eq(authzEvents.id, Number(options.id)));
|
|
624
|
+
if (options.after) {
|
|
625
|
+
if (!Number.isFinite(options.after.occurredAt.getTime()) ||
|
|
626
|
+
!Number.isSafeInteger(options.after.id))
|
|
627
|
+
return null;
|
|
628
|
+
conditions.push(or(lt(authzEvents.occurredAt, options.after.occurredAt), and(eq(authzEvents.occurredAt, options.after.occurredAt), lt(authzEvents.id, options.after.id))) ?? sql `false`);
|
|
629
|
+
}
|
|
630
|
+
// Deliberately minimal projection: no payload, actor identity, target identity, reasons, IP or references.
|
|
631
|
+
const rows = await db
|
|
632
|
+
.select({
|
|
633
|
+
id: authzEvents.id,
|
|
634
|
+
occurredAt: authzEvents.occurredAt,
|
|
635
|
+
action: authzEvents.action,
|
|
636
|
+
outcome: authzEvents.outcome,
|
|
637
|
+
teamId: authzEvents.teamId,
|
|
638
|
+
})
|
|
639
|
+
.from(authzEvents)
|
|
640
|
+
.where(and(...conditions))
|
|
641
|
+
.orderBy(desc(authzEvents.occurredAt), desc(authzEvents.id))
|
|
642
|
+
.limit(limit + 1);
|
|
643
|
+
const items = rows.slice(0, limit);
|
|
644
|
+
const last = items.at(-1);
|
|
645
|
+
// A read of the audit trail is itself audited (0.9.0), like `securityLogFor`.
|
|
646
|
+
await record(db, access, {
|
|
647
|
+
action: 'audit_log.viewed',
|
|
648
|
+
tenantId: access.tenant.id,
|
|
649
|
+
targetType: 'audit',
|
|
650
|
+
targetId: access.tenant.id,
|
|
651
|
+
});
|
|
652
|
+
return {
|
|
653
|
+
items,
|
|
654
|
+
next: rows.length > limit && last ? { occurredAt: last.occurredAt, id: last.id } : null,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom role keys always start with `c_` (enforced at creation, the host's `createRole`); every
|
|
3
|
+
* other key — including a starter role's, whether or not its row is still `built_in` — names an
|
|
4
|
+
* app-defined role. Several modules need "is this key one this app controls" rather than "is this
|
|
5
|
+
* row currently marked built-in", because a starter role's `built_in` flag can differ between an
|
|
6
|
+
* old tenant (`true`) and a new one (`false`) while the key means the same thing in both (ADR
|
|
7
|
+
* 0016). No imports, so any module can depend on it without risking a cycle.
|
|
8
|
+
*/
|
|
9
|
+
export declare const isCustomRoleKey: (key: string) => boolean;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom role keys always start with `c_` (enforced at creation, the host's `createRole`); every
|
|
3
|
+
* other key — including a starter role's, whether or not its row is still `built_in` — names an
|
|
4
|
+
* app-defined role. Several modules need "is this key one this app controls" rather than "is this
|
|
5
|
+
* row currently marked built-in", because a starter role's `built_in` flag can differ between an
|
|
6
|
+
* old tenant (`true`) and a new one (`false`) while the key means the same thing in both (ADR
|
|
7
|
+
* 0016). No imports, so any module can depend on it without risking a cycle.
|
|
8
|
+
*/
|
|
9
|
+
export const isCustomRoleKey = (key) => key.startsWith('c_');
|