@coffre/db 0.4.6 → 0.5.1

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 (45) hide show
  1. package/dist/connect.d.ts +1 -1
  2. package/dist/{database-edkMkG02.d.ts → database-C63WjCrC.d.ts} +1 -1
  3. package/dist/dialect.d.ts +1 -1
  4. package/dist/grants.d.ts +105 -10
  5. package/dist/grants.js +207 -7
  6. package/dist/hyperdrive.d.ts +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/log.d.ts +392 -2
  9. package/dist/log.js +53 -1
  10. package/dist/migrate.d.ts +41 -4
  11. package/dist/migrate.js +95 -8
  12. package/dist/migrations/postgres/0002_instance_roles.sql +4 -0
  13. package/dist/migrations/postgres/0003_credential_scopes.sql +4 -0
  14. package/dist/migrations/postgres/meta/0002_snapshot.json +2860 -0
  15. package/dist/migrations/postgres/meta/0003_snapshot.json +2872 -0
  16. package/dist/migrations/postgres/meta/_journal.json +14 -0
  17. package/dist/migrations/sqlite/0002_instance_roles.sql +2 -0
  18. package/dist/migrations/sqlite/0003_credential_scopes.sql +2 -0
  19. package/dist/migrations/sqlite/meta/0002_snapshot.json +2594 -0
  20. package/dist/migrations/sqlite/meta/0003_snapshot.json +2608 -0
  21. package/dist/migrations/sqlite/meta/_journal.json +14 -0
  22. package/dist/portable.d.ts +2 -2
  23. package/dist/{schema-BqCB3XFm.d.ts → schema-a7PkFgea.d.ts} +68 -0
  24. package/dist/schema-sqlite.d.ts +76 -0
  25. package/dist/schema-sqlite.js +4 -0
  26. package/dist/schema-version.d.ts +1 -1
  27. package/dist/schema-version.js +60 -26
  28. package/dist/schema.d.ts +1 -1
  29. package/dist/schema.js +4 -0
  30. package/package.json +2 -2
  31. package/src/grants.ts +272 -11
  32. package/src/log.ts +66 -2
  33. package/src/migrate.ts +111 -6
  34. package/src/migrations/postgres/0002_instance_roles.sql +4 -0
  35. package/src/migrations/postgres/0003_credential_scopes.sql +4 -0
  36. package/src/migrations/postgres/meta/0002_snapshot.json +2860 -0
  37. package/src/migrations/postgres/meta/0003_snapshot.json +2872 -0
  38. package/src/migrations/postgres/meta/_journal.json +14 -0
  39. package/src/migrations/sqlite/0002_instance_roles.sql +2 -0
  40. package/src/migrations/sqlite/0003_credential_scopes.sql +2 -0
  41. package/src/migrations/sqlite/meta/0002_snapshot.json +2594 -0
  42. package/src/migrations/sqlite/meta/0003_snapshot.json +2608 -0
  43. package/src/migrations/sqlite/meta/_journal.json +14 -0
  44. package/src/schema.sqlite.ts +6 -0
  45. package/src/schema.ts +11 -1
package/src/grants.ts CHANGED
@@ -1,13 +1,31 @@
1
- import { and, eq, gt, isNull, or } from 'drizzle-orm';
1
+ import {
2
+ administers,
3
+ convertEveryProjectGrants,
4
+ EVERYWHERE,
5
+ isInstanceRole,
6
+ isScope,
7
+ normalScope,
8
+ unscoped,
9
+ type Conversion,
10
+ type InstanceRole,
11
+ type Role,
12
+ type Scope,
13
+ } from '@coffre/core/access';
14
+ import type { StoredEntry } from '@coffre/core/audit';
15
+ import { isTombstone } from '@coffre/core/schemas';
16
+ import { ACCESS_ACTIONS } from '@coffre/core/vault';
17
+ import { and, desc, eq, gt, inArray, isNull, lt, or, sql, type SQL } from 'drizzle-orm';
2
18
 
3
19
  import { tablesOf, type Queryable } from './database.ts';
20
+ import { entryColumns, LOOK_BACK, newestAuthentic, type Authenticity, type Newest } from './log.ts';
4
21
 
5
22
  /**
6
23
  * One grant, as `vault_grants` holds it: on a project (`environmentId`
7
- * null), one of its environments (`projectId` is the environment's
8
- * project), every project (both ids null), or one environment slug in every
9
- * project (`environmentSlug`). Only the vault writes grants; the app reads
10
- * them for its lists.
24
+ * null), or one of its environments (`projectId` is the environment's
25
+ * project). A grant on every project of 0.4 names neither, and
26
+ * `environmentSlug` the one environment slug it covers in each, until the
27
+ * vault replaces it. Only the vault writes grants; the app reads them for
28
+ * its lists.
11
29
  */
