# 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 ``. Mirrors how `_views` works, so it is WAL'd and snapshotted with no new persistence machinery. ```json { "_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: ```json { "_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.