Edictum
Edictum ConsoleConcepts

Security Model

Adversarial threat modeling across 8 security boundaries -- authentication, tenant isolation, bundle signing, rate limiting, and 43+ adversarial tests.

AI Assistance

Right page if: you need the security reference for audits, pentesting scope, or compliance reviews -- covers 8 security boundaries, tenant isolation, bundle signing, rate limiting, and 43+ adversarial tests. Wrong page if: you want to deploy the console securely (see https://docs.edictum.ai/docs/console/self-hosting) or understand how Ed25519 signing works in detail (see https://docs.edictum.ai/docs/console/signing). Gotcha: a cross-tenant read, write, or inference is a ship-blocker, not a bug. Every database query filters by tenant_id -- this is enforced in the service layer, not in individual route handlers.

Edictum Console is a security product. The security model is not a feature -- it is the foundation. Every design decision assumes an adversarial environment: forged cookies, stolen API keys, cross-tenant probes, tampered bundles, brute-force login attempts. The console defends against all of them.

Authentication

The console supports two authentication methods: cookies (for humans using the dashboard) and API keys (for agents using the SDK).

Local Auth Provider

The default authentication provider. No external dependencies.

PropertyValue
Password storagebcrypt (work factor 12)
Minimum password length12 characters
Session storageRedis with configurable TTL (default 24 hours)
Cookie typeHttpOnly, SameSite=Lax
Secure flagAuto-set when EDICTUM_BASE_URL uses HTTPS
User enumerationPrevented -- same error for wrong email and wrong password

The AuthProvider protocol allows future providers (OIDC is on the roadmap). The protocol is 20 lines -- the cost of the abstraction is near-zero.

API Keys

Agents authenticate with API keys. Keys are scoped to an environment.

edk_production_CZxKQvN3mHz7qR8bW4xYp9dF
|   |           |
|   |           +- Random component (cryptographically secure)
|   +- Environment scope
+- Prefix (Edictum Key)
PropertyValue
Formatedk_{env}_{random}
Storagebcrypt hashed (with SHA-256 prehash)
LookupPrefix-indexed for fast resolution
DisplayFull key shown once at creation, never again
RevocationImmediate -- revoked key denied on next request
API responseMasked: edk_****mHz

Environment-Scoped API Key Enforcement

API keys are strictly isolated to their scoped environment across all endpoints. A key created for production cannot access data from staging or development — and the console enforces this at the route level, not just as a convention.

The environment is embedded in the key's format:

edk_production_CZxKQvN3mHz7qR8bW4xYp9dF
     ^^^^^^^^^^
     Scope: "production"

The following table shows how each affected endpoint enforces env-scoping for API key auth:

EndpointMismatch ResponseReason
GET /stream?env={env}403 ForbiddenExplicit scope mismatch — agent knowingly requested wrong env
GET /bundlesFiltered listReturns only bundles in key's env; no error
GET /bundles/{name}/current?env={env}403 ForbiddenExplicit env param mismatches key scope
GET /bundles/{name}/{version}404 Not FoundHides existence of versions in other envs
GET /bundles/{name}/{version}/yaml404 Not FoundHides existence of versions in other envs
GET /approvals/{id}404 Not FoundPrevents existence leakage across environments
GET /stats/overviewScoped resultsReturns metrics for key's env only

Why 404 instead of 403? For resource-lookup endpoints (bundles by version, approvals by ID), returning 403 would confirm that the resource exists — just in another environment. A 404 prevents existence leakage: an agent cannot probe whether production approvals or bundle versions exist by sending requests with a staging key.

Dashboard (session) auth is not subject to env-scoping. A logged-in user sees data across all environments.

Migration note: Before this enforcement was in place, API keys could access data across environments. If your agents relied on cross-environment access — for example, a staging key reading production bundles — those requests will now receive 403 or 404 responses. Create environment-specific keys for each environment your agents run in.

Dual Auth Resolution

Many endpoints accept both cookies and API keys. The get_current_tenant FastAPI dependency resolves either:

Request arrives
    |
    +-- Has session cookie? -> Validate in Redis -> Resolve tenant
    |
    +-- Has Authorization: Bearer edk_* header? -> Lookup by prefix -> bcrypt verify -> Resolve tenant
    |
    +-- Neither? -> 401 Unauthorized

Dashboard endpoints typically require cookies. Agent endpoints (events, approvals, sessions, stream) accept API keys. Some endpoints (bundles, deployments) accept both.

CSRF Protection

Cookie-authenticated mutating requests (POST, PUT, DELETE) require the X-Requested-With header. This prevents cross-site request forgery -- a malicious page cannot forge a request with this header due to browser CORS restrictions.

API key requests and webhook callbacks are exempt from CSRF checks. API keys are not ambient credentials (not sent automatically by browsers), and webhooks use their own signature verification.

CORS Configuration

CORS is configured with explicit allowlists rather than wildcards:

SettingValue
allow_methodsGET, POST, PUT, PATCH, DELETE, OPTIONS
allow_headersContent-Type, X-Requested-With, Authorization, X-Edictum-Agent-Id
allow_originsControlled by EDICTUM_CORS_ORIGINS env var (no wildcard in production)

Requests with methods or headers outside these lists are rejected by the browser before they reach the server.

Validation Error Responses

Pydantic 422 validation error responses omit the ctx and type fields. Only loc (field path) and msg (human-readable message) are returned. This prevents leaking internal validation context that could assist enumeration attacks.

Tenant Isolation (S3)

Tenant isolation is the highest-priority security boundary. A cross-tenant read, write, or inference is a ship-blocker -- not a bug.

Database Layer

Every database table has a tenant_id column. Every query filters by it. No exceptions.

-- Every query looks like this:
SELECT * FROM events
WHERE tenant_id = :tenant_id  -- ALWAYS present
  AND agent_id = :agent_id;

-- Never this:
SELECT * FROM events
WHERE agent_id = :agent_id;   -- Missing tenant_id = data leak

The tenant_id filter is applied in the service layer, not in individual route handlers. This reduces the surface area for mistakes -- a route handler cannot accidentally skip the filter.

Redis Layer

Session tokens are stored in Redis with a prefix:

session:{token}

The tenant_id is stored inside the session JSON value, not in the Redis key. Rate limit keys include client context. SSE connection state is tenant-scoped.

SSE Layer

Agent SSE streams are filtered by tenant. An agent authenticated with tenant A's API key will never receive events for tenant B. The PushManager routes events through tenant-keyed subscriptions -- cross-tenant delivery is impossible by construction.

Notification Layer

The notification manager's channel dict is keyed by tenant_id:

# channels: dict[str, list[Channel]]  (keyed by tenant_id)
# Fan-out only iterates the approval's tenant's channels
channels = self._channels.get(approval.tenant_id, [])

A notification for tenant A's approval will never fire on tenant B's Slack channel.

Webhook Layer

Webhook callbacks (Telegram, Slack, Discord) resolve the tenant from Redis using a composite key:

{platform}:tenant:{channel_id}:{approval_id}

A forged webhook with a different channel ID will fail tenant resolution and be denied.

After Redis resolution, the resolved tenant_id is cross-checked against the channel's own tenant_id. A mismatch — which can occur if a Redis key is tampered or mis-routed — is treated as an expired approval rather than an explicit rejection, to avoid leaking cross-tenant existence. Slack returns 403; Discord and Telegram respond with an "Approval expired" message.

Bundle Signing (S6)

Every deployed bundle is signed with Ed25519. This prevents tampered contracts from being enforced by agents.

Bundle YAML content
SHA-256 hash
revision_hash
Ed25519 sign
sign(private_key, yaml_bytes)
SSE event
signature (hex) + public_key (hex)
Agent verifies
Ed25519.verify(public_key, yaml_bytes, signature)
Valid
reload() with new contracts
Invalid
deny, keep current (fail-closed)

Key Storage

ComponentProtection
Private keyEncrypted at rest with NaCl SecretBox
Encryption keyDerived from EDICTUM_SIGNING_KEY_SECRET env var
Public keyStored in plaintext (it is public)
Key rotationGenerate new pair -> deactivate old -> re-sign all active deployments

Key Rotation

Initiated from the dashboard danger zone. One action:

  1. Generate new Ed25519 keypair
  2. Encrypt private key with NaCl SecretBox
  3. Mark old key as inactive
  4. Re-sign all currently-deployed bundles with new key
  5. Push contract_update events to all connected agents
  6. Agents receive re-signed bundles and verify against new public key

Old keys are deactivated, not deleted. Audit records reference the key that was active at deploy time.

Rate Limiting (S8)

Two rate limits protect against abuse:

Login Rate Limit

ParameterValue
ScopePer IP address
ImplementationRedis sliding window (sorted sets)
Response429 Too Many Requests with Retry-After header
WindowConfigurable via EDICTUM_RATE_LIMIT_WINDOW_SECONDS (default 300)
Max attemptsConfigurable via EDICTUM_RATE_LIMIT_MAX_ATTEMPTS (default 10)

Approval Rate Limit

ParameterValue
ScopePer agent (tenant_id + agent_id)
Limit10 requests per 60 seconds
ImplementationRedis sliding window
Response429 Too Many Requests with Retry-After header

Security Response Headers

The console sets the following HTTP security headers on every response:

HeaderValuePurpose
Strict-Transport-Securitymax-age=63072000; includeSubDomains (2-year)Forces HTTPS on repeat visits
Content-Security-PolicyRestricts script/frame/object sourcesMitigates XSS and clickjacking
X-Frame-OptionsDENYPrevents embedding in iframes
X-Content-Type-OptionsnosniffBlocks MIME-type sniffing
Referrer-Policystrict-origin-when-cross-originLimits referrer leakage on cross-origin navigation
Permissions-Policycamera=(), microphone=(), geolocation=(), payment=()Denies access to sensitive browser APIs

These headers are applied in security/headers.py via FastAPI middleware and cannot be disabled by configuration.

Request Body Size Limits

Incoming request bodies are capped by raw ASGI middleware before reaching any route handler. Oversized payloads return 413 Request Entity Too Large and are never parsed.

Endpoint groupLimit
Auth endpoints (/api/v1/auth/*)4 KB
Bundle and contract uploads5 MB
General API1 MB

This is a hardening measure — abnormally large payloads are rejected at the transport layer, not after deserialization. No configuration is exposed; the limits are fixed in the server.

Bootstrap Lock (S7)

Admin creation only works when zero users exist in the database. Two bootstrap paths, same guard:

PathMechanism
Environment variables_bootstrap_admin() in FastAPI lifespan -- creates admin if EDICTUM_ADMIN_EMAIL + EDICTUM_ADMIN_PASSWORD set and no users exist
Setup wizardPOST /api/v1/setup -- browser-based first-run, creates admin if no users exist

After the first admin is created, both paths are locked:

  • Env-var bootstrap: skips silently (logs "admin already exists")
  • Setup wizard: returns 409 Conflict

A tenant and Ed25519 signing keypair are created alongside the admin. The system is fully operational from the first login.

Secrets at Rest

Three categories of secrets are encrypted with NaCl SecretBox:

SecretEncryption Key
Ed25519 signing key private componentEDICTUM_SIGNING_KEY_SECRET
Notification channel configs (bot tokens, secrets)EDICTUM_SIGNING_KEY_SECRET
AI provider API keysEDICTUM_SIGNING_KEY_SECRET

All secrets are masked in API responses. The API never returns a plaintext bot token, API key, or private key.

Security Boundaries

Defense in Depth
request
Perimeter — Authentication
S8
Rate Limiting
Credential brute force
S1
Session Validation
Account takeover
S2
API Key Resolution
Unauthorized agent access
Isolation — Tenant Scoping
S3
Tenant Scoping
Cross-tenant data leak
S5
SSE Channel Auth
Contract/event leak
Core — State Integrity
S4
Approval State Machine
Unauthorized tool execution
S6
Bundle Signing
Tampered contracts
S7
Bootstrap Lock
Privilege escalation
Authentication
Isolation
Integrity

The console defines 8 security boundaries. Each has positive tests (proves it works) and adversarial tests (proves it doesn't break).

#BoundaryModuleDecisionRisk if Bypassed
S1Session cookie validationauth/local.pyAuthenticated or denyFull account takeover
S2API key resolutionauth/api_keys.pyValid key -> tenant, or denyUnauthorized agent access
S3Tenant scoping on queriesEvery route + serviceData scoped to tenantCross-tenant data leak
S4Approval state transitionsservices/approval_service.pyValid transition or denyUnauthorized tool execution
S5SSE channel authorizationroutes/stream.pyAgent sees own tenant onlyContract/event leak
S6Bundle signature verificationservices/signing_service.pyAuthentic or denyTampered contract deployment
S7Admin bootstrap lockmain.py lifespanCreate only if no users existPrivilege escalation
S8Rate limiting on authroutes/auth.pyThrottle or allowCredential brute force

Adversarial Test Suite

43+ adversarial tests organized by attack category across all 8 boundaries:

tests/test_adversarial/
+- test_s1_session_bypass.py       # Forged cookies, expired tokens, tampered payloads
+- test_s2_api_key_bypass.py       # Revoked keys, malformed keys, timing attacks
+- test_s3_tenant_isolation.py     # Cross-tenant access on EVERY endpoint (15+ tests)
+- test_s4_approval_state.py       # Invalid transitions, race conditions, replay
+- test_s5_sse_channel.py          # Agent receiving another tenant's events
+- test_s6_signature_bypass.py     # Tampered bundles, missing signatures
+- test_s7_bootstrap_lock.py       # Re-running bootstrap after admin exists
+- test_s8_rate_limit.py           # Burst attempts, distributed attempts

Attack categories tested per boundary:

CategoryWhat it tests
Input manipulationEncoding tricks, injection, type confusion, boundary values
Semantic bypassIndirection, TOCTOU, classification gaming
Failure modesDependency down, garbage responses, partial failure
Audit fidelityCorrect events emitted for each decision path

The adversarial suite runs on every PR: pytest -m security. A failure is a merge blocker. Any PR that adds or modifies a security boundary without adversarial tests is denied.

Tenant Isolation Tests (S3 -- Highest Priority)

Tenant isolation has the most tests because it is the highest-risk boundary. Attack patterns covered:

  • Direct ID manipulation: API key from tenant A, agent_id header from tenant B. GET/PUT on resources belonging to another tenant.
  • Auth context mismatch: dashboard cookie from tenant A, API key from tenant B in the same request.
  • Data leakage in responses: list endpoints returning cross-tenant items. Error messages revealing resource existence in other tenants (404 vs 403).
  • SSE cross-tenant: agent receiving events for wrong tenant after reconnection.

A successful cross-tenant read/write/inference is a ship-blocker, not a bug.

Next Steps

  • How It Works -- system architecture and the boundary principle
  • Hot-Reload -- Ed25519 signing in the SSE push flow
  • Approvals -- webhook signature verification for interactive channels

Last updated on

On this page