@wtfalch/authz-store 0.2.1 → 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/grants.d.ts +2 -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/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 +348 -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 +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,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
|
+
}>>;
|
package/dist/platform.js
ADDED
|
@@ -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
|
+
}
|