learn-data-access-control-with-phoebe / Builder session 6 of 8
Learn Data Access Control with Phoebe · Builder session 6 of 8

Secrets, keys and service accounts

Five sessions of grants, roles and policies all assume one thing: that the session asking the question belongs to the role it claims. A stolen credential breaks that assumption without touching a single policy. The warehouse still evaluates every rule perfectly, for the wrong person. Tonight is about the strings that make sessions exist: where they actually end up in a real estate, why the machine accounts holding them are the most valuable and least watched identities you have, and how to rotate one without an outage.

🟠 Advanced Analytics and platform engineers 45 min
0-3 · Welcome 3-20 · Where the password actually is 20-40 · Build-along: rotate one service account 40-45 · Q&A
Part 0

Where tonight sits in the track

The policy set over saltbox.analytics.customers is complete: a two-layer role hierarchy, a row access policy on region, masking policies on the identity and contact columns, and a pseudonymised modelling view whose surrogate key depends on one hashing secret. Every one of those controls is evaluated against the role a session holds. Tonight is about how a session gets a role in the first place, and about the handful of strings, mostly held by machines, that decide it. Session 7 then turns the whole thing into a review that runs on a schedule.

Live = presented in the 45 minutes Self-study = the full depth, after class 🔍 Every diagram zooms

★ What you walk out with tonight. A list of every service account that can reach the customers table and what each one runs. An honest answer to where each of their credentials is stored, including the one in the chat channel. One account rotated end to end, in the order that does not cause an outage. A rotation schedule with a named owner per account. And four search patterns to run over every repo you own.

LiveA policy set is only as good as the session it evaluates3 min▶

Everything built so far answers one question: given a session holding role R, what may it see? Row policies filter, masking policies transform, grants decide which objects exist at all. All of it is correct, and all of it takes the role on trust. The warehouse has no way of knowing that the session claiming to be the nightly dbt run is actually a laptop in a coffee shop with a copied password.

That is why credentials belong on the same scoreboard as policies. A leaked key for a service account that holds fr_finance is fr_finance, for anybody. A leaked key for the account that owns the vault schema is every surrogate in the modelling view, walked back to a name. The controls did not fail; they were never consulted, because the session presented a valid identity.

Real world

A Saltbox contractor's laptop was stolen with a notebook on it. The notebook had the BI service account's password in a cell, because that was quicker than asking for a login. The BI account held a role with SELECT on customers in clear, for dashboards nobody had reviewed since the first year. The masking policies were all live. None of them applied to that role.

Self-studyThe vocabulary for tonight3 min▶
  • Credential - anything a session presents to prove it is who it says: a password, an API token, a private key, a certificate.
  • Secret - any string whose disclosure grants access. Credentials are secrets; so is the hashing key from session 5, and so is an encryption key.
  • Service account - an identity used by software rather than a person. A pipeline, an orchestrator, a BI tool's connection, a model training job.
  • Secrets manager - a system whose only job is to store secrets encrypted, hand them to authorised callers at runtime, log every read and make rotation possible.
  • Rotation - replacing a credential with a new one and retiring the old one, on a schedule and on demand.
  • Short-lived credential - one that expires on its own after minutes or hours, issued on request against a longer-lived identity. Nothing to rotate, because nothing lives long enough to steal usefully.
  • Blast radius - everything a compromised credential can reach. The thing one-account-per-pipeline is designed to shrink.
Part 1 · covers where credentials leak and why service accounts are different

The four places a warehouse password ends up

9 min live

Nobody decides to publish a warehouse password. It arrives in the wrong place by the shortest path from "I need this to run" to "it runs", and the four shortest paths are the same in every estate. Each one has a search that finds it and a fix that closes it, and the fix is always the same shape: the value lives in a secrets manager and is injected at runtime, never in a repo, a profile file or a notebook.

