@coffre/db 0.5.0 → 0.6.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/dist/grants.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { i as Queryable } from "./database-C63WjCrC.js";
2
2
  import { Authenticity, Newest } from "./log.js";
3
- import { Conversion, InstanceRole, Scope } from "@coffre/core/access";
3
+ import { Conversion, ConversionInput, InstanceRole, Scope } from "@coffre/core/access";
4
4
  import { StoredEntry } from "@coffre/core/audit";
5
5
  //#region src/grants.d.ts
6
6
  /**
@@ -48,6 +48,17 @@ export declare function storedRole({ owner, role, scope }: StoredRole): {
48
48
  };
49
49
  /** The columns that store a member's instance role and scope: `owner` too, for a vault of 0.4 to read. */
50
50
  export declare function roleColumns(role: InstanceRole, scope: Scope): StoredRole;
51
+ /** Every project not deleted, with its environments not deleted: what a grant on every project of 0.4 reached. */
52
+ export declare function liveProjects(db: Queryable): Promise<ConversionInput['projects']>;
53
+ /**
54
+ * What the vault makes of a member's grants on every project of 0.4
55
+ * (`convertEveryProjectGrants`), from their instance role and their grants
56
+ * as stored, those live at `at`: the vault as it converts them, under their
57
+ * row's lock, and `coffre migrate` before, from the rows as they are. The
58
+ * role is the one their row holds: the vault converts these before it makes
59
+ * the Owners its deployment names.
60
+ */
61
+ export declare function everyProjectConversion(principal: string, role: InstanceRole, grants: readonly GrantRow[], at: number, projects: ConversionInput['projects']): Conversion;
51
62
  /** What the vault does, when this version first runs, to one member's grants on every project of 0.4. */
