Field encryption¶
Sensitive columns in the DB are encrypted with AES-256-GCM. Plaintext lives only briefly in worker RAM when needed for a check.
Which fields¶
| Table | Column | For |
|---|---|---|
hosts |
snmp_community |
SNMPv1/v2c community |
hosts |
ssh_password |
SSH login password (rare — key preferred) |
hosts |
ssh_private_key |
PEM content |
host_services |
config_overrides.password |
any password override |
notification_channels |
webhook.secret |
HMAC signature secret |
ai_config |
api_key |
Cloud LLM API keys |
Implementation: shared/encryption.py with encrypt_field() / decrypt_field().
Key¶
FIELD_ENCRYPTION_KEY is base64url, 32 bytes. Generated by setup script:
Lives in .env and secrets/field_encryption_key.
Lifecycle¶
sequenceDiagram
participant U as User/UI
participant API
participant DB
participant W as Worker
U->>API: Create host with snmp_community="public"
API->>API: encrypt_field("public") with FIELD_ENCRYPTION_KEY
API->>DB: INSERT hosts (snmp_community = b64(nonce||ciphertext||tag))
Note over DB: Plaintext nowhere stored
W->>DB: SELECT host for check
W->>W: decrypt_field(snmp_community)
W->>W: SNMP walk with plaintext
Note over W: Plaintext in RAM only, discarded immediately
API masking¶
HostOut (response schema in api/app/schemas) masks encrypted fields as "***". Even super admin doesn't see plaintext — they can only set, not read.
Legacy migration¶
Before v0.x there were plaintext fields. decrypt_field() has a fallback: if the value lacks the encrypted format (no magic byte), it's returned as plaintext. Old data still works, new data is encrypted.
On set, always encrypted — old plaintext entry becomes encrypted on first update.
Key loss¶
Lost key = data lost
Without FIELD_ENCRYPTION_KEY, encrypted fields stay unreadable forever. There's no recovery mechanism.
Consequence: backing up the key is as important as DB backup. Recommendation in Backup & restore.
Key rotation¶
Currently not UI-supported. Manual path:
- Generate new
FIELD_ENCRYPTION_KEY - Run a script that decrypts every encrypted column with old key and re-encrypts with new:
# scripts/rotate_field_key.py — abbreviated
from shared.encryption import decrypt_field, encrypt_field
from sqlalchemy.orm import Session
def rotate(db: Session, old_key: str, new_key: str):
for host in db.query(Host).filter(Host.snmp_community.isnot(None)).all():
plaintext = decrypt_field(host.snmp_community, key=old_key)
host.snmp_community = encrypt_field(plaintext, key=new_key)
db.commit()
- Replace
.envwith new key - Restart stack
No write traffic during rotation. Be very careful with backups.
Encryption in backups¶
PostgreSQL dumps contain encrypted values (not plaintext, not the key). Restore into an instance with a different FIELD_ENCRYPTION_KEY and the values are unreadable — always restore with the same key.
So .env backup is mandatory along with DB backup, but not in the same container:
- DB backup: volume / S3
.env: password manager / encrypted separate storage
Next¶
- Backup & restore — how key and backups are handled together
- Hardening checklist