@intentius/chant-lexicon-sql 0.124.54 → 0.124.56

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 (61) hide show
  1. package/dist/capability-plugin.d.ts +160 -0
  2. package/dist/capability-plugin.d.ts.map +1 -0
  3. package/dist/capability-plugin.js +370 -0
  4. package/dist/change-set.d.ts +35 -0
  5. package/dist/change-set.d.ts.map +1 -1
  6. package/dist/change-set.js +43 -0
  7. package/dist/ci-setup.d.ts +34 -0
  8. package/dist/ci-setup.d.ts.map +1 -0
  9. package/dist/ci-setup.js +106 -0
  10. package/dist/clickhouse/access.d.ts +20 -0
  11. package/dist/clickhouse/access.d.ts.map +1 -1
  12. package/dist/clickhouse/access.js +42 -1
  13. package/dist/clickhouse/live/describe-resources.d.ts +3 -1
  14. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  15. package/dist/clickhouse/live/describe-resources.js +31 -3
  16. package/dist/clickhouse/plan/deep.d.ts +42 -11
  17. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  18. package/dist/clickhouse/plan/deep.js +104 -33
  19. package/dist/exit-codes.d.ts +69 -0
  20. package/dist/exit-codes.d.ts.map +1 -0
  21. package/dist/exit-codes.js +105 -0
  22. package/dist/index.d.ts +4 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +12 -0
  25. package/dist/integrity.json +2 -2
  26. package/dist/lock-grain.d.ts +27 -0
  27. package/dist/lock-grain.d.ts.map +1 -0
  28. package/dist/lock-grain.js +13 -0
  29. package/dist/manifest.json +1 -1
  30. package/dist/plugin.d.ts.map +1 -1
  31. package/dist/plugin.js +8 -0
  32. package/dist/postgres/testing/migration.d.ts +2 -0
  33. package/dist/postgres/testing/migration.d.ts.map +1 -1
  34. package/dist/token-source.d.ts +28 -0
  35. package/dist/token-source.d.ts.map +1 -1
  36. package/dist/token-source.js +70 -1
  37. package/dist/yodel-run.d.ts +58 -0
  38. package/dist/yodel-run.d.ts.map +1 -0
  39. package/dist/yodel-run.js +145 -0
  40. package/package.json +2 -2
  41. package/src/capability-plugin.test.ts +317 -0
  42. package/src/capability-plugin.ts +515 -0
  43. package/src/change-set.ts +74 -0
  44. package/src/ci-setup.test.ts +67 -0
  45. package/src/ci-setup.ts +116 -0
  46. package/src/clickhouse/access.ts +49 -2
  47. package/src/clickhouse/live/access.e2e.test.ts +8 -7
  48. package/src/clickhouse/live/describe-resources.ts +27 -3
  49. package/src/clickhouse/plan/deep-grants-drift.test.ts +151 -0
  50. package/src/clickhouse/plan/deep-grants.test.ts +10 -5
  51. package/src/clickhouse/plan/deep.ts +103 -32
  52. package/src/exit-codes.test.ts +46 -0
  53. package/src/exit-codes.ts +140 -0
  54. package/src/index.ts +28 -0
  55. package/src/lock-grain.test.ts +57 -0
  56. package/src/lock-grain.ts +37 -0
  57. package/src/plugin.ts +9 -0
  58. package/src/testdata/fake-yodel.mjs +58 -0
  59. package/src/token-source-traded.test.ts +75 -0
  60. package/src/token-source.ts +74 -1
  61. package/src/yodel-run.ts +175 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * What a SQL project's CI jobs need from the sql lexicon (#4572, over
