This document is the source of truth for Delta-9's security model. It combines (a) the threat model + audit findings, (b) the per-agent RBAC matrix that defines who can do what, and (c) the current enforcement state for each control.
Last revised: 2026-05-19. Re-audit cadence: every two months OR after any change that adds an agent, an external dependency, or a new authentication surface.
┌──────────────────────────────┐
│ External (untrusted) │
│ Loyverse / Stripe / │
│ Square / LINE webhooks │
└────────────┬─────────────────┘
│ HMAC-verified payload
▼
┌───────────────────────────────────────────┐
│ Cluster edge (Traefik + Tailscale ACL) │
│ - panel.tailnet │
│ - n8n webhook receiver │
└────────────┬──────────────────────────────┘
│ mTLS / IP-allowlist
▼
┌────────────────────────────────────────────────────┐
│ Service plane │
│ - orchestrator / engines / gates / summarizer │
│ - panel / web dashboard │
│ - LiteLLM (chokes ALL outbound LLM) │
└────┬─────────────────┬─────────────────┬───────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌───────────┐
│ Redis │ │ Postgres │ │ Ollama │
│ broker │ │ (panel + │ │ (local) │
│ queues │ │ pgvector)│ │ │
└─────────┘ └──────────┘ └───────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Outbound third-party (cost / data exit) │
│ Anthropic API · Gemini · Langfuse · LINE │
│ (only LiteLLM + n8n + line_notifier reach) │
└──────────────────────────────────────────────┘
Trust shrinks as you move down. Every boundary must enforce something:
| Boundary | Control today | Gap |
|---|---|---|
| External → Cluster edge | HMAC on webhook receiver (Stripe/Square/Loyverse); Tailscale ACL on panel | Some sources allow empty secret (signature skipped); see Finding §3.1 |
| Edge → Service plane | TOTP+JWT (panel); HTTP basic over LAN (web) | None major |
| Service ↔ Service | In-cluster network only; LiteLLM master key | No NetworkPolicy (Finding §4.1); orchestrator→engine has no auth header |
| Service → Redis | Connection only | No Redis ACL / requirepass (Finding §5.1) |
| Service → Postgres | DB user creds; RLS policies present in DDL | SET LOCAL app.current_tenant is never called (Finding §7.1) |
| Service → External LLM | LiteLLM master key + budget caps | None major |
This is the authoritative permission table. New agents added to the
codebase MUST be added here. The detailed per-section breakdown lives
in docs/security/RBAC.md; the headline matrix is:
| Agent | Redis (R/W key prefixes) | LLM scope | DB | External egress |
|---|---|---|---|---|
ingest_v2 |
W t:{tenant}:events, delta9:seen:* |
❌ | ❌ | Loyverse API |
orchestrator |
R/W delta9:work_queue, delta9:*_queue, t:{tenant}:project:state |
LiteLLM only | ❌ | Google Chat, web API |
engine.js |
❌ | LiteLLM delta9-p0 |
❌ | LiteLLM only |
summarizer |
R delta9:p2p3_summarize |
LiteLLM delta9-p2p3-summarizer |
❌ | LiteLLM only |
audit_gate |
R delta9:audit_queue, W delta9:audit_dissent |
LiteLLM cross-validator | ❌ | LiteLLM only |
consensus_gate |
R/W delta9:consensus_*, R/W t:{tenant}:emb:* |
3-engine quorum | ❌ | LiteLLM + Ollama |
judge_gate |
R/W delta9:judge_* |
LiteLLM delta9-judge |
❌ | LiteLLM only |
reranker_server |
❌ | ❌ | ❌ | none |
panel |
❌ (Day 1) | none | RW panel.db |
none (cluster-local) |
webhook-receiver |
W t:{tenant}:events |
❌ | ❌ | inbound only |
n8n |
R delta9:*_dissent |
❌ | own SQLite | LINE_WEBHOOK_URL |
line_notifier |
❌ | ❌ | ❌ | LINE_NOTIFY_TOKEN endpoint |
The NetworkPolicy in k8s/security/networkpolicies.yaml encodes the
egress column; the matching K8s ServiceAccounts in
k8s/security/serviceaccounts.yaml provide one identity per service so
future RBAC Roles can attach.
Severity: 🔴 critical · 🟠 high · 🟡 medium · ⚪ info.
- Where:
delta9_panel/delta9_panel/settings.py::_default_fernet_key - Impact: If
FERNET_KEYis unset in production, the panel boots with a fresh random key per pod restart. Every encrypted TOTP secret on the panel-data PVC becomes undecryptable. Operators get locked out with no recovery path except backup codes. - Fix in this PR: production guard — refuses to boot when
is_prod and FERNET_KEYis missing or matches a dev-generated value. Seedelta9_panel/tests/test_settings_production_guard.py.
- Where:
k8s/redis-deployment.yaml - Impact: Any pod in the cluster reads all queues, the embedding cache, ProjectState, and the dedup set. No tenant isolation at the Redis layer.
- Fix this PR: staged rollout plan in
docs/security/REDIS_ACL_MIGRATION.md. We don't flip the flag in this PR because turning onrequirepassmid-flight breaks every running client until they pick up the new env var. Coordinated rollout is the safer path.
- Where:
k8s/*.yaml - Impact: Every pod can reach every other pod and any IP on the internet. No defense if a single workload is compromised.
- Fix this PR: ships
k8s/security/networkpolicies.yaml— default-deny + per-service allow rules matching the egress matrix. Operators apply after labelling their pods (app: <name>selectors must already match —kubectl describe deploy/<name>to verify).
allowPrivilegeEscalation: false,capabilities.drop: ["ALL"],readOnlyRootFilesystem: trueare not set on any Deployment.- Fix this PR: documented in
docs/security/CONTAINER_HARDENING.mdwith a copy-paste template. Existing Deployments aren't touched here — applied per-service in follow-ups so any rootfs-write regression is testable in isolation.
- Where: every Postgres call in
delta9_panel,core/,src/agents/ - Impact: If RLS policies are active in production, a tenant-scoped
query that forgets its
tenant_id =filter could leak. If they're not active, the RLS ininfra/sql/*is dead code. - Fix this PR: closed: The panel now applies
set_config('app.current_tenant', ...)via SQLAlchemy'safter_beginevent (delta9_panel/db/tenant_context.py). Shared data readers likecore/pos_cache.pyhave been updated to check for thepostgresqldialect and explicitly runset_config.
- Where:
web/server.jsPOST /api/events - Impact: A leaked key replays as orchestrator.
- Fix this PR: documented + rotation guidance in
docs/security/RBAC.md. Switching to HMAC-signed bodies is tracked separately.
— closed: both routes (panel/auth/logout(panel + web) no audit row/auth/logout, web HTML form, web JSON form) now write alogout.okaudit row when the cookie decodes cleanly. Logout still succeeds for expired/malformed cookies (auditing the failed-decode would just clutter the log with expired-session noise).— closed: writes a/console/clear(web) no audit rowconsole.clear: <conversationId|all>row alongside the existingconsole.chatrow so the timeline is self-consistent.- 🟡 Still open: cross-service state writes (orchestrator, gates,
summarizer) emit structured JSON logs but NOT to the panel's
audit_log. Plumbing them needs an audit-bus (Redis stream consumed by the panel, or a Postgresaudit_logshared across services). Tracked as a follow-up under this same finding.
- Where: every Delta-9 image;
ollama/ollama:latest; LiteLLMmain-stable - Impact: Supply-chain risk if a registry namespace is compromised or a base image moves unexpectedly.
- Fix this PR:
docs/security/IMAGE_PINNING.mdwalkthrough.
- Where: PR #79
delta9_panel/api/cron_health.py - Impact: Non-admins enumerate CronJob status. Low-impact, but inconsistent with other operator surfaces that are admin-gated.
- Fix: 1-line change goes into PR #79 (
Depends(require_admin)), not this PR — to avoid merge-order coupling.
- Where:
core/orchestrator.py+ engines whenPROJECT_STATE_ENABLED=1 - Impact: Law #7 mandates both engines read+append. The hook exists; whether it actually runs on every call needs a follow-up audit by code-reading the per-engine paths.
- Where:
delta9_panel/delta9_panel/api/auth.py - Impact: The
/verify-otpendpoint lacked the@limiter.limitdecorator present on/login. It also failed to triggerline_notifier.record_login_failure(). An attacker could bypass brute-force protection and rapidly guess 6-digit TOTP codes (~1,000,000 combinations) without alerting operators. - Fix this PR: closed: Added
@limiter.limit("5/minute")and wiredpyotp.verify()failures into the brute-force alert and audit logging systems.
| File | Type | Purpose |
|---|---|---|
SECURITY.md (this file) |
doc | Threat model + findings + RBAC summary |
docs/security/RBAC.md |
doc | Detailed per-agent permission matrix |
docs/security/CONTAINER_HARDENING.md |
doc | securityContext template |
docs/security/IMAGE_PINNING.md |
doc | Digest-pin walkthrough |
docs/security/REDIS_ACL_MIGRATION.md |
doc | Staged ACL rollout plan |
k8s/security/serviceaccounts.yaml |
manifest | One SA per Delta-9 workload |
k8s/security/networkpolicies.yaml |
manifest | Default-deny + per-service allow |
delta9_panel/delta9_panel/settings.py |
code | Production FERNET_KEY guard |
delta9_panel/tests/test_settings_production_guard.py |
test | Covers the guard |
CLAUDE.md |
doc | Link to this file from the panel section |
Nothing in this PR changes runtime behaviour on a happy-path production system: it adds policy artifacts (SA, NetworkPolicies) that operators apply explicitly, plus a guard that only fires when production is misconfigured (which is exactly when you want to fail).
If you believe you've found a security issue in Delta-9, do not open a public GitHub issue. Instead, email the maintainers (or open a private security advisory on the GitHub repository) with:
- A minimal reproduction (commands or HTTP requests)
- The affected version (commit SHA)
- Whether the issue is exploitable from outside the cluster
We aim to acknowledge within 48 hours and ship a fix or mitigation within 7 days for critical-class issues.