What to create in your GCP project (regional or multi-region, GoogleSQL
dialect — DBSheriff's queries are written for GoogleSQL, and databases created
with the PostgreSQL interface are not supported) so DBSheriff can monitor it. Unlike every
SQL-GRANT engine this product supports, Spanner has no internal
user/login/password concept at all — access control is entirely IAM.
"Creating an account" here means creating a service account and binding IAM
roles to it, not CREATE USER/GRANT.
Every fact and command in this guide was run against a live Spanner instance — this is not written from GCP documentation alone.
| Item | Required? | What it unlocks | Without it | How |
|---|---|---|---|---|
| The five service accounts of §1 with the IAM roles of §2 | required | everything: statements, sessions, kill, lock, posture, classification | DBSheriff cannot connect or cannot act | §2 |
spanner.googleapis.com + cloudresourcemanager.googleapis.com + logging.googleapis.com + iam.googleapis.com APIs enabled |
required | the instance/database to exist at all, and the audit-log read below | API calls fail | gcloud services enable |
Cloud Audit Data Access logging (DATA_READ/DATA_WRITE) for spanner.googleapis.com |
required for auth/statement attribution | the only durable, principal-attributed record of who ran what — SPANNER_SYS itself carries no user/principal column at all, live-verified |
every live/completed query is attributed "unknown", and the governance collector's auth/statement tables stay empty |
project-level gcloud projects set-iam-policy audit config — off by default, unlike BigQuery |
roles/logging.privateLogViewer for the lock service account |
required for the item above | permission to read Data Access log content — a materially different, easy-to-miss grant from roles/logging.viewer, live-verified: the general viewer role gets "Permission denied for all log views" reading these entries |
session-to-principal attribution and the governance collector's auth/statement audit both stay empty even with Data Access logging on | IAM, project-scoped |
roles/iam.securityReviewer for the posture service account |
required for PS09 | permission to read the project's IAM audit config, to check whether Data Access logging (the row above) is actually on | PS09 cannot evaluate, skipped with a logged warning | IAM, project-scoped |
The floor. The five service accounts with database-scoped IAM roles
gives you live query monitoring, classification, and most posture checks.
Add Data Access logging + roles/logging.privateLogViewer (lock) +
roles/iam.securityReviewer (posture) for real principal attribution,
PS09, and the governance collector's auth/statement audit trail — all
project-level, one-time settings, no instance restart.
| Account | What it does | IAM roles |
|---|---|---|
spanner-monitor |
reads SPANNER_SYS.OLDEST_ACTIVE_QUERIES/QUERY_STATS_TOP_MINUTE, instance metadata |
roles/spanner.databaseReader (database scope), roles/spanner.viewer (instance scope) |
spanner-kill |
terminates sessions via DeleteSession |
roles/spanner.databaseUser (database scope) |
spanner-lock |
removes/restores the locked principal's own database-level IAM binding; best-effort session termination; reads Data Access logs for both kill's session cache and the governance collector | roles/spanner.databaseAdmin (database scope), roles/logging.privateLogViewer (project scope) |
spanner-posture (optional — monitor is used when absent) |
posture scan's IAM-policy checks (PS03/04/05) borrow the lock credential directly for getIamPolicy — see §2; posture's own role covers PS09 |
roles/spanner.databaseReader (database scope), roles/iam.securityReviewer (project scope) |
spanner-classify |
reads schema + samples values for classification | roles/spanner.databaseReader (database scope) |
None holds roles/spanner.databaseAdmin/roles/owner/roles/editor
outside its own database-scoped binding. Posture (PS05) flags any other
principal holding one of those roles at the database level as an over-broad
admin — DBSheriff's own accounts are exempt by service-account naming
pattern (spanner-monitor/spanner-kill/spanner-lock/spanner-posture/
spanner-classify).
PROJECT=<your-project-id>
INSTANCE=<your-instance-id>
DATABASE=<your-database-id>
gcloud services enable spanner.googleapis.com cloudresourcemanager.googleapis.com \
logging.googleapis.com iam.googleapis.com --project="${PROJECT}"
for role in monitor kill lock posture classify; do
gcloud iam service-accounts create "spanner-${role}" \
--project="${PROJECT}" --display-name="DBSheriff Spanner ${role}"
done
# Database-scoped roles (every account)
gcloud spanner databases add-iam-policy-binding "${DATABASE}" \
--instance="${INSTANCE}" --project="${PROJECT}" \
--member="serviceAccount:spanner-monitor@${PROJECT}.iam.gserviceaccount.com" \
--role="roles/spanner.databaseReader"
gcloud spanner databases add-iam-policy-binding "${DATABASE}" \
--instance="${INSTANCE}" --project="${PROJECT}" \
--member="serviceAccount:spanner-kill@${PROJECT}.iam.gserviceaccount.com" \
--role="roles/spanner.databaseUser"
gcloud spanner databases add-iam-policy-binding "${DATABASE}" \
--instance="${INSTANCE}" --project="${PROJECT}" \
--member="serviceAccount:spanner-lock@${PROJECT}.iam.gserviceaccount.com" \
--role="roles/spanner.databaseAdmin"
gcloud spanner databases add-iam-policy-binding "${DATABASE}" \
--instance="${INSTANCE}" --project="${PROJECT}" \
--member="serviceAccount:spanner-posture@${PROJECT}.iam.gserviceaccount.com" \
--role="roles/spanner.databaseReader"
gcloud spanner databases add-iam-policy-binding "${DATABASE}" \
--instance="${INSTANCE}" --project="${PROJECT}" \
--member="serviceAccount:spanner-classify@${PROJECT}.iam.gserviceaccount.com" \
--role="roles/spanner.databaseReader"
# Instance-scoped (monitor only — config snapshot's get_instance())
gcloud spanner instances add-iam-policy-binding "${INSTANCE}" \
--project="${PROJECT}" \
--member="serviceAccount:spanner-monitor@${PROJECT}.iam.gserviceaccount.com" \
--role="roles/spanner.viewer"
# Project-scoped (lock and posture only)
gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:spanner-lock@${PROJECT}.iam.gserviceaccount.com" \
--role="roles/logging.privateLogViewer"
gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:spanner-posture@${PROJECT}.iam.gserviceaccount.com" \
--role="roles/iam.securityReviewer"
# One JSON key per account — this is what goes into the Admin Console's
# Monitored Servers tab as the monitor/kill/lock/posture/classification
# "service account key" field for this server.
for role in monitor kill lock posture classify; do
gcloud iam service-accounts keys create "spanner-${role}-key.json" \
--iam-account="spanner-${role}@${PROJECT}.iam.gserviceaccount.com"
done
# Data Access logging for spanner.googleapis.com — OFF by default, unlike
# BigQuery. Requires reading, editing, and re-applying the project's IAM
# policy (auditConfigs is not a simple additive binding).
gcloud projects get-iam-policy "${PROJECT}" --format=json > /tmp/policy.json
# add to the JSON's top level:
# "auditConfigs": [{"service": "spanner.googleapis.com",
# "auditLogConfigs": [{"logType": "DATA_READ"}, {"logType": "DATA_WRITE"}]}]
gcloud projects set-iam-policy "${PROJECT}" /tmp/policy.json
Add the server from the Admin Console's Monitored Servers tab:
server_name, engine spanner, host = project ID, dbname =
instance ID, warehouse = database ID (Spanner needs three
identifiers where most engines need at most two, so these generic fields are
repurposed, as BigQuery repurposes dbname for its GCP project ID), and the
five service-account key JSON files
above as the monitor/kill/lock/posture/classification credentials.
env_tier/burst_high_volume_threshold as with every other engine. Add
producer-spanner-pool (and, for the full audit/posture pipeline,
spanner-governance-collector) to ENABLED_PRODUCERS.
Monitoring of a newly added server starts within about five minutes. The
producer and the collector read the list of servers every
REGISTRY_RELOAD_INTERVAL_S seconds (300 by default); nothing needs a
restart. Until then the producer logs no active Spanner servers (and, when
it has others, nothing about the new one) — that is the wait, not an error.
roles/spanner.databaseReader (database scope) — read-only access to
SPANNER_SYS.*, information_schema.*, and application table data.
Live-verified: does not carry spanner.databases.getIamPolicy — PS03/
04/05's IAM-policy checks borrow the lock credential for that one call
instead.roles/spanner.databaseUser (database scope) — confirmed sufficient
for session create/query/DeleteSession. This is what makes kill work;
databaseReader alone was not tested as sufficient and is not assumed to
be.roles/spanner.databaseAdmin (database scope) — confirmed sufficient
for getIamPolicy/setIamPolicy at the database level, which is what
lock/unlock actually do (there is no ALTER USER ... DEACTIVATE — locking
a principal means removing its own IAM binding on this database, nothing
broader). Deliberately bound at the database level, not project-wide,
so a lock action can never touch a principal's access outside this one
database.roles/spanner.viewer (instance scope) — read-only instance metadata
(GetInstance: processing units, instance config, edition). Live-verified
that roles/spanner.databaseAdmin bound at the database level does
not carry this — a database-scoped grant does not imply anything at
the instance level, confirmed via a 403 on GetInstance under the lock
credential alone. This is why the governance collector's config snapshot
reads instance facts with the monitor credential and database facts
(dialect, encryption) with the lock credential.roles/logging.privateLogViewer (project scope) — live-verified
the single most easily-missed grant in this whole guide. Reading Cloud
Audit Data Access log content needs this specific role; the general
roles/logging.viewer gets "Permission denied for all log views" on the
exact same query. Every session-to-principal attribution (the live
producer's cache, the governance collector's auth/statement audit) goes
through this one grant.roles/iam.securityReviewer (project scope) — the narrow,
read-only, purpose-built role for reading a project's IAM audit config
(cloudresourcemanager.googleapis.com's getIamPolicy) — not a Spanner
role at all, but the least-privilege way to let PS09 check whether Data
Access logging is actually enabled.Unlike every SQL-GRANT engine, there is no CREATE AUDIT POLICY/
ALTER SYSTEM step — SPANNER_SYS.OLDEST_ACTIVE_QUERIES (live queries) and
SPANNER_SYS.QUERY_STATS_TOP_MINUTE/_10MINUTE/_HOUR (statement
statistics aggregated by query shape, not per execution) are always-on
system views, readable the moment roles/spanner.databaseReader is
granted. Neither carries a principal/user column at all — this is a
structural Spanner limitation, not a missing grant.
The only way to attribute a session or a statement to a real identity is
Cloud Audit Data Access logging, which is off by default (unlike
BigQuery, where it is always on) — enable it per §0/§1 above. Once enabled:
CreateSession/DeleteSession entries give session-to-principal mapping
(the live producer's own attribution cache, refreshed every 60s by
default); ExecuteSql/ExecuteStreamingSql entries carry the literal SQL
text, live-verified, and are a genuinely per-execution durable audit
trail — finer-grained than QUERY_STATS_TOP_MINUTE's shape-level
aggregation.
# service accounts + database-scoped bindings
gcloud spanner databases get-iam-policy "${DATABASE}" --instance="${INSTANCE}" --project="${PROJECT}"
# instance-scoped binding (monitor only)
gcloud spanner instances get-iam-policy "${INSTANCE}" --project="${PROJECT}"
# Data Access logging actually on
gcloud projects get-iam-policy "${PROJECT}" --format=json | grep -A5 spanner.googleapis.com
# lock credential can read Data Access log content (run with GOOGLE_APPLICATION_CREDENTIALS
# pointed at spanner-lock-key.json) — a "Permission denied for all log views" error here
# means roles/logging.privateLogViewer is missing or not yet propagated (~1-2 min after grant)
gcloud logging read 'protoPayload.methodName="google.spanner.v1.Spanner.CreateSession"' \
--project="${PROJECT}" --limit=1 --freshness=1h
Expected producer log line on first successful poll:
[<server>][poll] nothing new next poll in <N>s (or a captured statement,
logged as [<server>][RISK] or [<server>][SAFE], if one was running), with user= resolving to a real service-account email rather
than unknown once a session has existed long enough for its CreateSession
event to be ingested (observed live: under 30 seconds, typically) — an
attribution that never resolves past unknown at all means either Data
Access logging is off, or the lock credential's roles/logging.
privateLogViewer grant is missing.
DeleteSession
via the Sessions API, and it terminates the entire session (every
statement on it), not one in-flight statement. DeleteSession cannot
touch a multiplexed session at all — Google's own server rejects the
call outright ("Multiplexed sessions may not be deleted"), and DBSheriff
reports this clearly (KILL_FAILED, not a crash) rather than guessing.
The real problem: every officially supported Spanner client library —
Python, Java, Go, and Node.js — now defaults to multiplexed sessions for
read-write work, not just lightweight read-only queries, live-confirmed
against all four. Python, Java, and Node.js keep an
opt-out (two environment variables your application would set, not
something DBSheriff can see or control:
GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS=false and
GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS_FOR_RW=false); Go has none —
the traditional session pool was removed from that client entirely in
2025, so kill is structurally impossible for a Go-based application no
matter what you configure. In short: kill only works if your
application's own Spanner client has been explicitly configured to avoid
multiplexed sessions, which is not the default for any of the four
languages and not achievable at all in Go. The lock ladder's
best-effort "terminate existing sessions now" step hits the exact same
wall for the same reason.IAM_UNBIND (revoking the
principal's database-level IAM binding) is real and does eventually
block all new sessions, but GCP's own IAM propagation is what gates how
fast — live-measured at up to 77 seconds in one run before a fresh
session attempt actually started failing. Combined with the point above,
a locked identity can keep running queries, including the one that
triggered the lock, for well over a minute with no faster mechanism
available today.throttle response mode does nothing for this engine: a server can still
be set to it, but there is no action to take, so the response is recorded
as RESPONSE_THROTTLE_FAILED and the session is left alone (BigQuery, by
comparison, falls back to revoke); IAM_UNBIND (lock) is the ceiling
response.spanner_object_protection table the way every SQL-GRANT engine gets
one.SPANNER_SYS.QUERY_STATS_TOP_MINUTE is aggregated by query shape and
by minute, not per execution. The producer's "completed query" event is
synthetic — one per statement shape and finished minute, carrying that
minute's execution count, reported once — not one row per real statement,
and with no login, session or client address. Spanner keeps in the view
the statements it ranks highest, not all of them, so a statement run once
can be absent from it, and a statement that begins and ends between two
polls is not seen as running either: the completed path is not an audit.
The governance collector's ExecuteSql/ExecuteStreamingSql Data Access
log read (§3) is the genuine per-execution trail, gated on Data Access
logging being enabled; it reads back over the last 2 minutes on every
pass and over the last 15 minutes once every 5 minutes
(SPANNER_GOVERNANCE_LOG_OVERLAP_S, SPANNER_GOVERNANCE_STATEMENT_OVERLAP_S,
SPANNER_GOVERNANCE_STATEMENT_SWEEP_S) because Cloud Audit writes a long
statement's entry when it ends, stamped with when it began. A statement
that runs longer than the window it is read with is missed.SPANNER_IDENTITY_HOLD_S (60 seconds; 0 turns it off) and scores them with
the login once it is known. A session older than the logging delay is
attributed at the first poll; the first statement of a new one is acted on
after about half a minute. A session created before DBSheriff's attribution
window (SPANNER_SESSION_CACHE_LOOKBACK_S, five minutes) cannot be found by
that window, as after a restart of DBSheriff for the pooled connections of an
application. When its statement is as old as the hold, DBSheriff looks the
session up in the Cloud Audit log by its id: one query for each 50 such
sessions, over the last 24 hours (SPANNER_SESSION_LOOKBACK_H, 1 to 168), at most 6
queries a minute for the project (SPANNER_SESSION_LOOKUPS_PER_MIN, 0 to 60;
0 turns the lookup off), and a session that is not found is not looked up
again for 15 minutes (SPANNER_SESSION_NEGATIVE_TTL_S, 900 seconds; the
Cloud Logging read quota is per project). A session the lookup does not find
(Data Access logging off, older than the look-back, or the limit used up) is
scored as "no login" once its statement is as old as the hold, as before;
a login is never guessed. With the hold turned off there is no lookup.governance.spanner_statement_audit.succeeded/error_message are
always written NULL rather than guessing.