STATUS: implemented, shipped as v2.7.0. Access policy is off by default per project. The design held, but two things only surfaced during implementation and are load-bearing: principal identity MUST be resolved per call rather than from the gRPC
AuthContext(which is per-connection, and channel pooling made three distinct keys resolve as one), and system collections need a special case in the gate because they are global rather than project-scoped. See the v2.7.0 entry inCLAUDE.md.
Status: approved, not yet implemented
Date: 2026-08-08
Supersedes: the claim in docs/integration-guide.md that view filters are a
security boundary (corrected 2026-08-08, commit d8e6a2f-adjacent).
The database currently has no access control beyond authentication. Auth (2.4+) compares a bearer token against a flat list of shared keys per listener and attaches no identity to the call, so the server cannot know who is asking. Consequences established by inspection and live experiment:
Config::project is client-chosen and a
fully-qualified other_project:collection name is accepted from any client.
Demonstrated in practice — a default-project CLI reading
smartbotic-automation:image_hashes.decryptSensitiveFields()
runs for every authenticated reader.These were decided by the operator on 2026-08-08. Do not re-litigate them without checking back.
| # | Decision | Rationale |
|---|---|---|
| 1 | Principal = named API key. auth.keys[] entries may be {"name": "...", "key": "..."}. Bare strings remain valid and map to the reserved principal unnamed. |
Smallest change that yields an identity; no PKI or token issuer to operate. mTLS/JWT remain possible later since the principal is resolved behind one interface. |
| 2 | Enforcement covers reads, writes, version history, and files. | Read-only enforcement is a half-boundary: a principal that cannot read a row but can overwrite it is still an exposure. Version history returns old document bodies, so omitting it would bypass column masks trivially. |
| 3 | Callers with no principal get the reserved principal anonymous, grantable like any other. |
Keeps smartbotic-db-cli and local operator tooling working by explicit grant rather than by accident, and makes local access visible in the policy itself. |
| 4 | Security is per-project and OFF by default. Once enabled for a project, access within it is deny-by-default. | Not all consumers want this. Deny-by-default is what makes an enabled project an actual boundary. Mitigated by audit mode (below). |
| 5 | Files become project-scoped first, then policied per project + fileType. | Files have no project dimension today, so "per-project file policy" has no referent. This is the only option that satisfies decision 4 for files. |
Resolved once per RPC, before the handler runs:
unnamed.anonymous.Reserved names, rejected as key names in config: anonymous, unnamed.
Propagated via grpc::AuthContext::AddProperty("sbdb_principal", name) plus
SetPeerIdentityPropertyName("sbdb_principal"). The AuthContext* argument to
BearerAuthProcessor::Process is currently ignored — this is the hook.
For listeners with no auth processor there is no Process call at all, so the
handler-side resolver must treat "no sbdb_principal property" as anonymous.
One document per (principal, project) in the project's _policies system
collection, keyed <principal>. Mirrors how _views works, so it is WAL'd and
snapshotted with no new persistence machinery.
{
"_id": "shadowman",
"admin": false,
"collections": {
"users": { "read": true, "write": false, "mask": ["ssn", "profile.dob"], "row": [ { "field": "tenant", "op": "EQ", "value": "acme" } ] },
"sessions": { "read": true, "write": true }
},
"files": {
"plugin": { "read": true, "write": false },
"generated": { "read": true, "write": true }
}
}
collections / files keys support the exact name or "*" as a fallback.
Exact match wins over "*".mask uses the same dot-notation paths as view exclude, and reuses
applyProjection().row uses the same Filter shape as view where, AND-merged with the
caller's filters exactly as views already do."*" fallback → denied (decision 4).Reserved document __security__ in the same _policies collection:
{ "_id": "__security__", "enabled": true, "mode": "enforce" }
mode is "enforce" or "audit". Audit mode evaluates every policy and logs
what it would deny, then allows the request. This exists so a live project
can be switched on safely: enable in audit, watch the log until it is quiet,
then flip to enforce. Without it, decision 4 would lock out consumers the
moment security is enabled.
enabled: true is refused unless at least one policy in that project has
admin: true. This makes lockout structurally impossible rather than a matter
of operator care. admin: true grants read/write on everything in that project
plus the right to modify _policies.
Writes to _policies are themselves policy-checked: only an admin principal
in that project, or any principal when the project has security disabled.
INVALID_ARGUMENT. Allowing it turns the mask into an inference
channel: salary > 100000 leaks a value the caller may not read.Count respects the row predicate, because it is served from the same
scan() path as Find (2.4.4+).SimilaritySearch applies the row predicate before scoring, not after.
Filtering after scoring leaks membership through score distribution._-prefixed) are never reachable by a non-admin
principal in a security-enabled project.Three independently shippable releases. Each is testable on its own; the third depends on the first two.
Three independently shippable releases, in this order (set by the operator 2026-08-08: ship and install project-scoped files first, then the policy system).
| Release | Content | Plan |
|---|---|---|
| v2.6.0 | Project-scoped files. BREAKING (proto + client API). Includes migration of existing files to the default project. Ships and installs before any policy work. |
docs/superpowers/plans/2026-08-08-project-scoped-files-v2.6.0.md |
| v2.7.0 | Named API keys, principal resolution, principal in AuthContext, principal in the audit log. Additive and back-compatible — no enforcement yet. |
to be written |
| v2.8.0 | Policy store, enforcement across reads/writes/history/files, audit mode, CLI. | to be written |
Files go first because the breaking proto/client change is independent of the security semantics and is a prerequisite for file policies; landing it alone keeps the blast radius of each release small.
Blob storage dedups by checksum globally, and StoreResult.deduplicated is
returned to the caller. Left as-is, that flag tells project A that project B
already holds a given byte sequence — a cross-project existence oracle.
Resolution (implemented in v2.6.0): keep global blob dedup so the disk saving is
retained, but compute deduplicated from whether the requesting project
already references that checksum. No leak, no extra disk. getRefCount() stays
global and becomes operator-only.