Skip to main content

ClickHouse identity

The deploy layer creates ONE credential, and supplies one password per app. dfe-engine creates every user, role, grant, quota and row policy.

Coming from Splunk or Elastic? You set access per index or role in the search layer. DFE doesn't. dfe-engine creates ClickHouse users, roles, grants, quotas and row policies from your IdP groups, and ClickHouse enforces them. dfe-ui, dfe-hyperdx and plain SQL all read the same tables, so the database is the one place every path passes through. A group's read access, its _org_id fence and its query quota are defined once, and a client that skips the UI doesn't skip the fence.

Nothing inside a deployment can mint the first privileged account, so something outside must. Everything after that first account is reachable by SQL, and SQL behaves the same on k8s, docker and a local checkout. Putting it anywhere else means writing it three times.

users.xml can't express GRANT, a role, a quota or a row policy, so the engine is the only layer that can carry the model.

The one credential​

default, with a password, on every target.

targetmechanism
k8s clusteroperator spec.settings.defaultUserPassword, secret-backed
k8s singleCLICKHOUSE_USER + CLICKHOUSE_PASSWORD on the StatefulSet
dockerCLICKHOUSE_USER + CLICKHOUSE_PASSWORD on the container

default and not a nicer name, because it's the only account the ClickHouse operator can password from a Secret. Any other name has to carry its password inline in the ClickHouseCluster CR, which commits a credential to git.

This account exists for dfe-engine to create every other identity with, and to break glass. dfe-engine is the only workload that connects as it. Every app connects as its own user, below.

The supplied passwords​

Each app runs in a pod of its own and can't read the engine's secrets store, so its password comes from the deploy layer. The engine creates the user, adopts the supplied password from DFE_ENGINE_CLICKHOUSE_<ROLE>_PASSWORD, and re-asserts it on every reconcile. The app connects with the same value.

appuserengine readsk8s Secretdocker .env
hunt runnerdfe_hunt_runnerDFE_ENGINE_CLICKHOUSE_HUNT_RUNNER_PASSWORDhunt-runner-clickhouse (huntRunner.clickhouse)DFE_HUNT_RUNNER_CLICKHOUSE_PASSWORD
dfe-loaderdfe_loaderDFE_ENGINE_CLICKHOUSE_LOADER_PASSWORDloader-clickhouseDFE_LOADER_CLICKHOUSE_PASSWORD
keda shim, header-dev dfe-hyperdxdfe_query_readerDFE_ENGINE_CLICKHOUSE_QUERY_READER_PASSWORDquery-reader-clickhouseDFE_QUERY_READER_CLICKHOUSE_PASSWORD
OTel collectordfe_otel_writerDFE_ENGINE_CLICKHOUSE_OTEL_WRITER_PASSWORDotel-writer-clickhouse, copied into the collector's namespaceDFE_OTEL_WRITER_CLICKHOUSE_PASSWORD

On k8s dfe-extras mints each Secret once beside the engine, with ESO's Password generator, from clickhouse.serviceUsers in argocd/values/common.yaml (the hunt runner's from huntRunner.clickhouse). They render with the engine, a wave before every app that reads one. On docker make init mints each into .env.

dfe-hyperdx never holds the admin password. Under oidc-proxy the engine hands each team the connection it reads as, and on k8s dfe-hyperdx is given no ClickHouse password. On docker DEFAULT_CONNECTIONS carries the query reader's password in every mode, and only header-dev reads it.

The hunt runner's worker runs INSERT ... SELECT built from rule text, so its user is the identity a crafted rule runs as: no url(), s3(), remote() or file(), and no DDL.

Each app needs an engine whose contract names its password and dfe-schemas role catalogue 1.2.0, which mints otel_writer. On an older pair the user never exists and the app can't connect. The apps and the keda shim wait for the engine to answer ready, and the engine reconciles its service users before it does.

The collector's gateway waits too, on busybox's wget, because the collector image has no shell. It reads the logs table's columns once, at start, so a gateway that started before its user existed would write every log row without EventName until the pod restarted.