12
30
  export type GrantRow = {
13
31
  principal: string;
@@ -21,13 +39,10 @@ export type GrantRow = {
21
39
  grantedBy: string;
22
40
  };
23
41
 
24
- /**
25
- * Grants, lapsed ones too unless `liveAt` says when to judge them: one
26
- * member's, or everyone's; `everyProject`, only those on every project.
27
- */
42
+ /** Grants, lapsed ones too unless `liveAt` says when to judge them: one member's, or everyone's. */
28
43
  export async function readGrants(
29
44
  db: Queryable,
30
- filter: { principal?: string; liveAt?: number; everyProject?: boolean } = {},
45
+ filter: { principal?: string; liveAt?: number } = {},
31
46
  ): Promise<GrantRow[]> {
32
47
  const { vaultGrants, environments } = tablesOf(db);
33
48
  const rows = await db
@@ -38,7 +53,6 @@ export async function readGrants(
38
53
  and(
39
54
  filter.principal === undefined ? undefined : eq(vaultGrants.principal, filter.principal),
40
55
  filter.liveAt === undefined ? undefined : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, filter.liveAt)),
41
- filter.everyProject === true ? and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId)) : undefined,
42
56
  ),
43
57
  );
44
58
  return rows.map(({ grant, environmentProjectId }) => ({ ...grant, projectId: grant.projectId ?? environmentProjectId }));
@@ -49,3 +63,250 @@ export async function insertGrant(db: Queryable, grant: GrantRow): Promise<void>
49
63
  const { vaultGrants } = tablesOf(db);
50
64
  await db.insert(vaultGrants).values({ ...grant, projectId: grant.environmentId === null ? grant.projectId : null });
51
65
  }
