Security Model
Ragz is reviewed against OWASP ASVS L2 and the OWASP LLM Top 10. Five rules carry almost the entire security posture — they're deliberately narrow and enforced structurally (one code path, one function, a route-level dependency) rather than by convention, so a reviewer can check "is this rule true" by reading one file instead of auditing every call site.
1. Tenant isolation has one code path per store
Every Postgres query against an org-owned table goes through the
TenantContext dependency (modules/tenancy/) — a request-scoped object
carrying user_id, org_id, role, workspace_ids, group_ids, and
permissions. Handlers don't write their own WHERE org_id = ... filters;
they depend on TenantContext and the module layer applies it consistently.
Every Qdrant search goes through one filter-building function in
modules/retrieval/ that intersects tenant ID, workspace membership, and
ACL-group membership. Nothing else in the codebase constructs a Qdrant
filter — there is exactly one place where "which vectors can this user see"
is decided.
Adversarial isolation tests run on every PR
backend/tests/isolation/ contains adversarial leak tests — real Postgres
and Qdrant via testcontainers, no mocked stores — that specifically try to
make one tenant see another tenant's data. They gate every PR.
2. Document ACLs are enforced inside the vector query
An answer must never cite a document the asking user cannot open — and that guarantee is enforced inside the Qdrant query itself, not by filtering results in Python after the fact. Post-filtering is explicitly disallowed: if a Qdrant query didn't already exclude a chunk, a Python-side filter downstream is not an acceptable substitute (it's easy to forget, easy to bypass with a code path that skips it, and leaves a window where the retriever briefly "saw" data it shouldn't have).
The ACL model is Drive-style rather than fully invisible:
- A restricted document (one with
acl_group_idsset) still appears in the workspace's document list for plain members — its existence isn't hidden. - Its contents, citations, and chunks are ACL-enforced at the vector query — a member outside the allowed groups can see that the document exists but can't retrieve from it or open it.
- The
acl_group_idsfield itself is admin/superadmin-only metadata — blanked tonullin API responses for plain users, so a non-admin can't even enumerate which groups gate a document.
3. Secrets live encrypted in Postgres under one external key
Every secret Ragz stores (provider API keys, and similar) is envelope-
encrypted with AES-256-GCM before it touches Postgres. The
Key Encryption Key (KEK) is the single secret that lives outside the
database — as of Phase 1, a keyfile (backend/data/ragz_kek, path
overridable via RAGZ_KEK_FILE) with 0600 permissions, containing 32
random bytes; KMS/Vault-backed sourcing arrives later behind the same
load_kek() interface.
- Decryption happens in exactly one function,
ragz.core.crypto.decrypt(re-exported frommodules/secrets/crypto.pyfor the existing import path) — chosen to live incorespecifically so bothmodules/secretsand the JWT-signing-key storage incore/app_settingscan share it without a layering violation. - Secret fields are write-only in API schemas — you can set a provider key, you can never read it back. Only a display-safe fingerprint (last 4 characters + a truncated SHA-256 hash) is ever surfaced.
- Secrets never appear in
.envfiles (beyond the DB connection string and the KEK file path itself), logs, traces, or API responses.
The KEK is a single point of loss, on purpose
This is a deliberate trade-off, not an oversight: keeping exactly one key outside the database means there's exactly one thing to protect and back up, instead of scattered secrets with inconsistent handling. But it does mean the KEK file is as critical as any private key you operate — see Backups for the backup and restore procedure, and don't skip it.
4. AuthN/AuthZ are declarative at the route boundary
- Passwords are hashed with Argon2id (the
argon2-cffilibrary's defaults) — never compared or stored in plaintext, never logged. - Sessions use a 15-minute JWT access token plus a rotating refresh token, so a stolen access token has a short blast radius.
- Permission checks are FastAPI dependencies declared on the route, not
inline
if user.role == "admin"checks buried in handler bodies. A reviewer can see a route's authorization requirement without reading past the function signature. - Rate limiting is applied on auth and chat endpoints specifically — the two surfaces most attractive for credential stuffing and abuse.
5. The LLM boundary treats documents as data and output as untrusted
Retrieved chunks are never concatenated straight into a prompt as if they were instructions — they're wrapped in delimited data blocks, so a document that happens to contain "ignore previous instructions and..." is just quoted content, not a directive the model follows.
Model output is rendered as sanitized markdown only — no raw HTML execution path from an LLM response into the browser. In v1, agent tools are read-only: the agent loop can retrieve and search, it cannot take write-side actions on your behalf. That closes off an entire class of prompt-injection-to-action attacks (an injected instruction in a document has nothing destructive to invoke, even if it convinces the model to try).
Review bar
Every change to auth, tenancy, retrieval, or secrets handling is reviewed against OWASP ASVS Level 2 and the OWASP LLM Top 10 (prompt injection, insecure output handling, training data considerations where applicable, excessive agency, and so on) — not as a one-time audit, but as the standing bar for anything touching these five rules.
See also
- Architecture — where these rules sit in the request path.
- Data Model — the entities
TenantContextand the ACL filter actually operate over. - Backups — protecting the KEK and the encrypted secrets it guards.