diff --git a/README.md b/README.md index 145a0a4..c99cc31 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Observability on ClickHouse: OpenTelemetry ingestion, the ClickStack/HyperDX UI, | [labs/hostmetrics-volume](labs/hostmetrics-volume/) | Linux and Windows EC2: data points and wire bytes per host at 1-minute hostmetrics, four ways to watch a Windows game-server process stop (process scraper, perfcounters, `windows_service`, a check script) and one gateway behind an endpoint service (verified 2026-11-06) | | [labs/llm-serving-observability](labs/llm-serving-observability/) | Self-hosted LLM serving (vLLM behind LiteLLM) observed with ClickStack, runnable without a GPU: the OTel Collector configuration, an `nvidia-smi` โ†’ OTLP pusher, LiteLLM gateway-metric cardinality with and without the attribute allowlist, and the 32-tile HyperDX dashboard imported onto hand-made sources | | [labs/vault-audit](labs/vault-audit/) | HashiCorp Vault OSS audit trail and HA metrics in ClickStack: a three-node Raft cluster's audit files (written by whichever node is active) through `file_log` and OTTL into OTel logs โ€” TraceId per request, issuance joined to later use on HMAC'd accessors, no secret in clear โ€” plus per-node metrics with `unauthenticated_metrics_access`; failover checked (verified locally and on Managed ClickStack, 2026-11-06) | +| [labs/managed-rbac](labs/managed-rbac/) | How finely Managed ClickStack splits access per game, user and column: ClickStack roles (resource, name/tag/ID, the API's beta row conditions), the per-user JWT identity search uses and the one shared identity alerts use, row and masking policies on each โ€” and where alerts and Map keys leak (verified on Managed ClickStack with an Admin and a test user, 2026-11-07) | ### ๐ŸŽ“ Workshops (`workshops/`) @@ -117,6 +118,7 @@ ClickHouse ๊ธฐ๋ฐ˜ ๊ด€์ธก์„ฑ ์‹ค์Šต์ž…๋‹ˆ๋‹ค. OpenTelemetry ์ˆ˜์ง‘, ClickStack/Hy | [labs/hostmetrics-volume](labs/hostmetrics-volume/) | LinuxยทWindows EC2: 1๋ถ„ hostmetrics์˜ ํ˜ธ์ŠคํŠธ๋‹น data point์™€ ์ „์†ก ๋ฐ”์ดํŠธ, Windows ๊ฒŒ์ž„ ์„œ๋ฒ„ ํ”„๋กœ์„ธ์Šค ์ค‘์ง€๋ฅผ ๋ณด๋Š” ๋„ค ๋ฐฉ๋ฒ•(process scraper, perfcounters, `windows_service`, ํ™•์ธ ์Šคํฌ๋ฆฝํŠธ), endpoint service ๋’ค์˜ gateway ํ•˜๋‚˜ (2026-11-06 ๊ฒ€์ฆ) | | [labs/llm-serving-observability](labs/llm-serving-observability/) | LiteLLM ๋’ค์˜ vLLM์„ ClickStack์œผ๋กœ ๊ด€์ธก, GPU ์—†์ด ์‹คํ–‰: OTel Collector ์„ค์ •, `nvidia-smi` โ†’ OTLP ํ‘ธ์…”, ์†์„ฑ ํ—ˆ์šฉ ๋ชฉ๋ก ์œ ๋ฌด์— ๋”ฐ๋ฅธ LiteLLM ๊ฒŒ์ดํŠธ์›จ์ด ๋ฉ”ํŠธ๋ฆญ ์นด๋””๋„๋ฆฌํ‹ฐ, ์†์œผ๋กœ ๋งŒ๋“  ์†Œ์Šค์— ๊ฐ€์ ธ์˜จ HyperDX ๋Œ€์‹œ๋ณด๋“œ(ํƒ€์ผ 32๊ฐœ) | | [labs/vault-audit](labs/vault-audit/) | HashiCorp Vault OSS ๊ฐ์‚ฌ ๊ธฐ๋ก๊ณผ HA ๋ฉ”ํŠธ๋ฆญ์„ ClickStack์—์„œ: Raft 3๋…ธ๋“œ ํด๋Ÿฌ์Šคํ„ฐ์˜ ๊ฐ์‚ฌ ํŒŒ์ผ(๊ทธ๋•Œ active์ธ ๋…ธ๋“œ๊ฐ€ ์”€)์„ `file_log`์™€ OTTL๋กœ OTel ๋กœ๊ทธ๋กœ โ€” ์š”์ฒญ๋งˆ๋‹ค TraceId, HMAC ์ฒ˜๋ฆฌ๋œ accessor๋กœ ๋ฐœ๊ธ‰๊ณผ ์ดํ›„ ์‚ฌ์šฉ ์—ฐ๊ฒฐ, ํ‰๋ฌธ ์‹œํฌ๋ฆฟ ์—†์Œ โ€” ๊ทธ๋ฆฌ๊ณ  `unauthenticated_metrics_access`๋กœ ๋…ธ๋“œ๋ณ„ ๋ฉ”ํŠธ๋ฆญ, ์žฅ์•  ์ „ํ™˜ ๊ฒ€์‚ฌ(2026-11-06 ๋กœ์ปฌ๊ณผ Managed ClickStack์—์„œ ๊ฒ€์ฆ) | +| [labs/managed-rbac](labs/managed-rbac/) | Managed ClickStack ์ ‘๊ทผ ๊ถŒํ•œ์„ ๊ฒŒ์ž„ยท์‚ฌ์šฉ์žยท์—ด ๋‹จ์œ„๋กœ ์–ด๋””๊นŒ์ง€ ๋‚˜๋ˆŒ ์ˆ˜ ์žˆ๋‚˜: ClickStack ์—ญํ• (๋ฆฌ์†Œ์Šค, ์ด๋ฆ„ยทํƒœ๊ทธยทID, API์˜ beta ํ–‰ ์กฐ๊ฑด), ๊ฒ€์ƒ‰์ด ์“ฐ๋Š” ์‚ฌ์šฉ์ž๋ณ„ JWT ์‹ ์›๊ณผ ์•Œ๋ฆผ์ด ์“ฐ๋Š” ๊ณต์šฉ ์‹ ์› ํ•˜๋‚˜, ๊ฐ๊ฐ์— ๋Œ€ํ•œ rowยทmasking policy, ๊ทธ๋ฆฌ๊ณ  ์•Œ๋ฆผ๊ณผ Map ํ‚ค๋กœ ์ƒˆ๋Š” ์ง€์  (Admin๊ณผ ์‹œํ—˜ ์‚ฌ์šฉ์ž๋กœ Managed ClickStack์—์„œ ๊ฒ€์ฆ, 2026-11-07) | ### ๐ŸŽ“ ์›Œํฌ์ˆ (`workshops/`) diff --git a/labs/managed-rbac/.gitignore b/labs/managed-rbac/.gitignore new file mode 100644 index 0000000..872aa6b --- /dev/null +++ b/labs/managed-rbac/.gitignore @@ -0,0 +1 @@ +results/role-log.jsonl diff --git a/labs/managed-rbac/README.md b/labs/managed-rbac/README.md new file mode 100644 index 0000000..55949bd --- /dev/null +++ b/labs/managed-rbac/README.md @@ -0,0 +1,225 @@ +# How finely can Managed ClickStack split access? Per game, per user, per column + +[English](#english) | [ํ•œ๊ตญ์–ด](#ํ•œ๊ตญ์–ด) + +## English + +A question that comes with every multi-team ClickStack rollout: *if each game team (or each +person) must see only its own logs, how far down can access be split โ€” dashboards, search and +alerts alike?* This lab builds one shared log table for three games and tries every layer that +decides what a Managed ClickStack user sees: ClickStack roles, the ClickHouse identity +ClickStack queries with, row policies, and masking policies. + +> **Verified on** 2026-11-07, synthetic data only, in two rounds. +> Round 1: ClickHouse Cloud `26.8.1.608` (ap-northeast-2), ClickStack UI `hyperdx-chc 2.43.1`, +> alert worker `hyperdx-chc-alerts 2.42.0`, 04:36โ€“04:53 UTC, one ClickStack user (an Admin). +> Round 2: a new trial organization, ClickHouse Cloud `26.6.1.2455` (ap-northeast-2, one replica), +> the same ClickStack versions, 05:44โ€“06:33 UTC, **two users** โ€” the Admin and a test user whose +> ClickStack role was changed per step, each in its own browser profile. +> Evidence: `system.query_log` / `system.session_log` rows and the screens under +> [`images/`](images/), summarised in [`results/2026-11-07-managed.md`](results/2026-11-07-managed.md). +> +> **Not run:** the `objects-id` role and the system `ReadOnly` role on the test user; masking in +> round 2 (round 1 only); whether the JWT name's hash changes on a new login. + +### Result + +| Layer | What can be split | Search | Dashboards | Alerts | Checked how | +|---|---|---|---|---|---| +| Cloud console | who may open ClickStack at all (per service: SQL console no access / read only / admin) | โ€” | โ€” | creating alerts needs admin | docs only | +| ClickStack role โ€” resource level | dashboards, saved searches, sources, webhooks, alerts, notebooks: no access / read / manage | per source | read-only dashboards ("You have read-only access") | **no access hides the menu**, `/alerts` says "You do not have permission to view alerts" | test user, round 2 | +| ClickStack role โ€” fine-grained | dashboards, saved searches, sources by **name, tag or ID** (`is` / `contains`), OR-ed rules | **rows are not restricted**: the allowed source still returned bravo rows | only the tagged dashboard listed; another by URL: "Dashboard Not Available" | no per-alert rule | test user, round 2 | +| ClickStack role โ€” custom, without Team read | โ€” | the whole app: `Unauthorized` | same | same | test user, round 2 | +| ClickStack role โ€” **row condition** | `integration: clickhouse`, per connection + table, `where` in SQL or Lucene | โ€” | โ€” | โ€” | the API stores it (beta), the editor has no section for it, and **an Admin cannot assign it**: `Not allowed to assign a role with privileges exceeding your own` | +| ClickHouse row policy on a role | `TO sql_console_admin, sql_console_read_only` | **no effect** | no effect | no effect | ClickStack's session has `roles = []` (round 1) | +| ClickHouse row policy, blanket | `TO ALL EXCEPT default` | **filtered** | filtered (SQL tiles too) | **filtered** (the alert worker is in `ALL`) | `used_row_policies` on every query (round 1) | +| ClickHouse row policy, **per user** | `TO \`JWT::โ€ฆ::\`` | **that user only**: alpha 7,036 rows vs the Admin's 21,109 | same | alerts not filtered: the restricted user still sees every alert and its history | two users, round 2 | +| Per-user policy, console SQL editor | same policy | โ€” | โ€” | โ€” | round 2 service: the editor runs as the same JWT user, alpha only; round 1 service: as `sql-console` | +| Alert worker left out | `โ€ฆ EXCEPT default, \`hdx-alert-internal\`` | filtered | filtered | **sees everything**: an alert fires on rows its viewer cannot search | round 1 | +| Masking, String column | `UPDATE Body = replaceRegexpAll(โ€ฆ)` | masked | masked | โ€” | WHERE on the original value: 0 rows (round 1) | +| Masking, Map key | `UPDATE LogAttributes = mapUpdate(โ€ฆ)` | **row detail masked, the `LogAttributes['client.ip']` column is not** | same | โ€” | subcolumn read skips the mask; WHERE on the original still matches 57,920 rows (round 1) | +| Masking with a lambda | `mapApply((k, v) -> โ€ฆ)` | **every query fails** | fails | โ€” | `There is no column k in table` (round 1) | + +What this means: + +1. **ClickStack roles split screens, not data.** A tag rule hid the other games' dashboards from + the list and by URL, but the same user, searching the one source it could read, got bravo's + rows. Splitting data per game needs one of the database layers below. +2. **Role row conditions are not usable yet.** The Cloud API accepts and stores them, but the + role editor does not show them and the Admin's attempt to give such a role to a user was refused. +3. **Row policies work, per user too โ€” through the JWT identity, not roles.** Search and dashboard + queries run as a per-user `JWT::โ€ฆ::` user with **no roles**; alert queries run as one + shared user (`hdx-alert-internal` / `hyperdx-alert-internal`, role `hdx_alert_role`). A policy on + the SQL console roles never reached ClickStack; a policy naming one JWT user restricted exactly + that user โ€” side by side, the test user saw alpha only while the Admin saw all three games. + On the round 2 service the console SQL editor ran as the same JWT user and was restricted too. + The JWT name exists only after the user's first login (`UNKNOWN_ROLE` before) and its form + differs between services (round 2 adds the service ID). +4. **Alerts have one scope for the whole team.** The alert worker is one user, so a per-user policy + never applies to alerts: the test user restricted to alpha still listed every alert, saw + charlie firing and its history. A blanket policy that includes the alert worker filters every + alert alike; leaving the worker out lets alerts see everything. +5. **Write per-user policies as an allowlist.** A user with no policy is not restricted by the + others โ€” the Admin above saw everything. Start from a blanket policy that grants nothing + sensitive, then add one policy per person or team. +6. **Mask String columns, not Map keys.** A masked String column stayed masked in results and in + WHERE. A masked Map key was masked only where the whole map was read. A lambda in the masking + expression breaks every query of the user it applies to. +7. **Give every custom role Team read.** Without it the user's ClickStack is one `Unauthorized` + page. A custom role can never hold `manage` on users, and `manage all` is refused, so only the + system Admin role re-roles people. Roles are team-wide (the same role IDs on two services of one + organization); sources and dashboards are per service. + +### Roadmap (public statements, read 2026-11-07) + +- The RBAC announcement (2026-04-01) says row-based access control is planned and that roles + will be able to carry filtering conditions. +- The Cloud OpenAPI (read 2026-11-07) already has that shape on `POST โ€ฆ/clickstack/roles`: + a permission with `integration: clickhouse` and `conditions: {connection, from: {databaseName, + tableName}, where, whereLanguage}`, described as "row-level access", endpoint marked beta. + On 2026-11-07 the role editor did not show it and an Admin could not assign such a role. + +### Run it + +Needs a ClickHouse Cloud service with ClickStack, its `default` password and an org API key. +For the second-user checks, a second login in its own browser profile and a ClickStack Admin to +change its role in Team Settings. + +```bash +python3 bin/lab.py setup # table, 172,800 rows over 24 h, source, saved searches, dashboards, alerts +python3 bin/lab.py feed 60 # fresh rows every 30 s, so alerts have data +python3 bin/lab.py role all # the scenario roles (not assigned to anyone) +# apply the statements in sql/policies.sql one step at a time; after each, run a fresh search +python3 bin/lab.py probe 5 # what ClickStack and the alert worker ran, as whom, with which policies +python3 bin/lab.py alerts # alert states +python3 bin/lab.py cleanup # alerts, dashboards, saved searches, webhook, source, database +python3 bin/lab.py drop-role # then delete the scenario roles in Team Settings +``` + +Add `--ch-env --api-env ` for another service. `sql/schema.sql` โ€” the table +(ClickStack's `otel_logs` columns plus `Game` and `Env`, sorted by game and environment first). +`sql/rows.sql` โ€” synthetic rows: three games, prod 80 % / qa 20 %, three services each, 200 +players, 5 % ERROR. `bin/lab.py` โ€” the ClickStack objects and the scenario roles through the Cloud +API (`/clickstack/sources`, `/saved-searches`, `/dashboards`, `/alerts`, `/webhooks`, `/roles`). + +### Screens + +Round 1 (one Admin user, Seoul service): + +| | | +|---|---| +| ![Admin search, all three games](images/01-admin-search-all-games.webp) | ![Role editor, Tag is game:alpha](images/02-role-editor-tag-rule.webp) | +| 01 โ€” before any policy: alpha, bravo, charlie | 02 โ€” a tag rule set through the API, shown by the role editor | +| ![Role editor without a data section](images/03-role-editor-no-row-section.webp) | ![Blanket policy, alpha only](images/04-rowpolicy-all-search-alpha-only.webp) | +| 03 โ€” the same role: no section for its row condition | 04 โ€” `TO ALL EXCEPT default`: alpha only | +| ![Per-user policy adds bravo](images/05-rowpolicy-jwt-user-plus-bravo.webp) | ![SQL tile under policy](images/06-dashboard-sql-tile-under-policy.webp) | +| 05 โ€” plus a policy on this user's JWT identity: alpha + bravo | 06 โ€” an SQL tile with no WHERE, same policies: no charlie | +| ![Alerts under the policy](images/07-alerts-filtered-by-policy.webp) | ![Alerts with the worker excluded](images/09-alerts-leak-alert-user-excluded.webp) | +| 07 โ€” alert worker inside the policy: bravo and charlie OK | 09 โ€” alert worker excluded: bravo and charlie fire | +| ![Lambda masking error](images/08-masking-lambda-error.webp) | ![Map masking leak](images/10-masking-map-subcolumn-leak.webp) | +| 08 โ€” a lambda in a masking policy breaks the search | 10 โ€” row detail masked, the `client.ip` column not | +| ![Role-based policy, no effect](images/11-rolebased-policy-no-effect.webp) | | +| 11 โ€” a policy on the SQL console roles: all three games still | | + +Round 2 (Admin and test user, trial organization): + +| | | +|---|---| +| ![Row-condition role refused](images/r2-11-team-alpha-refused.webp) | ![No Team read](images/r2-02-no-team-read-unauthorized.webp) | +| r2-11 โ€” a role with a row condition: "Not allowed to assign a role with privileges exceeding your own" | r2-02 โ€” a custom role without Team read: the app is one `Unauthorized` | +| ![Read-only dashboard](images/r2-03-types-dashboard-readonly.webp) | ![Alerts denied](images/r2-05-types-alerts-denied.webp) | +| r2-03 โ€” resource level read: no Alerts menu, read-only dashboard | r2-05 โ€” Alerts no access: "You do not have permission to view alerts" | +| ![Tag rule, alpha only](images/r2-06-tag-rule-alpha-only.webp) | ![Tag rule, bravo by URL](images/r2-07-tag-rule-bravo-dashboard-blocked.webp) | +| r2-06 โ€” tag rule `game:alpha`: one dashboard listed | r2-07 โ€” the bravo dashboard by URL: "Dashboard Not Available" | +| ![Tag rule, bravo rows](images/r2-08-tag-rule-search-bravo-rows.webp) | ![Per-user policy, test user](images/r2-09-peruser-policy-user-alpha.webp) | +| r2-08 โ€” same user, same role: `Game:"bravo"` returns 7,039 rows | r2-09 โ€” per-user row policy: the test user sees alpha only (7,036) | +| ![Per-user policy, Admin](images/r2-10-peruser-policy-admin-all.webp) | ![Alerts for the restricted user](images/r2-12-peruser-alerts-all-games.webp) | +| r2-10 โ€” same range, the Admin: three games (21,109) | r2-12 โ€” the restricted user still sees every alert; charlie firing | +| ![Charlie search, zero](images/r2-13-peruser-search-charlie-zero.webp) | ![SQL editor, alpha only](images/r2-14-peruser-sql-console-alpha.webp) | +| r2-13 โ€” and cannot search charlie's errors: 0 rows | r2-14 โ€” console SQL editor as the same user: alpha only, via the JWT identity | + +--- + +## ํ•œ๊ตญ์–ด + +์—ฌ๋Ÿฌ ํŒ€์ด ClickStack์„ ๊ฐ™์ด ์“ธ ๋•Œ ๊ผญ ๋‚˜์˜ค๋Š” ์งˆ๋ฌธ์ž…๋‹ˆ๋‹ค. *๊ฒŒ์ž„๋ณ„๋กœ, ๋˜๋Š” ์‚ฌ๋žŒ๋ณ„๋กœ ์ž๊ธฐ ๋กœ๊ทธ๋งŒ +๋ณด๊ฒŒ ํ•˜๋ ค๋ฉด ์–ด๋””๊นŒ์ง€ ๋‚˜๋ˆŒ ์ˆ˜ ์žˆ๋‚˜์š”? ๋Œ€์‹œ๋ณด๋“œ, ๊ฒ€์ƒ‰, ์•Œ๋ฆผ ๋ชจ๋‘์š”.* ์ด ์‹ค์Šต์€ ์„ธ ๊ฒŒ์ž„์ด ํ•จ๊ป˜ +์“ฐ๋Š” ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ•˜๋‚˜๋ฅผ ๋งŒ๋“ค๊ณ , Managed ClickStack ์‚ฌ์šฉ์ž๊ฐ€ ๋ฌด์—‡์„ ๋ณด๋Š”์ง€ ์ •ํ•˜๋Š” ์ธต์„ ํ•˜๋‚˜์”ฉ +์‹œํ—˜ํ•ฉ๋‹ˆ๋‹ค. ClickStack ์—ญํ• , ClickStack์ด ์ฟผ๋ฆฌํ•  ๋•Œ ์“ฐ๋Š” ClickHouse ์‹ ์›, row policy, +masking policy์ž…๋‹ˆ๋‹ค. + +> **๊ฒ€์ฆ ํ™˜๊ฒฝ** 2026-11-07, ๋ฐ์ดํ„ฐ๋Š” ๋ชจ๋‘ ํ•ฉ์„ฑ, ๋‘ ์ฐจ๋ก€. +> 1์ฐจ: ClickHouse Cloud `26.8.1.608`(ap-northeast-2), ClickStack UI `hyperdx-chc 2.43.1`, +> ์•Œ๋ฆผ ์›Œ์ปค `hyperdx-chc-alerts 2.42.0`, 04:36โ€“04:53 UTC, ClickStack ์‚ฌ์šฉ์ž ํ•œ ๋ช…(Admin). +> 2์ฐจ: ์ƒˆ trial ์กฐ์ง, ClickHouse Cloud `26.6.1.2455`(ap-northeast-2, replica 1), ๊ฐ™์€ ClickStack +> ๋ฒ„์ „, 05:44โ€“06:33 UTC, **์‚ฌ์šฉ์ž ๋‘ ๋ช…** โ€” Admin๊ณผ, ๋‹จ๊ณ„๋งˆ๋‹ค ClickStack ์—ญํ• ์„ ๋ฐ”๊พผ ์‹œํ—˜ ์‚ฌ์šฉ์ž. +> ๋‘ ์‚ฌ๋žŒ์€ ๊ฐ์ž ๋‹ค๋ฅธ ๋ธŒ๋ผ์šฐ์ € ํ”„๋กœํ•„์—์„œ ์ ‘์†ํ–ˆ์Šต๋‹ˆ๋‹ค. +> ๊ทผ๊ฑฐ๋Š” `system.query_log`ยท`system.session_log` ํ–‰๊ณผ [`images/`](images/)์˜ ํ™”๋ฉด์ด๊ณ , +> [`results/2026-11-07-managed.md`](results/2026-11-07-managed.md)์— ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +> +> **๋Œ๋ฆฌ์ง€ ์•Š์€ ๊ฒƒ:** ์‹œํ—˜ ์‚ฌ์šฉ์ž์—๊ฒŒ `objects-id` ์—ญํ• ๊ณผ ์‹œ์Šคํ…œ `ReadOnly` ์—ญํ•  ๋ฐฐ์ •, 2์ฐจ์˜ +> masking(1์ฐจ๋งŒ), ์ƒˆ๋กœ ๋กœ๊ทธ์ธํ•  ๋•Œ JWT ์ด๋ฆ„์˜ ํ•ด์‹œ๊ฐ€ ๋ฐ”๋€Œ๋Š”์ง€. + +### ๊ฒฐ๊ณผ + +| ์ธต | ๋‚˜๋ˆŒ ์ˆ˜ ์žˆ๋Š” ๊ฒƒ | ๊ฒ€์ƒ‰ | ๋Œ€์‹œ๋ณด๋“œ | ์•Œ๋ฆผ | ํ™•์ธ ๋ฐฉ๋ฒ• | +|---|---|---|---|---|---| +| Cloud ์ฝ˜์†” | ClickStack์„ ์—ด ์ˆ˜ ์žˆ๋Š”์ง€(์„œ๋น„์Šค๋ณ„ SQL console ์ ‘๊ทผ ์—†์Œ / ์ฝ๊ธฐ ์ „์šฉ / ๊ด€๋ฆฌ์ž) | โ€” | โ€” | ์•Œ๋ฆผ ์ƒ์„ฑ์€ ๊ด€๋ฆฌ์ž ํ•„์š” | ๋ฌธ์„œ๋งŒ | +| ClickStack ์—ญํ•  โ€” ๋ฆฌ์†Œ์Šค ๋‹จ์œ„ | ๋Œ€์‹œ๋ณด๋“œ, ์ €์žฅ ๊ฒ€์ƒ‰, ์†Œ์Šค, ์›นํ›…, ์•Œ๋ฆผ, ๋…ธํŠธ๋ถ: ์ ‘๊ทผ ์—†์Œ / ์ฝ๊ธฐ / ๊ด€๋ฆฌ | ์†Œ์Šค ๋‹จ์œ„ | ์ฝ๊ธฐ ์ „์šฉ ๋Œ€์‹œ๋ณด๋“œ("You have read-only access") | **์ ‘๊ทผ ์—†์Œ์ด๋ฉด ๋ฉ”๋‰ด๊ฐ€ ์‚ฌ๋ผ์ง**, `/alerts`๋Š” "You do not have permission to view alerts" | ์‹œํ—˜ ์‚ฌ์šฉ์ž, 2์ฐจ | +| ClickStack ์—ญํ•  โ€” ์„ธ๋ถ€ ๊ทœ์น™ | ๋Œ€์‹œ๋ณด๋“œ, ์ €์žฅ ๊ฒ€์ƒ‰, ์†Œ์Šค๋ฅผ **์ด๋ฆ„ยทํƒœ๊ทธยทID**๋กœ(`is`/`contains`), ๊ทœ์น™๋ผ๋ฆฌ OR | **ํ–‰์€ ์•ˆ ๊ฑธ๋Ÿฌ์ง**: ํ—ˆ์šฉ๋œ ์†Œ์Šค์—์„œ bravo ํ–‰์ด ๊ทธ๋Œ€๋กœ ๋‚˜์˜ด | ํƒœ๊ทธ ๋ถ™์€ ๋Œ€์‹œ๋ณด๋“œ๋งŒ ๋ชฉ๋ก์— ๋‚˜์˜ค๊ณ , ๋‹ค๋ฅธ ๋Œ€์‹œ๋ณด๋“œ๋Š” URL๋กœ ์—ด์–ด๋„ "Dashboard Not Available" | ์•Œ๋ฆผ๋ณ„ ๊ทœ์น™ ์—†์Œ | ์‹œํ—˜ ์‚ฌ์šฉ์ž, 2์ฐจ | +| ClickStack ์—ญํ•  โ€” Team ์ฝ๊ธฐ๊ฐ€ ์—†๋Š” ์‚ฌ์šฉ์ž ์ •์˜ ์—ญํ•  | โ€” | ์•ฑ ์ „์ฒด๊ฐ€ `Unauthorized` | ๊ฐ™์Œ | ๊ฐ™์Œ | ์‹œํ—˜ ์‚ฌ์šฉ์ž, 2์ฐจ | +| ClickStack ์—ญํ•  โ€” **ํ–‰ ์กฐ๊ฑด** | `integration: clickhouse`, ์—ฐ๊ฒฐ + ํ…Œ์ด๋ธ” ๋‹จ์œ„, `where`๋Š” SQL ๋˜๋Š” Lucene | โ€” | โ€” | โ€” | API๋Š” ์ €์žฅํ•จ(beta), ํŽธ์ง‘๊ธฐ์—” ๊ทธ ์นธ์ด ์—†๊ณ , **Admin์ด ์‚ฌ๋žŒ์—๊ฒŒ ์ค„ ์ˆ˜ ์—†์Œ**: `Not allowed to assign a role with privileges exceeding your own` | +| ClickHouse row policy, ์—ญํ•  ๋Œ€์ƒ | `TO sql_console_admin, sql_console_read_only` | **ํšจ๊ณผ ์—†์Œ** | ํšจ๊ณผ ์—†์Œ | ํšจ๊ณผ ์—†์Œ | ClickStack ์„ธ์…˜์˜ `roles = []`(1์ฐจ) | +| ClickHouse row policy, ์ „์ฒด ๋Œ€์ƒ | `TO ALL EXCEPT default` | **๊ฑธ๋Ÿฌ์ง** | ๊ฑธ๋Ÿฌ์ง(SQL ํƒ€์ผ ํฌํ•จ) | **๊ฑธ๋Ÿฌ์ง**(์•Œ๋ฆผ ์›Œ์ปค๋„ `ALL`์— ํฌํ•จ) | ๋ชจ๋“  ์ฟผ๋ฆฌ์˜ `used_row_policies`(1์ฐจ) | +| ClickHouse row policy, **์‚ฌ์šฉ์ž ๋Œ€์ƒ** | `TO \`JWT::โ€ฆ<์ด๋ฉ”์ผ>::<ํ•ด์‹œ>\`` | **๊ทธ ์‚ฌ์šฉ์ž๋งŒ**: alpha 7,036ํ–‰, Admin์€ 21,109ํ–‰ | ๊ฐ™์Œ | ์•Œ๋ฆผ์€ ์•ˆ ๊ฑธ๋Ÿฌ์ง: ์ œํ•œ๋œ ์‚ฌ์šฉ์ž๋„ ๋ชจ๋“  ์•Œ๋ฆผ๊ณผ ์ด๋ ฅ์„ ๋ด„ | ์‚ฌ์šฉ์ž ๋‘ ๋ช…, 2์ฐจ | +| ์‚ฌ์šฉ์ž ๋Œ€์ƒ ์ •์ฑ…, ์ฝ˜์†” SQL ํŽธ์ง‘๊ธฐ | ๊ฐ™์€ ์ •์ฑ… | โ€” | โ€” | โ€” | 2์ฐจ ์„œ๋น„์Šค: ํŽธ์ง‘๊ธฐ๋„ ๊ฐ™์€ JWT ์‚ฌ์šฉ์ž๋กœ ๋Œ์•„ alpha๋งŒ. 1์ฐจ ์„œ๋น„์Šค: `sql-console`๋กœ ๋Ž | +| ์•Œ๋ฆผ ์›Œ์ปค๋ฅผ ๋บ€ ๊ฒฝ์šฐ | `โ€ฆ EXCEPT default, \`hdx-alert-internal\`` | ๊ฑธ๋Ÿฌ์ง | ๊ฑธ๋Ÿฌ์ง | **์ „๋ถ€ ๋ด„**: ๋งŒ๋“  ์‚ฌ๋žŒ์ด ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์—†๋Š” ํ–‰์œผ๋กœ ์•Œ๋ฆผ์ด ์šธ๋ฆผ | 1์ฐจ | +| Masking, String ์—ด | `UPDATE Body = replaceRegexpAll(โ€ฆ)` | ๊ฐ€๋ ค์ง | ๊ฐ€๋ ค์ง | โ€” | ์›๋ž˜ ๊ฐ’์œผ๋กœ WHERE: 0ํ–‰(1์ฐจ) | +| Masking, Map ํ‚ค | `UPDATE LogAttributes = mapUpdate(โ€ฆ)` | **ํ–‰ ์ƒ์„ธ๋Š” ๊ฐ€๋ ค์ง€๊ณ  `LogAttributes['client.ip']` ์—ด์€ ์•ˆ ๊ฐ€๋ ค์ง** | ๊ฐ™์Œ | โ€” | ์„œ๋ธŒ์ปฌ๋Ÿผ ์ฝ๊ธฐ๊ฐ€ ๋งˆ์Šคํฌ๋ฅผ ๊ฑด๋„ˆ๋œ€. ์›๋ž˜ ๊ฐ’์œผ๋กœ WHEREํ•˜๋ฉด 57,920ํ–‰(1์ฐจ) | +| ๋žŒ๋‹ค๋ฅผ ์“ด masking | `mapApply((k, v) -> โ€ฆ)` | **๋ชจ๋“  ์ฟผ๋ฆฌ ์‹คํŒจ** | ์‹คํŒจ | โ€” | `There is no column k in table`(1์ฐจ) | + +์ •๋ฆฌํ•˜๋ฉด: + +1. **ClickStack ์—ญํ• ์€ ํ™”๋ฉด์„ ๋‚˜๋ˆ„์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ๋‚˜๋ˆ„์ง€ ์•Š์Šต๋‹ˆ๋‹ค.** ํƒœ๊ทธ ๊ทœ์น™์€ ๋‹ค๋ฅธ ๊ฒŒ์ž„์˜ ๋Œ€์‹œ๋ณด๋“œ๋ฅผ + ๋ชฉ๋ก์—์„œ๋„, URL๋กœ๋„ ๋ง‰์•˜์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ๊ฐ™์€ ์‚ฌ์šฉ์ž๊ฐ€ ์ฝ์„ ์ˆ˜ ์žˆ๋Š” ์†Œ์Šค ํ•˜๋‚˜๋กœ ๊ฒ€์ƒ‰ํ•˜์ž bravo + ํ–‰์ด ๊ทธ๋Œ€๋กœ ๋‚˜์™”์Šต๋‹ˆ๋‹ค. ๊ฒŒ์ž„๋ณ„๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๋‚˜๋ˆ„๋ ค๋ฉด ์•„๋ž˜ DB ์ธต์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. +2. **์—ญํ•  ํ–‰ ์กฐ๊ฑด์€ ์•„์ง ์“ธ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.** Cloud API๋Š” ๋ฐ›์•„์„œ ์ €์žฅํ•˜์ง€๋งŒ, ์—ญํ•  ํŽธ์ง‘๊ธฐ์—๋Š” ๋ณด์ด์ง€ + ์•Š๊ณ , Admin์ด ๊ทธ ์—ญํ• ์„ ์‚ฌ๋žŒ์—๊ฒŒ ์ฃผ๋ ค ํ•˜์ž ๊ฑฐ๋ถ€๋์Šต๋‹ˆ๋‹ค. +3. **row policy๋Š” ์‚ฌ์šฉ์ž๋ณ„๋กœ๋„ ๋ฉ๋‹ˆ๋‹ค. ์—ญํ• ์ด ์•„๋‹ˆ๋ผ JWT ์‹ ์›์„ ๊ฑฐ์นฉ๋‹ˆ๋‹ค.** ๊ฒ€์ƒ‰ยท๋Œ€์‹œ๋ณด๋“œ ์ฟผ๋ฆฌ๋Š” + ์‚ฌ์šฉ์ž๋งˆ๋‹ค ๋‹ค๋ฅธ `JWT::โ€ฆ<์ด๋ฉ”์ผ>::<ํ•ด์‹œ>` ์‚ฌ์šฉ์ž๋กœ ๋Œ๊ณ , ์ด ์‚ฌ์šฉ์ž์—๊ฒŒ๋Š” **์—ญํ• ์ด ์—†์Šต๋‹ˆ๋‹ค**. + ์•Œ๋ฆผ ์ฟผ๋ฆฌ๋Š” ๊ณต์šฉ ์‚ฌ์šฉ์ž ํ•˜๋‚˜(`hdx-alert-internal` / `hyperdx-alert-internal`, ์—ญํ•  + `hdx_alert_role`)๋กœ ๋•๋‹ˆ๋‹ค. SQL console ์—ญํ• ์— ๊ฑด ์ •์ฑ…์€ ClickStack์— ๋‹ฟ์ง€ ์•Š์•˜๊ณ , JWT ์‚ฌ์šฉ์ž + ํ•˜๋‚˜๋ฅผ ์ง€์ •ํ•œ ์ •์ฑ…์€ ์ •ํ™•ํžˆ ๊ทธ ์‚ฌ๋žŒ์—๊ฒŒ๋งŒ ๊ฑธ๋ ธ์Šต๋‹ˆ๋‹ค. ๋‚˜๋ž€ํžˆ ๋†“๊ณ  ๋ณด๋‹ˆ ์‹œํ—˜ ์‚ฌ์šฉ์ž๋Š” alpha๋งŒ, + Admin์€ ์„ธ ๊ฒŒ์ž„์„ ๋‹ค ๋ดค์Šต๋‹ˆ๋‹ค. 2์ฐจ ์„œ๋น„์Šค์—์„œ๋Š” ์ฝ˜์†” SQL ํŽธ์ง‘๊ธฐ๋„ ๊ฐ™์€ JWT ์‚ฌ์šฉ์ž๋กœ ๋Œ์•„ ๊ฐ™์ด + ๊ฑธ๋ ธ์Šต๋‹ˆ๋‹ค. JWT ์ด๋ฆ„์€ ๊ทธ ์‚ฌ๋žŒ์ด ์ฒ˜์Œ ๋กœ๊ทธ์ธํ•œ ๋’ค์—์•ผ ์ƒ๊ธฐ๊ณ (๊ทธ ์ „์—๋Š” `UNKNOWN_ROLE`), ๋ชจ์–‘์ด + ์„œ๋น„์Šค๋งˆ๋‹ค ๋‹ค๋ฆ…๋‹ˆ๋‹ค(2์ฐจ๋Š” ์„œ๋น„์Šค ID๊ฐ€ ๋“ค์–ด๊ฐ). +4. **์•Œ๋ฆผ ๋ฒ”์œ„๋Š” ํŒ€ ์ „์ฒด์— ํ•˜๋‚˜์ž…๋‹ˆ๋‹ค.** ์•Œ๋ฆผ ์›Œ์ปค๊ฐ€ ์‚ฌ์šฉ์ž ํ•˜๋‚˜๋ผ์„œ ์‚ฌ์šฉ์ž๋ณ„ ์ •์ฑ…์€ ์•Œ๋ฆผ์— ๊ฑธ๋ฆฌ์ง€ + ์•Š์Šต๋‹ˆ๋‹ค. alpha๋กœ ์ œํ•œ๋œ ์‹œํ—˜ ์‚ฌ์šฉ์ž๋„ ์•Œ๋ฆผ ๋ชฉ๋ก์„ ๋‹ค ๋ดค๊ณ , charlie ์•Œ๋ฆผ์ด ์šธ๋ฆฌ๋Š” ๊ฒƒ๊ณผ ๊ทธ ์ด๋ ฅ๋„ + ๋ดค์Šต๋‹ˆ๋‹ค. ์•Œ๋ฆผ ์›Œ์ปค๋ฅผ ํฌํ•จํ•œ ์ „์ฒด ๋Œ€์ƒ ์ •์ฑ…์€ ๋ชจ๋“  ์•Œ๋ฆผ์„ ๋˜‘๊ฐ™์ด ๊ฑฐ๋ฅด๊ณ , ์›Œ์ปค๋ฅผ ๋นผ๋ฉด ์•Œ๋ฆผ์€ + ์ „๋ถ€๋ฅผ ๋ด…๋‹ˆ๋‹ค. +5. **์‚ฌ์šฉ์ž๋ณ„ ์ •์ฑ…์€ ํ—ˆ์šฉ ๋ชฉ๋ก์œผ๋กœ ์งœ์„ธ์š”.** ์ •์ฑ…์ด ์—†๋Š” ์‚ฌ์šฉ์ž๋Š” ๋‹ค๋ฅธ ์‚ฌ๋žŒ์˜ ์ •์ฑ…์— ๋ฌถ์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + ์œ„์˜ Admin์ด ์ „๋ถ€๋ฅผ ๋ณธ ๊ฒƒ์ฒ˜๋Ÿผ์š”. ๋ฏผ๊ฐํ•˜์ง€ ์•Š์€ ๊ฒƒ๋งŒ ์ฃผ๋Š” ์ „์ฒด ๋Œ€์ƒ ์ •์ฑ…์œผ๋กœ ์‹œ์ž‘ํ•ด, ์‚ฌ๋žŒ์ด๋‚˜ + ํŒ€๋งˆ๋‹ค ์ •์ฑ…์„ ํ•˜๋‚˜์”ฉ ๋”ํ•˜์„ธ์š”. +6. **๊ฐ€๋ฆด ๊ฐ’์€ Map ํ‚ค๊ฐ€ ์•„๋‹ˆ๋ผ String ์—ด์— ๋‘์„ธ์š”.** String ์—ด์˜ ๋งˆ์Šคํฌ๋Š” ๊ฒฐ๊ณผ์™€ WHERE ๋ชจ๋‘์—์„œ + ์œ ์ง€๋์Šต๋‹ˆ๋‹ค. Map ํ‚ค์˜ ๋งˆ์Šคํฌ๋Š” ๋งต ์ „์ฒด๋ฅผ ์ฝ์„ ๋•Œ๋งŒ ์ ์šฉ๋์Šต๋‹ˆ๋‹ค. masking ์‹์— ๋žŒ๋‹ค๋ฅผ ์“ฐ๋ฉด ๊ทธ + ์‚ฌ์šฉ์ž์˜ ์ฟผ๋ฆฌ๊ฐ€ ๋ชจ๋‘ ์‹คํŒจํ•ฉ๋‹ˆ๋‹ค. +7. **์‚ฌ์šฉ์ž ์ •์˜ ์—ญํ• ์—๋Š” Team ์ฝ๊ธฐ๋ฅผ ๊ผญ ๋„ฃ์œผ์„ธ์š”.** ์—†์œผ๋ฉด ๊ทธ ์‚ฌ์šฉ์ž์˜ ClickStack์€ + `Unauthorized` ํ•œ ์žฅ๋ฟ์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž ์ •์˜ ์—ญํ• ์€ ์‚ฌ์šฉ์ž์— ๋Œ€ํ•œ `manage`๋ฅผ ๊ฐ€์งˆ ์ˆ˜ ์—†๊ณ  + `manage all`๋„ ๊ฑฐ๋ถ€๋˜๋ฏ€๋กœ, ์‚ฌ๋žŒ์˜ ์—ญํ• ์„ ๋ฐ”๊พธ๋Š” ๊ฒƒ์€ ์‹œ์Šคํ…œ Admin๋งŒ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์—ญํ• ์€ ํŒ€ + ์ „์ฒด ๋‹จ์œ„(ํ•œ ์กฐ์ง์˜ ์„œ๋น„์Šค ๋‘ ๊ฐœ๊ฐ€ ๊ฐ™์€ ์—ญํ•  ID)์ด๊ณ , ์†Œ์Šค์™€ ๋Œ€์‹œ๋ณด๋“œ๋Š” ์„œ๋น„์Šค ๋‹จ์œ„์ž…๋‹ˆ๋‹ค. + +### ๋กœ๋“œ๋งต(๊ณต๊ฐœ๋œ ๋‚ด์šฉ, 2026-11-07 ์ฝ์Œ) + +- RBAC ๋ฐœํ‘œ ๊ธ€(2026-04-01)์€ ํ–‰ ๊ธฐ๋ฐ˜ ์ ‘๊ทผ ์ œ์–ด๋ฅผ ๊ณ„ํš ์ค‘์ด๊ณ , ์—ญํ• ์— ํ•„ํ„ฐ ์กฐ๊ฑด์„ ๋‹ด์„ ์ˆ˜ ์žˆ๊ฒŒ + ํ•˜๊ฒ ๋‹ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค. +- Cloud OpenAPI(2026-11-07 ์ฝ์Œ)์˜ `POST โ€ฆ/clickstack/roles`์—๋Š” ์ด๋ฏธ ๊ทธ ๋ชจ์–‘์ด ์žˆ์Šต๋‹ˆ๋‹ค. + `integration: clickhouse`์— `conditions: {connection, from: {databaseName, tableName}, where, + whereLanguage}`, ์„ค๋ช…์€ "row-level access", ์—”๋“œํฌ์ธํŠธ๋Š” beta์ž…๋‹ˆ๋‹ค. 2026-11-07 ํ˜„์žฌ ์—ญํ•  + ํŽธ์ง‘๊ธฐ์—๋Š” ๋ณด์ด์ง€ ์•Š๊ณ , Admin์ด ๊ทธ ์—ญํ• ์„ ์‚ฌ๋žŒ์—๊ฒŒ ์ค„ ์ˆ˜๋„ ์—†์—ˆ์Šต๋‹ˆ๋‹ค. + +### ์‹คํ–‰ + +ClickStack์ด ์ผœ์ง„ ClickHouse Cloud ์„œ๋น„์Šค, ๊ทธ ์„œ๋น„์Šค์˜ `default` ๋น„๋ฐ€๋ฒˆํ˜ธ, ์กฐ์ง API ํ‚ค๊ฐ€ +ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋‘ ๋ฒˆ์งธ ์‚ฌ์šฉ์ž ํ™•์ธ์—๋Š” ๋‹ค๋ฅธ ๋ธŒ๋ผ์šฐ์ € ํ”„๋กœํ•„์˜ ๋‘ ๋ฒˆ์งธ ๋กœ๊ทธ์ธ, ๊ทธ๋ฆฌ๊ณ  Team +Settings์—์„œ ๊ทธ ์‚ฌ๋žŒ์˜ ์—ญํ• ์„ ๋ฐ”๊ฟ€ ClickStack Admin์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋ช…๋ น์€ ์œ„ English ์ ˆ๊ณผ +๊ฐ™์Šต๋‹ˆ๋‹ค. ๊ฐ ๋‹จ๊ณ„ ๋’ค์—๋Š” ์‹œ๊ฐ„ ๋ฒ”์œ„๋ฅผ ์ƒˆ๋กœ ์žก์•„ ๊ฒ€์ƒ‰ํ•ด์•ผ ์บ์‹œ๋œ ๊ฒฐ๊ณผ๋ฅผ ๋ณด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. diff --git a/labs/managed-rbac/bin/lab.py b/labs/managed-rbac/bin/lab.py new file mode 100644 index 0000000..5698e2f --- /dev/null +++ b/labs/managed-rbac/bin/lab.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""How finely Managed ClickStack (ClickHouse Cloud) can split access โ€” stdlib only. + +One shared log table for three games (sql/schema.sql), the ClickStack objects a game team would +have (a source, per-game saved searches and dashboards, alerts), and a custom ClickStack role, +`rbac-lab-self`, whose permissions each scenario rewrites through the Cloud API. The person +running the lab assigns that role to themself once in Team Settings; every scenario after that +is one API call, then a look at search, dashboards and alerts (captures) and at system.query_log +(what ClickHouse actually ran, as which user). + + lab.py setup table + rows + source, saved searches, dashboards, webhook, alerts + lab.py role rewrite rbac-lab-self's permissions (list: lab.py role) + lab.py feed [minutes] insert fresh rows every 30 s so alerts have data (default 20) + lab.py probe [minutes] what ClickStack ran in the last N minutes on rbac_lab, by user + lab.py alerts alert states and recent history + lab.py cleanup role back to full, then delete every lab object and the database + +The role always keeps `manage` on Team, User and Role, so the person can switch back to Admin +in Team Settings at any point; `role full` restores Admin-equivalent access from the API alone. + +CH env: CH_HOST, CH_USER, CH_PASSWORD. API env: CHC_KEY, CHC_SECRET. Values are never printed. +""" +import base64 +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +LAB = os.path.dirname(HERE) +API = "https://api.clickhouse.cloud/v1" +DB, TABLE = "rbac_lab", "otel_logs" +PREFIX = "rbac-lab" +ROLE = "rbac-lab-self" +GAMES = ["alpha", "bravo", "charlie"] +SOURCE = "RBAC Lab Logs" + + +def load_env(path): + env = {} + for line in open(os.path.expanduser(path)): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + env[k.removeprefix("export ").strip()] = v.strip().strip('"').strip("'") + return env + + +def basic(u, p): + return "Basic " + base64.b64encode(f"{u}:{p}".encode()).decode() + + +class CH: + def __init__(self, env): + self.host = env["CH_HOST"] + self.auth = basic(env["CH_USER"], env["CH_PASSWORD"]) + + def q(self, sql, params=None, **settings): + qs = dict(settings) + for k, v in (params or {}).items(): + qs[f"param_{k}"] = v + url = f"https://{self.host}:8443/?" + urllib.parse.urlencode(qs) + req = urllib.request.Request(url, data=sql.encode(), headers={"Authorization": self.auth}) + try: + with urllib.request.urlopen(req, timeout=180) as r: + return r.read().decode() + except urllib.error.HTTPError as e: + raise SystemExit(f"ClickHouse HTTP {e.code}: {e.read()[:800].decode(errors='replace')}") + + def rows(self, sql, params=None): + out = self.q(sql + " FORMAT JSONEachRow", params) + return [json.loads(x) for x in out.splitlines() if x.strip()] + + +class Cloud: + def __init__(self, env, ch_host): + self.auth = basic(env["CHC_KEY"], env["CHC_SECRET"]) + self.org = self.call("GET", "/organizations")[0]["id"] + svc = next(s for s in self.call("GET", f"/organizations/{self.org}/services") + if any(e.get("host") == ch_host for e in s.get("endpoints", []))) + self.base = f"/organizations/{self.org}/services/{svc['id']}/clickstack" + + def call(self, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(API + path, data=data, method=method, + headers={"Authorization": self.auth, "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + except urllib.error.HTTPError as e: + raise SystemExit(f"{method} {path}: HTTP {e.code}: {e.read()[:800].decode(errors='replace')}") + d = json.loads(raw) if raw else {} + return d.get("result", d) + + def cs(self, method, path, body=None): + return self.call(method, self.base + path, body) + + +def mongo(action, subject, conditions=None, inverted=False): + p = {"action": action, "subject": subject, "integration": "mongodb", "inverted": inverted, "fields": []} + if conditions: + p["conditions"] = conditions + return p + + +def rows_where(connection, where, language="sql"): + return {"action": "read", "subject": "all", "integration": "clickhouse", + "conditions": {"connection": connection, "from": {"databaseName": DB, "tableName": TABLE}, + "where": where, "whereLanguage": language}} + + +# What the person keeps in every scenario: enough to open Team Settings and switch back to Admin. +# The API refuses create/update/delete on User for any custom role ("Only the Admin role can invite, +# remove, or re-role team members"), so a custom role can never hand itself back Admin. +KEEP = [mongo("read", "User")] +# 2026-11-07 round 2: a role without Team read makes the whole ClickStack app answer +# "Unauthorized" for its user, so every scenario carries it (KEEP_TEAM). +KEEP_TEAM = KEEP + [mongo("read", "Team")] + + +def scenarios(conn, ids): + """Each scenario: (description, permissions). ids maps object names to their IDs.""" + # "manage all" is refused for a custom role (it covers User), so "full" lists the subjects. + full = KEEP + [mongo("manage", x) for x in + ("Alert", "Connection", "Dashboard", "SavedSearch", "Source", "Team", "Webhook", "Notebook")] + by_tag = lambda subj, tag: mongo("read", subj, {"tags": tag}) # noqa: E731 + return { + "full": ("Everything an Admin has except re-roling users (manage all is refused)", full), + "types-noteam": ("Resource-type levels without Team read (the app refuses the user)", + KEEP + [mongo("read", "Dashboard"), mongo("read", "SavedSearch"), mongo("read", "Source"), + mongo("read", "Connection")]), + "types": ("Resource-type levels: dashboards/saved searches/sources read, alerts/webhooks/notebooks none", + KEEP_TEAM + [mongo("read", "Dashboard"), mongo("read", "SavedSearch"), mongo("read", "Source"), + mongo("read", "Connection")]), + "objects-alpha": ("Fine-grained objects: only what is tagged game:alpha, the lab source by name", + KEEP_TEAM + [by_tag("Dashboard", "game:alpha"), by_tag("SavedSearch", "game:alpha"), + mongo("read", "Source", {"name": SOURCE}), mongo("read", "Connection")]), + "objects-id": ("Fine-grained by ID: the bravo dashboard only", + KEEP_TEAM + [mongo("read", "Dashboard", {"_id": ids.get("dash:bravo", "")}), + mongo("read", "Source", {"name": SOURCE}), mongo("read", "Connection")]), + "rows-alpha": ("Row condition Game = 'alpha' (SQL), everything else manage", + full + [rows_where(conn, "Game = 'alpha'")]), + "rows-two": ("Two row conditions: alpha, or bravo in qa (are rules OR-ed?)", + full + [rows_where(conn, "Game = 'alpha'"), rows_where(conn, "Game = 'bravo' AND Env = 'qa'")]), + "rows-player": ("Per-player rows, Lucene condition: two player IDs", + full + [rows_where(conn, 'LogAttributes.player.id:"p-0001" OR LogAttributes.player.id:"p-0002"', + "lucene")]), + "team-alpha": ("A realistic team role: alpha objects (read) + alpha rows + manage alerts", + KEEP_TEAM + [by_tag("Dashboard", "game:alpha"), by_tag("SavedSearch", "game:alpha"), + mongo("read", "Source", {"name": SOURCE}), mongo("read", "Connection"), + mongo("manage", "Alert"), mongo("read", "Webhook"), + rows_where(conn, "Game = 'alpha'")]), + } + + +def connection_id(cloud): + src = next(s for s in cloud.cs("GET", "/sources") if s.get("connection")) + return src["connection"] + + +def lab_ids(cloud): + ids = {} + for s in cloud.cs("GET", "/sources"): + if s["name"] == SOURCE: + ids["source"] = s["id"] + for s in as_list(cloud.cs("GET", "/saved-searches")): + if s["name"].startswith(PREFIX): + ids["search:" + s["name"].split(" ยท ")[1]] = s["id"] + for d in cloud.cs("GET", "/dashboards"): + if d["name"].startswith(PREFIX): + ids["dash:" + d["name"].split(" ยท ")[1]] = d["id"] + return ids + + +def as_list(x): + return x.get("data", x) if isinstance(x, dict) else x + + +def setup(ch, cloud): + for stmt in open(os.path.join(LAB, "sql", "schema.sql")).read().split(";"): + if "CREATE" in stmt: + ch.q(stmt) + have = int(ch.q(f"SELECT count() FROM {DB}.{TABLE}").strip()) + if have == 0: + ch.q(open(os.path.join(LAB, "sql", "rows.sql")).read(), {"n": 172800, "span_s": 86400, "seed": "hist"}) + print("rows:", ch.q(f"SELECT count() FROM {DB}.{TABLE}").strip()) + conn = connection_id(cloud) + src = next((s for s in cloud.cs("GET", "/sources") if s["name"] == SOURCE), None) + if not src: + src = cloud.cs("POST", "/sources", { + "name": SOURCE, "kind": "log", "connection": conn, "from": {"databaseName": DB, "tableName": TABLE}, + "timestampValueExpression": "Timestamp", "displayedTimestampValueExpression": "Timestamp", + "defaultTableSelectExpression": "Timestamp, Game, Env, ServiceName, SeverityText, Body", + "serviceNameExpression": "ServiceName", "severityTextExpression": "SeverityText", + "bodyExpression": "Body", "eventAttributesExpression": "LogAttributes", + "resourceAttributesExpression": "ResourceAttributes", "traceIdExpression": "TraceId", + "spanIdExpression": "SpanId", "implicitColumnExpression": "Body"}) + print("source:", src["id"]) + searches = {s["name"]: s for s in as_list(cloud.cs("GET", "/saved-searches"))} + dashes = {d["name"]: d for d in cloud.cs("GET", "/dashboards")} + for g in GAMES: + name = f"{PREFIX} ยท {g} ยท errors" + if name not in searches: + searches[name] = cloud.cs("POST", "/saved-searches", { + "name": name, "sourceId": src["id"], "where": f'Game:"{g}" SeverityText:"ERROR"', + "whereLanguage": "lucene", "tags": [f"game:{g}", PREFIX], + "select": "Timestamp, Game, Env, ServiceName, SeverityText, Body, LogAttributes['player.id']"}) + dname = f"{PREFIX} ยท {g}" + if dname not in dashes: + dashes[dname] = cloud.cs("POST", "/dashboards", {"name": dname, "tags": [f"game:{g}", PREFIX], "tiles": [ + {"name": "Logs by service", "x": 0, "y": 0, "w": 12, "h": 4, + "config": {"displayType": "line", "sourceId": src["id"], "groupBy": "ServiceName", + "select": [{"aggFn": "count", "where": f'Game:"{g}"', "whereLanguage": "lucene"}]}}, + {"name": "Errors by environment", "x": 12, "y": 0, "w": 12, "h": 4, + "config": {"displayType": "line", "sourceId": src["id"], "groupBy": "Env", + "select": [{"aggFn": "count", "where": f'Game:"{g}" SeverityText:"ERROR"', + "whereLanguage": "lucene"}]}}, + {"name": "SQL tile: rows per game (all games, no filter)", "x": 0, "y": 4, "w": 12, "h": 4, + "config": {"configType": "sql", "displayType": "table", "connectionId": conn, "sourceId": src["id"], + "sqlTemplate": f"SELECT Game, Env, count() AS rows FROM {DB}.{TABLE} " + "WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) " + "AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64}) " + "GROUP BY Game, Env ORDER BY Game, Env"}}, + {"name": "Top players with errors", "x": 12, "y": 4, "w": 12, "h": 4, + "config": {"displayType": "table", "sourceId": src["id"], "groupBy": "LogAttributes['player.id']", + "select": [{"aggFn": "count", "where": f'Game:"{g}" SeverityText:"ERROR"', + "whereLanguage": "lucene"}]}}]}) + print("saved searches:", {k: v["id"] for k, v in searches.items() if k.startswith(PREFIX)}) + print("dashboards:", {k: v["id"] for k, v in dashes.items() if k.startswith(PREFIX)}) + hook = next((w for w in cloud.cs("GET", "/webhooks") if w["name"] == PREFIX), None) + if not hook: + hook = cloud.cs("POST", "/webhooks", {"name": PREFIX, "service": "generic", "url": "https://example.com/rbac-lab", + "description": "labs/managed-rbac; nothing receives this"}) + alerts = {a.get("name"): a for a in cloud.cs("GET", "/alerts")} + for g in GAMES: + name = f"{PREFIX} ยท {g} errors" + if name in alerts: + continue + cloud.cs("POST", "/alerts", { + "savedSearchId": searches[f"{PREFIX} ยท {g} ยท errors"]["id"], "source": "saved_search", "name": name, + "thresholdType": "above", "threshold": 1, "interval": "1m", # "above" = at least (>=) + "channel": {"type": "webhook", "webhookId": hook["id"]}, + "message": "labs/managed-rbac (synthetic data)"}) + print("alerts:", [a for a in [x.get("name") for x in cloud.cs("GET", "/alerts")] if a and a.startswith(PREFIX)]) + + +def set_role(cloud, name, role_name=ROLE): + conn = connection_id(cloud) + table = scenarios(conn, lab_ids(cloud)) + if name == "all": + for k in table: + set_role(cloud, k, f"{PREFIX} ยท {k}") + return + if name not in table: + for k, (desc, _) in table.items(): + print(f" {k:15} {desc}") + return + desc, perms = table[name] + body = {"name": role_name, "description": f"labs/managed-rbac scenario {name}: {desc}"[:250], "permissions": perms} + role = next((r for r in cloud.cs("GET", "/roles") if r["name"] == role_name), None) + role = cloud.cs("PUT", f"/roles/{role['id']}", body) if role else cloud.cs("POST", "/roles", body) + stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + os.makedirs(os.path.join(LAB, "results"), exist_ok=True) + with open(os.path.join(LAB, "results", "role-log.jsonl"), "a") as f: + f.write(json.dumps({"at": stamp, "role": role_name, "scenario": name, + "permissions": role.get("permissions", perms)}) + "\n") + print(f"{stamp} {role_name} = {name}: {desc}") + print(json.dumps(role.get("permissions", perms), indent=1)[:3000]) + + +def feed(ch, minutes): + end = time.time() + minutes * 60 + i = 0 + while time.time() < end: + ch.q(open(os.path.join(LAB, "sql", "rows.sql")).read(), {"n": 60, "span_s": 30, "seed": f"feed{time.time()}"}) + i += 1 + print(time.strftime("%H:%M:%S", time.gmtime()), "inserted 60 rows", flush=True) + time.sleep(30) + + +def probe(ch, minutes): + sql = f"""SELECT event_time, splitByChar(' ', http_user_agent)[1] AS client, + if(user LIKE 'JWT::%', concat('JWT (', splitByString('::', user)[3], ')'), user) AS who, + used_row_policies, Settings['additional_table_filters'] AS atf, read_rows, result_rows, + substring(replaceRegexpAll(query, '\\s+', ' '), 1, 600) AS q +FROM clusterAllReplicas(default, system.query_log) +WHERE event_time > now() - INTERVAL {minutes} MINUTE AND type = 'QueryFinish' + AND http_user_agent LIKE 'hyperdx%' AND has(databases, '{DB}') +ORDER BY event_time DESC LIMIT 40""" + for r in ch.rows(sql): + print(json.dumps(r, ensure_ascii=False)) + + +def alerts(cloud): + for a in cloud.cs("GET", "/alerts"): + if (a.get("name") or "").startswith(PREFIX): + print(a.get("name"), a.get("state"), a.get("id")) + + +def cleanup(ch, cloud): + set_role(cloud, "full") + for a in cloud.cs("GET", "/alerts"): + if (a.get("name") or "").startswith(PREFIX): + cloud.cs("DELETE", f"/alerts/{a['id']}") + for d in cloud.cs("GET", "/dashboards"): + if d["name"].startswith(PREFIX): + cloud.cs("DELETE", f"/dashboards/{d['id']}") + for s in as_list(cloud.cs("GET", "/saved-searches")): + if s["name"].startswith(PREFIX): + cloud.cs("DELETE", f"/saved-searches/{s['id']}") + for w in cloud.cs("GET", "/webhooks"): + if w["name"] == PREFIX: + cloud.cs("DELETE", f"/webhooks/{w['id']}") + for s in cloud.cs("GET", "/sources"): + if s["name"] == SOURCE: + cloud.cs("DELETE", f"/sources/{s['id']}") + ch.q(f"DROP DATABASE IF EXISTS {DB}") + print(f"cleaned up. Switch yourself back to Admin in Team Settings, then: DELETE role {ROLE} (lab.py drop-role)") + + +def main(): + args = sys.argv[1:] + opt = lambda k, d: args[args.index(k) + 1] if k in args else d # noqa: E731 + ch = CH(load_env(opt("--ch-env", "~/.config/khai-harness/secrets/clickhouse-seoul.env"))) + cloud = Cloud(load_env(opt("--api-env", "~/.config/khai-harness/secrets/chc-api.env")), ch.host) + cmd = args[0] if args else "help" + if cmd == "setup": + setup(ch, cloud) + elif cmd == "role": + name = args[1] if len(args) > 1 and not args[1].startswith("--") else "" + set_role(cloud, name, f"{PREFIX} ยท {name}") + elif cmd == "feed": + feed(ch, int(args[1]) if len(args) > 1 else 20) + elif cmd == "probe": + probe(ch, int(args[1]) if len(args) > 1 else 10) + elif cmd == "alerts": + alerts(cloud) + elif cmd == "cleanup": + cleanup(ch, cloud) + elif cmd == "reset-alerts": + for a in cloud.cs("GET", "/alerts"): + if (a.get("name") or "").startswith(PREFIX): + cloud.cs("DELETE", f"/alerts/{a['id']}") + setup(ch, cloud) + elif cmd == "drop-role": + for r in cloud.cs("GET", "/roles"): + if r["name"] == ROLE: + cloud.cs("DELETE", f"/roles/{r['id']}") + print("deleted", ROLE) + else: + print(__doc__) + + +if __name__ == "__main__": + main() diff --git a/labs/managed-rbac/images/01-admin-search-all-games.webp b/labs/managed-rbac/images/01-admin-search-all-games.webp new file mode 100644 index 0000000..2233631 Binary files /dev/null and b/labs/managed-rbac/images/01-admin-search-all-games.webp differ diff --git a/labs/managed-rbac/images/02-role-editor-tag-rule.webp b/labs/managed-rbac/images/02-role-editor-tag-rule.webp new file mode 100644 index 0000000..f09e676 Binary files /dev/null and b/labs/managed-rbac/images/02-role-editor-tag-rule.webp differ diff --git a/labs/managed-rbac/images/03-role-editor-no-row-section.webp b/labs/managed-rbac/images/03-role-editor-no-row-section.webp new file mode 100644 index 0000000..efd3493 Binary files /dev/null and b/labs/managed-rbac/images/03-role-editor-no-row-section.webp differ diff --git a/labs/managed-rbac/images/04-rowpolicy-all-search-alpha-only.webp b/labs/managed-rbac/images/04-rowpolicy-all-search-alpha-only.webp new file mode 100644 index 0000000..6de72a5 Binary files /dev/null and b/labs/managed-rbac/images/04-rowpolicy-all-search-alpha-only.webp differ diff --git a/labs/managed-rbac/images/05-rowpolicy-jwt-user-plus-bravo.webp b/labs/managed-rbac/images/05-rowpolicy-jwt-user-plus-bravo.webp new file mode 100644 index 0000000..46917b8 Binary files /dev/null and b/labs/managed-rbac/images/05-rowpolicy-jwt-user-plus-bravo.webp differ diff --git a/labs/managed-rbac/images/06-dashboard-sql-tile-under-policy.webp b/labs/managed-rbac/images/06-dashboard-sql-tile-under-policy.webp new file mode 100644 index 0000000..16734fd Binary files /dev/null and b/labs/managed-rbac/images/06-dashboard-sql-tile-under-policy.webp differ diff --git a/labs/managed-rbac/images/07-alerts-filtered-by-policy.webp b/labs/managed-rbac/images/07-alerts-filtered-by-policy.webp new file mode 100644 index 0000000..8ce4806 Binary files /dev/null and b/labs/managed-rbac/images/07-alerts-filtered-by-policy.webp differ diff --git a/labs/managed-rbac/images/08-masking-lambda-error.webp b/labs/managed-rbac/images/08-masking-lambda-error.webp new file mode 100644 index 0000000..ae82cfe Binary files /dev/null and b/labs/managed-rbac/images/08-masking-lambda-error.webp differ diff --git a/labs/managed-rbac/images/09-alerts-leak-alert-user-excluded.webp b/labs/managed-rbac/images/09-alerts-leak-alert-user-excluded.webp new file mode 100644 index 0000000..a96c3f4 Binary files /dev/null and b/labs/managed-rbac/images/09-alerts-leak-alert-user-excluded.webp differ diff --git a/labs/managed-rbac/images/10-masking-map-subcolumn-leak.webp b/labs/managed-rbac/images/10-masking-map-subcolumn-leak.webp new file mode 100644 index 0000000..e3952ac Binary files /dev/null and b/labs/managed-rbac/images/10-masking-map-subcolumn-leak.webp differ diff --git a/labs/managed-rbac/images/11-rolebased-policy-no-effect.webp b/labs/managed-rbac/images/11-rolebased-policy-no-effect.webp new file mode 100644 index 0000000..f6d9aa8 Binary files /dev/null and b/labs/managed-rbac/images/11-rolebased-policy-no-effect.webp differ diff --git a/labs/managed-rbac/images/r2-02-no-team-read-unauthorized.webp b/labs/managed-rbac/images/r2-02-no-team-read-unauthorized.webp new file mode 100644 index 0000000..744ca92 Binary files /dev/null and b/labs/managed-rbac/images/r2-02-no-team-read-unauthorized.webp differ diff --git a/labs/managed-rbac/images/r2-03-types-dashboard-readonly.webp b/labs/managed-rbac/images/r2-03-types-dashboard-readonly.webp new file mode 100644 index 0000000..49d8def Binary files /dev/null and b/labs/managed-rbac/images/r2-03-types-dashboard-readonly.webp differ diff --git a/labs/managed-rbac/images/r2-05-types-alerts-denied.webp b/labs/managed-rbac/images/r2-05-types-alerts-denied.webp new file mode 100644 index 0000000..e628247 Binary files /dev/null and b/labs/managed-rbac/images/r2-05-types-alerts-denied.webp differ diff --git a/labs/managed-rbac/images/r2-06-tag-rule-alpha-only.webp b/labs/managed-rbac/images/r2-06-tag-rule-alpha-only.webp new file mode 100644 index 0000000..f5017ba Binary files /dev/null and b/labs/managed-rbac/images/r2-06-tag-rule-alpha-only.webp differ diff --git a/labs/managed-rbac/images/r2-07-tag-rule-bravo-dashboard-blocked.webp b/labs/managed-rbac/images/r2-07-tag-rule-bravo-dashboard-blocked.webp new file mode 100644 index 0000000..b312a70 Binary files /dev/null and b/labs/managed-rbac/images/r2-07-tag-rule-bravo-dashboard-blocked.webp differ diff --git a/labs/managed-rbac/images/r2-08-tag-rule-search-bravo-rows.webp b/labs/managed-rbac/images/r2-08-tag-rule-search-bravo-rows.webp new file mode 100644 index 0000000..d1f7320 Binary files /dev/null and b/labs/managed-rbac/images/r2-08-tag-rule-search-bravo-rows.webp differ diff --git a/labs/managed-rbac/images/r2-09-peruser-policy-user-alpha.webp b/labs/managed-rbac/images/r2-09-peruser-policy-user-alpha.webp new file mode 100644 index 0000000..4a8f414 Binary files /dev/null and b/labs/managed-rbac/images/r2-09-peruser-policy-user-alpha.webp differ diff --git a/labs/managed-rbac/images/r2-10-peruser-policy-admin-all.webp b/labs/managed-rbac/images/r2-10-peruser-policy-admin-all.webp new file mode 100644 index 0000000..7e79687 Binary files /dev/null and b/labs/managed-rbac/images/r2-10-peruser-policy-admin-all.webp differ diff --git a/labs/managed-rbac/images/r2-11-team-alpha-refused.webp b/labs/managed-rbac/images/r2-11-team-alpha-refused.webp new file mode 100644 index 0000000..9736ecc Binary files /dev/null and b/labs/managed-rbac/images/r2-11-team-alpha-refused.webp differ diff --git a/labs/managed-rbac/images/r2-12-peruser-alerts-all-games.webp b/labs/managed-rbac/images/r2-12-peruser-alerts-all-games.webp new file mode 100644 index 0000000..3c0e184 Binary files /dev/null and b/labs/managed-rbac/images/r2-12-peruser-alerts-all-games.webp differ diff --git a/labs/managed-rbac/images/r2-13-peruser-search-charlie-zero.webp b/labs/managed-rbac/images/r2-13-peruser-search-charlie-zero.webp new file mode 100644 index 0000000..eb27b65 Binary files /dev/null and b/labs/managed-rbac/images/r2-13-peruser-search-charlie-zero.webp differ diff --git a/labs/managed-rbac/images/r2-14-peruser-sql-console-alpha.webp b/labs/managed-rbac/images/r2-14-peruser-sql-console-alpha.webp new file mode 100644 index 0000000..9bd7e24 Binary files /dev/null and b/labs/managed-rbac/images/r2-14-peruser-sql-console-alpha.webp differ diff --git a/labs/managed-rbac/lab.yaml b/labs/managed-rbac/lab.yaml new file mode 100644 index 0000000..e06b080 --- /dev/null +++ b/labs/managed-rbac/lab.yaml @@ -0,0 +1,5 @@ +# Runner keys (tools/lab.schema.md). Not run by hol: needs a ClickHouse Cloud service with Managed ClickStack. +target: cloud +tier: T1 +services: [clickhouse-cloud, clickstack] +verified_on: "ClickHouse Cloud 26.8.1.608 (one Admin) and 26.6.1.2455 (Admin + test user), Managed ClickStack hyperdx-chc 2.43.1 (alerts 2.42.0), 2026-11-07" diff --git a/labs/managed-rbac/results/2026-11-07-managed.md b/labs/managed-rbac/results/2026-11-07-managed.md new file mode 100644 index 0000000..e6378a3 --- /dev/null +++ b/labs/managed-rbac/results/2026-11-07-managed.md @@ -0,0 +1,109 @@ +# 2026-11-07 run โ€” Managed ClickStack, Seoul + +ClickHouse Cloud `26.8.1.608`, ap-northeast-2, 2 replicas. ClickStack UI client +`hyperdx-chc 2.43.1-sha831caee` (from `http_user_agent`), alert worker +`hyperdx-chc-alerts 2.42.0-sha91f0392`. One ClickStack user, role Admin. Times UTC. +Identifiers (IdP subject, the JWT name's hash, object IDs) are left out on purpose. + +## Data + +`bin/lab.py setup` at 04:36: 172,800 rows over the previous 24 h (alpha / bravo / charlie โ‰ˆ 46.5k +each, prod โ‰ˆ 80 %), then `bin/lab.py feed 90` (60 rows every 30 s) from 04:44. + +## Identities (query_log, session_log, last 7 days / today) + +| Who | Client | Session roles | Seen as | +|---|---|---|---| +| `JWT::_:::` | `hyperdx-chc 2.43.1` | `[]`, profile `default`, auth `JWT` | search, dashboards, filters | +| `hdx-alert-internal` | `hyperdx-chc-alerts 2.42.0` | `['hdx_alert_role']`, auth `SSL_CERTIFICATE` | every alert query | +| `sql-console` | `sqlui/261106โ€ฆ` | `['clickpipes_system','default_role']` | the console's SQL editor | + +`system.users`: the JWT user is in storage `cloud`, auth type `jwt`. `SHOW GRANTS` for it lists +the full SQL-console-admin privilege set granted directly (including `CREATE ROW POLICY`, +`CREATE MASKING POLICY`, `ROLE ADMIN`, with grant option). No ClickStack query had +`additional_table_filters` in `Settings`. One JWT name (one hash) was seen all day. + +## ClickStack roles (Cloud API, beta endpoints) + +- `GET /clickstack/roles` on both services of the organization: the same three system roles with the + same IDs. `GET /clickstack/sources`: 14 on one service, 0 on the other. +- `POST /clickstack/roles` with `manage User` or `manage all`: HTTP 400 `Roles cannot grant + create, update, or delete access to Users. Only the Admin role can invite, remove, or re-role + team members.` +- Eight roles accepted (`lab.py role all`, 04:40โ€“04:41), including three with + `integration: clickhouse` row conditions (SQL and Lucene). `system.row_policies` stayed empty โ€” + nothing is created on the ClickHouse side while no user holds the role. +- Team Settings โ†’ Members & Roles โ†’ Edit on `rbac-lab ยท team-alpha`: Dashboards / Saved Searches / + Sources "Using Fine-Grained Controls", the tag rule shown as *Tag is game:alpha ยท Read*; no + section for the row condition. The dialog was closed without Save. +- Team members: the runner (Admin) and one Member. No restricted-role session was observed. + +## Row policies (each step followed by a fresh search: a new time range) + +| Step | Policy | ClickStack search (6 h, group by Game) | Alert queries | query_log | +|---|---|---|---|---| +| D0 04:44 | none | 42,368 rows, 3 games | all games | `used_row_policies = []` | +| D1 04:52 | `USING Game='alpha' TO sql_console_admin, sql_console_read_only` | 1,070 rows / 15 min, **3 games** | โ€” | โ€” | +| D2 04:45 | `USING Game='alpha' TO ALL EXCEPT default` | 14,117 rows, alpha only | from 04:46 bravo, charlie `count()` = 0; states bravo, charlie OK, alpha ALERT (04:49) | policy listed on JWT and `hdx-alert-internal` queries | +| D3 04:46 | + `USING Game='bravo' TO ` | 28,242 rows, alpha + bravo; bravo dashboard SQL tile (no WHERE): alpha, bravo rows only | unchanged | both policies listed | +| D3' | same `TO` a JWT name that never logged in | โ€” | โ€” | `UNKNOWN_ROLE` | +| D4 04:49 | D2 altered to `EXCEPT default, hdx-alert-internal` | unchanged | 04:50 bravo, charlie ALERT | alert queries without policy | + +D1 ran last (after the others were dropped); D2โ€“D4 are in time order. + +## Masking policies + +| Step | Policy | Result | +|---|---|---| +| M1 04:49 | `LogAttributes = mapApply((k, v) -> (k, if(k='client.ip', '***masked***', v)), LogAttributes) TO ` | every ClickStack query of that user: `ClickHouseQueryError โ€ฆ There is no column k in table` | +| โ€” | `CREATE OR REPLACE MASKING POLICY โ€ฆ` | syntax error: not accepted; DROP then CREATE | +| M2 04:50 | `LogAttributes = mapUpdate(LogAttributes, map('client.ip','***masked***'))` | ClickStack column `LogAttributes['client.ip']` shows the original; row detail shows `***masked***` | +| M2, SQL user | same policy on a temporary user | `SELECT LogAttributes` masked; `SELECT LogAttributes['client.ip']` original (masked only with `optimize_functions_to_subcolumns = 0`); `WHERE LogAttributes['client.ip'] LIKE '10.%'` โ†’ 57,920 rows | +| M3 04:51 | `Body = replaceRegexpAll(Body, 'player=p-[0-9]+', 'player=***')` | ClickStack Body masked; SQL user: `WHERE Body LIKE '%player=p-0041%'` โ†’ 0, `LIKE '%player=***%'` โ†’ 57,920 | + +All policies and the temporary user dropped at 04:53. The ClickStack objects, the scenario roles +and `rbac_lab` were kept for a second round with more users. + +# Round 2 โ€” a second user, trial organization (same day) + +A new trial organization with one service: ClickHouse Cloud `26.6.1.2455`, ap-northeast-2, one +replica (12 GiB), ClickStack UI `hyperdx-chc 2.43.1-sha831caee`, alert worker +`hyperdx-chc-alerts 2.42.0-sha91f0392`. Two users: the organization Admin (Google sign-in, ClickStack +Admin) and a test user (email and password, console roles Member + Service Admin, ClickStack role +changed per step). Each in its own Chrome profile, so both views were driven side by side. +Same `sql/schema.sql`, `sql/rows.sql` (172,800 rows), `lab.py setup` and `role all`; feed every 30 s +from 05:48 to 06:32 UTC. Times UTC. + +## Identities + +| Who | Seen as | Note | +|---|---|---| +| `JWT::ClickHouse:::::::` | `hyperdx-chc` (search, dashboards) **and** `sql-console/production` (the console SQL editor) | the name carries the service ID here; on the Seoul service it did not | +| `hyperdx-alert-internal` | `hyperdx-chc-alerts` | `hdx-alert-internal` on the Seoul service | + +## ClickStack roles, as the test user saw them + +| Step | Role given in Team Settings | Result | +|---|---|---| +| R1 06:23 | `rbac-lab ยท rows-alpha` (row condition) | **refused**: `Not allowed to assign a role with privileges exceeding your own` (the Admin assigning) | +| R1' 06:31 | `rbac-lab ยท team-alpha` (row condition + objects + alerts) | refused, same message | +| R2 06:24 | `rbac-lab ยท types` as first written (no Team read) | assigned; the test user's ClickStack shows only `Unauthorized โ€” You are not authorized to access this resource` | +| R3 06:28 | `types` with `read Team` added | dashboards listed (all three) and open read-only ("You have read-only access to this dashboard"); no Alerts or AI notebooks in the menu; `/alerts` says "You do not have permission to view alerts" | +| R4 06:29 | `objects-alpha` (tag `game:alpha` read, source by name) | only `rbac-lab ยท alpha` listed; the bravo dashboard by URL: "Dashboard Not Available"; **search on the same source with `Game:"bravo"`: 7,039 rows** | +| R5 06:31 | `Member` (system) | all three alerts listed with state and history | + +`objects-id` and the system `ReadOnly` role were created but not assigned in this round. + +## Row policy on one user, two users side by side + +`CREATE ROW POLICY rbac_lab_user_alpha ON rbac_lab.otel_logs FOR SELECT USING Game = 'alpha' TO ` at 06:30. + +| | Test user | Admin | +|---|---|---| +| Search, same 3 h, group by Game | 7,036 rows, alpha only | 21,109 rows, three games | +| `Game:"charlie" SeverityText:"ERROR"`, 1 h | 0 rows, 0 scanned | โ€” | +| Alerts page (test user as Member) | all three alerts; charlie `ALERT`, bravo and charlie history bars red | same | +| Console SQL editor, `SELECT Game, count() โ€ฆ GROUP BY Game` | alpha 59,380; `currentUser() LIKE 'JWT::%'` = 1 | โ€” | + +query_log 06:23โ€“06:33: 32 queries of the test user carried `rbac_lab_user_alpha`; alert queries +carried none. diff --git a/labs/managed-rbac/sql/policies.sql b/labs/managed-rbac/sql/policies.sql new file mode 100644 index 0000000..c32a66a --- /dev/null +++ b/labs/managed-rbac/sql/policies.sql @@ -0,0 +1,45 @@ +-- labs/managed-rbac: the ClickHouse-side statements the 2026-11-07 run used, in order, on +-- ClickHouse Cloud 26.8.1.608 (Seoul). Run them as the service's `default` user. Replace +-- {jwt_user} with the ClickStack user's database name, read after that user has opened +-- ClickStack once: SELECT name FROM system.users WHERE name LIKE 'JWT::%' +-- Each step was followed by a fresh ClickStack search (a new time range, so no cached result) +-- and by system.query_log (user, used_row_policies, result_rows). + +-- D1. A policy on the SQL console roles. No effect on ClickStack: its JWT session has no roles. +CREATE ROW POLICY rbac_lab_role_based ON rbac_lab.otel_logs FOR SELECT USING Game = 'alpha' + TO sql_console_admin, sql_console_read_only; +DROP ROW POLICY rbac_lab_role_based ON rbac_lab.otel_logs; + +-- D2. A blanket policy. Every ClickStack user AND the alert worker see alpha only. +CREATE ROW POLICY rbac_lab_alpha_all ON rbac_lab.otel_logs FOR SELECT USING Game = 'alpha' + TO ALL EXCEPT default; + +-- D3. One more policy for one ClickStack user. Permissive policies are OR-ed: alpha + bravo. +CREATE ROW POLICY rbac_lab_user_bravo ON rbac_lab.otel_logs FOR SELECT USING Game = 'bravo' + TO `{jwt_user}`; +-- A name that has not logged in yet is refused: UNKNOWN_ROLE. + +-- D4. The alert worker left out of the blanket policy: alerts see every game again. +ALTER ROW POLICY rbac_lab_alpha_all ON rbac_lab.otel_logs TO ALL EXCEPT default, `hdx-alert-internal`; + +-- M1. Masking with a lambda: every query of that user fails ("There is no column k in table"). +CREATE MASKING POLICY rbac_lab_mask_ip ON rbac_lab.otel_logs + UPDATE LogAttributes = mapApply((k, v) -> (k, if(k = 'client.ip', '***masked***', v)), LogAttributes) + TO `{jwt_user}`; +DROP MASKING POLICY rbac_lab_mask_ip ON rbac_lab.otel_logs; -- OR REPLACE is not accepted + +-- M2. Masking a Map key without a lambda: the whole map is masked, LogAttributes['client.ip'] is not. +CREATE MASKING POLICY rbac_lab_mask_ip ON rbac_lab.otel_logs + UPDATE LogAttributes = mapUpdate(LogAttributes, map('client.ip', '***masked***')) + TO `{jwt_user}`; + +-- M3. Masking a String column: masked in results and in WHERE. +CREATE MASKING POLICY rbac_lab_mask_body ON rbac_lab.otel_logs + UPDATE Body = replaceRegexpAll(Body, 'player=p-[0-9]+', 'player=***') + TO `{jwt_user}`; + +-- Clean up +DROP ROW POLICY IF EXISTS rbac_lab_alpha_all ON rbac_lab.otel_logs; +DROP ROW POLICY IF EXISTS rbac_lab_user_bravo ON rbac_lab.otel_logs; +DROP MASKING POLICY IF EXISTS rbac_lab_mask_ip ON rbac_lab.otel_logs; +DROP MASKING POLICY IF EXISTS rbac_lab_mask_body ON rbac_lab.otel_logs; diff --git a/labs/managed-rbac/sql/rows.sql b/labs/managed-rbac/sql/rows.sql new file mode 100644 index 0000000..6ae439f --- /dev/null +++ b/labs/managed-rbac/sql/rows.sql @@ -0,0 +1,37 @@ +-- Synthetic rows: {n:UInt64} rows spread evenly from now() - {span_s:UInt64} seconds to now(). +-- Three games (alpha, bravo, charlie), prod 80 % / qa 20 %, three services per game, +-- 200 players, 5 % ERROR and 10 % WARN. Every value is made up. +INSERT INTO rbac_lab.otel_logs +SELECT + now64(9) - toIntervalMillisecond(intDiv(({n:UInt64} - number) * {span_s:UInt64} * 1000, {n:UInt64})) AS Timestamp, + lower(hex(cityHash64(number, 't'))) AS TraceId, + lower(hex(cityHash64(number, 's') % 4294967296)) AS SpanId, + 1 AS TraceFlags, + multiIf(r % 100 < 5, 'ERROR', r % 100 < 15, 'WARN', 'INFO') AS SeverityText, + multiIf(r % 100 < 5, 17, r % 100 < 15, 13, 9) AS SeverityNumber, + concat(game, '-', svc) AS ServiceName, + multiIf( + svc = 'billing' AND r % 100 < 5, concat('payment failed player=', player, ' code=PG_TIMEOUT'), + svc = 'billing', concat('purchase ok player=', player, ' item=gem_pack_', toString(r % 7)), + svc = 'match' AND r % 100 < 5, concat('match server crashed room=', toString(r % 500), ' player=', player), + svc = 'match', concat('match finished room=', toString(r % 500), ' player=', player, ' duration_s=', toString(60 + r % 600)), + r % 100 < 15, concat('slow login player=', player, ' latency_ms=', toString(800 + r % 2000)), + concat('login ok player=', player)) AS Body, + '' AS ResourceSchemaUrl, + map('service.name', concat(game, '-', svc), 'game', game, 'deployment.environment.name', env, + 'team', concat(game, '-liveops'), 'cloud.region', if(game = 'charlie', 'us-west-2', 'ap-northeast-1')) AS ResourceAttributes, + '' AS ScopeSchemaUrl, 'rbac-lab' AS ScopeName, '' AS ScopeVersion, map() AS ScopeAttributes, + map('player.id', player, 'client.ip', concat('10.', toString(r % 256), '.', toString(intDiv(r, 256) % 256), '.', toString(number % 254 + 1)), + 'guild.id', concat('g-', toString(r % 40))) AS LogAttributes, + '' AS EventName, + game AS Game, + env AS Env +FROM ( + SELECT number, + cityHash64(number, {seed:String}) AS r, + ['alpha', 'bravo', 'charlie'][number % 3 + 1] AS game, + if(intDiv(r, 7) % 10 < 8, 'prod', 'qa') AS env, + ['gateway', 'match', 'billing'][intDiv(r, 11) % 3 + 1] AS svc, + concat('p-', leftPad(toString(intDiv(r, 13) % 200), 4, '0')) AS player + FROM numbers({n:UInt64}) +); diff --git a/labs/managed-rbac/sql/schema.sql b/labs/managed-rbac/sql/schema.sql new file mode 100644 index 0000000..6f72d98 --- /dev/null +++ b/labs/managed-rbac/sql/schema.sql @@ -0,0 +1,31 @@ +-- labs/managed-rbac: one shared log table for three games, the way a game company often lands logs +-- (game and environment first in the sort key). ClickStack's otel_logs columns plus two top-level +-- columns, Game and Env, that row conditions and row policies can filter on cheaply. +CREATE DATABASE IF NOT EXISTS rbac_lab; + +CREATE TABLE IF NOT EXISTS rbac_lab.otel_logs +( + `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)), + `TraceId` String CODEC(ZSTD(1)), + `SpanId` String CODEC(ZSTD(1)), + `TraceFlags` UInt8, + `SeverityText` LowCardinality(String) CODEC(ZSTD(1)), + `SeverityNumber` UInt8, + `ServiceName` LowCardinality(String) CODEC(ZSTD(1)), + `Body` String CODEC(ZSTD(1)), + `ResourceSchemaUrl` LowCardinality(String) CODEC(ZSTD(1)), + `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)), + `ScopeSchemaUrl` LowCardinality(String) CODEC(ZSTD(1)), + `ScopeName` String CODEC(ZSTD(1)), + `ScopeVersion` LowCardinality(String) CODEC(ZSTD(1)), + `ScopeAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)), + `LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)), + `EventName` String CODEC(ZSTD(1)), + `Game` LowCardinality(String) CODEC(ZSTD(1)), + `Env` LowCardinality(String) CODEC(ZSTD(1)), + INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha') GRANULARITY 100000000 +) +ENGINE = MergeTree +PARTITION BY toDate(Timestamp) +ORDER BY (Game, Env, toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp) +TTL toDateTime(Timestamp) + toIntervalDay(7);