@intentius/chant-lexicon-sql 0.115.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.
Files changed (116) hide show
  1. package/dist/codegen/registry.d.ts.map +1 -1
  2. package/dist/config.d.ts +20 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/integrity.json +5 -5
  7. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  8. package/dist/lsp/postgres-template.d.ts.map +1 -1
  9. package/dist/manifest.json +1 -1
  10. package/dist/meta.json +35 -0
  11. package/dist/okf/index.md +7 -0
  12. package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
  13. package/dist/okf/types/PostgresFunction.md +9 -0
  14. package/dist/okf/types/PostgresGrant.md +9 -0
  15. package/dist/okf/types/PostgresPolicy.md +9 -0
  16. package/dist/okf/types/PostgresProcedure.md +9 -0
  17. package/dist/okf/types/PostgresRole.md +9 -0
  18. package/dist/okf/types/PostgresTrigger.md +9 -0
  19. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  20. package/dist/op/activity-contracts.d.ts +10 -0
  21. package/dist/op/activity-contracts.d.ts.map +1 -1
  22. package/dist/plugin.d.ts.map +1 -1
  23. package/dist/postgres/access/acl.d.ts +119 -0
  24. package/dist/postgres/access/acl.d.ts.map +1 -0
  25. package/dist/postgres/access/import.d.ts +13 -0
  26. package/dist/postgres/access/import.d.ts.map +1 -0
  27. package/dist/postgres/access/live.d.ts +24 -0
  28. package/dist/postgres/access/live.d.ts.map +1 -0
  29. package/dist/postgres/apply/apply.d.ts +6 -1
  30. package/dist/postgres/apply/apply.d.ts.map +1 -1
  31. package/dist/postgres/apply/statements.d.ts +12 -2
  32. package/dist/postgres/apply/statements.d.ts.map +1 -1
  33. package/dist/postgres/entities.d.ts +212 -3
  34. package/dist/postgres/entities.d.ts.map +1 -1
  35. package/dist/postgres/entity-types.d.ts +7 -0
  36. package/dist/postgres/entity-types.d.ts.map +1 -1
  37. package/dist/postgres/import/generator.d.ts +4 -1
  38. package/dist/postgres/import/generator.d.ts.map +1 -1
  39. package/dist/postgres/import/live-export.d.ts +6 -0
  40. package/dist/postgres/import/live-export.d.ts.map +1 -1
  41. package/dist/postgres/live/bind.d.ts +2 -0
  42. package/dist/postgres/live/bind.d.ts.map +1 -1
  43. package/dist/postgres/live/catalog.d.ts +25 -4
  44. package/dist/postgres/live/catalog.d.ts.map +1 -1
  45. package/dist/postgres/live/describe-resources.d.ts +15 -1
  46. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  47. package/dist/postgres/migrate/op.d.ts +29 -0
  48. package/dist/postgres/migrate/op.d.ts.map +1 -1
  49. package/dist/postgres/parser.d.ts +155 -5
  50. package/dist/postgres/parser.d.ts.map +1 -1
  51. package/dist/postgres/plan/commands.d.ts +12 -0
  52. package/dist/postgres/plan/commands.d.ts.map +1 -1
  53. package/dist/postgres/plan/deep.d.ts.map +1 -1
  54. package/dist/postgres/plan/diff.d.ts +11 -2
  55. package/dist/postgres/plan/diff.d.ts.map +1 -1
  56. package/dist/postgres/plan/normalize.d.ts +28 -2
  57. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  58. package/dist/postgres/plan/rules.d.ts +14 -0
  59. package/dist/postgres/plan/rules.d.ts.map +1 -1
  60. package/dist/postgres/plan/schema.d.ts +12 -1
  61. package/dist/postgres/plan/schema.d.ts.map +1 -1
  62. package/dist/postgres/plan/server-normalize.d.ts +4 -1
  63. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  64. package/dist/postgres.d.ts +1 -1
  65. package/dist/postgres.d.ts.map +1 -1
  66. package/dist/rules/postgres-templates.ts +2 -2
  67. package/dist/rules/sqlpg001.ts +10 -4
  68. package/dist/validate.d.ts.map +1 -1
  69. package/package.json +2 -2
  70. package/src/codegen/postgres-classifier-page.ts +1 -1
  71. package/src/codegen/registry.ts +7 -0
  72. package/src/config.ts +10 -0
  73. package/src/generated/lexicon-sql.json +35 -0
  74. package/src/index.ts +1 -1
  75. package/src/lint/rules/postgres-templates.ts +2 -2
  76. package/src/lint/rules/sqlpg001.ts +10 -4
  77. package/src/lsp/postgres-template.ts +8 -0
  78. package/src/mcp/postgres.ts +1 -1
  79. package/src/migration-statements.ts +7 -0
  80. package/src/op/activities/postgres-migration.ts +27 -2
  81. package/src/op/activity-contracts.ts +1 -0
  82. package/src/plugin.ts +6 -0
  83. package/src/postgres/access/access.e2e.test.ts +191 -0
  84. package/src/postgres/access/access.test.ts +147 -0
  85. package/src/postgres/access/acl.ts +400 -0
  86. package/src/postgres/access/import.ts +64 -0
  87. package/src/postgres/access/live.ts +123 -0
  88. package/src/postgres/apply/apply.ts +135 -6
  89. package/src/postgres/apply/statements.ts +104 -16
  90. package/src/postgres/entities.ts +613 -14
  91. package/src/postgres/entity-types.ts +7 -0
  92. package/src/postgres/import/generator.ts +18 -5
  93. package/src/postgres/import/live-export.ts +16 -2
  94. package/src/postgres/live/bind.ts +3 -0
  95. package/src/postgres/live/catalog.ts +155 -5
  96. package/src/postgres/live/describe-resources.ts +43 -9
  97. package/src/postgres/live/routines.e2e.test.ts +173 -0
  98. package/src/postgres/migrate/batches.e2e.test.ts +2 -1
  99. package/src/postgres/migrate/migrate.e2e.test.ts +2 -1
  100. package/src/postgres/migrate/migrate.test.ts +24 -1
  101. package/src/postgres/migrate/op.ts +80 -35
  102. package/src/postgres/migrate/outer.e2e.test.ts +163 -0
  103. package/src/postgres/parser.ts +554 -9
  104. package/src/postgres/plan/commands.ts +61 -8
  105. package/src/postgres/plan/deep.ts +79 -18
  106. package/src/postgres/plan/diff.ts +114 -9
  107. package/src/postgres/plan/normalize.ts +272 -5
  108. package/src/postgres/plan/rules.ts +80 -0
  109. package/src/postgres/plan/schema.ts +27 -6
  110. package/src/postgres/plan/server-normalize.ts +53 -1
  111. package/src/postgres/routines.test.ts +150 -0
  112. package/src/postgres.ts +22 -0
  113. package/src/skill-defs.ts +1 -1
  114. package/src/skills/chant-sql-postgres-migration.md +3 -1
  115. package/src/skills/chant-sql-postgres.md +25 -2
  116. package/src/validate.ts +7 -0
