# Credential Security Architecture

**Version:** 0.0.1
**Status:** Approved
**Author:** Mosaic Stack Team
**Date:** 2026-02-07
**Epic:** [#346](https://git.mosaicstack.dev/mosaic/stack/issues/346)
**Milestone:** M7-CredentialSecurity

## Table of Contents

1. [Problem Statement](#problem-statement)
2. [Threat Model](#threat-model)
3. [Architecture Decision](#architecture-decision)
4. [System Architecture](#system-architecture)
5. [Data Model](#data-model)
6. [API Design](#api-design)
7. [RLS Enforcement](#rls-enforcement)
8. [OpenBao Integration](#openbao-integration)
9. [Federation Isolation](#federation-isolation)
10. [Implementation Phases](#implementation-phases)
11. [Risk Mitigation](#risk-mitigation)

---

## Problem Statement

Mosaic Stack stores sensitive user credentials with critical security gaps:

1. **OAuth tokens stored plaintext** in the `accounts` table (`access_token`, `refresh_token`,
   `id_token`)
2. **LLM API keys stored plaintext** in `llm_provider_instances.config` JSON field
3. **RLS enabled but never enforced** — all 23 tables have policies but no `FORCE ROW LEVEL
SECURITY`, and Prisma connects as table owner, silently bypassing all policies
4. **No RLS on auth tables** — `accounts`, `sessions`, `verifications` have no policies
5. **No user credential management** — no model, API, or UI for storing user-provided tokens
6. **Master encryption key on disk** — `ENCRYPTION_KEY` in `.env` file

Users will store API keys, git tokens, and OAuth tokens for integrations. This data is private
and must never leak between users or across federation boundaries.

## Threat Model

### At-Rest Threats (mitigated by encryption)

| Threat                      | Impact                         | Mitigation                                  |
| --------------------------- | ------------------------------ | ------------------------------------------- |
| Database backup exposure    | All credentials leaked         | Column-level encryption via OpenBao Transit |
| SQL injection               | Attacker reads encrypted blobs | Encrypted data useless without Transit key  |
| Database admin access       | Full table reads               | Encrypted columns, RLS enforcement          |
| Filesystem access to `.env` | Master key compromised         | OpenBao Shamir key splitting (production)   |

### In-Use Threats (mitigated by access control)

| Threat                  | Impact                            | Mitigation                           |
| ----------------------- | --------------------------------- | ------------------------------------ |
| Cross-user data access  | User A sees User B's tokens       | RLS policies with FORCE enforcement  |
| Federation data leakage | Remote instance gets credentials  | Explicit deny-list in QueryService   |
| Application logic bugs  | Wrong user gets wrong credential  | RLS as defense-in-depth layer        |
| Compromised app server  | Memory access to decrypted values | Short-lived plaintext, audit logging |

### Not Mitigated

Full application server compromise with code execution grants access to decrypted credentials
in memory. This is an accepted risk — no encryption scheme protects against a fully compromised
application process.

## Architecture Decision

### Approach: Hybrid OpenBao + PostgreSQL Encryption

After evaluating three approaches, the hybrid model was selected:

| Concern                       | Pure DB (pgcrypto) | Pure Vault        | Hybrid (selected)              |
| ----------------------------- | ------------------ | ----------------- | ------------------------------ |
| Key on disk (turtles problem) | `.env` on disk     | Shamir-split      | Shamir-split                   |
| Audit trail                   | Custom logging     | Built-in          | Built-in                       |
| New infrastructure            | None               | OpenBao container | OpenBao container              |
| Per-user isolation            | RLS only           | Vault policies    | RLS + encryption               |
| Turnkey deployment            | Yes                | Manual unsealing  | Auto-unseal via init container |
| Dynamic secrets               | No                 | Yes               | Yes                            |
| License cost                  | Free               | Free (OpenBao)    | Free                           |

**Why not pure DB?** The "turtles all the way down" problem — encrypting in the DB still
requires a master key in an environment variable on disk. If the server is compromised, the
key is compromised.

**Why not pure Vault?** Operational complexity. Storing all credentials in Vault requires
significant Vault policy management. PostgreSQL with RLS provides a more natural data model
for user-scoped credentials.

**Why hybrid?** Best of both worlds — PostgreSQL stores encrypted credentials with RLS
enforcement, OpenBao handles key management via Transit engine. The master key never exists
on disk as a single value (Shamir-split in production).

### Why OpenBao (not HashiCorp Vault)?

- Truly open-source (Linux Foundation, OSI license)
- Drop-in Vault replacement (API-compatible)
- No Business Source License concerns
- Production-ready (v2.0)
- Smaller, focused ecosystem

## System Architecture

```
                     ┌──────────────────────┐
                     │   Next.js Frontend   │
                     │  /settings/creds     │
                     └──────────┬───────────┘
                                │ HTTPS
                     ┌──────────▼───────────┐
                     │     NestJS API       │
                     │  CredentialsService  │
                     │     VaultService     │
                     └───┬──────────────┬───┘
                         │              │
              Ciphertext │              │ Transit API
              (storage)  │              │ (encrypt/decrypt)
                         │              │
              ┌──────────▼──┐    ┌──────▼──────────┐
              │ PostgreSQL  │    │    OpenBao       │
              │ + RLS       │    │  Transit Engine  │
              │ + pgcrypto  │    │  + AppRole Auth  │
              └─────────────┘    │  + Audit Log     │
                                 └─────────────────┘
```

### Data Flow: Store Credential

1. User submits API key via frontend form
2. NestJS `CredentialsController` receives plaintext value
3. `CredentialsService` calls `VaultService.encrypt(value, TransitKey.CREDENTIALS)`
4. `VaultService` calls OpenBao Transit API: `POST /v1/transit/encrypt/mosaic-credentials`
5. Transit returns ciphertext: `vault:v1:base64data`
6. Ciphertext stored in `user_credentials.encrypted_value`
7. Masked value (`****abcd`) stored in `user_credentials.masked_value`
8. Activity log entry: `CREDENTIAL_CREATED`
9. Response includes masked value only — never the ciphertext or plaintext

### Data Flow: Retrieve Credential

1. User clicks "Reveal" on credential card
2. Frontend calls `GET /api/credentials/:id/value`
3. RLS-scoped query fetches row (user can only see own rows)
4. `VaultService.decrypt(ciphertext, TransitKey.CREDENTIALS)`
5. Transit returns plaintext
6. `lastUsedAt` updated on credential row
7. Activity log entry: `CREDENTIAL_ACCESSED`
8. Plaintext returned to frontend, auto-hidden after 30 seconds

### Fallback: No OpenBao Available

When OpenBao is unavailable (local dev, CI), `VaultService` falls back to the existing
`CryptoService` (AES-256-GCM with `ENCRYPTION_KEY` from environment).

Ciphertext format distinguishes the source:

- `vault:v1:...` — OpenBao Transit ciphertext
- `aes:iv:authTag:encrypted` — AES-256-GCM fallback
- No prefix — legacy plaintext (backward compatible, triggers encryption on next write)

## Data Model

### UserCredential Table

```
user_credentials
├── id              UUID (PK)
├── user_id         UUID (FK -> users)
├── workspace_id    UUID? (FK -> workspaces, nullable for user-global)
├── name            VARCHAR       -- "GitHub Personal Token"
├── provider        VARCHAR       -- "github", "openai", "custom"
├── type            CredentialType (API_KEY, OAUTH_TOKEN, ACCESS_TOKEN, SECRET, PASSWORD, CUSTOM)
├── scope           CredentialScope (USER, WORKSPACE, SYSTEM)
├── encrypted_value TEXT          -- OpenBao Transit ciphertext
├── masked_value    VARCHAR?      -- "****abcd"
├── description     TEXT?
├── expires_at      TIMESTAMPTZ?
├── last_used_at    TIMESTAMPTZ?
├── metadata        JSONB         -- provider-specific data
├── is_active       BOOLEAN       -- soft delete
├── rotated_at      TIMESTAMPTZ?
├── created_at      TIMESTAMPTZ
└── updated_at      TIMESTAMPTZ

UNIQUE(user_id, workspace_id, provider, name)
```

### Scope Semantics

| Scope     | Who Can Access     | Use Case                      |
| --------- | ------------------ | ----------------------------- |
| USER      | Owner only         | Personal API keys, git tokens |
| WORKSPACE | Workspace admins   | Shared integration tokens     |
| SYSTEM    | System admins only | Platform-level secrets        |

### Enum Additions

- `EntityType`: add `CREDENTIAL`
- `ActivityAction`: add `CREDENTIAL_CREATED`, `CREDENTIAL_ACCESSED`, `CREDENTIAL_ROTATED`,
  `CREDENTIAL_REVOKED`

## API Design

### User Credential Endpoints

```
POST   /api/credentials              Create credential (encrypt + store)
GET    /api/credentials              List credentials (masked values only)
GET    /api/credentials/:id          Get single credential (masked)
GET    /api/credentials/:id/value    Decrypt and return value (audit logged)
PATCH  /api/credentials/:id          Update metadata (not value)
POST   /api/credentials/:id/rotate   Replace with new encrypted value
DELETE /api/credentials/:id          Soft-delete (isActive=false)
```

Guards: `AuthGuard` + `WorkspaceGuard` + `PermissionGuard`

### Admin Secret Endpoints

```
POST   /api/admin/secrets            Create system-level secret
GET    /api/admin/secrets            List system secrets (masked)
PATCH  /api/admin/secrets/:id        Update system secret
DELETE /api/admin/secrets/:id        Revoke system secret
```

Guards: `AuthGuard` + `AdminGuard`

### Security Invariant

**Listing endpoints never return plaintext or ciphertext.** Only `maskedValue` appears in
list/get responses. Decryption requires an explicit `GET /value` call, which is always
audit-logged.

## RLS Enforcement

### Current Problem

All 23 RLS-enabled tables use `ENABLE ROW LEVEL SECURITY` but never `FORCE ROW LEVEL SECURITY`.
Prisma connects as the database owner role (`mosaic`), which bypasses all RLS policies by default.
The RLS context utilities in `apps/api/src/lib/db-context.ts` are fully implemented but never
called by any service.

### Solution

1. **FORCE ROW LEVEL SECURITY** on auth and credential tables
2. **Owner bypass policy** for migration compatibility
3. **RLS context interceptor** sets session variables in every authenticated request

```sql
ALTER TABLE user_credentials FORCE ROW LEVEL SECURITY;

-- Owner bypass for migrations
CREATE POLICY credentials_owner_bypass ON user_credentials
  FOR ALL TO mosaic USING (true);

-- User access policy
CREATE POLICY credentials_user_access ON user_credentials
  FOR ALL USING (
    (scope = 'USER' AND user_id = current_user_id())
    OR (scope = 'WORKSPACE' AND workspace_id IS NOT NULL
        AND is_workspace_admin(workspace_id, current_user_id()))
  );
```

### RLS Context Interceptor

Registered as `APP_INTERCEPTOR`, wraps all authenticated requests:

1. Extracts `userId` from `AuthGuard`
2. Extracts `workspaceId` from `WorkspaceGuard`
3. Executes `SET LOCAL app.current_user_id = '{userId}'` in Prisma transaction
4. Uses `AsyncLocalStorage` to propagate transaction client to services

## OpenBao Integration

### Turnkey Docker Deployment

Two containers added to `docker/docker-compose.yml`:

1. **openbao** — OpenBao server with file storage backend
2. **openbao-init** — Sidecar that auto-initializes, auto-unseals, and configures Transit

On first `docker compose up -d`:

- OpenBao initializes with 1-of-1 key share (turnkey simplicity)
- Transit secrets engine enabled
- Four named encryption keys created
- AppRole created with Transit-only policy
- Credentials saved to shared Docker volume

On restart:

- `openbao-init` reads stored unseal key and auto-unseals

### Named Transit Keys

| Key                     | Purpose                                          |
| ----------------------- | ------------------------------------------------ |
| `mosaic-credentials`    | User-stored credentials (API keys, git tokens)   |
| `mosaic-account-tokens` | BetterAuth OAuth tokens in accounts table        |
| `mosaic-federation`     | Federation private keys (replaces CryptoService) |
| `mosaic-llm-config`     | LLM provider API keys                            |

### Production Hardening

For production deployments (documented in `docs/OPENBAO.md`):

- Upgrade to 3-of-5 Shamir key splitting: `bao operator rekey -key-shares=5 -key-threshold=3`
- Enable TLS on listener
- Use external KMS for auto-unseal (AWS KMS, GCP CKMS, Azure Key Vault)
- Enable audit logging: `bao audit enable file file_path=/bao/logs/audit.log`
- Use Raft or Consul storage backend for HA
- Revoke root token after initial setup

## Federation Isolation

Credentials must never leak across federation boundaries:

1. **RLS enforcement** — Federated queries go through `QueryService` which operates within a
   specific workspace context. RLS policies restrict to authenticated user.
2. **Explicit deny-list** — `QueryService` denies queries for `UserCredential` entity type
3. **Transit key isolation** — Each credential type uses a separate named key. Federation keys
   (`mosaic-federation`) cannot decrypt user credentials (`mosaic-credentials`).
4. **Endpoint isolation** — Credential API requires session auth. Federated requests use
   signature-based auth and cannot access credential endpoints.

## Implementation Phases

### Phase 1: Security Foundations (p0)

Fix immediate security gaps:

| Issue | Title                                                  |
| ----- | ------------------------------------------------------ |
| #351  | Create RLS context interceptor (fix SEC-API-4)         |
| #350  | Add RLS policies to auth tables with FORCE enforcement |
| #352  | Encrypt existing plaintext Account tokens              |

### Phase 2: OpenBao Integration (p1)

Add OpenBao and VaultService:

| Issue | Title                                                      |
| ----- | ---------------------------------------------------------- |
| #357  | Add OpenBao to Docker Compose (turnkey setup)              |
| #353  | Create VaultService NestJS module for OpenBao Transit      |
| #354  | Write OpenBao documentation and production hardening guide |

### Phase 3: User Credential Storage (p1)

Build the credential management system:

| Issue | Title                                                |
| ----- | ---------------------------------------------------- |
| #355  | Create UserCredential Prisma model with RLS policies |
| #356  | Build credential CRUD API endpoints                  |

### Phase 4: Frontend (p1)

User-facing credential management:

| Issue | Title                                      |
| ----- | ------------------------------------------ |
| #358  | Build frontend credential management pages |

### Phase 5: Migration and Hardening (p1-p3)

Encrypt remaining plaintext and harden federation:

| Issue | Title                                     |
| ----- | ----------------------------------------- |
| #359  | Encrypt LLM provider API keys in database |
| #360  | Federation credential isolation           |
| #361  | Credential audit log viewer (stretch)     |

### Phase Dependencies

```
Phase 1 (RLS + Token Encryption)
  └── Phase 2 (OpenBao + VaultService)
        ├── Phase 3 (Credential Model + API)
        │     └── Phase 4 (Frontend)
        └── Phase 5 (LLM Migration + Federation)
```

## Risk Mitigation

| Risk                               | Mitigation                                                           |
| ---------------------------------- | -------------------------------------------------------------------- |
| FORCE RLS breaks Prisma migrations | Owner bypass policy grants full access to `mosaic` role              |
| FORCE RLS breaks BetterAuth writes | Interceptor sets user context; BetterAuth uses same client           |
| OpenBao container fails to start   | VaultService falls back to AES-256-GCM; app stays functional         |
| Data migration corrupts tokens     | Run in transaction; backup first; format prefix tracking             |
| BetterAuth reads encrypted tokens  | Prisma middleware transparently decrypts on read                     |
| Transit key rotation               | OpenBao handles versioning transparently; old ciphertext stays valid |

## Key Files Reference

| Purpose                | Path                                                                       |
| ---------------------- | -------------------------------------------------------------------------- |
| Existing CryptoService | `apps/api/src/federation/crypto.service.ts`                                |
| RLS context utilities  | `apps/api/src/lib/db-context.ts`                                           |
| Prisma schema          | `apps/api/prisma/schema.prisma`                                            |
| RLS migration          | `apps/api/prisma/migrations/20260129221004_add_rls_policies/migration.sql` |
| Docker Compose         | `docker/docker-compose.yml`                                                |
| App module             | `apps/api/src/app.module.ts`                                               |
| Auth guards            | `apps/api/src/auth/guards/auth.guard.ts`                                   |
| Workspace guard        | `apps/api/src/common/guards/workspace.guard.ts`                            |
| Security review        | `docs/reports/codebase-review-2026-02-05/01-security-review.md`            |