Where the password actually is dbt profiles.yml in git Search git log -p -- profiles.yml and every past commit Fix env_var() in the profile, value injected at run time, rotate the exposed one Notebook cell Search grep -ri password *.ipynb outputs too, not just code Fix fetch from the secrets manager inside the cell, strip outputs on commit CI variable in a log Search build logs for the value echo, set -x, a failed step Fix mark the variable masked, never print the env, rotate anyway Shared password in chat Search chat: "warehouse pw" pinned messages first Fix one account per pipeline, rotate, delete the message, then ask why it was needed All four are one fault: a long-lived value copied somewhere with a wider audience than the vault.
🔍 Click to zoom - four places, four searches, one fix.
LiveThe four, and how each one happens to sensible people4 min▶
WhereHow it got thereWho can read it now
A dbt profiles.yml committed by accidentSomebody ran dbt locally, the profile had a literal password, the file was not in .gitignore, and a "setup instructions" commit carried it in. Removing it in the next commit leaves it in history for everEverybody with read on the repo, every fork, every CI runner that clones it, and every laptop that ever pulled
A notebook cellConnecting from a notebook needs a password. Typing it in the cell is one step; fetching it properly is four. The notebook is then saved to a shared folder with the cell intact, and often with the connection's output printed below itAnybody with the folder, and any tool that indexes it
A CI environment variable printed in a logThe variable was set correctly. Then a step did echo or set -x to debug a failure, and the value landed in a build log that the whole engineering org can read and that is retained for a yearEverybody with log access, which is usually everybody
A shared team password in a chat channelA pipeline broke at 22:00, the person who knew the password was offline, and the fix was to paste it in the channel "for now". Chat search remembers for ever, and the channel has since gained three contractorsEvery current and past member of the channel, plus the chat platform's export

The professional rule that closes all four is one sentence. Credentials live in a secrets manager and are injected into the process at runtime. Not in a repo, not in a profile file, not in a notebook, not in a chat message. The reason is not tidiness: each of the four places has a wider audience and a longer memory than the vault, and a secret is only as secret as the widest place it has ever been.

LiveA service account is not a person3 min▶

Human accounts have accumulated protections over twenty years of being attacked. Service accounts have almost none of them, and they usually hold more.

  • No MFA. A pipeline cannot approve a push notification, so the credential is the whole proof of identity. Whoever holds the string is the account.
  • No offboarding. When a person leaves, their access is revoked. When the engineer who created svc_dbt_nightly leaves, the account and its password stay exactly as they were, and everybody who ever saw that password keeps it.
  • Shared across pipelines. One account created for the nightly build gets reused by the orchestrator, then by a model training job, because it already works. It now needs the union of three roles, and revoking it breaks three things, which is why it never gets revoked.
  • Broadest grants in the estate. A pipeline that writes every table needs a role that can write every table. That role, held by an identity with no MFA and no leaver process, is the single most valuable string in the warehouse.

Count them tonight. Most teams can name their human users with broad access and cannot name their service accounts at all. The build-along starts with that inventory, and it is usually the moment the session gets quiet.

LiveThe hashing key from session 5 is a credential with a different name2 min▶

The modelling view is pseudonymised by a keyed hash. The key does not log anybody in, so it is easy to treat it as configuration rather than as a secret. It is the opposite: whoever can read it can regenerate every surrogate and join the modelling view straight back to names, emails and phone numbers. That is a re-identification of every customer in one query.

So it follows every rule on this page. It lives in the secrets manager, it is read at runtime by the one function that needs it, it has an owner, and it has a rotation schedule with a written plan for re-issuing surrogates when it turns. Rotating it is more disruptive than rotating a password, because every consumer of the modelling view has to re-key on the same day, which is exactly why the plan has to exist before the key leaks rather than after.

Self-studyThe secrets manager: what it must do and what it must not be3 min▶

