@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
@@ -0,0 +1,25 @@
1
+ import type { DbOrTx, Scope } from './scoped.js';
2
+ import type { Access, Result } from './types.js';
3
+ export interface InstallOwnerOptions {
4
+ readonly tenantId: string;
5
+ readonly principalId: string;
6
+ /** A verified reference (the tenant's billing contact confirming by mail) that bypasses the freshness check. */
7
+ readonly reference?: string;
8
+ /** Days without any owner sign-in before the seat counts as inactive. */
9
+ readonly periodDays?: number;
10
+ }
11
+ /**
12
+ * What a host must supply about a person's own sign-in history. That table
13
+ * (`profiles`) is the host's, not the store's, so the two reads Boule made
14
+ * directly against it become this lookup instead: `personKnown` answers "has
15
+ * this id ever signed in here" for the person being installed, and
16
+ * `lastSeenAt` answers it for a batch of a tenant's current direct owners at
17
+ * once, so `installOwner` can compare every owner's activity against
18
+ * `periodDays` in one pass. A person missing from the returned map, or
19
+ * mapped to `null`, counts as never signed in.
20
+ */
21
+ export interface OwnerActivityLookup {
22
+ personKnown(principalId: string): Promise<boolean>;
23
+ lastSeenAt(principalIds: readonly string[]): Promise<ReadonlyMap<string, Date | null>>;
24
+ }
25
+ export declare function installOwner(operatorAccess: Access, db: DbOrTx, scopeColumn: Scope['column'], activity: OwnerActivityLookup, options: InstallOwnerOptions): Promise<Result<void>>;
@@ -0,0 +1,159 @@
1
+ import { and, eq, sql } from 'drizzle-orm';
2
+ import { writeParticipationPolicy, writePrimaryAssignment } from './assignments.js';
3
+ import { record } from './audit.js';
4
+ import { permitsPlatform, refreshAccess } from './policy-access.js';
5
+ import { roleDef } from './roles.js';
6
+ import { memberships, tenants } from './schema.js';
7
+ import { recomputeDerived } from './tree-writes.js';
8
+ import { derivedEventAction, descendantsOf } from './tree.js';
9
+ import { done, isUuid, refused, tenantTag } from './types.js';
10
+ /**
11
+ * The one exception to D8's rule that no session, and no standing operator
12
+ * reach, ever changes who belongs to a tenant. A tenant whose owner seat is
13
+ * gone or compromised has nobody left inside it who could appoint a
14
+ * replacement, which is exactly what the last-owner guard elsewhere is
15
+ * designed to prevent working around, so this is an ordinary operator
16
+ * action, gated by its own permission, never a break-glass session (M1).
17
+ */
18
+ const DEFAULT_PERIOD_DAYS = 30;
19
+ export async function installOwner(operatorAccess, db, scopeColumn, activity, options) {
20
+ if (operatorAccess.context === 'break_glass') {
21
+ return refused('break_glass', 'A support session never installs an owner.');
22
+ }
23
+ if (operatorAccess.tenant.kind !== 'operator' ||
24
+ !permitsPlatform(operatorAccess, 'owners:install')) {
25
+ return refused('no_permission', 'You do not have permission to install an owner.');
26
+ }
27
+ if (!isUuid(options.tenantId)) {
28
+ return refused('tenant_not_found', 'This organisation no longer exists.');
29
+ }
30
+ if (options.tenantId === operatorAccess.tenant.id) {
31
+ return refused('operator_tenant', 'The operator organisation is not what this action is for.');
32
+ }
33
+ if (!(await activity.personKnown(options.principalId)))
34
+ return refused('unknown_person', 'This person has never signed in here.');
35
+ return db.transaction(async (tx) => {
36
+ const fresh = await refreshAccess(tx, db, scopeColumn, operatorAccess);
37
+ if (!fresh || fresh.context !== 'operator' || !permitsPlatform(fresh, 'owners:install'))
38
+ return refused('no_permission', 'Your authority changed.');
39
+ const currentAccess = fresh;
40
+ const [tenantRow] = await tx
41
+ .select({ id: tenants.id, name: tenants.name })
42
+ .from(tenants)
43
+ .where(eq(tenants.id, options.tenantId))
44
+ .for('update');
45
+ if (!tenantRow)
46
+ return refused('tenant_not_found', 'This organisation no longer exists.');
47
+ const owners = await tx
48
+ .select({ principalId: memberships.principalId })
49
+ .from(memberships)
50
+ .where(and(eq(memberships.tenantId, tenantRow.id), eq(memberships.principalClass, 'human'),
51
+ // Not `and r.built_in`: a starter owner role (ADR 0016) is
52
+ // `built_in: false` for a tenant created after that change, but
53
+ // only the app's own owner role can ever hold the literal key
54
+ // `owner` (custom role keys always start with `c_`).
55
+ sql `exists(select 1 from authz_assignments a join authz_roles r on r.id=a.role_id where a.tenant_id=${memberships.tenantId} and a.principal_id=${memberships.principalId} and a.principal_class='human' and a.source='direct' and a.expires_at is null and r.key='owner')`, eq(memberships.source, 'direct')));
56
+ const lastSeen = await activity.lastSeenAt(owners.map((owner) => owner.principalId));
57
+ const periodMs = (options.periodDays ?? DEFAULT_PERIOD_DAYS) * 24 * 60 * 60 * 1000;
58
+ const cutoff = Date.now() - periodMs;
59
+ // Null (or missing from the map) counts as never signed in: this asks
60
+ // whether SOME owner is still within the period, not whether every
61
+ // owner has a timestamp at all.
62
+ const anOwnerIsActive = owners.some((owner) => {
63
+ const seenAt = lastSeen.get(owner.principalId) ?? null;
64
+ return seenAt !== null && seenAt.getTime() >= cutoff;
65
+ });
66
+ let reason;
67
+ if (owners.length === 0) {
68
+ reason = 'no_owner';
69
+ }
70
+ else if (!anOwnerIsActive) {
71
+ reason = 'owners_inactive';
72
+ }
73
+ else if (options.reference) {
74
+ // An owner is still within the period; only a verified reference
75
+ // overrides that, which is why this is checked last rather than first.
76
+ reason = 'reference';
77
+ }
78
+ else {
79
+ return refused('owners_active', 'An owner has signed in recently. A verified reference is needed to install another.');
80
+ }
81
+ const [existing] = await tx
82
+ .select({ principalId: memberships.principalId, source: memberships.source })
83
+ .from(memberships)
84
+ .where(and(eq(memberships.tenantId, tenantRow.id), eq(memberships.principalId, options.principalId), eq(memberships.principalClass, 'human')))
85
+ .limit(1);
86
+ if (existing) {
87
+ // grantedBy moves to the operator too: this is the operator's own act
88
+ // right now, not a continuation of whoever originally added the row.
89
+ //
90
+ // `source` moves to direct as well, and `via_tenant_id` goes with it.
91
+ // The point of this function is that the tenant gains an owner seat of
92
+ // its own, and `ownerCoverage` counts direct rows only (D19), so
93
+ // promoting a row that arrived from a parent and leaving it derived
94
+ // would satisfy nothing: the tenant would still read as ownerless, and
95
+ // the next propagation would recompute the role straight back off it.
96
+ await tx
97
+ .update(memberships)
98
+ .set({
99
+ source: 'direct',
100
+ viaTenantId: null,
101
+ grantedBy: currentAccess.actor.id,
102
+ updatedAt: new Date(),
103
+ })
104
+ .where(and(eq(memberships.tenantId, tenantRow.id), eq(memberships.principalId, options.principalId), eq(memberships.principalClass, 'human')));
105
+ }
106
+ else {
107
+ await tx.insert(memberships).values({
108
+ tenantId: tenantRow.id,
109
+ principalId: options.principalId,
110
+ principalClass: 'human',
111
+ source: 'direct',
112
+ tenantName: tenantRow.name,
113
+ grantedBy: currentAccess.actor.id,
114
+ });
115
+ await writeParticipationPolicy(tx, currentAccess.binding, tenantRow.id, {
116
+ id: options.principalId,
117
+ class: 'human',
118
+ });
119
+ }
120
+ const owner = await roleDef(tx, currentAccess.binding, tenantRow, 'owner');
121
+ if (!owner)
122
+ throw new Error('Missing owner role');
123
+ await writePrimaryAssignment(tx, currentAccess.binding, owner, { id: options.principalId, class: 'human' }, { createdBy: currentAccess.actor.id });
124
+ await record(tx, currentAccess, {
125
+ action: 'owner.installed',
126
+ tenantId: tenantRow.id,
127
+ targetType: 'membership',
128
+ targetId: options.principalId,
129
+ after: { role: 'owner' },
130
+ reason,
131
+ reference: options.reference,
132
+ });
133
+ // D19: the row just written is a direct membership, so a tenant holding
134
+ // children owes them the derived rows it implies, the same as every
135
+ // other direct write. An installed owner who did not reach the children
136
+ // would be an owner of a parent with no authority below it.
137
+ const descendants = await descendantsOf(tx, tenantRow.id);
138
+ const tags = [tenantTag(tenantRow.id)];
139
+ if (descendants.length > 0) {
140
+ const changes = await recomputeDerived(tx, currentAccess.binding, descendants.map((node) => node.id), [options.principalId]);
141
+ for (const change of changes) {
142
+ tags.push(tenantTag(change.tenantId));
143
+ await record(tx, currentAccess, {
144
+ action: derivedEventAction(change.kind),
145
+ tenantId: change.tenantId,
146
+ targetType: 'membership',
147
+ targetId: change.principalId,
148
+ before: change.before
149
+ ? { role: change.before.role, viaTenantId: change.before.viaTenantId }
150
+ : undefined,
151
+ after: change.after
152
+ ? { role: change.after.role, viaTenantId: change.after.viaTenantId }
153
+ : undefined,
154
+ });
155
+ }
156
+ }
157
+ return done(undefined, tags);
158
+ });
159
+ }
@@ -0,0 +1,160 @@
1
+ import type { StoreBinding } from './binding.js';
2
+ import type { OwnerActivityLookup } from './install-owner.js';
3
+ import type { StorePolicy } from './policy.js';
4
+ import type { DbOrTx, Scope } from './scoped.js';
5
+ import { type Access, type Principal, type Result } from './types.js';
6
+ /**
7
+ * Invitations (D10): a pending offer of membership, its own row rather than
8
+ * a pending membership, so it can expire, be revoked, or sit visibly as
9
+ * "mail failed" until someone retries. Every write here locks the tenant row
10
+ * first, refuses under a break-glass context (D8: no session ever changes
11
+ * who belongs), and records exactly one audit row through the `Access` or
12
+ * `Principal` it was handed.
13
+ *
14
+ * Wave 8a (authz#83) is the sending and management half:
15
+ * `generateInvitationToken`, `invite`, `markInvitationMail`,
16
+ * `resendInvitation`, `revokeInvitation`, `pendingInvitations`,
17
+ * `invitationPreview` and `invitationTenantId`. Wave 8b adds
18
+ * `acceptInvitation` and the born-attach savepoint it runs.
19
+ *
20
+ * `acceptInvitationForUser` does not move: it is the one caller of Boule's
21
+ * own `requireUser`, `ensureProfile` and `principalFromUser` from
22
+ * `access.ts`, none of which this package can reach, and stays in Boule as
23
+ * a non-goal (issue #98 decision 1).
24
+ */
25
+ export interface InviteOptions {
26
+ readonly email: string;
27
+ readonly role: string;
28
+ /** The host's own per-hour send limit (authz#83 wave 8 decision 2: no longer a package constant). */
29
+ readonly capPerHour: number;
30
+ /** The host's own acceptance route (decision 3): the store never hard-codes one. */
31
+ readonly link: (token: string) => string;
32
+ }
33
+ /**
34
+ * What a successful send hands back. The token is returned here once and
35
+ * never stored or logged in clear. `email` is the row's own, normalised
36
+ * address -- on `resendInvitation` this is the only place a caller who
37
+ * supplied nothing but an id can read it back, so the mail it sends targets
38
+ * the invitation's real address rather than whatever a form happened to
39
+ * carry.
40
+ */
41
+ export interface InvitationHandle {
42
+ readonly id: string;
43
+ readonly token: string;
44
+ readonly link: string;
45
+ readonly email: string;
46
+ }
47
+ export interface GeneratedInvitationToken {
48
+ readonly token: string;
49
+ readonly tokenHash: string;
50
+ readonly expiresAt: Date;
51
+ }
52
+ /**
53
+ * The token every invitation row uses: 32 random bytes (the package's own
54
+ * `core.invitation.tokenBytes`), hex-encoded for the link the mail carries,
55
+ * with its sha256 hex stored on the row. Exported so a host's own
56
+ * `createTenantAsOperator` builds its own pending invitation with exactly
57
+ * the same rule, rather than a second copy of it.
58
+ */
59
+ export declare function generateInvitationToken(): GeneratedInvitationToken;
60
+ /**
61
+ * Sends an invitation, or re-sends one: a pending row already sitting on
62
+ * `(tenant, email)` has its token and expiry rotated in place rather than a
63
+ * second row being created, which is also what the unique index on that
64
+ * pair enforces. The role on a rotated row is replaced by whatever this call
65
+ * asked for, since that is the role the grant check below just verified;
66
+ * leaving the old role in place would store something nobody re-checked.
67
+ *
68
+ * There is no check here for "this email already belongs to a member":
69
+ * a membership carries a principal id and a class, never an email (the
70
+ * import rule scans for exactly that), so there is no predicate to write.
71
+ * An address that already has an account simply accepts and finds it is
72
+ * already a member (see `acceptInvitation`, wave 8b).
73
+ */
74
+ export declare function invite(access: Access, db: DbOrTx, scopeColumn: Scope['column'], options: InviteOptions): Promise<Result<InvitationHandle>>;
75
+ /** `members:grant`; sets whether the mail for one invitation went out. Never audited: this is delivery bookkeeping, not an authorisation event. */
76
+ export declare function markInvitationMail(access: Access, db: DbOrTx, invitationId: string, state: 'sent' | 'failed'): Promise<Result<void>>;
77
+ /**
78
+ * A re-invite: reads the row's current email and role and sends through the
79
+ * same path `invite` uses, which is what rotates its token and expiry.
80
+ * Gated on `members:grant` before the row is even read, the same as
81
+ * `markInvitationMail`: without that, a member who cannot see invitations at
82
+ * all could still learn that a given invitation id exists here, and whether
83
+ * it is still pending, just from which refusal comes back.
84
+ */
85
+ export declare function resendInvitation(access: Access, db: DbOrTx, scopeColumn: Scope['column'], invitationId: string, options: Pick<InviteOptions, 'capPerHour' | 'link'>): Promise<Result<InvitationHandle>>;
86
+ /**
87
+ * Withdraws a pending invitation. Guarded the same way removing a member is:
88
+ * `members:revoke` and the guard-permission test on the role being taken
89
+ * away, and, for an owner invitation specifically, the same coverage rule
90
+ * a host's own membership removal enforces for a membership row: revoking it
91
+ * must not leave the tenant with no owner and no other pending owner
92
+ * invitation.
93
+ */
94
+ export declare function revokeInvitation(access: Access, db: DbOrTx, scopeColumn: Scope['column'], invitationId: string): Promise<Result<void>>;
95
+ /** What a born-attached invitation reports when the attachment itself could not be completed. The new owner stands either way; see `attemptBornAttach`. */
96
+ export interface AttachRefusal {
97
+ readonly reason: string;
98
+ readonly message: string;
99
+ }
100
+ export interface AcceptInvitationResult {
101
+ readonly tenantId: string;
102
+ /** Set only on a born-attached invitation: whether `attemptBornAttach` actually completed the attachment. */
103
+ readonly attached?: boolean;
104
+ /** Set only when a born-attached invitation's attachment was refused. The membership this function just wrote stands regardless. */
105
+ readonly attachRefusal?: AttachRefusal;
106
+ }
107
+ /**
108
+ * Accepts an invitation. There is no `Access` yet: the person is not a
109
+ * member until this function decides they may become one, so every check
110
+ * here reads rows directly rather than resolving one. Order follows D10 and
111
+ * D3 exactly: not pending, expired, unverified email, mismatched email,
112
+ * tenant state, then the inviter's authority re-checked as it stands now
113
+ * (not as it stood when they sent the invitation), then the profile, then
114
+ * the membership itself.
115
+ *
116
+ * `binding` and `policy` are threaded through to `roleForInvitation`,
117
+ * `loadAccess`/`operatorMayStillOnboard` and `attemptBornAttach`, the same
118
+ * as every other write this campaign has moved. `profiles` is the host's own
119
+ * port over its `profiles` table's one question this function asks --
120
+ * reusing `Pick<OwnerActivityLookup, 'personKnown'>` from install-owner.ts
121
+ * rather than declaring a second port for the same "has this id ever signed
122
+ * in" question.
123
+ */
124
+ export declare function acceptInvitation(principal: Principal, db: DbOrTx, scopeColumn: Scope['column'], binding: StoreBinding, policy: Pick<StorePolicy, 'maxDepth' | 'offered'>, profiles: Pick<OwnerActivityLookup, 'personKnown'>, token: string): Promise<Result<AcceptInvitationResult>>;
125
+ export interface PendingInvitationSummary {
126
+ readonly id: string;
127
+ readonly email: string;
128
+ readonly role: string;
129
+ readonly status: string;
130
+ readonly mailState: string;
131
+ readonly invitedBy: string | null;
132
+ readonly expiresAt: Date;
133
+ readonly createdAt: Date;
134
+ /** The organisation this invitation would attach to on acceptance, born-attached only (D19). */
135
+ readonly parentName: string | null;
136
+ }
137
+ /** `members:read`; every pending invitation for the members page, with `token_hash` never among the selected columns. */
138
+ export declare function pendingInvitations(access: Access, db: DbOrTx): Promise<readonly PendingInvitationSummary[]>;
139
+ export interface InvitationPreview {
140
+ readonly tenantName: string;
141
+ readonly role: string;
142
+ readonly status: string;
143
+ readonly expiresAt: Date;
144
+ /** The organisation this invitation would attach to on acceptance, born-attached only (D19). Never the parent's id: this page is reachable before anyone is signed in. */
145
+ readonly parentName: string | null;
146
+ }
147
+ /** For the acceptance page, reachable before anyone is signed in: what the invitation offers, never the hash, the inviter's address, or the parent's id. */
148
+ export declare function invitationPreview(token: string, db: DbOrTx): Promise<InvitationPreview | null>;
149
+ /**
150
+ * The tenant a token's invitation belongs to, regardless of its status --
151
+ * pending, accepted, revoked or expired. Exists for one caller only: the
152
+ * accept page, so that a `not_pending` refusal (the row has already been
153
+ * used) can be told apart from every other refusal by asking a second
154
+ * question -- is this signed-in person already a member there -- without
155
+ * touching `acceptInvitation` itself. Unlike `invitationPreview`, this may
156
+ * only be called once a principal is established: it hands back the raw
157
+ * tenant id, which `invitationPreview`'s own docblock deliberately never
158
+ * does for a visitor who has not signed in yet.
159
+ */
160
+ export declare function invitationTenantId(token: string, db: DbOrTx): Promise<string | null>;