@intentius/chant-lexicon-sql 0.116.0 → 0.117.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/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -5
- package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +20 -0
- package/dist/okf/index.md +4 -0
- package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
- package/dist/okf/types/PostgresGrant.md +9 -0
- package/dist/okf/types/PostgresPolicy.md +9 -0
- package/dist/okf/types/PostgresRole.md +9 -0
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +10 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/access/acl.d.ts +119 -0
- package/dist/postgres/access/acl.d.ts.map +1 -0
- package/dist/postgres/access/import.d.ts +13 -0
- package/dist/postgres/access/import.d.ts.map +1 -0
- package/dist/postgres/access/live.d.ts +24 -0
- package/dist/postgres/access/live.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +3 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +106 -4
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +4 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +4 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/live-export.d.ts +6 -0
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +2 -0
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +11 -2
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +9 -0
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/migrate/op.d.ts +29 -0
- package/dist/postgres/migrate/op.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +76 -3
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts +12 -0
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts +10 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +8 -1
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +8 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +2 -1
- package/dist/postgres/plan/schema.d.ts.map +1 -1
- package/dist/postgres/plan/server-normalize.d.ts +4 -1
- package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sqlpg001.ts +7 -4
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen/registry.ts +4 -0
- package/src/config.ts +10 -0
- package/src/generated/lexicon-sql.json +20 -0
- package/src/index.ts +1 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlpg001.ts +7 -4
- package/src/lsp/postgres-template.ts +2 -0
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.ts +7 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +1 -0
- package/src/plugin.ts +3 -0
- package/src/postgres/access/access.e2e.test.ts +191 -0
- package/src/postgres/access/access.test.ts +147 -0
- package/src/postgres/access/acl.ts +400 -0
- package/src/postgres/access/import.ts +64 -0
- package/src/postgres/access/live.ts +123 -0
- package/src/postgres/apply/apply.ts +117 -4
- package/src/postgres/apply/statements.ts +55 -9
- package/src/postgres/entities.ts +287 -11
- package/src/postgres/entity-types.ts +4 -0
- package/src/postgres/import/generator.ts +14 -4
- package/src/postgres/import/live-export.ts +12 -2
- package/src/postgres/live/bind.ts +3 -0
- package/src/postgres/live/catalog.ts +79 -3
- package/src/postgres/live/describe-resources.ts +27 -3
- package/src/postgres/migrate/migrate.test.ts +24 -1
- package/src/postgres/migrate/op.ts +80 -35
- package/src/postgres/migrate/outer.e2e.test.ts +163 -0
- package/src/postgres/parser.ts +247 -8
- package/src/postgres/plan/commands.ts +60 -7
- package/src/postgres/plan/deep.ts +24 -3
- package/src/postgres/plan/diff.ts +71 -6
- package/src/postgres/plan/normalize.ts +115 -2
- package/src/postgres/plan/rules.ts +44 -0
- package/src/postgres/plan/schema.ts +4 -2
- package/src/postgres/plan/server-normalize.ts +29 -1
- package/src/postgres.ts +11 -0
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres.md +9 -2
- package/src/validate.ts +4 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access against a live server (#3681): a declared role, a table with
|
|
3
|
+
* row-level security and a policy, grants on a schema, a table, a column and
|
|
4
|
+
* a function, a revoke from PUBLIC, and default privileges, applied where the
|
|
5
|
+
* profile manages access; then a grant and a revoke made by hand are drift
|
|
6
|
+
* the plan reports and an apply undoes; a profile that does not manage access
|
|
7
|
+
* plans none of it; and an import writes the access back as declarations.
|
|
8
|
+
*
|
|
9
|
+
* The server: `CHANT_SQL_E2E_POSTGRES_URL` (a `postgres://user@host:port/db`
|
|
10
|
+
* URL, with `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names a running one, such as
|
|
11
|
+
* the one `chant emulator up --lexicon sql` starts. Without it, a throwaway
|
|
12
|
+
* server at the pin, skipped cleanly when Docker is not available. The test
|
|
13
|
+
* writes only to the schema `chant_e2e_3681` and the roles
|
|
14
|
+
* `chant_e2e_3681_reader` and `chant_e2e_3681_writer`, all dropped afterwards.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
21
|
+
import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
|
|
22
|
+
import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../live/client";
|
|
23
|
+
import { readLiveSchema } from "../live/catalog";
|
|
24
|
+
import { planPgAgainstServer } from "../plan/commands";
|
|
25
|
+
import { postgresApply } from "../../op/activities/postgres-apply";
|
|
26
|
+
import { objectsToIR } from "../import/ir";
|
|
27
|
+
import { PostgresGenerator } from "../import/generator";
|
|
28
|
+
import { importedAccess } from "./import";
|
|
29
|
+
|
|
30
|
+
const S = "chant_e2e_3681";
|
|
31
|
+
const READER = "chant_e2e_3681_reader";
|
|
32
|
+
const WRITER = "chant_e2e_3681_writer";
|
|
33
|
+
const given = process.env.CHANT_SQL_E2E_POSTGRES_URL;
|
|
34
|
+
const enabled = given ? true : await dockerAvailable();
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pg-access-"));
|
|
36
|
+
let server: TestPostgres | undefined;
|
|
37
|
+
let endpoint: PostgresEndpoint;
|
|
38
|
+
let admin: PostgresClient;
|
|
39
|
+
|
|
40
|
+
const cleanup = async () => {
|
|
41
|
+
await admin.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`);
|
|
42
|
+
for (const r of [READER, WRITER]) await admin.query(`DROP ROLE IF EXISTS ${r}`);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
if (!enabled) return;
|
|
47
|
+
if (given) endpoint = { url: given, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
|
|
48
|
+
else {
|
|
49
|
+
server = await startTestPostgres();
|
|
50
|
+
endpoint = server.endpoint();
|
|
51
|
+
}
|
|
52
|
+
admin = await connectPostgres(endpoint);
|
|
53
|
+
await cleanup();
|
|
54
|
+
// The writer is the environment's: provisioned outside the declarations, only named in them.
|
|
55
|
+
await admin.query(`CREATE ROLE ${WRITER} NOLOGIN`);
|
|
56
|
+
}, 600_000);
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
if (enabled) {
|
|
60
|
+
await cleanup().catch(() => undefined);
|
|
61
|
+
await admin?.end();
|
|
62
|
+
}
|
|
63
|
+
await server?.stop();
|
|
64
|
+
rmSync(dir, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const MARKER = { stack: "e2e3681", env: "test" };
|
|
68
|
+
const profile = (access: boolean) => ({
|
|
69
|
+
config: { ownership: MARKER, sql: { profiles: { e2e: { url: endpoint.url, password: { env: "PG_E2E_PASSWORD" }, schemas: [S], ...(access ? { access: true } : {}) } } } },
|
|
70
|
+
env: { PG_E2E_PASSWORD: endpoint.password ?? "" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
interface Obj {
|
|
74
|
+
export: string;
|
|
75
|
+
type: string;
|
|
76
|
+
ddl: string;
|
|
77
|
+
dependsOn?: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const OBJECTS: Obj[] = [
|
|
81
|
+
{ export: "app", type: "Postgres::Schema", ddl: `CREATE SCHEMA ${S}` },
|
|
82
|
+
{ export: "reader", type: "Postgres::Role", ddl: `CREATE ROLE ${READER} NOLOGIN` },
|
|
83
|
+
{
|
|
84
|
+
export: "orders",
|
|
85
|
+
type: "Postgres::Table",
|
|
86
|
+
dependsOn: ["app"],
|
|
87
|
+
ddl: `CREATE TABLE ${S}.orders (id bigint PRIMARY KEY, tenant text NOT NULL, note varchar(40));\nALTER TABLE ${S}.orders ENABLE ROW LEVEL SECURITY`,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
export: "ordersTenant",
|
|
91
|
+
type: "Postgres::Policy",
|
|
92
|
+
dependsOn: ["orders", "reader"],
|
|
93
|
+
ddl: `CREATE POLICY orders_tenant ON ${S}.orders FOR SELECT TO ${READER} USING (tenant = current_setting('app.tenant', true) AND note <> 'hidden')`,
|
|
94
|
+
},
|
|
95
|
+
{ export: "total", type: "Postgres::Function", dependsOn: ["app"], ddl: `CREATE FUNCTION ${S}.total() RETURNS bigint LANGUAGE sql AS 'SELECT 1::bigint'` },
|
|
96
|
+
{ export: "useApp", type: "Postgres::Grant", dependsOn: ["app", "reader"], ddl: `GRANT USAGE ON SCHEMA ${S} TO ${READER}` },
|
|
97
|
+
{ export: "readOrders", type: "Postgres::Grant", dependsOn: ["orders", "reader"], ddl: `GRANT SELECT ON ${S}.orders TO ${READER}` },
|
|
98
|
+
{ export: "writeNote", type: "Postgres::Grant", dependsOn: ["orders"], ddl: `GRANT UPDATE (note) ON ${S}.orders TO ${WRITER}` },
|
|
99
|
+
{ export: "totalPrivate", type: "Postgres::Grant", dependsOn: ["total"], ddl: `REVOKE EXECUTE ON FUNCTION ${S}.total() FROM PUBLIC` },
|
|
100
|
+
{ export: "totalReader", type: "Postgres::Grant", dependsOn: ["total", "reader"], ddl: `GRANT EXECUTE ON FUNCTION ${S}.total() TO ${READER}` },
|
|
101
|
+
{ export: "readNewTables", type: "Postgres::DefaultPrivileges", dependsOn: ["app", "reader"], ddl: `ALTER DEFAULT PRIVILEGES IN SCHEMA ${S} GRANT SELECT ON TABLES TO ${READER}` },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
function writeBuild(file: string, objects: Obj[]): string {
|
|
105
|
+
const path = join(dir, file);
|
|
106
|
+
writeFileSync(path, JSON.stringify({ dialect: "postgres", postgresMajor: 18, objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
|
|
107
|
+
return path;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const apply = (path: string, access = true) => postgresApply({ buildPath: path, environment: "e2e" }, undefined, { ...profile(access), log: () => undefined });
|
|
111
|
+
const plan = (path: string, access = true) => planPgAgainstServer("e2e", path, profile(access));
|
|
112
|
+
const rules = async (path: string) => (await plan(path)).changes.map((c) => [c.object, c.rule]);
|
|
113
|
+
|
|
114
|
+
describe.skipIf(!enabled)("access on a live server", () => {
|
|
115
|
+
test("applied, then drift by hand reported and undone, unmanaged left alone, imported back", async () => {
|
|
116
|
+
const build = writeBuild("v1.json", OBJECTS);
|
|
117
|
+
|
|
118
|
+
// A profile that does not manage access: the access declarations are filtered, the table is created without row-level security.
|
|
119
|
+
const off = await apply(build, false);
|
|
120
|
+
expect(off.failed).toEqual([]);
|
|
121
|
+
expect(off.notAttempted.map((n) => n.reason)).toEqual(Array(8).fill("filtered"));
|
|
122
|
+
expect(off.notAttempted).toHaveLength(8);
|
|
123
|
+
expect((await admin.query<{ rls: boolean }>(`SELECT relrowsecurity AS rls FROM pg_class WHERE oid = '${S}.orders'::regclass`))[0]!.rls).toBe(false);
|
|
124
|
+
const unmanaged = await plan(build, false);
|
|
125
|
+
expect(unmanaged.changes).toEqual([]);
|
|
126
|
+
expect(unmanaged.hints.join(" ")).toContain("9 access declarations are not planned");
|
|
127
|
+
|
|
128
|
+
// Managed: every access declaration applied.
|
|
129
|
+
expect(await rules(build)).toEqual([
|
|
130
|
+
[`reader (${READER})`, "SQLPG200"],
|
|
131
|
+
[`orders (${S}.orders)`, "SQLPG293"],
|
|
132
|
+
[`ordersTenant (${S}.orders_tenant ON ${S}.orders)`, "SQLPG290"],
|
|
133
|
+
[`default privileges in ${S} on tables TO ${READER}`, "SQLPG298"],
|
|
134
|
+
[`relation ${S}.orders (note) TO ${WRITER}`, "SQLPG296"],
|
|
135
|
+
[`relation ${S}.orders TO ${READER}`, "SQLPG296"],
|
|
136
|
+
[`routine ${S}.total() TO PUBLIC`, "SQLPG297"],
|
|
137
|
+
[`routine ${S}.total() TO ${READER}`, "SQLPG296"],
|
|
138
|
+
[`schema ${S} TO ${READER}`, "SQLPG296"],
|
|
139
|
+
]);
|
|
140
|
+
const on = await apply(build);
|
|
141
|
+
expect(on.failed).toEqual([]);
|
|
142
|
+
expect(on.applied.map((a) => [a.name, a.action])).toEqual([
|
|
143
|
+
[S, "unchanged"],
|
|
144
|
+
[`${S}.total()`, "unchanged"],
|
|
145
|
+
[READER, "created"],
|
|
146
|
+
[`${S}.orders`, "updated"],
|
|
147
|
+
[`orders_tenant ON ${S}.orders`, "created"],
|
|
148
|
+
[`grant usage on schema ${S} to ${READER}`, "updated"],
|
|
149
|
+
[`grant select on table ${S}.orders to ${READER}`, "updated"],
|
|
150
|
+
[`grant update(note) on table ${S}.orders to ${WRITER}`, "updated"],
|
|
151
|
+
[`revoke execute on function ${S}.total() from public`, "updated"],
|
|
152
|
+
[`grant execute on function ${S}.total() to ${READER}`, "updated"],
|
|
153
|
+
[`default privileges grant select on tables in ${S} to ${READER}`, "updated"],
|
|
154
|
+
]);
|
|
155
|
+
expect(await rules(build)).toEqual([]);
|
|
156
|
+
const can = async (sql: string) => (await admin.query<{ ok: boolean }>(sql))[0]!.ok;
|
|
157
|
+
expect(await can(`SELECT has_table_privilege('${READER}', '${S}.orders', 'SELECT') AS ok`)).toBe(true);
|
|
158
|
+
expect(await can(`SELECT has_column_privilege('${WRITER}', '${S}.orders', 'note', 'UPDATE') AS ok`)).toBe(true);
|
|
159
|
+
expect(await can(`SELECT has_function_privilege('public', '${S}.total()', 'EXECUTE') AS ok`)).toBe(false);
|
|
160
|
+
expect(await can(`SELECT relrowsecurity AS ok FROM pg_class WHERE oid = '${S}.orders'::regclass`)).toBe(true);
|
|
161
|
+
|
|
162
|
+
// By hand: a grant nobody declared, and a declared grant revoked. The plan reports both; an apply undoes both.
|
|
163
|
+
await admin.query(`GRANT INSERT ON ${S}.orders TO ${WRITER}`);
|
|
164
|
+
await admin.query(`REVOKE SELECT ON ${S}.orders FROM ${READER}`);
|
|
165
|
+
expect(await rules(build)).toEqual([
|
|
166
|
+
[`relation ${S}.orders TO ${READER}`, "SQLPG296"],
|
|
167
|
+
[`relation ${S}.orders TO ${WRITER}`, "SQLPG297"],
|
|
168
|
+
]);
|
|
169
|
+
const undo = await apply(build);
|
|
170
|
+
expect(undo.failed).toEqual([]);
|
|
171
|
+
expect(undo.statements.map((s) => s.sql)).toEqual(expect.arrayContaining([`GRANT SELECT ON TABLE ${S}.orders TO ${READER}`, `REVOKE INSERT ON TABLE ${S}.orders FROM ${WRITER}`]));
|
|
172
|
+
expect(await rules(build)).toEqual([]);
|
|
173
|
+
|
|
174
|
+
// A policy changed by hand is drift too.
|
|
175
|
+
await admin.query(`ALTER POLICY orders_tenant ON ${S}.orders USING (true)`);
|
|
176
|
+
expect(await rules(build)).toEqual([[`ordersTenant (${S}.orders_tenant ON ${S}.orders)`, "SQLPG291"]]);
|
|
177
|
+
await apply(build);
|
|
178
|
+
expect(await rules(build)).toEqual([]);
|
|
179
|
+
|
|
180
|
+
// Import: the policy and the row-level security with the table, the privileges as grant declarations.
|
|
181
|
+
const live = await readLiveSchema(admin, { schemas: [S], access: true });
|
|
182
|
+
const access = await importedAccess(admin, live, [S]);
|
|
183
|
+
const [file] = new PostgresGenerator().generate(objectsToIR([...live.map((o) => ({ type: o.type, schema: o.schema, name: o.name, ddl: o.statement })), ...access]));
|
|
184
|
+
expect(file!.content).toContain("export const ordersTenant = policy`");
|
|
185
|
+
expect(file!.content).toContain(`ALTER TABLE \${chantE2e3681Schema}.orders ENABLE ROW LEVEL SECURITY`);
|
|
186
|
+
expect(file!.content).toContain(`GRANT SELECT ON TABLE \${orders} TO ${READER}`);
|
|
187
|
+
expect(file!.content).toContain(`REVOKE EXECUTE ON FUNCTION \${total}() FROM PUBLIC`);
|
|
188
|
+
expect(file!.content).toContain(`ALTER DEFAULT PRIVILEGES IN SCHEMA \${chantE2e3681Schema} GRANT SELECT ON TABLES TO ${READER}`);
|
|
189
|
+
expect(file!.content).toContain(`GRANT USAGE ON SCHEMA \${chantE2e3681Schema} TO ${READER}`);
|
|
190
|
+
}, 600_000);
|
|
191
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row-level security, roles, grants and default privileges (#3681): what the
|
|
3
|
+
* tags parse and refuse, how the declarations add up to access, and how a
|
|
4
|
+
* change is classified and made.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { func, grant, policy, role, schema, table } from "../entities";
|
|
9
|
+
import { diffObject, keyedByQualifiedName, pgSchemaFromLive, type PgSchemaObject } from "../plan/schema";
|
|
10
|
+
import { diffPgSchemas, matchPgObjects } from "../plan/diff";
|
|
11
|
+
import { accessScoped } from "../plan/commands";
|
|
12
|
+
import { alterSteps } from "../apply/statements";
|
|
13
|
+
import { declaredObjects } from "../apply/apply";
|
|
14
|
+
import type { LivePgObject } from "../live/catalog";
|
|
15
|
+
import { declaredAccess, diffAccess } from "./acl";
|
|
16
|
+
|
|
17
|
+
const app = schema`CREATE SCHEMA app`;
|
|
18
|
+
const reader = role`CREATE ROLE app_reader NOLOGIN`;
|
|
19
|
+
const orders = table`
|
|
20
|
+
CREATE TABLE ${app}.orders (id bigint PRIMARY KEY, tenant text NOT NULL, note text);
|
|
21
|
+
ALTER TABLE ${app}.orders ENABLE ROW LEVEL SECURITY;
|
|
22
|
+
ALTER TABLE ${app}.orders FORCE ROW LEVEL SECURITY`;
|
|
23
|
+
|
|
24
|
+
describe("the tags", () => {
|
|
25
|
+
test("a table's row-level security, a policy, a role", () => {
|
|
26
|
+
expect(orders.props).toMatchObject({ rowSecurity: true, forceRowSecurity: true });
|
|
27
|
+
const p = policy`CREATE POLICY tenant_rows ON ${orders} AS RESTRICTIVE FOR SELECT TO ${reader}, PUBLIC USING (${orders.columns.tenant} = current_setting('app.tenant'))`;
|
|
28
|
+
expect(p.entityType).toBe("Postgres::Policy");
|
|
29
|
+
expect(p.props).toMatchObject({ schema: "app", name: "tenant_rows", tableName: "app.orders", permissive: false, command: "select", roles: ["app_reader", "public"], using: "tenant = current_setting('app.tenant')" });
|
|
30
|
+
expect(p.dependsOn).toEqual(expect.arrayContaining([orders, reader]));
|
|
31
|
+
expect(reader.props).toMatchObject({ name: "app_reader", options: ["nologin"] });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("grants on a schema, a table's columns and a routine, a revoke, default privileges", () => {
|
|
35
|
+
const f = func`CREATE FUNCTION ${app}.total(n int) RETURNS bigint LANGUAGE sql AS 'select 1'`;
|
|
36
|
+
expect(grant`GRANT USAGE ON SCHEMA ${app} TO ${reader}`.props).toMatchObject({ action: "grant", on: "schema", objectNames: ["app"], grantees: ["app_reader"] });
|
|
37
|
+
expect(grant`GRANT SELECT, UPDATE (note) ON ${orders} TO writer WITH GRANT OPTION`.props).toMatchObject({
|
|
38
|
+
on: "table",
|
|
39
|
+
privileges: [{ privilege: "select" }, { privilege: "update", columns: ["note"] }],
|
|
40
|
+
withGrantOption: true,
|
|
41
|
+
});
|
|
42
|
+
expect(grant`REVOKE EXECUTE ON FUNCTION ${f} FROM PUBLIC`.props).toMatchObject({ action: "revoke", objectNames: ["app.total(int)"], grantees: ["public"] });
|
|
43
|
+
const d = grant`ALTER DEFAULT PRIVILEGES IN SCHEMA ${app} GRANT SELECT ON TABLES TO ${reader}`;
|
|
44
|
+
expect(d.entityType).toBe("Postgres::DefaultPrivileges");
|
|
45
|
+
expect(d.props).toMatchObject({ inSchemas: ["app"], on: "tables", privileges: ["select"] });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("secrets, memberships, ALL ... IN SCHEMA and an unnamed overload are refused", () => {
|
|
49
|
+
expect(() => role`CREATE ROLE r LOGIN PASSWORD 'x'`).toThrow(/environment's secret/);
|
|
50
|
+
expect(() => role`CREATE ROLE r IN ROLE admins`).toThrow(/environment's/);
|
|
51
|
+
expect(() => grant`GRANT admins TO r`).toThrow(/membership/);
|
|
52
|
+
expect(() => grant`GRANT SELECT ON ALL TABLES IN SCHEMA app TO r`).toThrow(/ALTER DEFAULT PRIVILEGES/);
|
|
53
|
+
expect(() => grant`GRANT EXECUTE ON FUNCTION app.f TO r`).toThrow(/parameter types/);
|
|
54
|
+
expect(() => table`ALTER TABLE app.t ENABLE ROW LEVEL SECURITY`).toThrow(/after the table's CREATE TABLE/);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Access ─────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const decl = (type: string, ddl: string, key = ddl): PgSchemaObject => ({ key, canonical: { ...diffObject(type, ddl, "public"), exportName: key } });
|
|
61
|
+
const T = (ddl: string) => decl("Postgres::Table", ddl);
|
|
62
|
+
const F = (ddl: string) => decl("Postgres::Function", ddl);
|
|
63
|
+
const G = (ddl: string) => decl("Postgres::Grant", ddl);
|
|
64
|
+
const D = (ddl: string) => decl("Postgres::DefaultPrivileges", ddl);
|
|
65
|
+
|
|
66
|
+
const entries = (objects: PgSchemaObject[]) =>
|
|
67
|
+
Object.fromEntries([...declaredAccess(objects, { major: 18 }).state].map(([k, e]) => [k, [...e.privileges].map(([p, g]) => `${p}${g ? "*" : ""}`).sort().join(",")]));
|
|
68
|
+
|
|
69
|
+
describe("the access declarations add up to", () => {
|
|
70
|
+
test("a routine's EXECUTE to PUBLIC unless revoked; grants added, revokes taken away, in order", () => {
|
|
71
|
+
expect(entries([F("CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'")])).toEqual({ "routine app.f() TO PUBLIC": "execute" });
|
|
72
|
+
expect(
|
|
73
|
+
entries([
|
|
74
|
+
F("CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'"),
|
|
75
|
+
T("CREATE TABLE app.t (a int, b int)"),
|
|
76
|
+
G("REVOKE EXECUTE ON FUNCTION app.f() FROM PUBLIC"),
|
|
77
|
+
G("GRANT ALL ON app.t TO r WITH GRANT OPTION"),
|
|
78
|
+
G("REVOKE GRANT OPTION FOR DELETE ON app.t FROM r"),
|
|
79
|
+
G("REVOKE TRUNCATE, MAINTAIN ON app.t FROM r"),
|
|
80
|
+
G("GRANT SELECT (b) ON app.t TO s"),
|
|
81
|
+
]),
|
|
82
|
+
).toEqual({ "relation app.t TO r": "delete,insert*,references*,select*,trigger*,update*", "relation app.t (b) TO s": "select" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("default privileges are compared themselves, and given to the objects chant creates", () => {
|
|
86
|
+
expect(
|
|
87
|
+
entries([
|
|
88
|
+
T("CREATE TABLE app.t (a int)"),
|
|
89
|
+
D("ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO r"),
|
|
90
|
+
D("ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC"),
|
|
91
|
+
F("CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'"),
|
|
92
|
+
]),
|
|
93
|
+
).toEqual({ "default privileges in app on tables TO r": "select", "relation app.t TO r": "select" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a change is a GRANT, a REVOKE, or ALTER DEFAULT PRIVILEGES", () => {
|
|
97
|
+
const before = declaredAccess([T("CREATE TABLE app.t (a int)"), G("GRANT SELECT, INSERT ON app.t TO r")], { major: 18 });
|
|
98
|
+
const after = declaredAccess([T("CREATE TABLE app.t (a int)"), G("GRANT SELECT ON app.t TO r WITH GRANT OPTION"), G("GRANT USAGE ON SCHEMA app TO r"), D("ALTER DEFAULT PRIVILEGES FOR ROLE owner IN SCHEMA app GRANT SELECT ON TABLES TO r")], { major: 18 });
|
|
99
|
+
expect(diffAccess(before.state, after.state).map((c) => [c.change.object, c.change.rule, c.sql])).toEqual([
|
|
100
|
+
["acl default privileges for owner in app on tables TO r", "SQLPG298", ["ALTER DEFAULT PRIVILEGES FOR ROLE owner IN SCHEMA app GRANT SELECT ON TABLES TO r"]],
|
|
101
|
+
["acl relation app.t TO r", "SQLPG296", ["REVOKE INSERT ON TABLE app.t FROM r", "GRANT SELECT ON TABLE app.t TO r WITH GRANT OPTION"]],
|
|
102
|
+
["acl schema app TO r", "SQLPG296", ["GRANT USAGE ON SCHEMA app TO r"]],
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("between two builds, a new function's EXECUTE to PUBLIC is not a change", () => {
|
|
107
|
+
const f = "CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'";
|
|
108
|
+
expect(diffPgSchemas([], [F(f)]).changes.map((c) => c.rule)).toEqual(["SQLPG200"]);
|
|
109
|
+
expect(diffPgSchemas([], [F(f), G("REVOKE EXECUTE ON FUNCTION app.f() FROM PUBLIC")]).changes.map((c) => [c.object, c.rule])).toEqual([
|
|
110
|
+
[f, "SQLPG200"],
|
|
111
|
+
["acl routine app.f() TO PUBLIC", "SQLPG297"],
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a profile that does not manage access leaves out the access declarations and a table's row-level security", () => {
|
|
116
|
+
const { kept, unmanaged } = accessScoped(keyedByQualifiedName([T("CREATE TABLE app.t (a int);\nALTER TABLE app.t ENABLE ROW LEVEL SECURITY"), G("GRANT SELECT ON app.t TO r")]), false);
|
|
117
|
+
expect(unmanaged).toBe(2);
|
|
118
|
+
expect(kept.map((o) => [o.canonical.kind, o.canonical.fields.rowSecurity])).toEqual([["table", undefined]]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── Policies, row-level security, roles ────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const live = (type: string, statement: string) => pgSchemaFromLive([{ type, statement, name: "", oid: "1" } as LivePgObject], "public");
|
|
125
|
+
function steps(type: string, liveDdl: string, declaredDdl: string): string[] {
|
|
126
|
+
const [obj] = declaredObjects(JSON.stringify({ dialect: "postgres", objects: [{ export: "x", type, ddl: declaredDdl, dependsOn: [] }] }));
|
|
127
|
+
const after = keyedByQualifiedName([{ key: "x", canonical: obj!.canonical }]);
|
|
128
|
+
const before = live(type, liveDdl);
|
|
129
|
+
const m = matchPgObjects(before, after).find((x) => x.kind === "matched")!;
|
|
130
|
+
return alterSteps(obj!, m.before!.canonical, diffPgSchemas(before, after).changes, { major: 18 }).steps.map((s) => s.sql);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("the classifier and the statements", () => {
|
|
134
|
+
const P = "Postgres::Policy";
|
|
135
|
+
test("a policy's roles and expressions are ALTER POLICY; its command is a drop and a create", () => {
|
|
136
|
+
const p1 = "CREATE POLICY p ON app.t FOR SELECT TO r USING (a > 0)";
|
|
137
|
+
expect(steps(P, p1, "CREATE POLICY p ON app.t FOR SELECT TO r, s USING (a > 1)")).toEqual(["ALTER POLICY p ON app.t TO r, s USING (a > 1)"]);
|
|
138
|
+
expect(steps(P, p1, "CREATE POLICY p ON app.t FOR UPDATE TO r USING (a > 0)").slice(0, 2)).toEqual(["DROP POLICY p ON app.t", "CREATE POLICY p ON app.t FOR UPDATE TO r USING (a > 0)"]);
|
|
139
|
+
expect(diffPgSchemas([], keyedByQualifiedName([decl(P, p1)])).changes.map((c) => c.rule)).toEqual(["SQLPG290"]);
|
|
140
|
+
expect(diffPgSchemas(live(P, p1), []).changes.map((c) => c.rule)).toEqual(["SQLPG292"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("row-level security toggled, and a role's attributes changed", () => {
|
|
144
|
+
expect(steps("Postgres::Table", "CREATE TABLE app.t (a int)", "CREATE TABLE app.t (a int);\nALTER TABLE app.t ENABLE ROW LEVEL SECURITY")).toEqual(["ALTER TABLE app.t ENABLE ROW LEVEL SECURITY"]);
|
|
145
|
+
expect(steps("Postgres::Role", "CREATE ROLE r", "CREATE ROLE r LOGIN NOINHERIT CONNECTION LIMIT 5")).toEqual(["ALTER ROLE r WITH LOGIN NOINHERIT CONNECTION LIMIT 5"]);
|
|
146
|
+
});
|
|
147
|
+
});
|