@intentius/chant-lexicon-sql 0.124.52 → 0.124.55
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/capability-plugin.d.ts +160 -0
- package/dist/capability-plugin.d.ts.map +1 -0
- package/dist/capability-plugin.js +370 -0
- package/dist/change-set.d.ts +35 -0
- package/dist/change-set.d.ts.map +1 -1
- package/dist/change-set.js +43 -0
- package/dist/ci-setup.d.ts +34 -0
- package/dist/ci-setup.d.ts.map +1 -0
- package/dist/ci-setup.js +106 -0
- package/dist/clickhouse/access.d.ts +20 -0
- package/dist/clickhouse/access.d.ts.map +1 -1
- package/dist/clickhouse/access.js +42 -1
- package/dist/clickhouse/live/describe-resources.d.ts +3 -1
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.js +31 -3
- package/dist/clickhouse/plan/deep.d.ts +42 -11
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.js +104 -33
- package/dist/exit-codes.d.ts +69 -0
- package/dist/exit-codes.d.ts.map +1 -0
- package/dist/exit-codes.js +105 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/integrity.json +2 -2
- package/dist/lock-grain.d.ts +27 -0
- package/dist/lock-grain.d.ts.map +1 -0
- package/dist/lock-grain.js +13 -0
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +8 -0
- package/dist/postgres/testing/migration.d.ts +2 -0
- package/dist/postgres/testing/migration.d.ts.map +1 -1
- package/dist/token-source.d.ts +28 -0
- package/dist/token-source.d.ts.map +1 -1
- package/dist/token-source.js +70 -1
- package/dist/yodel-run.d.ts +58 -0
- package/dist/yodel-run.d.ts.map +1 -0
- package/dist/yodel-run.js +145 -0
- package/package.json +2 -2
- package/src/capability-plugin.test.ts +317 -0
- package/src/capability-plugin.ts +515 -0
- package/src/change-set.ts +74 -0
- package/src/ci-setup.test.ts +67 -0
- package/src/ci-setup.ts +116 -0
- package/src/clickhouse/access.ts +49 -2
- package/src/clickhouse/live/access.e2e.test.ts +8 -7
- package/src/clickhouse/live/describe-resources.ts +27 -3
- package/src/clickhouse/plan/deep-grants-drift.test.ts +151 -0
- package/src/clickhouse/plan/deep-grants.test.ts +10 -5
- package/src/clickhouse/plan/deep.ts +103 -32
- package/src/exit-codes.test.ts +46 -0
- package/src/exit-codes.ts +140 -0
- package/src/index.ts +28 -0
- package/src/lock-grain.test.ts +57 -0
- package/src/lock-grain.ts +37 -0
- package/src/plugin.ts +9 -0
- package/src/testdata/fake-yodel.mjs +58 -0
- package/src/token-source-traded.test.ts +75 -0
- package/src/token-source.ts +74 -1
- package/src/yodel-run.ts +175 -0
package/src/ci-setup.ts
ADDED
|
@@ -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
|
+
}
|
package/src/clickhouse/access.ts
CHANGED
|
@@ -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
|
-
|
|
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] === "*" ?
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
4
|
-
* after an approval moves the plan digest. The server is a
|
|
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
|
|
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(
|
|
65
|
+
expect(await pendingGrants(target, declared)).toEqual([]);
|
|
61
66
|
});
|
|
62
67
|
|
|
63
68
|
test("no grant declarations read nothing", async () => {
|