3
+ * #4550's `LexiconPlugin.ciSetup`): the setup lines for chant's default
4
+ * image, and the replay server per dialect as a job service
5
+ * (`LexiconPlugin.ciServices`). sql-yodeler's own versions were
6
+ * `src/ci/render.ts` (`installLines`, `REPLAY_SERVICE`, `waitFor`), which
7
+ * now come from here.
8
+ *
9
+ * The lines: a Node check, since the lexicon and chant run on Node 22, then
10
+ * the project's packages with `npm ci` unless the lexicon is already
11
+ * installed, with `node_modules/.bin` put on PATH for the job's later
12
+ * steps. That one install carries the dialect's client too: Postgres is the
13
+ * lexicon's own `pg` dependency and ClickHouse is spoken over HTTP, so no
14
+ * separate client goes into the image. A token source needs no cloud
15
+ * command line in CI either: in a job that traded its OIDC token through
16
+ * chant's `ci.identity`, `./token-source.ts` mints from what the trade left.
17
+ *
18
+ * The replay server is the image the lexicon's emulator runs, at the same
19
+ * pin (`./spec/pin.ts`, `./spec/postgres-pin.ts`), so a replay in CI and
20
+ * `chant emulator up` on a laptop run one server. It is a throwaway: the
21
+ * replay resets it. The dialects are read from the text of the project's
22
+ * `chant.config.ts`, never by running it, as lexicon detection reads source.
23
+ */
24
+ import { existsSync, readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import type { CiService } from "@intentius/chant/components/ci-services";
27
+ import { clickhouseImage } from "./spec/pin";
28
+ import { POSTGRES_LATEST_MAJOR, postgresImage } from "./spec/postgres-pin";
29
+
30
+ export type SqlCiDialect = "clickhouse" | "postgres";
31
+
32
+ /** The profile the replay check runs on, as sql-yodeler names it. */
33
+ export const REPLAY_PROFILE = "replay";
34
+
35
+ /** The oldest Node the lexicon and chant run on. */
36
+ export const SQL_CI_NODE_MAJOR = 22;
37
+
38
+ /** Fails the job, naming the version, when the image's Node is older than {@link SQL_CI_NODE_MAJOR}. */
39
+ export const NODE_CHECK_LINE = `node -e 'const m = Number(process.versions.node.split(".")[0]); if (m < ${String(SQL_CI_NODE_MAJOR)}) { console.error("the sql lexicon needs Node ${String(SQL_CI_NODE_MAJOR)} or newer; this image has " + process.versions.node); process.exit(1); }'`;
40
+
41
+ /**
42
+ * The project's packages, unless the lexicon is already installed (an image
43
+ * that carries them pays nothing), and their bins on PATH: exported for the
44
+ * rest of a GitLab script, appended to `$GITHUB_PATH` for a GitHub or
45
+ * Forgejo job's later steps.
46
+ */
47
+ export const PACKAGES_INSTALL_LINE =
48
+ '{ [ -d node_modules/@intentius/chant-lexicon-sql ] || npm ci; } && export PATH="$PWD/node_modules/.bin:$PATH" && if [ -n "${GITHUB_PATH:-}" ]; then echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"; fi';
49
+
50
+ /** The replay server of each dialect: the emulator's pinned image, under a prefixed name. */
51
+ export function replayService(dialect: SqlCiDialect): CiService {
52
+ if (dialect === "clickhouse") {
53
+ return {
54
+ name: "replay-clickhouse",
55
+ image: clickhouseImage(),
56
+ port: 8123,
57
+ // The default user, with no password, from outside the container.
58
+ env: { CLICKHOUSE_SKIP_USER_SETUP: "1" },
59
+ health: "clickhouse-client --query 'SELECT 1'",
60
+ jobEnv: { REPLAY_CLICKHOUSE_URL: "http://replay-clickhouse:8123" },
61
+ };
62
+ }
63
+ return {
64
+ name: "replay-postgres",
65
+ image: postgresImage(POSTGRES_LATEST_MAJOR),
66
+ port: 5432,
67
+ env: { POSTGRES_PASSWORD: "replay" },
68
+ // Over TCP: during its first start the image runs a server on the socket only, then restarts.
69
+ health: "pg_isready -h 127.0.0.1 -U postgres",
70
+ jobEnv: { REPLAY_POSTGRES_URL: "postgres://replay-postgres:5432/postgres", REPLAY_POSTGRES_USER: "postgres", REPLAY_POSTGRES_PASSWORD: "replay" },
71
+ };
72
+ }
73
+
74
+ /** The text of the project's config, or undefined without one. */
75
+ function configText(dir: string): string | undefined {
76
+ for (const name of ["chant.config.ts", "chant.config.mts", "chant.config.js"]) {
77
+ const file = join(dir, name);
78
+ if (!existsSync(file)) continue;
79
+ try {
80
+ return readFileSync(file, "utf-8");
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * The dialects the config names (`dialect: "postgres"`, or a list), in
90
+ * {@link SqlCiDialect} order; ClickHouse, the lexicon's default, when it
91
+ * names none.
92
+ */
93
+ export function sqlDialectsOf(text: string): SqlCiDialect[] {
94
+ const m = /\bdialect\s*:\s*(\[[^\]]*\]|["'][a-z]+["'])/.exec(text);
95
+ if (!m) return ["clickhouse"];
96
+ const named = new Set([...m[1]!.matchAll(/["']([a-z]+)["']/g)].map((x) => x[1]));
97
+ const out = (["clickhouse", "postgres"] as const).filter((d) => named.has(d));
98
+ return out.length > 0 ? [...out] : ["clickhouse"];
99
+ }
100
+
101
+ /** Whether the config declares the `replay` profile, the one the replay check runs on. */
102
+ export function declaresReplay(text: string): boolean {
103
+ return new RegExp(`(^|[\\s{,])["']?${REPLAY_PROFILE}["']?\\s*:`).test(text);
104
+ }
105
+
106
+ /** The setup lines for the project at `dir`: none when it has no config naming the sql lexicon's settings. */
107
+ export function sqlCiSetup(ctx: { dir: string }): string[] {
108
+ return configText(ctx.dir) === undefined ? [] : [NODE_CHECK_LINE, PACKAGES_INSTALL_LINE];
109
+ }
110
+
111
+ /** The replay service per dialect for the project at `dir`, when its config declares the `replay` profile. */
112
+ export function sqlCiServices(ctx: { dir: string }): CiService[] {
113
+ const text = configText(ctx.dir);
114
+ if (text === undefined || !declaresReplay(text)) return [];
115
+ return sqlDialectsOf(text).map(replayService);
116
+ }
@@ -552,7 +552,8 @@ export function atomKey(a: GrantAtom): string {
552
552
  return `${a.revoke ? "REVOKE " : ""}${a.privilege}${a.column !== undefined ? `(${a.column})` : ""} ON ${a.target}${a.option ? " WITH GRANT OPTION" : ""}`;
553
553
  }
554
554
 
555
- function grantAtoms(ddl: string, defaultDatabase: string): Array<{ grantees: string[]; atoms: GrantAtom[] }> {
555
+ /** Each statement's grantees and atoms. A bare table is qualified with `defaultDatabase`, or left bare when it is undefined. */
556
+ function grantAtoms(ddl: string, defaultDatabase: string | undefined): Array<{ grantees: string[]; atoms: GrantAtom[] }> {
556
557
  const out: Array<{ grantees: string[]; atoms: GrantAtom[] }> = [];
557
558
  for (const statement of splitLines(ddl)) {
558
559
  const tokens = tokenizeText(statement, 0);
@@ -565,7 +566,7 @@ function grantAtoms(ddl: string, defaultDatabase: string): Array<{ grantees: str
565
566
  const parts = sigOf(tokens, node.target)
566
567
  .filter((t) => !(t.kind === "punct" && t.text === "."))
567
568
  .map((t) => unquote(t.text));
568
- target = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : parts[0] === "*" ? "*" : `${defaultDatabase}.${parts[0]}`;
569
+ target = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : parts[0] === "*" || defaultDatabase === undefined ? parts[0] : `${defaultDatabase}.${parts[0]}`;
569
570
  }
570
571
  for (const item of node.items) {
571
572
  const sig = sigOf(tokens, item);
@@ -646,6 +647,52 @@ export function grantsCanonical(ddl: string, grantee: string, defaultDatabase =
646
647
  );
647
648
  }
648
649
 
650
+ /**
651
+ * The atoms one grant declaration gives, one per grantee (#4582): `key` is
652
+ * the atom as the declaration spells its target (a bare table stays bare),
653
+ * which is how the deep diff addresses it on both sides; `atom` has the
654
+ * target qualified with `defaultDatabase`, which is what the server is asked
655
+ * about.
656
+ */
657
+ export function declaredGrantAtoms(ddl: string, defaultDatabase = "default"): Array<{ grantee: string; key: string; atom: GrantAtom }> {
658
+ const spelled = grantAtoms(ddl, undefined);
659
+ const resolved = grantAtoms(ddl, defaultDatabase);
660
+ const out: Array<{ grantee: string; key: string; atom: GrantAtom }> = [];
661
+ spelled.forEach((g, i) => {
662
+ for (const grantee of g.grantees) {
663
+ g.atoms.forEach((a, j) => out.push({ grantee, key: atomKey(a), atom: resolved[i]!.atoms[j]! }));
664
+ }
665
+ });
666
+ return out;
667
+ }
668
+
669
+ /**
670
+ * Whether a grantee holding `have` (its `SHOW GRANTS` atoms) holds `a`: the
671
+ * same atom, or one the server folds it under (the table's privilege for a
672
+ * column's, the database's for a table's, `*.*` for any), with a grant or
673
+ * admin option at least as wide, and no partial revoke the server holds
674
+ * taking part of it back.
675
+ */
676
+ export function holdsAtom(have: readonly GrantAtom[], a: GrantAtom): boolean {
677
+ if (a.role !== undefined) return have.some((b) => b.role === a.role && (b.option ?? false) >= (a.option ?? false));
678
+ const granted = have.some(
679
+ (b) =>
680
+ !b.revoke &&
681
+ b.role === undefined &&
682
+ b.privilege === a.privilege &&
683
+ (b.option ?? false) >= (a.option ?? false) &&
684
+ ((b.target === a.target && (b.column === undefined || b.column === a.column)) || (b.column === undefined && covers(b.target!, a.target!))),
685
+ );
686
+ if (!granted) return false;
687
+ return !have.some(
688
+ (r) =>
689
+ r.revoke === true &&
690
+ r.privilege === a.privilege &&
691
+ (r.target === a.target || (a.column === undefined && covers(a.target!, r.target!))) &&
692
+ (a.column === undefined || r.column === undefined || r.column === a.column),
693
+ );
694
+ }
695
+
649
696
  /** Every grantee a grant statement names. */
650
697
  export function granteesOf(ddl: string): string[] {
651
698
  return [...new Set(grantAtoms(ddl, "default").flatMap((g) => g.grantees))];
@@ -13,7 +13,8 @@
13
13
  * - a privilege granted by hand to a declared grantee is SQLCH274 and is
14
14
  * revoked, a declared one revoked by hand is SQLCH273 and is granted
15
15
  * again, a changed role setting is SQLCH270, a changed policy SQLCH272;
16
- * the deep read lists both as pending, with the statements (#3733);
16
+ * the deep read lists the hand grant as pending, with the statement
17
+ * (#3733), and the hand revoke as absent drift on its declaration (#4582);
17
18
  * - the row policy filters rows for the user;
18
19
  * - with a profile that does not manage access (no `access: true`, #3716),
19
20
  * none of it is applied, planned or read: each access declaration is
@@ -169,8 +170,10 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
169
170
  expect(diff.changes).toEqual([]);
170
171
  const entities = new Map(Object.entries(v1).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
171
172
  const deep = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: configOf(endpoint), env: envOf(endpoint) });
172
- expect(Object.keys(deep.unobserved ?? {}).sort()).toEqual(["readEvents", "readerToApp"]);
173
+ // Each grant declaration is read atom by atom (#4582), as the declaration spells it.
174
+ expect(deep.unobserved ?? {}).toEqual({});
173
175
  expect(deep.pending).toBeUndefined();
176
+ for (const k of ["readEvents", "readerToApp"] as const) expect(deep.resources[k]!.properties, k).toEqual(sqlDeepNormalizationHooks.prepare!(v1[k].props as unknown as Record<string, unknown>, { entityType: "ClickHouse::Grant", side: "declared" }));
174
177
  for (const k of ["reader", "app", "tenant"] as const) expect(compared(deep.resources[k]!.properties), k).toEqual(compared(v1[k].props));
175
178
 
176
179
  // The policy filters rows for the user.
@@ -181,12 +184,10 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
181
184
  // Changed by hand: a privilege granted, a declared one revoked. Changed in the declaration: a setting and the condition.
182
185
  await q(`GRANT INSERT ON ${DB}.events TO ${READER}`);
183
186
  await q(`REVOKE ${READER} FROM ${APP}`);
184
- // The deep read lists what an apply would grant and revoke (#3733), so an approval's plan digest moves.
187
+ // The deep read lists the hand grant as pending (#3733) and the hand revoke on its declaration (#4582), so an approval's plan digest moves.
185
188
  const handMade = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: configOf(endpoint), env: envOf(endpoint) });
186
- expect(handMade.pending).toEqual([
187
- { subject: `grants ${READER}`, change: `REVOKE INSERT ON \`${DB}\`.\`events\` FROM \`${READER}\``, entities: ["readEvents"] },
188
- { subject: `grants ${APP}`, change: `GRANT \`${READER}\` TO \`${APP}\``, entities: ["readerToApp"] },
189
- ]);
189
+ expect(handMade.pending).toEqual([{ subject: `grants ${READER}`, change: `REVOKE INSERT ON \`${DB}\`.\`events\` FROM \`${READER}\``, entities: ["readEvents"] }]);
190
+ expect(handMade.resources.readerToApp!.properties).toEqual({});
190
191
  const v2 = declarations({ memory: "2000000000", tenant: "b" });
191
192
  const planned = await planAgainstServer("test", writeBuild("v2.json", v2), { config: configOf(endpoint), env: envOf(endpoint) });
192
193
  expect(planned.changes.map((c) => [c.object, c.field, c.rule])).toEqual([
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * `describeResources()` for the ClickHouse dialect: which declared databases,
3
- * tables and views exist on the environment's server.
3
+ * tables and views exist on the environment's server. A grant declaration is
4
+ * present when the server has every grantee it names, and missing when it
5
+ * lacks one (#4582); which privileges the grantee holds is the deep diff's.
4
6
  *
5
7
  * One read of `system.databases`, `system.tables` and `system.functions` per
6
8
  * run, then a lookup per declared entity, so N entities are three queries,
@@ -26,6 +28,7 @@ import {
26
28
  import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "./bind";
27
29
  import { ACCESS_ENTITY_TYPES, ACCESS_UNMANAGED_DETAIL, accessOf, readLiveAccess, readLiveSchema, type DeclaredAccess, type LiveObject } from "./catalog";
28
30
  import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
31
+ import { granteesOf } from "../access";
29
32
  import { objectKey } from "../plan/normalize";
30
33
  import { isChantManaged, readMarker, stripMarker } from "../ownership";
31
34
 
@@ -71,7 +74,18 @@ function adapter(options: BindOptions & { owned?: boolean; access?: (defaultData
71
74
  return { unobserved: { reason: "filtered", detail: ACCESS_UNMANAGED_DETAIL } };
72
75
  }
73
76
  if (entity.type === CLICKHOUSE_ENTITY_TYPES.grant) {
74
- return { unobserved: { reason: "unsupported-kind", detail: "a grant is compared as part of its grantees' grants, which chant sql plan reports" } };
77
+ let grantees: string[];
78
+ try {
79
+ grantees = granteesOf(String(entity.props.ddl ?? ""));
80
+ } catch (err) {
81
+ return { unobserved: { reason: "read-failed", detail: `the grant does not parse: ${(err as Error).message}` } };
82
+ }
83
+ const gone = grantees.find((g) => !byKey.has(objectKey({ type: entity.type, name: g })));
84
+ const queried = `${target.endpoint.url} grants ${gone ?? grantees.join(", ")}`;
85
+ if (gone !== undefined || grantees.length === 0) return { absent: true, queried };
86
+ // A grant carries no comment, so no marker: like a user or a role, it is never chant's by marker.
87
+ if (options.owned) return { unobserved: { reason: "filtered", detail: "a grant carries no chant ownership marker and owned was requested" }, queried };
88
+ return { present: { type: entity.type, physicalId: `grants ${grantees.join(", ")}`, status: "", ownership: "foreign" }, queried };
75
89
  }
76
90
  const { database, name } = declaredAddress(entity, target.defaultDatabase);
77
91
  const queried = `${target.endpoint.url} ${database ? `${database}.` : ""}${name}`;
@@ -112,5 +126,15 @@ export async function describeResources(
112
126
  const entity = options.entities.get(name);
113
127
  return { name, type: entity?.entityType ?? "", props: entity?.props ?? {} };
114
128
  });
115
- return observeEntities(declared, adapter({ ...options, access: (db) => accessOf(options.entities, db) }));
129
+ const grantees = [...options.entities.values()]
130
+ .filter((e) => e.entityType === CLICKHOUSE_ENTITY_TYPES.grant)
131
+ .flatMap((e) => {
132
+ try {
133
+ return granteesOf(String(e.props.ddl ?? ""));
134
+ } catch {
135
+ return [];
136
+ }
137
+ });
138
+ const access = (db: string): DeclaredAccess[] => [...accessOf(options.entities, db), ...[...new Set(grantees)].map((name) => ({ kind: "grants", name }))];
139
+ return observeEntities(declared, adapter({ ...options, access }));
116
140
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * ClickHouse grants in `chant lifecycle diff --live` (#4582): each grant
3
+ * declaration is read atom by atom, so a privilege revoked by hand is
4
+ * `absent` drift on the declaration that gives it, a privilege granted by
5
+ * hand is pending on its grantee, and a grant whose grantee the server does
6
+ * not have is missing in the thin diff. The server is a stub answering the
7
+ * queries `readLiveAccess` sends with recorded `SHOW GRANTS FOR` lines; no
8
+ * ClickHouse runs.
9
+ */
10
+
11
+ import { beforeEach, describe, expect, test, vi } from "vitest";
12
+ import { diffDeepObservation } from "@intentius/chant/lifecycle/deep-observe";
13
+ import { normalizeObservation } from "@intentius/chant/observation";
14
+ import { normalizeDeepObservation } from "@intentius/chant/deep-observation";
15
+ import { grant } from "../entities";
16
+ import type { ClickHouseTarget } from "../live/bind";
17
+
18
+ /** What `SHOW GRANTS FOR` prints, by grantee; a grantee missing here does not exist. */
19
+ let held: Record<string, string[]> = {};
20
+
21
+ const target: ClickHouseTarget = { endpoint: { url: "http://stub:8123" }, source: "sql.profiles.test", defaultDatabase: "shop", access: true };
22
+
23
+ vi.mock("../http", async (importOriginal) => {
24
+ const actual = await importOriginal<typeof import("../http")>();
25
+ return {
26
+ ...actual,
27
+ clickhouseQuery: async (_endpoint: unknown, sql: string) => {
28
+ if (/FROM system\.users/.test(sql)) return Object.keys(held).filter((n) => sql.includes(`'${n}'`)).map((name) => ({ name }));
29
+ if (/FROM system\.roles/.test(sql)) return [];
30
+ const show = /^SHOW GRANTS FOR `(.+)`$/.exec(sql.trim());
31
+ if (show) return (held[show[1]!] ?? []).map((line) => ({ [`GRANTS FOR ${show[1]}`]: line }));
32
+ throw new Error(`unexpected query: ${sql}`);
33
+ },
34
+ };
35
+ });
36
+
37
+ vi.mock("../live/bind", async (importOriginal) => {
38
+ const actual = await importOriginal<typeof import("../live/bind")>();
39
+ return { ...actual, bindClickHouse: async () => target };
40
+ });
41
+
42
+ vi.mock("../live/catalog", async (importOriginal) => {
43
+ const actual = await importOriginal<typeof import("../live/catalog")>();
44
+ // No tables, views or databases: these tests declare grants only.
45
+ return { ...actual, readLiveSchema: async () => [] };
46
+ });
47
+
48
+ const { observeResourcesDeep, sqlDeepNormalizationHooks } = await import("./deep");
49
+ const { describeResources } = await import("../live/describe-resources");
50
+
51
+ type Entities = Map<string, { entityType: string; props: Record<string, unknown> }>;
52
+
53
+ const entities = (all: Record<string, ReturnType<typeof grant>>): Entities =>
54
+ new Map(Object.entries(all).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
55
+
56
+ const declared = entities({
57
+ readEvents: grant`GRANT SELECT ON shop.events TO app`,
58
+ // A bare table is the profile's default database.
59
+ readOrders: grant`GRANT SELECT ON orders TO app`,
60
+ });
61
+
62
+ async function liveDiff(all: Entities) {
63
+ const live = await observeResourcesDeep({ environment: "test", entityNames: [...all.keys()], entities: all });
64
+ return diffDeepObservation(all, normalizeDeepObservation(live), sqlDeepNormalizationHooks);
65
+ }
66
+
67
+ beforeEach(() => {
68
+ held = {};
69
+ });
70
+
71
+ describe("grants in the live diff (#4582)", () => {
72
+ test("a grant declaration's declared tree is its atoms by grantee, as it spells them", () => {
73
+ const props = declared.get("readOrders")!.props;
74
+ expect(sqlDeepNormalizationHooks.prepare!(props, { entityType: "ClickHouse::Grant", side: "declared" })).toEqual({ grants: { app: { "SELECT ON orders": "granted" } } });
75
+ });
76
+
77
+ test("a grantee holding every declared grant is unchanged, and no grant is unobserved", async () => {
78
+ held = { app: ["GRANT SELECT ON shop.events TO app", "GRANT SELECT ON shop.orders TO app"] };
79
+ const diff = await liveDiff(declared);
80
+ expect(diff.drifted).toEqual([]);
81
+ expect(diff.unobserved).toEqual([]);
82
+ expect(diff.unchanged).toEqual(["readEvents", "readOrders"]);
83
+ expect(diff.pending).toBeUndefined();
84
+ });
85
+
86
+ test("a privilege revoked by hand is absent drift on the declaration that gives it (SQLCH273)", async () => {
87
+ held = { app: ["GRANT SELECT ON shop.events TO app"] };
88
+ const diff = await liveDiff(declared);
89
+ expect(diff.drifted).toEqual([{ name: "readOrders", type: "ClickHouse::Grant", changes: [{ path: "grants.app.SELECT ON orders", kind: "absent", declared: "granted" }] }]);
90
+ expect(diff.unchanged).toEqual(["readEvents"]);
91
+ expect(diff.pending).toBeUndefined();
92
+ });
93
+
94
+ test("a privilege granted by hand is pending on its grantee, the revoke an apply would send (SQLCH274)", async () => {
95
+ held = { app: ["GRANT INSERT, SELECT ON shop.events TO app", "GRANT SELECT ON shop.orders TO app"] };
96
+ const diff = await liveDiff(declared);
97
+ expect(diff.drifted).toEqual([]);
98
+ expect(diff.pending).toEqual([{ subject: "grants app", change: "REVOKE INSERT ON `shop`.`events` FROM `app`", entities: ["readEvents", "readOrders"] }]);
99
+ });
100
+
101
+ test("a privilege the server folds under a wider one is held", async () => {
102
+ held = { app: ["GRANT SELECT ON shop.* TO app"] };
103
+ const all = entities({ readShop: grant`GRANT SELECT ON shop.* TO app`, readEvents: grant`GRANT SELECT ON shop.events TO app` });
104
+ const diff = await liveDiff(all);
105
+ expect(diff.drifted).toEqual([]);
106
+ expect(diff.unchanged).toEqual(["readEvents", "readShop"]);
107
+ });
108
+
109
+ test("a partial revoke held under a declared privilege is drift", async () => {
110
+ held = { app: ["GRANT SELECT ON shop.events TO app", "REVOKE SELECT(email) ON shop.events FROM app", "GRANT SELECT ON shop.orders TO app"] };
111
+ const diff = await liveDiff(declared);
112
+ expect(diff.drifted.map((d) => [d.name, d.changes.map((c) => `${c.path} ${c.kind}`)])).toEqual([["readEvents", ["grants.app.SELECT ON shop.events absent"]]]);
113
+ });
114
+
115
+ test("a role revoked by hand is absent drift", async () => {
116
+ held = { app: ["GRANT reader TO app"] };
117
+ const all = entities({ appReads: grant`GRANT reader, writer TO app` });
118
+ const diff = await liveDiff(all);
119
+ expect(diff.drifted).toEqual([{ name: "appReads", type: "ClickHouse::Grant", changes: [{ path: "grants.app.ROLE writer", kind: "absent", declared: "granted" }] }]);
120
+ });
121
+
122
+ test("a grant whose grantee the server does not have is missing in the thin diff and not restated in the deep one", async () => {
123
+ held = {};
124
+ const diff = await liveDiff(declared);
125
+ expect(diff.drifted).toEqual([]);
126
+ expect(diff.unobserved).toEqual([]);
127
+ const thin = normalizeObservation(await describeResources({ environment: "test", entityNames: [...declared.keys()], entities: declared }));
128
+ expect(Object.keys(thin.resources)).toEqual([]);
129
+ expect(Object.keys(thin.unobserved)).toEqual([]);
130
+ });
131
+
132
+ test("a grant whose grantees the server has is present in the thin diff", async () => {
133
+ held = { app: ["GRANT SELECT ON shop.events TO app"] };
134
+ const thin = normalizeObservation(await describeResources({ environment: "test", entityNames: [...declared.keys()], entities: declared }));
135
+ expect(Object.keys(thin.resources).sort()).toEqual(["readEvents", "readOrders"]);
136
+ expect(thin.resources.readEvents).toMatchObject({ type: "ClickHouse::Grant", physicalId: "grants app" });
137
+ });
138
+
139
+ test("a profile that does not manage access leaves grants filtered", async () => {
140
+ target.access = false;
141
+ try {
142
+ const diff = await liveDiff(declared);
143
+ expect(diff.unobserved.map((u) => [u.name, u.reason])).toEqual([
144
+ ["readEvents", "filtered"],
145
+ ["readOrders", "filtered"],
146
+ ]);
147
+ } finally {
148
+ target.access = true;
149
+ }
150
+ });
151
+ });
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The grants the deep read reports as pending (#3733): what an apply would
3
- * grant or revoke from each declared grantee, so a privilege granted by hand
4
- * after an approval moves the plan digest. The server is a stub answering the
5
- * queries `readLiveAccess` sends.
3
+ * send a grantee holding a privilege no declaration gives it, so a privilege
4
+ * granted by hand after an approval moves the plan digest. The server is a
5
+ * stub answering the queries `readLiveAccess` sends.
6
6
  */
7
7
 
8
8
  import { describe, expect, test, vi } from "vitest";
@@ -55,9 +55,14 @@ describe("pendingGrants (#3733)", () => {
55
55
  ]);
56
56
  });
57
57
 
58
- test("a grantee the server does not hold yet is given every declared grant", async () => {
58
+ test("a grantee that only lacks declared grants has nothing pending: each declaration reports them (#4582)", async () => {
59
+ held = { app: ["GRANT SELECT ON shop.events TO app"] };
60
+ expect(await pendingGrants(target, declared)).toEqual([]);
61
+ });
62
+
63
+ test("a grantee the server does not have has nothing pending: the thin diff reports it missing (#4582)", async () => {
59
64
  held = {};
60
- expect((await pendingGrants(target, declared)).map((p) => p.change)).toEqual(["GRANT SELECT ON `shop`.`events` TO `app`; GRANT SELECT ON `shop`.`orders` TO `app`"]);
65
+ expect(await pendingGrants(target, declared)).toEqual([]);
61
66
  });
62
67
 
63
68
  test("no grant declarations read nothing", async () => {