52
63
  export type EveryProjectPreview = {
53
64
  principal: string;
@@ -78,9 +89,7 @@ export type EveryProjectPreview = {
78
89
  * `reported`, by the vault's own rule (`tamperedMembers`) over the entries as
79
90
  * they read: whether that report is the vault's own, or forged so as to
80
91
  * hide what it will do, only the vault can tell. Any member the vault
81
- * converts is listed. A root admin's it leaves while they are one, until
82
- * they are not; which they are, the vault's configuration says and the
83
- * database does not, so they are listed too.
92
+ * converts is listed.
84
93
  */
85
94
  export declare function everyProjectPreview(db: Queryable, at: number): Promise<EveryProjectPreview[]>;
86
95
  /** A service token or trust binding of 0.4, which carries no scope of its own yet. */
package/dist/grants.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { tablesOf } from "./index.js";
2
+ import { tombstone, truth } from "./dialect.js";
2
3
  import { LOOK_BACK, entryColumns, newestAuthentic } from "./log.js";
3
- import { and, desc, eq, gt, inArray, isNull, lt, or, sql } from "drizzle-orm";
4
+ import { and, asc, desc, eq, gt, inArray, isNull, lt, or, sql } from "drizzle-orm";
4
5
  import { EVERYWHERE, administers, convertEveryProjectGrants, isInstanceRole, isScope, normalScope, unscoped } from "@coffre/core/access";
5
- import { isTombstone } from "@coffre/core/schemas";
6
6
  import { ACCESS_ACTIONS } from "@coffre/core/vault";
7
7
  //#region src/grants.ts
8
8
  /** Grants, lapsed ones too unless `liveAt` says when to judge them: one member's, or everyone's. */
@@ -60,6 +60,54 @@ function roleColumns(role, scope) {
60
60
  scope: role === "member" || unscoped(normal) ? null : JSON.stringify(normal)
61
61
  };
62
62
  }
63
+ /** Every project not deleted, with its environments not deleted: what a grant on every project of 0.4 reached. */
64
+ async function liveProjects(db) {
65
+ const { projects, environments } = tablesOf(db);
66
+ const [found, inside] = await Promise.all([db.select({
67
+ id: projects.id,
68
+ deleted: truth(tombstone(projects.slug))
69
+ }).from(projects).orderBy(asc(projects.id)), db.select({
70
+ id: environments.id,
71
+ projectId: environments.projectId,
72
+ slug: environments.slug,
73
+ deleted: truth(tombstone(environments.slug))
74
+ }).from(environments).orderBy(asc(environments.id))]);
75
+ return found.filter((project) => !project.deleted).map((project) => ({
76
+ id: project.id,
77
+ environments: inside.filter((environment) => environment.projectId === project.id && !environment.deleted).map(({ id, slug }) => ({
78
+ id,
79
+ slug
80
+ }))
81
+ }));
82
+ }
83
+ /**
84
+ * What the vault makes of a member's grants on every project of 0.4
85
+ * (`convertEveryProjectGrants`), from their instance role and their grants
86
+ * as stored, those live at `at`: the vault as it converts them, under their
87
+ * row's lock, and `coffre migrate` before, from the rows as they are. The
88
+ * role is the one their row holds: the vault converts these before it makes
89
+ * the Owners its deployment names.
90
+ */
91
+ function everyProjectConversion(principal, role, grants, at, projects) {
92
+ const person = principal.startsWith("user:");
93
+ const live = grants.filter((grant) => grant.expiresAt === null || grant.expiresAt > at);
94
+ return convertEveryProjectGrants({
95
+ person,
96
+ role: person ? role : "member",
97
+ everyProject: live.filter((grant) => grant.projectId === null && grant.environmentId === null).map((grant) => ({
98
+ environmentSlug: grant.environmentSlug,
99
+ role: grant.role,
100
+ expiresAt: grant.expiresAt
101
+ })),
102
+ grants: live.filter((grant) => grant.projectId !== null).map((grant) => ({
103
+ projectId: grant.projectId,
104
+ environmentId: grant.environmentId,
105
+ role: grant.role,
106
+ expiresAt: grant.expiresAt
107
+ })),
108
+ projects
109
+ });
110
+ }
63
111
  /**
64
112
  * What the vault will make of the grants on every project of 0.4 that live
65
113
  * members hold, by the rule it converts them with
@@ -71,16 +119,15 @@ function roleColumns(role, scope) {
71
119
  * `reported`, by the vault's own rule (`tamperedMembers`) over the entries as
72
120
  * they read: whether that report is the vault's own, or forged so as to
73
121
  * hide what it will do, only the vault can tell. Any member the vault
74
- * converts is listed. A root admin's it leaves while they are one, until
75
- * they are not; which they are, the vault's configuration says and the
76
- * database does not, so they are listed too.
122
+ * converts is listed.
77
123
  */
78
124
  async function everyProjectPreview(db, at) {
79
125
  const { vaultMembers, projects, environments } = tablesOf(db);
80
- const held = (await readGrants(db, { liveAt: at })).filter((grant) => grant.projectId === null && grant.environmentId === null);
126
+ const grants = await readGrants(db, { liveAt: at });
127
+ const held = grants.filter((grant) => grant.projectId === null && grant.environmentId === null);
81
128
  if (held.length === 0) return [];
82
129
  const holders = [...new Set(held.map((grant) => grant.principal))].sort();
83
- const [members, grants, places, inside, tampered] = await Promise.all([
130
+ const [members, places, inside, live, tampered] = await Promise.all([
84
131
  db.select({
85
132
  principal: vaultMembers.principal,
86
133
  status: vaultMembers.status,
@@ -88,7 +135,6 @@ async function everyProjectPreview(db, at) {
88
135
  role: vaultMembers.role,
89
136
  scope: vaultMembers.scope
90
137
  }).from(vaultMembers),
91
- readGrants(db, { liveAt: at }),
92
138
  db.select({
93
139
  id: projects.id,
94
140
  slug: projects.slug
@@ -98,43 +144,21 @@ async function everyProjectPreview(db, at) {
98
144
  projectId: environments.projectId,
99
145
  slug: environments.slug
100
146
  }).from(environments),
147
+ liveProjects(db),
101
148
  tamperedMembers(db)
102
149
  ]);
103
- const live = places.filter((project) => !isTombstone(project.slug)).map((project) => ({
104
- id: project.id,
105
- environments: inside.filter((environment) => environment.projectId === project.id && !isTombstone(environment.slug)).map(({ id, slug }) => ({
106
- id,
107
- slug
108
- }))
109
- }));
110
150
  const paths = Object.fromEntries(places.flatMap((project) => [[project.id, project.slug], ...inside.filter((environment) => environment.projectId === project.id).map((environment) => [environment.id, `${project.slug}/${environment.slug}`])]));
111
151
  return holders.flatMap((principal) => {
112
152
  const member = members.find((row) => row.principal === principal);
113
153
  if (member?.status !== "active") return [];
114
- const theirs = held.filter((grant) => grant.principal === principal);
115
154
  return [{
116
155
  principal,
117
- before: theirs.map((grant) => ({
156
+ before: held.filter((grant) => grant.principal === principal).map((grant) => ({
118
157
  place: grant.environmentSlug === null ? "*" : `*/${grant.environmentSlug}`,
119
158
  role: grant.role,
120
159
  expiresAt: grant.expiresAt
121
160
  })),
122
- conversion: convertEveryProjectGrants({
123
- person: principal.startsWith("user:"),
124
- role: principal.startsWith("user:") ? storedRole(member).role : "member",
125
- everyProject: theirs.map((grant) => ({
126
- environmentSlug: grant.environmentSlug,
127
- role: grant.role,
128
- expiresAt: grant.expiresAt
129
- })),
130
- grants: grants.filter((grant) => grant.principal === principal && grant.projectId !== null).map((grant) => ({
131
- projectId: grant.projectId,
132
- environmentId: grant.environmentId,
133
- role: grant.role,
134
- expiresAt: grant.expiresAt
135
- })),
136
- projects: live
137
- }),
161
+ conversion: everyProjectConversion(principal, storedRole(member).role, grants.filter((grant) => grant.principal === principal), at, live),
138
162
  paths,
139
163
  reported: tampered.has(principal)
140
164
  }];
@@ -224,4 +248,4 @@ async function tamperReportsAbout(db, principal, limit, after, before) {
224
248
  return await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.subjectPrincipal, principal), eq(auditLog.action, "vault.tampered"), inArray(auditLog.code, [...TAMPER_CODES]), gt(auditLog.seq, after), before === void 0 ? void 0 : lt(auditLog.seq, before))).orderBy(desc(auditLog.seq)).limit(limit);
225
249
  }
226
250
  //#endregion
227
- export { accessEntriesAbout, everyProjectPreview, insertGrant, newestAccess, readGrants, reportStands, roleColumns, storedRole, tamperedMembers, unsealedCredentials };
251
+ export { accessEntriesAbout, everyProjectConversion, everyProjectPreview, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole, tamperedMembers, unsealedCredentials };
package/dist/log.d.ts CHANGED
@@ -450,6 +450,8 @@ export type Authenticity = {
450
450
  authentic: (entry: StoredEntry) => boolean;
451
451
  forged: (entry: StoredEntry) => void;
452
452
  };
453
+ /** Up to `limit` of the entries in the vault's name, allowed, of one `action`, before `before` (the newest when undefined), newest first. */
454
+ export declare function newestVaultEntries(db: Queryable, action: string, limit: number, before?: bigint): Promise<StoredEntry[]>;
453
455
  /** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
454
456
  export declare const LOOK_BACK: {
455
457
  page: number;
package/dist/log.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { t as afterCommit } from "./commits-BuW-NL3n.js";
2
2
  import { tablesOf } from "./index.js";
3
3
  import { clockMillis, forUpdate } from "./dialect.js";
4
- import { desc, eq } from "drizzle-orm";
4
+ import { and, desc, eq, lt } from "drizzle-orm";
5
5
  import { GENESIS_HASH, sealEntry } from "@coffre/core/audit";
6
6
  //#region src/log.ts
7
7
  /** The log's head does not name its last entry: something moved it, or cut the log behind it. */
@@ -163,6 +163,11 @@ function entryColumns(db) {
163
163
  hash: auditLog.hash
164
164
  };
165
165
  }
166
+ /** Up to `limit` of the entries in the vault's name, allowed, of one `action`, before `before` (the newest when undefined), newest first. */
167
+ async function newestVaultEntries(db, action, limit, before) {
168
+ const { auditLog } = tablesOf(db);
169
+ return await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, action), eq(auditLog.decision, "allow"), before === void 0 ? void 0 : lt(auditLog.seq, before))).orderBy(desc(auditLog.seq)).limit(limit);
170
+ }
166
171
  /** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
167
172
  const LOOK_BACK = {
168
173
  page: 32,
@@ -189,4 +194,4 @@ async function newestAuthentic(page, check) {
189
194
  return "unreached";
190
195
  }
191
196
  //#endregion
192
- export { LOOK_BACK, LogHeadMismatch, LogRewound, appendEntries, entryColumns, forgetLogHeads, lockLogHead, newestAuthentic };
197
+ export { LOOK_BACK, LogHeadMismatch, LogRewound, appendEntries, entryColumns, forgetLogHeads, lockLogHead, newestAuthentic, newestVaultEntries };
package/dist/migrate.d.ts CHANGED
@@ -3,6 +3,40 @@ import { PoolClient } from "pg";
3
3
  //#region src/migrate.d.ts
4
4
  /** Reassert database privileges that pg_dump and CREATE DATABASE TEMPLATE do not carry. */
5
5
  export declare function restrictDatabase(client: Pick<PoolClient, 'query'>): Promise<void>;
6
+ /** An owner of coffre's objects whose privileges the login lacks: not a member of it, or one that does not inherit them. */
7
+ export type ForeignOwner = {
8
+ name: string;
9
+ member: boolean;
10
+ };
11
+ /**
12
+ * The login cannot change coffre's objects, the ledger's schema, the tables
13
+ * and the functions: their owners are roles whose privileges it does not
14
+ * have, as neither one of them nor a member of one that inherits them.
15
+ * Postgres would refuse the first migration that touches one, "permission
16
+ * denied for schema drizzle"; this says whose login migrates, or what makes
17
+ * this one able to: on Postgres 16 and later, a membership that inherits,
18
+ * which each one's GRANT says; before, a membership, and a login that
19
+ * inherits (`ALTER ROLE … INHERIT`).
20
+ */
21
+ export declare class NotOwner extends Error {
22
+ readonly login: string;
23
+ readonly owners: ForeignOwner[];
24
+ constructor(login: string, owners: ForeignOwner[], { grantsInherit, inherits }: {
25
+ grantsInherit: boolean;
26
+ inherits: boolean;
27
+ });
28
+ }
29
+ /** What coffre's migrations make and so change later: relations and functions, by name, in public. */
30
+ type Managed = {
31
+ relations: string[];
32
+ functions: string[];
33
+ };
34
+ /**
35
+ * The names a migration's SQL makes: CREATE TABLE, VIEW, MATERIALIZED VIEW
36
+ * and SEQUENCE, and CREATE FUNCTION, in public. Unquoted, a name is folded
37
+ * to lower case, as Postgres folds it.
38
+ */
39
+ export declare function managedObjects(sqlTexts: readonly string[]): Managed;
6
40
  /**
7
41
  * Beside this module: src/migrations in the workspace, and dist/migrations
8
42
  * in the built package, where the build copies them.
@@ -35,17 +69,20 @@ export type MigrationPlan = {
35
69
  };
36
70
  /**
37
71
  * Which of this version's migrations a database has applied, and which it
38
- * has not, without changing it or waiting on a migration under way. A
39
- * history that is not a prefix of this version's is refused, as
40
- * `migrateDatabase` refuses it.
72
+ * has not, without changing it or waiting on a migration under way; and
73
+ * whether this login could apply them (`notOwner`), as `migrateDatabase`
74
+ * requires: thrown, when it cannot read which. A history that is not a
75
+ * prefix of this version's is refused, as `migrateDatabase` refuses it.
41
76
  */
42
77
  export declare function migrationStatus(url: string): Promise<{
43
78
  applied: string[];
44
79
  pending: string[];
80
+ notOwner: NotOwner | null;
45
81
  }>;
46
82
  /**
47
83
  * Apply missing migrations, check their history and reassert database
48
- * privileges. `onPlan` hears what there is to apply before it is, under
84
+ * privileges, as a login that can change coffre's objects: another is
85
+ * refused, NotOwner, before anything changes. `onPlan` hears what there is to apply before it is, under
49
86
  * the migration lock, and may stop the run by throwing: all of it then goes
50
87
  * in one call, with no word of each.
51
88
  */
package/dist/migrate.js CHANGED
@@ -25,6 +25,71 @@ async function restrictDatabase(client) {
25
25
  END $$`);
26
26
  }
27
27
  /**
28
+ * The login cannot change coffre's objects, the ledger's schema, the tables
29
+ * and the functions: their owners are roles whose privileges it does not
30
+ * have, as neither one of them nor a member of one that inherits them.
31
+ * Postgres would refuse the first migration that touches one, "permission
32
+ * denied for schema drizzle"; this says whose login migrates, or what makes
33
+ * this one able to: on Postgres 16 and later, a membership that inherits,
34
+ * which each one's GRANT says; before, a membership, and a login that
35
+ * inherits (`ALTER ROLE … INHERIT`).
36
+ */
37
+ var NotOwner = class extends Error {
38
+ login;
39
+ owners;
40
+ constructor(login, owners, { grantsInherit, inherits }) {
41
+ const one = owners.length === 1;
42
+ const members = owners.every((owner) => owner.member);
43
+ const fixes = [...owners.filter((owner) => grantsInherit || !owner.member).map((owner) => `GRANT ${identifier(owner.name)} TO ${identifier(login)}${grantsInherit ? " WITH INHERIT TRUE" : ""}`), ...grantsInherit || inherits ? [] : [`ALTER ROLE ${identifier(login)} INHERIT`]];
44
+ super(`coffre's tables are owned by ${owners.map((owner) => owner.name).join(" and ")}, and this login, ${login}, ` + (members ? `is a member of ${one ? "it" : "them"} but does not inherit ${one ? "its" : "their"} privileges: ` : `is neither ${one ? "it nor a member of it" : "one nor a member of one"}: `) + `migrate with ${one ? `${owners[0].name}'s` : "their"} connection string, or, as a role that administers ${one ? "it" : "them"}, run ${fixes.join("; ")}`);
45
+ this.name = "NotOwner";
46
+ this.login = login;
47
+ this.owners = owners;
48
+ }
49
+ };
50
+ /** A role's name as SQL takes it: always quoted, as a keyword such as `user` must be. */
51
+ function identifier(name) {
52
+ return `"${name.replace(/"/g, "\"\"")}"`;
53
+ }
54
+ /**
55
+ * The names a migration's SQL makes: CREATE TABLE, VIEW, MATERIALIZED VIEW
56
+ * and SEQUENCE, and CREATE FUNCTION, in public. Unquoted, a name is folded
57
+ * to lower case, as Postgres folds it.
58
+ */
59
+ function managedObjects(sqlTexts) {
60
+ const name = String.raw`(?:"?public"?\.)?(?:"((?:[^"]|"")+)"|([A-Za-z_][A-Za-z0-9_$]*))`;
61
+ 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");
62
+ const fn = new RegExp(String.raw`\bCREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+${name}\s*\(`, "gi");
63
+ const names = (pattern) => [...new Set(sqlTexts.flatMap((text) => [...text.matchAll(pattern)].map(([, quoted, bare]) => quoted !== void 0 ? quoted.replace(/""/g, "\"") : bare.toLowerCase())))];
64
+ return {
65
+ relations: names(relation),
66
+ functions: names(fn)
67
+ };
68
+ }
69
+ /**
70
+ * The owners of what migrations change, whose privileges the login lacks:
71
+ * the drizzle schema and everything in it, and in public the relations and
72
+ * functions coffre's migrations make ($1, $2), and nothing else of public,
73
+ * which may hold a database's other tables. A superuser has every role's.
74
+ */
75
+ const FOREIGN_OWNERS = `
76
+ WITH owned AS (
77
+ SELECT nspowner AS owner FROM pg_namespace WHERE nspname = 'drizzle'
78
+ UNION ALL
79
+ SELECT c.relowner FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
80
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
81
+ AND (n.nspname = 'drizzle' OR (n.nspname = 'public' AND c.relname = ANY($1::text[])))
82
+ UNION ALL
83
+ SELECT p.proowner FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
84
+ WHERE n.nspname = 'public' AND p.proname = ANY($2::text[])
85
+ )
86
+ SELECT DISTINCT r.rolname AS owner, pg_has_role(owned.owner, 'MEMBER') AS member, current_user AS login,
87
+ current_setting('server_version_num')::int >= 160000 AS grants_inherit,
88
+ (SELECT rolinherit FROM pg_roles WHERE rolname = current_user) AS inherits
89
+ FROM owned JOIN pg_roles r ON r.oid = owned.owner
90
+ WHERE NOT pg_has_role(owned.owner, 'USAGE')
91
+ ORDER BY r.rolname`;
92
+ /**
28
93
  * Beside this module: src/migrations in the workspace, and dist/migrations
29
94
  * in the built package, where the build copies them.
30
95
  */
@@ -41,7 +106,8 @@ async function expectedMigrations(engine) {
41
106
  return {
42
107
  createdAt: String(entry.when),
43
108
  hash: createHash("sha256").update(sqlText).digest("hex"),
44
- tag: entry.tag
109
+ tag: entry.tag,
110
+ sql: sqlText
45
111
  };
46
112
  }));
