Security Model & Best Practices¶
Security architecture and hardening notes for Qorel.
Current status: built-in user authentication, gateway JWTs, RBAC, audit logging, safer browser sessions, and secret-field handling exist today. OAuth, Vault-backed secret resolution, production TLS profiles, and deployment-time network hardening are still future production-packaging work.
Table of Contents¶
- Security Principles
- Authentication
- Authorization (RBAC)
- Network Security
- Data Encryption
- Secrets Management
- Audit & Compliance
- Threat Model
- Security Checklist
- Incident Response
Security Principles¶
Defense in Depth¶
Multiple layers of security controls:
Physical Security → Network Security → Application Security → Data Security
Least Privilege¶
Every component should have minimum required permissions: - Adapters should only write to assigned Kafka-compatible topics - Sinks should only read from specific topics - Gateway Runtime cannot access Control Plane database directly - UI users have role-based access
Zero Trust¶
Never trust, always verify: - Hardened deployments should encrypt network traffic, including internal links where practical - All API calls authenticated - All configuration changes audited - No implicit trust between components
Authentication¶
User Authentication (Control Plane UI/API)¶
Implemented method: built-in user auth with JWT-backed sessions.
Future enterprise path: OAuth2/OIDC can be added later, but it is not part of the current completed security baseline.
Flow:
1. User logs in via UI
↓
2. Control API validates credentials (or delegates to IdP)
↓
3. API issues JWT token (expires in 12 hours)
↓
4. UI includes token in Authorization header
↓
5. API validates token on every request
JWT Structure:
{
"sub": "alice@example.com",
"scope": "user",
"roles": ["Engineer"],
"permissions": ["adapters:create", "sinks:update", "deployments:create"],
"exp": 1704988800,
"iat": 1704902400,
"iss": "qorel-api"
}
Token refresh:
# UI automatically refreshes token before expiration
POST /api/v1/auth/refresh
Authorization: Bearer <current-token>
Response:
{
"token": "<new-token>",
"expires_at": "2025-01-12T00:00:00Z"
}
Gateway Authentication (Machine-to-Machine)¶
Current shipped method: admin-created enrollment tokens let a gateway enroll
as pending; an operator approves it, then the control plane issues a
long-lived gateway JWT for config polling, heartbeat, and gateway-side actions.
Possession of the gateway_id alone is no longer sufficient to mint a token,
every /token and /token/renew request must additionally prove possession
of the gateway's private key (see "Device-identity keypair" below).
Manual fallback: operators can still create and approve a gateway record
directly in controlled local/dev environments before the gateway requests a
token. Doing so does not set a public key on the row; an admin must supply one
(via PUT /api/v1/gateways/{gateway_id} with public_key) before that
gateway can pass proof-of-possession, or the gateway must instead go through
the real /enroll flow so it self-registers its own key.
Still planned, not shipped: TPM-backed attestation and full mTLS/PKI are reserved for a higher-assurance enterprise tier, deliberately not built as part of the baseline device-identity fix (unnecessary weight for the threat model this fixes).
Device-identity keypair (TOFU, bound to enrollment token)¶
On first use, the gateway generates an Ed25519 keypair
(gateway_runtime/identity.py) and persists the private key locally
(/data/keys/gateway_identity.pem, 0600 file / 0700 directory permissions,
never transmitted). The public key is sent once, at enrollment; the control
plane stores it against the gateway row and rejects any later enrollment
attempt for the same gateway_id that presents a different key (409),
closing the "leaked enrollment token used to hijack an already-claimed
gateway_id" gap. This is trust-on-first-enroll (TOFU), not a CA-backed
identity, the security property is "the same physical device that enrolled
is the one minting tokens," not "a third party has cryptographically vouched
for this device."
Every /token and /token/renew request must include a fresh Ed25519
signature over f"{gateway_id}|{purpose}|{timestamp}|{nonce}", where
purpose is "token" or "token_renew" (binding a signature to one
endpoint so a captured /token signature can't be replayed against
/token/renew). The control plane verifies the signature against the stored
public key and additionally rejects the request if the timestamp is more
than 120 seconds from server time, or if the nonce matches the immediately
preceding request's nonce (Gateway.last_token_nonce), a lightweight replay
guard, not a full nonce-cache store.
Current shipped flow¶
- Admin creates an enrollment token in the Gateways UI.
- Gateway runtime starts with
CONTROL_PLANE_URL,CONTROL_PLANE_GATEWAY_ID, andCONTROL_PLANE_ENROLLMENT_TOKEN, and generates (or loads) its Ed25519 keypair. - Gateway runtime calls
POST /api/v1/gateways/enrollwith its public key. - Control plane creates or updates the gateway as
pending, storing the public key (or rejecting with409if a different key is already on file). - Operator reviews and approves the pending gateway in the UI.
- Gateway runtime requests a gateway token, signing the request with its private key.
- Gateway runtime polls config and posts heartbeat/health back to the control plane.
Self-registration is not enabled in the currently shipped implementation.
POST /api/v1/gateways/register is intentionally disabled today, so any doc or
example that assumes zero-touch self-registration should be treated as a
planned enrollment direction rather than current behavior.
Current enrollment bootstrap example¶
# 1. Admin creates an enrollment token in the Gateways UI.
# 2. Gateway runtime enrolls with that token, including its Ed25519 public key.
POST /api/v1/gateways/enroll
{
"gateway_id": "gateway-rig-alpha-001",
"enrollment_token": "qre_...",
"hostname": "rig-alpha.local",
"hardware_info": {
"site": "Offshore Rig Alpha",
"hardware": "Dell Edge Gateway 3200"
},
"public_key": "<base64 Ed25519 public key>"
}
# 3. Operator approves the pending gateway.
POST /api/v1/gateways/{gateway_id}/approve
# 4. Gateway runtime requests its machine token, signing
# "gateway-rig-alpha-001|token|<timestamp>|<nonce>" with its private key.
POST /api/v1/gateways/token
{
"gateway_id": "gateway-rig-alpha-001",
"timestamp": 1782000000,
"nonce": "<random>",
"signature": "<base64 Ed25519 signature>"
}
Future stronger identity proof¶
# Gateway generates certificate on first boot
openssl req -new -x509 -days 3650 \
-keyout /etc/qorel/gateway.key \
-out /etc/qorel/gateway.crt \
-subj "/CN=gateway-rig-alpha-001"
# Future signed enrollment / registration with Control API
curl -X POST https://api.qorel.example.com/api/v1/gateways/register \
-H "Content-Type: application/json" \
-d '{
"gateway_id": "gateway-rig-alpha-001",
"certificate": "<PEM-encoded-cert>",
"metadata": {
"location": "Offshore Rig Alpha",
"hardware": "Dell Edge Gateway 3200"
}
}'
# Admin approves in UI
# Control API issues long-lived JWT (1 year expiration)
{
"gateway_id": "gateway-rig-alpha-001",
"token": "eyJhbGciOi...",
"expires_at": "2026-01-10T00:00:00Z"
}
This full mTLS/PKI-issued-certificate model remains a further future step beyond the Ed25519 TOFU keypair already shipped above; it adds a trusted CA vouching for device identity, rather than trust-on-first-enroll.
Ongoing authentication¶
# Gateway includes token in every API call
GET /api/v1/gateways/gateway-rig-alpha-001/config/current
Authorization: Bearer <gateway-token>
Token rotation¶
# Renewal path exposed by the control plane, also requires a fresh signature,
# same as the initial token request, with purpose="token_renew".
POST /api/v1/gateways/token/renew
Authorization: Bearer <current-token>
{
"timestamp": 1782000000,
"nonce": "<random>",
"signature": "<base64 Ed25519 signature over gateway_id|token_renew|timestamp|nonce>"
}
Adapter Authentication to Kafka (Current)¶
The local dev stack and the current gateway runtime use plaintext Kafka connections with no authentication. KafkaProducer is initialized without SASL parameters.
Production Hardening (Not Yet Implemented)¶
The following SASL/SCRAM-SHA-512 configuration is planned for production hardening but is not yet wired in the codebase.
Planned method: SASL/SCRAM-SHA-512
Server-side configuration:
listeners=SASL_SSL://0.0.0.0:9093
security.inter.broker.protocol=SASL_SSL
sasl.mechanism.inter.broker.protocol=SCRAM-SHA-512
sasl.enabled.mechanisms=SCRAM-SHA-512
Planned adapter credentials. Not implemented. The Kafka sink does not
support SASL today. When it is built, credentials will be ordinary catalog
fields marked secret: true, entered in the operator UI and Fernet-encrypted
at rest like every other secret field. Qorel has no config-interpolation
syntax, and none is committed to here.
{
"output": {
"kafka_bootstrap": "kafka.example.com:9093",
"security_protocol": "SASL_SSL",
"sasl_mechanism": "SCRAM-SHA-512",
"sasl_username": "adapter-user",
"sasl_password": "<secret field, stored encrypted, never returned by GET>"
}
}
Credential creation:
kafka-configs --bootstrap-server kafka:9093 \
--alter --add-config 'SCRAM-SHA-512=[password=<secure-password>]' \
--entity-type users --entity-name adapter-user
Authorization (RBAC)¶
Roles & Permissions¶
| Role | Permissions | Use Case |
|---|---|---|
| Viewer | Read dashboards, view configs | Business analysts, executives |
| Operator | Viewer + acknowledge alarms, view logs | Operations team, 24/7 monitoring |
| Engineer | Operator + create/edit adapters, sinks, deployments; test connections | Industrial engineers, integrators |
| Admin | Engineer + manage gateways, users, deploy configs | System administrators |
| Copilot | Read topology, suggest changes (requires approval) | AI assistant, not yet built; see ADR-010 |
Permission Matrix¶
The real permission strings are adapters:*, sinks:*, deployments:*,
validation:update, and configs:* (see core/security.py), the
"pipeline" object was retired in favor of separately reusable adapters and
sinks composed into deployments, and the permission names below reflect that.
| Resource | Viewer | Operator | Engineer | Admin |
|---|---|---|---|---|
| View dashboards | ✓ | ✓ | ✓ | ✓ |
| View configs | ✓ | ✓ | ✓ | ✓ |
| Acknowledge alarms | ✗ | ✓ | ✓ | ✓ |
| View logs | ✗ | ✓ | ✓ | ✓ |
| Create adapters / sinks | ✗ | ✗ | ✓ | ✓ |
| Create / edit deployments | ✗ | ✗ | ✓ | ✓ |
| Delete adapters / sinks / deployments | ✗ | ✗ | ✗ | ✓ |
| Deploy configs | ✗ | ✗ | ✗ | ✓ |
| Manage gateways | ✗ | ✗ | ✗ | ✓ |
| Manage users | ✗ | ✗ | ✗ | ✓ |
Kafka Topic ACLs¶
Per-adapter isolation:
# Adapter can only write to its assigned topics
kafka-acls --bootstrap-server kafka:9093 \
--add --allow-principal User:adapter-modbus-001 \
--operation Write \
--topic telemetry.raw \
--topic events.raw
# Adapter cannot read (write-only)
kafka-acls --bootstrap-server kafka:9093 \
--add --deny-principal User:adapter-modbus-001 \
--operation Read \
--topic '*'
Sink permissions:
# Sink can only read from clean topics
kafka-acls --bootstrap-server kafka:9093 \
--add --allow-principal User:sink-timescaledb \
--operation Read \
--topic telemetry.clean \
--topic events.clean \
--group sink-timescaledb-group
Implementation (Control API)¶
# control_plane/api/auth.py
from functools import wraps
from jose import jwt
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer
security = HTTPBearer()
def get_current_user(token: str = Depends(security)):
try:
payload = jwt.decode(token.credentials, JWT_SECRET, algorithms=["HS256"])
return User(
email=payload["sub"],
roles=payload["roles"],
permissions=payload["permissions"]
)
except jwt.JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
def require_permission(permission: str):
def decorator(func):
@wraps(func)
async def wrapper(*args, user: User = Depends(get_current_user), **kwargs):
if permission not in user.permissions:
raise HTTPException(status_code=403, detail=f"Missing permission: {permission}")
return await func(*args, user=user, **kwargs)
return wrapper
return decorator
# Usage in API endpoints
@app.post("/api/v1/deployments")
@require_permission("deployments:create")
async def create_deployment(deployment: DeploymentCreate, user: User = Depends(get_current_user)):
# Create deployment logic
audit_log(user.email, "deployment.create", deployment.id)
return {"status": "created"}
Network Security¶
Network Segmentation¶
┌─────────────────────────────────────────────┐
│ DMZ (Public) │
│ - Load Balancer │
│ - UI (HTTPS only) │
└─────────────┬───────────────────────────────┘
│ (Firewall)
┌─────────────▼───────────────────────────────┐
│ Control Plane Network │
│ - Control API (private IP) │
│ - PostgreSQL (no external access) │
└─────────────┬───────────────────────────────┘
│ (Firewall)
┌─────────────▼───────────────────────────────┐
│ Data Plane / Destination Network │
│ - Customer Kafka-compatible sink target │
│ (VPN/private peering only, if configured) │
│ - Schema Registry │
└─────────────┬───────────────────────────────┘
│ (VPN)
┌─────────────▼───────────────────────────────┐
│ Edge Networks │
│ - Gateways (outbound only) │
│ - Protocol adapters (isolated) │
└─────────────────────────────────────────────┘
Firewall Rules¶
DMZ → Control Plane:
Allow: TCP 443 (HTTPS) → Control API
Block: All other inbound
Control Plane → Data Plane / Destinations:
Allow: TCP 9093 (Kafka-compatible protocol over TLS, if using an external sink target)
Allow: TCP 8081 (Schema Registry)
Allow: TCP 5432 (PostgreSQL, internal only)
Edge → Cloud (outbound only):
Allow: TCP 443 → control.qorel.cloud (Control API)
Allow: TCP 9093 → customer-owned Kafka-compatible sink target, if configured
Allow: UDP 123 → pool.ntp.org (NTP)
Block: All inbound (no SSH, no admin ports)
Edge local management (restricted):
Allow: TCP 22 (SSH) from 192.168.1.0/24 (management VLAN only)
Allow: TCP 9090 (Prometheus metrics) from monitoring server
Docker Socket Security¶
The gateway runtime mounts the host Docker socket (/var/run/docker.sock) to
launch and control adapter containers. The Docker socket grants the equivalent
of root on the host machine. Any process that can write to it can escape the
container.
Current dev-stack posture: the socket is mounted directly into the
gateway_runtime container. This is acceptable for a trusted local dev
environment where Docker runs as your own user.
Current production posture (as of 2026-07-10): gateway_runtime (and
therefore its Docker socket access) runs on each edge host, not the
control-plane server, deploy/prod/docker-compose.edge.yml. That file
still mounts the raw socket directly, deliberately: edge devices are
single-tenant, which narrows the blast radius a shared multi-tenant host
would have had. Whether docker-socket-proxy is still worth adding on top of
that is an open question, not a committed Phase 2 target, revisit if a
concrete multi-tenant edge scenario shows up. The pattern below is
illustrative, not something currently wired into either compose file:
# Illustrative only, not present in deploy/prod/docker-compose.edge.yml today.
socket-proxy:
image: tecnativa/docker-socket-proxy:0.3.0
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
CONTAINERS: 1
IMAGES: 1
NETWORKS: 1
POST: 1 # allow POST (create/start/stop)
DELETE: 1 # allow DELETE (remove containers)
# All other categories default to 0 (denied)
networks:
- socket-proxy-net
gateway_runtime:
environment:
DOCKER_HOST: tcp://socket-proxy:2375
networks:
- socket-proxy-net
- app-net
# No longer mounts /var/run/docker.sock directly
Until the socket proxy is in place, treat the gateway runtime container as having implicit host-level access and restrict who can deploy or modify it.
VPN Configuration (for Edge → Cloud)¶
IPSec tunnel:
# Edge gateway
ipsec up qorel-tunnel
# Traffic routed through tunnel:
# - Kafka replication (9093)
# - Control API (443)
# Public internet bypass:
# - NTP (no sensitive data)
WireGuard alternative:
# /etc/wireguard/wg0.conf
[Interface]
PrivateKey = <gateway-private-key>
Address = 10.0.1.2/24
[Peer]
PublicKey = <cloud-public-key>
Endpoint = vpn.qorel.cloud:51820
AllowedIPs = 10.0.0.0/16
PersistentKeepalive = 25
Data Encryption¶
In Transit¶
Production target: encrypted network traffic:
| Connection | Method | Key Strength |
|---|---|---|
| UI ↔ Control API | HTTPS/TLS 1.3 | RSA 2048 / ECDSA P-256 |
| Gateway ↔ Control API | HTTPS/TLS 1.3 | RSA 2048 |
| Local broker ↔ External Kafka-compatible sink destination | Kafka over TLS (SASL_SSL) | AES-256 |
| Adapter ↔ Local Kafka-compatible broker | TLS in hardened deployments; plaintext in local dev | AES-256 when TLS is enabled |
TLS configuration (Control API):
# nginx.conf
ssl_protocols TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_stapling on;
ssl_stapling_verify on;
Kafka-compatible broker TLS (production packaging target):
listeners=SSL://0.0.0.0:9093
ssl.keystore.location=/var/private/ssl/kafka.server.keystore.jks
ssl.keystore.password=${KEYSTORE_PASSWORD}
ssl.key.password=${KEY_PASSWORD}
ssl.truststore.location=/var/private/ssl/kafka.server.truststore.jks
ssl.truststore.password=${TRUSTSTORE_PASSWORD}
ssl.client.auth=required
ssl.enabled.protocols=TLSv1.3
ssl.cipher.suites=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
At Rest¶
Kafka logs (disk encryption):
# Linux LUKS encryption
cryptsetup luksFormat /dev/sdb
cryptsetup luksOpen /dev/sdb kafka_data
mkfs.ext4 /dev/mapper/kafka_data
mount /dev/mapper/kafka_data /var/lib/kafka/data
Database (PostgreSQL):
# Transparent Data Encryption (TDE)
# Or cloud-managed encryption (AWS RDS encryption, Azure SQL TDE)
Edge gateway (local buffering):
# Full disk encryption (on sensitive deployments)
cryptsetup luksFormat /dev/mmcblk0p2
# Auto-unlock on boot with TPM
Secrets Management¶
Current Shipped Behavior¶
Secret fields in adapter and sink configs are handled safely by the control plane:
- Fields marked secret: true in the catalog are stored separately from config, never returned in GET responses, and cleared on re-fetch.
- The UI shows a "stored securely" state indicator for secrets that are set but not returned.
- Secret values at rest are Fernet-encrypted (ConfigSecret.ciphertext) before being persisted, keyed by QOREL_CONFIG_SECRET_KEY. Key rotation is supported: MultiFernet decrypts against the current and previous key during a rotation window, and each row records a key_version. This is application-layer encryption, not plaintext, database-level encryption (e.g. disk/volume encryption) remains a separate, additional operator responsibility for defense in depth.
Production Hardening (Not Yet Implemented)¶
External secret managers¶
Qorel does not integrate with an external secret manager. There is no Vault
integration, no hvac dependency, and no interpolation syntax that resolves
secret references inside adapter or sink configs. Secrets are held by the
control plane and Fernet-encrypted at rest, as described above.
Integrating an external secret manager is a plausible direction for higher-assurance deployments. No syntax or architecture is committed to here, and none should be assumed or designed against.
Orchestrator secrets¶
Production Compose packaging ships. Platform-level secrets
(QOREL_JWT_SECRET, QOREL_CONFIG_SECRET_KEY, POSTGRES_PASSWORD) are
injected as environment variables from a deployment-managed .env file; see
deploy/prod/.env.control-plane.example and deploy/prod/.env.edge.example.
Kubernetes manifests and image-pull-secret templates remain open, so no
Kubernetes Secret layout is documented yet.
Audit & Compliance¶
Audit Log¶
Every security-relevant event logged, matching the real audit_events columns below, actor_username, action, resource_type, resource_public_id, ip_address, created_at, plus a free-form details JSON column for event-specific fields. Example of a real recorded event (app/routers/auth.py, record_audit_event):
{
"actor_username": "alice",
"action": "auth.login",
"resource_type": "user",
"resource_public_id": "alice",
"details": {},
"ip_address": "192.168.1.100",
"created_at": "2025-01-10T14:30:00.123Z"
}
Configuration changes:
{
"actor_username": "bob",
"action": "deployment.activated",
"resource_type": "deployment",
"resource_public_id": "offshore_well_12",
"details": {},
"ip_address": "10.0.1.50",
"created_at": "2025-01-10T15:00:00Z"
}
Failed authentication is not currently audited. POST /api/v1/auth/token increments User.failed_login_attempts on a bad password but does not call record_audit_event() on that path, only successful logins are recorded. There is no authentication.failure row to query. If failed-login auditing becomes a requirement (e.g. for brute-force detection or a specific compliance control), it needs to be added rather than assumed present.
Storage¶
Current implementation: PostgreSQL only.
CREATE TABLE audit_events (
id SERIAL PRIMARY KEY,
actor_username VARCHAR(128),
action VARCHAR(128) NOT NULL,
resource_type VARCHAR(64) NOT NULL,
resource_public_id VARCHAR(128) NOT NULL,
details JSONB DEFAULT '{}',
ip_address VARCHAR(45),
created_at TIMESTAMPTZ NOT NULL,
INDEX idx_actor_username (actor_username),
INDEX idx_action (action),
INDEX idx_resource_type (resource_type),
INDEX idx_resource_public_id (resource_public_id),
INDEX idx_created_at (created_at)
);
Every control-plane mutation (adapters, sinks, deployments, alerting rules,
escalation policies, users, auth events) is recorded here via
record_audit_event(). This is the durability guarantee that actually
exists today: a relational row per event, queryable and indexed.
An earlier version of this document additionally described a second,
append-only write to a Kafka topic (audit_trail, infinite retention, 3x
replication) as a "dual storage" durability layer. That write was never
implemented; there is no Kafka producer anywhere in the audit path. It
has been removed from this document rather than left as an unbuilt
durability claim. If tamper-evident, append-only audit storage becomes a
genuine requirement (e.g. for a specific compliance certification), it
should be scoped as its own piece of work rather than assumed present.
Compliance Reports¶
The queries below are written against the real audit_events schema above.
GDPR: User data access logs
SELECT created_at, action, resource_type, resource_public_id
FROM audit_events
WHERE actor_username = 'alice'
AND created_at > NOW() - INTERVAL '90 days'
ORDER BY created_at DESC;
SOC 2: Config change audit trail
SELECT created_at, actor_username, action, details
FROM audit_events
WHERE resource_type IN ('deployment', 'gateway', 'adapter', 'sink')
AND created_at BETWEEN '2025-01-01' AND '2025-12-31'
ORDER BY created_at;
ISO 27001: Access control review, as noted above, failed logins are not
audit-logged today, only counted on the users row. This reads that
counter directly rather than querying a nonexistent authentication.failure
audit event:
SELECT username, failed_login_attempts
FROM users
WHERE failed_login_attempts > 5;
Threat Model¶
Threats & Mitigations¶
| Threat | Impact | Mitigation |
|---|---|---|
| Unauthorized access to Control API | Configuration tampering, data exposure | JWT auth, RBAC. Rate limiting and an IP allowlist are not implemented, see Production Hardening note below. |
| MITM attack on edge → cloud | Data interception, credential theft | TLS 1.3, certificate pinning |
| Compromised adapter container | Malicious data injection | Sandboxing, read-only filesystem, limited Kafka ACLs |
| Kafka-compatible broker credential theft | Unauthorized data access | SASL/SCRAM, credential rotation, Vault or orchestrator secret integration when production packaging supports it |
| Unencrypted OPC UA session | PLC data transmitted in cleartext on OT network | V1 limitation, security_mode=None only; adapter logs a WARNING on connect; upgrade path to Sign/SignAndEncrypt planned for V2 |
| DDoS on Control API | Service unavailability | Not implemented today, no rate limiting, throttling, or auto-scaling exists in the current codebase. CDN/edge protection is a deployment-environment decision outside Qorel's own code. |
| Insider threat (malicious admin) | Data deletion, config destruction | Audit logs, approval workflows, immutable backups |
| Physical access to edge gateway | Device tampering | Disk encryption, tamper-evident seals, secure boot |
Production Hardening (Not Yet Implemented): API rate limiting and an IP allowlist for the Control API are not wired into the current codebase; there is no rate-limiting middleware or IP-allowlist check anywhere in control-plane/app. Do not document these as shipped behavior until they exist.
Attack Scenarios¶
Scenario 1: Compromised Edge Gateway
Attack:
Attacker gains physical access to edge gateway
Boots into recovery mode
Attempts to read Kafka data or credentials
Mitigations: - Full disk encryption (LUKS) - Secure boot (TPM-based) - Credentials stored outside plaintext config once production secret resolution is implemented - Tamper-evident physical seals - Remote wipe capability
Scenario 2: Stolen JWT Token
Attack:
Attacker intercepts JWT token (XSS, browser vulnerability)
Uses token to access Control API
Mitigations:
- Short token expiration (12 hours)
- HttpOnly cookies (for web UI)
- Audit logging of login events (auth.login, app/routers/auth.py)
- Server-side logout invalidation, POST /api/v1/auth/logout sets
User.token_valid_after = now() (in addition to clearing the HttpOnly
cookie). get_current_user() rejects any token whose iat claim predates
that watermark, so a bearer token captured before logout and replayed
directly via the Authorization header (bypassing the cookie) stops
working immediately on the next authenticated request, not just at natural
expiration. This is a single watermark column, not a growing
blocklist/jti store, logging out invalidates every token issued before
that moment, including ones the user forgot they had, not just "this
session."
- Known limitation: JWT iat has whole-second precision. A fresh login
issued in the same wall-clock second as a preceding logout is not
retroactively invalidated by that logout. Real human re-logins always
take longer than one second, so this has no practical security impact.
Not implemented today: - IP address binding, no per-token/per-session IP binding exists, not even as an opt-in toggle.
Security Checklist¶
Pre-Deployment¶
- [ ] TLS certificates generated and installed
- [ ] Secrets kept out of plaintext config using the production-supported secret mechanism
- [ ] Firewall rules configured
- [ ] VPN tunnel established (for edge deployments)
- [ ] Kafka-compatible broker ACLs configured where authentication is enabled
- [ ] Database credentials rotated from defaults
- [ ] Admin accounts use strong passwords (or SSO)
- [ ] Audit logging enabled
Post-Deployment¶
- [ ] Security scan completed (vulnerability assessment)
- [ ] Penetration testing performed
- [ ] Audit logs reviewed (no unauthorized access)
- [ ] Backups encrypted and tested
- [ ] Incident response plan documented
- [ ] Security training for operators completed
Ongoing¶
- [ ] Monthly credential rotation
- [ ] Quarterly access review (remove unused accounts)
- [ ] Security patches applied within 7 days
- [ ] Audit logs reviewed weekly
- [ ] Backup restoration tested quarterly
Incident Response¶
Incident Response Plan¶
Severity Levels:
| Level | Definition | Example | Response Time |
|---|---|---|---|
| Critical | Active breach, data exfiltration | Compromised admin account | < 1 hour |
| High | Potential breach, failed access attempts | 100 failed logins from unknown IP | < 4 hours |
| Medium | Security misconfiguration | Exposed API endpoint | < 24 hours |
| Low | Security best practice violation | Weak password detected | < 1 week |
Response Procedure¶
Step 1: Detection
Automated alerts (failed logins, unusual access patterns)
Manual reports (user notices suspicious activity)
Step 2: Containment
# Revoke compromised credentials
vault token revoke <token-id>
# Block suspicious IP
iptables -A INPUT -s 203.0.113.45 -j DROP
# Disable compromised user, blocks all login attempts immediately.
# The user account is flagged disabled=True in the database; any subsequent
# login attempt returns HTTP 403 before the password is checked.
POST /api/v1/users/{username}/disable
Authorization: Bearer <admin-token>
# Re-enable after investigation and credential reset
POST /api/v1/users/{username}/enable
Authorization: Bearer <admin-token>
Brute-force detection and automated lockout: the control plane increments
failed_login_attempts on every failed password check and resets it to 0 on
successful login. After the 5th consecutive failed attempt, the account is
automatically locked (User.locked_until = now() + 15 minutes), further
login attempts return 423 Locked immediately, without checking the
password, until the lockout window elapses. A successful login after the
window clears both locked_until and failed_login_attempts. An admin can
also clear an active lockout early via POST /api/v1/users/{username}/enable
(the same endpoint used to re-enable a disabled account). Query the audit log
or the users list (GET /api/v1/users) to surface accounts with elevated
failure counts or active lockouts.
Step 3: Investigation
-- Review audit logs
SELECT * FROM audit_log
WHERE user_email = 'alice@example.com'
AND timestamp > NOW() - INTERVAL '24 hours'
ORDER BY timestamp;
-- Check for unauthorized config changes
SELECT * FROM audit_log
WHERE event_type LIKE '%.create' OR event_type LIKE '%.delete'
AND user_email = 'alice@example.com';
Step 4: Remediation
- Rotate all affected credentials
- Apply security patches
- Update firewall rules
- Restore from backup if data compromised
Step 5: Post-Incident
- Document incident in report
- Update security procedures
- Conduct training
- Implement additional controls
Emergency Contacts¶
Security Team Lead: security@example.com
On-Call Engineer: +1-555-ONCALL
Vendor Support: support@qorel.io
End of Security Documentation
For architecture details, see ARCHITECTURE.md. For deployment, see DEPLOYMENT.md.