Skip to content

feat(db-bigquery): accept the service account key as a string, from the environment - #3039

Merged
lloydtabb merged 3 commits into
mainfrom
bq-service-account-key-json
Aug 15, 2026
Merged

lloydtabb merged 3 commits into
mainfrom
bq-service-account-key-json

Conversation

@lloydtabb

@lloydtabb lloydtabb commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

The gap

BigQuery has two credential slots today, and neither one works for a server that holds its service account key in an environment variable:

  • serviceAccountKeyPath — file-typed, so {env: …} resolves, but it needs a file.
  • serviceAccountKey — takes the key itself, but it is json-typed, and a json slot holds its value literally. config_compile.ts refuses reference indirection into structured config on purpose ("the security invariant that prevents reference injection into structured config"), so {env: …} is never resolved there.

The result is that this config:

"bq": {
  "is": "bigquery",
  "projectId": "my-project",
  "serviceAccountKey": {"env": "BIGQUERY_CREDENTIALS_JSON"}
}

hands the SDK the literal object {env: "BIGQUERY_CREDENTIALS_JSON"} as its credentials, which fails as:

The incoming JSON object does not contain a client_email field

The environment variable is never read, so the error is identical whether it holds the key, a path, or nothing — and it names neither the property nor the reason. client_email/private_key are honored by the constructor but are not declared properties, so the compiler drops them with an "unknown property" warning before the connection sees them.

The change

One new secret-typed (string) property, where references do resolve:

"serviceAccountKeyJson": {"env": "BIGQUERY_CREDENTIALS_JSON"}

