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_ids set) 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_ids field itself is admin/superadmin-only metadata — blanked to null in 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 from modules/secrets/crypto.py for the existing import path) — chosen to live in core specifically so both modules/secrets and the JWT-signing-key storage in core/app_settings can 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 .env files (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-cffi library'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 TenantContext and the ACL filter actually operate over.
  • Backups — protecting the KEK and the encrypted secrets it guards.