47
113
  for (let index = 1; index < expected.length; index += 1) if (BigInt(expected[index - 1].createdAt) >= BigInt(expected[index].createdAt)) throw new Error("Drizzle migration journal timestamps must be strictly increasing");
@@ -106,6 +172,18 @@ async function postgresMigrator(url) {
106
172
  },
107
173
  migrate: (migrationsFolder) => migrate(drizzle(client), { migrationsFolder }),
108
174
  restrict: () => restrictDatabase(client),
175
+ async notOwner({ relations, functions }) {
176
+ const { rows } = await client.query(FOREIGN_OWNERS, [relations, functions]);
177
+ if (rows.length === 0) return null;
178
+ const [{ login, grants_inherit: grantsInherit, inherits }] = rows;
179
+ return new NotOwner(login, rows.map(({ owner, member }) => ({
180
+ name: owner,
181
+ member
182
+ })), {
183
+ grantsInherit,
184
+ inherits
185
+ });
186
+ },
109
187
  async close() {
110
188
  client.release();
111
189
  await pool.end();
@@ -125,14 +203,16 @@ async function sqliteMigrator(url) {
125
203
  async unlock() {},
126
204
  migrate: (migrationsFolder) => migrate(sqlite, { migrationsFolder }),
127
205
  async restrict() {},
206
+ notOwner: async () => null,
128
207
  close
129
208
  };
130
209
  }