Any of the mainstream secrets managers will do. What matters is the set of properties, because a shared spreadsheet with a lock icon has none of them.

  • Encrypted at rest, decrypted only for an authorised caller. The service reads its own secret; the engineer who deployed it does not need to see the value and usually should not.
  • Every read is logged. Which identity fetched which secret, when. This is the audit trail that a password in a file can never have, and it is what tells you the blast radius after an incident.
  • Versioned. Two versions of a secret can be valid at once, which is the property the whole rotation sequence depends on.
  • Reachable from where the pipeline runs. If the runner cannot reach the manager, the engineer will paste the value into an environment variable "temporarily", and the four places are back.

What it must not be: a password manager designed for humans, shared through a team vault, with the warehouse password sitting next to somebody's airline login. Those tools are excellent for people and have no runtime injection, no per-service identity and no useful read log for a machine.

Part 2 · covers rotation, short-lived credentials and least privilege for machines

Rotation, short-lived credentials, and least privilege for machines

8 min live

A credential that never changes is a credential whose every past exposure is still live. Rotation is the control that puts an expiry date on every leak that has already happened, and it only works when it is a schedule somebody owns rather than a good intention. The stronger shape removes the long-lived secret altogether.

Rotate in this order and nothing goes down 1 New key issue a second one 2 Store secrets manager only 3 Repoint pipeline reads new 4 Verify run it, read the logs 5 Revoke old then run it again Between 1 and 5 both keys are valid. That window is the whole trick. Revoke first the popular order The pipeline is down before the new key exists. Nothing to fall back to, so the fix happens under pressure, and the new value gets pasted into the nearest place that works. Rotation only works if nothing depends on the old value being static. Find those dependencies first.
🔍 Click to zoom - new, store, repoint, verify, revoke. Revoking first is how outages happen.
LiveRotation is a schedule with an owner, not an aspiration3 min▶

"We rotate credentials regularly" appears in every security questionnaire and is true in very few estates, because rotation without an owner and a date is a wish. The working version has three parts.

  • A schedule per account. Ninety days is a common default for a long-lived password. The exact number matters less than the fact that it exists and that the next date is written down somewhere a review can read.
  • A named owner per account. Not a team. A person whose name is next to svc_dbt_nightly and who is the one asked when it is overdue. Ownership is what turns a schedule into rotations that actually happen.
  • A rotation on demand. Whenever the value has been seen by somebody who should not have it: a leaver, a log, a chat paste. The schedule is the floor, not the ceiling.

Rotation has one precondition that stops most first attempts. Nothing may depend on the old value being static. A hard-coded password in a notebook, a connection string baked into a container image, a second pipeline nobody knew was using the account: every one of those breaks the moment the old value dies. The rotation itself is five minutes. Finding those dependencies is the work, and the inventory in the build-along is where it starts.

LiveShort-lived credentials: the shape that removes the password3 min▶

Rotation shortens the life of a leak. The stronger move is to make sure there is no long-lived secret to leak in the first place. Two patterns do that, and both are the direction of travel across warehouse platforms.

  • Key-pair authentication. The service holds a private key and the warehouse holds the matching public key. Nothing that grants access is ever sent over the wire or written into a profile, and the public half can be published without harm. Most platforms also let two public keys be registered at once, which makes rotation the five-step strip above with no shared-secret handling at all.
  • Federated, short-lived tokens. The pipeline authenticates to the identity provider that already knows what it is, receives a token valid for an hour, and uses that. There is no password to rotate because there is no password. A stolen token is worthless by lunchtime.

Which of these a given warehouse supports, and with what syntax, is a platform question and changes often enough that the vendor's current documentation is the only reliable source. The design rule does not change: prefer the credential that expires on its own, and where a long-lived one is unavoidable, treat it as the liability it is.

Real world

Saltbox moved the nightly dbt run from a password to a key pair after the second chat-channel incident. The migration took an afternoon. Six months later a build log with the old debugging output was found in a shared drive. It still contained the old password. The password no longer worked, and the private key had never been in a log, because there is no way to echo a file you never read into a variable.

