@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
package/dist/alerts.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { and, eq, gte, sql } from 'drizzle-orm';
|
|
2
|
+
import { orphanedRoleKeys } from './boot.js';
|
|
3
|
+
import { reconcileDerived } from './reconcile.js';
|
|
4
|
+
import { breakGlassSessions, invitations } from './schema.js';
|
|
5
|
+
/** D9's first alert: D19's drift check found a row the tenant tree no longer calls for. */
|
|
6
|
+
export async function checkOrphanMemberships(handle, binding) {
|
|
7
|
+
const report = await reconcileDerived(handle, binding);
|
|
8
|
+
if (report.orphans.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
const detail = report.orphans;
|
|
11
|
+
return {
|
|
12
|
+
check: 'orphan_memberships',
|
|
13
|
+
message: `${detail.length} derived membership row(s) are no longer called for by the tenant tree.`,
|
|
14
|
+
detail,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const STUCK_MAIL_THRESHOLD_MS = 60 * 60 * 1000;
|
|
18
|
+
/**
|
|
19
|
+
* D9's second alert: an invitation stuck with failed mail for more than an
|
|
20
|
+
* hour. `invitations` carries no separate "mail state last changed at"
|
|
21
|
+
* column beyond `mail_state_at` (0001), only `created_at` for a row that
|
|
22
|
+
* predates it, and a re-invite rotates the token on the same row rather than
|
|
23
|
+
* creating a new one (`invitations.ts`'s `resendInvitation` calls straight
|
|
24
|
+
* back through `invite`), so a resend that fails again does not reset this
|
|
25
|
+
* clock; that is a real gap in what this table can say and not something
|
|
26
|
+
* this check can paper over. `created_at` is what a pre-0001 row has, and
|
|
27
|
+
* for a row that has never once sent successfully (the common failure, a bad
|
|
28
|
+
* address or a mail provider outage) it is exactly the right clock: the
|
|
29
|
+
* invitation has been unusable since it was created.
|
|
30
|
+
*
|
|
31
|
+
* `binding` is unused here — see this module's header on why every check
|
|
32
|
+
* takes it.
|
|
33
|
+
*/
|
|
34
|
+
export async function checkStuckInvitations(handle, _binding) {
|
|
35
|
+
const cutoff = new Date(Date.now() - STUCK_MAIL_THRESHOLD_MS);
|
|
36
|
+
const rows = await handle
|
|
37
|
+
.select({
|
|
38
|
+
id: invitations.id,
|
|
39
|
+
tenantId: invitations.tenantId,
|
|
40
|
+
// Not the address. This finding goes to the event log, which
|
|
41
|
+
// `erasePerson` can never reach; an invitation id and its tenant are
|
|
42
|
+
// enough for somebody to act on the alert, and the address is one
|
|
43
|
+
// query away for whoever does.
|
|
44
|
+
failedSince: sql `coalesce(${invitations.mailStateAt}, ${invitations.createdAt})`,
|
|
45
|
+
})
|
|
46
|
+
.from(invitations)
|
|
47
|
+
.where(and(eq(invitations.status, 'pending'), eq(invitations.mailState, 'failed'),
|
|
48
|
+
// Measured from when the mail state last changed, not from when the
|
|
49
|
+
// row was written: a re-invite rotates the token on the SAME row, so
|
|
50
|
+
// `created_at` answers a different question and answers it early.
|
|
51
|
+
// `coalesce` covers a row written before `mail_state_at` existed,
|
|
52
|
+
// which has no better moment to offer.
|
|
53
|
+
//
|
|
54
|
+
// The cutoff goes in as an ISO string with an explicit cast, not as
|
|
55
|
+
// a Date. A raw `sql` template carries no column type for the value
|
|
56
|
+
// it interpolates, so the driver cannot encode a Date and throws at
|
|
57
|
+
// bind time; only a comparison against a bare column can be left to
|
|
58
|
+
// drizzle's own operators.
|
|
59
|
+
sql `coalesce(${invitations.mailStateAt}, ${invitations.createdAt}) < ${cutoff.toISOString()}::timestamptz`));
|
|
60
|
+
if (rows.length === 0)
|
|
61
|
+
return null;
|
|
62
|
+
return {
|
|
63
|
+
check: 'stuck_invitations',
|
|
64
|
+
message: `${rows.length} invitation(s) have sat with failed mail for over an hour.`,
|
|
65
|
+
detail: rows,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const BREAK_GLASS_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
69
|
+
/** More than this many sessions on one tenant in the rolling window trips the alert. */
|
|
70
|
+
const BREAK_GLASS_BURST_THRESHOLD = 2;
|
|
71
|
+
/**
|
|
72
|
+
* D9's third alert: more than two break-glass sessions opened on one tenant
|
|
73
|
+
* in a rolling day, regardless of which operator opened them or whether any
|
|
74
|
+
* are still open.
|
|
75
|
+
*
|
|
76
|
+
* `binding` is unused here — see this module's header on why every check
|
|
77
|
+
* takes it.
|
|
78
|
+
*/
|
|
79
|
+
export async function checkBreakGlassBurst(handle, _binding) {
|
|
80
|
+
const since = new Date(Date.now() - BREAK_GLASS_WINDOW_MS);
|
|
81
|
+
const rows = await handle
|
|
82
|
+
.select({ tenantId: breakGlassSessions.tenantId, count: sql `count(*)::int` })
|
|
83
|
+
.from(breakGlassSessions)
|
|
84
|
+
.where(gte(breakGlassSessions.startedAt, since))
|
|
85
|
+
.groupBy(breakGlassSessions.tenantId)
|
|
86
|
+
.having(sql `count(*) > ${BREAK_GLASS_BURST_THRESHOLD}`);
|
|
87
|
+
if (rows.length === 0)
|
|
88
|
+
return null;
|
|
89
|
+
return {
|
|
90
|
+
check: 'break_glass_burst',
|
|
91
|
+
message: `${rows.length} tenant(s) have had more than ${BREAK_GLASS_BURST_THRESHOLD} break-glass sessions opened in the last rolling day.`,
|
|
92
|
+
detail: rows,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** D9's fourth alert: `boot.ts`'s `orphanedRoleKeys`, imported rather than reimplemented. */
|
|
96
|
+
export async function checkOrphanedRoleKeys(handle, binding) {
|
|
97
|
+
const found = await orphanedRoleKeys(handle, binding);
|
|
98
|
+
if (found.length === 0)
|
|
99
|
+
return null;
|
|
100
|
+
const detail = found;
|
|
101
|
+
return {
|
|
102
|
+
check: 'orphaned_role_keys',
|
|
103
|
+
message: `${detail.length} row(s) name a role key the running vocabulary does not resolve.`,
|
|
104
|
+
detail,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The one place a finding reaches the sink: the host's `reporting.alert`,
|
|
109
|
+
* one `alert.<check>` row in its event log, always.
|
|
110
|
+
*/
|
|
111
|
+
export function reportAlert(reporting, finding) {
|
|
112
|
+
// The finding's detail is projected to flat fields (a list of rows becomes
|
|
113
|
+
// a count, never the rows) by the host's own `reporting.alert`.
|
|
114
|
+
reporting.alert(finding);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Runs all four checks against one handle and binding and reports whatever
|
|
118
|
+
* fired. What a nightly job or a boot hook calls; `startup.ts`'s
|
|
119
|
+
* `registerHousekeeping` is the only caller in this package.
|
|
120
|
+
*/
|
|
121
|
+
export async function runAlerts(handle, binding, reporting) {
|
|
122
|
+
const results = await Promise.all([
|
|
123
|
+
checkOrphanMemberships(handle, binding),
|
|
124
|
+
checkStuckInvitations(handle, binding),
|
|
125
|
+
checkBreakGlassBurst(handle, binding),
|
|
126
|
+
checkOrphanedRoleKeys(handle, binding),
|
|
127
|
+
]);
|
|
128
|
+
const findings = results.filter((finding) => finding !== null);
|
|
129
|
+
for (const finding of findings)
|
|
130
|
+
reportAlert(reporting, finding);
|
|
131
|
+
return findings;
|
|
132
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type ResourceAssignment, type ResourcePrincipal, type ResourceRole } from '@wtfalch/authz';
|
|
2
|
+
import type { StoreBinding } from './binding.js';
|
|
3
|
+
import type { DbOrTx } from './scoped.js';
|
|
4
|
+
import type { Access } from './types.js';
|
|
5
|
+
export declare const identityWhere: (tenantId: string, principal: ResourcePrincipal) => import("drizzle-orm").SQL<unknown> | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* A display projection only. Authorization always reads every current assignment. `binding`
|
|
8
|
+
* because the primary assignment this reads is scoped to one application/platform pair, the same
|
|
9
|
+
* pair every other query in this module takes explicitly rather than importing.
|
|
10
|
+
*/
|
|
11
|
+
export declare const primaryRoleKey: (binding: StoreBinding) => import("drizzle-orm").SQL<string>;
|
|
12
|
+
export declare function primaryAssignment(role: ResourceRole, principal: ResourcePrincipal, expiresAt?: number): ResourceAssignment;
|
|
13
|
+
/**
|
|
14
|
+
* Both the administrative operation and every scoped grant must be covered. `access` already
|
|
15
|
+
* carries its own `binding`, so `compileRoleGrants` reads `access.binding.catalogue` rather than
|
|
16
|
+
* taking a separate catalogue argument, matching how `permits` itself reads `access.binding`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function roleAssignmentRefusal(access: Access, role: ResourceRole, assignment: ResourceAssignment, operation: string | null): string | null;
|
|
19
|
+
/** Caller holds the tenant lock and has checked delegation, guards and recipient eligibility. */
|
|
20
|
+
export declare function writePrimaryAssignment(tx: DbOrTx, binding: StoreBinding, role: ResourceRole, principal: ResourcePrincipal, options?: {
|
|
21
|
+
source?: string;
|
|
22
|
+
viaTenantId?: string | null;
|
|
23
|
+
createdBy?: string | null;
|
|
24
|
+
expiresAt?: Date | null;
|
|
25
|
+
}): Promise<void>;
|
|
26
|
+
export declare function roleById(tx: DbOrTx, binding: StoreBinding, tenantId: string, id: string): Promise<{
|
|
27
|
+
id: string;
|
|
28
|
+
tenantId: string;
|
|
29
|
+
applicationId: string;
|
|
30
|
+
platformId: string;
|
|
31
|
+
key: string;
|
|
32
|
+
name: string;
|
|
33
|
+
description: string;
|
|
34
|
+
builtIn: boolean;
|
|
35
|
+
revision: number;
|
|
36
|
+
entries: {
|
|
37
|
+
permission: string;
|
|
38
|
+
boundary: {
|
|
39
|
+
kind: "organisation";
|
|
40
|
+
organisationId: string;
|
|
41
|
+
descendants?: boolean | undefined;
|
|
42
|
+
} | {
|
|
43
|
+
kind: "organisations";
|
|
44
|
+
organisationIds: string[];
|
|
45
|
+
} | {
|
|
46
|
+
kind: "platform";
|
|
47
|
+
};
|
|
48
|
+
scope: {
|
|
49
|
+
kind: "organisation";
|
|
50
|
+
} | {
|
|
51
|
+
kind: "team";
|
|
52
|
+
teamId: string;
|
|
53
|
+
descendants: boolean;
|
|
54
|
+
} | {
|
|
55
|
+
kind: "own_teams";
|
|
56
|
+
descendants: boolean;
|
|
57
|
+
} | {
|
|
58
|
+
kind: "resource";
|
|
59
|
+
resourceType: string;
|
|
60
|
+
resourceId: string;
|
|
61
|
+
};
|
|
62
|
+
relation: "any" | "owner" | "actor" | "subject";
|
|
63
|
+
}[];
|
|
64
|
+
guards: string[];
|
|
65
|
+
createdBy: string | null;
|
|
66
|
+
createdAt: Date;
|
|
67
|
+
updatedAt: Date;
|
|
68
|
+
updatedBy: string | null;
|
|
69
|
+
} | null>;
|
|
70
|
+
/** Default personal access is an ordinary, persisted assignment created when a person joins. */
|
|
71
|
+
export declare function writeParticipationPolicy(tx: DbOrTx, binding: StoreBinding, tenantId: string, principal: ResourcePrincipal): Promise<void>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { compileRoleGrants, } from '@wtfalch/authz';
|
|
3
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
4
|
+
import { permits } from './policy-access.js';
|
|
5
|
+
import { policyAssignments, policyRoles } from './policy-schema.js';
|
|
6
|
+
import { memberships } from './schema.js';
|
|
7
|
+
export const identityWhere = (tenantId, principal) => and(eq(memberships.tenantId, tenantId), eq(memberships.principalId, principal.id), eq(memberships.principalClass, principal.class));
|
|
8
|
+
/**
|
|
9
|
+
* A display projection only. Authorization always reads every current assignment. `binding`
|
|
10
|
+
* because the primary assignment this reads is scoped to one application/platform pair, the same
|
|
11
|
+
* pair every other query in this module takes explicitly rather than importing.
|
|
12
|
+
*/
|
|
13
|
+
export const primaryRoleKey = (binding) => sql `coalesce((select r.key from authz_assignments a join authz_roles r on r.id=a.role_id where a.tenant_id="memberships"."tenant_id" and a.principal_id="memberships"."principal_id" and a.principal_class="memberships"."principal_class" and a.application_id=${binding.applicationId} and a.platform_id=${binding.platformId} and a.primary_assignment and (a.expires_at is null or a.expires_at>now())), '')`;
|
|
14
|
+
export function primaryAssignment(role, principal, expiresAt) {
|
|
15
|
+
return {
|
|
16
|
+
id: randomUUID(),
|
|
17
|
+
roleId: role.id,
|
|
18
|
+
organisationId: role.organisationId,
|
|
19
|
+
applicationId: role.applicationId,
|
|
20
|
+
platformId: role.platformId,
|
|
21
|
+
boundary: { kind: 'platform' },
|
|
22
|
+
scope: { kind: 'organisation' },
|
|
23
|
+
recipient: { kind: 'principal', principal: { id: principal.id, class: principal.class } },
|
|
24
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Both the administrative operation and every scoped grant must be covered. `access` already
|
|
29
|
+
* carries its own `binding`, so `compileRoleGrants` reads `access.binding.catalogue` rather than
|
|
30
|
+
* taking a separate catalogue argument, matching how `permits` itself reads `access.binding`.
|
|
31
|
+
*/
|
|
32
|
+
export function roleAssignmentRefusal(access, role, assignment, operation) {
|
|
33
|
+
if (access.context === 'break_glass')
|
|
34
|
+
return 'break_glass';
|
|
35
|
+
if (operation && !permits(access, operation))
|
|
36
|
+
return 'no_permission';
|
|
37
|
+
if (operation && role.guards.some((guard) => !permits(access, guard)))
|
|
38
|
+
return 'guarded';
|
|
39
|
+
try {
|
|
40
|
+
return compileRoleGrants(role, assignment, access.binding.catalogue).every((g) => access.authority.canDelegate(g))
|
|
41
|
+
? null
|
|
42
|
+
: 'not_covered';
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return 'invalid_role';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Caller holds the tenant lock and has checked delegation, guards and recipient eligibility. */
|
|
49
|
+
export async function writePrimaryAssignment(tx, binding, role, principal, options = {}) {
|
|
50
|
+
const assignment = primaryAssignment(role, principal, options.expiresAt?.getTime());
|
|
51
|
+
// The compiler validates persisted authority; there is no special built-in runtime evaluator.
|
|
52
|
+
compileRoleGrants(role, assignment, binding.catalogue);
|
|
53
|
+
await tx
|
|
54
|
+
.delete(policyAssignments)
|
|
55
|
+
.where(and(eq(policyAssignments.tenantId, role.organisationId), eq(policyAssignments.applicationId, binding.applicationId), eq(policyAssignments.platformId, binding.platformId), eq(policyAssignments.principalId, principal.id), eq(policyAssignments.principalClass, principal.class), eq(policyAssignments.primary, true)));
|
|
56
|
+
await tx.insert(policyAssignments).values({
|
|
57
|
+
id: assignment.id,
|
|
58
|
+
tenantId: role.organisationId,
|
|
59
|
+
applicationId: binding.applicationId,
|
|
60
|
+
platformId: binding.platformId,
|
|
61
|
+
roleId: role.id,
|
|
62
|
+
principalId: principal.id,
|
|
63
|
+
principalClass: principal.class,
|
|
64
|
+
boundary: assignment.boundary,
|
|
65
|
+
scope: { kind: 'organisation' },
|
|
66
|
+
primary: true,
|
|
67
|
+
source: options.source ?? 'direct',
|
|
68
|
+
viaTenantId: options.viaTenantId ?? null,
|
|
69
|
+
createdBy: options.createdBy ?? null,
|
|
70
|
+
expiresAt: options.expiresAt ?? null,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export async function roleById(tx, binding, tenantId, id) {
|
|
74
|
+
const [row] = await tx
|
|
75
|
+
.select()
|
|
76
|
+
.from(policyRoles)
|
|
77
|
+
.where(and(eq(policyRoles.id, id), eq(policyRoles.tenantId, tenantId), eq(policyRoles.applicationId, binding.applicationId), eq(policyRoles.platformId, binding.platformId)))
|
|
78
|
+
.limit(1);
|
|
79
|
+
return row ?? null;
|
|
80
|
+
}
|
|
81
|
+
/** Default personal access is an ordinary, persisted assignment created when a person joins. */
|
|
82
|
+
export async function writeParticipationPolicy(tx, binding, tenantId, principal) {
|
|
83
|
+
if (principal.class !== 'human')
|
|
84
|
+
return;
|
|
85
|
+
const [role] = await tx
|
|
86
|
+
.select()
|
|
87
|
+
.from(policyRoles)
|
|
88
|
+
.where(and(eq(policyRoles.tenantId, tenantId), eq(policyRoles.applicationId, binding.applicationId), eq(policyRoles.platformId, binding.platformId), eq(policyRoles.key, 'self_service'), eq(policyRoles.builtIn, true)))
|
|
89
|
+
.limit(1);
|
|
90
|
+
if (!role)
|
|
91
|
+
throw new Error('Missing versioned personal access role');
|
|
92
|
+
await tx.insert(policyAssignments).values({
|
|
93
|
+
tenantId,
|
|
94
|
+
applicationId: binding.applicationId,
|
|
95
|
+
platformId: binding.platformId,
|
|
96
|
+
roleId: role.id,
|
|
97
|
+
principalId: principal.id,
|
|
98
|
+
principalClass: principal.class,
|
|
99
|
+
boundary: { kind: 'organisation', organisationId: tenantId },
|
|
100
|
+
scope: { kind: 'organisation' },
|
|
101
|
+
primary: false,
|
|
102
|
+
});
|
|
103
|
+
}
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { type ActorClass, type Context, type EventName, type Outcome } from '@wtfalch/authz';
|
|
2
|
+
import type { DbOrTx } from './scoped.js';
|
|
3
|
+
import type { Access, Principal } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* What a caller supplies for one row; everything else (the actor, the
|
|
6
|
+
* context, the session, the timestamp, the schema version) is filled in by
|
|
7
|
+
* `record`, `recordAs` or `writeServiceEvent` below, never by the caller, so
|
|
8
|
+
* a feature module cannot sign a row as someone it is not (D9).
|
|
9
|
+
*/
|
|
10
|
+
export interface EventInput {
|
|
11
|
+
readonly teamId?: string;
|
|
12
|
+
readonly subject?: {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly class: ActorClass;
|
|
15
|
+
};
|
|
16
|
+
readonly action: EventName;
|
|
17
|
+
readonly tenantId: string | null;
|
|
18
|
+
readonly targetType: string;
|
|
19
|
+
readonly targetId: string;
|
|
20
|
+
readonly outcome?: Outcome;
|
|
21
|
+
readonly before?: unknown;
|
|
22
|
+
readonly after?: unknown;
|
|
23
|
+
readonly reason?: string;
|
|
24
|
+
readonly reference?: string;
|
|
25
|
+
readonly tenantVisible?: boolean;
|
|
26
|
+
readonly request?: {
|
|
27
|
+
readonly id?: string;
|
|
28
|
+
readonly ip?: string;
|
|
29
|
+
readonly userAgent?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The audit insert every authority write uses. Actor fields come from the
|
|
34
|
+
* `Access` it was handed, never from an argument (D9): a feature module
|
|
35
|
+
* cannot sign a row as anyone but the principal that was actually resolved
|
|
36
|
+
* for this request. `context` and the break-glass session id, when there is
|
|
37
|
+
* one, come from the same `Access`, so a row written under a support session
|
|
38
|
+
* always says so.
|
|
39
|
+
*
|
|
40
|
+
* Under a session, `reason` and `reference` fall back to the session's own
|
|
41
|
+
* (1c): the migration's CHECK requires both, from the closed reason-code
|
|
42
|
+
* set, on every row whose context is `break_glass`, and a caller writing a
|
|
43
|
+
* row while a session is open rarely has a different reason to give than the
|
|
44
|
+
* one the session itself was opened for. A caller's own value, when given,
|
|
45
|
+
* still wins.
|
|
46
|
+
*/
|
|
47
|
+
export declare function record(tx: DbOrTx, access: Access, input: EventInput): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* The same insert for a write with no `Access` yet: self-serve `createTenant`
|
|
50
|
+
* resolves nothing before the tenant row exists, so there is no membership
|
|
51
|
+
* to build one from. Never carries a break-glass session, because there is
|
|
52
|
+
* no session without an `Access` to hold it.
|
|
53
|
+
*/
|
|
54
|
+
export declare function recordAs(tx: DbOrTx, actor: Principal, context: Context, input: EventInput): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* The bootstrap, migration, deploy and job rows: written as `service`, never
|
|
57
|
+
* as a person or a credential, because nothing signed in to cause them.
|
|
58
|
+
* `context` defaults to `standard`; pass `'operator'` for a row about the
|
|
59
|
+
* operator tenant specifically (an operator-only system role changing, say).
|
|
60
|
+
*/
|
|
61
|
+
export declare function writeServiceEvent(tx: DbOrTx, actor: {
|
|
62
|
+
readonly id: string;
|
|
63
|
+
readonly display: string;
|
|
64
|
+
}, input: EventInput & {
|
|
65
|
+
readonly context?: 'standard' | 'operator';
|
|
66
|
+
}): Promise<void>;
|
|
67
|
+
/** One row of the tenant's own security log, already narrowed to what the tenant may see. */
|
|
68
|
+
export interface SecurityLogEntry {
|
|
69
|
+
readonly id: number;
|
|
70
|
+
readonly occurredAt: Date;
|
|
71
|
+
readonly action: string;
|
|
72
|
+
readonly actorDisplay: string;
|
|
73
|
+
readonly targetType: string;
|
|
74
|
+
readonly targetId: string;
|
|
75
|
+
readonly outcome: string;
|
|
76
|
+
readonly context: string;
|
|
77
|
+
readonly reason: string | null;
|
|
78
|
+
readonly reference: string | null;
|
|
79
|
+
/** Set when the actor has since been erased, so the display name is a placeholder rather than a person. */
|
|
80
|
+
readonly erasedAt: Date | null;
|
|
81
|
+
}
|
|
82
|
+
export interface SecurityLogPage {
|
|
83
|
+
readonly items: readonly SecurityLogEntry[];
|
|
84
|
+
readonly next: {
|
|
85
|
+
readonly occurredAt: Date;
|
|
86
|
+
readonly id: number;
|
|
87
|
+
} | null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The tenant's own security log: what happened to this organisation's
|
|
91
|
+
* authority, newest first.
|
|
92
|
+
*
|
|
93
|
+
* `db` is the host's root handle, taken explicitly rather than imported: the
|
|
94
|
+
* store has no database of its own to reach for.
|
|
95
|
+
*
|
|
96
|
+
* **`tenant_visible` is not optional and not the caller's choice.** The
|
|
97
|
+
* column decides, per the migration's own words, "whether the tenant's own
|
|
98
|
+
* security log shows this row", and it is set once per event type from the
|
|
99
|
+
* vocabulary rather than per write. Operator-side events about a tenant are
|
|
100
|
+
* written with it false, so a query that forgot the predicate would show a
|
|
101
|
+
* customer the estate's internal handling of them. It is filtered here, in
|
|
102
|
+
* the one function a tenant-facing page is meant to call, rather than left to
|
|
103
|
+
* each page to remember.
|
|
104
|
+
*
|
|
105
|
+
* **`null` when the actor may not read it, never an empty page**, the same
|
|
106
|
+
* distinction `membersOf` draws and for the same reason: "you may not see
|
|
107
|
+
* this" and "nothing has happened" are different facts, and a log that
|
|
108
|
+
* renders the second for the first invites somebody to conclude their
|
|
109
|
+
* organisation has no history.
|
|
110
|
+
*
|
|
111
|
+
* Keyset-paged on `(occurred_at, id)` descending, the reverse of the
|
|
112
|
+
* table's own ordering. A log is read newest first and grows at the end a
|
|
113
|
+
* reader starts from, which is exactly the case an offset cannot page
|
|
114
|
+
* stably: a row arriving mid-read shifts every later offset by one.
|
|
115
|
+
*/
|
|
116
|
+
export declare function securityLogFor(access: Access, db: DbOrTx, options?: {
|
|
117
|
+
readonly limit?: number;
|
|
118
|
+
readonly after?: {
|
|
119
|
+
occurredAt: Date;
|
|
120
|
+
id: number;
|
|
121
|
+
};
|
|
122
|
+
}): Promise<SecurityLogPage | null>;
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { authzEventSchema, core, tenantVisibleByDefault, } from '@wtfalch/authz';
|
|
2
|
+
import { and, desc, eq, lt, or } from 'drizzle-orm';
|
|
3
|
+
import { permitsRead } from './policy-access.js';
|
|
4
|
+
import { authzEvents } from './schema.js';
|
|
5
|
+
/**
|
|
6
|
+
* The one place a row actually gets built and inserted. Every field the
|
|
7
|
+
* schema requires is filled in here, the candidate row is validated with
|
|
8
|
+
* `authzEventSchema.parse` before anything is sent (the package refuses an
|
|
9
|
+
* unknown key or a field past its length limit, so a mistake here fails
|
|
10
|
+
* loudly at write time rather than becoming a silently wrong row), and only
|
|
11
|
+
* then does it reach `tx.insert`. `tx` rather than a fixed transaction type,
|
|
12
|
+
* because a plain `Db` insert is just as valid a single statement as one
|
|
13
|
+
* inside a transaction; callers that need this row atomic with another
|
|
14
|
+
* write (which is every authority write except a service log) pass their
|
|
15
|
+
* own transaction through.
|
|
16
|
+
*/
|
|
17
|
+
async function insertEvent(tx, actor, context, sessionId, input) {
|
|
18
|
+
const occurredAt = new Date();
|
|
19
|
+
const row = {
|
|
20
|
+
occurred_at: occurredAt.toISOString(),
|
|
21
|
+
tenant_id: input.tenantId,
|
|
22
|
+
actor_class: actor.class,
|
|
23
|
+
actor_id: actor.id,
|
|
24
|
+
actor_display: actor.display,
|
|
25
|
+
action: input.action,
|
|
26
|
+
target_type: input.targetType,
|
|
27
|
+
target_id: input.targetId,
|
|
28
|
+
outcome: input.outcome ?? 'success',
|
|
29
|
+
context,
|
|
30
|
+
session_id: sessionId,
|
|
31
|
+
reason: input.reason ?? null,
|
|
32
|
+
reference: input.reference ?? null,
|
|
33
|
+
request_id: input.request?.id ?? null,
|
|
34
|
+
ip: input.request?.ip ?? null,
|
|
35
|
+
user_agent: input.request?.userAgent ?? null,
|
|
36
|
+
tenant_visible: input.tenantVisible ?? tenantVisibleByDefault(input.action),
|
|
37
|
+
before: input.before ?? null,
|
|
38
|
+
after: input.after ?? null,
|
|
39
|
+
erased_at: null,
|
|
40
|
+
schema_version: core.version,
|
|
41
|
+
};
|
|
42
|
+
authzEventSchema.parse(row);
|
|
43
|
+
await tx.insert(authzEvents).values({
|
|
44
|
+
occurredAt,
|
|
45
|
+
tenantId: row.tenant_id,
|
|
46
|
+
teamId: input.teamId ?? null,
|
|
47
|
+
subjectId: input.subject?.id ?? null,
|
|
48
|
+
subjectClass: input.subject?.class ?? null,
|
|
49
|
+
actorClass: row.actor_class,
|
|
50
|
+
actorId: row.actor_id,
|
|
51
|
+
actorDisplay: row.actor_display,
|
|
52
|
+
action: row.action,
|
|
53
|
+
targetType: row.target_type,
|
|
54
|
+
targetId: row.target_id,
|
|
55
|
+
outcome: row.outcome,
|
|
56
|
+
context: row.context,
|
|
57
|
+
sessionId: row.session_id,
|
|
58
|
+
reason: row.reason,
|
|
59
|
+
reference: row.reference,
|
|
60
|
+
requestId: row.request_id,
|
|
61
|
+
ip: row.ip,
|
|
62
|
+
userAgent: row.user_agent,
|
|
63
|
+
tenantVisible: row.tenant_visible,
|
|
64
|
+
before: row.before,
|
|
65
|
+
after: row.after,
|
|
66
|
+
schemaVersion: row.schema_version,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The audit insert every authority write uses. Actor fields come from the
|
|
71
|
+
* `Access` it was handed, never from an argument (D9): a feature module
|
|
72
|
+
* cannot sign a row as anyone but the principal that was actually resolved
|
|
73
|
+
* for this request. `context` and the break-glass session id, when there is
|
|
74
|
+
* one, come from the same `Access`, so a row written under a support session
|
|
75
|
+
* always says so.
|
|
76
|
+
*
|
|
77
|
+
* Under a session, `reason` and `reference` fall back to the session's own
|
|
78
|
+
* (1c): the migration's CHECK requires both, from the closed reason-code
|
|
79
|
+
* set, on every row whose context is `break_glass`, and a caller writing a
|
|
80
|
+
* row while a session is open rarely has a different reason to give than the
|
|
81
|
+
* one the session itself was opened for. A caller's own value, when given,
|
|
82
|
+
* still wins.
|
|
83
|
+
*/
|
|
84
|
+
export async function record(tx, access, input) {
|
|
85
|
+
// `??` rather than a spread: a caller passing `reason: undefined` by way
|
|
86
|
+
// of an optional argument must still get the session's value, or the row
|
|
87
|
+
// fails the migration's CHECK.
|
|
88
|
+
const grant = access.context === 'break_glass' ? access.breakGlass : null;
|
|
89
|
+
const withSession = grant
|
|
90
|
+
? {
|
|
91
|
+
...input,
|
|
92
|
+
reason: input.reason ?? grant.reason,
|
|
93
|
+
reference: input.reference ?? grant.reference,
|
|
94
|
+
}
|
|
95
|
+
: input;
|
|
96
|
+
await insertEvent(tx, { class: access.actor.class, id: access.actor.id, display: access.actor.display }, access.context, access.breakGlass?.id ?? null, withSession);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The same insert for a write with no `Access` yet: self-serve `createTenant`
|
|
100
|
+
* resolves nothing before the tenant row exists, so there is no membership
|
|
101
|
+
* to build one from. Never carries a break-glass session, because there is
|
|
102
|
+
* no session without an `Access` to hold it.
|
|
103
|
+
*/
|
|
104
|
+
export async function recordAs(tx, actor, context, input) {
|
|
105
|
+
await insertEvent(tx, { class: actor.class, id: actor.id, display: actor.display }, context, null, input);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The bootstrap, migration, deploy and job rows: written as `service`, never
|
|
109
|
+
* as a person or a credential, because nothing signed in to cause them.
|
|
110
|
+
* `context` defaults to `standard`; pass `'operator'` for a row about the
|
|
111
|
+
* operator tenant specifically (an operator-only system role changing, say).
|
|
112
|
+
*/
|
|
113
|
+
export async function writeServiceEvent(tx, actor, input) {
|
|
114
|
+
const { context = 'standard', ...rest } = input;
|
|
115
|
+
await insertEvent(tx, { class: 'service', id: actor.id, display: actor.display }, context, null, rest);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The tenant's own security log: what happened to this organisation's
|
|
119
|
+
* authority, newest first.
|
|
120
|
+
*
|
|
121
|
+
* `db` is the host's root handle, taken explicitly rather than imported: the
|
|
122
|
+
* store has no database of its own to reach for.
|
|
123
|
+
*
|
|
124
|
+
* **`tenant_visible` is not optional and not the caller's choice.** The
|
|
125
|
+
* column decides, per the migration's own words, "whether the tenant's own
|
|
126
|
+
* security log shows this row", and it is set once per event type from the
|
|
127
|
+
* vocabulary rather than per write. Operator-side events about a tenant are
|
|
128
|
+
* written with it false, so a query that forgot the predicate would show a
|
|
129
|
+
* customer the estate's internal handling of them. It is filtered here, in
|
|
130
|
+
* the one function a tenant-facing page is meant to call, rather than left to
|
|
131
|
+
* each page to remember.
|
|
132
|
+
*
|
|
133
|
+
* **`null` when the actor may not read it, never an empty page**, the same
|
|
134
|
+
* distinction `membersOf` draws and for the same reason: "you may not see
|
|
135
|
+
* this" and "nothing has happened" are different facts, and a log that
|
|
136
|
+
* renders the second for the first invites somebody to conclude their
|
|
137
|
+
* organisation has no history.
|
|
138
|
+
*
|
|
139
|
+
* Keyset-paged on `(occurred_at, id)` descending, the reverse of the
|
|
140
|
+
* table's own ordering. A log is read newest first and grows at the end a
|
|
141
|
+
* reader starts from, which is exactly the case an offset cannot page
|
|
142
|
+
* stably: a row arriving mid-read shifts every later offset by one.
|
|
143
|
+
*/
|
|
144
|
+
export async function securityLogFor(access, db, options = {}) {
|
|
145
|
+
if (!(await permitsRead(access, db, 'platform.audit:read')))
|
|
146
|
+
return null;
|
|
147
|
+
const limit = Math.min(options.limit ?? 50, 200);
|
|
148
|
+
const conditions = [
|
|
149
|
+
eq(authzEvents.tenantId, access.tenant.id),
|
|
150
|
+
eq(authzEvents.tenantVisible, true),
|
|
151
|
+
];
|
|
152
|
+
if (options.after) {
|
|
153
|
+
const { occurredAt, id } = options.after;
|
|
154
|
+
// Strictly before the cursor in the same order the query sorts by, with
|
|
155
|
+
// the identity column breaking ties, so two events in one transaction
|
|
156
|
+
// cannot hide each other across a page boundary.
|
|
157
|
+
const keyset = or(lt(authzEvents.occurredAt, occurredAt), and(eq(authzEvents.occurredAt, occurredAt), lt(authzEvents.id, id)));
|
|
158
|
+
if (keyset)
|
|
159
|
+
conditions.push(keyset);
|
|
160
|
+
}
|
|
161
|
+
const rows = await db
|
|
162
|
+
.select({
|
|
163
|
+
id: authzEvents.id,
|
|
164
|
+
occurredAt: authzEvents.occurredAt,
|
|
165
|
+
action: authzEvents.action,
|
|
166
|
+
actorDisplay: authzEvents.actorDisplay,
|
|
167
|
+
targetType: authzEvents.targetType,
|
|
168
|
+
targetId: authzEvents.targetId,
|
|
169
|
+
outcome: authzEvents.outcome,
|
|
170
|
+
context: authzEvents.context,
|
|
171
|
+
reason: authzEvents.reason,
|
|
172
|
+
reference: authzEvents.reference,
|
|
173
|
+
erasedAt: authzEvents.erasedAt,
|
|
174
|
+
})
|
|
175
|
+
.from(authzEvents)
|
|
176
|
+
.where(and(...conditions))
|
|
177
|
+
.orderBy(desc(authzEvents.occurredAt), desc(authzEvents.id))
|
|
178
|
+
.limit(limit + 1);
|
|
179
|
+
const page = rows.slice(0, limit);
|
|
180
|
+
const last = page.at(-1);
|
|
181
|
+
// Reads that expose another party's activity are events too (0.9.0): a
|
|
182
|
+
// read of a security log is itself audited, tenant-visible by default, so
|
|
183
|
+
// the tenant sees what was read, not only that it exists.
|
|
184
|
+
await record(db, access, {
|
|
185
|
+
action: 'audit_log.viewed',
|
|
186
|
+
tenantId: access.tenant.id,
|
|
187
|
+
targetType: 'audit',
|
|
188
|
+
targetId: access.tenant.id,
|
|
189
|
+
});
|
|
190
|
+
return {
|
|
191
|
+
items: page,
|
|
192
|
+
next: rows.length > limit && last ? { occurredAt: last.occurredAt, id: last.id } : null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResourceCatalogue, ResourcePermission } from '@wtfalch/authz';
|
|
2
|
+
import type { PolicyBinding } from './owners.js';
|
|
3
|
+
/**
|
|
4
|
+
* `PolicyBinding` (`applicationId`, `platformId`) plus the host's own resource catalogue. The
|
|
5
|
+
* store has no catalogue of its own — each host defines its own permissions — so every moved
|
|
6
|
+
* module that used to read `APPLICATION_ID`, `PLATFORM_ID` or `catalogue` from its own
|
|
7
|
+
* policy-catalogue file now takes a `StoreBinding` as a parameter instead.
|
|
8
|
+
*/
|
|
9
|
+
export interface StoreBinding extends PolicyBinding {
|
|
10
|
+
readonly catalogue: ResourceCatalogue;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The one lookup every moved module did as `catalogue[permission]`. A tiny helper rather than
|
|
14
|
+
* each call site repeating the index expression, since `permits`, `permitsPlatform`,
|
|
15
|
+
* `policyTarget` and `explain` all need it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function permissionOf(binding: StoreBinding, permission: string): ResourcePermission | undefined;
|
package/dist/binding.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one lookup every moved module did as `catalogue[permission]`. A tiny helper rather than
|
|
3
|
+
* each call site repeating the index expression, since `permits`, `permitsPlatform`,
|
|
4
|
+
* `policyTarget` and `explain` all need it.
|
|
5
|
+
*/
|
|
6
|
+
export function permissionOf(binding, permission) {
|
|
7
|
+
return binding.catalogue[permission];
|
|
8
|
+
}
|