The always-deployed set​

dfe-engine seeds these on every deployment. Seeding is non-destructive and operators can edit them. Definitions: dfe-schemas roles/clickhouse.yaml.

identityCH objectsused byprivilege
dfe_loaderuser + dfe_loader_role + dfe_loader_profiledfe-loaderINSERT ON dfe.*, SELECT ON system.columns and system.tables, async-insert settings
dfe_query_readeruser + dfe_query_reader_role + profilethe platform team's dfe-hyperdx connection, the engine's parameterised views, the keda shimSELECT ON dfe.* plus system introspection, readonly=2
dfe_hunt_runneruser + dfe_hunt_runner_rolehunt-runnerSELECT/INSERT ON dfe.*
dfe_otel_writeruser + dfe_otel_writer_rolethe OTel collector's gatewayINSERT ON dfe.otel_*, SELECT ON dfe.otel_traces, SELECT ON system.metrics, system.events and system.parts, SHOW TABLES ON dfe.* and system.*
dfe_otel_reader_rolerole onlyadmin + infra-admin group usersSELECT ON dfe.*
dfe_tenant_rolerole + row policyorg-bound OIDC usersSELECT fenced by _org_id
dfe_org_<org>user per orgorg_viewer userspins one org's _org_id

A role with mint_user set also gets a user, with a secret the engine mints through the scalo secrets backend unless the deploy layer supplies the password. The rest are granted to an identity that already exists. The service users are reconciled whether or not tenant isolation is on.

readonly=2 on the reader is deliberate, not a slack setting. dfe-hyperdx sends date_time_output_format with every query, and readonly=1 rejects the whole request. allow_ddl=0 still bars DDL.

The writer's SELECT ON dfe.otel_traces is there because ClickHouse runs a materialised view's query as the user whose insert fired it, and otel_traces_trace_id_ts_mv reads otel_traces. Without it every traces batch fails with code 497. Its SHOW TABLES reads no rows. system.parts lists only the tables a user can see, and the collector reports every table's size.

Accepted privilege​

Each of these is wider than the app's queries need, and stays.

whoholdswhy it stays
keda shimthe all-orgs reader, with system.query_log, text_log and processesit shares the platform reader's role, and no caller sends it SQL: it runs its catalogued queries with identifier-checked binds
OTel collectorotel_traces rows, and every dfe and system table's DDL through SHOW TABLESthe traces view needs the rows, the size report needs the tables listed, and no source table's rows are readable
dfe-loaderINSERT ON dfe.*a source's table is created with the source, and the loader has no listener an outsider reaches
OTel collectorINSERT ON dfe.otel_*a source table is named for its source, and a source name cannot hold _ (dfe-engine source/models.py)

Quotas ride the tiers​

Each tier reconciles to a ClickHouse settings profile, a quota and a role.

objectname
roledfe_<tier>_role
settings profiledfe_<tier>_profile
quotadfe_<tier>_quota

Two families ship seeded and the set is extensible: analyst tiers for people, hunt tiers for scheduled work. Each family has one default, applied to any group that names no tier. A quota carries an interval plus per-interval maxima.

The engine hardcodes no tier list. The reconciler applies whatever tiers the governance config holds, so a deployment can add its own.

Where each part is defined​

concernhomeapplies on
the one credentialdfe-infra chart values, dfe-docker .envits own target
each app's passworddfe-infra clickhouse.serviceUsers and huntRunner.clickhouse (dfe-extras), dfe-docker .envits own target
service rolesgovernance/ch/service-roles (engine, versioned)every target
quota tiersgovernance/ch/tiers (engine, versioned)every target
group to tier/org bindingengine governanceevery target

Don't declare a user in the ClickHouseCluster CR. A user there needs an authentication method inline or ClickHouse refuses users.yaml outright and every server crashloops (CANNOT_LOAD_CONFIG), and the result would apply on k8s alone.

Shared-fact mechanics between dfe-infra and dfe-docker: docs/ci-maintenance-design.md.