Skip to content

Commit dc58c42

Browse files
andre-salvaticlaude
andcommitted
chore: narrow the CI secret's blast radius and pin actions by SHA
Two mitigations for the long-lived DATABRICKS_CLIENT_SECRET, plus the context for why it is still there at all. The secret moves from the workflow-level env block, where it was visible to every step including dependency installation, to the three steps that actually talk to Databricks. Dependency install and unit tests are exactly where third-party code executes, and they never needed it. The host and client id stay at workflow level — they are identifiers, not credentials. Every third-party action is now pinned to a full commit SHA rather than a tag, since tags are mutable and whoever controls an action's repo can repoint one at code that then runs on the runner with that environment. databricks/setup-cli resolves the CLI version from a VERSION file at the pinned ref, so the SHA pin keeps the CLI on 1.9.0 exactly as the tag did — verified, not assumed. The architecture spec now records why OIDC / workload identity federation is the mechanism Databricks recommends instead, and why this repo cannot use it: the trust anchor is a service principal federation policy, an account-level API, and Free Edition has no account-level API access. The exact policy command and workflow delta are documented for anyone forking onto a paid account. Neither mitigation shortens the credential's lifetime; only OIDC does that. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 11cf489 commit dc58c42

2 files changed

Lines changed: 70 additions & 10 deletions

File tree

‎.github/workflows/onpush.yml‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,34 @@ concurrency:
2121
group: deploy-${{ github.ref }}
2222
cancel-in-progress: false
2323

24+
# `DATABRICKS_CLIENT_SECRET` is deliberately NOT here: a workflow-level env var is visible
25+
# to every step, including dependency installation, where third-party action and package
26+
# code executes. It is injected per-step, only on the three steps that talk to Databricks.
27+
# (The host and client ID are identifiers, not credentials — leaking them grants nothing.)
2428
env:
2529
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }}
2630
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CLIENT_ID }}
27-
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
2831
TEMPLATE_ALERT_EMAILS: ${{ secrets.TEMPLATE_ALERT_EMAILS }}
2932

3033
jobs:
3134
ci:
3235
runs-on: ubuntu-latest
3336

3437
steps:
35-
- uses: actions/checkout@v4
38+
# Third-party actions are pinned to a full commit SHA, not a tag: tags are mutable,
39+
# so whoever controls the action's repo can repoint one at new code that runs on this
40+
# runner. The trailing comment records which release the SHA corresponds to.
41+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
3642

3743
- name: Set up Python
38-
uses: actions/setup-python@v5
44+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
3945
with:
4046
python-version: '3.12'
4147

4248
- name: Install uv
43-
# Pinned to the full release tag: setup-uv stopped publishing moving major
44-
# tags (v1..v7) after v7, so `@v9` does not resolve — use the exact tag.
45-
uses: astral-sh/setup-uv@v9.0.0
49+
# setup-uv stopped publishing moving major tags (v1..v7) after v7, so `@v9` does
50+
# not resolve — the comment records the exact release this SHA belongs to.
51+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
4652
with:
4753
version: "0.11.31"
4854
enable-cache: true
@@ -62,22 +68,28 @@ jobs:
6268

6369
- name: Upload coverage report
6470
if: always()
65-
uses: actions/upload-artifact@v4
71+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
6672
with:
6773
name: coverage-report
6874
path: reports/coverage/
6975
retention-days: 14
7076

71-
# Pin the CLI to a tagged release so an upstream change can't silently break CI.
77+
# Pinned so an upstream change can't silently break CI; comment records the release.
7278
- name: Install Databricks CLI
73-
uses: databricks/setup-cli@v1.9.0
79+
uses: databricks/setup-cli@8b7b124dc4f5b9621e959ea61cf4d3dd4e421f67 # v1.9.0
7480

7581
- name: Deploy on staging
82+
env:
83+
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
7684
run: make deploy env=staging
7785

7886
- name: Run integration tests on staging
87+
env:
88+
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
7989
run: make run env=staging
8090