LiveOne account per pipeline, and why that is least privilege for machines2 min▶

Least privilege for people was sessions 1 and 2: one functional role per job, holding exactly the access roles that job needs. Machines get the same treatment, and for the same reason.

  • One service account per pipeline. svc_dbt_nightly, svc_orchestrator, svc_churn_model, svc_bi_refresh. Not one svc_data that everything uses.
  • Each with its own functional role. fr_pipeline_dbt can write the models it builds. fr_pipeline_churn can read the modelling view and nothing else. Neither can read the vault schema where the hashing key lives, because neither job needs to.
  • The payoff is containment. A compromised orchestrator key cannot read the hashing secret, so the modelling view stays pseudonymous through the incident. A revoked key breaks exactly one thing, so revoking it is a decision somebody will actually take at 22:00 instead of deferring until Monday.
LiveOn the bench: the top-role row is also the picture of a stolen key2 min▶

Nothing in tonight's session changes a number on the session 8 bench, and that is the point. The bench evaluates 150 requests against the policies for the role each request claims. It assumes the claim is true.

Policies onLeaksOver-blocksStanding privileges
One shared analyst role109030
+ pseudonymise the modelling view (the whole design)000
The whole design + grant ACCOUNTADMIN109030

Read the last row as a credential story rather than a grant story. It is identical to the opening state, with every policy still defined and deployed, because a role above the policy layer is a role the policy layer does not reach. A stolen credential for an account holding that role produces exactly the same picture: 109 leaks, 30 standing privileges, and a warehouse that will tell an auditor every control is in place. A perfect policy set with a leaked service-account key is 109 leaks by another route. The bench cannot see the difference, and neither can the access review, which is why service accounts get their own row in session 7.

Self-studyWhat to do in the first hour after a credential leaks3 min▶

The order matters, and the wrong order is the intuitive one.

  • Rotate before you investigate. The leaked value must stop working before anybody spends an hour working out who saw it. Follow the five-step strip: the pipeline stays up and the old value dies.
  • Then read the logs for the old value's lifetime. The warehouse login history for that account, from the earliest date the value could have been exposed. Unfamiliar client, unfamiliar network, unfamiliar hour: any of those turns a leak into an incident.
  • Assume the role's whole reach was read. Whatever the account's role could see in clear is what leaked. This is where one-account-per-pipeline pays off, because the answer is one pipeline's tables rather than everything.
  • If it was the hashing key, the modelling view is re-identifiable. Rotate the key, re-issue surrogates to every consumer, and treat every past export of the view as containing real customer_ids.
  • Write the finding into the schedule. The account that leaked gets a shorter rotation period and a review of how the value reached the place it was found.
Build-along

Inventory, then rotate one service account end to end

★ 20 min · everyone builds

Bring the account list from your own warehouse and read access to your own repos and chat. The account rotated tonight is the one with the smallest blast radius, so that a mistake costs one pipeline and not the estate. Every value below is a placeholder; the real ones never appear in a file like this.

Inventory every service account that can reach customers. List the non-human users, the roles each holds, and what each one runs. If two pipelines share one account, write both down under it, because that shared line is tomorrow's outage.

★ Try it now show users; -- keep the rows with no human owner: svc_, etl_, bi_, anything created by a ticket show grants to user svc_dbt_nightly; show grants to user svc_bi_refresh; show grants to role fr_pipeline_dbt; -- who actually read customers in the last 30 days, from your platform's query history view select user_name, role_name, count(*) as queries from query_history where start_time >= dateadd(day, -30, current_timestamp()) and query_text ilike '%analytics.customers%' group by user_name, role_name order by queries desc;

Write the result as a table: account, role, pipeline it runs, last used, owner. The owner column is usually blank for at least one account, and that account is the one nobody will rotate until tonight.