@@ -63,5 +63,40 @@
63
63
  "resourceType": "Postgres::Extension",
64
64
  "kind": "resource",
65
65
  "lexicon": "sql"
66
+ },
67
+ "PostgresFunction": {
68
+ "resourceType": "Postgres::Function",
69
+ "kind": "resource",
70
+ "lexicon": "sql"
71
+ },
72
+ "PostgresProcedure": {
73
+ "resourceType": "Postgres::Procedure",
74
+ "kind": "resource",
75
+ "lexicon": "sql"
76
+ },
77
+ "PostgresTrigger": {
78
+ "resourceType": "Postgres::Trigger",
79
+ "kind": "resource",
80
+ "lexicon": "sql"
81
+ },
82
+ "PostgresPolicy": {
83
+ "resourceType": "Postgres::Policy",
84
+ "kind": "resource",
85
+ "lexicon": "sql"
86
+ },
87
+ "PostgresRole": {
88
+ "resourceType": "Postgres::Role",
89
+ "kind": "resource",
90
+ "lexicon": "sql"
91
+ },
92
+ "PostgresGrant": {
93
+ "resourceType": "Postgres::Grant",
94
+ "kind": "resource",
95
+ "lexicon": "sql"
96
+ },
97
+ "PostgresDefaultPrivileges": {
98
+ "resourceType": "Postgres::DefaultPrivileges",
99
+ "kind": "resource",
100
+ "lexicon": "sql"
66
101
  }
