docs/adr/0085-tenant-credential-vault.md
Rama master · réplica de lectura del repositorio original.
# ADR-0085: Tenant Credential Vault for Automated Provider Access
## Metadata
| Field | Value |
| -------------- | -------------------------------------------------------- |
| **Status** | Accepted |
| **Date** | 2026-10-16 |
| **Authors** | Alessio Brillo |
| **Deciders** | Alessio Brillo |
| **Supersedes** | — |
| **Relates to** | ADR-0004, ADR-0082, ADR-0083, ADR-0084 |
| **Project** | DOMINUS |
## Context
ADR-0083/0084 established the post-acquisition automation pipeline
(`portfolio → site → indexing → dns → parking → listing`) behind kill
switches and dry-run defaults. Every step that spends money or talks to an
external provider on behalf of a SaaS tenant needs that tenant's own
credentials:
- Registrar API tokens for automatic purchase (Dynadot, Porkbun, Namecheap,
OVH, GoDaddy, Cloudflare...)
- Parking provider API keys for revenue reporting and domain delegation
- Google AdSense / Google Search Console OAuth tokens for monetization and
indexing
- Marketplace API keys for offer sync (when vendor APIs are verified)
Today every provider is configured from **operator-level env vars** (one
registrar for the whole deployment). A multi-tenant SaaS cannot automate
"for the tenant" without per-tenant credentials, stored safely and resolved
per request.
Storing plaintext credentials in the database is unacceptable. The vault
must encrypt at rest, never log or return plaintext, and degrade gracefully
when the encryption key is absent.
## Decision Drivers
1. **Zero plaintext at rest**: credentials are encrypted before INSERT and
decrypted only in memory for the lifetime of a single provider call.
2. **Provider-agnostic**: one vault table holds credentials for any provider
type (registrar, parking, adsense, gsc, marketplace). New providers add
rows, not columns.
3. **Tenant isolation**: repository-enforced via `resolveTenantId()`, same
pattern as `tenant_automation_settings` (0063) and
`tenant_listing_settings` (0065).
4. **Operator brake**: the env-level `VAULT_ENCRYPTION_KEY` is the global
gate. When unset, credential writes are refused (fail-closed) — no
silent plaintext fallback. The community edition (single-tenant, env
providers) is unaffected.
5. **ADR-0082 honesty**: no provider adapter is declared as working before
its credentials can be verified against the real vendor API. The vault
stores credentials; adapter verification is a separate milestone
(M8b, M10, M12).
## Considered Options
### Option A: One column per provider on the tenant table
**Advantages:** simple schema, no new table.
**Disadvantages:** schema churn for every new provider; no audit trail of
credential rotation; cannot store provider-specific metadata (scopes,
refresh tokens, account IDs). Rejected.
### Option B: Separate table per provider type
**Advantages:** strong typing per provider.
**Disadvantages:** N tables for N providers; adding a parking provider
means a migration; repository per provider. Rejected.
### Option C: Single `tenant_credentials` table with provider type + encrypted blob (chosen)
**Advantages:** one table, one repository, provider-agnostic; rotation
audited via `updated_at` and `automation_audit`; plaintext never
persisted.
**Disadvantages:** decryption happens in application code; key management
is the operator's responsibility (VAULT_ENCRYPTION_KEY). Mitigated by
scrypt key derivation and AES-GCM authenticated encryption.
## Decision
1. **`tenant_credentials` table** (migration 0066): one row per
`(tenant_id, provider_type, provider_name)`. Columns:
`encrypted_credential` (TEXT, never null), `credential_scope` (TEXT,
e.g. `dns:edit` or `adsense:read`), `account_id` (TEXT, opaque vendor
account identifier), `created_at`, `updated_at`. Unique index on
`(tenant_id, provider_type, provider_name)`.
2. **Encryption at rest**: `CredentialVaultService` derives a data-encryption
key from `VAULT_ENCRYPTION_KEY` via `scryptSync` (random 16-byte salt
stored alongside the ciphertext) and encrypts with AES-256-GCM (12-byte
nonce, 16-byte auth tag). Ciphertext = `salt || nonce || tag ||
ciphertext`, base64-encoded for storage. Decryption verifies the auth
tag before returning plaintext — tampered ciphertext raises
`VaultDecryptionError`.
3. **CredentialProvider abstraction** (`src/providers/credentials/`):
`getCredential(tenantId, providerType, providerName)` returns the
decrypted plaintext for a single provider call; `setCredential(...)`
encrypts and stores. The community edition without `VAULT_ENCRYPTION_KEY`
gets a `NoOpCredentialVault` that refuses writes and returns undefined
for reads — providers degrade to their existing env-based configuration.
4. **API + CLI**: `PUT /api/v1/tenant/credentials`, `GET` (metadata only,
never plaintext), `DELETE`, plus `dominus credentials set|get|clear`
commands. The dashboard shows provider name + scope + last updated, not
the secret.
5. **Rotation**: re-encrypt on next write; old ciphertext is replaced. No
re-encryption of existing rows on key rotation — the operator must
re-set each credential after rotating `VAULT_ENCRYPTION_KEY`.
## Consequences
**Positive:** tenants can connect their own registrar/parking/AdSense
accounts without the platform ever seeing plaintext in logs or backups;
the same vault serves every future provider; fail-closed when unconfigured.
**Negative:** key loss is permanent (no password recovery by design — the
operator must re-set credentials); decryption adds latency to every
provider call (sub-millisecond, acceptable); the operator must safeguard
`VAULT_ENCRYPTION_KEY` outside the database (env file with 0600, secret
manager, etc.).
**Risk Assessment:** low. Plaintext never touches storage; the only
plaintext lifetime is in-memory during a single provider call. The
encryption construction (scrypt + AES-GCM) is standard and uses Node's
built-in `crypto`, no new dependencies.
## Compliance Check
- [x] ADR-0004 provider abstraction: credentials resolved through an
interface, never hardcoded into business logic.
- [x] ADR-0082 honesty: the vault stores credentials; adapters are enabled
only when verified against the real vendor API.
- [x] ADR-0038 tenant isolation: repository queries filter on
`resolveTenantId()`.
- [x] Security: no credentials in logs, errors, or API responses; at-rest
encryption with authenticated ciphertext.
- [x] English identifiers and documentation throughout.