Find where each credential is stored, honestly. For each account, name the place the running pipeline gets the value from. Then search the four places anyway, including the chat search, because the honest answer and the actual answer differ more often than anybody expects.

★ Try it now # the four patterns, across every repo you can clone git grep -n -i -E 'password|passwd|secret|api[_-]?key|token' -- ':!*.md' ':!*.lock' git log -p --all -S 'password' -- 'profiles.yml' '*.env' '*.ipynb' | grep -n -i 'password' grep -rl --include='*.ipynb' -i -E '"password"|passwd=|api_key' . # then the CI build logs for the account names, and the chat search for "warehouse", "password", "pw"

Every hit is a rotation on demand, tonight, before the scheduled one. Removing the line from the file does nothing; the value is in history, in the log and in the chat export until it stops working.

Create one new credential for one pipeline account, while the old one still works. Pick the account with the smallest reach. Issue the second credential and put it in the secrets manager as a new version. Nothing has changed for the running pipeline yet.

★ Try it now -- 1 new key: issue the second credential on the account. Exact statement is platform-specific; -- the pattern is that the old and new are both valid from this point. alter user svc_churn_model set <second credential> = '<REDACTED>'; # 2 store: the new value goes into the secrets manager as version 2, nowhere else secrets put warehouse/svc_churn_model --value '<REDACTED>'

Repoint the pipeline and confirm it runs. The pipeline reads the credential from the secrets manager at start, so repointing is a version bump rather than a code change. Run it, and read the run log for two things: that it connected as the right account, and that nothing printed the value.

★ Try it now # 3 repoint: the pipeline fetches at runtime, never from a file import os from secrets_client import get_secret # your secrets manager SDK conn = warehouse.connect( user="svc_churn_model", credential=get_secret("warehouse/svc_churn_model"), # never a literal here role="fr_pipeline_churn", ) # 4 verify: run it, then check the warehouse side # select user_name, role_name, client, start_time from login_history where user_name = 'SVC_CHURN_MODEL' order by start_time desc limit 5;

The dbt equivalent is a profile that reads the environment rather than holding a literal, with the environment populated from the secrets manager by the runner and not by a person.

★ Try it now saltbox: target: prod outputs: prod: user: svc_dbt_nightly password: "{{ env_var('DBT_WAREHOUSE_CREDENTIAL') }}" # injected by the runner at start role: fr_pipeline_dbt

Revoke the old credential and confirm nothing else broke. Only now. Then run the pipeline once more, and watch the login history for the next day for anything that tries the old value: that is the second pipeline nobody knew was using the account.

★ Try it now -- 5 revoke old: the first credential stops working. Platform-specific statement, same pattern everywhere. alter user svc_churn_model unset <first credential>; -- then: anything still presenting the old value shows up here as a failed login -- select user_name, error_message, start_time from login_history where user_name = 'SVC_CHURN_MODEL' and is_success = 'NO';

A failed login after revocation is not a bug in the rotation. It is the inventory being corrected by reality, and the account it belongs to goes on the list for its own credential.

Write the rotation schedule with an owner per account. One row per service account: what it runs, where the credential lives, rotation period, next date, named owner. Put it in the repo next to the policy definitions, because session 7 will read it and flag every row whose next date has passed.

★ Try it now account,runs,credential_lives_in,period_days,next_rotation,owner svc_dbt_nightly,nightly dbt build,secrets manager: warehouse/svc_dbt_nightly,90,2026-12-06,platform lead svc_orchestrator,DAG scheduling,secrets manager: warehouse/svc_orchestrator,90,2026-12-06,platform lead svc_churn_model,churn scoring,secrets manager: warehouse/svc_churn_model,90,2026-12-06,ds lead svc_bi_refresh,dashboard extracts,secrets manager: warehouse/svc_bi_refresh,90,2026-12-06,analytics lead vault_hash_key,surrogate_key() secret,secrets manager: vault/surrogate_hash_key,365,2027-09-06,platform lead

