@intentius/chant-lexicon-sql 0.118.1 → 0.120.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clickhouse/apply/apply.d.ts +1 -1
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +8 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +1 -1
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/live-export.d.ts +19 -0
- package/dist/clickhouse/import/live-export.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts +8 -0
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +15 -0
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts +19 -2
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +3 -0
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/rebuild-handoff.d.ts +2 -0
- package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/engines.d.ts +44 -0
- package/dist/clickhouse/rebuild/engines.d.ts.map +1 -0
- package/dist/clickhouse/rebuild/verify.d.ts +25 -1
- package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts +5 -0
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/config.d.ts +32 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/core/foreign-tables.d.ts +13 -0
- package/dist/core/foreign-tables.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +7 -3
- package/dist/files/clickhouse.d.ts.map +1 -1
- package/dist/files/postgres.d.ts +3 -0
- package/dist/files/postgres.d.ts.map +1 -1
- package/dist/import-parser.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/clickhouse-emulator.d.ts +7 -1
- package/dist/op/activities/clickhouse-emulator.d.ts.map +1 -1
- package/dist/op/activities/postgres-emulator.d.ts +8 -1
- package/dist/op/activities/postgres-emulator.d.ts.map +1 -1
- package/dist/postgres/access/changes.d.ts +23 -0
- package/dist/postgres/access/changes.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +8 -1
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +3 -3
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +31 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/sql-files.d.ts +2 -1
- package/dist/sql-files.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +8 -2
- package/src/clickhouse/apply/statements.ts +32 -5
- package/src/clickhouse/import/generator.ts +8 -3
- package/src/clickhouse/import/import.test.ts +64 -0
- package/src/clickhouse/import/live-export.ts +70 -2
- package/src/clickhouse/live/access.e2e.test.ts +71 -18
- package/src/clickhouse/live/bind.ts +10 -0
- package/src/clickhouse/live/catalog.ts +25 -2
- package/src/clickhouse/live/describe-resources.ts +5 -2
- package/src/clickhouse/live/live.test.ts +8 -0
- package/src/clickhouse/plan/commands.ts +6 -3
- package/src/clickhouse/plan/deep-grants.test.ts +67 -0
- package/src/clickhouse/plan/deep.ts +70 -6
- package/src/clickhouse/plan/diff.ts +4 -1
- package/src/clickhouse/plan/normalize.ts +5 -2
- package/src/clickhouse/plan/plan.test.ts +9 -1
- package/src/clickhouse/plan/rebuild-handoff.ts +11 -1
- package/src/clickhouse/rebuild/engines.ts +163 -0
- package/src/clickhouse/rebuild/rebuild-collapsing.e2e.test.ts +120 -0
- package/src/clickhouse/rebuild/rebuild-into-collapsing.e2e.test.ts +198 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +78 -0
- package/src/clickhouse/rebuild/verify.ts +153 -24
- package/src/clickhouse/testing/fake-server.ts +3 -2
- package/src/codegen/classifier-page.ts +1 -1
- package/src/config.ts +16 -4
- package/src/core/foreign-tables.ts +28 -0
- package/src/files/clickhouse.ts +71 -5
- package/src/files/postgres.ts +2 -1
- package/src/import-parser.ts +2 -0
- package/src/migration-statements.test.ts +33 -0
- package/src/migration-statements.ts +2 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +1 -1
- package/src/op/activities/clickhouse-emulator.ts +22 -1
- package/src/op/activities/postgres-apply-lock-timeout.e2e.test.ts +87 -0
- package/src/op/activities/postgres-apply.test.ts +32 -0
- package/src/op/activities/postgres-emulator.test.ts +20 -0
- package/src/op/activities/postgres-emulator.ts +41 -1
- package/src/op/apply-gate-access-clickhouse.e2e.test.ts +195 -0
- package/src/op/apply-gate-access.e2e.test.ts +207 -0
- package/src/postgres/access/access.test.ts +26 -1
- package/src/postgres/access/changes.ts +39 -0
- package/src/postgres/apply/apply.ts +10 -7
- package/src/postgres/entities.ts +20 -1
- package/src/postgres/live/bind.ts +16 -4
- package/src/postgres/live/catalog.ts +3 -15
- package/src/postgres/live/client.ts +85 -0
- package/src/postgres/live/live.test.ts +54 -0
- package/src/postgres/live/lock-timeout.e2e.test.ts +70 -0
- package/src/postgres/plan/commands.ts +2 -14
- package/src/postgres/plan/deep.ts +44 -2
- package/src/serializer.test.ts +4 -0
- package/src/skills/chant-sql-postgres-plan.md +1 -1
- package/src/skills/chant-sql-rebuild.md +2 -0
- package/src/skills/chant-sql.md +1 -1
- package/src/sql-files.test.ts +78 -1
- package/src/sql-files.ts +2 -1
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* - `owned` keeps the objects whose comment carries chant's ownership marker
|
|
14
14
|
* (`../ownership.ts`), the trailer the applier stamps. The trailer itself is
|
|
15
15
|
* never written into a declaration: `readLiveSchema` takes it off.
|
|
16
|
+
* - a migration runner's history table (`schema_migrations`,
|
|
17
|
+
* `goose_db_version`) is left out with a warning (#3676), as on Postgres.
|
|
18
|
+
* - A SQL user-defined function belongs to no database, and every project's
|
|
19
|
+
* functions sit side by side on a server (#3718). Without a selector, an
|
|
20
|
+
* import adopts only the functions the imported objects call (and those
|
|
21
|
+
* functions call), and the ones `sql.profiles.<env>.importFunctions` names;
|
|
22
|
+
* a warning names the rest. A function carries no comment, so `owned` does
|
|
23
|
+
* not apply to it.
|
|
16
24
|
*/
|
|
17
25
|
|
|
18
26
|
import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
|
|
@@ -20,6 +28,48 @@ import { bindClickHouse, type BindOptions } from "../live/bind";
|
|
|
20
28
|
import { readLiveSchema } from "../live/catalog";
|
|
21
29
|
import { objectsToIR, stripServerDefaults, type ImportedObject } from "./ir";
|
|
22
30
|
import { isChantManaged } from "../ownership";
|
|
31
|
+
import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
|
|
32
|
+
import { isTrivia, tokenizeText } from "../tokens";
|
|
33
|
+
import { unquote } from "../parser";
|
|
34
|
+
import type { LiveObject } from "../live/catalog";
|
|
35
|
+
|
|
36
|
+
/** The functions among `names` a statement calls: a name followed by `(`. */
|
|
37
|
+
export function calledFunctions(ddl: string, names: ReadonlySet<string>): string[] {
|
|
38
|
+
const tokens = tokenizeText(ddl, 0).filter((t) => !isTrivia(t));
|
|
39
|
+
const out = new Set<string>();
|
|
40
|
+
tokens.forEach((t, i) => {
|
|
41
|
+
if (t.kind !== "ident" && t.kind !== "qident") return;
|
|
42
|
+
const next = tokens[i + 1];
|
|
43
|
+
if (next?.kind !== "punct" || next.text !== "(") return;
|
|
44
|
+
const name = t.kind === "qident" ? unquote(t.text) : t.text;
|
|
45
|
+
if (names.has(name)) out.add(name);
|
|
46
|
+
});
|
|
47
|
+
return [...out];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether `patterns` (names, or prefixes ending in `*`) name a function. */
|
|
51
|
+
const named = (patterns: readonly string[], name: string) => patterns.some((p) => (p.endsWith("*") ? name.startsWith(p.slice(0, -1)) : p === name));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The functions an import adopts (#3718): those the objects call, those
|
|
55
|
+
* `patterns` name, and the functions those call in turn; and the rest.
|
|
56
|
+
*/
|
|
57
|
+
export function scopedFunctions(objects: readonly LiveObject[], functions: readonly LiveObject[], patterns: readonly string[] = []): { adopted: LiveObject[]; skipped: string[] } {
|
|
58
|
+
const byName = new Map(functions.map((f) => [f.name, f]));
|
|
59
|
+
const names = new Set(byName.keys());
|
|
60
|
+
const adopted = new Set<string>(functions.filter((f) => named(patterns, f.name)).map((f) => f.name));
|
|
61
|
+
for (const o of objects) for (const n of calledFunctions(o.statement, names)) adopted.add(n);
|
|
62
|
+
const queue = [...adopted];
|
|
63
|
+
while (queue.length > 0) {
|
|
64
|
+
for (const n of calledFunctions(byName.get(queue.pop()!)!.statement, names)) {
|
|
65
|
+
if (!adopted.has(n)) {
|
|
66
|
+
adopted.add(n);
|
|
67
|
+
queue.push(n);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { adopted: functions.filter((f) => adopted.has(f.name)), skipped: functions.filter((f) => !adopted.has(f.name)).map((f) => f.name) };
|
|
72
|
+
}
|
|
23
73
|
|
|
24
74
|
export interface ExportOptions extends Omit<BindOptions, "environment"> {
|
|
25
75
|
environment: string;
|
|
@@ -32,7 +82,10 @@ export interface ExportOptions extends Omit<BindOptions, "environment"> {
|
|
|
32
82
|
|
|
33
83
|
export async function exportResources(options: ExportOptions): Promise<ExportedTemplate> {
|
|
34
84
|
const target = await bindClickHouse(options);
|
|
35
|
-
const
|
|
85
|
+
const all = await readLiveSchema(target);
|
|
86
|
+
const isFunction = (o: LiveObject) => o.type === CLICKHOUSE_ENTITY_TYPES.function;
|
|
87
|
+
// A selector picks what it names, functions included; without one, functions go with what calls them (#3718).
|
|
88
|
+
const live = options.selector ? all : all.filter((o) => !isFunction(o));
|
|
36
89
|
const selected = live.filter((o) => {
|
|
37
90
|
if (options.owned && !isChantManaged(o.comment)) return false;
|
|
38
91
|
if (options.selector?.type && o.type !== options.selector.type) return false;
|
|
@@ -40,14 +93,29 @@ export async function exportResources(options: ExportOptions): Promise<ExportedT
|
|
|
40
93
|
if (name !== undefined && name !== o.name && name !== `${o.database}.${o.name}`) return false;
|
|
41
94
|
return true;
|
|
42
95
|
});
|
|
96
|
+
const warnings: string[] = [];
|
|
97
|
+
if (!options.selector) {
|
|
98
|
+
const { adopted, skipped } = scopedFunctions(selected.filter((o) => !o.foreign), all.filter(isFunction), target.importFunctions);
|
|
99
|
+
selected.push(...adopted);
|
|
100
|
+
if (skipped.length > 0) {
|
|
101
|
+
warnings.push(
|
|
102
|
+
`${skipped.length} SQL function${skipped.length === 1 ? "" : "s"} on the server ${skipped.length === 1 ? "is" : "are"} not imported, since no imported object calls ${skipped.length === 1 ? "it" : "them"}: ${skipped.join(", ")}. Name ${skipped.length === 1 ? "it" : "them"} in sql.profiles.${options.environment}.importFunctions to import ${skipped.length === 1 ? "it" : "them"}.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
43
106
|
const objects: ImportedObject[] = selected
|
|
44
107
|
// The `default` database exists on every server; declaring it would make apply create what is there.
|
|
45
108
|
.filter((o) => !(o.type === "ClickHouse::Database" && o.name === "default"))
|
|
109
|
+
.filter((o) => {
|
|
110
|
+
if (!o.foreign) return true;
|
|
111
|
+
warnings.push(`${o.database ? `${o.database}.` : ""}${o.name} is kept by ${o.foreign}; left out, since declaring it would have chant change what that tool owns`);
|
|
112
|
+
return false;
|
|
113
|
+
})
|
|
46
114
|
.map((o) => ({
|
|
47
115
|
type: o.type,
|
|
48
116
|
...(o.database ? { database: o.database } : {}),
|
|
49
117
|
name: o.name,
|
|
50
118
|
ddl: options.verbatim ? o.statement : stripServerDefaults(o.statement),
|
|
51
119
|
}));
|
|
52
|
-
return objectsToIR(objects) as ExportedTemplate;
|
|
120
|
+
return objectsToIR(objects, warnings) as ExportedTemplate;
|
|
53
121
|
}
|
|
@@ -13,7 +13,11 @@
|
|
|
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
|
-
*
|
|
16
|
+
* the deep read lists both as pending, with the statements (#3733);
|
|
17
|
+
* - the row policy filters rows for the user;
|
|
18
|
+
* - with a profile that does not manage access (no `access: true`, #3716),
|
|
19
|
+
* none of it is applied, planned or read: each access declaration is
|
|
20
|
+
* reported filtered, and a hand change to a grant is not undone.
|
|
17
21
|
*
|
|
18
22
|
* Beside a Replicated database (two replicas of the pinned server in
|
|
19
23
|
* Docker): access control is in no database, so the database's log does not
|
|
@@ -23,7 +27,7 @@
|
|
|
23
27
|
* `CLICKHOUSE_PASSWORD`) names a running one, such as the one
|
|
24
28
|
* `chant emulator up --lexicon sql` starts. Without it, a throwaway server
|
|
25
29
|
* at the pin, skipped cleanly when Docker is not available. Users and roles
|
|
26
|
-
* are global to the server, so the test names everything `
|
|
30
|
+
* are global to the server, so the test names everything `chant_e2e_3716_access*`
|
|
27
31
|
* and drops it afterwards.
|
|
28
32
|
*/
|
|
29
33
|
|
|
@@ -37,9 +41,10 @@ import { clickhouseImage } from "../../spec/pin";
|
|
|
37
41
|
import { database, grant, policy, role, table, user } from "../entities";
|
|
38
42
|
import { planAgainstServer } from "../plan/commands";
|
|
39
43
|
import { observeResourcesDeep, sqlDeepNormalizationHooks } from "../plan/deep";
|
|
44
|
+
import { describeResources } from "./describe-resources";
|
|
40
45
|
import { clickhouseApply } from "../../op/activities/clickhouse-apply";
|
|
41
46
|
|
|
42
|
-
const DB = "
|
|
47
|
+
const DB = "chant_e2e_3716_access";
|
|
43
48
|
const READER = `${DB}_reader`;
|
|
44
49
|
const APP = `${DB}_app`;
|
|
45
50
|
const fromEnv = process.env.CLICKHOUSE_URL;
|
|
@@ -76,13 +81,27 @@ afterAll(async () => {
|
|
|
76
81
|
rmSync(dir, { recursive: true, force: true });
|
|
77
82
|
});
|
|
78
83
|
|
|
79
|
-
const envOf = (at: ClickHouseEndpoint
|
|
80
|
-
|
|
81
|
-
...(at.
|
|
82
|
-
...(at.password ? { CLICKHOUSE_PASSWORD: at.password } : {}),
|
|
83
|
-
...(topology ? { CLICKHOUSE_TOPOLOGY: topology } : {}),
|
|
84
|
+
const envOf = (at: ClickHouseEndpoint) => ({
|
|
85
|
+
...(at.user ? { CH_USER: at.user } : {}),
|
|
86
|
+
...(at.password ? { CH_PASSWORD: at.password } : {}),
|
|
84
87
|
});
|
|
85
88
|
|
|
89
|
+
/** The `test` profile: the server, and whether it manages access (#3716). */
|
|
90
|
+
const configOf = (at: ClickHouseEndpoint, opts: { topology?: string; access?: boolean } = {}) => ({
|
|
91
|
+
ownership: { stack: "e2e3682", env: "test" },
|
|
92
|
+
sql: {
|
|
93
|
+
profiles: {
|
|
94
|
+
test: {
|
|
95
|
+
url: at.url,
|
|
96
|
+
...(at.user ? { user: { env: "CH_USER" } } : {}),
|
|
97
|
+
...(at.password ? { password: { env: "CH_PASSWORD" } } : {}),
|
|
98
|
+
...(opts.topology ? { topology: opts.topology } : {}),
|
|
99
|
+
...(opts.access !== false ? { access: true } : {}),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}) as never;
|
|
104
|
+
|
|
86
105
|
type Entity = { entityType: string; props: { ddl: string }; dependsOn?: unknown };
|
|
87
106
|
function writeBuild(file: string, declared: Record<string, Entity>): string {
|
|
88
107
|
const path = join(dir, file);
|
|
@@ -91,11 +110,11 @@ function writeBuild(file: string, declared: Record<string, Entity>): string {
|
|
|
91
110
|
return path;
|
|
92
111
|
}
|
|
93
112
|
|
|
94
|
-
async function apply(at: ClickHouseEndpoint, buildPath: string, topology?: string) {
|
|
113
|
+
async function apply(at: ClickHouseEndpoint, buildPath: string, opts: { topology?: string; access?: boolean } = {}) {
|
|
95
114
|
const sent: string[] = [];
|
|
96
115
|
const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, {
|
|
97
|
-
config:
|
|
98
|
-
env: envOf(at
|
|
116
|
+
config: configOf(at, opts),
|
|
117
|
+
env: envOf(at),
|
|
99
118
|
log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
|
|
100
119
|
});
|
|
101
120
|
return { outcome, sent };
|
|
@@ -146,11 +165,12 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
|
|
|
146
165
|
expect((await q<{ s: string }>(`SELECT auth_type[1] AS s FROM system.users WHERE name = '${APP}'`))[0]!.s).toBe("sha256_password");
|
|
147
166
|
|
|
148
167
|
// The server prints the policy's condition and the grants its own way: still the declarations.
|
|
149
|
-
const diff = await planAgainstServer("test", build, { config:
|
|
168
|
+
const diff = await planAgainstServer("test", build, { config: configOf(endpoint), env: envOf(endpoint) });
|
|
150
169
|
expect(diff.changes).toEqual([]);
|
|
151
170
|
const entities = new Map(Object.entries(v1).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
|
|
152
|
-
const deep = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config:
|
|
171
|
+
const deep = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: configOf(endpoint), env: envOf(endpoint) });
|
|
153
172
|
expect(Object.keys(deep.unobserved ?? {}).sort()).toEqual(["readEvents", "readerToApp"]);
|
|
173
|
+
expect(deep.pending).toBeUndefined();
|
|
154
174
|
for (const k of ["reader", "app", "tenant"] as const) expect(compared(deep.resources[k]!.properties), k).toEqual(compared(v1[k].props));
|
|
155
175
|
|
|
156
176
|
// The policy filters rows for the user.
|
|
@@ -161,8 +181,14 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
|
|
|
161
181
|
// Changed by hand: a privilege granted, a declared one revoked. Changed in the declaration: a setting and the condition.
|
|
162
182
|
await q(`GRANT INSERT ON ${DB}.events TO ${READER}`);
|
|
163
183
|
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.
|
|
185
|
+
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
|
+
]);
|
|
164
190
|
const v2 = declarations({ memory: "2000000000", tenant: "b" });
|
|
165
|
-
const planned = await planAgainstServer("test", writeBuild("v2.json", v2), { config:
|
|
191
|
+
const planned = await planAgainstServer("test", writeBuild("v2.json", v2), { config: configOf(endpoint), env: envOf(endpoint) });
|
|
166
192
|
expect(planned.changes.map((c) => [c.object, c.field, c.rule])).toEqual([
|
|
167
193
|
[`reader (role ${READER})`, "settings", "SQLCH270"],
|
|
168
194
|
// Revoking the role took it off the user's default roles too.
|
|
@@ -180,8 +206,35 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
|
|
|
180
206
|
`GRANT \`${READER}\` TO \`${APP}\``,
|
|
181
207
|
`ALTER USER \`${APP}\` DEFAULT ROLE ${READER}`,
|
|
182
208
|
]);
|
|
183
|
-
expect((await planAgainstServer("test", writeBuild("v2.json", v2), { config:
|
|
209
|
+
expect((await planAgainstServer("test", writeBuild("v2.json", v2), { config: configOf(endpoint), env: envOf(endpoint) })).changes).toEqual([]);
|
|
184
210
|
expect(Number((await q<{ n: string }>(`SELECT count() AS n FROM ${DB}.events`, asApp))[0]!.n)).toBe(1);
|
|
211
|
+
|
|
212
|
+
// A profile that does not manage access (#3716): nothing access-related is read, planned or applied.
|
|
213
|
+
await q(`GRANT INSERT ON ${DB}.events TO ${READER}`);
|
|
214
|
+
const off = { access: false };
|
|
215
|
+
const v3 = declarations({ memory: "3000000000", tenant: "a" });
|
|
216
|
+
const offBuild = writeBuild("v3.json", v3);
|
|
217
|
+
const offPlan = await planAgainstServer("test", offBuild, { config: configOf(endpoint, off), env: envOf(endpoint) });
|
|
218
|
+
expect(offPlan.changes).toEqual([]);
|
|
219
|
+
expect(offPlan.hints).toContain("5 access declarations are not planned: test's profile does not manage access (sql.profiles.<env>.access)");
|
|
220
|
+
const offApply = await apply(endpoint, offBuild, off);
|
|
221
|
+
expect(offApply.outcome.failed).toEqual([]);
|
|
222
|
+
expect(offApply.sent).toEqual([]);
|
|
223
|
+
expect(offApply.outcome.notAttempted.map((n) => [n.name, n.reason])).toEqual([
|
|
224
|
+
[`role ${READER}`, "filtered"],
|
|
225
|
+
[`user ${APP}`, "filtered"],
|
|
226
|
+
[`row policy ${DB}_tenant ON ${DB}.events`, "filtered"],
|
|
227
|
+
[`grants ${READER}`, "filtered"],
|
|
228
|
+
[`grants ${APP}`, "filtered"],
|
|
229
|
+
]);
|
|
230
|
+
expect((await q<{ s: string }>(`SHOW GRANTS FOR ${READER}`)).map((r) => Object.values(r)[0]).join("\n")).toMatch(/GRANT INSERT\b.* ON chant_e2e_3716_access\.events TO/);
|
|
231
|
+
const v3entities = new Map(Object.entries(v3).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
|
|
232
|
+
const offDeep = await observeResourcesDeep({ environment: "test", entityNames: [...v3entities.keys()], entities: v3entities, config: configOf(endpoint, off), env: envOf(endpoint) });
|
|
233
|
+
expect(Object.entries(offDeep.unobserved ?? {}).map(([k, u]) => [k, u.reason]).sort()).toEqual(
|
|
234
|
+
["app", "readEvents", "reader", "readerToApp", "tenant"].map((k) => [k, "filtered"]),
|
|
235
|
+
);
|
|
236
|
+
const offThin = await describeResources({ environment: "test", entityNames: [...v3entities.keys()], entities: v3entities, config: configOf(endpoint, off), env: envOf(endpoint) });
|
|
237
|
+
expect(Object.keys(offThin.unobserved ?? {}).sort()).toEqual(["app", "readEvents", "reader", "readerToApp", "tenant"]);
|
|
185
238
|
}, 300_000);
|
|
186
239
|
});
|
|
187
240
|
|
|
@@ -198,12 +251,12 @@ describe.skipIf(!docker)("access control beside a Replicated database (#3682)",
|
|
|
198
251
|
const [r1, r2] = cluster!.replicas as [ClickHouseEndpoint, ClickHouseEndpoint];
|
|
199
252
|
const { shop, events, reader, tenant, readEvents } = declarations();
|
|
200
253
|
const build = writeBuild("repl.json", { shop, events, reader, tenant, readEvents });
|
|
201
|
-
const first = await apply(r1, build, "replicated");
|
|
254
|
+
const first = await apply(r1, build, { topology: "replicated" });
|
|
202
255
|
expect(first.outcome.failed).toEqual([]);
|
|
203
256
|
expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER/);
|
|
204
257
|
await q(first.sent[0]!, r2);
|
|
205
258
|
await q(`SYSTEM SYNC DATABASE REPLICA ${DB}`, r2);
|
|
206
|
-
const second = await apply(r2, build, "replicated");
|
|
259
|
+
const second = await apply(r2, build, { topology: "replicated" });
|
|
207
260
|
expect(second.outcome.failed).toEqual([]);
|
|
208
261
|
expect(second.outcome.applied.filter((a) => a.action === "created").map((a) => a.name)).toEqual([
|
|
209
262
|
`role ${READER}`,
|
|
@@ -212,7 +265,7 @@ describe.skipIf(!docker)("access control beside a Replicated database (#3682)",
|
|
|
212
265
|
]);
|
|
213
266
|
for (const at of [r1, r2]) {
|
|
214
267
|
expect(Number((await q<{ n: string }>(`SELECT count() AS n FROM system.row_policies WHERE short_name = '${DB}_tenant'`, at))[0]!.n)).toBe(1);
|
|
215
|
-
expect((await apply(at, build, "replicated")).sent).toEqual([]);
|
|
268
|
+
expect((await apply(at, build, { topology: "replicated" })).sent).toEqual([]);
|
|
216
269
|
}
|
|
217
270
|
}, 300_000);
|
|
218
271
|
});
|
|
@@ -24,6 +24,14 @@ export interface ClickHouseTarget {
|
|
|
24
24
|
* as declared.
|
|
25
25
|
*/
|
|
26
26
|
topology?: Topology;
|
|
27
|
+
/**
|
|
28
|
+
* Whether chant manages access here (`sql.profiles.<env>.access`, #3716):
|
|
29
|
+
* the declared users, roles, row policies and grants. Off when omitted, as
|
|
30
|
+
* on Postgres, and off for a server bound by `CLICKHOUSE_URL`.
|
|
31
|
+
*/
|
|
32
|
+
access?: boolean;
|
|
33
|
+
/** `sql.profiles.<env>.importFunctions` (#3718): the functions an import adopts besides those the imported objects call. */
|
|
34
|
+
importFunctions?: string[];
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
export interface UnresolvedTarget {
|
|
@@ -75,6 +83,8 @@ export function resolveClickHouseTarget(input: {
|
|
|
75
83
|
...(profile.databases ? { databases: profile.databases } : {}),
|
|
76
84
|
defaultDatabase: profile.defaultDatabase ?? "default",
|
|
77
85
|
...(profile.topology !== undefined ? { topology: toTopology(profile.topology) } : {}),
|
|
86
|
+
...(profile.access === true ? { access: true } : {}),
|
|
87
|
+
...(profile.importFunctions ? { importFunctions: profile.importFunctions } : {}),
|
|
78
88
|
};
|
|
79
89
|
}
|
|
80
90
|
const url = env.CLICKHOUSE_URL;
|
|
@@ -9,6 +9,7 @@ import { clickhouseQuery } from "../http";
|
|
|
9
9
|
import type { ClickHouseTarget } from "./bind";
|
|
10
10
|
import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities";
|
|
11
11
|
import { isChantWorkingObject, stripMarkerFromStatement } from "../ownership";
|
|
12
|
+
import { foreignTool } from "../../core/foreign-tables";
|
|
12
13
|
|
|
13
14
|
/** The server's own databases, never part of a declared schema. */
|
|
14
15
|
export const SYSTEM_DATABASES = new Set(["system", "information_schema", "INFORMATION_SCHEMA"]);
|
|
@@ -26,6 +27,8 @@ export interface LiveObject {
|
|
|
26
27
|
statement: string;
|
|
27
28
|
/** A row policy's table. */
|
|
28
29
|
table?: string;
|
|
30
|
+
/** The tool that keeps it, for a migration runner's history table (`../../core/foreign-tables.ts`): never chant's to import, change or drop. */
|
|
31
|
+
foreign?: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
const quote = (s: string) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
@@ -46,6 +49,11 @@ export function entityTypeOfEngine(engine: string): ClickHouseEntityType {
|
|
|
46
49
|
* `SELECT` on the database sees it without a grant on `system.dictionaries`;
|
|
47
50
|
* a table with the `Dictionary` engine is a table.
|
|
48
51
|
*
|
|
52
|
+
* A table another tool keeps its migration history in (`schema_migrations`,
|
|
53
|
+
* `goose_db_version`, `../../core/foreign-tables.ts`) is read and marked with
|
|
54
|
+
* the tool, as the Postgres reader marks it (#3676): import leaves it out
|
|
55
|
+
* with a warning, and a plan names it in a hint instead of dropping it.
|
|
56
|
+
*
|
|
49
57
|
* So are chant's own working objects (`../ownership.ts`
|
|
50
58
|
* `isChantWorkingObject`): a rebuild migration's new, dual-write and retained
|
|
51
59
|
* tables, and the effect receipts database. None of them is a declaration, so
|
|
@@ -87,13 +95,16 @@ export async function readLiveSchema(target: ClickHouseTarget, opts: { withState
|
|
|
87
95
|
}
|
|
88
96
|
for (const t of tables) {
|
|
89
97
|
if (working.has(t.database) || isChantWorkingObject(t.comment)) continue;
|
|
98
|
+
const type = Number(t.dictionary) === 1 ? CLICKHOUSE_ENTITY_TYPES.dictionary : entityTypeOfEngine(t.engine);
|
|
99
|
+
const foreign = type === CLICKHOUSE_ENTITY_TYPES.table ? foreignTool(t.name) : undefined;
|
|
90
100
|
out.push({
|
|
91
|
-
type
|
|
101
|
+
type,
|
|
92
102
|
database: t.database,
|
|
93
103
|
name: t.name,
|
|
94
104
|
engine: t.engine,
|
|
95
105
|
...(t.uuid && !/^0{8}-/.test(t.uuid) ? { uuid: t.uuid } : {}),
|
|
96
106
|
...(t.comment ? { comment: t.comment } : {}),
|
|
107
|
+
...(foreign ? { foreign } : {}),
|
|
97
108
|
statement: await statement("TABLE", `${ident(t.database)}.${ident(t.name)}`),
|
|
98
109
|
});
|
|
99
110
|
}
|
|
@@ -168,7 +179,19 @@ export async function readLiveAccess(target: ClickHouseTarget, declared: readonl
|
|
|
168
179
|
return out;
|
|
169
180
|
}
|
|
170
181
|
|
|
171
|
-
|
|
182
|
+
/** The canonical kinds a profile's `access` switch decides (#3716). */
|
|
183
|
+
export const ACCESS_KINDS: ReadonlySet<string> = new Set(["user", "role", "rowPolicy", "grants"]);
|
|
184
|
+
|
|
185
|
+
/** The entity types a profile's `access` switch decides (#3716). */
|
|
186
|
+
export const ACCESS_ENTITY_TYPES: ReadonlySet<string> = new Set([CLICKHOUSE_ENTITY_TYPES.user, CLICKHOUSE_ENTITY_TYPES.role, CLICKHOUSE_ENTITY_TYPES.rowPolicy, CLICKHOUSE_ENTITY_TYPES.grant]);
|
|
187
|
+
|
|
188
|
+
/** Why an access declaration is not read, planned or applied where the profile does not manage access. */
|
|
189
|
+
export const ACCESS_UNMANAGED_DETAIL = "access is not managed in this environment: set sql.profiles.<env>.access to plan and apply users, roles, row policies and grants";
|
|
190
|
+
|
|
191
|
+
/** The hint a plan gives for the access declarations it left out. */
|
|
192
|
+
export function accessUnmanagedHint(n: number, environment: string): string {
|
|
193
|
+
return `${n} access declaration${n === 1 ? " is" : "s are"} not planned: ${environment}'s profile does not manage access (sql.profiles.<env>.access)`;
|
|
194
|
+
}
|
|
172
195
|
|
|
173
196
|
/** The access objects among declared canonical objects, for `readLiveAccess`. */
|
|
174
197
|
export function declaredAccess(objects: ReadonlyArray<{ kind: string; name: string; database?: string; table?: string }>): DeclaredAccess[] {
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type ObserverAdapter,
|
|
25
25
|
} from "@intentius/chant/observation";
|
|
26
26
|
import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "./bind";
|
|
27
|
-
import { accessOf, readLiveAccess, readLiveSchema, type DeclaredAccess, type LiveObject } from "./catalog";
|
|
27
|
+
import { ACCESS_ENTITY_TYPES, ACCESS_UNMANAGED_DETAIL, accessOf, readLiveAccess, readLiveSchema, type DeclaredAccess, type LiveObject } from "./catalog";
|
|
28
28
|
import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
|
|
29
29
|
import { objectKey } from "../plan/normalize";
|
|
30
30
|
import { isChantManaged, readMarker, stripMarker } from "../ownership";
|
|
@@ -54,7 +54,7 @@ function adapter(options: BindOptions & { owned?: boolean; access?: (defaultData
|
|
|
54
54
|
async bind() {
|
|
55
55
|
const target = await bindClickHouse(options);
|
|
56
56
|
const byKey = new Map<string, LiveObject>();
|
|
57
|
-
const access = await readLiveAccess(target, options.access?.(target.defaultDatabase) ?? [], { withStatements: false });
|
|
57
|
+
const access = target.access === true ? await readLiveAccess(target, options.access?.(target.defaultDatabase) ?? [], { withStatements: false }) : [];
|
|
58
58
|
for (const o of [...(await readLiveSchema(target, { withStatements: false })), ...access]) {
|
|
59
59
|
byKey.set(objectKey(o), o);
|
|
60
60
|
}
|
|
@@ -65,6 +65,9 @@ function adapter(options: BindOptions & { owned?: boolean; access?: (defaultData
|
|
|
65
65
|
if (!entity.type.startsWith("ClickHouse::")) {
|
|
66
66
|
return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
|
|
67
67
|
}
|
|
68
|
+
if (target.access !== true && ACCESS_ENTITY_TYPES.has(entity.type)) {
|
|
69
|
+
return { unobserved: { reason: "filtered", detail: ACCESS_UNMANAGED_DETAIL } };
|
|
70
|
+
}
|
|
68
71
|
if (entity.type === CLICKHOUSE_ENTITY_TYPES.grant) {
|
|
69
72
|
return { unobserved: { reason: "unsupported-kind", detail: "a grant is compared as part of its grantees' grants, which chant sql plan reports" } };
|
|
70
73
|
}
|
|
@@ -60,6 +60,14 @@ describe("binding an environment to a server", () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("access is managed only where the profile says so (#3716)", () => {
|
|
64
|
+
const on = resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "http://ch:8123", access: true } } } } as never, env: {} });
|
|
65
|
+
expect(on).toMatchObject({ access: true });
|
|
66
|
+
const off = resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "http://ch:8123" } } } } as never, env: {} });
|
|
67
|
+
expect(off).not.toHaveProperty("access");
|
|
68
|
+
expect(resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123" } })).not.toHaveProperty("access");
|
|
69
|
+
});
|
|
70
|
+
|
|
63
71
|
test("a credential variable the profile names but nobody set is no-credentials", () => {
|
|
64
72
|
const t = resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "x", password: { env: "P" } } } } }, env: {} });
|
|
65
73
|
expect(isUnresolvedTarget(t) && t.reason).toBe("no-credentials");
|
|
@@ -35,7 +35,7 @@ import { renderDiff } from "./report";
|
|
|
35
35
|
import { keyedByQualifiedName, schemaFromBuildFile, schemaFromServer } from "./schema";
|
|
36
36
|
import { dropFormattingOnly } from "./server-format";
|
|
37
37
|
import { scopeOf } from "./normalize";
|
|
38
|
-
import { declaredAccess } from "../live/catalog";
|
|
38
|
+
import { ACCESS_KINDS, accessUnmanagedHint, declaredAccess } from "../live/catalog";
|
|
39
39
|
import { bindClickHouse } from "../live/bind";
|
|
40
40
|
import { rebuildOpSuggestions } from "./rebuild-handoff";
|
|
41
41
|
import { readFileSync } from "node:fs";
|
|
@@ -120,7 +120,10 @@ export async function runDiff(ctx: CommandGroupContext): Promise<number> {
|
|
|
120
120
|
/** The declared objects against the server, keys and labels by `database.name`. */
|
|
121
121
|
export async function planAgainstServer(environment: string, buildFile: string, options: Parameters<typeof bindClickHouse>[0] = {}): Promise<SchemaDiff> {
|
|
122
122
|
const target = await bindClickHouse({ ...options, environment });
|
|
123
|
-
const
|
|
123
|
+
const all = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase, target.topology));
|
|
124
|
+
// Access declarations a profile does not manage are not planned, and the server's access is not read (#3716).
|
|
125
|
+
const declared = target.access === true ? all : all.filter((o) => !ACCESS_KINDS.has(o.canonical.kind));
|
|
126
|
+
const unmanaged = all.length - declared.length;
|
|
124
127
|
const databases = new Set(declared.map((o) => scopeOf(o.canonical)));
|
|
125
128
|
const unreadable: UnreadableEntry[] = [];
|
|
126
129
|
const live = await schemaFromServer(target, databases, unreadable, declaredAccess(declared.map((o) => o.canonical)));
|
|
@@ -136,7 +139,7 @@ export async function planAgainstServer(environment: string, buildFile: string,
|
|
|
136
139
|
return {
|
|
137
140
|
changes: relabel,
|
|
138
141
|
rebuilds: relabel.filter((c) => c.class === "rebuild"),
|
|
139
|
-
hints: diff.hints,
|
|
142
|
+
hints: unmanaged > 0 ? [...diff.hints, accessUnmanagedHint(unmanaged, environment)] : diff.hints,
|
|
140
143
|
...(rebuildOps.length > 0 ? { rebuildOps } : {}),
|
|
141
144
|
...(unreadable.length > 0 ? { unreadable } : {}),
|
|
142
145
|
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test, vi } from "vitest";
|
|
9
|
+
import { grant } from "../entities";
|
|
10
|
+
import type { ClickHouseTarget } from "../live/bind";
|
|
11
|
+
|
|
12
|
+
/** What `SHOW GRANTS FOR` prints, by grantee; a grantee missing here does not exist. */
|
|
13
|
+
let held: Record<string, string[]> = {};
|
|
14
|
+
|
|
15
|
+
vi.mock("../http", async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal<typeof import("../http")>();
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
clickhouseQuery: async (_endpoint: unknown, sql: string) => {
|
|
20
|
+
if (/FROM system\.users/.test(sql)) return Object.keys(held).filter((n) => sql.includes(`'${n}'`)).map((name) => ({ name }));
|
|
21
|
+
if (/FROM system\.roles/.test(sql)) return [];
|
|
22
|
+
const show = /^SHOW GRANTS FOR `(.+)`$/.exec(sql.trim());
|
|
23
|
+
if (show) return (held[show[1]!] ?? []).map((line) => ({ [`GRANTS FOR ${show[1]}`]: line }));
|
|
24
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const { pendingGrants } = await import("./deep");
|
|
30
|
+
|
|
31
|
+
const target: ClickHouseTarget = { endpoint: { url: "http://stub:8123" }, source: "sql.profiles.test", defaultDatabase: "default", access: true };
|
|
32
|
+
|
|
33
|
+
const entities = (all: Record<string, ReturnType<typeof grant>>) =>
|
|
34
|
+
new Map(Object.entries(all).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
|
|
35
|
+
|
|
36
|
+
const declared = entities({
|
|
37
|
+
readEvents: grant`GRANT SELECT ON shop.events TO app`,
|
|
38
|
+
readOrders: grant`GRANT SELECT ON shop.orders TO app`,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("pendingGrants (#3733)", () => {
|
|
42
|
+
test("a grantee holding what is declared has nothing pending", async () => {
|
|
43
|
+
held = { app: ["GRANT SELECT ON shop.events TO app", "GRANT SELECT ON shop.orders TO app"] };
|
|
44
|
+
expect(await pendingGrants(target, declared)).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a privilege granted by hand is a REVOKE, one revoked by hand a GRANT, revokes first", async () => {
|
|
48
|
+
held = { app: ["GRANT INSERT, SELECT ON shop.events TO app"] };
|
|
49
|
+
expect(await pendingGrants(target, declared)).toEqual([
|
|
50
|
+
{
|
|
51
|
+
subject: "grants app",
|
|
52
|
+
change: "REVOKE INSERT ON `shop`.`events` FROM `app`; GRANT SELECT ON `shop`.`orders` TO `app`",
|
|
53
|
+
entities: ["readEvents", "readOrders"],
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a grantee the server does not hold yet is given every declared grant", async () => {
|
|
59
|
+
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`"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("no grant declarations read nothing", async () => {
|
|
64
|
+
held = {};
|
|
65
|
+
expect(await pendingGrants(target, new Map())).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -17,17 +17,29 @@
|
|
|
17
17
|
* (#3664). When a server's copy of a table differs from the profile
|
|
18
18
|
* server's, that copy is what is reported, with the server in `observedOn`,
|
|
19
19
|
* which the drift report prints as `seen on`.
|
|
20
|
+
*
|
|
21
|
+
* Where the profile manages access, the grants an apply would give or take
|
|
22
|
+
* from each declared grantee are reported as `pending` (#3733), the same
|
|
23
|
+
* statements `chant sql plan` plans (SQLCH273, SQLCH274), so a privilege
|
|
24
|
+
* granted or revoked by hand after an approval moves the plan digest.
|
|
20
25
|
*/
|
|
21
26
|
|
|
22
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
deepObservation,
|
|
29
|
+
type DeepObservationResult,
|
|
30
|
+
type DeepPendingChange,
|
|
31
|
+
type DeepResourceObservation,
|
|
32
|
+
type DeepNormalizationHooks,
|
|
33
|
+
} from "@intentius/chant/deep-observation";
|
|
23
34
|
import type { UnobservedEntity } from "@intentius/chant/lexicon";
|
|
24
35
|
import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "../live/bind";
|
|
25
|
-
import { accessOf, readLiveAccess, readLiveSchema, SYSTEM_DATABASES, type LiveObject } from "../live/catalog";
|
|
36
|
+
import { ACCESS_ENTITY_TYPES, ACCESS_UNMANAGED_DETAIL, accessOf, readLiveAccess, readLiveSchema, SYSTEM_DATABASES, type LiveObject } from "../live/catalog";
|
|
26
37
|
import { clickhouseQuery } from "../http";
|
|
27
38
|
import { sqlString } from "../apply/statements";
|
|
28
39
|
import { stripMarkerFromStatement } from "../ownership";
|
|
29
40
|
import { database, dictionary, func, policy, role, table, user, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
|
|
30
|
-
import { canonicalObject, objectKey, type CanonicalColumn, type CanonicalObject } from "./normalize";
|
|
41
|
+
import { canonicalObject, grantsObject, objectKey, type CanonicalColumn, type CanonicalObject } from "./normalize";
|
|
42
|
+
import { grantStatement, grantsByGrantee, revokeStatement, type GrantAtom } from "../access";
|
|
31
43
|
import { renderFor } from "../topology";
|
|
32
44
|
|
|
33
45
|
type Props = Record<string, unknown>;
|
|
@@ -207,7 +219,7 @@ export async function observeResourcesDeep(
|
|
|
207
219
|
let live: LiveObject[];
|
|
208
220
|
try {
|
|
209
221
|
target = await bindClickHouse(options);
|
|
210
|
-
live = [...(await readLiveSchema(target)), ...(await readLiveAccess(target, accessOf(options.entities, target.defaultDatabase)))];
|
|
222
|
+
live = [...(await readLiveSchema(target)), ...(target.access === true ? await readLiveAccess(target, accessOf(options.entities, target.defaultDatabase)) : [])];
|
|
211
223
|
} catch (err) {
|
|
212
224
|
const why = classifyClickHouseFailure(err);
|
|
213
225
|
for (const name of options.entityNames) {
|
|
@@ -227,17 +239,34 @@ export async function observeResourcesDeep(
|
|
|
227
239
|
}
|
|
228
240
|
return deepObservation({}, unobserved);
|
|
229
241
|
}
|
|
242
|
+
const pending: DeepPendingChange[] = [];
|
|
243
|
+
if (target.access === true) {
|
|
244
|
+
try {
|
|
245
|
+
pending.push(...(await pendingGrants(target, options.entities)));
|
|
246
|
+
} catch (err) {
|
|
247
|
+
const why = classifyClickHouseFailure(err);
|
|
248
|
+
for (const name of options.entityNames) {
|
|
249
|
+
if (options.entities.get(name)?.entityType !== CLICKHOUSE_ENTITY_TYPES.grant) continue;
|
|
250
|
+
unobserved[name] = { type: CLICKHOUSE_ENTITY_TYPES.grant, reason: "read-failed", detail: `the server's grants could not be compared: ${why.detail}` };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
230
254
|
for (const name of options.entityNames) {
|
|
231
255
|
const entity = options.entities.get(name);
|
|
232
256
|
if (!entity || !entity.entityType.startsWith("ClickHouse::")) {
|
|
233
257
|
unobserved[name] = { type: entity?.entityType ?? "", reason: "unsupported-kind" };
|
|
234
258
|
continue;
|
|
235
259
|
}
|
|
260
|
+
if (target.access !== true && ACCESS_ENTITY_TYPES.has(entity.entityType)) {
|
|
261
|
+
unobserved[name] = { type: entity.entityType, reason: "filtered", detail: ACCESS_UNMANAGED_DETAIL };
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
236
264
|
if (entity.entityType === CLICKHOUSE_ENTITY_TYPES.grant) {
|
|
265
|
+
if (unobserved[name]) continue;
|
|
237
266
|
unobserved[name] = {
|
|
238
267
|
type: entity.entityType,
|
|
239
268
|
reason: "unsupported-kind",
|
|
240
|
-
detail: "a grant is compared as part of its grantees' grants
|
|
269
|
+
detail: "a grant is compared as part of its grantees' grants: what an apply would grant or revoke is listed as pending (SQLCH273, SQLCH274)",
|
|
241
270
|
};
|
|
242
271
|
continue;
|
|
243
272
|
}
|
|
@@ -280,5 +309,40 @@ export async function observeResourcesDeep(
|
|
|
280
309
|
unobserved[name] = { type: entity.entityType, reason: "read-failed", detail: `the server's definition does not parse: ${(err as Error).message}` };
|
|
281
310
|
}
|
|
282
311
|
}
|
|
283
|
-
return deepObservation(resources, unobserved);
|
|
312
|
+
return deepObservation(resources, unobserved, pending);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The grants an apply would give or take from each declared grantee (#3733),
|
|
317
|
+
* as `chant sql plan` compares them: the declared grants against what
|
|
318
|
+
* `SHOW GRANTS FOR` prints, atom by atom. One change per grantee, its
|
|
319
|
+
* REVOKEs before its GRANTs as the applier sends them, with the grant
|
|
320
|
+
* declarations behind it. A grantee the server does not hold yet is given
|
|
321
|
+
* every declared grant.
|
|
322
|
+
*/
|
|
323
|
+
export async function pendingGrants(
|
|
324
|
+
target: ClickHouseTarget,
|
|
325
|
+
entities: ReadonlyMap<string, { entityType: string; props: Record<string, unknown> }>,
|
|
326
|
+
): Promise<DeepPendingChange[]> {
|
|
327
|
+
const declared = grantsByGrantee([...entities].map(([name, e]) => ({ export: name, type: e.entityType, ddl: String(e.props.ddl ?? "") })));
|
|
328
|
+
if (declared.length === 0) return [];
|
|
329
|
+
const live = await readLiveAccess(target, declared.map((g) => ({ kind: "grants", name: g.grantee })));
|
|
330
|
+
const held = new Map(live.filter((o) => o.type === CLICKHOUSE_ENTITY_TYPES.grant).map((o) => [o.name, grantsObject(o.statement, o.name, target.defaultDatabase).access ?? {}]));
|
|
331
|
+
const atom = (json: string) => JSON.parse(json) as GrantAtom;
|
|
332
|
+
const out: DeepPendingChange[] = [];
|
|
333
|
+
for (const g of declared) {
|
|
334
|
+
const want = grantsObject(g.ddl, g.grantee, target.defaultDatabase).access ?? {};
|
|
335
|
+
const have = held.get(g.grantee) ?? {};
|
|
336
|
+
const statements = [
|
|
337
|
+
...Object.keys(have)
|
|
338
|
+
.filter((k) => !(k in want))
|
|
339
|
+
.map((k) => revokeStatement(atom(have[k]!), g.grantee)),
|
|
340
|
+
...Object.keys(want)
|
|
341
|
+
.filter((k) => !(k in have))
|
|
342
|
+
.map((k) => grantStatement(atom(want[k]!), g.grantee)),
|
|
343
|
+
];
|
|
344
|
+
if (statements.length === 0) continue;
|
|
345
|
+
out.push({ subject: `grants ${g.grantee}`, change: statements.join("; "), entities: [...g.exports].sort() });
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
284
348
|
}
|
|
@@ -259,7 +259,10 @@ export function diffSchemas(before: readonly SchemaObject[], after: readonly Sch
|
|
|
259
259
|
for (const m of matches) {
|
|
260
260
|
if (m.kind === "created") changes.push(change(m.after.key, "object", "SQLCH200", undefined, qualified(m.after.canonical)));
|
|
261
261
|
else if (m.kind === "matched") diffObject(m.after.key, m.before.canonical, m.after.canonical, changes, hints);
|
|
262
|
-
else {
|
|
262
|
+
else if (m.before.canonical.foreign) {
|
|
263
|
+
// A migration runner's history table (#3676): not chant's to drop.
|
|
264
|
+
hints.push(`${qualified(m.before.canonical)} is kept by ${m.before.canonical.foreign}; it is not chant's to drop and is left alone`);
|
|
265
|
+
} else {
|
|
263
266
|
const b = m.before;
|
|
264
267
|
changes.push(change(b.key, "object", "SQLCH250", qualified(b.canonical), undefined, { destructive: b.canonical.kind === "table" || b.canonical.kind === "materializedView" }));
|
|
265
268
|
}
|