Your dbt job's service account has a key file sitting on a shared drive the early data team used, and another copy in a former contractor's laptop backup. The key has no expiry date. Key creation has been blocked in your organisation for a while, but this key predates the block, and it still gets an access token today. Whoever has the file can use the job's access to your BigQuery datasets, unless a VPC Service Controls perimeter limits where the account's requests can come from.
If your platform is older than your key-creation policy, it's worth assuming at least one key like this exists until an inventory says otherwise.
In brief. Blocking key creation doesn't retire keys made before the block. A legacy pipeline key can read, and possibly export, curated BigQuery data. BigQuery's Data Access logs, the record of which tables were read, with which key, and from where, stay in the _Default bucket for 30 days unless you change it, so after that you may not be able to say what a key read. This week: export your service account key inventory with Cloud Asset Inventory, find the user-managed keys with no expiry, and check when each one last authenticated.
This series treats the data platform's own machinery as the target: the service accounts that run jobs, the orchestrator, CI for SQL and dbt, connectors, keys, and exports. It uses the same example platform as The Lakehouse in Production: Microsoft Entra ID for workforce identity, Google Cloud for the data platform (BigQuery, Datastream, Cloud Composer, and dbt), and Azure SQL Database at source. It's a composite, and it already has the hardening from that series' first part, including an organisation policy that blocks key creation and upload.
Part 1 follows one credential to the data it can reach, then goes through the controls that narrow that path, roughly in the order they'd pay off on the example platform. The first one is an inventory, and the rest of the series builds on it.
Why blocking key creation didn't fix this
Google Cloud has two pairs of organisation policy constraints that stop service account keys at the source. iam.managed.disableServiceAccountKeyCreation and its legacy form iam.disableServiceAccountKeyCreation block new keys. iam.managed.disableServiceAccountKeyUpload and iam.disableServiceAccountKeyUpload block uploaded public keys. Organisations created on or after 3 May 2024 get the legacy pair enforced by default (Google Cloud, n.d.aa).
They only apply going forward. Google says they "are not retroactive; they do not affect previously created and configured service accounts" (Google Cloud, n.d.aa). iam.serviceAccountKeyExpiryHours caps key lifetime, but only for keys created after it applies (Google Cloud, n.d.aa). So a key made in the platform's early days keeps working until someone disables or deletes it.
We tested this in a sandbox project in October 2026. With creation blocked, a request for a new key failed with FAILED_PRECONDITION. A key we'd made during a short, approved exception still got a fresh token after the block went back on. We used a client that always asks for a new token, because gcloud can return a cached one, which says nothing about whether the key still works.
So the hardening stops new keys, subject to any exceptions, and finding the old ones is separate work.
From the file to the data
Each hop below says what the grant allows and what record it leaves. Figure 1 shows the same path and records.
Hop 1: the file. The key is a JSON credentials file made for dbt's BigQuery service-account method, which points at it with a keyfile field (dbt Labs, n.d.b). Files like this tend to outlive the projects they were made for. They turn up on a shared drive from a migration, in a laptop backup, or on a branch nobody deleted, and storing or copying them usually leaves no record in Google Cloud. The file store may keep its own access logs, which are worth pulling during an investigation. ATT&CK calls this Credentials In Files, searching "local file systems and remote file shares for files containing insecurely stored credentials" (MITRE, 2026b).
Hop 2: the token. Whoever has the file can authenticate as the service account. Each access token typically expires after an hour (Google Cloud, n.d.ab), but the key doesn't, so it can be used again for as long as it stays valid. Each authentication with the key is counted by the Key Authentication Events metric (Google Cloud, n.d.e). Google can automatically disable keys it finds exposed publicly, and that's on by default (Google Cloud, n.d.aa; Google Cloud, n.d.w), but a private share or a backup isn't public, so it's unlikely to help here. From this point the activity looks like the account's own, which ATT&CK files under valid cloud accounts (MITRE, 2026a). Audit log entries for its calls record the key in authenticationInfo.serviceAccountKeyName, next to the account's email in principalEmail, for services that log it. Google notes that some, including App Engine and Compute Engine, don't (Google Cloud, n.d.o).
Hop 3: the grants. dbt's documentation lists BigQuery Data Editor, BigQuery User, and BigQuery Job User as "the typical starting point" for dbt access (dbt Labs, n.d.b). Data Editor has bigquery.tables.getData, bigquery.tables.updateData, and bigquery.tables.export, and can be granted on a dataset or a single table. Job User has bigquery.jobs.create and can only be granted on a project, folder, or organisation (Google Cloud, n.d.g). On the example platform the job has Data Editor on the platform's datasets and Job User on the project. BigQuery User would add more, and Part 2 measures how much. A dbt job that builds the silver and gold layers (the cleaned and business-ready tables) usually needs to read the layers below and write its own outputs, so the key can reach most of the curated data.
Job User decides where the account can run jobs, not what it can read. bigquery.tables.getData alone lets it read a table's rows through the API's tabledata.list method (Google Cloud, n.d.u), and a query job can be billed to a different project from the one that holds the data (Google Cloud, n.d.f). So removing Job User from your project doesn't contain the key; the dataset grants do. Data Editor can also change and delete those tables, so the key is an integrity risk as well as a confidentiality one. Changes to table data appear as TableDataChange entries in Data Access (Google Cloud, n.d.f).
Hop 4: the reads. A query job reads the tables the account can see. ATT&CK's Databases sub-technique covers this kind of collection (MITRE, 2026c). BigQuery records table reads as TableDataRead entries in the Data Access log, including reads through tabledata.list that run no job, and the job itself as JobInsertion and JobChange entries (Google Cloud, n.d.f). When a job is billed to another project, the read is logged in the project that holds the data while the job's own entries go to the billing project, and the billing project's ID is redacted there unless the two projects share a domain or an organisation (Google Cloud, n.d.f). For BigQuery those logs are on by default and land in the _Default bucket unless you've routed them elsewhere (Google Cloud, n.d.i), and _Default keeps them for 30 days unless someone has changed it (Google Cloud, n.d.j).
Hop 5: the data leaves. The same grants allow more than queries. Exporting a table to Cloud Storage needs bigquery.tables.export, bigquery.jobs.create, and write access to the destination bucket (Google Cloud, n.d.q). The roles above cover the first two. The third is granted on the bucket, and that bucket can sit in a project or organisation you don't control, so your own allow policies don't limit it. A VPC Service Controls perimeter around BigQuery is the usual control for that path. A Principal Access Boundary policy on the account may also keep it to resources in your organisation, for services that enforce one. Any log of the write to the bucket belongs to the bucket's owner.
In ATT&CK, one of the Databases sub-technique's detection analytics names BigQuery among the platforms to watch for unusual table exports (MITRE, 2026c). Transfer Data to Cloud Account covers moving data to another account on the same provider (MITRE, 2025), and applying it to a BigQuery export is our reading. Part 5 covers the export, copy, transfer, and connection paths, and the perimeter controls around them.
None of these hops needs a vulnerability; each uses a permission the account already holds.
Other credentials like this
A legacy key is the clearest example, but the pattern shows up elsewhere. Application-default credentials on an engineer's laptop let tools act as that engineer, with everything the engineer can reach, and the key constraints generally don't apply to them. Later parts cover a short-lived token printed in a CI log (Part 3), an Airflow connection holding a key (Part 4), and a connector vendor's credential (Part 6).
Controls that narrow the path
These are ordered roughly by how much each one narrows this path on the example platform. The right order for you depends on what you already have, your team, and your tooling, and none of them closes the path on its own.
1. Find the keys you already have
The constraint stops new keys. To find the old ones, Cloud Asset Inventory can export service accounts (iam.googleapis.com/ServiceAccount) and their keys (iam.googleapis.com/ServiceAccountKey) as they stood at a point in time you choose (Google Cloud, n.d.p; Google Cloud, n.d.b). Ours came out as newline-delimited JSON, one asset per line. Each key record had its type, origin, and validity window, and disabled keys had "disabled": true.
Listing 1 reads that file. For each user-managed key it reports the key's age, whether it ever expires, whether it's disabled or was uploaded, and whether its account is in the same export. It also writes out the graph the series builds on: accounts and keys as nodes, a disabled flag on each key, and an edge from each key to the account it authenticates as. It skips Google-managed keys. Google holds their private halves in escrow and nobody can access them directly (Google Cloud, n.d.ab), and using them to act as an account is impersonation, which Part 2 covers.
Python
"""Credential inventory over a Cloud Asset Inventory export (Part 1). Reads newline-delimited JSON exported with --content-type=resource and emits the series graph format: principals, credentials, and the edges between them, plus one finding per user-managed key. """ import json import sys from datetime import datetime, timezone SERVICE_ACCOUNT = "iam.googleapis.com/ServiceAccount" KEY = "iam.googleapis.com/ServiceAccountKey" def parse_time(value): # validAfterTime looks like 2023-03-02T09:20:11Z; a bare date means UTC midnight parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) def inventory(lines, as_of): accounts, keys = {}, [] for line in lines: if not line.strip(): continue asset = json.loads(line) data = asset["resource"]["data"] if asset["asset_type"] == SERVICE_ACCOUNT: accounts[data["uniqueId"]] = data["email"] elif asset["asset_type"] == KEY and data.get("keyType") == "USER_MANAGED": parent_id = asset["resource"]["parent"].rsplit("/", 1)[1] keys.append((parent_id, data, asset.get("ancestors") or [])) nodes = {} for email in accounts.values(): principal = f"serviceAccount:{email}" nodes[principal] = {"id": principal, "kind": "principal", "attrs": {"in_export": True}} edges, findings = [], [] for parent_id, data, ancestors in keys: email = data["name"].split("/serviceAccounts/")[1].split("/keys/")[0] principal = f"serviceAccount:{email}" credential = f"key:{data['name']}" in_export = parent_id in accounts # A key with no end time is treated as never expiring, so it's reported ends = data.get("validBeforeTime", "9999-") no_expiry = ends.startswith("9999-") nodes.setdefault(principal, {"id": principal, "kind": "principal", "attrs": {"in_export": in_export}}) nodes[credential] = {"id": credential, "kind": "credential", "attrs": {"key_type": "USER_MANAGED", "disabled": bool(data.get("disabled"))}} edges.append({"from": credential, "to": principal, "type": "authenticates_as", "evidence": "asset-export"}) findings.append({ "key": data["name"], "account": email, "age_days": (as_of - parse_time(data["validAfterTime"])).days, "no_expiry": no_expiry, "expired": not no_expiry and parse_time(ends) <= as_of, "disabled": bool(data.get("disabled")), "uploaded": data.get("keyOrigin") == "USER_PROVIDED", "account_in_export": in_export, # Audit logs name the key with this prefix (serviceAccountKeyName) "log_key_name": f"//iam.googleapis.com/{data['name']}", "project": data["name"].split("/")[1], "ancestors": ancestors, }) return { "format": "pas-graph/1", "nodes": sorted(nodes.values(), key=lambda n: n["id"]), "edges": sorted(edges, key=lambda e: (e["from"], e["to"], e["type"])), "findings": sorted(findings, key=lambda f: f["key"]), } if __name__ == "__main__": path, as_of = sys.argv[1], parse_time(sys.argv[2]) with open(path, encoding="utf-8") as export: print(json.dumps(inventory(export, as_of), indent=2, sort_keys=True)) Rust
//! Credential inventory over a Cloud Asset Inventory export (Part 1). //! //! Reads newline-delimited JSON exported with --content-type=resource and //! emits the series graph format: principals, credentials, and the edges //! between them, plus one finding per user-managed key. use chrono::{DateTime, NaiveDate, Utc}; use serde_json::{json, Value}; use std::collections::BTreeMap; const SERVICE_ACCOUNT: &str = "iam.googleapis.com/ServiceAccount"; const KEY: &str = "iam.googleapis.com/ServiceAccountKey"; pub fn parse_time(value: &str) -> DateTime<Utc> { // validAfterTime looks like 2023-03-02T09:20:11Z; a bare date means UTC midnight match DateTime::parse_from_rfc3339(value) { Ok(t) => t.with_timezone(&Utc), Err(_) => NaiveDate::parse_from_str(value, "%Y-%m-%d").expect("RFC 3339 timestamp or date") .and_hms_opt(0, 0, 0).unwrap().and_utc(), } } fn text<'a>(v: &'a Value, field: &str) -> &'a str { v[field].as_str().unwrap_or("") } pub fn inventory<'a>(lines: impl Iterator<Item = &'a str>, as_of: DateTime<Utc>) -> Value { let mut accounts = BTreeMap::new(); let mut keys = Vec::new(); for line in lines.filter(|l| !l.trim().is_empty()) { let asset: Value = serde_json::from_str(line).expect("one JSON asset per line"); let data = asset["resource"]["data"].clone(); match text(&asset, "asset_type") { SERVICE_ACCOUNT => { accounts.insert(text(&data, "uniqueId").to_string(), text(&data, "email").to_string()); } KEY if text(&data, "keyType") == "USER_MANAGED" => { let parent = text(&asset["resource"], "parent"); let (_, parent_id) = parent.rsplit_once('/').expect("parent is a resource name"); let ancestors = asset["ancestors"].clone(); keys.push((parent_id.to_string(), data, ancestors)); } _ => {} } } let mut nodes = BTreeMap::new(); for email in accounts.values() { let principal = format!("serviceAccount:{email}"); nodes.insert(principal.clone(), json!({"id": principal, "kind": "principal", "attrs": {"in_export": true}})); } let (mut edges, mut findings) = (Vec::new(), Vec::new()); for (parent_id, data, ancestors) in &keys { let name = text(data, "name"); let email = name.split("/serviceAccounts/").nth(1).expect("key name holds its account") .split("/keys/").next().unwrap_or(""); let principal = format!("serviceAccount:{email}"); let credential = format!("key:{name}"); let in_export = accounts.contains_key(parent_id); let created = parse_time(text(data, "validAfterTime")); // A key with no end time is treated as never expiring, so it's reported let ends = data["validBeforeTime"].as_str().unwrap_or("9999-"); let no_expiry = ends.starts_with("9999-"); nodes.entry(principal.clone()).or_insert_with(|| json!({"id": principal, "kind": "principal", "attrs": {"in_export": in_export}})); nodes.insert(credential.clone(), json!({"id": credential, "kind": "credential", "attrs": {"key_type": "USER_MANAGED", "disabled": data["disabled"].as_bool().unwrap_or(false)}})); edges.push(json!({"from": credential, "to": principal, "type": "authenticates_as", "evidence": "asset-export"})); findings.push(json!({ "key": name, "account": email, // Whole days, rounded down, to match Python's timedelta.days "age_days": (as_of - created).num_microseconds().expectSource:https://dev.to/sakurasky/from-a-leaked-pipeline-credential-to-bulk-data-49cp