2026-08-08-rls-cls-design.md 7.6 KB

Row- and Column-Level Security — Design

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).

Why

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:

  • Every key holder has identical, complete access.
  • Views are not a boundary: they only reject writes on a view name, and the underlying collection stays readable.
  • Projects are not a boundary: 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.
  • Field-level encryption is encryption at rest; decryptSensitiveFields() runs for every authenticated reader.

Approved decisions

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.

Model

Principal resolution

Resolved once per RPC, before the handler runs:

  1. Listener has auth and the token matches a named key → that name.
  2. Listener has auth and the token matches a bare key → unnamed.
  3. Listener has no auth → 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.

Policy record

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.
  • Absent collection entry, with no "*" fallback → denied (decision 4).

Per-project enable + audit mode

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.

Lockout prevention

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.

Semantics decided by the implementer (not open questions)

  • Masked fields cannot be filtered on. A filter naming a masked path is rejected with INVALID_ARGUMENT. Allowing it turns the mask into an inference channel: salary > 100000 leaks a value the caller may not read.
  • A write touching a masked field is denied, not silently dropped. Silent field loss is worse than an error.
  • 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.
  • System collections (_-prefixed) are never reachable by a non-admin principal in a security-enabled project.

Staging

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 deduplication and cross-project leakage

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.