@intentius/chant-lexicon-sql 0.116.0 → 0.118.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/access.d.ts +132 -0
- package/dist/clickhouse/access.d.ts.map +1 -0
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +2 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/entities.d.ts +171 -2
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/http.d.ts +3 -0
- package/dist/clickhouse/http.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +28 -0
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/ir.d.ts.map +1 -1
- package/dist/clickhouse/import/parser.d.ts +3 -3
- package/dist/clickhouse/import/parser.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +41 -6
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts +3 -2
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/parser.d.ts +54 -3
- package/dist/clickhouse/parser.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +3 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts +12 -0
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +61 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/report.d.ts.map +1 -1
- package/dist/clickhouse/plan/rules.d.ts +7 -0
- package/dist/clickhouse/plan/rules.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +8 -4
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +7 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +8 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +48 -0
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +70 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/core/references.d.ts +4 -2
- package/dist/core/references.d.ts.map +1 -1
- package/dist/core/statements.d.ts +11 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/files/build-root.d.ts +26 -0
- package/dist/files/build-root.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +19 -0
- package/dist/files/clickhouse.d.ts.map +1 -0
- package/dist/files/common.d.ts +25 -0
- package/dist/files/common.d.ts.map +1 -0
- package/dist/files/postgres.d.ts +36 -0
- package/dist/files/postgres.d.ts.map +1 -0
- package/dist/import-parser.d.ts +25 -0
- package/dist/import-parser.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +8 -8
- package/dist/lint/rules/sqlch001.d.ts +1 -1
- package/dist/lint/rules/sqlch001.d.ts.map +1 -1
- package/dist/lint/rules/sqlch002.d.ts.map +1 -1
- package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
- package/dist/lint/rules/templates.d.ts +1 -1
- package/dist/lint/rules/templates.d.ts.map +1 -1
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +50 -0
- package/dist/okf/index.md +11 -1
- package/dist/okf/rules/SQLCH001.md +2 -2
- package/dist/okf/types/Dictionary.md +9 -0
- package/dist/okf/types/Function.md +9 -0
- package/dist/okf/types/Grant.md +9 -0
- package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
- package/dist/okf/types/PostgresGrant.md +9 -0
- package/dist/okf/types/PostgresPolicy.md +9 -0
- package/dist/okf/types/PostgresRole.md +9 -0
- package/dist/okf/types/Role.md +9 -0
- package/dist/okf/types/RowPolicy.md +9 -0
- package/dist/okf/types/User.md +9 -0
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +19 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/access/acl.d.ts +119 -0
- package/dist/postgres/access/acl.d.ts.map +1 -0
- package/dist/postgres/access/import.d.ts +13 -0
- package/dist/postgres/access/import.d.ts.map +1 -0
- package/dist/postgres/access/live.d.ts +24 -0
- package/dist/postgres/access/live.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +71 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +106 -4
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +4 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +37 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/live-export.d.ts +6 -0
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +2 -0
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +11 -2
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +3 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +9 -0
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/migrate/op.d.ts +29 -0
- package/dist/postgres/migrate/op.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +76 -3
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts +12 -0
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts +10 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +8 -1
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +8 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +2 -1
- package/dist/postgres/plan/schema.d.ts.map +1 -1
- package/dist/postgres/plan/server-normalize.d.ts +4 -1
- package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sqlch001.ts +26 -4
- package/dist/rules/sqlch002.ts +3 -2
- package/dist/rules/sqlpg001.ts +7 -4
- package/dist/rules/templates.ts +2 -2
- package/dist/serializer.d.ts +2 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/sql-files.d.ts +79 -0
- package/dist/sql-files.d.ts.map +1 -0
- package/dist/token-source.d.ts +88 -0
- package/dist/token-source.d.ts.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/access.test.ts +203 -0
- package/src/clickhouse/access.ts +595 -0
- package/src/clickhouse/apply/apply.ts +29 -20
- package/src/clickhouse/apply/statements.ts +105 -8
- package/src/clickhouse/dictionary.test.ts +177 -0
- package/src/clickhouse/entities.ts +356 -6
- package/src/clickhouse/function.test.ts +91 -0
- package/src/clickhouse/http.ts +11 -2
- package/src/clickhouse/import/generator.ts +81 -46
- package/src/clickhouse/import/import.test.ts +9 -3
- package/src/clickhouse/import/ir.ts +6 -2
- package/src/clickhouse/import/parser.ts +10 -18
- package/src/clickhouse/live/access.e2e.test.ts +218 -0
- package/src/clickhouse/live/bind.ts +17 -6
- package/src/clickhouse/live/catalog.ts +106 -10
- package/src/clickhouse/live/describe-resources.ts +23 -10
- package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
- package/src/clickhouse/live/functions.e2e.test.ts +155 -0
- package/src/clickhouse/live/live.test.ts +2 -2
- package/src/clickhouse/parser.ts +136 -4
- package/src/clickhouse/plan/commands.ts +18 -7
- package/src/clickhouse/plan/deep.ts +71 -11
- package/src/clickhouse/plan/diff.ts +65 -2
- package/src/clickhouse/plan/normalize.ts +200 -6
- package/src/clickhouse/plan/report.ts +14 -3
- package/src/clickhouse/plan/rules.ts +44 -1
- package/src/clickhouse/plan/schema.ts +40 -17
- package/src/clickhouse/plan/server-format.ts +1 -1
- package/src/clickhouse/rebuild/backfill.ts +5 -25
- package/src/clickhouse/rebuild/observe.ts +7 -3
- package/src/clickhouse/rebuild/op.ts +9 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
- package/src/clickhouse/rebuild/steps.ts +136 -1
- package/src/clickhouse/testing/fake-server.ts +2 -0
- package/src/clickhouse/testing/writable-server.ts +2 -0
- package/src/clickhouse/topology.ts +17 -2
- package/src/clickhouse.ts +19 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/codegen/registry.ts +10 -0
- package/src/config.ts +22 -3
- package/src/core/references.ts +5 -3
- package/src/core/statements.ts +11 -1
- package/src/files/build-root.ts +62 -0
- package/src/files/clickhouse.ts +52 -0
- package/src/files/common.ts +54 -0
- package/src/files/postgres.ts +371 -0
- package/src/generated/lexicon-sql.json +50 -0
- package/src/import-parser.ts +61 -0
- package/src/index.ts +14 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlch001.ts +26 -4
- package/src/lint/rules/sqlch002.ts +3 -2
- package/src/lint/rules/sqlpg001.ts +7 -4
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/postgres-template.ts +2 -0
- package/src/lsp/template.ts +3 -3
- package/src/mcp/index.ts +6 -6
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.test.ts +25 -0
- package/src/migration-statements.ts +8 -0
- package/src/op/activities/clickhouse-rebuild.ts +1 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +2 -0
- package/src/plugin.ts +29 -5
- package/src/postgres/access/access.e2e.test.ts +191 -0
- package/src/postgres/access/access.test.ts +147 -0
- package/src/postgres/access/acl.ts +400 -0
- package/src/postgres/access/import.ts +64 -0
- package/src/postgres/access/live.ts +123 -0
- package/src/postgres/apply/apply.ts +117 -4
- package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
- package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
- package/src/postgres/apply/statements.test.ts +138 -6
- package/src/postgres/apply/statements.ts +210 -18
- package/src/postgres/entities.ts +287 -11
- package/src/postgres/entity-types.ts +4 -0
- package/src/postgres/import/generator.ts +129 -46
- package/src/postgres/import/live-export.ts +12 -2
- package/src/postgres/live/bind.ts +20 -6
- package/src/postgres/live/catalog.ts +79 -3
- package/src/postgres/live/client.ts +10 -2
- package/src/postgres/live/describe-resources.ts +27 -3
- package/src/postgres/migrate/migrate.test.ts +24 -1
- package/src/postgres/migrate/op.ts +80 -35
- package/src/postgres/migrate/outer.e2e.test.ts +163 -0
- package/src/postgres/parser.ts +247 -8
- package/src/postgres/plan/commands.ts +60 -7
- package/src/postgres/plan/deep.ts +24 -3
- package/src/postgres/plan/diff.ts +71 -6
- package/src/postgres/plan/normalize.ts +115 -2
- package/src/postgres/plan/plan.test.ts +2 -2
- package/src/postgres/plan/rules.ts +49 -5
- package/src/postgres/plan/schema.ts +4 -2
- package/src/postgres/plan/server-normalize.ts +29 -1
- package/src/postgres.ts +11 -0
- package/src/serializer.ts +14 -9
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres-plan.md +3 -3
- package/src/skills/chant-sql-postgres.md +9 -2
- package/src/skills/chant-sql-rebuild.md +3 -2
- package/src/skills/chant-sql.md +7 -1
- package/src/sql-files.test.ts +188 -0
- package/src/sql-files.ts +218 -0
- package/src/token-source.e2e.test.ts +142 -0
- package/src/token-source.test.ts +119 -0
- package/src/token-source.ts +189 -0
- package/src/validate.ts +10 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClickHouse access control as declarations (#3682): users, roles, row
|
|
3
|
+
* policies and grants.
|
|
4
|
+
*
|
|
5
|
+
* Each is a statement the server keeps outside any table: `CREATE USER`,
|
|
6
|
+
* `CREATE ROLE`, `CREATE ROW POLICY` and `GRANT`. This module parses them
|
|
7
|
+
* (the tags in `./entities.ts`, the lint rule, the plan), reduces each to
|
|
8
|
+
* the fields a plan compares, and writes the statements that make a change.
|
|
9
|
+
*
|
|
10
|
+
* What a declaration owns and what the environment owns:
|
|
11
|
+
*
|
|
12
|
+
* - A password is the environment's. `IDENTIFIED BY` is refused in a
|
|
13
|
+
* declaration (only `ssh_key BY KEY`, a public key, is taken), so no
|
|
14
|
+
* secret is ever in a build. A user declared with no `IDENTIFIED` clause
|
|
15
|
+
* is compared without it, and is not created by chant: create it with its
|
|
16
|
+
* password, and the next apply manages the rest of it. A user declared
|
|
17
|
+
* with a method that holds no secret (`ssl_certificate`, `ldap`,
|
|
18
|
+
* `kerberos`, `http`, `ssh_key`, `no_password`) is created and kept so.
|
|
19
|
+
* - Grants are compared per grantee, for each grantee the build declares a
|
|
20
|
+
* grant to: every grant declaration naming it adds up to its complete
|
|
21
|
+
* list, and a grant made by hand to it is revoked. A grant another one
|
|
22
|
+
* covers (`SELECT ON db.t` beside `SELECT ON db.*`) is folded away, as the
|
|
23
|
+
* server folds it.
|
|
24
|
+
* - None of these objects has a comment, so none carries chant's ownership
|
|
25
|
+
* marker. A plan reads only the ones the build declares; chant creates and
|
|
26
|
+
* changes them, and never drops one.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { isTrivia, tokenizeText, type Token } from "./tokens";
|
|
30
|
+
import { kw, SqlCursor, type Span } from "../core/cursor";
|
|
31
|
+
import { unquote } from "./parser";
|
|
32
|
+
|
|
33
|
+
export type AccessStatement = "user" | "role" | "rowPolicy" | "grant";
|
|
34
|
+
|
|
35
|
+
/** One parsed access statement. Clause spans are token spans, keywords left out. */
|
|
36
|
+
export interface AccessNode {
|
|
37
|
+
statement: AccessStatement;
|
|
38
|
+
orReplace: boolean;
|
|
39
|
+
ifNotExists: boolean;
|
|
40
|
+
/** A user's, role's or row policy's name; none for a grant. */
|
|
41
|
+
name?: Span;
|
|
42
|
+
onCluster?: Span;
|
|
43
|
+
/** A row policy's table, `[db.]table`. */
|
|
44
|
+
table?: Span;
|
|
45
|
+
/** Each clause after its keywords, by key: `identified`, `host`, `defaultRole`, `defaultDatabase`, `grantees`, `settings`, `validUntil`, `as`, `for`, `using`, `to`. */
|
|
46
|
+
clauses: Partial<Record<string, Span>>;
|
|
47
|
+
/** A grant's privileges or roles, one span each. */
|
|
48
|
+
items: Span[];
|
|
49
|
+
/** A grant's `ON` target; none for a grant of roles. */
|
|
50
|
+
target?: Span;
|
|
51
|
+
/** A grant's grantees. */
|
|
52
|
+
grantees: Span[];
|
|
53
|
+
/** `WITH GRANT OPTION` or `WITH ADMIN OPTION`. */
|
|
54
|
+
withOption?: "GRANT" | "ADMIN";
|
|
55
|
+
/** A `REVOKE` line, as `SHOW GRANTS` prints a partial revoke. Never declared. */
|
|
56
|
+
revoke?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isName = (t: Token | undefined): boolean => t !== undefined && (t.kind === "ident" || t.kind === "qident" || t.kind === "ref");
|
|
60
|
+
|
|
61
|
+
class AccessParser extends SqlCursor {
|
|
62
|
+
private name(what: string): Span {
|
|
63
|
+
const t = this.next();
|
|
64
|
+
if (!isName(t)) this.fail(`expected ${what}`, t);
|
|
65
|
+
const i = this.idx(-1);
|
|
66
|
+
return { from: i, to: i + 1, refs: t.kind === "ref" ? [t.part] : [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `name` or `db.name`, a `*` in either place. */
|
|
70
|
+
private qualified(): Span {
|
|
71
|
+
const from = this.idx();
|
|
72
|
+
const refs: number[] = [];
|
|
73
|
+
const one = () => {
|
|
74
|
+
const t = this.next();
|
|
75
|
+
if (!(isName(t) || (t.kind === "op" && t.text === "*"))) this.fail("expected a name", t);
|
|
76
|
+
if (t.kind === "ref") refs.push(t.part);
|
|
77
|
+
};
|
|
78
|
+
one();
|
|
79
|
+
while (this.isPunct(".")) {
|
|
80
|
+
this.p++;
|
|
81
|
+
one();
|
|
82
|
+
}
|
|
83
|
+
return this.span(from, refs);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private orReplace = false;
|
|
87
|
+
private ifNotExists = false;
|
|
88
|
+
|
|
89
|
+
/** `IF NOT EXISTS` or `OR REPLACE`, which ClickHouse takes after USER, ROLE and ROW POLICY. */
|
|
90
|
+
private head(): void {
|
|
91
|
+
if (kw(this.peek(), "IF") && kw(this.peek(1), "NOT") && kw(this.peek(2), "EXISTS")) {
|
|
92
|
+
this.p += 3;
|
|
93
|
+
this.ifNotExists = true;
|
|
94
|
+
} else if (kw(this.peek(), "OR") && kw(this.peek(1), "REPLACE")) {
|
|
95
|
+
this.p += 2;
|
|
96
|
+
this.orReplace = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private onClusterClause(): Span | undefined {
|
|
101
|
+
if (kw(this.peek(), "ON") && kw(this.peek(1), "CLUSTER")) {
|
|
102
|
+
this.p += 2;
|
|
103
|
+
const from = this.idx();
|
|
104
|
+
const t = this.next();
|
|
105
|
+
if (!isName(t) && t.kind !== "string") this.fail("expected a cluster name", t);
|
|
106
|
+
return this.span(from, t.kind === "ref" ? [t.part] : []);
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Clauses in any order: each starts with one of `starts` (a keyword sequence) and runs to the next clause. */
|
|
112
|
+
private clauses(starts: Record<string, string[]>, node: AccessNode): void {
|
|
113
|
+
const stopWords = [...new Set(Object.values(starts).map((w) => w[0]!))];
|
|
114
|
+
for (;;) {
|
|
115
|
+
if (this.atEnd()) return;
|
|
116
|
+
const hit = Object.entries(starts).find(([, words]) => words.every((w, i) => kw(this.peek(i), w)));
|
|
117
|
+
if (!hit) this.fail(`unexpected ${this.peek()?.text}: expected ${Object.values(starts).map((w) => w.join(" ")).join(", ")}`);
|
|
118
|
+
const [key, words] = hit;
|
|
119
|
+
this.p += words.length;
|
|
120
|
+
if (node.clauses[key]) this.fail(`${words.join(" ")} is written twice`);
|
|
121
|
+
// `NOT IDENTIFIED` has no value; every other clause has one.
|
|
122
|
+
node.clauses[key] = key === "notIdentified" ? { from: this.idx(-1), to: this.idx(-1) + 1, refs: [] } : this.expr(stopWords, false);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
parse(): AccessNode {
|
|
127
|
+
if (this.accept("GRANT")) return this.grant(false);
|
|
128
|
+
if (this.accept("REVOKE")) return this.grant(true);
|
|
129
|
+
this.expect("CREATE");
|
|
130
|
+
const node = (statement: AccessStatement): AccessNode => ({ statement, orReplace: this.orReplace, ifNotExists: this.ifNotExists, clauses: {}, items: [], grantees: [] });
|
|
131
|
+
if (this.accept("USER")) {
|
|
132
|
+
this.head();
|
|
133
|
+
const n = node("user");
|
|
134
|
+
n.name = this.name("a user name");
|
|
135
|
+
if (this.isPunct(",")) this.fail("declare one user per template");
|
|
136
|
+
n.onCluster = this.onClusterClause();
|
|
137
|
+
this.clauses(
|
|
138
|
+
{
|
|
139
|
+
notIdentified: ["NOT", "IDENTIFIED"],
|
|
140
|
+
identified: ["IDENTIFIED"],
|
|
141
|
+
host: ["HOST"],
|
|
142
|
+
validUntil: ["VALID", "UNTIL"],
|
|
143
|
+
in: ["IN"],
|
|
144
|
+
defaultRole: ["DEFAULT", "ROLE"],
|
|
145
|
+
defaultDatabase: ["DEFAULT", "DATABASE"],
|
|
146
|
+
grantees: ["GRANTEES"],
|
|
147
|
+
settings: ["SETTINGS"],
|
|
148
|
+
},
|
|
149
|
+
n,
|
|
150
|
+
);
|
|
151
|
+
refusePassword(this, this.tokens, n);
|
|
152
|
+
return n;
|
|
153
|
+
}
|
|
154
|
+
if (this.accept("ROLE")) {
|
|
155
|
+
this.head();
|
|
156
|
+
const n = node("role");
|
|
157
|
+
n.name = this.name("a role name");
|
|
158
|
+
if (this.isPunct(",")) this.fail("declare one role per template");
|
|
159
|
+
n.onCluster = this.onClusterClause();
|
|
160
|
+
this.clauses({ in: ["IN"], settings: ["SETTINGS"] }, n);
|
|
161
|
+
return n;
|
|
162
|
+
}
|
|
163
|
+
if (this.accept("ROW")) this.expect("POLICY");
|
|
164
|
+
else if (!this.accept("POLICY")) this.fail("expected USER, ROLE or ROW POLICY");
|
|
165
|
+
this.head();
|
|
166
|
+
const n = node("rowPolicy");
|
|
167
|
+
n.name = this.name("a policy name");
|
|
168
|
+
n.onCluster = this.onClusterClause();
|
|
169
|
+
this.expect("ON");
|
|
170
|
+
n.table = this.qualified();
|
|
171
|
+
if (this.isPunct(",")) this.fail("declare one row policy, on one table, per template");
|
|
172
|
+
this.clauses({ as: ["AS"], for: ["FOR"], using: ["USING"], to: ["TO"] }, n);
|
|
173
|
+
if (!n.clauses.using) this.fail("expected USING and the policy's condition");
|
|
174
|
+
return n;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private grant(revoke: boolean): AccessNode {
|
|
178
|
+
const n: AccessNode = { statement: "grant", orReplace: false, ifNotExists: false, clauses: {}, items: [], grantees: [], ...(revoke ? { revoke: true } : {}) };
|
|
179
|
+
n.onCluster = this.onClusterClause();
|
|
180
|
+
if (revoke && (this.acceptSeq("GRANT", "OPTION", "FOR") || this.acceptSeq("ADMIN", "OPTION", "FOR"))) this.fail("REVOKE ... OPTION FOR is not read");
|
|
181
|
+
if (kw(this.peek(), "CURRENT") && kw(this.peek(1), "GRANTS")) this.fail("GRANT CURRENT GRANTS grants whatever the applying user holds; name the privileges");
|
|
182
|
+
const to = revoke ? "FROM" : "TO";
|
|
183
|
+
for (;;) {
|
|
184
|
+
n.items.push(this.expr(["ON", to]));
|
|
185
|
+
if (this.acceptPunct(",")) continue;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
if (this.accept("ON")) n.target = this.qualified();
|
|
189
|
+
this.expect(to);
|
|
190
|
+
for (;;) {
|
|
191
|
+
n.grantees.push(this.expr(["WITH"]));
|
|
192
|
+
if (this.acceptPunct(",")) continue;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (this.accept("WITH")) {
|
|
196
|
+
if (this.acceptSeq("GRANT", "OPTION")) n.withOption = "GRANT";
|
|
197
|
+
else if (this.acceptSeq("ADMIN", "OPTION")) n.withOption = "ADMIN";
|
|
198
|
+
else if (kw(this.peek(), "REPLACE")) this.fail("WITH REPLACE OPTION revokes whatever else the grantee holds; declare the complete list instead");
|
|
199
|
+
else this.fail("expected GRANT OPTION or ADMIN OPTION");
|
|
200
|
+
}
|
|
201
|
+
if (!n.target && n.withOption === "GRANT") this.fail("WITH GRANT OPTION is for privileges; a role takes WITH ADMIN OPTION");
|
|
202
|
+
this.acceptPunct(";");
|
|
203
|
+
if (this.peek() !== undefined) this.fail("unexpected token after the grant");
|
|
204
|
+
return n;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The authentication methods that hold no secret: a user declared with one is created by chant. */
|
|
209
|
+
export const SECRETLESS = new Set(["no_password", "ssl_certificate", "ldap", "kerberos", "http", "ssh_key"]);
|
|
210
|
+
|
|
211
|
+
function refusePassword(p: { fail(message: string, t?: Token): never }, tokens: Token[], node: AccessNode): void {
|
|
212
|
+
const span = node.clauses.identified;
|
|
213
|
+
if (!span) return;
|
|
214
|
+
const sig = tokens.slice(span.from, span.to).filter((t) => !isTrivia(t));
|
|
215
|
+
const words = sig.map((t) => t.text.toLowerCase());
|
|
216
|
+
const method = words[0] === "with" ? words[1] : undefined;
|
|
217
|
+
// `SHOW CREATE USER` prints a password's method without the password (`IDENTIFIED WITH sha256_password`): that much is read.
|
|
218
|
+
const secret = words.includes("by") || words.includes("hash");
|
|
219
|
+
if (secret && method !== "ssh_key") {
|
|
220
|
+
p.fail(
|
|
221
|
+
method === undefined
|
|
222
|
+
? "IDENTIFIED BY holds a password, which is the environment's: declare the user without IDENTIFIED, create it with its password, and chant manages the rest"
|
|
223
|
+
: `IDENTIFIED WITH ${method} holds a password or its hash, which is the environment's: declare the user without IDENTIFIED, create it with its password, and chant manages the rest`,
|
|
224
|
+
sig[0],
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Parse one access statement. Throws `SqlSyntaxError` located in the statement. */
|
|
230
|
+
export function parseAccess(tokens: Token[]): AccessNode {
|
|
231
|
+
return new AccessParser(tokens).parse();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Whether a statement's text is an access statement this module reads. */
|
|
235
|
+
export function isAccessStatement(ddl: string): boolean {
|
|
236
|
+
const words = ddl
|
|
237
|
+
.replace(/--[^\n]*\n/g, " ")
|
|
238
|
+
.trim()
|
|
239
|
+
.split(/\s+/, 4)
|
|
240
|
+
.map((w) => w.toUpperCase());
|
|
241
|
+
if (words[0] === "GRANT" || words[0] === "REVOKE") return true;
|
|
242
|
+
if (words[0] !== "CREATE") return false;
|
|
243
|
+
return words[1] === "USER" || words[1] === "ROLE" || words[1] === "ROW" || words[1] === "POLICY";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── canonical forms ───────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
const text = (tokens: Token[], span: Span | undefined): string | undefined =>
|
|
249
|
+
span && span.to > span.from
|
|
250
|
+
? tokens
|
|
251
|
+
.slice(span.from, span.to)
|
|
252
|
+
.map((t) => t.text)
|
|
253
|
+
.join("")
|
|
254
|
+
.trim()
|
|
255
|
+
: undefined;
|
|
256
|
+
|
|
257
|
+
const sigOf = (tokens: Token[], span: Span): Token[] => tokens.slice(span.from, span.to).filter((t) => !isTrivia(t));
|
|
258
|
+
|
|
259
|
+
/** Tokens joined by single spaces: identifiers unquoted, bare words as `word` says. */
|
|
260
|
+
function join(sig: Token[], word: (t: string) => string): string {
|
|
261
|
+
return sig.map((t) => (t.kind === "qident" ? unquote(t.text) : t.kind === "ident" ? word(t.text) : t.text)).join(" ");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A comma-separated list of names, sorted, with the words of `keywords` upper case: `DEFAULT ROLE b, a` and `TO ALL EXCEPT r`. */
|
|
265
|
+
function nameList(tokens: Token[], span: Span, keywords: string[]): string {
|
|
266
|
+
const sig = sigOf(tokens, span);
|
|
267
|
+
const out: string[] = [];
|
|
268
|
+
let cur: Token[] = [];
|
|
269
|
+
const flush = () => {
|
|
270
|
+
if (cur.length) out.push(join(cur, (w) => (keywords.includes(w.toUpperCase()) ? w.toUpperCase() : w)));
|
|
271
|
+
cur = [];
|
|
272
|
+
};
|
|
273
|
+
for (const t of sig) {
|
|
274
|
+
if (t.kind === "punct" && t.text === ",") flush();
|
|
275
|
+
else if (t.kind === "ident" && keywords.includes(t.text.toUpperCase()) && cur.length > 0 && !keywords.includes(cur[cur.length - 1]!.text.toUpperCase())) {
|
|
276
|
+
// `ALL EXCEPT a`: the keyword starts a new part.
|
|
277
|
+
flush();
|
|
278
|
+
cur.push(t);
|
|
279
|
+
} else cur.push(t);
|
|
280
|
+
}
|
|
281
|
+
flush();
|
|
282
|
+
const keys = out.filter((x) => keywords.includes(x.split(" ")[0]!));
|
|
283
|
+
const names = out.filter((x) => !keywords.includes(x.split(" ")[0]!)).sort();
|
|
284
|
+
return [...keys, ...names].join(" , ");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `SETTINGS a = 1 READONLY, b MIN 0 MAX 2`: each setting, sorted by name, `READONLY` as the server prints it (`CONST`). */
|
|
288
|
+
function settingsList(tokens: Token[], span: Span): string {
|
|
289
|
+
const sig = sigOf(tokens, span);
|
|
290
|
+
const parts: Token[][] = [[]];
|
|
291
|
+
let depth = 0;
|
|
292
|
+
for (const t of sig) {
|
|
293
|
+
if (t.text === "(") depth++;
|
|
294
|
+
if (t.text === ")") depth--;
|
|
295
|
+
if (depth === 0 && t.kind === "punct" && t.text === ",") parts.push([]);
|
|
296
|
+
else parts[parts.length - 1]!.push(t);
|
|
297
|
+
}
|
|
298
|
+
const words = new Set(["MIN", "MAX", "CONST", "READONLY", "WRITABLE", "CHANGEABLE_IN_READONLY", "PROFILE", "NONE"]);
|
|
299
|
+
return parts
|
|
300
|
+
.filter((p) => p.length > 0)
|
|
301
|
+
.map((p) => join(p, (w) => (words.has(w.toUpperCase()) ? w.toUpperCase() : w)).replace(/\bREADONLY$/, "CONST"))
|
|
302
|
+
.sort()
|
|
303
|
+
.join(" , ");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** `HOST LOCAL, IP '10.0.0.0/8'`: each host, sorted, the kind of each upper case. */
|
|
307
|
+
function hostList(tokens: Token[], span: Span): string {
|
|
308
|
+
const words = new Set(["LOCAL", "ANY", "NONE", "IP", "NAME", "REGEXP", "LIKE"]);
|
|
309
|
+
const sig = sigOf(tokens, span);
|
|
310
|
+
const parts: string[] = [];
|
|
311
|
+
let cur: Token[] = [];
|
|
312
|
+
for (const t of [...sig, undefined]) {
|
|
313
|
+
if (!t || (t.kind === "punct" && t.text === ",")) {
|
|
314
|
+
if (cur.length) parts.push(join(cur, (w) => (words.has(w.toUpperCase()) ? w.toUpperCase() : w)));
|
|
315
|
+
cur = [];
|
|
316
|
+
} else cur.push(t);
|
|
317
|
+
}
|
|
318
|
+
return parts.sort().join(" , ");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** `[db.]name` with a table's database filled in. */
|
|
322
|
+
function qualifiedName(tokens: Token[], span: Span, defaultDatabase: string): { database: string; name: string } {
|
|
323
|
+
const parts = sigOf(tokens, span)
|
|
324
|
+
.filter((t) => !(t.kind === "punct" && t.text === "."))
|
|
325
|
+
.map((t) => unquote(t.text));
|
|
326
|
+
return parts.length >= 2 ? { database: parts[parts.length - 2]!, name: parts[parts.length - 1]! } : { database: defaultDatabase, name: parts[0] ?? "" };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** What a plan compares of a user, role or row policy: one string per field, absent at its default. */
|
|
330
|
+
export interface AccessCanonical {
|
|
331
|
+
kind: "user" | "role" | "rowPolicy";
|
|
332
|
+
name: string;
|
|
333
|
+
/** A row policy's table's database. */
|
|
334
|
+
database?: string;
|
|
335
|
+
/** A row policy's table. */
|
|
336
|
+
table?: string;
|
|
337
|
+
fields: Record<string, string>;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A user, role or row policy as a plan compares it. A field at the server's
|
|
342
|
+
* default is left out, so a declaration that leaves out `HOST ANY` and the
|
|
343
|
+
* server that does not print it agree. A user's `IDENTIFIED` is kept only
|
|
344
|
+
* when written; a plan compares it only when the declaration has one.
|
|
345
|
+
*/
|
|
346
|
+
export function accessCanonical(ddl: string, defaultDatabase = "default"): AccessCanonical {
|
|
347
|
+
const tokens = tokenizeText(ddl, 0);
|
|
348
|
+
const node = parseAccess(tokens);
|
|
349
|
+
if (node.statement === "grant") throw new Error("a grant is compared as its grantee's grants (grantsCanonical)");
|
|
350
|
+
const name = unquote(text(tokens, node.name) ?? "");
|
|
351
|
+
const fields: Record<string, string> = {};
|
|
352
|
+
const put = (key: string, value: string | undefined, def?: string) => {
|
|
353
|
+
if (value !== undefined && value !== "" && value !== def) fields[key] = value;
|
|
354
|
+
};
|
|
355
|
+
const c = node.clauses;
|
|
356
|
+
if (node.statement === "user") {
|
|
357
|
+
if (c.notIdentified) fields.identified = "no_password";
|
|
358
|
+
else if (c.identified) {
|
|
359
|
+
// `WITH method` lower case, as the server prints it; key words upper case; strings as written.
|
|
360
|
+
const sig = sigOf(tokens, c.identified);
|
|
361
|
+
const out = sig.map((t, i) => (t.kind === "ident" ? (i === 1 && sig[0]!.text.toUpperCase() === "WITH" ? t.text.toLowerCase() : t.text.toUpperCase()) : t.text));
|
|
362
|
+
fields.identified = out.join(" ").replace(/^WITH no_password$/, "no_password").replace(/^WITH /, "");
|
|
363
|
+
}
|
|
364
|
+
put("host", c.host && hostList(tokens, c.host), "ANY");
|
|
365
|
+
put("validUntil", text(tokens, c.validUntil));
|
|
366
|
+
put("defaultRole", c.defaultRole && nameList(tokens, c.defaultRole, ["ALL", "NONE", "EXCEPT"]), "ALL");
|
|
367
|
+
put("defaultDatabase", c.defaultDatabase && unquote(text(tokens, c.defaultDatabase) ?? ""), "NONE");
|
|
368
|
+
put("grantees", c.grantees && nameList(tokens, c.grantees, ["ANY", "NONE", "EXCEPT"]), "ANY");
|
|
369
|
+
put("settings", c.settings && settingsList(tokens, c.settings), "NONE");
|
|
370
|
+
return { kind: "user", name, fields };
|
|
371
|
+
}
|
|
372
|
+
if (node.statement === "role") {
|
|
373
|
+
put("settings", c.settings && settingsList(tokens, c.settings), "NONE");
|
|
374
|
+
return { kind: "role", name, fields };
|
|
375
|
+
}
|
|
376
|
+
const table = qualifiedName(tokens, node.table!, defaultDatabase);
|
|
377
|
+
put("as", c.as && text(tokens, c.as)!.toUpperCase(), "PERMISSIVE");
|
|
378
|
+
put("for", c.for && join(sigOf(tokens, c.for), (w) => w.toUpperCase()), "SELECT");
|
|
379
|
+
put("using", text(tokens, c.using));
|
|
380
|
+
put("to", c.to && nameList(tokens, c.to, ["ALL", "NONE", "EXCEPT"]), "NONE");
|
|
381
|
+
return { kind: "rowPolicy", name, database: table.database, table: table.name, fields };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── grants ────────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
/** One grant, the smallest thing a plan adds or takes away. */
|
|
387
|
+
export interface GrantAtom {
|
|
388
|
+
/** A privilege, upper case (`SELECT`, `SHOW TABLES`), or the role granted. */
|
|
389
|
+
privilege?: string;
|
|
390
|
+
role?: string;
|
|
391
|
+
/** One column of a column-level privilege. */
|
|
392
|
+
column?: string;
|
|
393
|
+
/** `db.table`, `db.*`, `*.*` or `*`, unquoted. */
|
|
394
|
+
target?: string;
|
|
395
|
+
/** `WITH GRANT OPTION` (a privilege) or `WITH ADMIN OPTION` (a role). */
|
|
396
|
+
option?: boolean;
|
|
397
|
+
/** A partial revoke the server holds (`REVOKE SELECT(b) ON db.t`). Never declared. */
|
|
398
|
+
revoke?: boolean;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** An atom's key, which is also how a plan prints it: `SELECT(a) ON shop.t`, `ROLE reader WITH ADMIN OPTION`. */
|
|
402
|
+
export function atomKey(a: GrantAtom): string {
|
|
403
|
+
if (a.role !== undefined) return `ROLE ${a.role}${a.option ? " WITH ADMIN OPTION" : ""}`;
|
|
404
|
+
return `${a.revoke ? "REVOKE " : ""}${a.privilege}${a.column !== undefined ? `(${a.column})` : ""} ON ${a.target}${a.option ? " WITH GRANT OPTION" : ""}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function grantAtoms(ddl: string, defaultDatabase: string): Array<{ grantees: string[]; atoms: GrantAtom[] }> {
|
|
408
|
+
const out: Array<{ grantees: string[]; atoms: GrantAtom[] }> = [];
|
|
409
|
+
for (const statement of splitLines(ddl)) {
|
|
410
|
+
const tokens = tokenizeText(statement, 0);
|
|
411
|
+
const node = parseAccess(tokens);
|
|
412
|
+
if (node.statement !== "grant") throw new Error(`not a grant: ${statement}`);
|
|
413
|
+
const grantees = node.grantees.map((g) => unquote(text(tokens, g) ?? ""));
|
|
414
|
+
const atoms: GrantAtom[] = [];
|
|
415
|
+
let target: string | undefined;
|
|
416
|
+
if (node.target) {
|
|
417
|
+
const parts = sigOf(tokens, node.target)
|
|
418
|
+
.filter((t) => !(t.kind === "punct" && t.text === "."))
|
|
419
|
+
.map((t) => unquote(t.text));
|
|
420
|
+
target = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : parts[0] === "*" ? "*" : `${defaultDatabase}.${parts[0]}`;
|
|
421
|
+
}
|
|
422
|
+
for (const item of node.items) {
|
|
423
|
+
const sig = sigOf(tokens, item);
|
|
424
|
+
if (!target) {
|
|
425
|
+
atoms.push({ role: unquote(text(tokens, item) ?? ""), ...(node.withOption === "ADMIN" ? { option: true } : {}) });
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const open = sig.findIndex((t) => t.text === "(");
|
|
429
|
+
const words = (open < 0 ? sig : sig.slice(0, open)).map((t) => t.text.toUpperCase()).join(" ");
|
|
430
|
+
const columns =
|
|
431
|
+
open < 0
|
|
432
|
+
? [undefined]
|
|
433
|
+
: sig
|
|
434
|
+
.slice(open + 1, -1)
|
|
435
|
+
.filter((t) => !(t.kind === "punct" && t.text === ","))
|
|
436
|
+
.map((t) => unquote(t.text));
|
|
437
|
+
for (const column of columns) {
|
|
438
|
+
atoms.push({ privilege: words, ...(column !== undefined ? { column } : {}), target, ...(node.withOption === "GRANT" ? { option: true } : {}), ...(node.revoke ? { revoke: true } : {}) });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
out.push({ grantees, atoms });
|
|
442
|
+
}
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Statements on their own lines, or separated by `;`, as `SHOW GRANTS` prints them and a group of declarations is joined. */
|
|
447
|
+
function splitLines(ddl: string): string[] {
|
|
448
|
+
return ddl
|
|
449
|
+
.split(/;\s*\n|\n(?=\s*(?:GRANT|REVOKE)\b)/i)
|
|
450
|
+
.map((s) => s.trim().replace(/;$/, ""))
|
|
451
|
+
.filter((s) => s.length > 0 && !/^--/.test(s));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Whether `outer` covers `inner`'s target: the same table, its database's `*`, or `*.*`. */
|
|
455
|
+
function covers(outer: string, inner: string): boolean {
|
|
456
|
+
if (outer === inner) return true;
|
|
457
|
+
if (outer === "*.*") return true;
|
|
458
|
+
const [db] = inner.split(".");
|
|
459
|
+
return outer === `${db}.*` && inner !== outer;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The atoms a grantee holds, with each atom another covers left out, as the
|
|
464
|
+
* server folds them: a column's privilege under the table's, a table's under
|
|
465
|
+
* its database's, any under `*.*`, the same privilege, with a grant option at
|
|
466
|
+
* least as wide.
|
|
467
|
+
*/
|
|
468
|
+
export function foldAtoms(atoms: readonly GrantAtom[]): GrantAtom[] {
|
|
469
|
+
const keyed = new Map(atoms.map((a) => [atomKey(a), a]));
|
|
470
|
+
const all = [...keyed.values()];
|
|
471
|
+
return all.filter(
|
|
472
|
+
(a) =>
|
|
473
|
+
a.role !== undefined ||
|
|
474
|
+
a.revoke ||
|
|
475
|
+
!all.some(
|
|
476
|
+
(b) =>
|
|
477
|
+
b !== a &&
|
|
478
|
+
!b.revoke &&
|
|
479
|
+
b.role === undefined &&
|
|
480
|
+
b.privilege === a.privilege &&
|
|
481
|
+
b.column === undefined &&
|
|
482
|
+
(b.option ?? false) >= (a.option ?? false) &&
|
|
483
|
+
covers(b.target!, a.target!) &&
|
|
484
|
+
(a.column !== undefined || b.target !== a.target),
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** A grantee's grants as a plan compares them: each atom's key, mapped to the atom. */
|
|
490
|
+
export function grantsCanonical(ddl: string, grantee: string, defaultDatabase = "default"): Record<string, string> {
|
|
491
|
+
const atoms = grantAtoms(ddl, defaultDatabase)
|
|
492
|
+
.filter((g) => g.grantees.includes(grantee))
|
|
493
|
+
.flatMap((g) => g.atoms);
|
|
494
|
+
return Object.fromEntries(
|
|
495
|
+
foldAtoms(atoms)
|
|
496
|
+
.map((a) => [atomKey(a), JSON.stringify(a)] as const)
|
|
497
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Every grantee a grant statement names. */
|
|
502
|
+
export function granteesOf(ddl: string): string[] {
|
|
503
|
+
return [...new Set(grantAtoms(ddl, "default").flatMap((g) => g.grantees))];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── statements ────────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
const ident = (name: string): string => `\`${name.replace(/`/g, "``")}\``;
|
|
509
|
+
const targetSql = (target: string): string =>
|
|
510
|
+
target === "*" || target === "*.*"
|
|
511
|
+
? target
|
|
512
|
+
: target
|
|
513
|
+
.split(".")
|
|
514
|
+
.map((p) => (p === "*" ? p : ident(p)))
|
|
515
|
+
.join(".");
|
|
516
|
+
|
|
517
|
+
/** The statement that gives `grantee` one atom. */
|
|
518
|
+
export function grantStatement(atom: GrantAtom, grantee: string): string {
|
|
519
|
+
if (atom.role !== undefined) return `GRANT ${ident(atom.role)} TO ${ident(grantee)}${atom.option ? " WITH ADMIN OPTION" : ""}`;
|
|
520
|
+
const priv = `${atom.privilege}${atom.column !== undefined ? `(${ident(atom.column)})` : ""}`;
|
|
521
|
+
// Undoing a partial revoke grants the privilege back.
|
|
522
|
+
return `GRANT ${priv} ON ${targetSql(atom.target!)} TO ${ident(grantee)}${atom.option && !atom.revoke ? " WITH GRANT OPTION" : ""}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The statement that takes one atom from `grantee`. */
|
|
526
|
+
export function revokeStatement(atom: GrantAtom, grantee: string): string {
|
|
527
|
+
if (atom.role !== undefined) return `REVOKE ${ident(atom.role)} FROM ${ident(grantee)}`;
|
|
528
|
+
const priv = `${atom.privilege}${atom.column !== undefined ? `(${ident(atom.column)})` : ""}`;
|
|
529
|
+
return `REVOKE ${priv} ON ${targetSql(atom.target!)} FROM ${ident(grantee)}`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The `ALTER` that sets one field of a user or role to its declared value,
|
|
534
|
+
* or back to the server's default when the declaration leaves it out.
|
|
535
|
+
*/
|
|
536
|
+
export function alterAccessField(kind: "user" | "role", name: string, field: string, declared: AccessCanonical["fields"], declaredText: Partial<Record<string, string>>): string {
|
|
537
|
+
const head = `ALTER ${kind === "user" ? "USER" : "ROLE"} ${ident(name)}`;
|
|
538
|
+
const value = declaredText[field];
|
|
539
|
+
switch (field) {
|
|
540
|
+
case "identified":
|
|
541
|
+
return value === undefined || value === "NOT IDENTIFIED" ? `${head} NOT IDENTIFIED` : `${head} IDENTIFIED ${value}`;
|
|
542
|
+
case "host":
|
|
543
|
+
return `${head} HOST ${value ?? "ANY"}`;
|
|
544
|
+
case "validUntil":
|
|
545
|
+
return `${head} VALID UNTIL ${value ?? "'infinity'"}`;
|
|
546
|
+
case "defaultRole":
|
|
547
|
+
return `${head} DEFAULT ROLE ${value ?? "ALL"}`;
|
|
548
|
+
case "defaultDatabase":
|
|
549
|
+
return `${head} DEFAULT DATABASE ${value ?? "NONE"}`;
|
|
550
|
+
case "grantees":
|
|
551
|
+
return `${head} GRANTEES ${value ?? "ANY"}`;
|
|
552
|
+
case "settings":
|
|
553
|
+
return `${head} SETTINGS ${value ?? "NONE"}`;
|
|
554
|
+
default:
|
|
555
|
+
throw new Error(`no ALTER for ${kind} field ${field}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** A user's or role's clauses as written, by field, for the `ALTER` that sets one. */
|
|
560
|
+
export function accessClauseText(ddl: string): Partial<Record<string, string>> {
|
|
561
|
+
const tokens = tokenizeText(ddl, 0);
|
|
562
|
+
const node = parseAccess(tokens);
|
|
563
|
+
const out: Partial<Record<string, string>> = {};
|
|
564
|
+
for (const [key, span] of Object.entries(node.clauses)) {
|
|
565
|
+
if (!span) continue;
|
|
566
|
+
out[key === "notIdentified" ? "identified" : key] = key === "notIdentified" ? "NOT IDENTIFIED" : text(tokens, span);
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The grant declarations of a build, one entry per grantee they name: the
|
|
573
|
+
* export names that grant to it, their statements on their own lines, and
|
|
574
|
+
* what they depend on. A plan compares each entry with what the server
|
|
575
|
+
* holds for that grantee.
|
|
576
|
+
*/
|
|
577
|
+
export function grantsByGrantee(objects: ReadonlyArray<{ export: string; type: string; ddl: string; dependsOn?: readonly string[] }>): Array<{
|
|
578
|
+
grantee: string;
|
|
579
|
+
exports: string[];
|
|
580
|
+
ddl: string;
|
|
581
|
+
dependsOn: string[];
|
|
582
|
+
}> {
|
|
583
|
+
const by = new Map<string, { exports: string[]; statements: string[]; dependsOn: Set<string> }>();
|
|
584
|
+
for (const o of objects) {
|
|
585
|
+
if (o.type !== "ClickHouse::Grant") continue;
|
|
586
|
+
for (const grantee of granteesOf(o.ddl)) {
|
|
587
|
+
const entry = by.get(grantee) ?? { exports: [], statements: [], dependsOn: new Set<string>() };
|
|
588
|
+
entry.exports.push(o.export);
|
|
589
|
+
entry.statements.push(o.ddl.trim().replace(/;$/, ""));
|
|
590
|
+
for (const d of o.dependsOn ?? []) entry.dependsOn.add(d);
|
|
591
|
+
by.set(grantee, entry);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return [...by].map(([grantee, e]) => ({ grantee, exports: e.exports, ddl: e.statements.join(";\n"), dependsOn: [...e.dependsOn] }));
|
|
595
|
+
}
|
|
@@ -43,8 +43,9 @@ import {
|
|
|
43
43
|
} from "../../core/apply";
|
|
44
44
|
import { clickhouseQuery } from "../http";
|
|
45
45
|
import type { ClickHouseTarget } from "../live/bind";
|
|
46
|
-
import { readLiveSchema, type LiveObject } from "../live/catalog";
|
|
47
|
-
import { canonicalObject, type CanonicalObject } from "../plan/normalize";
|
|
46
|
+
import { declaredAccess, readLiveAccess, readLiveSchema, type LiveObject } from "../live/catalog";
|
|
47
|
+
import { canonicalObject, grantsObject, liveCanonical, objectKey, scopeOf, type CanonicalObject } from "../plan/normalize";
|
|
48
|
+
import { grantsByGrantee } from "../access";
|
|
48
49
|
import { diffSchemas, type Change } from "../plan/diff";
|
|
49
50
|
import { dropFormattingOnly } from "../plan/server-format";
|
|
50
51
|
import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities";
|
|
@@ -65,18 +66,25 @@ export type { FailedObject } from "../../core/apply";
|
|
|
65
66
|
* is what the changes compare and every `CREATE` sends.
|
|
66
67
|
*/
|
|
67
68
|
export function declaredObjects(json: string, defaultDatabase = "default", topology?: Topology): DeclaredObject[] {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
69
|
+
const objects = readBuildObjects(json, "clickhouse", (o) => o);
|
|
70
|
+
const declared: DeclaredObject[] = objects
|
|
71
|
+
.filter((o) => o.type !== CLICKHOUSE_ENTITY_TYPES.grant)
|
|
72
|
+
.map((o) => {
|
|
73
|
+
const ddl = renderFor(o.ddl, topology);
|
|
74
|
+
const canonical = canonicalObject(ddl, defaultDatabase);
|
|
75
|
+
return { exportName: o.export, type: o.type as ClickHouseEntityType, key: objectKey(canonical), ddl, canonical, dependsOn: o.dependsOn };
|
|
76
|
+
});
|
|
77
|
+
// Grants are compared per grantee (`../access.ts`), once every other object exists, and wait for the
|
|
78
|
+
// grantee when the build declares it: a user the environment has still to create gets none.
|
|
79
|
+
const principals = new Map(declared.filter((o) => o.canonical.kind === "user" || o.canonical.kind === "role").map((o) => [o.canonical.name, o.exportName]));
|
|
80
|
+
for (const g of grantsByGrantee(objects)) {
|
|
81
|
+
const canonical = grantsObject(g.ddl, g.grantee, defaultDatabase);
|
|
82
|
+
const key = objectKey(canonical);
|
|
83
|
+
const grantee = principals.get(g.grantee);
|
|
84
|
+
const dependsOn = grantee !== undefined && !g.dependsOn.includes(grantee) ? [...g.dependsOn, grantee] : g.dependsOn;
|
|
85
|
+
declared.push({ exportName: key, type: CLICKHOUSE_ENTITY_TYPES.grant, key, ddl: g.ddl, canonical, dependsOn });
|
|
86
|
+
}
|
|
87
|
+
return declared;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
/** The plan axis: one ref per declared object, by its kind and its name on the server. */
|
|
@@ -108,18 +116,18 @@ export class ClickHouseApplyError extends SqlApplyError<ClickHouseApplyOutcome>
|
|
|
108
116
|
}
|
|
109
117
|
}
|
|
110
118
|
|
|
111
|
-
const liveKey = (o: LiveObject) => (o
|
|
119
|
+
const liveKey = (o: LiveObject) => objectKey(o);
|
|
112
120
|
|
|
113
121
|
/**
|
|
114
122
|
* The server's objects in the databases the declarations use, as the diff
|
|
115
123
|
* compares them. The `default` database itself is never part of a schema.
|
|
116
124
|
*/
|
|
117
125
|
async function liveSchema(target: ClickHouseTarget, declared: readonly DeclaredObject[]): Promise<{ objects: LiveObject[]; canonical: Map<string, CanonicalObject> }> {
|
|
118
|
-
const
|
|
119
|
-
const objects = (await readLiveSchema(target))
|
|
126
|
+
const scope = new Set(declared.map((o) => scopeOf(o.canonical)));
|
|
127
|
+
const objects = [...(await readLiveSchema(target)), ...(await readLiveAccess(target, declaredAccess(declared.map((o) => o.canonical))))]
|
|
120
128
|
.filter((o) => !(o.type === CLICKHOUSE_ENTITY_TYPES.database && o.name === "default"))
|
|
121
|
-
.filter((o) =>
|
|
122
|
-
return { objects, canonical: new Map(objects.map((o) => [liveKey(o),
|
|
129
|
+
.filter((o) => scope.has(scopeOf(o)));
|
|
130
|
+
return { objects, canonical: new Map(objects.map((o) => [liveKey(o), liveCanonical(o, target.defaultDatabase)])) };
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
/**
|
|
@@ -187,7 +195,8 @@ export async function applyClickHouse(target: ClickHouseTarget, declared: readon
|
|
|
187
195
|
continue;
|
|
188
196
|
}
|
|
189
197
|
if (entry.verdict === "withheld") {
|
|
190
|
-
|
|
198
|
+
// A user the environment has still to create is not on the server, so nothing that needs it is attempted.
|
|
199
|
+
if (liveObject) onServer.add(obj.exportName);
|
|
191
200
|
outcome.notAttempted.push({ ...ref, reason: "filtered", detail: entry.detail });
|
|
192
201
|
continue;
|
|
193
202
|
}
|