@decohere/data-postgres 0.0.0-stage → 0.0.1-dev.5
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/README.md +24 -2
- package/package.json +35 -4
- package/src/bootstrap.ts +82 -0
- package/src/index.ts +8 -0
- package/src/server-manager.ts +300 -0
- package/src/user-scripts.ts +234 -0
- package/tests/bootstrap-schemas.docker.spec.ts +145 -0
- package/tests/bootstrap.spec.ts +203 -0
- package/tests/db-least-privilege.pentest.spec.ts +125 -0
- package/tests/postgres-container.ts +139 -0
- package/tests/prepare-database.docker.spec.ts +96 -0
- package/tests/server-manager.bootstrap.spec.ts +72 -0
- package/tests/trusted-dev-postgres.spec.ts +217 -0
- package/tests/user-scripts.pentest.spec.ts +42 -0
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @decohere/data-postgres
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Everything in Decohere's data layer that only Postgres needs. An app installs it when its database is Postgres; the scaffold does this for you.
|
|
4
|
+
|
|
5
|
+
## Setting up a data source at startup
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { postgres } from "@decohere/data-postgres";
|
|
9
|
+
|
|
10
|
+
builder.use(mikroOrm({ entities, bootstrap: postgres.bootstrap })); // or typeOrm({ … })
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Before the provider connects, `postgres.bootstrap` does what each data source's config asks:
|
|
14
|
+
- creates the database;
|
|
15
|
+
- creates an app role and an optional migrate role;
|
|
16
|
+
- creates the schema;
|
|
17
|
+
- grants each role only what it needs.
|
|
18
|
+
|
|
19
|
+
Data sources on one server and database are set up once, in order, and running it again changes nothing. The rules (`bootstrap`, `auth: trust`, the admin and migrate roles) are in [ADR 0011](../../../docs/adr/0011-database-bootstrap.md).
|
|
20
|
+
|
|
21
|
+
A runner or script that opens its own connection can call `bootstrapDataSources(dataSources, { applicationName })` directly.
|
|
22
|
+
|
|
23
|
+
## What else goes here
|
|
24
|
+
|
|
25
|
+
Postgres-only code in general, as it's built: the migration runner's advisory lock and history table, and starting a development Postgres on demand. Another database would get a package of its own with the same shape.
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decohere/data-postgres",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1-dev.5",
|
|
4
|
+
"description": "Postgres for @decohere/data: creates a data source's database, least-privilege roles and schema at startup",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"typescript",
|
|
7
|
+
"postgres",
|
|
8
|
+
"data-access",
|
|
9
|
+
"decohere"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/typesmiths/decohere/tree/main/projects/data/data-postgres#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/typesmiths/decohere/issues"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/typesmiths/decohere.git",
|
|
18
|
+
"directory": "projects/data/data-postgres"
|
|
19
|
+
},
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"author": "Typesmiths",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./types/src/index.d.ts",
|
|
26
|
+
"import": "./es6/src/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@decohere/data": "0.0.1-dev.5",
|
|
34
|
+
"pg": "^8.23.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {}
|
|
37
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertBootstrapCredentials,
|
|
3
|
+
assertDataSourceAuth,
|
|
4
|
+
assertSynchronizeAllowed,
|
|
5
|
+
type ConnectionInfo,
|
|
6
|
+
DataConfigurationError,
|
|
7
|
+
shouldBootstrapDatabase,
|
|
8
|
+
} from "@decohere/data";
|
|
9
|
+
import { PostgresServerManager } from "./server-manager.js";
|
|
10
|
+
|
|
11
|
+
export type BootstrapDataSourcesOptions = {
|
|
12
|
+
/** Shown as `application_name` on the bootstrap connections. */
|
|
13
|
+
applicationName: string;
|
|
14
|
+
/** Defaults to `process.env.NODE_ENV`. Decides whether `auth: trust` and `synchronize` are allowed. */
|
|
15
|
+
nodeEnv?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** What every data source on one server and database must agree on. */
|
|
19
|
+
const SHARED_FIELDS = ["auth", "adminUser", "migrateUsername"] as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check every data source's auth and `synchronize`, then create the databases, roles and schemas of those that
|
|
23
|
+
* bootstrap. Data sources on the same server and database are bootstrapped once, one after the
|
|
24
|
+
* other: in parallel they would race to create the same database and roles. Running it again
|
|
25
|
+
* changes nothing.
|
|
26
|
+
*
|
|
27
|
+
* Pass it to a provider as `bootstrap: postgres.bootstrap`; runners and scripts that open their own
|
|
28
|
+
* connection can call it directly.
|
|
29
|
+
*/
|
|
30
|
+
export async function bootstrapDataSources(
|
|
31
|
+
dataSources: readonly ConnectionInfo[],
|
|
32
|
+
options: BootstrapDataSourcesOptions,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const nodeEnv = options.nodeEnv ?? process.env.NODE_ENV;
|
|
35
|
+
for (const info of dataSources) {
|
|
36
|
+
assertDataSourceAuth(info, nodeEnv);
|
|
37
|
+
assertSynchronizeAllowed(info, nodeEnv);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const toBootstrap = dataSources.filter((info) => shouldBootstrapDatabase(info, nodeEnv));
|
|
41
|
+
for (const info of toBootstrap) assertBootstrapCredentials(info);
|
|
42
|
+
|
|
43
|
+
for (const group of byServerAndDatabase(toBootstrap)) {
|
|
44
|
+
const [first] = group;
|
|
45
|
+
await using server = new PostgresServerManager(options.applicationName, first);
|
|
46
|
+
await server.connect();
|
|
47
|
+
await server.initializeDatabase(first.database);
|
|
48
|
+
for (const info of group) {
|
|
49
|
+
await server.provisionRoles({
|
|
50
|
+
database: info.database,
|
|
51
|
+
appUsername: info.username,
|
|
52
|
+
appPassword: info.password,
|
|
53
|
+
migrateUsername: info.migrateUsername,
|
|
54
|
+
migratePassword: info.migratePassword,
|
|
55
|
+
allowAppDdl: info.synchronize === true,
|
|
56
|
+
schema: info.schema,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function byServerAndDatabase(dataSources: readonly ConnectionInfo[]): ConnectionInfo[][] {
|
|
63
|
+
const groups = new Map<string, ConnectionInfo[]>();
|
|
64
|
+
for (const info of dataSources) {
|
|
65
|
+
const key = `${info.host}:${info.port}/${info.database}`;
|
|
66
|
+
const group = groups.get(key);
|
|
67
|
+
if (group == null) {
|
|
68
|
+
groups.set(key, [info]);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const [first] = group;
|
|
72
|
+
for (const field of SHARED_FIELDS) {
|
|
73
|
+
if (info[field] !== first[field]) {
|
|
74
|
+
throw new DataConfigurationError(
|
|
75
|
+
`Data sources "${first.name}" and "${info.name}" share database ${key} but set different ${field} (${JSON.stringify(first[field])} and ${JSON.stringify(info[field])})`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
group.push(info);
|
|
80
|
+
}
|
|
81
|
+
return [...groups.values()];
|
|
82
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Everything Postgres-only in Decohere's data layer. Today: creating a data source's database,
|
|
2
|
+
// least-privilege roles and schema at startup, which a provider runs when it is passed
|
|
3
|
+
// `bootstrap: postgres.bootstrap`.
|
|
4
|
+
import { bootstrapDataSources } from "./bootstrap.js";
|
|
5
|
+
|
|
6
|
+
export * from "./bootstrap.js";
|
|
7
|
+
|
|
8
|
+
export const postgres = { bootstrap: bootstrapDataSources };
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { type ConnectionInfo, DataConfigurationError } from "@decohere/data";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import {
|
|
4
|
+
alterDatabaseOwnerQuery,
|
|
5
|
+
createSchemaQuery,
|
|
6
|
+
createUserQuery,
|
|
7
|
+
databaseOwnerQuery,
|
|
8
|
+
defaultPrivilegesForAppQueries,
|
|
9
|
+
grantConnectQuery,
|
|
10
|
+
grantRoleMembershipQuery,
|
|
11
|
+
grantSchemaCreateQuery,
|
|
12
|
+
grantSchemaPrivilegesQuery,
|
|
13
|
+
grantSchemaUsageQuery,
|
|
14
|
+
hasDatabasePrivilegeQuery,
|
|
15
|
+
hasRolePrivilegesQuery,
|
|
16
|
+
hasSchemaPrivilegeQuery,
|
|
17
|
+
quoteIdent,
|
|
18
|
+
revokeRoleMembershipQuery,
|
|
19
|
+
roleExistsQuery,
|
|
20
|
+
type SqlQuery,
|
|
21
|
+
} from "./user-scripts.js";
|
|
22
|
+
|
|
23
|
+
export type ProvisionRolesOptions = {
|
|
24
|
+
database: string;
|
|
25
|
+
appUsername: string;
|
|
26
|
+
/** Omit only when the server is declared `auth: trust`. */
|
|
27
|
+
appPassword?: string;
|
|
28
|
+
migrateUsername?: string;
|
|
29
|
+
migratePassword?: string;
|
|
30
|
+
/** When true (TypeORM synchronize), grant CREATE on the schema to the app role (dev only). */
|
|
31
|
+
allowAppDdl: boolean;
|
|
32
|
+
/** The schema the app role works in, created when missing. Default `public`. */
|
|
33
|
+
schema?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Official Postgres images always have this DB. `pg` defaults an omitted
|
|
38
|
+
* `database` to the username — bootstrap roles like `decohere_bootstrap` are
|
|
39
|
+
* not databases, so connect here before CREATE DATABASE.
|
|
40
|
+
*/
|
|
41
|
+
const MAINTENANCE_DATABASE = "postgres";
|
|
42
|
+
|
|
43
|
+
export class PostgresServerManager {
|
|
44
|
+
private pool: Pool;
|
|
45
|
+
private client: import("pg").PoolClient;
|
|
46
|
+
|
|
47
|
+
public constructor(
|
|
48
|
+
private readonly applicationName: string,
|
|
49
|
+
private readonly config: ConnectionInfo,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
/** `auth: trust`: the server lets every connection in, so roles have no password. */
|
|
53
|
+
private get trusted(): boolean {
|
|
54
|
+
return this.config.auth === "trust";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public async connect() {
|
|
58
|
+
const { adminUser, adminPassword, host, port } = this.config;
|
|
59
|
+
if (!adminUser || (!adminPassword && !this.trusted)) {
|
|
60
|
+
throw new DataConfigurationError(
|
|
61
|
+
`Data source "${this.config.name}": adminUser and adminPassword are required to bootstrap the database (adminPassword may be left out only with auth: trust)`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
this.pool = new Pool({
|
|
65
|
+
user: adminUser,
|
|
66
|
+
host,
|
|
67
|
+
password: adminPassword,
|
|
68
|
+
port,
|
|
69
|
+
database: MAINTENANCE_DATABASE,
|
|
70
|
+
application_name: this.applicationName,
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
this.client = await this.pool.connect();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
76
|
+
throw new DataConfigurationError(
|
|
77
|
+
`Cannot connect to PostgreSQL at ${host}:${port}. Is the server running? Cause: ${cause}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public async initializeDatabase(database: string) {
|
|
83
|
+
quoteIdent(database, "database");
|
|
84
|
+
const result = await this.pool.query(
|
|
85
|
+
`SELECT datname FROM pg_catalog.pg_database WHERE lower(datname) = lower($1)`,
|
|
86
|
+
[database],
|
|
87
|
+
);
|
|
88
|
+
if (result.rowCount === 1) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const dbIdent = quoteIdent(database, "database");
|
|
93
|
+
await this.pool.query(`CREATE DATABASE ${dbIdent}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create the runtime app role (+ optional migrate role) and its schema, with least-privilege
|
|
98
|
+
* grants. Running it again changes nothing: each grant is made only where the role lacks it,
|
|
99
|
+
* since Postgres checks the grantor's rights even for a grant that would change nothing.
|
|
100
|
+
*
|
|
101
|
+
* The admin acts with the database owner's rights while it runs (it is made a member of the
|
|
102
|
+
* owner, and of the migrate role, only for as long as it needs to be). That is how it creates a
|
|
103
|
+
* schema and grants on it once the migrate role owns the database.
|
|
104
|
+
*/
|
|
105
|
+
public async provisionRoles(options: ProvisionRolesOptions): Promise<void> {
|
|
106
|
+
const {
|
|
107
|
+
database,
|
|
108
|
+
appUsername,
|
|
109
|
+
appPassword,
|
|
110
|
+
migrateUsername,
|
|
111
|
+
migratePassword,
|
|
112
|
+
allowAppDdl,
|
|
113
|
+
schema = "public",
|
|
114
|
+
} = options;
|
|
115
|
+
quoteIdent(schema, "schema");
|
|
116
|
+
|
|
117
|
+
if (appUsername === this.config.adminUser) {
|
|
118
|
+
throw new DataConfigurationError(
|
|
119
|
+
`Runtime username "${appUsername}" must not equal adminUser — use a dedicated app role`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (migrateUsername && migrateUsername === appUsername) {
|
|
123
|
+
throw new DataConfigurationError(
|
|
124
|
+
"migrateUsername must differ from the runtime app username",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (migrateUsername && migrateUsername === this.config.adminUser) {
|
|
128
|
+
throw new DataConfigurationError("migrateUsername must not equal adminUser");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const adminUser = this.config.adminUser;
|
|
132
|
+
if (!adminUser) {
|
|
133
|
+
throw new DataConfigurationError("adminUser is required to provision database roles");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const pool = new Pool({
|
|
137
|
+
host: this.config.host,
|
|
138
|
+
port: this.config.port,
|
|
139
|
+
user: adminUser,
|
|
140
|
+
database,
|
|
141
|
+
password: this.config.adminPassword,
|
|
142
|
+
application_name: this.applicationName,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
await using roles = new BorrowedRoles(pool, adminUser);
|
|
147
|
+
|
|
148
|
+
if (migrateUsername) {
|
|
149
|
+
await this.ensureRole(pool, migrateUsername, migratePassword);
|
|
150
|
+
// Membership is required for OWNER TO and DEFAULT PRIVILEGES FOR ROLE (not superuser).
|
|
151
|
+
await roles.borrow(migrateUsername);
|
|
152
|
+
// The owner holds every privilege on the database, so no separate GRANT is needed.
|
|
153
|
+
if ((await databaseOwner(pool, database)) !== migrateUsername) {
|
|
154
|
+
await pool.query(alterDatabaseOwnerQuery(database, migrateUsername).text);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const owner = await databaseOwner(pool, database);
|
|
159
|
+
if (owner !== adminUser) await roles.borrow(owner);
|
|
160
|
+
|
|
161
|
+
// Owned by the database owner (the migrate role, when there is one), like `public`.
|
|
162
|
+
await pool.query(createSchemaQuery(schema, owner).text);
|
|
163
|
+
|
|
164
|
+
if (
|
|
165
|
+
migrateUsername &&
|
|
166
|
+
!(await hasAll(pool, (privilege) =>
|
|
167
|
+
hasSchemaPrivilegeQuery(migrateUsername, schema, privilege),
|
|
168
|
+
))
|
|
169
|
+
) {
|
|
170
|
+
await pool.query(grantSchemaPrivilegesQuery(migrateUsername, schema).text);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await this.ensureRole(pool, appUsername, appPassword);
|
|
174
|
+
if (
|
|
175
|
+
!(await granted(pool, hasDatabasePrivilegeQuery(appUsername, database, "CONNECT")))
|
|
176
|
+
) {
|
|
177
|
+
await pool.query(grantConnectQuery(appUsername, database).text);
|
|
178
|
+
}
|
|
179
|
+
if (!(await granted(pool, hasSchemaPrivilegeQuery(appUsername, schema, "USAGE")))) {
|
|
180
|
+
await pool.query(grantSchemaUsageQuery(appUsername, schema).text);
|
|
181
|
+
}
|
|
182
|
+
// Dev synchronize: the app connection must be able to CREATE tables.
|
|
183
|
+
if (
|
|
184
|
+
allowAppDdl &&
|
|
185
|
+
!(await granted(pool, hasSchemaPrivilegeQuery(appUsername, schema, "CREATE")))
|
|
186
|
+
) {
|
|
187
|
+
await pool.query(grantSchemaCreateQuery(appUsername, schema).text);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (migrateUsername) {
|
|
191
|
+
// Setting a default privilege that is already set changes nothing.
|
|
192
|
+
for (const q of defaultPrivilegesForAppQueries(migrateUsername, appUsername, schema)) {
|
|
193
|
+
await pool.query(q.text);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
await pool.end();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** @deprecated Use provisionRoles. */
|
|
202
|
+
public async createApiUser(
|
|
203
|
+
username: string,
|
|
204
|
+
password: string,
|
|
205
|
+
database: string,
|
|
206
|
+
_role?: string,
|
|
207
|
+
) {
|
|
208
|
+
await this.provisionRoles({
|
|
209
|
+
database,
|
|
210
|
+
appUsername: username,
|
|
211
|
+
appPassword: password,
|
|
212
|
+
migrateUsername: this.config.migrateUsername,
|
|
213
|
+
migratePassword: this.config.migratePassword,
|
|
214
|
+
allowAppDdl: this.config.synchronize === true,
|
|
215
|
+
schema: this.config.schema,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async ensureRole(pool: Pool, username: string, password?: string): Promise<void> {
|
|
220
|
+
if (!password && !this.trusted) {
|
|
221
|
+
throw new DataConfigurationError(
|
|
222
|
+
`A password is required for database role "${username}" unless the data source sets auth: trust`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const exists = roleExistsQuery(username);
|
|
226
|
+
const found = await pool.query(exists.text, exists.values);
|
|
227
|
+
if (found.rowCount === 1) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const create = createUserQuery(username, password);
|
|
231
|
+
await pool.query(create.text, create.values);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
public [Symbol.asyncDispose]() {
|
|
235
|
+
return this.closeConnection();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
public async closeConnection() {
|
|
239
|
+
this.client?.release();
|
|
240
|
+
await this.pool?.end();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function granted(pool: Pool, query: SqlQuery): Promise<boolean> {
|
|
245
|
+
const result = await pool.query(query.text, query.values);
|
|
246
|
+
return result.rows[0]?.granted === true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Every schema privilege that `GRANT ALL ON SCHEMA` gives. Checked one at a time: Postgres
|
|
250
|
+
* answers true for a list when any one of them is held. */
|
|
251
|
+
async function hasAll(pool: Pool, query: (privilege: string) => SqlQuery): Promise<boolean> {
|
|
252
|
+
for (const privilege of ["USAGE", "CREATE"]) {
|
|
253
|
+
if (!(await granted(pool, query(privilege)))) return false;
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function databaseOwner(pool: Pool, database: string): Promise<string> {
|
|
259
|
+
const query = databaseOwnerQuery(database);
|
|
260
|
+
const result = await pool.query(query.text, query.values);
|
|
261
|
+
const owner: string | undefined = result.rows[0]?.owner;
|
|
262
|
+
if (owner == null) {
|
|
263
|
+
throw new DataConfigurationError(`Database "${database}" does not exist`);
|
|
264
|
+
}
|
|
265
|
+
return owner;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Roles the admin is made a member of for one provisioning run, so it acts with their rights.
|
|
270
|
+
* Disposing gives back exactly what was borrowed, even when the run fails: memberships the admin
|
|
271
|
+
* already had (or rights it has as a superuser) are left alone.
|
|
272
|
+
*/
|
|
273
|
+
class BorrowedRoles {
|
|
274
|
+
private readonly borrowed: string[] = [];
|
|
275
|
+
|
|
276
|
+
public constructor(
|
|
277
|
+
private readonly pool: Pool,
|
|
278
|
+
private readonly adminUser: string,
|
|
279
|
+
) {}
|
|
280
|
+
|
|
281
|
+
public async borrow(role: string): Promise<void> {
|
|
282
|
+
if (this.borrowed.includes(role)) return;
|
|
283
|
+
if (await granted(this.pool, hasRolePrivilegesQuery(this.adminUser, role))) return;
|
|
284
|
+
try {
|
|
285
|
+
await this.pool.query(grantRoleMembershipQuery(this.adminUser, role).text);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
throw new DataConfigurationError(
|
|
288
|
+
`Bootstrap role "${this.adminUser}" cannot act as "${role}". Make it a member of "${role}" (or create "${role}" with it), then run again.`,
|
|
289
|
+
{ cause: err },
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
this.borrowed.push(role);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
public async [Symbol.asyncDispose](): Promise<void> {
|
|
296
|
+
for (const role of this.borrowed.reverse()) {
|
|
297
|
+
await this.pool.query(revokeRoleMembershipQuery(this.adminUser, role).text);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/** Postgres identifier: letters, digits, underscore; must start with letter or underscore. */
|
|
2
|
+
const SAFE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3
|
+
|
|
4
|
+
export function assertSafeSqlIdent(value: string, label: string): string {
|
|
5
|
+
if (typeof value !== "string" || value.length === 0 || !SAFE_IDENT.test(value)) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
`Invalid ${label}: must match ${SAFE_IDENT} (got ${JSON.stringify(value)})`,
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Double-quote a validated identifier for use in DDL. */
|
|
14
|
+
export function quoteIdent(value: string, label = "identifier"): string {
|
|
15
|
+
const safe = assertSafeSqlIdent(value, label);
|
|
16
|
+
return `"${safe.replaceAll('"', '""')}"`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Single-quote a string literal for DDL that cannot take bind parameters
|
|
21
|
+
* (e.g. CREATE USER … PASSWORD — Postgres rejects PASSWORD $1).
|
|
22
|
+
*/
|
|
23
|
+
export function quoteLiteral(value: string): string {
|
|
24
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SqlQuery = { text: string; values: unknown[] };
|
|
28
|
+
|
|
29
|
+
/** Existence check — username bound as $1 (never concatenated). */
|
|
30
|
+
export function roleExistsQuery(username: string): SqlQuery {
|
|
31
|
+
assertSafeSqlIdent(username, "username");
|
|
32
|
+
return {
|
|
33
|
+
text: `SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = $1`,
|
|
34
|
+
values: [username],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* CREATE USER with password as an escaped SQL literal.
|
|
40
|
+
* Username is validated + quoted as an identifier (cannot be parameterized in DDL).
|
|
41
|
+
* Password cannot use bind params in CREATE USER … PASSWORD (Postgres syntax error).
|
|
42
|
+
*
|
|
43
|
+
* `password` omitted (undefined, or null from a config file) creates a role with no password, for a local dev server that
|
|
44
|
+
* trusts every connection. An empty string is still an error: that is a typo, not a choice.
|
|
45
|
+
* Callers decide when no password is allowed (see PostgresServerManager).
|
|
46
|
+
*/
|
|
47
|
+
export function createUserQuery(username: string, password?: string | null): SqlQuery {
|
|
48
|
+
const userIdent = quoteIdent(username, "username");
|
|
49
|
+
const attributes = "NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE";
|
|
50
|
+
if (password == null) {
|
|
51
|
+
return { text: `CREATE USER ${userIdent} WITH LOGIN ${attributes}`, values: [] };
|
|
52
|
+
}
|
|
53
|
+
if (typeof password !== "string" || password.length === 0) {
|
|
54
|
+
throw new Error("Invalid password: must be a non-empty string");
|
|
55
|
+
}
|
|
56
|
+
const passwordLiteral = quoteLiteral(password);
|
|
57
|
+
return {
|
|
58
|
+
text: `CREATE USER ${userIdent} WITH LOGIN PASSWORD ${passwordLiteral} ${attributes}`,
|
|
59
|
+
values: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** CONNECT only — production runtime app role. */
|
|
64
|
+
export function grantConnectQuery(username: string, database: string): SqlQuery {
|
|
65
|
+
const userIdent = quoteIdent(username, "username");
|
|
66
|
+
const dbIdent = quoteIdent(database, "database");
|
|
67
|
+
return {
|
|
68
|
+
text: `GRANT CONNECT ON DATABASE ${dbIdent} TO ${userIdent}`,
|
|
69
|
+
values: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function grantSchemaUsageQuery(username: string, schema = "public"): SqlQuery {
|
|
74
|
+
const userIdent = quoteIdent(username, "username");
|
|
75
|
+
const schemaIdent = quoteIdent(schema, "schema");
|
|
76
|
+
return {
|
|
77
|
+
text: `GRANT USAGE ON SCHEMA ${schemaIdent} TO ${userIdent}`,
|
|
78
|
+
values: [],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Needed for TypeORM synchronize (dev only). */
|
|
83
|
+
export function grantSchemaCreateQuery(username: string, schema = "public"): SqlQuery {
|
|
84
|
+
const userIdent = quoteIdent(username, "username");
|
|
85
|
+
const schemaIdent = quoteIdent(schema, "schema");
|
|
86
|
+
return {
|
|
87
|
+
text: `GRANT CREATE ON SCHEMA ${schemaIdent} TO ${userIdent}`,
|
|
88
|
+
values: [],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The schema, owned by `owner`. Unchanged when it already exists. */
|
|
93
|
+
export function createSchemaQuery(schema: string, owner: string): SqlQuery {
|
|
94
|
+
const schemaIdent = quoteIdent(schema, "schema");
|
|
95
|
+
const ownerIdent = quoteIdent(owner, "owner");
|
|
96
|
+
return {
|
|
97
|
+
text: `CREATE SCHEMA IF NOT EXISTS ${schemaIdent} AUTHORIZATION ${ownerIdent}`,
|
|
98
|
+
values: [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a role already holds a privilege. Postgres checks that the grantor may grant even when
|
|
104
|
+
* the grant would change nothing, so bootstrap asks first and grants only what is missing.
|
|
105
|
+
*/
|
|
106
|
+
export function hasDatabasePrivilegeQuery(
|
|
107
|
+
username: string,
|
|
108
|
+
database: string,
|
|
109
|
+
privilege: string,
|
|
110
|
+
): SqlQuery {
|
|
111
|
+
assertSafeSqlIdent(username, "username");
|
|
112
|
+
assertSafeSqlIdent(database, "database");
|
|
113
|
+
return {
|
|
114
|
+
text: `SELECT has_database_privilege($1, $2, $3) AS granted`,
|
|
115
|
+
values: [username, database, privilege],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function hasSchemaPrivilegeQuery(
|
|
120
|
+
username: string,
|
|
121
|
+
schema: string,
|
|
122
|
+
privilege: string,
|
|
123
|
+
): SqlQuery {
|
|
124
|
+
assertSafeSqlIdent(username, "username");
|
|
125
|
+
assertSafeSqlIdent(schema, "schema");
|
|
126
|
+
return {
|
|
127
|
+
text: `SELECT has_schema_privilege($1, $2, $3) AS granted`,
|
|
128
|
+
values: [username, schema, privilege],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether `member` already has the privileges of `role` (inherited, or as a superuser). */
|
|
133
|
+
export function hasRolePrivilegesQuery(member: string, role: string): SqlQuery {
|
|
134
|
+
assertSafeSqlIdent(member, "member");
|
|
135
|
+
assertSafeSqlIdent(role, "role");
|
|
136
|
+
return {
|
|
137
|
+
text: `SELECT pg_has_role($1, $2, 'USAGE') AS granted`,
|
|
138
|
+
values: [member, role],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function databaseOwnerQuery(database: string): SqlQuery {
|
|
143
|
+
assertSafeSqlIdent(database, "database");
|
|
144
|
+
return {
|
|
145
|
+
text: `SELECT pg_get_userbyid(datdba) AS owner FROM pg_catalog.pg_database WHERE datname = $1`,
|
|
146
|
+
values: [database],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function grantSchemaPrivilegesQuery(username: string, schema = "public"): SqlQuery {
|
|
151
|
+
const userIdent = quoteIdent(username, "username");
|
|
152
|
+
const schemaIdent = quoteIdent(schema, "schema");
|
|
153
|
+
return {
|
|
154
|
+
text: `GRANT ALL ON SCHEMA ${schemaIdent} TO ${userIdent}`,
|
|
155
|
+
values: [],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function alterDatabaseOwnerQuery(database: string, username: string): SqlQuery {
|
|
160
|
+
const dbIdent = quoteIdent(database, "database");
|
|
161
|
+
const userIdent = quoteIdent(username, "username");
|
|
162
|
+
return {
|
|
163
|
+
text: `ALTER DATABASE ${dbIdent} OWNER TO ${userIdent}`,
|
|
164
|
+
values: [],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Postgres requires the session user to be a member of the target role to
|
|
170
|
+
* `ALTER DATABASE … OWNER TO` / `ALTER DEFAULT PRIVILEGES FOR ROLE`.
|
|
171
|
+
*/
|
|
172
|
+
export function grantRoleMembershipQuery(member: string, role: string): SqlQuery {
|
|
173
|
+
const memberIdent = quoteIdent(member, "member");
|
|
174
|
+
const roleIdent = quoteIdent(role, "role");
|
|
175
|
+
return {
|
|
176
|
+
text: `GRANT ${roleIdent} TO ${memberIdent}`,
|
|
177
|
+
values: [],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function revokeRoleMembershipQuery(member: string, role: string): SqlQuery {
|
|
182
|
+
const memberIdent = quoteIdent(member, "member");
|
|
183
|
+
const roleIdent = quoteIdent(role, "role");
|
|
184
|
+
return {
|
|
185
|
+
text: `REVOKE ${roleIdent} FROM ${memberIdent}`,
|
|
186
|
+
values: [],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Future tables/sequences created by the migration role are usable by the app role (DML only).
|
|
192
|
+
*/
|
|
193
|
+
export function defaultPrivilegesForAppQueries(
|
|
194
|
+
migrateUsername: string,
|
|
195
|
+
appUsername: string,
|
|
196
|
+
schema = "public",
|
|
197
|
+
): SqlQuery[] {
|
|
198
|
+
const migrateIdent = quoteIdent(migrateUsername, "migrateUsername");
|
|
199
|
+
const appIdent = quoteIdent(appUsername, "username");
|
|
200
|
+
const schemaIdent = quoteIdent(schema, "schema");
|
|
201
|
+
return [
|
|
202
|
+
{
|
|
203
|
+
text: `ALTER DEFAULT PRIVILEGES FOR ROLE ${migrateIdent} IN SCHEMA ${schemaIdent} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${appIdent}`,
|
|
204
|
+
values: [],
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
text: `ALTER DEFAULT PRIVILEGES FOR ROLE ${migrateIdent} IN SCHEMA ${schemaIdent} GRANT USAGE, SELECT ON SEQUENCES TO ${appIdent}`,
|
|
208
|
+
values: [],
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @deprecated Prefer discrete query helpers + PostgresServerManager.provisionRoles.
|
|
215
|
+
* Kept for call-site / pen-test migration: emits create + CONNECT + USAGE (not ALL PRIVILEGES).
|
|
216
|
+
*/
|
|
217
|
+
const createOwnerUser = (username: string, password: string, database: string): SqlQuery => {
|
|
218
|
+
quoteIdent(username, "username");
|
|
219
|
+
quoteIdent(database, "database");
|
|
220
|
+
if (typeof password !== "string" || password.length === 0) {
|
|
221
|
+
throw new Error("Invalid password: must be a non-empty string");
|
|
222
|
+
}
|
|
223
|
+
const create = createUserQuery(username, password);
|
|
224
|
+
const connect = grantConnectQuery(username, database);
|
|
225
|
+
const usage = grantSchemaUsageQuery(username);
|
|
226
|
+
return {
|
|
227
|
+
text: [create.text, connect.text, usage.text].join(";\n"),
|
|
228
|
+
values: [],
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const userExists = (username: string): SqlQuery => roleExistsQuery(username);
|
|
233
|
+
|
|
234
|
+
export const userScripts = { createOwnerUser, userExists };
|