It takes the entire key file as either raw JSON or base64, detected rather than declared — JSON always starts with {, and { is not in the base64 alphabet, so the two can be told apart with certainty. Base64 is worth accepting because it is a single unquoted token: a JSON object full of braces and quotes survives a shell, a CI secret editor, and a .env file poorly. (An earlier revision of this PR had two properties and justified base64 by newline mangling. That was wrong — a downloaded key file carries the private key's newlines as \n escapes, so the JSON is one line with no newlines to mangle. One property, correct reason.)

Details worth a second look in review:

  • The parsed value is validated, not just type-checked as an object. It must carry client_email + private_key, or be an external_account config — the other shape the SDK's credentials option accepts (GoogleAuthOptions.credentials: CredentialBody | ExternalAccountClientOptions), which carries no key of its own. Without this, {} and any unrelated JSON reach the SDK and come back as the very client_email error this property exists to eliminate.
  • Precedence: serviceAccountKey → serviceAccountKeyJson → client_email/private_key. Existing behavior is unchanged when the new property is unset.
  • Values are trimmed, and empty or whitespace-only ones are skipped: {env: X} with X set-but-empty resolves to "" and must not read as "the key is here", and a key that arrived via a here-doc or $(cat key.json) carries a trailing newline that would otherwise break the encoding sniff.
  • Errors quote nothing. JSON.parse's own SyntaxError embeds the input it choked on, which here is a private key. One message covers both encodings, because Buffer's base64 decoder drops unrecognized characters rather than failing — by the time the parse fails, there is no way to know which encoding was meant.
  • Raw key material never reaches this.config; every key-bearing property is destructured out.

Tests

packages/malloy-db-bigquery/src/bigquery_credentials.unit.spec.ts, 20 hermetic tests (no warehouse): both encodings through one property, whitespace tolerance, newline restoration from \n escapes, external-account acceptance, key material staying out of the retained config, empty/whitespace fallthrough, the precedence ladder, and each error path — including that the malformed message contains neither the key nor the text it came from, and that {} and a half-a-pair key are rejected here rather than at the first query.

Three drive the real overlay stack through MalloyConfig with a set environment variable — the end-to-end claim this PR makes. One pins the old behavior for contrast: serviceAccountKey: {env: …} still lands the reference object in the credentials, so if malloy ever resolves references into json slots, that test fails and says so.

Two more read the registration back: that the property is secret (retyping it to json would silently restore the whole gap — verified by mutation that exactly those tests fail when it does), and that no second base64 property exists.

The live db-bigquery suite (149 tests) also passes against a real warehouse with this change.

Two follow-ups included

  • connection/CONTEXT.md now documents the property and the precedence order. Its BigQuery list was also missing serviceAccountKey entirely; that is fixed. Per the cross-repo obligation in that file, the companion docs PR is docs(config): supplying a BigQuery service account key from the environment malloydata.github.io#339.
  • connector-unit jest project (separate commit, droppable on its own). packages/malloy-db-*/**/*.unit.spec.ts are hermetic but live beside tests that need a warehouse, so they ran only in that backend's credentialed CI job — four specs across three connector packages, including this one. They now run in ci-core on every PR. The backend projects exclude the same pattern so each file runs in exactly one project; a file matched by two would appear twice in jest --listTests and fail scripts/ci-test-sanity-check.sh. Verified locally: sanity check passes, no duplicates, all 54 connector unit tests run with no credentials present.

… string

`serviceAccountKey` is a `json`-typed property, and a json-typed slot takes
its value literally — the config compiler refuses reference indirection into
structured config, so an `{env: "..."}` reference is never resolved in one. A
deployment whose credentials live in an environment variable rather than on
disk therefore cannot reach the slot at all: the literal `{env: "..."}` object
is what lands in the credentials, and the SDK reports it as "the incoming JSON
object does not contain a client_email field" — an error that names neither
the property nor the reason.

Add `serviceAccountKeyJson` and `serviceAccountKeyJsonBase64`, both `secret`
strings, so a reference resolves and the connection parses what arrives. The
base64 form is for transports that mangle the quoting and embedded newlines of
raw JSON. Going through JSON also restores the private key's newlines from its
`\n` escapes, which supplying client_email/private_key as separate strings
does not.

Precedence is serviceAccountKey, then Json, then JsonBase64, then
client_email/private_key; an empty value is skipped, as an unset environment
variable produces. Parse failures name the property and what it expects
without quoting the value, since JSON.parse's own SyntaxError would put a
private key in the message. All three key-bearing properties are destructured
out of the retained config, so raw key material never comes to rest there.

Signed-off-by: lloyd tabb <lloyd@malloydata.org>
@lloydtabb

Copy link
Copy Markdown
Collaborator Author

@mtoy-googly-moogly do you think "serviceAccountKeyJsonBase64": {"env": "BIGQUERY_CREDENTIALS_JSON_B64"} is overkill? I saw it being used on the web so I thought to do it, but maybe the plain json is enough.

…te the key

Review follow-ups on the property added in the previous commit.

One property, not two. The encoding is detected rather than declared: JSON
always begins with `{`, and `{` is not in the base64 alphabet, so the two
cannot be confused. A deployment that switches encodings no longer has to edit
config as well. Values are trimmed first, since a key that arrived through a
here-doc or `$(cat key.json)` carries a trailing newline that would otherwise
be read as content by both the sniff and the emptiness check.

Validate what was parsed. Checking only for "an object" let `{}` and any
unrelated JSON reach the SDK and come back as "the incoming JSON object does
not contain a client_email field" — the exact error this property exists to
eliminate. A key must now carry client_email and private_key, or be an
`external_account` config, which is the other shape the SDK's `credentials`
option accepts and which carries no key of its own. The check is a type
predicate, so the cast to CredentialBody is gone.

Correct the base64 rationale. It said raw JSON's newlines get mangled in
transit; a downloaded key file carries its newlines as `\n` escapes, so the
JSON is a single line and has none to mangle. The real reason is quoting —
braces and quotes survive a shell, a CI secret editor, and a `.env` file
poorly, and base64 is one token that survives all three.

Also: document the property in connection/CONTEXT.md, whose BigQuery list was
missing serviceAccountKey as well; and fix a test name that claimed to cover
an unset environment variable when an unset reference is dropped before the
connection sees it. It covers set-but-empty, now also set-but-whitespace.

Signed-off-by: lloyd tabb <lloyd@malloydata.org>
`packages/malloy-db-*/**/*.unit.spec.ts` are hermetic — they stub the SDK or
test config parsing — but they sit in the same directories as the tests that
need a live warehouse, so they ran only in that backend's credentialed CI job.
A hermetic test of BigQuery config parsing sat out every PR that didn't touch
BigQuery, which is exactly the PR that breaks it. Four specs across three
connector packages were in that position.

Add a `connector-unit` project that claims them and run it in `ci-core`. The
backend projects exclude the same pattern, so each file still runs in exactly
one project: a file matched by two would appear twice in `jest --listTests`
and fail scripts/ci-test-sanity-check.sh, which diffs that list against the
source tree.

Signed-off-by: lloyd tabb <lloyd@malloydata.org>
lloydtabb added a commit to malloydata/malloydata.github.io that referenced this pull request Aug 14, 2026
Follows the change in malloydata/malloy#3039 to a single
`serviceAccountKeyJson` that takes JSON or base64 and detects which. Drops the
separate base64 property, and states the true reason base64 is offered:
quoting, not newlines — a downloaded key file carries its newlines as `\n`
escapes, so the JSON is one line with none to mangle.

Signed-off-by: lloyd tabb <lloyd@malloydata.org>
@lloydtabb lloydtabb changed the title feat(db-bigquery): accept the service account key as a JSON or base64 string feat(db-bigquery): accept the service account key as a string, from the environment Aug 14, 2026
@lloydtabb
lloydtabb merged commit 57170db into main Aug 15, 2026
19 checks passed
@lloydtabb
lloydtabb deleted the bq-service-account-key-json branch August 15, 2026 16:41
lloydtabb added a commit to malloydata/malloydata.github.io that referenced this pull request Aug 15, 2026
…onment (#339)

* docs(config): document supplying a BigQuery service account key from the environment

`serviceAccountKey` is a `json` property, and json properties take their value
literally — an `{"env": "..."}` inside one is never resolved. A server holding
its key in an environment variable therefore cannot use that property, and the
failure arrives as an error from BigQuery about a missing client_email field
rather than anything naming the config.

Document `serviceAccountKeyJson` and `serviceAccountKeyJsonBase64`
(malloydata/malloy#3039), which are `secret` strings and do take references,
with the shell commands that produce each form. Also state the json-property
exception in the Environment Variables section, which until now claimed
without qualification that any property value can be replaced with a
reference.

Signed-off-by: lloyd tabb <lloyd@malloydata.org>

* docs(config): one BigQuery key property, with the encoding detected

Follows the change in malloydata/malloy#3039 to a single
`serviceAccountKeyJson` that takes JSON or base64 and detects which. Drops the
separate base64 property, and states the true reason base64 is offered:
quoting, not newlines — a downloaded key file carries its newlines as `\n`
escapes, so the JSON is one line with none to mangle.

Signed-off-by: lloyd tabb <lloyd@malloydata.org>

* docs(config): drop the encoding-detection and json-exception paragraphs

Review feedback on #339. The detection rule (JSON starts with `{`, base64
can't) is implementation detail nobody reading this page needs — both
encodings work, which the examples already show. The json-property exception
in the Environment Variables section is dropped too; the note in the BigQuery
section covers the case a reader actually hits, and postgres and trino already
carry their own.

Signed-off-by: lloyd tabb <lloyd@malloydata.org>

---------

Signed-off-by: lloyd tabb <lloyd@malloydata.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant