Google BigQuery Integration Guide
Connection Configuration
BurnLedger connects to BigQuery using a service account key with read-only permissions.
Configuration fields:
| Field | Required | Description |
|---|---|---|
project_id |
Yes | GCP project ID. |
dataset |
Yes | BigQuery dataset name. |
credentials_json |
Yes | Base64-encoded service account JSON key. |
location |
No | Dataset location (e.g., US, EU). Inferred if not set. |
read_only |
Conditional | Your assertion that the credential is read-only. Required whenever the dataset ACL cannot prove otherwise — which is the common case. See Service Account Setup. |
These are the exact JSON keys the connector reads — snake_case in both SDKs. projectId, credentialsJson, or readOnly are silently ignored, and the connection then fails with "project_id is required".
TypeScript
const system = await bl.registerSystem({
name: "analytics-users",
connectorType: "bigquery",
connectionConfig: {
project_id: "myproject-123",
dataset: "user_analytics",
credentials_json: process.env.BQ_CREDENTIALS_B64,
read_only: true,
},
subjectQuery: "SELECT * FROM `user_analytics.users` WHERE email = @subject",
});
Python
system = bl.register_system(
name="analytics-users",
connector_type="bigquery",
connection_config={
"project_id": "myproject-123",
"dataset": "user_analytics",
"credentials_json": os.environ["BQ_CREDENTIALS_B64"],
"read_only": True,
},
subject_query="SELECT * FROM `user_analytics.users` WHERE email = @subject",
)
Query Template Format
Standard BigQuery SQL with @subject as the named parameter placeholder. The identifier is bound as a named query parameter — never string-interpolated.
Not validated at registration. BigQuery templates are not checked when the system is registered: BurnLedger does not verify that the template contains
@subject, has aWHEREclause, or is a read-onlySELECT. Make sure@subjectis present — a template without it queries every row regardless of subject.
-- Simple lookup
SELECT * FROM `user_analytics.users` WHERE email = @subject
-- Cross-table join
SELECT u.id, u.email, e.event_type, e.timestamp
FROM `user_analytics.users` u
JOIN `user_analytics.events` e ON e.user_id = u.id
WHERE u.email = @subject
Service Account Setup
BurnLedger inspects the credential's access to the dataset — without ever writing. It reads the dataset's IAM Access entries and checks whether the service account principal holds a WRITER or OWNER role on the dataset.
Two things about this check are important:
- A detected
WRITER/OWNERgrant refuses the connection, withWRITE_ACCESS_DETECTED, exactly as every other connector does. This guide used to say the opposite, and it was true once: BigQuery warned and connected anyway, which made it the one backend where a credential positively observed holdingWRITERorOWNERcould be registered and have a deletion certified against it. Nothing was lost by closing it — the branch fires only on evidence read straight off the dataset ACL, so the refusal costs exactly the credentials BigQuery itself told us can write. - The dataset ACL still can never prove read-only. Project-level IAM roles (e.g.
roles/bigquery.dataEditoron the project) and grants via groups or domains confer write access without ever appearing in the dataset ACL. So every outcome other than a positiveWRITER/OWNERmatch — principal absent, listed only asREADER, no access list at all, or acredentials_jsonwhoseclient_emailcould not be parsed — is treated as inconclusive.
An inconclusive result is where the fail-closed gate lives: set "read_only": true in the connector config to assert the credential is read-only (BurnLedger only ever reads regardless). Without the assertion, construction is refused with CONNECTION_FAILED and the message "cannot verify the credential is read-only". In practice this means most BigQuery configurations need read_only: true.
Create a custom role
gcloud iam roles create burnledger_bq_readonly \
--project=myproject-123 \
--title="BurnLedger BigQuery Read-Only" \
--permissions=bigquery.datasets.get,bigquery.tables.get,bigquery.tables.getData,bigquery.jobs.create
Create a service account
gcloud iam service-accounts create burnledger-bq-ro \
--display-name="BurnLedger BigQuery Read-Only"
gcloud projects add-iam-policy-binding myproject-123 \
--member="serviceAccount:burnledger-bq-ro@myproject-123.iam.gserviceaccount.com" \
--role="projects/myproject-123/roles/burnledger_bq_readonly"
gcloud iam service-accounts keys create burnledger-bq-key.json \
--iam-account=burnledger-bq-ro@myproject-123.iam.gserviceaccount.com
base64 -i burnledger-bq-key.json
Do not grant bigquery.tables.delete, bigquery.tables.update, or bigquery.tables.updateData.
Restorable copies
A BigQuery deletion cannot be certified until the dataset's time-travel window has passed over it.
Every BigQuery dataset keeps a time-travel window, and it cannot be switched
off: max_time_travel_hours accepts 48 to 168 hours and defaults to 168.
Inside that window FOR SYSTEM_TIME AS OF reads the table as it was, and
CREATE TABLE … CLONE or a copy job turns that read back into a live table. So
rows deleted this morning are still readable, and still restorable, for as long
as the window lasts — while the ordinary SELECT BurnLedger runs returns
nothing.
When a subject query returns no rows, BurnLedger therefore establishes that no restorable point inside the window would have answered differently:
- It dry-runs the same statement — free, scans nothing, needs only the
bigquery.jobs.createthe query itself needs — and takes BigQuery's ownreferencedTableslist, resolved through views and cross-project references. Table names are never parsed out of the SQL. - It reads each referenced table's dataset for the window
(
bigquery.datasets.get) and each table's last-modified time (bigquery.tables.get). - A table last modified before its window opened has the same content at
every restorable point, so the zero holds throughout and is reported. A table
modified inside the window might have been modified by the deletion, so
the attestation is refused with
S3_VERSIONING_CONFLICTandmechanism: time travel.
Anything that cannot be established — a dry run that names no tables, a view or
external table whose last-modified time describes the definition rather than
the data, a query reading 50 or more tables (BigQuery stops promising a complete
list there), a metadata call the credential is refused — is refused with
CONNECTION_FAILED, never read as a "no".
To certify a BigQuery deletion: wait for the dataset's time-travel window to
pass over the delete, or lower max_time_travel_hours toward the 48-hour
minimum to shorten the wait:
ALTER SCHEMA `my_project.my_dataset` SET OPTIONS (max_time_travel_hours = 48);
Note that BigQuery keeps a further seven days of fail-safe storage after the time-travel window, recoverable only by Google Cloud Support. That is outside what a customer credential can restore and is not checked here.
Nothing is checked when the query finds rows — a non-zero count fails the deletion claim on its own.
The permissions this needs (bigquery.jobs.create, bigquery.datasets.get,
bigquery.tables.get) are already in the custom role above.
BigQuery-Specific Considerations
Query costs
BigQuery charges per bytes scanned. BurnLedger queries should target specific partitions and columns to minimize cost. Use partitioned tables and cluster on the subject identifier column.
Streaming buffer
Recently streamed rows may not appear in query results immediately. Wait for the streaming buffer to flush (typically under a few minutes) before creating an attestation.
Table expiration
If tables or partitions have expiration policies, data may disappear without explicit deletion. BurnLedger proves absence regardless of cause.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
CONNECTION_FAILED |
Invalid credentials or project ID; or the enclave has no route to bigquery.googleapis.com (see the status note at the top) |
Verify the service account key and project ID. From the enclave, this connector cannot work at all yet. |
CONNECTION_FAILED with "cannot verify the credential is read-only" |
The dataset ACL could not prove the principal is read-only (the usual case) | Set "read_only": true in the connector config. |
BAD_REQUEST with "project_id is required" |
Config used camelCase keys (projectId, credentialsJson) |
Use the snake_case keys exactly as listed in Connection Configuration. |
S3_VERSIONING_CONFLICT: "still holds a recoverable copy … via time travel" |
A table the query reads was written to inside its dataset's time-travel window, so the deleted rows are still restorable | Wait for the window to pass, or lower max_time_travel_hours. See Restorable copies. |
CONNECTION_FAILED: "cannot determine whether BigQuery retains a recoverable copy" |
The time-travel check could not run — a denied bigquery.tables.get / bigquery.datasets.get, a view among the referenced tables, or a query reading 50 or more tables |
Read the reason on the error; it names which of the three it was and what to grant or change. |
WRITE_ACCESS_DETECTED |
Not produced by the BigQuery connector. A WRITER/OWNER grant only raises a diagnostic warning and the connection proceeds |
If you see this code, it came from another system in the same attestation. To act on the warning, remove bigquery.tables.updateData and bigquery.tables.delete from the role. |