67
102
  }
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ export { database, table, view, literal } from "./clickhouse/entities";
18
18
  // A tag folds as the function its file imports, so `table` imported from
19
19
  // `@intentius/chant-lexicon-sql/postgres` is Postgres's. Declarations import
20
20
  // them from the dialect subpath.
21
- export { schema, index, sequence, type, domain, extension } from "./postgres/entities";
21
+ export { schema, index, sequence, type, domain, extension, func, procedure, trigger, policy, role, grant } from "./postgres/entities";
22
22
 
23
23
  // A schema change's statements between two builds, rendered offline for a
24
24
  // migration file (#3644); also at `@intentius/chant-lexicon-sql/migration-statements`.
@@ -17,12 +17,12 @@ export const POSTGRES_TEMPLATE_SOURCES: readonly TemplateSource<PostgresTag>[] =
17
17
  {
18
18
  dialect: "postgres",
19
19
  modules: ["@intentius/chant-lexicon-sql/postgres"],
20
- tags: ["schema", "table", "index", "view", "sequence", "type", "domain", "extension"],
20
+ tags: ["schema", "table", "index", "view", "sequence", "type", "domain", "extension", "func", "procedure", "trigger", "policy", "role", "grant"],
21
21
  },
22
22
  {
23
23
  dialect: "postgres",
24
24
  modules: ["@intentius/chant-lexicon-sql"],
25
- tags: ["schema", "index", "sequence", "type", "domain", "extension"],
25
+ tags: ["schema", "index", "sequence", "type", "domain", "extension", "func", "procedure", "trigger", "policy", "role", "grant"],
26
26
  },
27
27
  ];
28
28
 
@@ -1,6 +1,6 @@
1
1
  import type { LintContext, LintDiagnostic, LintRule } from "@intentius/chant/lint/rule";
2
2
  import { parseStatements, SqlSyntaxError } from "../../postgres/parser";
3
- import { STATEMENT_NAMES, type PostgresTag } from "../../postgres/entities";
3
+ import { followOnStray, STATEMENT_NAMES, type PostgresTag } from "../../postgres/entities";
4
4
  import { findPostgresTemplates, postgresTokensOf, templatePosition, tokenPosition } from "./postgres-templates";
5
5
 
6
6
  /** The statement each tag holds. */
@@ -13,6 +13,12 @@ const STATEMENT: Record<PostgresTag, string> = {
13
13
  type: "enum",
14
14
  domain: "domain",
15
15
  extension: "extension",
16
+ func: "function",
17
+ procedure: "procedure",
18
+ trigger: "trigger",
19
+ policy: "policy",
20
+ role: "role",
21
+ grant: "grant",
16
22
  };
17
23
 
18
24
  const TAG_OF: Record<string, PostgresTag> = Object.fromEntries(Object.entries(STATEMENT).map(([tag, statement]) => [statement, tag as PostgresTag]));
@@ -59,15 +65,15 @@ export const sqlpg001: LintRule = {
59
65
  report(
60
66
  use
61
67
  ? `${found.tag}\`...\` holds a ${STATEMENT_NAMES[node!.statement]}; use the ${use} tag`
62
- : `${found.tag}\`...\` starts with a COMMENT ON; it goes after the CREATE it comments on`,
68
+ : `${found.tag}\`...\` starts with a ${STATEMENT_NAMES[node!.statement]}; it goes after the CREATE it belongs to`,
63
69
  templatePosition(source, found, 0, 0),
64
70
  );
65
71
  continue;
66
72
  }
