From 37dfe5088b80b32080d838e4783295f6fc805a7d Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 28 Jun 2026 08:03:47 +0200 Subject: [PATCH 1/2] fix(schema): degrade gracefully when system.dictionaries SELECT is denied loadSchemaLineage read system.dictionaries via the throwing queryJson, so a low-privilege user (e.g. a demo/OAuth role with SHOW grants but no SELECT ON system.dictionaries) hit Code 497 ACCESS_DENIED and the entire schema/lineage graph failed to render. Switch that read to the best-effort tryQueryData helper, matching how system.data_skipping_indices (loadSchemaCards) and system.parts (loadTableDetail) already degrade: a denied/missing system.dictionaries now yields no dictionary edges instead of aborting the graph. system.tables/system.columns already work for such users via their implicit SHOW TABLES/COLUMNS privileges, so the graph now draws with no grant change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01YDq48xZSSAreCbtaq83uVm --- src/net/ch-client.js | 5 +++-- tests/unit/ch-client.test.js | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 2bdf12a..00a9c3d 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -149,8 +149,9 @@ export async function loadSchemaLineage(ctx, focus) { + 'partition_key, sorting_key, primary_key, sampling_key'; const tablesJson = await queryJson(ctx, `SELECT ${cols} FROM system.tables WHERE database = ${sqlString(db)} ORDER BY name`); const tables = tablesJson.data || []; - const dictsJson = await queryJson(ctx, `SELECT database, name, source FROM system.dictionaries WHERE database = ${sqlString(db)}`); - const dictionaries = dictsJson.data || []; + // Best-effort: a denied/missing system.dictionaries (low-priv users lack + // SELECT on it) must degrade to no dictionary edges, never abort the graph. + const dictionaries = await tryQueryData(ctx, `SELECT database, name, source FROM system.dictionaries WHERE database = ${sqlString(db)}`) || []; // Robust source extraction for views/MVs: let ClickHouse parse the SELECT. await Promise.all(tables.map(async (t) => { if (!t.as_select || (t.engine !== 'View' && t.engine !== 'MaterializedView')) return; diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index b12291c..d3ec742 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -359,6 +359,17 @@ describe('loadSchemaLineage', () => { const out = await loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }); expect(out.tables[0].astTables).toBeUndefined(); }); + it('tolerates a denied system.dictionaries (degrades to no dictionary edges, graph still loads)', async () => { + const ctx = ctxWith((url, init) => { + const sql = init.body; + // Low-priv users (e.g. the demo role) lack SELECT on system.dictionaries. + if (/system\.dictionaries/.test(sql)) return jsonResp('DB::Exception: demo: Not enough privileges. ... grant SELECT ON system.dictionaries. (ACCESS_DENIED)', false, 500); + return jsonResp({ data: [{ database: 'lin', name: 'events', engine: 'MergeTree', as_select: '' }] }); + }); + const out = await loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }); + expect(out.tables).toHaveLength(1); + expect(out.dictionaries).toEqual([]); + }); it('includes the card metadata columns in the scoped tables query', async () => { const seen = []; const ctx = ctxWith((url, init) => { From e040fe05d0de1de0d73d29fcbc7d9d51d1b30719 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 28 Jun 2026 08:10:04 +0200 Subject: [PATCH 2/2] docs(schema): document required grants + best-effort degradation tiers Explain that the lineage graph works with no extra grants (the implicit SELECT from SHOW TABLES/COLUMNS covers system.tables/columns, row-filtered to accessible DBs), and that SELECT on system.dictionaries / data_skipping_indices / parts only buys fidelity (dictionary edges, skip-index badges, per-partition detail). Adds a no-degrade grant snippet and notes secrets stay masked. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01YDq48xZSSAreCbtaq83uVm --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 5f73c21..2528736 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,35 @@ plus light regex on `create_table_query` (`TO` target) and `engine_full` empty). Graph math is pure in `src/core/schema-graph.js` (100%-covered); the SVG is the same dagre-laid-out renderer the pipeline graph uses. +### Required grants + +Every introspection read is **best-effort**: a denied or missing `system.*` table +degrades the affected layer instead of failing the graph, so the lineage view works +even for low-privilege users. The graph draws with **no extra grants** — the implicit +`SELECT` that `SHOW TABLES` / `SHOW COLUMNS` give over `system.tables` / +`system.columns` is enough (and those rows are already filtered to the databases the +user can otherwise access). What you grant only buys *fidelity*: + +| To get… | the role needs | if denied (default) | +|---|---|---| +| the graph itself + node cards | `SHOW TABLES`, `SHOW COLUMNS` (→ implicit `SELECT ON system.tables` / `system.columns`) | required — without these there's nothing to draw | +| dictionary (`dict`) lineage edges | `SELECT ON system.dictionaries` | no dictionary edges; the rest of the graph still draws | +| skip-index badges on the rich cards | `SELECT ON system.data_skipping_indices` | cards show the engine/rows/bytes header without the skip line | +| per-partition rows in the node detail pane | `SELECT ON system.parts` | detail pane shows columns/keys/DDL but no partition breakdown | + +So for full, **no-degrade** schema mode, grant the three optional `SELECT`s above to +the role your users log in as, e.g.: + +```sql +GRANT SELECT ON system.dictionaries TO ; +GRANT SELECT ON system.data_skipping_indices TO ; +GRANT SELECT ON system.parts TO ; +``` + +These are metadata-only and stay row-filtered to the databases the role can already +read; DDL secrets remain masked unless the role separately holds +`displaySecretsInShowAndSelect`. + ## Saved queries & the Library Queries you save (★ **Save** next to Run, or `⌘S`) land in the sidebar **★ Library**