@wtfalch/authz-store 0.2.1 → 0.4.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 +393 -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 +135 -0
- package/dist/audit.js +231 -0
- package/dist/binding.d.ts +26 -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/grants.d.ts +2 -0
- package/dist/index.d.ts +34 -1
- package/dist/index.js +34 -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/nesting.d.ts +124 -0
- package/dist/nesting.js +515 -0
- 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 +357 -0
- package/dist/policy-entry.d.ts +11 -0
- package/dist/policy-entry.js +10 -0
- package/dist/policy-resources.d.ts +5 -0
- package/dist/policy-resources.js +46 -0
- package/dist/policy-schema.d.ts +449 -0
- package/dist/policy-schema.js +63 -0
- package/dist/policy.d.ts +71 -0
- package/dist/policy.js +78 -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 +143 -0
- package/package.json +13 -4
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { type ResourceAssignment, type ResourceRestrictions, type ResourceRole } from '@wtfalch/authz';
|
|
2
|
+
import type { StoreBinding } from './binding.js';
|
|
3
|
+
import { policyAssignments, policyRoles } from './policy-schema.js';
|
|
4
|
+
import { type DbOrTx, type Scope } from './scoped.js';
|
|
5
|
+
import type { Access, Principal, TenantRow } from './types.js';
|
|
6
|
+
export declare function policyRole(row: typeof policyRoles.$inferSelect): ResourceRole;
|
|
7
|
+
export declare function policyAssignment(row: typeof policyAssignments.$inferSelect): ResourceAssignment;
|
|
8
|
+
export declare function restriction(tenant: TenantRow, support?: ResourceRestrictions['support']): ResourceRestrictions;
|
|
9
|
+
export declare function policyTarget(access: Pick<Access, 'binding' | 'tenant'>, permission: string, id?: string, platform?: boolean): {
|
|
10
|
+
id: string;
|
|
11
|
+
type: string;
|
|
12
|
+
applicationId: string;
|
|
13
|
+
platformId: string;
|
|
14
|
+
organisationId: string | null;
|
|
15
|
+
teamId: null;
|
|
16
|
+
};
|
|
17
|
+
/** For organisation-wide administrative operations; scoped domain reads pass a real resource to authority.allows. */
|
|
18
|
+
export declare function permits(access: Access, permission: string): boolean;
|
|
19
|
+
export declare function permitsPlatform(access: Access, permission: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The one choke point every read-effect permission's gate runs through
|
|
22
|
+
* (`membersOf`, `credentialsFor`, `allTenants`, `securityLogFor`,
|
|
23
|
+
* `pendingInvitations`, the attach-proposal readers in `nesting.ts`,
|
|
24
|
+
* `scopedAuditLog`), rather than each writing its own `break_glass.viewed`
|
|
25
|
+
* call: when access is granted and the request is inside an open support
|
|
26
|
+
* session, it writes `break_glass.viewed` — with the session's id/reason/
|
|
27
|
+
* reference, via `record`'s existing break-glass auto-fill — so the tenant
|
|
28
|
+
* can see what a session actually read, not only that one was open. A
|
|
29
|
+
* denied read writes nothing here; `deny()` already audits a denied
|
|
30
|
+
* sensitive/depth-0 permission on its own path.
|
|
31
|
+
*
|
|
32
|
+
* `db` is the host's root handle, passed through to `record`, rather than
|
|
33
|
+
* imported: the store has no database of its own to reach for.
|
|
34
|
+
*
|
|
35
|
+
* Dynamic import of `./audit.js`, not a static one: `audit.ts` imports
|
|
36
|
+
* `permitsRead` from this module, so a static import the other way would be
|
|
37
|
+
* a circular one evaluated at module-load time instead of call time.
|
|
38
|
+
*/
|
|
39
|
+
export declare function auditedReadGate(access: Access, db: DbOrTx, permission: string, allowed: boolean): Promise<boolean>;
|
|
40
|
+
/** `permits`, audited under an open support session. Use for every org-scoped read gate. */
|
|
41
|
+
export declare function permitsRead(access: Access, db: DbOrTx, permission: string): Promise<boolean>;
|
|
42
|
+
/** `permitsPlatform`, audited under an open support session. Use for every platform-scoped read gate. */
|
|
43
|
+
export declare function permitsPlatformRead(access: Access, db: DbOrTx, permission: string): Promise<boolean>;
|
|
44
|
+
export declare function requirePolicySchema(tx: DbOrTx, binding: StoreBinding): Promise<void>;
|
|
45
|
+
export declare function loadPolicyState(tx: DbOrTx, binding: StoreBinding, tenantId: string): Promise<{
|
|
46
|
+
teams: {
|
|
47
|
+
id: string;
|
|
48
|
+
tenantId: string;
|
|
49
|
+
parentId: string | null;
|
|
50
|
+
name: string;
|
|
51
|
+
archivedAt: Date | null;
|
|
52
|
+
createdAt: Date;
|
|
53
|
+
}[];
|
|
54
|
+
participants: {
|
|
55
|
+
tenantId: string;
|
|
56
|
+
teamId: string;
|
|
57
|
+
principalId: string;
|
|
58
|
+
principalClass: string;
|
|
59
|
+
}[];
|
|
60
|
+
roleRows: {
|
|
61
|
+
id: string;
|
|
62
|
+
tenantId: string;
|
|
63
|
+
applicationId: string;
|
|
64
|
+
platformId: string;
|
|
65
|
+
key: string;
|
|
66
|
+
name: string;
|
|
67
|
+
description: string;
|
|
68
|
+
builtIn: boolean;
|
|
69
|
+
revision: number;
|
|
70
|
+
entries: {
|
|
71
|
+
permission: string;
|
|
72
|
+
boundary: {
|
|
73
|
+
kind: "organisation";
|
|
74
|
+
organisationId: string;
|
|
75
|
+
descendants?: boolean | undefined;
|
|
76
|
+
} | {
|
|
77
|
+
kind: "organisations";
|
|
78
|
+
organisationIds: string[];
|
|
79
|
+
} | {
|
|
80
|
+
kind: "platform";
|
|
81
|
+
};
|
|
82
|
+
scope: {
|
|
83
|
+
kind: "organisation";
|
|
84
|
+
} | {
|
|
85
|
+
kind: "team";
|
|
86
|
+
teamId: string;
|
|
87
|
+
descendants: boolean;
|
|
88
|
+
} | {
|
|
89
|
+
kind: "own_teams";
|
|
90
|
+
descendants: boolean;
|
|
91
|
+
} | {
|
|
92
|
+
kind: "resource";
|
|
93
|
+
resourceType: string;
|
|
94
|
+
resourceId: string;
|
|
95
|
+
};
|
|
96
|
+
relation: "any" | "owner" | "actor" | "subject";
|
|
97
|
+
}[];
|
|
98
|
+
guards: string[];
|
|
99
|
+
createdBy: string | null;
|
|
100
|
+
createdAt: Date;
|
|
101
|
+
updatedAt: Date;
|
|
102
|
+
updatedBy: string | null;
|
|
103
|
+
}[];
|
|
104
|
+
assignmentRows: {
|
|
105
|
+
id: string;
|
|
106
|
+
tenantId: string;
|
|
107
|
+
applicationId: string;
|
|
108
|
+
platformId: string;
|
|
109
|
+
roleId: string;
|
|
110
|
+
principalId: string | null;
|
|
111
|
+
principalClass: string | null;
|
|
112
|
+
teamId: string | null;
|
|
113
|
+
recipientDescendants: boolean;
|
|
114
|
+
boundary: {
|
|
115
|
+
kind: "organisation";
|
|
116
|
+
organisationId: string;
|
|
117
|
+
descendants?: boolean | undefined;
|
|
118
|
+
} | {
|
|
119
|
+
kind: "organisations";
|
|
120
|
+
organisationIds: string[];
|
|
121
|
+
} | {
|
|
122
|
+
kind: "platform";
|
|
123
|
+
};
|
|
124
|
+
scope: {
|
|
125
|
+
kind: "organisation";
|
|
126
|
+
} | {
|
|
127
|
+
kind: "team";
|
|
128
|
+
teamId: string;
|
|
129
|
+
descendants: boolean;
|
|
130
|
+
} | {
|
|
131
|
+
kind: "own_teams";
|
|
132
|
+
descendants: boolean;
|
|
133
|
+
} | {
|
|
134
|
+
kind: "resource";
|
|
135
|
+
resourceType: string;
|
|
136
|
+
resourceId: string;
|
|
137
|
+
};
|
|
138
|
+
source: string;
|
|
139
|
+
viaTenantId: string | null;
|
|
140
|
+
primary: boolean;
|
|
141
|
+
expiresAt: Date | null;
|
|
142
|
+
createdBy: string | null;
|
|
143
|
+
createdAt: Date;
|
|
144
|
+
}[];
|
|
145
|
+
tenantRows: {
|
|
146
|
+
id: string;
|
|
147
|
+
kind: import("@wtfalch/authz").TenantKind;
|
|
148
|
+
slug: string;
|
|
149
|
+
name: string;
|
|
150
|
+
state: import("@wtfalch/authz").TenantState;
|
|
151
|
+
parentId: string | null;
|
|
152
|
+
ceiling: string[];
|
|
153
|
+
selfDenied: string[];
|
|
154
|
+
frozen: boolean;
|
|
155
|
+
createdBy: string | null;
|
|
156
|
+
createdAt: Date;
|
|
157
|
+
updatedAt: Date;
|
|
158
|
+
}[];
|
|
159
|
+
memberRows: {
|
|
160
|
+
tenantId: string;
|
|
161
|
+
principalId: string;
|
|
162
|
+
principalClass: string;
|
|
163
|
+
source: string;
|
|
164
|
+
viaTenantId: string | null;
|
|
165
|
+
tenantName: string;
|
|
166
|
+
grantedBy: string | null;
|
|
167
|
+
createdAt: Date;
|
|
168
|
+
updatedAt: Date;
|
|
169
|
+
}[];
|
|
170
|
+
grants: {
|
|
171
|
+
id: string;
|
|
172
|
+
applicationId: string;
|
|
173
|
+
platformId: string;
|
|
174
|
+
boundary: {
|
|
175
|
+
kind: "organisation";
|
|
176
|
+
organisationId: string;
|
|
177
|
+
descendants?: boolean | undefined;
|
|
178
|
+
} | {
|
|
179
|
+
kind: "organisations";
|
|
180
|
+
organisationIds: string[];
|
|
181
|
+
} | {
|
|
182
|
+
kind: "platform";
|
|
183
|
+
};
|
|
184
|
+
permission: string;
|
|
185
|
+
recipient: {
|
|
186
|
+
kind: "principal";
|
|
187
|
+
principal: {
|
|
188
|
+
id: string;
|
|
189
|
+
class: "human" | "api_key" | "agent" | "service";
|
|
190
|
+
};
|
|
191
|
+
} | {
|
|
192
|
+
kind: "team";
|
|
193
|
+
teamId: string;
|
|
194
|
+
descendants: boolean;
|
|
195
|
+
};
|
|
196
|
+
scope: {
|
|
197
|
+
kind: "organisation";
|
|
198
|
+
} | {
|
|
199
|
+
kind: "team";
|
|
200
|
+
teamId: string;
|
|
201
|
+
descendants: boolean;
|
|
202
|
+
} | {
|
|
203
|
+
kind: "own_teams";
|
|
204
|
+
descendants: boolean;
|
|
205
|
+
} | {
|
|
206
|
+
kind: "resource";
|
|
207
|
+
resourceType: string;
|
|
208
|
+
resourceId: string;
|
|
209
|
+
};
|
|
210
|
+
relation: "any" | "owner" | "actor" | "subject";
|
|
211
|
+
constraints?: ({
|
|
212
|
+
kind: "organisation";
|
|
213
|
+
} | {
|
|
214
|
+
kind: "team";
|
|
215
|
+
teamId: string;
|
|
216
|
+
descendants: boolean;
|
|
217
|
+
} | {
|
|
218
|
+
kind: "own_teams";
|
|
219
|
+
descendants: boolean;
|
|
220
|
+
} | {
|
|
221
|
+
kind: "resource";
|
|
222
|
+
resourceType: string;
|
|
223
|
+
resourceId: string;
|
|
224
|
+
})[] | undefined;
|
|
225
|
+
startsAt?: number | undefined;
|
|
226
|
+
expiresAt?: number | undefined;
|
|
227
|
+
conditions?: ({
|
|
228
|
+
kind: "attribute";
|
|
229
|
+
attribute: string;
|
|
230
|
+
in: string[];
|
|
231
|
+
} | {
|
|
232
|
+
kind: "schedule";
|
|
233
|
+
timeZone: string;
|
|
234
|
+
windows: {
|
|
235
|
+
days: ("mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun")[];
|
|
236
|
+
from: string;
|
|
237
|
+
to: string;
|
|
238
|
+
}[];
|
|
239
|
+
})[] | undefined;
|
|
240
|
+
guest?: true | undefined;
|
|
241
|
+
budget?: {
|
|
242
|
+
limit: number;
|
|
243
|
+
windowSeconds: number;
|
|
244
|
+
} | undefined;
|
|
245
|
+
}[];
|
|
246
|
+
}>;
|
|
247
|
+
export type PolicyState = Awaited<ReturnType<typeof loadPolicyState>>;
|
|
248
|
+
/**
|
|
249
|
+
* Authoritative loader shared by reads and locked writes. No membership role field is consulted.
|
|
250
|
+
*
|
|
251
|
+
* `db` and `scopeColumn` are separate from `tx`: the returned `Access.db` is always opened
|
|
252
|
+
* against `db` (the host's root handle), never against `tx`, even when `tx` is an open
|
|
253
|
+
* transaction a caller passed in for a fresher read — the same split the host's own copy made.
|
|
254
|
+
* `scopeColumn` is the host's tenant-table registry turned into a `Scope['column']` function
|
|
255
|
+
* (`scopeColumnOf(TENANT_TABLES)` in a host); the registry itself stays in the host because it
|
|
256
|
+
* grows with every feature table a host adds.
|
|
257
|
+
*/
|
|
258
|
+
export declare function loadAccess(tx: DbOrTx, binding: StoreBinding, db: DbOrTx, scopeColumn: Scope['column'], principal: Principal, tenantId: string, allowSupport?: boolean, acceptingInvitation?: boolean): Promise<Access | null>;
|
|
259
|
+
/** Must be called inside the same transaction as the mutation. */
|
|
260
|
+
export declare function refreshAccess(tx: DbOrTx, db: DbOrTx, scopeColumn: Scope['column'], access: Access): Promise<Access | null>;
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { compileRoleGrants, credentialAccess, resourceAccess, } from '@wtfalch/authz';
|
|
2
|
+
import { and, eq, gt, isNull } from 'drizzle-orm';
|
|
3
|
+
import { policyResource } from './policy-resources.js';
|
|
4
|
+
import { policyAssignments, policyRoles, policyVersion } from './policy-schema.js';
|
|
5
|
+
import { accessTeamMembers, accessTeams } from './resource-schema.js';
|
|
6
|
+
import { isCustomRoleKey } from './role-keys.js';
|
|
7
|
+
import { breakGlassSessions, credentials, memberships, tenants } from './schema.js';
|
|
8
|
+
import { openScopedDb } from './scoped.js';
|
|
9
|
+
export function policyRole(row) {
|
|
10
|
+
return {
|
|
11
|
+
id: row.id,
|
|
12
|
+
key: row.key,
|
|
13
|
+
applicationId: row.applicationId,
|
|
14
|
+
platformId: row.platformId,
|
|
15
|
+
organisationId: row.tenantId,
|
|
16
|
+
label: row.name,
|
|
17
|
+
description: row.description || row.name,
|
|
18
|
+
revision: row.revision,
|
|
19
|
+
builtIn: row.builtIn,
|
|
20
|
+
entries: row.entries,
|
|
21
|
+
guards: row.guards,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function policyAssignment(row) {
|
|
25
|
+
if (row.principalId && !row.principalClass)
|
|
26
|
+
throw new Error('Untyped assignment');
|
|
27
|
+
return {
|
|
28
|
+
id: row.id,
|
|
29
|
+
roleId: row.roleId,
|
|
30
|
+
applicationId: row.applicationId,
|
|
31
|
+
platformId: row.platformId,
|
|
32
|
+
organisationId: row.tenantId,
|
|
33
|
+
boundary: row.boundary,
|
|
34
|
+
scope: row.scope,
|
|
35
|
+
recipient: row.principalId
|
|
36
|
+
? {
|
|
37
|
+
kind: 'principal',
|
|
38
|
+
principal: {
|
|
39
|
+
id: row.principalId,
|
|
40
|
+
class: row.principalClass,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
: { kind: 'team', teamId: row.teamId, descendants: row.recipientDescendants },
|
|
44
|
+
...(row.expiresAt ? { expiresAt: row.expiresAt.getTime() } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function restriction(tenant, support = null) {
|
|
48
|
+
return {
|
|
49
|
+
kind: tenant.kind,
|
|
50
|
+
state: tenant.state,
|
|
51
|
+
ceiling: [...tenant.ceiling],
|
|
52
|
+
selfDenied: [...tenant.selfDenied],
|
|
53
|
+
denied: [],
|
|
54
|
+
support,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function policyTarget(access, permission, id = access.tenant.id, platform = false) {
|
|
58
|
+
const def = access.binding.catalogue[permission];
|
|
59
|
+
return {
|
|
60
|
+
id,
|
|
61
|
+
type: def?.resourceType ?? 'unknown',
|
|
62
|
+
applicationId: access.binding.applicationId,
|
|
63
|
+
platformId: access.binding.platformId,
|
|
64
|
+
organisationId: platform ? null : access.tenant.id,
|
|
65
|
+
teamId: null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** For organisation-wide administrative operations; scoped domain reads pass a real resource to authority.allows. */
|
|
69
|
+
export function permits(access, permission) {
|
|
70
|
+
return access.authority
|
|
71
|
+
.clauses(permission)
|
|
72
|
+
.some((c) => c.applicationId === access.binding.applicationId &&
|
|
73
|
+
c.platformId === access.binding.platformId &&
|
|
74
|
+
c.organisationId === access.tenant.id &&
|
|
75
|
+
c.resourceType === access.binding.catalogue[permission]?.resourceType &&
|
|
76
|
+
c.teamIds === null &&
|
|
77
|
+
c.resourceId === undefined &&
|
|
78
|
+
c.relation === 'any');
|
|
79
|
+
}
|
|
80
|
+
export function permitsPlatform(access, permission) {
|
|
81
|
+
return access.authority
|
|
82
|
+
.clauses(permission)
|
|
83
|
+
.some((c) => c.applicationId === access.binding.applicationId &&
|
|
84
|
+
c.platformId === access.binding.platformId &&
|
|
85
|
+
c.organisationId === null &&
|
|
86
|
+
c.resourceType === access.binding.catalogue[permission]?.resourceType &&
|
|
87
|
+
c.teamIds === null &&
|
|
88
|
+
c.resourceId === undefined &&
|
|
89
|
+
c.relation === 'any');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The one choke point every read-effect permission's gate runs through
|
|
93
|
+
* (`membersOf`, `credentialsFor`, `allTenants`, `securityLogFor`,
|
|
94
|
+
* `pendingInvitations`, the attach-proposal readers in `nesting.ts`,
|
|
95
|
+
* `scopedAuditLog`), rather than each writing its own `break_glass.viewed`
|
|
96
|
+
* call: when access is granted and the request is inside an open support
|
|
97
|
+
* session, it writes `break_glass.viewed` — with the session's id/reason/
|
|
98
|
+
* reference, via `record`'s existing break-glass auto-fill — so the tenant
|
|
99
|
+
* can see what a session actually read, not only that one was open. A
|
|
100
|
+
* denied read writes nothing here; `deny()` already audits a denied
|
|
101
|
+
* sensitive/depth-0 permission on its own path.
|
|
102
|
+
*
|
|
103
|
+
* `db` is the host's root handle, passed through to `record`, rather than
|
|
104
|
+
* imported: the store has no database of its own to reach for.
|
|
105
|
+
*
|
|
106
|
+
* Dynamic import of `./audit.js`, not a static one: `audit.ts` imports
|
|
107
|
+
* `permitsRead` from this module, so a static import the other way would be
|
|
108
|
+
* a circular one evaluated at module-load time instead of call time.
|
|
109
|
+
*/
|
|
110
|
+
export async function auditedReadGate(access, db, permission, allowed) {
|
|
111
|
+
if (allowed && access.context === 'break_glass') {
|
|
112
|
+
const { record } = await import('./audit.js');
|
|
113
|
+
await record(db, access, {
|
|
114
|
+
action: 'break_glass.viewed',
|
|
115
|
+
tenantId: access.tenant.id,
|
|
116
|
+
targetType: 'permission',
|
|
117
|
+
targetId: permission,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return allowed;
|
|
121
|
+
}
|
|
122
|
+
/** `permits`, audited under an open support session. Use for every org-scoped read gate. */
|
|
123
|
+
export async function permitsRead(access, db, permission) {
|
|
124
|
+
return auditedReadGate(access, db, permission, permits(access, permission));
|
|
125
|
+
}
|
|
126
|
+
/** `permitsPlatform`, audited under an open support session. Use for every platform-scoped read gate. */
|
|
127
|
+
export async function permitsPlatformRead(access, db, permission) {
|
|
128
|
+
return auditedReadGate(access, db, permission, permitsPlatform(access, permission));
|
|
129
|
+
}
|
|
130
|
+
export async function requirePolicySchema(tx, binding) {
|
|
131
|
+
const [version] = await tx.select().from(policyVersion).where(eq(policyVersion.id, 1));
|
|
132
|
+
if (version?.version !== 2 ||
|
|
133
|
+
version.applicationId !== binding.applicationId ||
|
|
134
|
+
version.platformId !== binding.platformId)
|
|
135
|
+
throw new Error('This application requires the completed authz v2 maintenance migration');
|
|
136
|
+
}
|
|
137
|
+
export async function loadPolicyState(tx, binding, tenantId) {
|
|
138
|
+
const [teamRows, participants, roleRows, assignmentRows, tenantRows, memberRows] = await Promise.all([
|
|
139
|
+
tx.select().from(accessTeams),
|
|
140
|
+
tx.select().from(accessTeamMembers),
|
|
141
|
+
tx
|
|
142
|
+
.select()
|
|
143
|
+
.from(policyRoles)
|
|
144
|
+
.where(and(eq(policyRoles.tenantId, tenantId), eq(policyRoles.applicationId, binding.applicationId), eq(policyRoles.platformId, binding.platformId))),
|
|
145
|
+
tx
|
|
146
|
+
.select()
|
|
147
|
+
.from(policyAssignments)
|
|
148
|
+
.where(and(eq(policyAssignments.tenantId, tenantId), eq(policyAssignments.applicationId, binding.applicationId), eq(policyAssignments.platformId, binding.platformId))),
|
|
149
|
+
tx.select().from(tenants),
|
|
150
|
+
tx.select().from(memberships).where(eq(memberships.tenantId, tenantId)),
|
|
151
|
+
]);
|
|
152
|
+
const roleMap = new Map(roleRows.map((r) => [r.id, r]));
|
|
153
|
+
const grants = [];
|
|
154
|
+
for (const a of assignmentRows) {
|
|
155
|
+
const r = roleMap.get(a.roleId);
|
|
156
|
+
if (!r)
|
|
157
|
+
throw new Error('Orphaned role assignment');
|
|
158
|
+
grants.push(...compileRoleGrants(policyRole(r), policyAssignment(a), binding.catalogue));
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
teams: teamRows,
|
|
162
|
+
participants,
|
|
163
|
+
roleRows,
|
|
164
|
+
assignmentRows,
|
|
165
|
+
tenantRows,
|
|
166
|
+
memberRows,
|
|
167
|
+
grants,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Authoritative loader shared by reads and locked writes. No membership role field is consulted.
|
|
172
|
+
*
|
|
173
|
+
* `db` and `scopeColumn` are separate from `tx`: the returned `Access.db` is always opened
|
|
174
|
+
* against `db` (the host's root handle), never against `tx`, even when `tx` is an open
|
|
175
|
+
* transaction a caller passed in for a fresher read — the same split the host's own copy made.
|
|
176
|
+
* `scopeColumn` is the host's tenant-table registry turned into a `Scope['column']` function
|
|
177
|
+
* (`scopeColumnOf(TENANT_TABLES)` in a host); the registry itself stays in the host because it
|
|
178
|
+
* grows with every feature table a host adds.
|
|
179
|
+
*/
|
|
180
|
+
export async function loadAccess(tx, binding, db, scopeColumn, principal, tenantId, allowSupport = true, acceptingInvitation = false) {
|
|
181
|
+
await requirePolicySchema(tx, binding);
|
|
182
|
+
const state = await loadPolicyState(tx, binding, tenantId);
|
|
183
|
+
const tenant = state.tenantRows.find((t) => t.id === tenantId);
|
|
184
|
+
if (!tenant)
|
|
185
|
+
return null;
|
|
186
|
+
const member = state.memberRows.find((m) => m.principalId === principal.id && m.principalClass === principal.class);
|
|
187
|
+
let support = null;
|
|
188
|
+
let grants = state.grants;
|
|
189
|
+
let role = state.roleRows.find((r) => state.assignmentRows.some((a) => a.primary &&
|
|
190
|
+
(!a.expiresAt || a.expiresAt.getTime() > Date.now()) &&
|
|
191
|
+
a.roleId === r.id &&
|
|
192
|
+
a.principalId === principal.id &&
|
|
193
|
+
a.principalClass === principal.class))?.key ?? '';
|
|
194
|
+
if (!member) {
|
|
195
|
+
if (!allowSupport || principal.class !== 'human')
|
|
196
|
+
return null;
|
|
197
|
+
const [session] = await tx
|
|
198
|
+
.select()
|
|
199
|
+
.from(breakGlassSessions)
|
|
200
|
+
.where(and(eq(breakGlassSessions.operatorId, principal.id), eq(breakGlassSessions.tenantId, tenantId), isNull(breakGlassSessions.endedAt), gt(breakGlassSessions.expiresAt, new Date())))
|
|
201
|
+
.limit(1);
|
|
202
|
+
if (!session)
|
|
203
|
+
return null;
|
|
204
|
+
const operator = state.tenantRows.find((t) => t.kind === 'operator');
|
|
205
|
+
if (!operator)
|
|
206
|
+
return null;
|
|
207
|
+
const operatorAccess = await loadAccess(tx, binding, db, scopeColumn, principal, operator.id, false);
|
|
208
|
+
if (!operatorAccess || !permits(operatorAccess, 'break_glass:start'))
|
|
209
|
+
return null;
|
|
210
|
+
// Not `r.builtIn`: a starter role (ADR 0016) is `built_in: false` for a
|
|
211
|
+
// tenant created after that change but must still be honoured here, or a
|
|
212
|
+
// session started against it would read as immediately invalid.
|
|
213
|
+
const supportRole = state.roleRows.find((r) => r.id === session.roleId && !isCustomRoleKey(r.key));
|
|
214
|
+
if (!supportRole)
|
|
215
|
+
return null;
|
|
216
|
+
support = {
|
|
217
|
+
id: session.id,
|
|
218
|
+
expiresAt: session.expiresAt,
|
|
219
|
+
readOnly: session.readOnly || !permits(operatorAccess, 'break_glass:start-write'),
|
|
220
|
+
reason: session.reasonCode,
|
|
221
|
+
reference: session.reference,
|
|
222
|
+
};
|
|
223
|
+
role = supportRole.key;
|
|
224
|
+
grants = [
|
|
225
|
+
...compileRoleGrants(policyRole(supportRole), {
|
|
226
|
+
id: session.id,
|
|
227
|
+
roleId: supportRole.id,
|
|
228
|
+
applicationId: binding.applicationId,
|
|
229
|
+
platformId: binding.platformId,
|
|
230
|
+
organisationId: tenantId,
|
|
231
|
+
boundary: { kind: 'organisation', organisationId: tenantId },
|
|
232
|
+
recipient: { kind: 'principal', principal: { id: principal.id, class: principal.class } },
|
|
233
|
+
expiresAt: session.expiresAt.getTime(),
|
|
234
|
+
}, binding.catalogue),
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
else if (tenant.state === 'archived')
|
|
238
|
+
return null;
|
|
239
|
+
const operatorTenant = state.tenantRows.find((t) => t.kind === 'operator');
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
const input = {
|
|
242
|
+
applicationId: binding.applicationId,
|
|
243
|
+
platformId: binding.platformId,
|
|
244
|
+
principal: { id: principal.id, class: principal.class },
|
|
245
|
+
catalogue: binding.catalogue,
|
|
246
|
+
now,
|
|
247
|
+
grants,
|
|
248
|
+
organisations: state.tenantRows.map((t) => ({
|
|
249
|
+
id: t.id,
|
|
250
|
+
restrictions: restriction((acceptingInvitation && !allowSupport && t.id === tenantId && t.state === 'read_only'
|
|
251
|
+
? { ...t, state: 'active' }
|
|
252
|
+
: t), t.id === tenantId && support
|
|
253
|
+
? { id: support.id, expiresAt: support.expiresAt.getTime(), readOnly: support.readOnly }
|
|
254
|
+
: null),
|
|
255
|
+
})),
|
|
256
|
+
platformRestrictions: {
|
|
257
|
+
kind: 'operator',
|
|
258
|
+
state: 'active',
|
|
259
|
+
ceiling: [],
|
|
260
|
+
selfDenied: [],
|
|
261
|
+
denied: [],
|
|
262
|
+
support: null,
|
|
263
|
+
frozen: operatorTenant?.frozen ?? false,
|
|
264
|
+
},
|
|
265
|
+
teams: state.teams.map((t) => ({ id: t.id, organisationId: t.tenantId, parentId: t.parentId })),
|
|
266
|
+
memberships: state.participants.map((p) => ({
|
|
267
|
+
teamId: p.teamId,
|
|
268
|
+
principal: { id: p.principalId, class: p.principalClass },
|
|
269
|
+
})),
|
|
270
|
+
};
|
|
271
|
+
let authority = resourceAccess(input);
|
|
272
|
+
if (principal.class !== 'human') {
|
|
273
|
+
const links = [];
|
|
274
|
+
const visited = new Set();
|
|
275
|
+
let cursor = { id: principal.id, class: principal.class };
|
|
276
|
+
while (cursor.class !== 'human') {
|
|
277
|
+
if (visited.has(`${cursor.class}:${cursor.id}`) || visited.size >= 32)
|
|
278
|
+
return null;
|
|
279
|
+
visited.add(`${cursor.class}:${cursor.id}`);
|
|
280
|
+
// Narrow to the columns this walk reads: a service-credential chain
|
|
281
|
+
// never needs `secretHash`/`secretPrefix`/`keysIssuedId`, and a host
|
|
282
|
+
// that dropped those columns (files, ai) would otherwise fail every
|
|
283
|
+
// request through this path with "column does not exist".
|
|
284
|
+
const [credential] = await tx
|
|
285
|
+
.select({
|
|
286
|
+
issuerId: credentials.issuerId,
|
|
287
|
+
issuerClass: credentials.issuerClass,
|
|
288
|
+
expiresAt: credentials.expiresAt,
|
|
289
|
+
revokedAt: credentials.revokedAt,
|
|
290
|
+
})
|
|
291
|
+
.from(credentials)
|
|
292
|
+
.where(and(eq(credentials.id, cursor.id), eq(credentials.kind, cursor.class)))
|
|
293
|
+
.limit(1);
|
|
294
|
+
if (!credential?.issuerId || !credential.issuerClass)
|
|
295
|
+
return null;
|
|
296
|
+
const issuer = {
|
|
297
|
+
id: credential.issuerId,
|
|
298
|
+
class: credential.issuerClass,
|
|
299
|
+
};
|
|
300
|
+
links.unshift({
|
|
301
|
+
principal: cursor,
|
|
302
|
+
issuer,
|
|
303
|
+
expiresAt: credential.expiresAt?.getTime() ?? Number.MAX_SAFE_INTEGER,
|
|
304
|
+
revokedAt: credential.revokedAt?.getTime() ?? null,
|
|
305
|
+
grants: grants.filter((g) => g.recipient.kind === 'principal' &&
|
|
306
|
+
g.recipient.principal.id === cursor.id &&
|
|
307
|
+
g.recipient.principal.class === cursor.class),
|
|
308
|
+
});
|
|
309
|
+
cursor = issuer;
|
|
310
|
+
}
|
|
311
|
+
const issuerMember = state.memberRows.some((m) => m.principalId === cursor.id && m.principalClass === cursor.class);
|
|
312
|
+
const resources = new Map();
|
|
313
|
+
for (const grant of links.flatMap((l) => l.grants))
|
|
314
|
+
for (const scope of [grant.scope, ...(grant.constraints ?? [])]) {
|
|
315
|
+
if (scope.kind !== 'resource')
|
|
316
|
+
continue;
|
|
317
|
+
const key = `${scope.resourceType}:${scope.resourceId}`;
|
|
318
|
+
if (!resources.has(key)) {
|
|
319
|
+
const resource = await policyResource(tx, binding, tenantId, scope.resourceType, scope.resourceId);
|
|
320
|
+
if (resource)
|
|
321
|
+
resources.set(key, resource);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
authority = credentialAccess({
|
|
325
|
+
root: { ...input, principal: cursor, grants: issuerMember ? grants : [] },
|
|
326
|
+
chain: links,
|
|
327
|
+
resources,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
const readOnly = tenant.state !== 'active' || support?.readOnly === true;
|
|
331
|
+
return {
|
|
332
|
+
actor: principal,
|
|
333
|
+
tenant,
|
|
334
|
+
role,
|
|
335
|
+
authority,
|
|
336
|
+
policyState: state,
|
|
337
|
+
context: support ? 'break_glass' : tenant.kind === 'operator' ? 'operator' : 'standard',
|
|
338
|
+
breakGlass: support,
|
|
339
|
+
readOnly,
|
|
340
|
+
db: openScopedDb(db, {
|
|
341
|
+
column: scopeColumn,
|
|
342
|
+
value: tenantId,
|
|
343
|
+
readOnly,
|
|
344
|
+
label: 'tenant',
|
|
345
|
+
}),
|
|
346
|
+
binding,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/** Must be called inside the same transaction as the mutation. */
|
|
350
|
+
export async function refreshAccess(tx, db, scopeColumn, access) {
|
|
351
|
+
await tx
|
|
352
|
+
.select({ id: tenants.id })
|
|
353
|
+
.from(tenants)
|
|
354
|
+
.where(eq(tenants.id, access.tenant.id))
|
|
355
|
+
.for('update');
|
|
356
|
+
return loadAccess(tx, access.binding, db, scopeColumn, access.actor, access.tenant.id);
|
|
357
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@wtfalch/authz-store/policy` — a client-safe subpath for `definePolicy` and the types its
|
|
3
|
+
* signature needs. The main entry (`.`) re-exports `migrateStore`, which imports
|
|
4
|
+
* `node:fs/promises`, so a client component that only reads the policy catalogue for permission
|
|
5
|
+
* labels cannot import from `.` without pulling Node into a production browser build
|
|
6
|
+
* (https://github.com/wtfalch/boule/pull/162). Nothing this module imports, directly or
|
|
7
|
+
* transitively, may import a `node:` module, `drizzle-orm`, or a database handle — proved by
|
|
8
|
+
* `scripts/tests/package-contract.test.mjs`.
|
|
9
|
+
*/
|
|
10
|
+
export { definePolicy, type PolicyData, type PolicyRoleTemplate, type StorePolicy, } from './policy.js';
|
|
11
|
+
export type { StoreBinding } from './binding.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@wtfalch/authz-store/policy` — a client-safe subpath for `definePolicy` and the types its
|
|
3
|
+
* signature needs. The main entry (`.`) re-exports `migrateStore`, which imports
|
|
4
|
+
* `node:fs/promises`, so a client component that only reads the policy catalogue for permission
|
|
5
|
+
* labels cannot import from `.` without pulling Node into a production browser build
|
|
6
|
+
* (https://github.com/wtfalch/boule/pull/162). Nothing this module imports, directly or
|
|
7
|
+
* transitively, may import a `node:` module, `drizzle-orm`, or a database handle — proved by
|
|
8
|
+
* `scripts/tests/package-contract.test.mjs`.
|
|
9
|
+
*/
|
|
10
|
+
export { definePolicy, } from './policy.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AccessResource } from '@wtfalch/authz';
|
|
2
|
+
import type { StoreBinding } from './binding.js';
|
|
3
|
+
import type { DbOrTx } from './scoped.js';
|
|
4
|
+
/** Authoritative resource metadata, shared by delegation and credential lineage evaluation. */
|
|
5
|
+
export declare function policyResource(tx: DbOrTx, binding: StoreBinding, organisationId: string, type: string, id: string): Promise<AccessResource | undefined>;
|