67
- const stray = rest.find((n) => n.statement !== "comment");
73
+ const stray = followOnStray(found.tag, rest);
68
74
  if (stray) {
69
75
  report(
70
- `${found.tag}\`...\` holds a second statement (${STATEMENT_NAMES[stray.statement]}); a template declares one object, followed only by COMMENT ON statements for it`,
76
+ `${found.tag}\`...\` holds a second statement (${STATEMENT_NAMES[stray.statement]}); a template declares one object, followed only by COMMENT ON statements for it${found.tag === "table" ? " and ALTER TABLE ... ROW LEVEL SECURITY" : ""}`,
71
77
  templatePosition(source, found, 0, 0),
72
78
  );
73
79
  }
@@ -149,6 +149,14 @@ export function postgresDeclarations(content: string, fileName = "file.ts"): Pos
149
149
  case "extension":
150
150
  sqlName = text(node.name.span);
151
151
  break;
152
+ case "function":
153
+ case "procedure":
154
+ case "trigger":
155
+ case "policy":
156
+ case "role":
157
+ sqlName = text(node.name.span);
158
+ kind = node.statement;
159
+ break;
152
160
  default:
153
161
  continue;
154
162
  }
@@ -81,7 +81,7 @@ export function postgresSearch(params: Record<string, unknown>): unknown {
81
81
  };
82
82
  }
83
83
 
84
- const TAGS = { schema: pg.schema, table: pg.table, index: pg.index, view: pg.view, sequence: pg.sequence, type: pg.type, domain: pg.domain, extension: pg.extension } as const;
84
+ const TAGS = { schema: pg.schema, table: pg.table, index: pg.index, view: pg.view, sequence: pg.sequence, type: pg.type, domain: pg.domain, extension: pg.extension, func: pg.func, procedure: pg.procedure, trigger: pg.trigger, policy: pg.policy, role: pg.role, grant: pg.grant } as const;
85
85
  export const POSTGRES_TAGS = Object.keys(TAGS);
86
86
 
87
87
  /** Parse one statement with the Postgres tag; an error comes back as SQLPG001 with its line and column. */
@@ -237,6 +237,13 @@ function postgresStatements(beforeJson: string, afterJson: string, options: Diff
237
237
  });
238
238
  }
239
239
  }
240
+ // Privileges (#3681), after the objects they are on: one statement each, under the declaration behind it.
241
+ const afterByExport = new Map(after.map((o) => [o.exportName, o]));
242
+ for (const c of diff.access ?? []) {
243
+ const decl = c.exports.map((e) => afterByExport.get(e)).find((o) => o !== undefined);
244
+ const where = decl ? at(decl.exportName, decl.type, decl.name) : at(c.change.object, c.target.kind === "default" ? POSTGRES_ENTITY_TYPES.defaultPrivileges : POSTGRES_ENTITY_TYPES.grant, c.change.object.slice(4));
245
+ for (const sql of c.sql) steps.push({ kind: "statement", ...where, sql, rule: c.change.rule, class: c.change.class, transactional: true });
246
+ }
240
247
  const beforeByExport = new Map(before.map((o) => [o.exportName, o]));
241
248
  for (const d of plan.drops) {
242
249
  const o = beforeByExport.get(d.key);
@@ -17,8 +17,8 @@ import { resolveBoundTarget } from "../../postgres/live/bind";
17
17
  import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../../postgres/live/client";
18
18
  import { DEFAULT_POSTGRES_APPLY_TIMEOUTS, declaredObjects } from "../../postgres/apply/apply";
19
19
  import { PG_CLASSIFIER_RULES } from "../../postgres/plan/rules";
20
- import { declaredTable, migrationPlanDigest, observeMigration } from "../../postgres/migrate/observe";
21
- import { backfill, verifyMigration, type BackfillDeps, type BackfillResult, type VerifyResult } from "../../postgres/migrate/backfill";
20
+ import { declaredTable, migrationPlanDigest, MigrationRefusal, observeMigration } from "../../postgres/migrate/observe";
21
+ import { backfill, MigrationVerificationError, verifyMigration, type BackfillDeps, type BackfillResult, type VerifyResult } from "../../postgres/migrate/backfill";
22
22
  import {
23
23
  carryOver,
24
24
  compensate,
@@ -60,8 +60,33 @@ async function loadConfig(cwd: string): Promise<Pick<ChantConfig, "sql" | "owner
60
60
  }
61
61
  }
