ADR-0021: Databricks authentication: U2M first, M2M for CI, tokens never persisted¶
- Status: Proposed (reviewed 2026-10-04). Not built yet: ODS doesn't talk to Databricks directly; only the workspace
hostandworkspace_idsettings are used (Catalog Explorer links). - Date: 2026-09-28
- Issues: #297 (related: #294, #295, #126, #17, #30)
- Deciders: @n1ckyb
Context¶
ODS will talk to Databricks directly, not only through dbt: - Unity Catalog metadata and lineage (#17, #30); - SQL checks through the SQL Statement API.
It needs to authenticate two kinds of caller:
- people, on their own machines;
- CI, in the databricks workflow (#294).
What #294 / #295 found¶
The CI script (.github/databricks/ci.py, PR #295) has run against a Databricks Free
Edition workspace.
- Scopes. The service principal's OAuth secret was scoped without all-apis. A
token for sql was granted, and that is enough for SQL statements. The script asks
for all-apis, then sql (or DATABRICKS_OAUTH_SCOPES). Identity comes from
current_user(), because SCIM needs more than sql.
- Federation subject. This repository's GitHub OIDC subject uses the immutable-ID
form, repo:buchochelliq-labs@285328036/open-data-suite@1382703972:environment:databricks-free.
A policy written for the documented repo:<owner>/<repo>:… form matches nothing.
When federation is refused, the script prints the issuer, subject and audience that
a policy must match. These are claims, not credentials.
- Free Edition has no account console. Federation policies are account-level
objects, so a Free Edition workspace can't have one. There, CI falls back to the
service principal's secret.
Constraints¶
- Rule 9 and ADR-0005: configuration holds only references
(
{ secret = "env:…" }, a profile name). Resolved tokens never enterConfig, state, events, logs or output. - Rule 1: the code lives in
providers/ods-provider-databricks. Core and modules never name the vendor. They see a capability and a neutral contract. - No network in tests. The token endpoint and the Databricks CLI are faked.
SecretProvider(#126) resolves references. Only itsenv:scheme is needed here.
Options considered¶
Option A — Personal access tokens only¶
- Pros: simplest; one header.
- Cons: long-lived, usually as broad as the user, and easily pasted into files. Databricks recommends OAuth instead. Kept only as an explicit last resort.
Option B — ODS runs its own OAuth flows and keeps its own token cache¶
- Pros: no dependency on the Databricks CLI.
- Cons: ODS would store refresh tokens, which makes it a secret store (rule 9). Users would sign in twice, once for the CLI and once for ODS. It also duplicates a browser flow the CLI already does well. Rejected.
Option C — A Databricks SDK for Rust¶
- Pros: the credential chain comes ready-made.
- Cons: we found no official, maintained Databricks SDK for Rust to depend on. A community crate would need its licence and maintenance checked under ADR-0002. If an official one ships, the chain below can move onto it without changing the contract.
Option D — A small credential chain in the provider, reusing the CLI for people (chosen)¶
- U2M through the Databricks CLI's profiles and token cache.
- M2M with a service principal's OAuth secret, or with workload identity federation.
- PAT only when configured explicitly.
- Tokens live in memory only.
- Pros: no token is stored by ODS. People sign in once, with the tool they already use. It is the same flow CI already runs.
- Cons: U2M needs the Databricks CLI installed. The CLI's command-line output becomes an interface we depend on.
Decision¶
ods-provider-databricks authenticates with a credential chain: U2M through a
Databricks CLI profile for people (modelled as delegation to that profile, and
reported with the profile's own auth type), a service principal's OAuth secret or workload identity
federation for CI, and a PAT only when asked for by name. Tokens are held in memory,
never written, and never cross the SDK contract. Configuration holds only references.
U2M ships first. M2M follows for the databricks CI job (#294), and federation after
that.
1. Methods¶
auth |
For | How | Holds |
|---|---|---|---|
cli |
people | Delegated to a Databricks CLI profile. U2M (OAuth authorization code with PKCE) is what it is meant for: databricks auth login signs in, and ODS runs databricks auth token --profile <p> and reads access_token and expiry from its JSON. The CLI refreshes from its own cache. |
a profile name, and host |
m2m |
CI, services | client_credentials grant at <host>/oidc/v1/token, with HTTP Basic auth of the client ID and secret |
client_id, a client_secret reference |
federated |
CI where the account allows it | RFC 8693 token exchange at <host>/oidc/v1/token: the CI platform's OIDC token (GitHub's today) for a Databricks token, under the service principal's federation policy |
client_id, optionally audience |
pat |
last resort | a bearer token, sent as is | a token reference |
clidetails:- It is modelled as delegation, not as U2M. A profile can use a PAT, a client
secret or other methods as well as a U2M login. Whatever it resolves to is the
profile's choice, not ODS's. So
statusreportsdelegated, with the profile name and the profile's own auth type. It claimsuseronly when that type is the CLI's U2M login (databricks-cli). - Finding the auth type. ODS asks the CLI
(
databricks auth describe --profile <p> --output json) and reads only the auth type and host fields. Before this ships, a test against the real CLI checks that this output never contains the profile's secret values. A fake CLI covers it in unit tests. If the type can't be determined,statussaysunknown, and a warning says so. ODS never presents an unknown type as U2M. - A profile that resolves to a PAT is refused. A PAT is only used when named
(
auth = "pat", below). The error says so. We believeauth tokenonly returns OAuth tokens, but we don't rely on that. hostis required in ODS's configuration (orDATABRICKS_HOST), until host discovery is decided. ODS never parses~/.databrickscfg, so a profile alone gives it no host. Whenauth describereports the profile's host, ODS compares it withhost. On a mismatch it refuses before sending anything, so a token issued for one workspace is never sent to another.- ODS never parses
~/.databrickscfg, because profiles can hold PATs and client secrets. The CLI resolves them. - ODS never reads or writes the CLI's token cache file, whose format is the CLI's.
- ODS never runs
databricks auth loginitself. When there is no valid login, the error names the command to run, e.g.databricks auth login --profile free. - The CLI is found on
PATH, or at thecli_pathsetting. - Choosing a method:
- When
authis set, only that method is used. There is no fallback. This is recommended in CI, so a misconfiguration fails instead of changing identity. -
When
authis unset, methods are tried in this order:federated, only whenclient_idis set and the process has a CI OIDC token;m2m, whenclient_idandclient_secretare set;cli, whenprofileis set.
A method that is refused falls back to the next, as
ci.pydoes, and the report says why each one was skipped.patis never chosen automatically. - The short-lived tokenci.pyexports asDATABRICKS_TOKENis an OAuth token, not a PAT. It can be passed asauth = "pat"withtoken = { secret = "env:DATABRICKS_TOKEN" }until ODS runs the M2M flow itself.
2. Scopes¶
- Each operation asks for the narrowest scope that serves it:
- SQL statements:
sql; - Unity Catalog REST calls:
unity-catalog, if Databricks grants that scope name; - otherwise
all-apis. scopes = [...]overrides the list. It is tried in order while Databricks answers that a scope isn't assigned, asci.pydoes.ci.pytries the broadest scope first, because a smoke check doesn't know what it will need. ODS knows the operation, so it starts narrow.-
295 verified
workspace before it becomes a default.all-apisandsql. Any other scope name is checked against a real¶ - Tokens from a CLI profile carry the scopes its login requested. ODS can't narrow
them, and says so in
status.
3. Configuration (ADR-0005)¶
[providers.uc]
kind = "databricks"
[providers.uc.settings]
host = "https://<workspace>.cloud.databricks.com"
auth = "cli" # cli | m2m | federated | pat; unset = automatic
profile = "free" # cli: a ~/.databrickscfg profile; host is still required
cli_path = "databricks" # cli: optional, default the CLI on PATH
client_id = "<application id>" # m2m, federated: not a secret
client_secret = { secret = "env:DATABRICKS_CLIENT_SECRET" }
audience = "<account id>" # federated
scopes = ["sql"]
# token = { secret = "env:DATABRICKS_TOKEN" } # pat, last resort
client_secret and token are credential-like names, so ADR-0005 already rejects
plaintext values (ODS-E0103).
- The provider names its settings, as the dbt provider does (#214), so an unknown key
is ODS-E0102.
- DATABRICKS_HOST and DATABRICKS_CONFIG_PROFILE are read as defaults for host
and profile, after flags and before configuration, as the dbt provider reads
DBT_*.
4. Where the code lives (rules 1 and 2)¶
ods-provider-databricks: the chain, the token endpoint client and the CLI runner, in a privateauthmodule.ods-core: a capability,user_login. It is advertised by a provider that signs people in through an external tool.ods_core::choosethen lets the CLI offer a login hint, or fall back to "configure a reference".ods-sdk: a contract,credentials0.1:status() -> AuthStatus: the method (user,service_secret,workload,static_token, ordelegatedwith the tool profile's name and its own reported auth type,unknownwhen it can't be told), the principal if known, the scopes, and when the token expires;login_hint() -> Option<String>.
No token crosses the contract. Providers use their tokens internally.
- ods-provider-fake: a fake credentials implementation. The conformance suite
checks that no AuthStatus field or error can hold a token.
- ods-cli: wires the provider, and shows status in commands that connect.
5. Handling tokens¶
- Tokens are held in a wrapper whose
DebugandDisplayprint<redacted>and whose memory is cleared on drop. We will use a maintained crate for this (e.g.secrecy, MIT/Apache-2.0), checked withcargo deny. - A token is refreshed shortly before
expiry. On one401, the provider gets a new token and retries once. - Tokens are never passed on a command line, in an environment variable to a child process, or in a URL.
- Error messages keep Databricks' own error text, truncated. They never include
request bodies or headers. HTTP tracing redacts
Authorization. - Only HTTPS hosts are accepted. Redirects that change host are not followed with credentials.
6. Threat model¶
| What | Where it could leak | Mitigation |
|---|---|---|
| Access tokens | ODS config, state, events, logs, JSON output, error messages | Never written: memory only, redacted wrapper, no token in the contract. A test runs every method with a marker token and searches all outputs and the state database for it. |
| Access tokens | process list, child environments | Not passed on command lines or to children. The CLI's answer is read from a pipe. |
| Refresh tokens | the CLI's cache on disk | Owned and protected by the CLI. ODS doesn't read, copy or write the file. |
PATs and secrets in ~/.databrickscfg |
ODS reading the file | ODS doesn't parse it; the CLI resolves profiles. |
| Client secret | configuration, CI logs | A reference only (ADR-0005). In CI, a GitHub environment secret, masked, with required reviewers. Fork PRs never run the job. Federation removes the secret where the account allows it. |
| An over-broad token | misuse if stolen | Narrowest scope per operation. The CI principal has only USE CATALOG, CREATE SCHEMA and warehouse use. |
| A spoofed host | tokens sent to an attacker | HTTPS only, TLS always verified, and no credentialed redirects to another host. |
| A profile's token sent to another workspace | the configured host |
When the CLI reports the profile's host, a mismatch with host is refused before any request. |
| A profile that silently uses a PAT or secret | an identity the user didn't expect | status reports the profile's own auth type, never an assumed U2M. A PAT-backed profile is refused. |
| OIDC claims printed on refusal | public CI logs | They name the repository, environment and audience, which aren't secrets. The OIDC token itself is never printed. |
| A PR changing the CI script | the job's credentials | The databricks label runs a PR's own code: review it first (docs/contributing-databricks.md, #295). |
Out of scope: a compromised user account or machine, and memory dumps.
7. Tests¶
- Unit tests, no network: the HTTP client sits behind a small transport trait. A fake token endpoint covers each grant, scope fallback, expiry and refresh, a refused federation, and error messages that never contain a token.
- A fake
databricksCLI infixtures/, likefixtures/dbt/fake-dbt. It covers a valid login, an expired login ("rundatabricks auth login"), and a missing CLI. It also covers each auth typeauth describecan report (U2M, PAT, M2M, unknown), and a host that doesn't matchhost. - Conformance: the
credentialssuite, run by the fake and Databricks providers. - Live: U2M by hand against Free Edition, with a CLI profile. M2M in the
databricksCI job (#294). Federation where an account allows it.
Consequences¶
- Positive:
- ODS stores no Databricks token. People sign in once, with the Databricks CLI.
- CI runs the flow it already has. Where the account allows federation, CI needs no secret at all.
- Core sees a capability and a token-free status, nothing vendor-specific.
- Negative / trade-offs:
- U2M needs the Databricks CLI. The JSON of
auth tokenandauth describebecomes an interface we test against. hostmust be configured even when a profile names one.- Free Edition can't use federation, so CI there keeps a service principal secret.
- Scope names beyond
all-apisandsqlare unverified until tested on a real workspace. - A new dependency for the token wrapper.
- Follow-up issues:
-
126:
SecretProviderwith theenv:scheme, whichclient_secretandtoken¶need.
- Host discovery: make
hostoptional forcliby reading it fromdatabricks auth describe, once a test shows that output never includes credentials. Until thenhostis required. - Federation from other CI platforms' OIDC tokens.
- Move the chain onto an official Databricks SDK for Rust, if one ships under a permissive licence.
References¶
-
297; #294 and PR #295 (
.github/databricks/ci.py,¶docs/contributing-databricks.md). - ADR-0005 (references), ADR-0006 (contracts and capabilities), ADR-0002 (dependencies).
- Databricks documentation: OAuth U2M and M2M, workload identity federation, and
databricks auth login/databricks auth token. - RFC 7636 (PKCE), RFC 8693 (token exchange).