@wtfalch/authz-store 0.1.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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/credential-secret.d.ts +70 -0
- package/dist/credential-secret.js +89 -0
- package/dist/grants.d.ts +395 -0
- package/dist/grants.js +68 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/migrate.d.ts +9 -0
- package/dist/migrate.js +41 -0
- package/dist/owners.d.ts +23 -0
- package/dist/owners.js +40 -0
- package/dist/policy-schema.d.ts +812 -0
- package/dist/policy-schema.js +57 -0
- package/dist/resource-schema.d.ts +188 -0
- package/dist/resource-schema.js +22 -0
- package/dist/schema.d.ts +1821 -0
- package/dist/schema.js +193 -0
- package/dist/scoped.d.ts +140 -0
- package/dist/scoped.js +176 -0
- package/migrations/0001_baseline.sql +571 -0
- package/migrations/0002_grants.sql +27 -0
- package/package.json +44 -0
package/dist/schema.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { bigint, boolean, index, jsonb, pgTable, primaryKey, smallint, text, timestamp, uniqueIndex, uuid, } from 'drizzle-orm/pg-core';
|
|
3
|
+
/**
|
|
4
|
+
* Drizzle mirror of the authority tables: the eight drizzle/0003_authz.sql
|
|
5
|
+
* ships (tenants, roles, role_permissions, memberships, invitations,
|
|
6
|
+
* break_glass_sessions, credentials, authz_events) and the ninth
|
|
7
|
+
* drizzle/0005_nesting.sql adds (attach_proposals). drizzle/0006_credentials.sql
|
|
8
|
+
* adds a column to `credentials` rather than a table, and
|
|
9
|
+
* drizzle/0008_erasure_and_boot.sql adds the tenth (system_role_versions).
|
|
10
|
+
*
|
|
11
|
+
* CHECK constraints, partial indexes, INCLUDE columns and triggers are not
|
|
12
|
+
* expressible in this version of Drizzle's table builder and live only in
|
|
13
|
+
* the SQL; the index declarations below exist so `getTableConfig` reports
|
|
14
|
+
* one index per table index for the host's tenancy manifest,
|
|
15
|
+
* not so this file reproduces the SQL's exact predicate or INCLUDE list.
|
|
16
|
+
* Read the migration for the authoritative definition of every constraint.
|
|
17
|
+
*
|
|
18
|
+
* `profiles` is not here: it is a person-scoped table the host's own database
|
|
19
|
+
* module owns, not one of the authority tables.
|
|
20
|
+
*/
|
|
21
|
+
// ---------------------------------------------------------------- tenants
|
|
22
|
+
export const tenants = pgTable('tenants', {
|
|
23
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
24
|
+
kind: text('kind').$type().notNull().default('customer'),
|
|
25
|
+
slug: text('slug').notNull(),
|
|
26
|
+
name: text('name').notNull(),
|
|
27
|
+
state: text('state').$type().notNull().default('active'),
|
|
28
|
+
parentId: uuid('parent_id'),
|
|
29
|
+
// Vocabulary-offered permissions this tenant may use (D22). No default:
|
|
30
|
+
// the creating transaction always writes one explicitly.
|
|
31
|
+
ceiling: text('ceiling').array().notNull(),
|
|
32
|
+
selfDenied: text('self_denied').array().notNull().default(sql `'{}'`),
|
|
33
|
+
// Only the single `kind = 'operator'` row may ever be true, enforced by
|
|
34
|
+
// tenants_frozen_operator_check. The two hosts number that migration
|
|
35
|
+
// differently (0010 and 0012), which is why no number is named here.
|
|
36
|
+
frozen: boolean('frozen').notNull().default(false),
|
|
37
|
+
createdBy: text('created_by'),
|
|
38
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
39
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
40
|
+
}, (t) => [
|
|
41
|
+
// Full definition is a partial unique index (only kind = 'operator');
|
|
42
|
+
// this declares the column so the manifest sees an index here.
|
|
43
|
+
uniqueIndex('tenants_one_operator_idx').on(t.kind),
|
|
44
|
+
index('tenants_parent_idx').on(t.parentId),
|
|
45
|
+
]);
|
|
46
|
+
// ------------------------------------------------------------- memberships
|
|
47
|
+
export const memberships = pgTable('memberships', {
|
|
48
|
+
tenantId: uuid('tenant_id').notNull(),
|
|
49
|
+
principalId: text('principal_id').notNull(),
|
|
50
|
+
principalClass: text('principal_class').notNull(),
|
|
51
|
+
source: text('source').notNull().default('direct'),
|
|
52
|
+
viaTenantId: uuid('via_tenant_id'),
|
|
53
|
+
// Denormalised from tenants.name so the switcher reads one index range
|
|
54
|
+
// (D5); renameTenant rewrites it in the same transaction.
|
|
55
|
+
tenantName: text('tenant_name').notNull(),
|
|
56
|
+
grantedBy: text('granted_by'),
|
|
57
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
58
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
59
|
+
}, (t) => [
|
|
60
|
+
primaryKey({ columns: [t.tenantId, t.principalClass, t.principalId] }),
|
|
61
|
+
// Full definition also INCLUDEs (role, source, principal_class); not
|
|
62
|
+
// expressible here, see the file header.
|
|
63
|
+
index('memberships_principal_idx').on(t.principalId, t.tenantName, t.tenantId),
|
|
64
|
+
index('memberships_via_idx').on(t.viaTenantId),
|
|
65
|
+
]);
|
|
66
|
+
// ------------------------------------------------------------- invitations
|
|
67
|
+
export const invitations = pgTable('invitations', {
|
|
68
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
69
|
+
tenantId: uuid('tenant_id').notNull(),
|
|
70
|
+
email: text('email').notNull(),
|
|
71
|
+
roleId: uuid('role_id').notNull(),
|
|
72
|
+
tokenHash: text('token_hash').notNull().unique(),
|
|
73
|
+
status: text('status').notNull().default('pending'),
|
|
74
|
+
mailState: text('mail_state').notNull().default('pending'),
|
|
75
|
+
// When mail_state last changed, so D9's "mail failed for more than an
|
|
76
|
+
// hour" alert measures the right thing (0008). Null on a row that
|
|
77
|
+
// predates the column.
|
|
78
|
+
mailStateAt: timestamp('mail_state_at', { withTimezone: true }),
|
|
79
|
+
invitedBy: text('invited_by'),
|
|
80
|
+
invitedByClass: text('invited_by_class'),
|
|
81
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
82
|
+
acceptedBy: text('accepted_by'),
|
|
83
|
+
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
|
84
|
+
revokedBy: text('revoked_by'),
|
|
85
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
86
|
+
// A child a parent creates is born attached (D10, D19): accepting this
|
|
87
|
+
// invitation writes the first owner's membership and performs the
|
|
88
|
+
// attachment in one transaction. Null on every ordinary invitation.
|
|
89
|
+
attachParentId: uuid('attach_parent_id'),
|
|
90
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
91
|
+
}, (t) => [
|
|
92
|
+
// Full definition is a partial unique index (only status = 'pending');
|
|
93
|
+
// this declares the columns so the manifest sees an index here.
|
|
94
|
+
uniqueIndex('invitations_pending_idx').on(t.tenantId, t.email),
|
|
95
|
+
index('invitations_tenant_idx').on(t.tenantId, t.createdAt.desc()),
|
|
96
|
+
]);
|
|
97
|
+
// -------------------------------------------------------- attach_proposals
|
|
98
|
+
export const attachProposals = pgTable('attach_proposals', {
|
|
99
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
100
|
+
parentId: uuid('parent_id').notNull(),
|
|
101
|
+
childId: uuid('child_id').notNull(),
|
|
102
|
+
status: text('status').notNull().default('pending'),
|
|
103
|
+
reason: text('reason').notNull(),
|
|
104
|
+
reference: text('reference').notNull(),
|
|
105
|
+
proposedBy: text('proposed_by').notNull(),
|
|
106
|
+
parentAcceptedBy: text('parent_accepted_by'),
|
|
107
|
+
parentAcceptedAt: timestamp('parent_accepted_at', { withTimezone: true }),
|
|
108
|
+
decidedBy: text('decided_by'),
|
|
109
|
+
decidedAt: timestamp('decided_at', { withTimezone: true }),
|
|
110
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
111
|
+
}, (t) => [
|
|
112
|
+
// Full definition is a partial unique index (only status = 'pending');
|
|
113
|
+
// this declares the columns so the manifest sees an index here.
|
|
114
|
+
uniqueIndex('attach_proposals_pending_idx').on(t.parentId, t.childId),
|
|
115
|
+
index('attach_proposals_child_idx').on(t.childId, t.createdAt.desc()),
|
|
116
|
+
index('attach_proposals_parent_idx').on(t.parentId, t.createdAt.desc()),
|
|
117
|
+
]);
|
|
118
|
+
// ----------------------------------------------------- break_glass_sessions
|
|
119
|
+
export const breakGlassSessions = pgTable('break_glass_sessions', {
|
|
120
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
121
|
+
operatorId: text('operator_id').notNull(),
|
|
122
|
+
tenantId: uuid('tenant_id').notNull(),
|
|
123
|
+
roleId: uuid('role_id').notNull(),
|
|
124
|
+
readOnly: boolean('read_only').notNull().default(true),
|
|
125
|
+
reasonCode: text('reason_code').notNull(),
|
|
126
|
+
reference: text('reference').notNull(),
|
|
127
|
+
startedAt: timestamp('started_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
128
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
129
|
+
endedAt: timestamp('ended_at', { withTimezone: true }),
|
|
130
|
+
endedBy: text('ended_by'),
|
|
131
|
+
}, (t) => [
|
|
132
|
+
// Full definition is partial (WHERE ended_at IS NULL); see the header.
|
|
133
|
+
index('bg_active_idx').on(t.operatorId, t.tenantId),
|
|
134
|
+
index('bg_tenant_idx').on(t.tenantId, t.startedAt.desc()),
|
|
135
|
+
]);
|
|
136
|
+
// -------------------------------------------------------------- credentials
|
|
137
|
+
export const credentials = pgTable('credentials', {
|
|
138
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
139
|
+
tenantId: uuid('tenant_id').notNull(),
|
|
140
|
+
kind: text('kind').notNull(),
|
|
141
|
+
issuerId: text('issuer_id').notNull(),
|
|
142
|
+
issuerClass: text('issuer_class').notNull(),
|
|
143
|
+
name: text('name').notNull(),
|
|
144
|
+
secretHash: text('secret_hash').notNull().unique(),
|
|
145
|
+
// The first characters of the secret's random half, kept in clear so a
|
|
146
|
+
// list can tell one credential from another (0006). Not a secret.
|
|
147
|
+
secretPrefix: text('secret_prefix'),
|
|
148
|
+
createdBy: text('created_by'),
|
|
149
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
150
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
|
151
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
152
|
+
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
|
153
|
+
}, (t) => [
|
|
154
|
+
index('credentials_tenant_idx').on(t.tenantId),
|
|
155
|
+
// Full definition is partial (WHERE revoked_at IS NULL); see the header.
|
|
156
|
+
index('credentials_live_idx').on(t.tenantId, t.createdAt.desc()),
|
|
157
|
+
]);
|
|
158
|
+
// ------------------------------------------------------------- authz_events
|
|
159
|
+
export const authzEvents = pgTable('authz_events', {
|
|
160
|
+
// (occurred_at, id) is the ordering; see the migration for why this is a
|
|
161
|
+
// bigint identity rather than a time-ordered uuid.
|
|
162
|
+
id: bigint('id', { mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(),
|
|
163
|
+
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().default(sql `now()`),
|
|
164
|
+
tenantId: uuid('tenant_id'),
|
|
165
|
+
teamId: uuid('team_id'),
|
|
166
|
+
subjectId: text('subject_id'),
|
|
167
|
+
subjectClass: text('subject_class'),
|
|
168
|
+
actorClass: text('actor_class').notNull(),
|
|
169
|
+
actorId: text('actor_id').notNull(),
|
|
170
|
+
actorDisplay: text('actor_display').notNull(),
|
|
171
|
+
action: text('action').notNull(),
|
|
172
|
+
targetType: text('target_type').notNull(),
|
|
173
|
+
targetId: text('target_id').notNull(),
|
|
174
|
+
outcome: text('outcome').notNull(),
|
|
175
|
+
context: text('context').notNull(),
|
|
176
|
+
sessionId: text('session_id'),
|
|
177
|
+
reason: text('reason'),
|
|
178
|
+
reference: text('reference'),
|
|
179
|
+
requestId: text('request_id'),
|
|
180
|
+
ip: text('ip'),
|
|
181
|
+
userAgent: text('user_agent'),
|
|
182
|
+
tenantVisible: boolean('tenant_visible').notNull(),
|
|
183
|
+
before: jsonb('before'),
|
|
184
|
+
after: jsonb('after'),
|
|
185
|
+
erasedAt: timestamp('erased_at', { withTimezone: true }),
|
|
186
|
+
schemaVersion: smallint('schema_version').notNull().default(1),
|
|
187
|
+
}, (t) => [
|
|
188
|
+
index('authz_events_tenant_time_idx').on(t.tenantId, t.occurredAt.desc(), t.id.desc()),
|
|
189
|
+
index('authz_events_actor_time_idx').on(t.actorId, t.occurredAt.desc()),
|
|
190
|
+
index('authz_events_team_time_idx').on(t.tenantId, t.teamId, t.occurredAt.desc(), t.id.desc()),
|
|
191
|
+
index('authz_events_subject_time_idx').on(t.tenantId, t.subjectClass, t.subjectId, t.occurredAt.desc(), t.id.desc()),
|
|
192
|
+
index('authz_events_action_time_idx').on(t.action, t.occurredAt.desc()),
|
|
193
|
+
]);
|
package/dist/scoped.d.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { type SQL } from 'drizzle-orm';
|
|
2
|
+
import type { PgColumn, PgDatabase, PgInsertValue, PgQueryResultHKT, PgTable, PgTransaction, PgUpdateSetSource } from 'drizzle-orm/pg-core';
|
|
3
|
+
/**
|
|
4
|
+
* Thrown by a write method (insert, update, delete, transaction) called on a
|
|
5
|
+
* scope that is read-only: a tenant whose state is `read_only` or
|
|
6
|
+
* `suspended`, or a read-only break-glass session. Thrown synchronously,
|
|
7
|
+
* before a query is built or a connection touched, so a page that writes on
|
|
8
|
+
* read (a last-viewed stamp, a draft autosave) cannot leak a write through a
|
|
9
|
+
* read-only context regardless of what the call site already checked (D6,
|
|
10
|
+
* M4).
|
|
11
|
+
*/
|
|
12
|
+
export declare class ReadOnlyAccessError extends Error {
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Thrown when a table has no column registered for the scope in use.
|
|
16
|
+
* The host's registries are the only source of truth for what a `ScopedDb`
|
|
17
|
+
* may touch; a table missing from all of them is a bug in the host's registry,
|
|
18
|
+
* not something the caller can work around.
|
|
19
|
+
*/
|
|
20
|
+
export declare class UnscopedTableError extends Error {
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What one `ScopedDb` carries. `column` is a function rather than one fixed
|
|
24
|
+
* column because a single scope kind (tenant, person) covers many tables,
|
|
25
|
+
* each with its own scope column; `value` is the id every predicate compares
|
|
26
|
+
* it against; `readOnly` and `label` are used by the guard and its error
|
|
27
|
+
* messages respectively.
|
|
28
|
+
*/
|
|
29
|
+
export interface Scope {
|
|
30
|
+
readonly column: (table: PgTable) => PgColumn | undefined;
|
|
31
|
+
readonly value: string;
|
|
32
|
+
readonly readOnly: boolean;
|
|
33
|
+
readonly label: 'tenant' | 'person';
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Either the raw handle or a transaction already open on it. Every method
|
|
37
|
+
* below accepts both, so code already inside one transaction (a `grant`
|
|
38
|
+
* writing a membership and its audit row together) passes that transaction
|
|
39
|
+
* through rather than opening a second, nested one for no reason.
|
|
40
|
+
*
|
|
41
|
+
* Named through drizzle's own base types rather than one host's concrete
|
|
42
|
+
* handle: this package does not know which driver a host uses, and every
|
|
43
|
+
* method here builds statements rather than sending them.
|
|
44
|
+
*/
|
|
45
|
+
type HostSchema = any;
|
|
46
|
+
export type DbOrTx = PgDatabase<PgQueryResultHKT, HostSchema, HostSchema> | PgTransaction<PgQueryResultHKT, HostSchema, HostSchema>;
|
|
47
|
+
/**
|
|
48
|
+
* The only way feature code touches a tenant or person table (D6). A tenant
|
|
49
|
+
* instance is built by `openScopedDb` from a fresh membership read inside
|
|
50
|
+
* the host's `accessFor`; a person instance is built from that person's own
|
|
51
|
+
* id. Nothing else constructs one, which is what the private constructor is
|
|
52
|
+
* for: even a module-level function in this same file cannot call it, only a
|
|
53
|
+
* static member of the class, which is why `openScopedDb` wraps
|
|
54
|
+
* `ScopedDb.open` rather than calling `new ScopedDb` itself.
|
|
55
|
+
*
|
|
56
|
+
* Every write method refuses at the query-builder layer when the scope is
|
|
57
|
+
* read-only, before building or sending anything, independent of whatever
|
|
58
|
+
* the call site already decided.
|
|
59
|
+
*/
|
|
60
|
+
export declare class ScopedDb {
|
|
61
|
+
private readonly root;
|
|
62
|
+
private readonly scope;
|
|
63
|
+
private constructor();
|
|
64
|
+
static open(root: DbOrTx, scope: Scope): ScopedDb;
|
|
65
|
+
private columnFor;
|
|
66
|
+
/**
|
|
67
|
+
* The property key an insert or update payload uses for this column.
|
|
68
|
+
* Drizzle keys `.values()`/`.set()` by the table's own field name (for
|
|
69
|
+
* example `tenantId`), not the SQL column name (`tenant_id`), and a column
|
|
70
|
+
* does not know its own field name, so this looks it up the same way
|
|
71
|
+
* drizzle itself does: by identity against `getTableColumns`.
|
|
72
|
+
*/
|
|
73
|
+
private keyFor;
|
|
74
|
+
private assertWritable;
|
|
75
|
+
/**
|
|
76
|
+
* The predicate every method here builds from: the scope column compared
|
|
77
|
+
* to the scope's own value. Exposed on its own because a caller sometimes
|
|
78
|
+
* needs it directly (a join, a count) rather than through one of the
|
|
79
|
+
* methods below.
|
|
80
|
+
*/
|
|
81
|
+
predicate(table: PgTable): SQL;
|
|
82
|
+
/**
|
|
83
|
+
* The select builder, scoped, so `.orderBy`/`.limit`/further `.where`
|
|
84
|
+
* continue to chain on the result. The cast on `table` works around a
|
|
85
|
+
* drizzle typing limitation (a generic table parameter does not satisfy
|
|
86
|
+
* `.from()`'s subquery-detection conditional); the query it builds is
|
|
87
|
+
* identical either way, the cast only affects the row type TypeScript
|
|
88
|
+
* infers back, which is why insert and update below, which do not touch
|
|
89
|
+
* `.from()`, keep their full per-table typing.
|
|
90
|
+
*/
|
|
91
|
+
select<T extends PgTable>(table: T, extra?: SQL): Omit<import("drizzle-orm/pg-core").PgSelectBase<string, Record<string, PgColumn<import("drizzle-orm").ColumnBaseConfig<import("drizzle-orm").ColumnDataType, string>, {}, {}>>, "single", Record<string, "not-null">, false, "where", {
|
|
92
|
+
[x: string]: unknown;
|
|
93
|
+
}[], {
|
|
94
|
+
[x: string]: PgColumn<import("drizzle-orm").ColumnBaseConfig<import("drizzle-orm").ColumnDataType, string>, {}, {}>;
|
|
95
|
+
}>, "where">;
|
|
96
|
+
/**
|
|
97
|
+
* Strips the scope column from every row and writes the scope's own value
|
|
98
|
+
* instead, after the spread rather than merged into it, so a caller-
|
|
99
|
+
* supplied value for that column (a compromised form body setting
|
|
100
|
+
* `tenantId` to some other tenant) never survives into the statement.
|
|
101
|
+
*/
|
|
102
|
+
insert<T extends PgTable>(table: T, values: PgInsertValue<T> | PgInsertValue<T>[]): import("drizzle-orm/pg-core").PgInsertBase<T, PgQueryResultHKT, undefined, undefined, false, never>;
|
|
103
|
+
/**
|
|
104
|
+
* Strips the scope column from `set` (dropped, not overwritten: an update
|
|
105
|
+
* never moves a row between tenants) and adds the scope predicate to the
|
|
106
|
+
* WHERE, so a write can only ever land on the caller's own rows.
|
|
107
|
+
*/
|
|
108
|
+
update<T extends PgTable>(table: T, set: Partial<PgUpdateSetSource<T>>, extra?: SQL): Omit<import("drizzle-orm/pg-core").PgUpdateBase<T, PgQueryResultHKT, undefined, undefined, undefined, Record<T["_"]["name"], "not-null">, [], false, "where" | "leftJoin" | "rightJoin" | "innerJoin" | "fullJoin">, "where" | "leftJoin" | "rightJoin" | "innerJoin" | "fullJoin">;
|
|
109
|
+
/**
|
|
110
|
+
* `extra` is required, not optional: a delete with only the scope
|
|
111
|
+
* predicate would remove every row this principal can see, so the type
|
|
112
|
+
* system refuses a call without it. The runtime check is the second net
|
|
113
|
+
* for a caller that reaches this past the type system (an `any`-typed call
|
|
114
|
+
* site).
|
|
115
|
+
*/
|
|
116
|
+
delete<T extends PgTable>(table: T, extra: SQL): Omit<import("drizzle-orm/pg-core").PgDeleteBase<T, PgQueryResultHKT, undefined, undefined, false, "where">, "where">;
|
|
117
|
+
/**
|
|
118
|
+
* Refused outright on a read-only scope, the same as insert, update and
|
|
119
|
+
* delete: a transaction is opened to write, and refusing only once the
|
|
120
|
+
* callback attempts one would already have reached the client (postgres-js
|
|
121
|
+
* sends `BEGIN` the moment `transaction()` is called, before the callback
|
|
122
|
+
* ever runs), which is exactly what the read-only guard exists to prevent.
|
|
123
|
+
*/
|
|
124
|
+
transaction<R>(fn: (tx: ScopedDb) => Promise<R>): Promise<R>;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The only way to build a `ScopedDb`. A host calls this from the two places
|
|
128
|
+
* that legitimately mint a scope — a tenant scope from a fresh membership
|
|
129
|
+
* read, and a person scope from a person's own id — and re-exports neither
|
|
130
|
+
* this nor the class from its own barrel, so a feature module cannot mint a
|
|
131
|
+
* scope of its own.
|
|
132
|
+
*/
|
|
133
|
+
export declare function openScopedDb(root: DbOrTx, scope: Scope): ScopedDb;
|
|
134
|
+
/**
|
|
135
|
+
* Turns a host's table-to-column registry into a `Scope['column']`. The
|
|
136
|
+
* registry itself is the host's: only it knows which of its tables carry a
|
|
137
|
+
* tenant id and which carry a person id.
|
|
138
|
+
*/
|
|
139
|
+
export declare function scopeColumnOf(tables: ReadonlyMap<PgTable, PgColumn>): (table: PgTable) => PgColumn | undefined;
|
|
140
|
+
export {};
|
package/dist/scoped.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { and, eq, getTableColumns } from 'drizzle-orm';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by a write method (insert, update, delete, transaction) called on a
|
|
4
|
+
* scope that is read-only: a tenant whose state is `read_only` or
|
|
5
|
+
* `suspended`, or a read-only break-glass session. Thrown synchronously,
|
|
6
|
+
* before a query is built or a connection touched, so a page that writes on
|
|
7
|
+
* read (a last-viewed stamp, a draft autosave) cannot leak a write through a
|
|
8
|
+
* read-only context regardless of what the call site already checked (D6,
|
|
9
|
+
* M4).
|
|
10
|
+
*/
|
|
11
|
+
export class ReadOnlyAccessError extends Error {
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Thrown when a table has no column registered for the scope in use.
|
|
15
|
+
* The host's registries are the only source of truth for what a `ScopedDb`
|
|
16
|
+
* may touch; a table missing from all of them is a bug in the host's registry,
|
|
17
|
+
* not something the caller can work around.
|
|
18
|
+
*/
|
|
19
|
+
export class UnscopedTableError extends Error {
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The only way feature code touches a tenant or person table (D6). A tenant
|
|
23
|
+
* instance is built by `openScopedDb` from a fresh membership read inside
|
|
24
|
+
* the host's `accessFor`; a person instance is built from that person's own
|
|
25
|
+
* id. Nothing else constructs one, which is what the private constructor is
|
|
26
|
+
* for: even a module-level function in this same file cannot call it, only a
|
|
27
|
+
* static member of the class, which is why `openScopedDb` wraps
|
|
28
|
+
* `ScopedDb.open` rather than calling `new ScopedDb` itself.
|
|
29
|
+
*
|
|
30
|
+
* Every write method refuses at the query-builder layer when the scope is
|
|
31
|
+
* read-only, before building or sending anything, independent of whatever
|
|
32
|
+
* the call site already decided.
|
|
33
|
+
*/
|
|
34
|
+
export class ScopedDb {
|
|
35
|
+
root;
|
|
36
|
+
scope;
|
|
37
|
+
constructor(root, scope) {
|
|
38
|
+
this.root = root;
|
|
39
|
+
this.scope = scope;
|
|
40
|
+
}
|
|
41
|
+
static open(root, scope) {
|
|
42
|
+
return new ScopedDb(root, scope);
|
|
43
|
+
}
|
|
44
|
+
columnFor(table) {
|
|
45
|
+
const column = this.scope.column(table);
|
|
46
|
+
if (!column) {
|
|
47
|
+
throw new UnscopedTableError(`table is not registered for the ${this.scope.label} scope; add it to the host's registry`);
|
|
48
|
+
}
|
|
49
|
+
return column;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The property key an insert or update payload uses for this column.
|
|
53
|
+
* Drizzle keys `.values()`/`.set()` by the table's own field name (for
|
|
54
|
+
* example `tenantId`), not the SQL column name (`tenant_id`), and a column
|
|
55
|
+
* does not know its own field name, so this looks it up the same way
|
|
56
|
+
* drizzle itself does: by identity against `getTableColumns`.
|
|
57
|
+
*/
|
|
58
|
+
keyFor(table, column) {
|
|
59
|
+
for (const [key, candidate] of Object.entries(getTableColumns(table))) {
|
|
60
|
+
if (candidate === column)
|
|
61
|
+
return key;
|
|
62
|
+
}
|
|
63
|
+
// Unreachable given the invariant that every entry in a host's registry is
|
|
64
|
+
// one of that table's own columns.
|
|
65
|
+
throw new UnscopedTableError("the scope column is not one of this table's own columns");
|
|
66
|
+
}
|
|
67
|
+
assertWritable() {
|
|
68
|
+
if (this.scope.readOnly) {
|
|
69
|
+
throw new ReadOnlyAccessError(`the ${this.scope.label} scope is read-only`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The predicate every method here builds from: the scope column compared
|
|
74
|
+
* to the scope's own value. Exposed on its own because a caller sometimes
|
|
75
|
+
* needs it directly (a join, a count) rather than through one of the
|
|
76
|
+
* methods below.
|
|
77
|
+
*/
|
|
78
|
+
predicate(table) {
|
|
79
|
+
return eq(this.columnFor(table), this.scope.value);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The select builder, scoped, so `.orderBy`/`.limit`/further `.where`
|
|
83
|
+
* continue to chain on the result. The cast on `table` works around a
|
|
84
|
+
* drizzle typing limitation (a generic table parameter does not satisfy
|
|
85
|
+
* `.from()`'s subquery-detection conditional); the query it builds is
|
|
86
|
+
* identical either way, the cast only affects the row type TypeScript
|
|
87
|
+
* infers back, which is why insert and update below, which do not touch
|
|
88
|
+
* `.from()`, keep their full per-table typing.
|
|
89
|
+
*/
|
|
90
|
+
select(table, extra) {
|
|
91
|
+
const pred = this.predicate(table);
|
|
92
|
+
return this.root
|
|
93
|
+
.select()
|
|
94
|
+
.from(table)
|
|
95
|
+
.where(extra ? and(pred, extra) : pred);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Strips the scope column from every row and writes the scope's own value
|
|
99
|
+
* instead, after the spread rather than merged into it, so a caller-
|
|
100
|
+
* supplied value for that column (a compromised form body setting
|
|
101
|
+
* `tenantId` to some other tenant) never survives into the statement.
|
|
102
|
+
*/
|
|
103
|
+
insert(table, values) {
|
|
104
|
+
this.assertWritable();
|
|
105
|
+
const column = this.columnFor(table);
|
|
106
|
+
const key = this.keyFor(table, column);
|
|
107
|
+
const rows = Array.isArray(values) ? values : [values];
|
|
108
|
+
const scoped = rows.map((row) => ({ ...row, [key]: this.scope.value }));
|
|
109
|
+
return this.root.insert(table).values(scoped);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Strips the scope column from `set` (dropped, not overwritten: an update
|
|
113
|
+
* never moves a row between tenants) and adds the scope predicate to the
|
|
114
|
+
* WHERE, so a write can only ever land on the caller's own rows.
|
|
115
|
+
*/
|
|
116
|
+
update(table, set, extra) {
|
|
117
|
+
this.assertWritable();
|
|
118
|
+
const column = this.columnFor(table);
|
|
119
|
+
const key = this.keyFor(table, column);
|
|
120
|
+
const rest = { ...set };
|
|
121
|
+
delete rest[key];
|
|
122
|
+
const pred = this.predicate(table);
|
|
123
|
+
return this.root
|
|
124
|
+
.update(table)
|
|
125
|
+
.set(rest)
|
|
126
|
+
.where(extra ? and(pred, extra) : pred);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* `extra` is required, not optional: a delete with only the scope
|
|
130
|
+
* predicate would remove every row this principal can see, so the type
|
|
131
|
+
* system refuses a call without it. The runtime check is the second net
|
|
132
|
+
* for a caller that reaches this past the type system (an `any`-typed call
|
|
133
|
+
* site).
|
|
134
|
+
*/
|
|
135
|
+
delete(table, extra) {
|
|
136
|
+
this.assertWritable();
|
|
137
|
+
if (!extra) {
|
|
138
|
+
throw new Error('ScopedDb.delete requires an explicit predicate');
|
|
139
|
+
}
|
|
140
|
+
const pred = this.predicate(table);
|
|
141
|
+
return this.root.delete(table).where(and(pred, extra));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Refused outright on a read-only scope, the same as insert, update and
|
|
145
|
+
* delete: a transaction is opened to write, and refusing only once the
|
|
146
|
+
* callback attempts one would already have reached the client (postgres-js
|
|
147
|
+
* sends `BEGIN` the moment `transaction()` is called, before the callback
|
|
148
|
+
* ever runs), which is exactly what the read-only guard exists to prevent.
|
|
149
|
+
*/
|
|
150
|
+
transaction(fn) {
|
|
151
|
+
// Deliberately not declared `async`: an `async` function always returns a
|
|
152
|
+
// promise and never throws to its caller synchronously, even when the
|
|
153
|
+
// very first line throws, which would turn this guard into a rejection
|
|
154
|
+
// instead of the synchronous refusal every other write method gives.
|
|
155
|
+
this.assertWritable();
|
|
156
|
+
return this.root.transaction((tx) => fn(ScopedDb.open(tx, this.scope)));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The only way to build a `ScopedDb`. A host calls this from the two places
|
|
161
|
+
* that legitimately mint a scope — a tenant scope from a fresh membership
|
|
162
|
+
* read, and a person scope from a person's own id — and re-exports neither
|
|
163
|
+
* this nor the class from its own barrel, so a feature module cannot mint a
|
|
164
|
+
* scope of its own.
|
|
165
|
+
*/
|
|
166
|
+
export function openScopedDb(root, scope) {
|
|
167
|
+
return ScopedDb.open(root, scope);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Turns a host's table-to-column registry into a `Scope['column']`. The
|
|
171
|
+
* registry itself is the host's: only it knows which of its tables carry a
|
|
172
|
+
* tenant id and which carry a person id.
|
|
173
|
+
*/
|
|
174
|
+
export function scopeColumnOf(tables) {
|
|
175
|
+
return (table) => tables.get(table);
|
|
176
|
+
}
|