62
62
 
63
+ /**
64
+ * With `onFailure: "keep"` (#3687) no onFailure drops what the expand added,
65
+ * so a step that fails says what a person does instead: a refusal or a
66
+ * failed verification would otherwise fail every run after it the same way.
67
+ */
68
+ const ON_FAILURE_DROPS = "onFailure drops what the expand added, and the next run starts again.";
69
+
70
+ function noting(args: PostgresMigrationArgs, err: unknown): unknown {
71
+ if (args.keepOnFailure && (err instanceof MigrationRefusal || err instanceof MigrationVerificationError)) {
72
+ const kept =
73
+ `This migration keeps what the expand added on failure (onFailure: "keep"), so nothing drops it: ` +
74
+ `to start again from a new column, run the Op once with onFailure: "drop" (the default), whose onFailure drops it when this step fails again.`;
75
+ err.message = err.message.includes(ON_FAILURE_DROPS) ? err.message.replace(ON_FAILURE_DROPS, kept) : `${err.message.replace(/\.?$/, ".")} ${kept}`;
76
+ }
77
+ return err;
78
+ }
79
+
63
80
  /** Bind the server, connect and read the declaration, run `body` with it, then close the connection. */
64
81
  async function withRun<T>(args: PostgresMigrationArgs, signal: AbortSignal | undefined, deps: PostgresMigrationDeps, body: (run: MigrationRun) => Promise<T>): Promise<T> {
82
+ try {
83
+ return await bound(args, signal, deps, body);
84
+ } catch (err) {
85
+ throw noting(args, err);
86
+ }
87
+ }
88
+
89
+ async function bound<T>(args: PostgresMigrationArgs, signal: AbortSignal | undefined, deps: PostgresMigrationDeps, body: (run: MigrationRun) => Promise<T>): Promise<T> {
65
90
  const cwd = args.cwd ?? process.cwd();
66
91
  const config = deps.config ?? (await loadConfig(cwd));
67
92
  const marker = resolveOwnershipMarker(args, config, "PostgresMigrationOp");
@@ -127,6 +127,7 @@ const migrationArgs = z.strictObject({
127
127
  stack: z.string().optional(),
128
128
  ownershipEnv: z.string().optional(),
129
129
  cwd: z.string().optional(),
130
+ keepOnFailure: z.boolean().optional(),
130
131
  });
131
132
 
132
133
  const migrationState = z.enum(["migrate", "switched", "done"]);
package/src/plugin.ts CHANGED
@@ -103,6 +103,12 @@ export const sqlPlugin: LexiconPlugin = {
103
103
  { name: "type", isTag: true, description: "A Postgres CREATE TYPE ... AS ENUM, parsed into an enum entity" },
104
104
  { name: "domain", isTag: true, description: "A Postgres CREATE DOMAIN, parsed into a domain entity" },
105
105
  { name: "extension", isTag: true, description: "A Postgres CREATE EXTENSION, parsed into an extension entity" },
106
+ { name: "func", isTag: true, description: "A Postgres CREATE FUNCTION, parsed into a function entity with its body kept verbatim" },
107
+ { name: "procedure", isTag: true, description: "A Postgres CREATE PROCEDURE, parsed into a procedure entity with its body kept verbatim" },
108
+ { name: "trigger", isTag: true, description: "A Postgres CREATE TRIGGER, parsed into a trigger entity on its table" },
109
+ { name: "policy", isTag: true, description: "A Postgres CREATE POLICY, parsed into a row-level security policy on its table" },
110
+ { name: "role", isTag: true, description: "A Postgres CREATE ROLE without a password or memberships, parsed into a role entity" },
111
+ { name: "grant", isTag: true, description: "A Postgres GRANT, REVOKE or ALTER DEFAULT PRIVILEGES, parsed into the privileges it declares" },
106
112
  {
107
113
  name: "literal",
108
114
  isTag: false,
@@ -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
+ });