66
+
67
+ /** A member's instance role and its scope, as `vault_members` stores them. */
68
+ export type StoredRole = { owner: boolean; role: string | null; scope: string | null };
69
+
70
+ /**
71
+ * A member's instance role and its scope, as their row says: a row no vault
72
+ * of 0.5 wrote has no role, and its `owner` says whether they are an Admin.
73
+ * A role or a scope the vault would never have written reads as holding
74
+ * nothing (the vault then refuses the row by its MAC); a member's scope is
75
+ * everywhere, since a member's role reaches nothing.
76
+ */
77
+ export function storedRole({ owner, role, scope }: StoredRole): { role: InstanceRole; scope: Scope } {
78
+ const named: InstanceRole = role === null ? (owner ? 'admin' : 'member') : isInstanceRole(role) ? role : 'member';
79
+ if (named === 'member' || scope === null) return { role: named, scope: EVERYWHERE };
80
+ let parsed: unknown = null;
81
+ try {
82
+ parsed = JSON.parse(scope);
83
+ } catch {}
84
+ return { role: named, scope: isScope(parsed) ? normalScope(parsed) : NOWHERE };
85
+ }
86
+
87
+ /** A scope that takes in nothing: what a stored scope nobody can read means. */
88
+ const NOWHERE: Scope = { projects: { only: [] }, environments: { only: [] } };
89
+
90
+ /** The columns that store a member's instance role and scope: `owner` too, for a vault of 0.4 to read. */
91
+ export function roleColumns(role: InstanceRole, scope: Scope): StoredRole {
92
+ const normal = normalScope(scope);
93
+ return { owner: administers(role), role, scope: role === 'member' || unscoped(normal) ? null : JSON.stringify(normal) };
94
+ }
95
+
96
+ /** What the vault does, when this version first runs, to one member's grants on every project of 0.4. */
97
+ export type EveryProjectPreview = {
98
+ principal: string;
99
+ /** Each, as a path (`*`, or `*` and the slug it covers), its role and its end. */
100
+ before: { place: string; role: string; expiresAt: number | null }[];
101
+ conversion: Conversion;
102
+ /** The places the conversion names, by id: `market`, `market/dev`. */
103
+ paths: Record<string, string>;
104
+ /**
105
+ * Whether the vault has reported their row changed around it since the
106
+ * last change to what they hold (`tamperedMembers`), by the entries in its
107
+ * name: then it leaves them as they are, unless the report is forged.
108
+ */
109
+ reported: boolean;
110
+ };
111
+
112
+ /**
113
+ * What the vault will make of the grants on every project of 0.4 that live
114
+ * members hold, by the rule it converts them with
115
+ * (`convertEveryProjectGrants`), from the rows as they are: what
116
+ * `coffre migrate` says before the vault does it. A display: the vault
117
+ * reads again, under its locks, when it converts, and checks what this
118
+ * cannot, holding no key: each row's MAC, and each entry's in its name. So
119
+ * a member it has reported changed around it is listed, and marked
120
+ * `reported`, by the vault's own rule (`tamperedMembers`) over the entries as
121
+ * they read: whether that report is the vault's own, or forged so as to
122
+ * hide what it will do, only the vault can tell. Any member the vault
123
+ * converts is listed. A root admin's it leaves while they are one, until
124
+ * they are not; which they are, the vault's configuration says and the
125
+ * database does not, so they are listed too.
126
+ */
127
+ export async function everyProjectPreview(db: Queryable, at: number): Promise<EveryProjectPreview[]> {
128
+ const { vaultMembers, projects, environments } = tablesOf(db);
129
+ const held = (await readGrants(db, { liveAt: at })).filter((grant) => grant.projectId === null && grant.environmentId === null);
130
+ if (held.length === 0) return [];
131
+ const holders = [...new Set(held.map((grant) => grant.principal))].sort();
132
+ const [members, grants, places, inside, tampered] = await Promise.all([
133
+ db.select({ principal: vaultMembers.principal, status: vaultMembers.status, owner: vaultMembers.owner, role: vaultMembers.role, scope: vaultMembers.scope }).from(vaultMembers),
134
+ readGrants(db, { liveAt: at }),
135
+ db.select({ id: projects.id, slug: projects.slug }).from(projects),
136
+ db.select({ id: environments.id, projectId: environments.projectId, slug: environments.slug }).from(environments),
137
+ // The vault's own rule (`reportStands`), over every entry in its name, for everyone in two queries.
138
+ tamperedMembers(db),
139
+ ]);
140
+ // A deleted place keeps its row under a tombstone's slug, its own or its project's: no grant reaches it.
141
+ const live = places.filter((project) => !isTombstone(project.slug)).map((project) => ({
142
+ id: project.id,
143
+ environments: inside.filter((environment) => environment.projectId === project.id && !isTombstone(environment.slug)).map(({ id, slug }) => ({ id, slug })),
144
+ }));
145
+ const paths = Object.fromEntries(places.flatMap((project) => [
146
+ [project.id, project.slug],
147
+ ...inside.filter((environment) => environment.projectId === project.id).map((environment) => [environment.id, `${project.slug}/${environment.slug}`]),
148
+ ]));
149
+ return holders.flatMap((principal) => {
150
+ const member = members.find((row) => row.principal === principal);
151
+ if (member?.status !== 'active') return [];
152
+ const theirs = held.filter((grant) => grant.principal === principal);
153
+ return [{
154
+ principal,
155
+ before: theirs.map((grant) => ({ place: grant.environmentSlug === null ? '*' : `*/${grant.environmentSlug}`, role: grant.role, expiresAt: grant.expiresAt })),
156
+ conversion: convertEveryProjectGrants({
157
+ person: principal.startsWith('user:'),
158
+ role: principal.startsWith('user:') ? storedRole(member).role : 'member',
159
+ everyProject: theirs.map((grant) => ({ environmentSlug: grant.environmentSlug, role: grant.role as Role, expiresAt: grant.expiresAt })),
160
+ grants: grants
161
+ .filter((grant) => grant.principal === principal && grant.projectId !== null)
162
+ .map((grant) => ({ projectId: grant.projectId, environmentId: grant.environmentId, role: grant.role as Role, expiresAt: grant.expiresAt })),
163
+ projects: live,
164
+ }),
165
+ paths,
166
+ reported: tampered.has(principal),
167
+ }];
168
+ });
169
+ }
170
+
171
+ /** A service token or trust binding of 0.4, which carries no scope of its own yet. */
172
+ export type Unsealed = { kind: 'token' | 'binding'; id: string; principal: string };
173
+
174
+ /**
175
+ * The live service tokens and trust bindings of active service accounts
176
+ * that carry no scope yet, as 0.4 made them: what the app seals with each
177
+ * account's grants once a process (`sealScopes`), and what `coffre migrate`
178
+ * says it will. A credential a binding issued carries its binding's.
179
+ */
180
+ export async function unsealedCredentials(db: Queryable, at: Date): Promise<Unsealed[]> {
181
+ const { credentials, serviceBindings, vaultMembers } = tablesOf(db);
182
+ const [tokens, bindings] = await Promise.all([
183
+ db
184
+ .select({ id: credentials.id, principal: credentials.principal })
185
+ .from(credentials)
186
+ .innerJoin(vaultMembers, and(eq(vaultMembers.principal, credentials.principal), eq(vaultMembers.generation, credentials.generation)))
187
+ .where(and(
188
+ eq(credentials.kind, 'service'),
189
+ isNull(credentials.scope),
190
+ sql`${credentials.createdBy} NOT LIKE 'binding:%'`,
191
+ isNull(credentials.revokedAt),
192
+ gt(credentials.expiresAt, at),
193
+ eq(vaultMembers.status, 'active'),
194
+ )),
195
+ db
196
+ .select({ id: serviceBindings.id, principal: serviceBindings.principal })
197
+ .from(serviceBindings)
198
+ .innerJoin(vaultMembers, and(eq(vaultMembers.principal, serviceBindings.principal), eq(vaultMembers.generation, serviceBindings.generation)))
199
+ .where(and(isNull(serviceBindings.scope), isNull(serviceBindings.revokedAt), eq(vaultMembers.status, 'active'))),
200
+ ]);
201
+ return [
202
+ ...tokens.map((row) => ({ kind: 'token' as const, ...row })),
203
+ ...bindings.map((row) => ({ kind: 'binding' as const, ...row })),
204
+ ].sort((a, b) => a.principal.localeCompare(b.principal) || a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id));
205
+ }
206
+
207
+ /**
208
+ * The members the vault has found changed around it, and not started over
209
+ * since, as a reader without its key can tell: `reportStands`' rule, taking
210
+ * every entry in the vault's name as its own, for everyone at once, where
211
+ * the app lists members and `coffre migrate` marks them. Their newest
212
+ * `vault.tampered`, for their row (`mac`) or an older one put back
213
+ * (`stale`), is newer than the newest entry changing what they hold. Both
214
+ * reads go by the log's (author, action, seq) and (author, subject, seq)
215
+ * indexes, and findings are few.
216
+ */
217
+ export async function tamperedMembers(db: Queryable, principal?: string): Promise<Set<string>> {
218
+ const { auditLog } = tablesOf(db);
219
+ const newest = (actions: readonly string[], extra?: SQL) =>
220
+ db
221
+ .select({ principal: auditLog.subjectPrincipal, seq: sql<string>`max(${auditLog.seq})`.mapWith(BigInt) })
222
+ .from(auditLog)
223
+ .where(
224
+ and(
225
+ eq(auditLog.author, 'vault'),
226
+ inArray(auditLog.action, [...actions]),
227
+ principal === undefined ? undefined : eq(auditLog.subjectPrincipal, principal),
228
+ extra,
229
+ ),
230
+ )
231
+ .groupBy(auditLog.subjectPrincipal);
232
+ const found = await newest(['vault.tampered'], inArray(auditLog.code, [...TAMPER_CODES]));
233
+ if (found.length === 0) return new Set();
234
+ const changed = new Map(
235
+ (await newest(ACCESS_ACTIONS, and(eq(auditLog.decision, 'allow'), inArray(auditLog.subjectPrincipal, found.map((row) => row.principal!)))))
236
+ .map((row) => [row.principal!, row.seq]),
237
+ );
238
+ return new Set(found.filter((row) => row.seq > (changed.get(row.principal!) ?? -1n)).map((row) => row.principal!));
239
+ }
240
+
241
+ /** The codes of the vault's reports that a member's row was changed around it: their row's MAC fails (`mac`), or an older one was put back (`stale`). */
242
+ const TAMPER_CODES = ['mac', 'stale'] as const;
243
+
244
+ /**
245
+ * Whether the vault's report that `principal`'s row was changed around it
246
+ * stands: one of its own is newer than its newest entry changing what they
247
+ * hold, and only starting them over, or another change, settles it. While
248
+ * one stands the vault leaves their grants on every project of 0.4 as they
249
+ * are. Both entries as `check`, the vault's MACs, tells them apart,
250
+ * passing over those forged in its name; a reader without its key takes
251
+ * them all as the vault's (`tamperedMembers`). Only reports after that
252
+ * entry are read, so older ones, forged or not, never hide one. From
253
+ * LOOK_BACK forged entries on, of either kind, one may stand, so it is
254
+ * taken to.
255
+ */
256
+ export async function reportStands(db: Queryable, principal: string, check: Authenticity): Promise<boolean> {
257
+ const access = await newestAccess(db, principal, check);
258
+ if (access === 'unreached') return true;
259
+ const after = typeof access === 'string' ? -1n : access.seq;
260
+ const report = await newestAuthentic((before) => tamperReportsAbout(db, principal, LOOK_BACK.page, after, before), check);
261
+ return report === 'unreached' || typeof report !== 'string';
262
+ }
263
+
264
+ /** The newest access entry about `principal` that `check` finds the vault's own. */
265
+ export function newestAccess(db: Queryable, principal: string, check: Authenticity): Promise<Newest> {
266
+ return newestAuthentic((before) => accessEntriesAbout(db, principal, LOOK_BACK.page, before), check);
267
+ }
268
+
269
+ /** Up to `limit` of the vault's allowed access entries about `principal` before `before` (the newest when undefined), newest first. */
270
+ export async function accessEntriesAbout(db: Queryable, principal: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
271
+ const { auditLog } = tablesOf(db);
272
+ const rows = await db
273
+ .select(entryColumns(db))
274
+ .from(auditLog)
275
+ .where(
276
+ and(
277
+ eq(auditLog.author, 'vault'),
278
+ eq(auditLog.subjectPrincipal, principal),
279
+ inArray(auditLog.action, [...ACCESS_ACTIONS]),
280
+ eq(auditLog.decision, 'allow'),
281
+ before === undefined ? undefined : lt(auditLog.seq, before),
282
+ ),
283
+ )
284
+ .orderBy(desc(auditLog.seq))
285
+ .limit(limit);
286
+ return rows as StoredEntry[];
287
+ }
288
+
289
+ /**
290
+ * Up to `limit` of the vault's reports that `principal`'s row was changed
291
+ * around it (`TAMPER_CODES`), after `after` and before `before` (the newest
292
+ * when undefined), newest first.
293
+ */
294
+ async function tamperReportsAbout(db: Queryable, principal: string, limit: number, after: bigint, before?: bigint): Promise<StoredEntry[]> {
295
+ const { auditLog } = tablesOf(db);
296
+ const rows = await db
297
+ .select(entryColumns(db))
298
+ .from(auditLog)
299
+ .where(
300
+ and(
301
+ eq(auditLog.author, 'vault'),
302
+ eq(auditLog.subjectPrincipal, principal),
303
+ eq(auditLog.action, 'vault.tampered'),
304
+ inArray(auditLog.code, [...TAMPER_CODES]),
305
+ gt(auditLog.seq, after),
306
+ before === undefined ? undefined : lt(auditLog.seq, before),
307
+ ),
308
+ )
309
+ .orderBy(desc(auditLog.seq))
310
+ .limit(limit);
311
+ return rows as StoredEntry[];
312
+ }
package/src/log.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { GENESIS_HASH, sealEntry, type LogFields, type LogKey } from '@coffre/core/audit';
1
+ import { GENESIS_HASH, sealEntry, type LogFields, type LogKey, type StoredEntry } from '@coffre/core/audit';
2
2
  import { desc, eq } from 'drizzle-orm';
