@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 +13 -4
- package/dist/grants.js +58 -34
- package/dist/log.d.ts +2 -0
- package/dist/log.js +7 -2
- package/dist/migrate.d.ts +41 -4
- package/dist/migrate.js +95 -8
- package/package.json +2 -2
- package/src/grants.ts +54 -23
- package/src/log.ts +20 -1
- package/src/migrate.ts +111 -6
- package/src/schema.ts +1 -2
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.
|
|
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.
|
|
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
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
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
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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 {
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
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:
|
|
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
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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
|
|
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
|
|
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.
|
|
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
|