Grep your repos for the four patterns, on a schedule. The search from step 2 runs once tonight and finds the past. Put it in CI so it runs on every commit and finds the future, failing the build when a pattern matches. A scan that runs once is a snapshot; the four places refill.

Rotate the smallest thing first, and rotate it twice. The second rotation of the same account is the one that proves the process works, because the first one always finds a dependency nobody wrote down. An account that has been rotated twice without an outage is an account you can rotate at 22:00 during an incident without a meeting.

Homework

Before session 7

30 min
  1. Finish the inventory. Every service account in the warehouse, not only the ones that reach customers, with an owner in every row. A blank owner is a finding, not a gap in the spreadsheet.
  2. Rotate one more account, following the five steps, and write down what broke. If nothing broke, write down that nothing broke and the date, because that is evidence too.
  3. Split one shared service account into one per pipeline. Start with the one that holds the broadest role, and give each new account a functional role that holds only what its pipeline needs.
  4. Move the hashing key from wherever it currently lives into the secrets manager, with a read log, and confirm the surrogate function still produces the same tokens afterwards. Then write the re-issue plan for the day it has to change.
  5. Put the four-pattern grep into CI on one repo and watch it fail once on purpose, so you know it is actually running. A check that has never failed has never been tested.
Check yourself

Three questions

A warehouse password was committed in a dbt profiles.yml and removed in the next commit. What is the state of that credential?

Removing a line changes the present and not the past. The only fix for a value that has been in a wider place than the vault is to make that value stop working.

Which order rotates a service account credential without an outage?

The window where both are valid is what keeps the pipeline up. Revoking first causes the outage; never revoking leaves every past exposure live for ever.

The finished policy set scores 0 leaks on the bench. A service account holding ACCOUNTADMIN has its key stolen. What does the bench say about the resulting exposure?

The bench evaluates policies for the role a session claims. A stolen credential presents a valid claim, so the row for that role is the row you get. A perfect policy set with a leaked admin key is 109 leaks by another route.

Sources covered

What tonight maps to

✓ The four places a password ends up in a repo Committed profile, notebook cell, CI log, chat channel. The search that finds each and the single fix: a secrets manager with runtime injection.
✓ Service-account hygiene Why a machine identity is not a person, one account per pipeline with its own functional role, the inventory with an owner per row.
✓ Rotation and short-lived credentials The five-step order that avoids an outage, the static-dependency precondition, and key-pair or federated tokens as the direction of travel, stated as patterns rather than vendor syntax.
◐ The hashing key as a credential Built in session 5. Tonight gives it a home in the secrets manager, an owner and a rotation row with a re-issue plan.
◐ Service accounts in the access review Session 7 reads the rotation schedule and the login history and flags overdue rows and unused accounts automatically.
· Identity provider, SSO and MFA for humans; network security Not covered by design. Both are upstream of every control here; tonight is about the credentials machines present once that layer already exists.

Session 6 cheat sheet

The four placesCommitted profile, notebook cell, CI log, chat channel. Search all four; each hit is a rotation tonight.
The one ruleCredentials live in a secrets manager and are injected at runtime. Never a repo, a profile file or a notebook.
Not a personNo MFA, no offboarding, shared across pipelines, broadest grants. The most valuable string in the estate.
Rotate in orderNew key, store, repoint, verify, revoke old. Both valid in between. Revoking first is the outage.
Schedule with an ownerPeriod, next date, named person per account. Rotation without an owner is an aspiration.
Short-lived beats rotatedKey pairs and federated tokens remove the long-lived password. Prefer the credential that expires itself.
One account per pipelineOwn functional role each. A revoked key breaks one thing; a stolen one reads one thing.
On the benchTop role row: 109 leaks, 30 standing, every policy deployed. Also the picture of a stolen admin key.