3
3
 
4
4
  import { afterCommit } from './commits.ts';
5
- import { tablesOf, type Transaction } from './database.ts';
5
+ import { tablesOf, type Queryable, type Transaction } from './database.ts';
6
6
  import { clockMillis, forUpdate } from './dialect.ts';
7
7
 
8
8
  /**
@@ -187,3 +187,67 @@ export async function appendEntries(
187
187
  afterCommit(tx, () => remember(key, written));
188
188
  return { seqStart: head.nextSeq, ...written, occurredAt: now };
189
189
  }
190
+
191
+ // --- reading the vault's entries back ---------------------------------------------
192
+
193
+ /** An entry's columns, as the chain's codec reads them (`StoredEntry`). */
194
+ export function entryColumns(db: Queryable) {
195
+ const { auditLog } = tablesOf(db);
196
+ return {
197
+ seq: auditLog.seq,
198
+ author: auditLog.author,
199
+ keyId: auditLog.keyId,
200
+ occurredAt: auditLog.occurredAt,
201
+ actor: auditLog.actor,
202
+ action: auditLog.action,
203
+ decision: auditLog.decision,
204
+ code: auditLog.code,
205
+ subjectPrincipal: auditLog.subjectPrincipal,
206
+ projectId: auditLog.projectId,
207
+ environmentId: auditLog.environmentId,
208
+ secretId: auditLog.secretId,
209
+ secretVersionId: auditLog.secretVersionId,
210
+ operationId: auditLog.operationId,
211
+ requestId: auditLog.requestId,
212
+ sourceIp: auditLog.sourceIp,
213
+ relatedSeq: auditLog.relatedSeq,
214
+ metadata: auditLog.metadata,
215
+ prevHash: auditLog.prevHash,
216
+ mac: auditLog.mac,
217
+ hash: auditLog.hash,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * How a reader tells the vault's own entries from those forged in its name:
223
+ * by their MAC, which only the vault holds the key to check. Each it finds
224
+ * forged goes to `forged`.
225
+ */
226
+ export type Authenticity = { authentic: (entry: StoredEntry) => boolean; forged: (entry: StoredEntry) => void };
227
+
228
+ /** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
229
+ export const LOOK_BACK = { page: 32, pages: 32 };
230
+
231
+ /** What `newestAuthentic` finds: the entry, or why there is none. */
232
+ export type Newest = StoredEntry | 'none' | 'forged' | 'unreached';
233
+
234
+ /**
235
+ * The newest of the entries `page` reads, newest first and before a seq,
236
+ * that `check` finds authentic, read back a page at a time: 'none' when
237
+ * there are none at all, 'forged' when every one is in the vault's name but
238
+ * not its own, and 'unreached' once it has read LOOK_BACK such entries
239
+ * (1024), whether or not more are left.
240
+ */
241
+ export async function newestAuthentic(page: (before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest> {
242
+ let before: bigint | undefined;
243
+ for (let read = 0; read < LOOK_BACK.pages; read += 1) {
244
+ const entries = await page(before);
245
+ for (const entry of entries) {
246
+ if (check.authentic(entry)) return entry;
247
+ check.forged(entry);
248
+ }
249
+ if (entries.length < LOOK_BACK.page) return before === undefined && entries.length === 0 ? 'none' : 'forged';
250
+ before = entries.at(-1)!.seq;
251
+ }
252
+ return 'unreached';
253
+ }
package/src/migrate.ts CHANGED
@@ -40,6 +40,7 @@ interface ExpectedMigration {
40
40
  createdAt: string;
41
41
  hash: string;
42
42
  tag: string;
43
+ sql: string;
43
44
  }
44
45
 
45
46
  interface AppliedMigration {
@@ -56,9 +57,95 @@ interface Migrator {
56
57
  migrate(migrationsFolder: string): Promise<void>;
57
58
  /** Reassert privileges a one-database backup does not carry. */
58
59
  restrict(): Promise<void>;
60
+ /** Why this login cannot change the objects migrations change, `managed` among them, or null when it can. */
61
+ notOwner(managed: Managed): Promise<NotOwner | null>;
59
62
  close(): Promise<void>;
60
63
  }
61
64
 
65
+ /** An owner of coffre's objects whose privileges the login lacks: not a member of it, or one that does not inherit them. */
66
+ export type ForeignOwner = { name: string; member: boolean };
67
+
68
+ /**
69
+ * The login cannot change coffre's objects, the ledger's schema, the tables
70
+ * and the functions: their owners are roles whose privileges it does not
71
+ * have, as neither one of them nor a member of one that inherits them.
72
+ * Postgres would refuse the first migration that touches one, "permission
73
+ * denied for schema drizzle"; this says whose login migrates, or what makes
74
+ * this one able to: on Postgres 16 and later, a membership that inherits,
75
+ * which each one's GRANT says; before, a membership, and a login that
76
+ * inherits (`ALTER ROLE … INHERIT`).
77
+ */
78
+ export class NotOwner extends Error {
79
+ readonly login: string;
80
+ readonly owners: ForeignOwner[];
81
+
82
+ constructor(login: string, owners: ForeignOwner[], { grantsInherit, inherits }: { grantsInherit: boolean; inherits: boolean }) {
83
+ const one = owners.length === 1;
84
+ const members = owners.every((owner) => owner.member);
85
+ const fixes = [
86
+ ...owners.filter((owner) => grantsInherit || !owner.member).map((owner) => `GRANT ${identifier(owner.name)} TO ${identifier(login)}${grantsInherit ? ' WITH INHERIT TRUE' : ''}`),
87
+ ...(grantsInherit || inherits ? [] : [`ALTER ROLE ${identifier(login)} INHERIT`]),
88
+ ];
89
+ super(
90
+ `coffre's tables are owned by ${owners.map((owner) => owner.name).join(' and ')}, and this login, ${login}, ` +
91
+ (members
92
+ ? `is a member of ${one ? 'it' : 'them'} but does not inherit ${one ? 'its' : 'their'} privileges: `
93
+ : `is neither ${one ? 'it nor a member of it' : 'one nor a member of one'}: `) +
94
+ `migrate with ${one ? `${owners[0]!.name}'s` : 'their'} connection string, or, as a role that administers ${one ? 'it' : 'them'}, run ${fixes.join('; ')}`,
95
+ );
96
+ this.name = 'NotOwner';
97
+ this.login = login;
98
+ this.owners = owners;
99
+ }
100
+ }
101
+
102
+ /** A role's name as SQL takes it: always quoted, as a keyword such as `user` must be. */
103
+ function identifier(name: string): string {
104
+ return `"${name.replace(/"/g, '""')}"`;
105
+ }
106
+
107
+ /** What coffre's migrations make and so change later: relations and functions, by name, in public. */
108
+ type Managed = { relations: string[]; functions: string[] };
109
+
110
+ /**
111
+ * The names a migration's SQL makes: CREATE TABLE, VIEW, MATERIALIZED VIEW
112
+ * and SEQUENCE, and CREATE FUNCTION, in public. Unquoted, a name is folded
113
+ * to lower case, as Postgres folds it.
114
+ */
115
+ export function managedObjects(sqlTexts: readonly string[]): Managed {
116
+ const name = String.raw`(?:"?public"?\.)?(?:"((?:[^"]|"")+)"|([A-Za-z_][A-Za-z0-9_$]*))`;
117
+ const relation = new RegExp(String.raw`\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:UNLOGGED\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|SEQUENCE)\s+(?:IF\s+NOT\s+EXISTS\s+)?${name}`, 'gi');
118
+ const fn = new RegExp(String.raw`\bCREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+${name}\s*\(`, 'gi');
119
+ const names = (pattern: RegExp) => [
120
+ ...new Set(sqlTexts.flatMap((text) => [...text.matchAll(pattern)].map(([, quoted, bare]) => (quoted !== undefined ? quoted.replace(/""/g, '"') : bare!.toLowerCase())))),
121
+ ];
122
+ return { relations: names(relation), functions: names(fn) };
123
+ }
124
+
125
+ /**
126
+ * The owners of what migrations change, whose privileges the login lacks:
127
+ * the drizzle schema and everything in it, and in public the relations and
128
+ * functions coffre's migrations make ($1, $2), and nothing else of public,
129
+ * which may hold a database's other tables. A superuser has every role's.
130
+ */
131
+ const FOREIGN_OWNERS = `
132
+ WITH owned AS (
133
+ SELECT nspowner AS owner FROM pg_namespace WHERE nspname = 'drizzle'
134
+ UNION ALL
135
+ SELECT c.relowner FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
136
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
137
+ AND (n.nspname = 'drizzle' OR (n.nspname = 'public' AND c.relname = ANY($1::text[])))
138
+ UNION ALL
139
+ SELECT p.proowner FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
140
+ WHERE n.nspname = 'public' AND p.proname = ANY($2::text[])
141
+ )
142
+ SELECT DISTINCT r.rolname AS owner, pg_has_role(owned.owner, 'MEMBER') AS member, current_user AS login,
143
+ current_setting('server_version_num')::int >= 160000 AS grants_inherit,
144
+ (SELECT rolinherit FROM pg_roles WHERE rolname = current_user) AS inherits
145
+ FROM owned JOIN pg_roles r ON r.oid = owned.owner
146
+ WHERE NOT pg_has_role(owned.owner, 'USAGE')
147
+ ORDER BY r.rolname`;
148
+
62
149
  /**
63
150
  * Beside this module: src/migrations in the workspace, and dist/migrations
64
151
  * in the built package, where the build copies them.
@@ -89,6 +176,7 @@ async function expectedMigrations(engine: Engine): Promise<ExpectedMigration[]>
89
176
  createdAt: String(entry.when),
90
177
  hash: createHash('sha256').update(sqlText).digest('hex'),
91
178
  tag: entry.tag,
179
+ sql: sqlText,
92
180
  };
93
181
  }),
94
182
  );
@@ -177,6 +265,12 @@ async function postgresMigrator(url: string): Promise<Migrator> {
177
265
  },
178
266
  migrate: (migrationsFolder) => migrate(drizzle(client), { migrationsFolder }),
179
267
  restrict: () => restrictDatabase(client),
268
+ async notOwner({ relations, functions }) {
269
+ const { rows } = await client.query<{ owner: string; member: boolean; login: string; grants_inherit: boolean; inherits: boolean }>(FOREIGN_OWNERS, [relations, functions]);
270
+ if (rows.length === 0) return null;
271
+ const [{ login, grants_inherit: grantsInherit, inherits }] = rows as [(typeof rows)[number]];
272
+ return new NotOwner(login, rows.map(({ owner, member }) => ({ name: owner, member })), { grantsInherit, inherits });
273
+ },
180
274
  async close() {
181
275
  client.release();
182
276
  await pool.end();
@@ -206,6 +300,8 @@ async function sqliteMigrator(url: string): Promise<Migrator> {
206
300
  async unlock() {},
207
301
  migrate: (migrationsFolder) => migrate(sqlite, { migrationsFolder }),
208
302
  async restrict() {},
303
+ // One file, which whoever opens it may change.
304
+ notOwner: async () => null,
209
305
  close,
210
306
  };
211
307
  }
@@ -218,21 +314,27 @@ export type MigrationPlan = { applied: number; total: number; pending: string[]
218
314
 
219
315
  /**
220
316
  * Which of this version's migrations a database has applied, and which it
221
- * has not, without changing it or waiting on a migration under way. A
222
- * history that is not a prefix of this version's is refused, as
223
- * `migrateDatabase` refuses it.
317
+ * has not, without changing it or waiting on a migration under way; and
318
+ * whether this login could apply them (`notOwner`), as `migrateDatabase`
319
+ * requires: thrown, when it cannot read which. A history that is not a
320
+ * prefix of this version's is refused, as `migrateDatabase` refuses it.
224
321
  */
225
- export async function migrationStatus(url: string): Promise<{ applied: string[]; pending: string[] }> {
322
+ export async function migrationStatus(url: string): Promise<{ applied: string[]; pending: string[]; notOwner: NotOwner | null }> {
226
323
  const engine = engineOfUrl(url);
227
324
  const expected = await expectedMigrations(engine);
228
325
  const migrator = await { postgres: postgresMigrator, sqlite: sqliteMigrator }[engine](url);
229
326
  try {
230
- const history = await migrator.applied();
327
+ const notOwner = await migrator.notOwner(managedObjects(expected.map((entry) => entry.sql)));
328
+ // A login that may not even read the ledger is told why.
329
+ const history = await migrator.applied().catch((error: unknown) => {
330
+ throw notOwner ?? error;
331
+ });
231
332
  verifyHistory(expected, history, false);
232
333
  const applied = history?.length ?? 0;
233
334
  return {
234
335
  applied: expected.slice(0, applied).map((entry) => entry.tag),
235
336
  pending: expected.slice(applied).map((entry) => entry.tag),
337
+ notOwner,
236
338
  };
237
339
  } finally {
238
340
  await migrator.close();
@@ -241,7 +343,8 @@ export async function migrationStatus(url: string): Promise<{ applied: string[];
241
343
 
242
344
  /**
243
345
  * Apply missing migrations, check their history and reassert database
244
- * privileges. `onPlan` hears what there is to apply before it is, under
346
+ * privileges, as a login that can change coffre's objects: another is
347
+ * refused, NotOwner, before anything changes. `onPlan` hears what there is to apply before it is, under
245
348
  * the migration lock, and may stop the run by throwing: all of it then goes
246
349
  * in one call, with no word of each.
247
350
  */
@@ -258,6 +361,8 @@ export async function migrateDatabase(
258
361
  await migrator.lock();
259
362
  locked = true;
260
363
 
364
+ const notOwner = await migrator.notOwner(managedObjects(expected.map((entry) => entry.sql)));
365
+ if (notOwner !== null) throw notOwner;
261
366
  const applied = await migrator.applied();
262
367
  verifyHistory(expected, applied, false);
263
368
  const done = applied?.length ?? 0;
@@ -0,0 +1,4 @@
1
+ ALTER TABLE "vault_members" ADD COLUMN "role" text;--> statement-breakpoint
2
+ ALTER TABLE "vault_members" ADD COLUMN "scope" text;--> statement-breakpoint
3
+ -- The vault sets a person's instance role and its scope, as it sets the rest of their row.
4
+ GRANT UPDATE (role, scope) ON vault_members TO coffre_vault;
@@ -0,0 +1,4 @@
1
+ ALTER TABLE "credentials" ADD COLUMN "scope" text;--> statement-breakpoint
2
+ ALTER TABLE "service_bindings" ADD COLUMN "scope" text;--> statement-breakpoint
3
+ -- The app seals each token and binding of 0.4 with its account's grants, once, as it seals the rest of their rows.
4
+ GRANT UPDATE (scope) ON credentials, service_bindings TO coffre_app;