@decohere/data-typeorm 0.0.1-dev.126 → 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/package.json +8 -24
- package/src/bootstrap-policy.ts +84 -0
- package/src/bootstrap.ts +76 -0
- package/src/data-source.factory.ts +51 -0
- package/src/database-options.ts +10 -0
- package/src/dialect-map.ts +12 -0
- package/{es6/src/inject-data-source.js → src/inject-data-source.ts} +15 -9
- package/src/scripts/user.ts +234 -0
- package/src/server-manager.ts +300 -0
- package/src/sql-pretty.transport.ts +29 -0
- package/src/type-orm.module.ts +155 -0
- package/src/type-orm.provider.ts +37 -0
- package/src/type-orm.repository.ts +33 -0
- package/src/typeorm-scribe.logger.ts +55 -0
- package/src/typeorm.extensions.ts +42 -0
- package/src/unit-of-work.ts +52 -0
- package/tests/bootstrap-schemas.docker.spec.ts +145 -0
- package/tests/bootstrap.spec.ts +156 -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 +212 -0
- package/tests/type-orm.module.spec.ts +123 -0
- package/tests/typeorm.lifecycle.spec.ts +97 -0
- package/tests/unit-of-work.spec.ts +54 -0
- package/tests/user-scripts.pentest.spec.ts +42 -0
- package/LICENSE +0 -15
- package/commonjs/src/bootstrap-policy.js +0 -69
- package/commonjs/src/bootstrap.js +0 -142
- package/commonjs/src/data-source.factory.js +0 -42
- package/commonjs/src/database-options.js +0 -4
- package/commonjs/src/dialect-map.js +0 -26
- package/commonjs/src/index.js +0 -24
- package/commonjs/src/inject-data-source.js +0 -33
- package/commonjs/src/scripts/user.js +0 -259
- package/commonjs/src/server-manager.js +0 -294
- package/commonjs/src/sql-pretty.transport.js +0 -35
- package/commonjs/src/type-orm.module.js +0 -115
- package/commonjs/src/type-orm.provider.js +0 -36
- package/commonjs/src/type-orm.repository.js +0 -30
- package/commonjs/src/typeorm-scribe.logger.js +0 -70
- package/commonjs/src/typeorm.extensions.js +0 -60
- package/commonjs/src/unit-of-work.js +0 -49
- package/es6/src/bootstrap-policy.js +0 -55
- package/es6/src/bootstrap.js +0 -139
- package/es6/src/data-source.factory.js +0 -27
- package/es6/src/database-options.js +0 -1
- package/es6/src/dialect-map.js +0 -8
- package/es6/src/scripts/user.js +0 -209
- package/es6/src/server-manager.js +0 -284
- package/es6/src/sql-pretty.transport.js +0 -20
- package/es6/src/type-orm.module.js +0 -105
- package/es6/src/type-orm.provider.js +0 -26
- package/es6/src/type-orm.repository.js +0 -20
- package/es6/src/typeorm-scribe.logger.js +0 -60
- package/es6/src/typeorm.extensions.js +0 -50
- package/es6/src/unit-of-work.js +0 -39
- package/types/src/bootstrap-policy.d.ts +0 -19
- package/types/src/bootstrap.d.ts +0 -16
- package/types/src/data-source.factory.d.ts +0 -17
- package/types/src/database-options.d.ts +0 -9
- package/types/src/dialect-map.d.ts +0 -8
- package/types/src/index.d.ts +0 -7
- package/types/src/inject-data-source.d.ts +0 -14
- package/types/src/scripts/user.d.ts +0 -56
- package/types/src/server-manager.d.ts +0 -39
- package/types/src/sql-pretty.transport.d.ts +0 -2
- package/types/src/type-orm.module.d.ts +0 -17
- package/types/src/type-orm.provider.d.ts +0 -14
- package/types/src/type-orm.repository.d.ts +0 -12
- package/types/src/typeorm-scribe.logger.d.ts +0 -9
- package/types/src/typeorm.extensions.d.ts +0 -11
- package/types/src/unit-of-work.d.ts +0 -20
- /package/{es6/src/index.js → src/index.ts} +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { IUnitOfWork } from "@decohere/data";
|
|
2
|
+
import type { Constructable } from "@decohere/core/reflection";
|
|
3
|
+
import type { DataSource, QueryRunner, Repository } from "typeorm";
|
|
4
|
+
|
|
5
|
+
export class TypeOrmUnitOfWork implements IUnitOfWork {
|
|
6
|
+
private readonly dataSource: DataSource;
|
|
7
|
+
public readonly queryRunner: QueryRunner;
|
|
8
|
+
|
|
9
|
+
public constructor(dataSource: DataSource) {
|
|
10
|
+
this.dataSource = dataSource;
|
|
11
|
+
this.queryRunner = this.dataSource.createQueryRunner();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public get entityManager() {
|
|
15
|
+
return this.queryRunner.manager;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public getRepository<T>(entity: Constructable): Repository<T> {
|
|
19
|
+
return this.entityManager.getRepository<T>(entity);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public async beginTransaction(): Promise<void> {
|
|
23
|
+
return this.queryRunner.startTransaction();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public async commit() {
|
|
27
|
+
return this.queryRunner.commitTransaction();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hand the connection back to the pool, rolling back first if a transaction is still open
|
|
32
|
+
* (e.g. the request failed before commit). Otherwise the next user of that pooled
|
|
33
|
+
* connection would inherit the half-finished transaction (#502).
|
|
34
|
+
*/
|
|
35
|
+
public async [Symbol.asyncDispose]() {
|
|
36
|
+
if (this.queryRunner.isReleased) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
if (this.queryRunner.isTransactionActive) {
|
|
41
|
+
await this.queryRunner.rollbackTransaction();
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
await this.queryRunner.release();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Prefer `await using` / async disposal; this starts the same cleanup without waiting. */
|
|
49
|
+
public [Symbol.dispose]() {
|
|
50
|
+
void this[Symbol.asyncDispose]();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ConnectionInfo } from "@decohere/data";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
4
|
+
import { bootstrapDataSources } from "../src/bootstrap.js";
|
|
5
|
+
import { type PostgresContainer, startPostgres } from "./postgres-container.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ledger's shape (#656): one trusted dev server, one database, a schema per module, and a
|
|
9
|
+
* migrate role that ends up owning the database. Needs Docker: runs under `pnpm test:docker`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const BOOTSTRAP_USER = "decohere_bootstrap";
|
|
13
|
+
const APP_USER = "ledger_app";
|
|
14
|
+
const MIGRATE_USER = "ledger_migrate";
|
|
15
|
+
const DATABASE = "ledger_dev";
|
|
16
|
+
const SCHEMAS = ["accounts", "liquidity"] as const;
|
|
17
|
+
|
|
18
|
+
describe("bootstrapDataSources against a trusted Postgres (#656)", () => {
|
|
19
|
+
let postgres: PostgresContainer;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
postgres = await startPostgres({ trust: true });
|
|
23
|
+
await postgres.superuser.query(
|
|
24
|
+
`CREATE ROLE ${BOOTSTRAP_USER} LOGIN CREATEDB CREATEROLE NOSUPERUSER NOBYPASSRLS`,
|
|
25
|
+
);
|
|
26
|
+
}, 120_000);
|
|
27
|
+
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
await postgres?.stop();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function dataSource(schema: string): ConnectionInfo {
|
|
33
|
+
return {
|
|
34
|
+
name: schema,
|
|
35
|
+
provider: "postgres",
|
|
36
|
+
host: postgres.host,
|
|
37
|
+
port: postgres.port,
|
|
38
|
+
database: DATABASE,
|
|
39
|
+
username: APP_USER,
|
|
40
|
+
auth: "trust",
|
|
41
|
+
schema,
|
|
42
|
+
synchronize: true,
|
|
43
|
+
seed: false,
|
|
44
|
+
bootstrap: true,
|
|
45
|
+
adminUser: BOOTSTRAP_USER,
|
|
46
|
+
migrateUsername: MIGRATE_USER,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bootstrap() {
|
|
51
|
+
return bootstrapDataSources(SCHEMAS.map(dataSource), {
|
|
52
|
+
applicationName: "ledger.it",
|
|
53
|
+
nodeEnv: "development",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function as<T>(user: string, work: (pool: Pool) => Promise<T>): Promise<T> {
|
|
58
|
+
const pool = new Pool({
|
|
59
|
+
host: postgres.host,
|
|
60
|
+
port: postgres.port,
|
|
61
|
+
user,
|
|
62
|
+
database: DATABASE,
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
return await work(pool);
|
|
66
|
+
} finally {
|
|
67
|
+
await pool.end();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Everything the bootstrap may touch, as text, so two runs can be compared. */
|
|
72
|
+
async function catalogState() {
|
|
73
|
+
return as(MIGRATE_USER, async (pool) => {
|
|
74
|
+
const { rows } = await pool.query(`
|
|
75
|
+
SELECT 'schema ' || nspname || ' owner=' || pg_get_userbyid(nspowner) || ' acl=' || coalesce(nspacl::text, '') AS line
|
|
76
|
+
FROM pg_namespace WHERE nspname IN ('public', 'accounts', 'liquidity')
|
|
77
|
+
UNION ALL
|
|
78
|
+
SELECT 'database owner=' || pg_get_userbyid(datdba) || ' acl=' || coalesce(datacl::text, '')
|
|
79
|
+
FROM pg_database WHERE datname = current_database()
|
|
80
|
+
UNION ALL
|
|
81
|
+
SELECT 'default ' || pg_get_userbyid(defaclrole) || ' ' || defaclnamespace::regnamespace::text
|
|
82
|
+
|| ' ' || defaclobjtype::text || ' ' || defaclacl::text
|
|
83
|
+
FROM pg_default_acl
|
|
84
|
+
UNION ALL
|
|
85
|
+
SELECT 'member ' || pg_get_userbyid(member) || ' of ' || pg_get_userbyid(roleid)
|
|
86
|
+
|| ' admin=' || admin_option || ' inherit=' || inherit_option || ' set=' || set_option
|
|
87
|
+
FROM pg_auth_members
|
|
88
|
+
WHERE pg_get_userbyid(member) IN ('${BOOTSTRAP_USER}', '${APP_USER}', '${MIGRATE_USER}')
|
|
89
|
+
ORDER BY 1`);
|
|
90
|
+
return rows.map((row: { line: string }) => row.line);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
it("creates the database, the roles and each schema, and lets the app role work in them", async () => {
|
|
95
|
+
await bootstrap();
|
|
96
|
+
|
|
97
|
+
const owners = await as(APP_USER, async (pool) => {
|
|
98
|
+
const { rows } = await pool.query(
|
|
99
|
+
`SELECT nspname, pg_get_userbyid(nspowner) AS owner FROM pg_namespace
|
|
100
|
+
WHERE nspname = ANY($1) ORDER BY nspname`,
|
|
101
|
+
[SCHEMAS],
|
|
102
|
+
);
|
|
103
|
+
return rows;
|
|
104
|
+
});
|
|
105
|
+
expect(owners).toEqual([
|
|
106
|
+
{ nspname: "accounts", owner: MIGRATE_USER },
|
|
107
|
+
{ nspname: "liquidity", owner: MIGRATE_USER },
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
// synchronize: the app role creates its own tables in each schema.
|
|
111
|
+
for (const schema of SCHEMAS) {
|
|
112
|
+
await as(APP_USER, (pool) =>
|
|
113
|
+
pool.query(
|
|
114
|
+
`CREATE TABLE ${schema}.synced (id int); INSERT INTO ${schema}.synced VALUES (1)`,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Default privileges apply in the named schemas: tables the migrate role creates there
|
|
120
|
+
// are readable and writable by the app role.
|
|
121
|
+
for (const schema of SCHEMAS) {
|
|
122
|
+
await as(MIGRATE_USER, (pool) => pool.query(`CREATE TABLE ${schema}.migrated (id int)`));
|
|
123
|
+
await as(APP_USER, (pool) =>
|
|
124
|
+
pool.query(
|
|
125
|
+
`INSERT INTO ${schema}.migrated VALUES (1); SELECT * FROM ${schema}.migrated`,
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("changes nothing when run again, now that the migrate role owns the database", async () => {
|
|
132
|
+
const before = await catalogState();
|
|
133
|
+
expect(before.find((line) => line.startsWith("database "))).toMatch(
|
|
134
|
+
new RegExp(`^database owner=${MIGRATE_USER} `),
|
|
135
|
+
);
|
|
136
|
+
// The admin gives back the roles it borrowed: it keeps no membership that lends it their rights.
|
|
137
|
+
expect(before.filter((line) => line.startsWith(`member ${BOOTSTRAP_USER} `))).not.toEqual(
|
|
138
|
+
expect.arrayContaining([expect.stringContaining("inherit=true")]),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
await bootstrap();
|
|
142
|
+
|
|
143
|
+
expect(await catalogState()).toEqual(before);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { ConnectionInfo } from "@decohere/data";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const { PoolMock, queryMock } = vi.hoisted(() => {
|
|
5
|
+
const queryMock = vi.fn();
|
|
6
|
+
const PoolMock = vi.fn(function Pool(
|
|
7
|
+
this: {
|
|
8
|
+
connect: () => Promise<{ release: () => void }>;
|
|
9
|
+
query: typeof queryMock;
|
|
10
|
+
end: () => Promise<void>;
|
|
11
|
+
},
|
|
12
|
+
_options: unknown,
|
|
13
|
+
) {
|
|
14
|
+
this.connect = async () => ({ release() {} });
|
|
15
|
+
this.query = queryMock;
|
|
16
|
+
this.end = async () => {};
|
|
17
|
+
return this;
|
|
18
|
+
});
|
|
19
|
+
return { PoolMock, queryMock };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
vi.mock("pg", () => ({ Pool: PoolMock }));
|
|
23
|
+
|
|
24
|
+
import { bootstrapDataSources } from "../src/bootstrap.js";
|
|
25
|
+
|
|
26
|
+
/** Edges the real-Postgres suite (bootstrap-schemas.docker.spec.ts) does not see. */
|
|
27
|
+
|
|
28
|
+
function dataSource(overrides: Partial<ConnectionInfo> = {}): ConnectionInfo {
|
|
29
|
+
return {
|
|
30
|
+
name: "accounts",
|
|
31
|
+
provider: "postgres",
|
|
32
|
+
host: "postgres",
|
|
33
|
+
port: 5432,
|
|
34
|
+
database: "ledger_dev",
|
|
35
|
+
username: "ledger_app",
|
|
36
|
+
auth: "trust",
|
|
37
|
+
schema: "accounts",
|
|
38
|
+
synchronize: true,
|
|
39
|
+
seed: false,
|
|
40
|
+
bootstrap: true,
|
|
41
|
+
adminUser: "decohere_bootstrap",
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pools opened on the maintenance database: one per server and database bootstrapped. */
|
|
47
|
+
function serverConnections(): number {
|
|
48
|
+
return PoolMock.mock.calls.filter(
|
|
49
|
+
(call) => (call[0] as { database: string }).database === "postgres",
|
|
50
|
+
).length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sqlSent(): string[] {
|
|
54
|
+
return queryMock.mock.calls.map((call) => String(call[0]));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let privilegesHeld = false;
|
|
58
|
+
|
|
59
|
+
describe("bootstrapDataSources (#656)", () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
PoolMock.mockClear();
|
|
62
|
+
queryMock.mockReset();
|
|
63
|
+
privilegesHeld = false;
|
|
64
|
+
queryMock.mockImplementation(async (sql: string) => {
|
|
65
|
+
if (sql.includes("datdba"))
|
|
66
|
+
return { rowCount: 1, rows: [{ owner: "decohere_bootstrap" }] };
|
|
67
|
+
if (sql.includes("AS granted"))
|
|
68
|
+
return { rowCount: 1, rows: [{ granted: privilegesHeld }] };
|
|
69
|
+
return { rowCount: 0, rows: [] };
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const options = { applicationName: "ledger", nodeEnv: "development" };
|
|
74
|
+
|
|
75
|
+
it("bootstraps data sources that share a server and database once", async () => {
|
|
76
|
+
await bootstrapDataSources(
|
|
77
|
+
[
|
|
78
|
+
dataSource(),
|
|
79
|
+
dataSource({ name: "liquidity", schema: "liquidity" }),
|
|
80
|
+
dataSource({ name: "other", database: "other_dev", schema: undefined }),
|
|
81
|
+
],
|
|
82
|
+
options,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(serverConnections()).toBe(2);
|
|
86
|
+
expect(sqlSent().filter((sql) => sql.startsWith("CREATE SCHEMA"))).toEqual([
|
|
87
|
+
'CREATE SCHEMA IF NOT EXISTS "accounts" AUTHORIZATION "decohere_bootstrap"',
|
|
88
|
+
'CREATE SCHEMA IF NOT EXISTS "liquidity" AUTHORIZATION "decohere_bootstrap"',
|
|
89
|
+
'CREATE SCHEMA IF NOT EXISTS "public" AUTHORIZATION "decohere_bootstrap"',
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("grants on the data source's schema, not on public", async () => {
|
|
94
|
+
await bootstrapDataSources([dataSource()], options);
|
|
95
|
+
|
|
96
|
+
const grants = sqlSent().filter((sql) => sql.startsWith("GRANT"));
|
|
97
|
+
expect(grants).toEqual([
|
|
98
|
+
'GRANT CONNECT ON DATABASE "ledger_dev" TO "ledger_app"',
|
|
99
|
+
'GRANT USAGE ON SCHEMA "accounts" TO "ledger_app"',
|
|
100
|
+
'GRANT CREATE ON SCHEMA "accounts" TO "ledger_app"',
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("grants nothing the roles already hold", async () => {
|
|
105
|
+
privilegesHeld = true;
|
|
106
|
+
await bootstrapDataSources([dataSource({ migrateUsername: "ledger_migrate" })], options);
|
|
107
|
+
|
|
108
|
+
expect(sqlSent().filter((sql) => /^(GRANT|REVOKE)/.test(sql))).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("refuses data sources on one database that disagree on how to bootstrap it", async () => {
|
|
112
|
+
await expect(
|
|
113
|
+
bootstrapDataSources(
|
|
114
|
+
[dataSource(), dataSource({ name: "liquidity", migrateUsername: "ledger_migrate" })],
|
|
115
|
+
options,
|
|
116
|
+
),
|
|
117
|
+
).rejects.toThrow(
|
|
118
|
+
/Data sources "accounts" and "liquidity" share database postgres:5432\/ledger_dev but set different migrateUsername/,
|
|
119
|
+
);
|
|
120
|
+
expect(PoolMock).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("checks the auth of every data source, including those it does not bootstrap", async () => {
|
|
124
|
+
await expect(
|
|
125
|
+
bootstrapDataSources(
|
|
126
|
+
[dataSource({ name: "readonly", bootstrap: false, auth: undefined })],
|
|
127
|
+
options,
|
|
128
|
+
),
|
|
129
|
+
).rejects.toThrow(/Data source "readonly" has no password/);
|
|
130
|
+
expect(PoolMock).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("refuses auth: trust outside development before connecting", async () => {
|
|
134
|
+
await expect(
|
|
135
|
+
bootstrapDataSources([dataSource()], {
|
|
136
|
+
applicationName: "ledger",
|
|
137
|
+
nodeEnv: "production",
|
|
138
|
+
}),
|
|
139
|
+
).rejects.toThrow(/sets auth: trust, which is allowed only in development/);
|
|
140
|
+
expect(PoolMock).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("explains when the admin cannot act as the database owner", async () => {
|
|
144
|
+
queryMock.mockImplementation(async (sql: string) => {
|
|
145
|
+
if (sql.includes("datdba")) return { rowCount: 1, rows: [{ owner: "postgres" }] };
|
|
146
|
+
if (sql.includes("AS granted")) return { rowCount: 1, rows: [{ granted: false }] };
|
|
147
|
+
if (sql.startsWith('GRANT "postgres"'))
|
|
148
|
+
throw new Error('permission denied to grant role "postgres"');
|
|
149
|
+
return { rowCount: 0, rows: [] };
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await expect(bootstrapDataSources([dataSource()], options)).rejects.toThrow(
|
|
153
|
+
/Bootstrap role "decohere_bootstrap" cannot act as "postgres"/,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { ConnectionInfo } from "@decohere/data";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { shouldBootstrapDatabase } from "../src/bootstrap-policy.js";
|
|
4
|
+
import {
|
|
5
|
+
createSchemaQuery,
|
|
6
|
+
createUserQuery,
|
|
7
|
+
defaultPrivilegesForAppQueries,
|
|
8
|
+
grantConnectQuery,
|
|
9
|
+
grantSchemaCreateQuery,
|
|
10
|
+
grantSchemaUsageQuery,
|
|
11
|
+
userScripts,
|
|
12
|
+
} from "../src/scripts/user.js";
|
|
13
|
+
|
|
14
|
+
function baseInfo(overrides: Partial<ConnectionInfo> = {}): ConnectionInfo {
|
|
15
|
+
return {
|
|
16
|
+
name: "default",
|
|
17
|
+
provider: "postgres",
|
|
18
|
+
host: "localhost",
|
|
19
|
+
port: 5432,
|
|
20
|
+
database: "webapi_dev",
|
|
21
|
+
synchronize: false,
|
|
22
|
+
seed: false,
|
|
23
|
+
username: "webapi_app",
|
|
24
|
+
password: "app-secret",
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("pen-test — least-privilege DB roles CU-869eh3065", () => {
|
|
30
|
+
it("runtime createOwnerUser helper grants CONNECT+USAGE, not ALL PRIVILEGES", () => {
|
|
31
|
+
const { text } = userScripts.createOwnerUser("webapi_app", "s3cret!", "webapi_dev");
|
|
32
|
+
expect(text).toMatch(/GRANT CONNECT ON DATABASE/);
|
|
33
|
+
expect(text).toMatch(/GRANT USAGE ON SCHEMA "public"/);
|
|
34
|
+
expect(text).not.toMatch(/GRANT ALL PRIVILEGES ON DATABASE/);
|
|
35
|
+
expect(text).not.toMatch(/GRANT ALL ON SCHEMA/);
|
|
36
|
+
expect(text).toMatch(/NOSUPERUSER/);
|
|
37
|
+
expect(text).toMatch(/NOCREATEDB/);
|
|
38
|
+
expect(text).toMatch(/NOCREATEROLE/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("app grants stay DML-oriented", () => {
|
|
42
|
+
const connect = grantConnectQuery("webapi_app", "webapi_dev");
|
|
43
|
+
const usage = grantSchemaUsageQuery("webapi_app");
|
|
44
|
+
expect(connect.text).toContain("GRANT CONNECT");
|
|
45
|
+
expect(usage.text).toContain("GRANT USAGE");
|
|
46
|
+
expect(grantSchemaCreateQuery("webapi_app").text).toContain("GRANT CREATE");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects a schema name with SQL metacharacters", () => {
|
|
50
|
+
expect(() => grantSchemaUsageQuery("webapi_app", 'accounts"; DROP SCHEMA x; --')).toThrow(
|
|
51
|
+
/Invalid schema/,
|
|
52
|
+
);
|
|
53
|
+
expect(() => createSchemaQuery("accounts; DROP", "webapi_migrate")).toThrow(
|
|
54
|
+
/Invalid schema/,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("default privileges grant DML (not ALL) from migrate role to app role", () => {
|
|
59
|
+
const qs = defaultPrivilegesForAppQueries("webapi_migrate", "webapi_app");
|
|
60
|
+
const joined = qs.map((q) => q.text).join("\n");
|
|
61
|
+
expect(joined).toMatch(/GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES/);
|
|
62
|
+
expect(joined).toMatch(/GRANT USAGE, SELECT ON SEQUENCES/);
|
|
63
|
+
expect(joined).not.toMatch(/GRANT ALL ON TABLES/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("CREATE USER escapes password literals (Postgres forbids PASSWORD $1)", () => {
|
|
67
|
+
const create = createUserQuery("webapi_app", "x'; DROP ROLE x; --");
|
|
68
|
+
expect(create.text).not.toMatch(/PASSWORD\s+\$\d/);
|
|
69
|
+
expect(create.values).toEqual([]);
|
|
70
|
+
// Escaped so the quote does not terminate the literal early.
|
|
71
|
+
expect(create.text).toContain("PASSWORD 'x''; DROP ROLE x; --'");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("bootstrap gate CU-869eh3065", () => {
|
|
76
|
+
it("does not bootstrap in production when bootstrap is omitted", () => {
|
|
77
|
+
expect(
|
|
78
|
+
shouldBootstrapDatabase(
|
|
79
|
+
baseInfo({ adminUser: "decohere_bootstrap", adminPassword: "x" }),
|
|
80
|
+
"production",
|
|
81
|
+
),
|
|
82
|
+
).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("does not bootstrap when bootstrap: false even in development", () => {
|
|
86
|
+
expect(
|
|
87
|
+
shouldBootstrapDatabase(
|
|
88
|
+
baseInfo({
|
|
89
|
+
bootstrap: false,
|
|
90
|
+
adminUser: "decohere_bootstrap",
|
|
91
|
+
adminPassword: "x",
|
|
92
|
+
}),
|
|
93
|
+
"development",
|
|
94
|
+
),
|
|
95
|
+
).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("bootstraps in development when admin creds are present", () => {
|
|
99
|
+
expect(
|
|
100
|
+
shouldBootstrapDatabase(
|
|
101
|
+
baseInfo({ adminUser: "decohere_bootstrap", adminPassword: "x" }),
|
|
102
|
+
"dev",
|
|
103
|
+
),
|
|
104
|
+
).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("bootstraps when bootstrap: true and admin creds are set", () => {
|
|
108
|
+
expect(
|
|
109
|
+
shouldBootstrapDatabase(
|
|
110
|
+
baseInfo({
|
|
111
|
+
bootstrap: true,
|
|
112
|
+
adminUser: "decohere_bootstrap",
|
|
113
|
+
adminPassword: "x",
|
|
114
|
+
}),
|
|
115
|
+
"production",
|
|
116
|
+
),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("refuses bootstrap: true without admin credentials", () => {
|
|
121
|
+
expect(() => shouldBootstrapDatabase(baseInfo({ bootstrap: true }), "production")).toThrow(
|
|
122
|
+
/adminUser and adminPassword/,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { createConnection } from "node:net";
|
|
4
|
+
import { Pool } from "pg";
|
|
5
|
+
import { vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
// test/fs.setup.ts swaps node:fs for memfs; /.dockerenv is on the real disk.
|
|
8
|
+
const { existsSync } = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
9
|
+
|
|
10
|
+
/** A throwaway postgres:16 container for `*.docker.spec.ts` suites. */
|
|
11
|
+
export type PostgresContainer = {
|
|
12
|
+
host: string;
|
|
13
|
+
port: number;
|
|
14
|
+
superuser: Pool;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const SUPERUSER = "postgres";
|
|
19
|
+
const SUPER_PASSWORD = "postgres";
|
|
20
|
+
|
|
21
|
+
export type StartPostgresOptions = {
|
|
22
|
+
/** Start the server with every connection trusted (pg_hba `trust`), like a local dev server. */
|
|
23
|
+
trust?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function startPostgres(
|
|
27
|
+
options: StartPostgresOptions = {},
|
|
28
|
+
): Promise<PostgresContainer> {
|
|
29
|
+
try {
|
|
30
|
+
execFileSync("docker", ["info"], { stdio: "ignore" });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
throw new Error("Docker is required for data-typeorm *.docker.spec.ts suites", {
|
|
33
|
+
cause: err,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const name = `decohere-data-typeorm-it-${randomBytes(4).toString("hex")}`;
|
|
38
|
+
const auth = options.trust
|
|
39
|
+
? ["-e", "POSTGRES_HOST_AUTH_METHOD=trust"]
|
|
40
|
+
: ["-e", `POSTGRES_PASSWORD=${SUPER_PASSWORD}`];
|
|
41
|
+
const containerId = execFileSync(
|
|
42
|
+
"docker",
|
|
43
|
+
["run", "-d", "--rm", "--name", name, ...auth, "-p", "127.0.0.1::5432", "postgres:16"],
|
|
44
|
+
{ encoding: "utf8" },
|
|
45
|
+
).trim();
|
|
46
|
+
|
|
47
|
+
const { host, port } = address(containerId);
|
|
48
|
+
await waitForTcp(host, port, 60_000);
|
|
49
|
+
const superuser = await waitForSuperuser(host, port, options.trust === true);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
host,
|
|
53
|
+
port,
|
|
54
|
+
superuser,
|
|
55
|
+
async stop() {
|
|
56
|
+
await superuser.end();
|
|
57
|
+
execFileSync("docker", ["stop", containerId], { stdio: "ignore" });
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Inside a dev container that shares the host's Docker daemon, a port published on the host's
|
|
64
|
+
* 127.0.0.1 is out of reach, but the container's bridge address is not. Elsewhere (CI), use the
|
|
65
|
+
* published port.
|
|
66
|
+
*/
|
|
67
|
+
function address(containerId: string): { host: string; port: number } {
|
|
68
|
+
if (existsSync("/.dockerenv")) {
|
|
69
|
+
const ip = execFileSync(
|
|
70
|
+
"docker",
|
|
71
|
+
[
|
|
72
|
+
"inspect",
|
|
73
|
+
"-f",
|
|
74
|
+
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
|
|
75
|
+
containerId,
|
|
76
|
+
],
|
|
77
|
+
{ encoding: "utf8" },
|
|
78
|
+
).trim();
|
|
79
|
+
if (ip.length === 0) throw new Error(`Container ${containerId} has no bridge address`);
|
|
80
|
+
return { host: ip, port: 5432 };
|
|
81
|
+
}
|
|
82
|
+
const portMap = execFileSync("docker", ["port", containerId, "5432/tcp"], {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
}).trim();
|
|
85
|
+
// e.g. 127.0.0.1:32768
|
|
86
|
+
const match = portMap.match(/:(\d+)\s*$/);
|
|
87
|
+
if (!match?.[1]) {
|
|
88
|
+
throw new Error(`Could not parse docker port mapping from: ${portMap}`);
|
|
89
|
+
}
|
|
90
|
+
return { host: "127.0.0.1", port: Number(match[1]) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function waitForTcp(host: string, port: number, timeoutMs: number): Promise<void> {
|
|
94
|
+
const start = Date.now();
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const attempt = () => {
|
|
97
|
+
const socket = createConnection({ host, port }, () => {
|
|
98
|
+
socket.end();
|
|
99
|
+
resolve();
|
|
100
|
+
});
|
|
101
|
+
socket.on("error", () => {
|
|
102
|
+
socket.destroy();
|
|
103
|
+
if (Date.now() - start > timeoutMs) {
|
|
104
|
+
reject(new Error(`Timed out waiting for Postgres at ${host}:${port}`));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
setTimeout(attempt, 200);
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
attempt();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The image restarts the server once after init, so the first logins can still fail. */
|
|
115
|
+
async function waitForSuperuser(host: string, port: number, trust: boolean): Promise<Pool> {
|
|
116
|
+
const start = Date.now();
|
|
117
|
+
for (;;) {
|
|
118
|
+
const pool = new Pool({
|
|
119
|
+
host,
|
|
120
|
+
port,
|
|
121
|
+
user: SUPERUSER,
|
|
122
|
+
password: trust ? undefined : SUPER_PASSWORD,
|
|
123
|
+
database: "postgres",
|
|
124
|
+
connectionTimeoutMillis: 2000,
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
await pool.query("SELECT 1");
|
|
128
|
+
return pool;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
await pool.end();
|
|
131
|
+
if (Date.now() - start > 60_000) {
|
|
132
|
+
throw new Error("Timed out waiting for Postgres to accept superuser login", {
|
|
133
|
+
cause: err,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|