API · Governance

Data governance in one plugin with @voltro/plugin-governance — AES-256-GCM field encryption for .encrypted() columns (handler sees plaintext, ciphertext at rest), admin-gated GDPR governance.export/erase over declared subjectScopes, a consent ledger, and retention TTL sweeps. Memory store, zero infra.

Four compliance primitives, one plugin. @voltro/plugin-governance gives you field encryption (AES-256-GCM for .encrypted() columns — handlers see plaintext, the column holds enc:v1:… at rest), GDPR export/erase (admin-gated, walking declared subject scopes), a consent ledger, and retention sweeps (delete/anonymise rows past a TTL). The template boots zero-infra with a dev encryption key. Template id: api-governance.

Scaffold

voltro create-project acme --api=api-governance

The shipped .env carries a DEV VOLTRO_FIELD_ENCRYPTION_KEY. Generate your own (openssl rand -hex 32) and keep it in a secret store for production.

Field encryption

// database/schema.ts
export const profiles = table('profiles', {
  id: id(), name: text(), email: text(),
  ssn: text().encrypted(),         // AES-256-GCM at rest
}).with(audit())

// app.config.ts
governancePlugin({ fieldEncryption: true /* reads VOLTRO_FIELD_ENCRYPTION_KEY */ })

You pass plaintext; the store middleware encrypts on write and decrypts on read — handlers never touch the ciphertext. Boot fails loud if an .encrypted() column exists but no cipher is registered.

ID=$(curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d '{"tag":"profiles.create","input":{"name":"Ada","email":"ada@acme.com","ssn":"123-45-6789"}}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["id"])')
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
  -d "{\"tag\":\"profiles.get\",\"input\":{\"id\":\"$ID\"}}"
# → { ok:true, result:{ …, ssn:"123-45-6789" } }   (plaintext — the column stored enc:v1:…)
governancePlugin({
  fieldEncryption: true,
  subjectScopes: [{ table: 'profiles', subjectField: 'id' }],     // GDPR walks these
  retention: [{ table: 'profiles', ttlMs: 365 * 86_400_000, action: 'delete' }],
})
  • GDPRgovernance.export / governance.erase are admin-gated (call as a subject with admin:full — see api-rbac); they walk subjectScopes to bundle or erase everything belonging to a subject. GovernanceService exposes the same in-handler.
  • Consentgovernance.consent (a mutation) records a grant; governance.hasConsent (a reactive query) checks it — subscribe to it on the web, or call GovernanceService.hasConsent(...) in a handler.
  • Retention — the sweep deletes (or anonymizes) rows whose age past dateField (default createdAt) exceeds ttlMs.

Rules

  • Don't encrypt what you filter on. An .encrypted() column is ciphertext on disk — no WHERE / ORDER BY in SQL. Encrypt fields you read back WHOLE (PII, tokens, notes).
  • The key is everything. GCM fails closed on a bad key — you get an error, never silent corruption. Back the key up; rotating it means re-encrypting.
  • GDPR endpoints are admin-only by design. Don't remove the admin:full gate — an unauthenticated export/erase is a data-exfiltration / griefing hole.