@wtfalch/authz-store 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +335 -5
  2. package/dist/activations.d.ts +60 -0
  3. package/dist/activations.js +516 -0
  4. package/dist/alerts.d.ts +86 -0
  5. package/dist/alerts.js +132 -0
  6. package/dist/assignments.d.ts +71 -0
  7. package/dist/assignments.js +103 -0
  8. package/dist/audit.d.ts +122 -0
  9. package/dist/audit.js +194 -0
  10. package/dist/binding.d.ts +17 -0
  11. package/dist/binding.js +8 -0
  12. package/dist/boot.d.ts +54 -0
  13. package/dist/boot.js +143 -0
  14. package/dist/bootstrap.d.ts +28 -0
  15. package/dist/bootstrap.js +83 -0
  16. package/dist/break-glass.d.ts +61 -0
  17. package/dist/break-glass.js +247 -0
  18. package/dist/credentials.d.ts +94 -0
  19. package/dist/credentials.js +230 -0
  20. package/dist/denial.d.ts +9 -0
  21. package/dist/denial.js +49 -0
  22. package/dist/erase.d.ts +58 -0
  23. package/dist/erase.js +108 -0
  24. package/dist/events.d.ts +77 -0
  25. package/dist/events.js +107 -0
  26. package/dist/export.d.ts +161 -0
  27. package/dist/export.js +293 -0
  28. package/dist/index.d.ts +33 -1
  29. package/dist/index.js +33 -1
  30. package/dist/install-owner.d.ts +25 -0
  31. package/dist/install-owner.js +159 -0
  32. package/dist/invitations.d.ts +160 -0
  33. package/dist/invitations.js +685 -0
  34. package/dist/membership-rows.d.ts +206 -0
  35. package/dist/membership-rows.js +271 -0
  36. package/dist/memberships.d.ts +87 -0
  37. package/dist/memberships.js +272 -0
  38. package/dist/migrate.js +15 -4
  39. package/dist/nesting.d.ts +124 -0
  40. package/dist/nesting.js +515 -0
  41. package/dist/owners.d.ts +6 -1
  42. package/dist/owners.js +7 -3
  43. package/dist/person-records.d.ts +186 -0
  44. package/dist/person-records.js +263 -0
  45. package/dist/platform.d.ts +20 -0
  46. package/dist/platform.js +65 -0
  47. package/dist/policy-access.d.ts +260 -0
  48. package/dist/policy-access.js +348 -0
  49. package/dist/policy-resources.d.ts +5 -0
  50. package/dist/policy-resources.js +46 -0
  51. package/dist/policy-schema.d.ts +445 -0
  52. package/dist/policy-schema.js +63 -0
  53. package/dist/policy.d.ts +64 -0
  54. package/dist/policy.js +65 -0
  55. package/dist/propagate.d.ts +43 -0
  56. package/dist/propagate.js +47 -0
  57. package/dist/reconcile.d.ts +78 -0
  58. package/dist/reconcile.js +94 -0
  59. package/dist/resource-access.d.ts +344 -0
  60. package/dist/resource-access.js +656 -0
  61. package/dist/role-keys.d.ts +9 -0
  62. package/dist/role-keys.js +9 -0
  63. package/dist/roles.d.ts +36 -0
  64. package/dist/roles.js +191 -0
  65. package/dist/schema.d.ts +18 -1
  66. package/dist/schema.js +8 -1
  67. package/dist/startup.d.ts +57 -0
  68. package/dist/startup.js +113 -0
  69. package/dist/tenants.d.ts +213 -0
  70. package/dist/tenants.js +808 -0
  71. package/dist/tree-writes.d.ts +65 -0
  72. package/dist/tree-writes.js +201 -0
  73. package/dist/tree.d.ts +272 -0
  74. package/dist/tree.js +565 -0
  75. package/dist/types.d.ts +87 -0
  76. package/dist/types.js +15 -0
  77. package/migrations/0003_product_tenant_kind.sql +14 -0
  78. package/migrations/0004_credential_keys_issued_id.sql +33 -0
  79. package/migrations/0005_activations.sql +71 -0
  80. package/migrations/0006_erase_person.sql +134 -0
  81. package/package.json +9 -4
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
+ }
@@ -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;
@@ -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
+ }