131
210
  /**
132
211
  * Which of this version's migrations a database has applied, and which it
133
- * has not, without changing it or waiting on a migration under way. A
134
- * history that is not a prefix of this version's is refused, as
135
- * `migrateDatabase` refuses it.
212
+ * has not, without changing it or waiting on a migration under way; and
213
+ * whether this login could apply them (`notOwner`), as `migrateDatabase`
214
+ * requires: thrown, when it cannot read which. A history that is not a
215
+ * prefix of this version's is refused, as `migrateDatabase` refuses it.
136
216
  */
137
217
  async function migrationStatus(url) {
138
218
  const engine = engineOfUrl(url);
@@ -142,12 +222,16 @@ async function migrationStatus(url) {
142
222
  sqlite: sqliteMigrator
143
223
  }[engine](url);
144
224
  try {
145
- const history = await migrator.applied();
225
+ const notOwner = await migrator.notOwner(managedObjects(expected.map((entry) => entry.sql)));
226
+ const history = await migrator.applied().catch((error) => {
227
+ throw notOwner ?? error;
228
+ });
146
229
  verifyHistory(expected, history, false);
147
230
  const applied = history?.length ?? 0;
148
231
  return {
149
232
  applied: expected.slice(0, applied).map((entry) => entry.tag),
150
- pending: expected.slice(applied).map((entry) => entry.tag)
233
+ pending: expected.slice(applied).map((entry) => entry.tag),
234
+ notOwner
151
235
  };
152
236
  } finally {
153
237
  await migrator.close();
@@ -155,7 +239,8 @@ async function migrationStatus(url) {
155
239
  }
156
240
  /**
157
241
  * Apply missing migrations, check their history and reassert database
158
- * privileges. `onPlan` hears what there is to apply before it is, under
242
+ * privileges, as a login that can change coffre's objects: another is
243
+ * refused, NotOwner, before anything changes. `onPlan` hears what there is to apply before it is, under
159
244
  * the migration lock, and may stop the run by throwing: all of it then goes
160
245
  * in one call, with no word of each.
161
246
  */
@@ -170,6 +255,8 @@ async function migrateDatabase(url, onPlan) {
170
255
  try {
171
256
  await migrator.lock();
172
257
  locked = true;
258
+ const notOwner = await migrator.notOwner(managedObjects(expected.map((entry) => entry.sql)));
259
+ if (notOwner !== null) throw notOwner;
173
260
  const applied = await migrator.applied();
174
261
  verifyHistory(expected, applied, false);
175
262
  const done = applied?.length ?? 0;
@@ -190,4 +277,4 @@ async function migrateDatabase(url, onPlan) {
190
277
  }
191
278
  }
192
279
  //#endregion
193
- export { DatabaseAhead, migrateDatabase, migrationStatus, migrationsFolder, restrictDatabase };
280
+ export { DatabaseAhead, NotOwner, managedObjects, migrateDatabase, migrationStatus, migrationsFolder, restrictDatabase };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/db",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "coffre's database: the schema, its migrations and the connections, on Postgres or SQLite.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -79,7 +79,7 @@
79
79
  "drizzle-orm": "0.45.2",
80
80
  "pg": "8.16.3",
81
81
  "pg-connection-string": "2.14.0",
82
- "@coffre/core": "0.5.0"
82
+ "@coffre/core": "0.6.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "26.1.1",
package/src/grants.ts CHANGED
@@ -7,17 +7,18 @@ import {
7
7
  normalScope,
8
8
  unscoped,
9
9
  type Conversion,
10
+ type ConversionInput,
10
11
  type InstanceRole,
11
12
  type Role,
12
13
  type Scope,
13
14
  } from '@coffre/core/access';
14
15
  import type { StoredEntry } from '@coffre/core/audit';
15
- import { isTombstone } from '@coffre/core/schemas';
16
16
  import { ACCESS_ACTIONS } from '@coffre/core/vault';
17
- import { and, desc, eq, gt, inArray, isNull, lt, or, sql, type SQL } from 'drizzle-orm';
17
+ import { and, asc, desc, eq, gt, inArray, isNull, like, lt, or, sql, type SQL } from 'drizzle-orm';
18
18
 
19
19
  import { tablesOf, type Queryable } from './database.ts';
20
- import { entryColumns, LOOK_BACK, newestAuthentic, type Authenticity, type Newest } from './log.ts';
20
+ import { tombstone, truth } from './dialect.ts';
21
+ import { entryColumns, LOOK_BACK, newestAuthentic, newestVaultEntries, type Authenticity, type Newest } from './log.ts';
21
22
 
22
23
  /**
23
24
  * One grant, as `vault_grants` holds it: on a project (`environmentId`
@@ -93,6 +94,49 @@ export function roleColumns(role: InstanceRole, scope: Scope): StoredRole {
93
94
  return { owner: administers(role), role, scope: role === 'member' || unscoped(normal) ? null : JSON.stringify(normal) };
94
95
  }
95
96
 
97
+ /** Every project not deleted, with its environments not deleted: what a grant on every project of 0.4 reached. */
98
+ export async function liveProjects(db: Queryable): Promise<ConversionInput['projects']> {
99
+ const { projects, environments } = tablesOf(db);
100
+ const [found, inside] = await Promise.all([
101
+ db.select({ id: projects.id, deleted: truth(tombstone(projects.slug)) }).from(projects).orderBy(asc(projects.id)),
102
+ db.select({ id: environments.id, projectId: environments.projectId, slug: environments.slug, deleted: truth(tombstone(environments.slug)) }).from(environments).orderBy(asc(environments.id)),
103
+ ]);
104
+ return found.filter((project) => !project.deleted).map((project) => ({
105
+ id: project.id,
106
+ environments: inside.filter((environment) => environment.projectId === project.id && !environment.deleted).map(({ id, slug }) => ({ id, slug })),
107
+ }));
108
+ }
109
+
110
+ /**
111
+ * What the vault makes of a member's grants on every project of 0.4
112
+ * (`convertEveryProjectGrants`), from their instance role and their grants
113
+ * as stored, those live at `at`: the vault as it converts them, under their
114
+ * row's lock, and `coffre migrate` before, from the rows as they are. The
115
+ * role is the one their row holds: the vault converts these before it makes
116
+ * the Owners its deployment names.
117
+ */
118
+ export function everyProjectConversion(
119
+ principal: string,
120
+ role: InstanceRole,
121
+ grants: readonly GrantRow[],
122
+ at: number,
123
+ projects: ConversionInput['projects'],
124
+ ): Conversion {
125
+ const person = principal.startsWith('user:');
126
+ const live = grants.filter((grant) => grant.expiresAt === null || grant.expiresAt > at);
127
+ return convertEveryProjectGrants({
128
+ person,
129
+ role: person ? role : 'member',
130
+ everyProject: live
131
+ .filter((grant) => grant.projectId === null && grant.environmentId === null)
132
+ .map((grant) => ({ environmentSlug: grant.environmentSlug, role: grant.role as Role, expiresAt: grant.expiresAt })),
133
+ grants: live
134
+ .filter((grant) => grant.projectId !== null)
135
+ .map((grant) => ({ projectId: grant.projectId, environmentId: grant.environmentId, role: grant.role as Role, expiresAt: grant.expiresAt })),
136
+ projects,
137
+ });
138
+ }
139
+
96
140
  /** What the vault does, when this version first runs, to one member's grants on every project of 0.4. */
97
141
  export type EveryProjectPreview = {
98
142
  principal: string;
@@ -120,28 +164,23 @@ export type EveryProjectPreview = {
120
164
  * `reported`, by the vault's own rule (`tamperedMembers`) over the entries as
121
165
  * they read: whether that report is the vault's own, or forged so as to
122
166
  * 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.
167
+ * converts is listed.
126
168
  */
127
169
  export async function everyProjectPreview(db: Queryable, at: number): Promise<EveryProjectPreview[]> {
128
170
  const { vaultMembers, projects, environments } = tablesOf(db);
129
- const held = (await readGrants(db, { liveAt: at })).filter((grant) => grant.projectId === null && grant.environmentId === null);
171
+ const grants = await readGrants(db, { liveAt: at });
172
+ const held = grants.filter((grant) => grant.projectId === null && grant.environmentId === null);
130
173
  if (held.length === 0) return [];
131
174
  const holders = [...new Set(held.map((grant) => grant.principal))].sort();
132
- const [members, grants, places, inside, tampered] = await Promise.all([
175
+ const [members, places, inside, live, tampered] = await Promise.all([
133
176
  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
177
  db.select({ id: projects.id, slug: projects.slug }).from(projects),
136
178
  db.select({ id: environments.id, projectId: environments.projectId, slug: environments.slug }).from(environments),
179
+ liveProjects(db),
137
180
  // The vault's own rule (`reportStands`), over every entry in its name, for everyone in two queries.
138
181
  tamperedMembers(db),
139
182
  ]);
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
- }));
183
+ // A deleted place keeps its row under a tombstone's slug, its own or its project's, named as it was.
145
184
  const paths = Object.fromEntries(places.flatMap((project) => [
146
185
  [project.id, project.slug],
147
186
  ...inside.filter((environment) => environment.projectId === project.id).map((environment) => [environment.id, `${project.slug}/${environment.slug}`]),
@@ -153,15 +192,7 @@ export async function everyProjectPreview(db: Queryable, at: number): Promise<Ev
153
192
  return [{
154
193
  principal,
155
194
  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
- }),
195
+ conversion: everyProjectConversion(principal, storedRole(member).role, grants.filter((grant) => grant.principal === principal), at, live),
165
196
  paths,
166
197
  reported: tampered.has(principal),
167
198
  }];
package/src/log.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { GENESIS_HASH, sealEntry, type LogFields, type LogKey, type StoredEntry } from '@coffre/core/audit';
2
- import { desc, eq } from 'drizzle-orm';
2
+ import { and, desc, eq, lt } from 'drizzle-orm';
3
3
 
4
4
  import { afterCommit } from './commits.ts';
5
5
  import { tablesOf, type Queryable, type Transaction } from './database.ts';
@@ -225,6 +225,25 @@ export function entryColumns(db: Queryable) {
225
225
  */
226
226
  export type Authenticity = { authentic: (entry: StoredEntry) => boolean; forged: (entry: StoredEntry) => void };
227
227
 
228
+ /** Up to `limit` of the entries in the vault's name, allowed, of one `action`, before `before` (the newest when undefined), newest first. */
229
+ export async function newestVaultEntries(db: Queryable, action: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
230
+ const { auditLog } = tablesOf(db);
231
+ const rows = await db
232
+ .select(entryColumns(db))
233
+ .from(auditLog)
234
+ .where(
235
+ and(
236
+ eq(auditLog.author, 'vault'),
237
+ eq(auditLog.action, action),
238
+ eq(auditLog.decision, 'allow'),
239
+ before === undefined ? undefined : lt(auditLog.seq, before),
240
+ ),
241
+ )
242
+ .orderBy(desc(auditLog.seq))
243
+ .limit(limit);
244
+ return rows as StoredEntry[];
245
+ }
246
+
228
247
  /** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
229
248
  export const LOOK_BACK = { page: 32, pages: 32 };
230
249
 
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;
package/src/schema.ts CHANGED
@@ -205,8 +205,7 @@ export const vaultMembers = pgTable(
205
205
  principal: text().primaryKey(),
206
206
  status: text().notNull(),
207
207
  // An owner of 0.4: an Admin, unless `role` says otherwise. Written as
208
- // whether `role` administers, for a vault of 0.4 to read. Root admins
209
- // come from the vault's configuration, never from a row.
208
+ // whether `role` administers, for a vault of 0.4 to read.
210
209
  owner: boolean().notNull().default(false),
211
210
  // A person's instance role (`INSTANCE_ROLES` in @coffre/core/access),
212
211
  // and where it applies, as JSON (`Scope`, projects by id); null on a row