8191
- name: Deploy on prod
8292
if: github.ref == 'refs/heads/main'
93+
env:
94+
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }}
8395
run: make deploy env=prod

‎specs/architecture.md‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,55 @@ and `specs/**`. There is **no `bundle validate` step** — `make deploy` regener
101101
trigger allows re-running CI without a new commit.
102102

103103
Requires `DATABRICKS_HOST`, `DATABRICKS_CLIENT_ID`, `DATABRICKS_CLIENT_SECRET`, and
104-
`TEMPLATE_ALERT_EMAILS` repo secrets. CLI and action versions are pinned.
104+
`TEMPLATE_ALERT_EMAILS` repo secrets. Every third-party action is pinned to a **full commit SHA**
105+
(with the release in a trailing comment) rather than a tag — tags are mutable, so whoever controls an
106+
action's repository can repoint one at code that then runs on the runner. `databricks/setup-cli`
107+
resolves the CLI version from a `VERSION` file at the pinned ref, so the SHA pin also keeps the CLI
108+
version fixed.
109+
110+
### CI authentication — why the client secret, and what replaces it
111+
112+
CI authenticates as `template-sp` with a **long-lived OAuth client secret** stored as a GitHub
113+
secret. Databricks explicitly recommends against this: the preferred mechanism is
114+
[workload identity federation](https://docs.databricks.com/aws/en/dev-tools/auth/provider-github)
115+
(OIDC), where the runner mints a short-lived JWT signed by GitHub and Databricks exchanges it for an
116+
OAuth token — nothing stored, nothing to rotate, useless if leaked.
117+
118+
**This template cannot use it, because it runs on Databricks Free Edition**, which has
119+
[no access to the account console or account-level APIs](https://docs.databricks.com/aws/en/getting-started/free-edition-limitations).
120+
The trust anchor for OIDC is a *service principal federation policy*, and that is an account-level
121+
API — every call returns `unauthenticated` on Free Edition regardless of workspace admin rights.
122+
123+
Two mitigations do apply here, and are in place. First, `DATABRICKS_CLIENT_SECRET` is injected
124+
**per-step**, only on the three steps that talk to Databricks — a workflow-level `env:` would expose
125+
it to every step, including dependency installation, which is exactly where third-party code
126+
executes. Second, the SHA pinning described above closes the other route to that runner environment.
127+
Neither shortens the credential's lifetime, which is what only OIDC fixes.
128+
129+
If you fork this template onto a paid account, this is the first hardening to apply. An account
130+
admin creates the policy once:
131+
132+
```bash
133+
databricks account service-principal-federation-policy create <TEMPLATE_SP_NUMERIC_ID> --json '{
134+
"oidc_policy": {
135+
"issuer": "https://token.actions.githubusercontent.com",
136+
"subject_claim": "repository",
137+
"subject": "<github-org>/<repo>"
138+
}
139+
}'
140+
```
141+
142+
then in `onpush.yml` add `permissions: {id-token: write, contents: read}`, set
143+
`DATABRICKS_AUTH_TYPE: github-oidc`, and drop `DATABRICKS_CLIENT_SECRET`. Match on the `repository`
144+
claim rather than the default `sub`: `sub` embeds the branch
145+
(`repo:org/repo:ref:refs/heads/main`) and policy subjects are exact matches with no wildcards, but
146+
CI deploys to staging on **every** branch — one policy on `repository` covers them all. That does
147+
not widen prod access, which is gated by `if: github.ref == 'refs/heads/main'` in the workflow. (A
148+
per-environment policy, `...:environment:staging` / `...:environment:production`, is tighter and
149+
pairs naturally with GitHub Environments and required reviewers.)
150+
151+
Local development is unaffected either way — it authenticates through the `dev`/`staging`/`prod`
152+
profiles in `.databrickscfg`.
105153

106154
<!-- Diagram source: assets/ci_cd.drawio (edit in https://app.diagrams.net, then export over assets/ci_cd.png) -->
107155
<img src="../assets/ci_cd.png" alt="CI/CD: local dev loop and the onpush.yml GitHub Actions pipeline">

0 commit comments

Comments
 (0)