Files
stack/apps/api/src/credentials/README.md
Jason Woltje 864c23dc94
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat(#355): Create UserCredential model with RLS and encryption support
Implements secure user credential storage with comprehensive RLS policies
and encryption-ready architecture for Phase 3 of M9-CredentialSecurity.

**Features:**
- UserCredential Prisma model with 19 fields
- CredentialType enum (6 values: API_KEY, OAUTH_TOKEN, etc.)
- CredentialScope enum (USER, WORKSPACE, SYSTEM)
- FORCE ROW LEVEL SECURITY with 3 policies
- Encrypted value storage (OpenBao Transit ready)
- Cascade delete on user/workspace deletion
- Activity logging integration (CREDENTIAL_* actions)
- 28 comprehensive test cases

**Security:**
- RLS owner bypass, user access, workspace admin policies
- SQL injection hardening for is_workspace_admin()
- Encryption version tracking ready
- Full down migration for reversibility

**Testing:**
- 100% enum coverage (all CredentialType + CredentialScope values)
- Unique constraint enforcement
- Foreign key cascade deletes
- Timestamp behavior validation
- JSONB metadata storage

**Files:**
- Migration: 20260207_add_user_credentials (184 lines + 76 line down.sql)
- Security: 20260207163740_fix_sql_injection_is_workspace_admin
- Tests: user-credential.model.spec.ts (28 tests, 544 lines)
- Docs: README.md (228 lines), scratchpad

Fixes #355

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:39:15 -06:00

6.8 KiB

User Credentials Module

Secure storage and management of user API keys, OAuth tokens, and other credentials.

Overview

The User Credentials module provides encrypted storage for sensitive user credentials with:

  • Encryption: OpenBao Transit (preferred) or AES-256-GCM fallback
  • Access Control: Row-Level Security (RLS) with scope-based isolation
  • Audit Logging: Automatic tracking of credential access and modifications
  • Multi-tenancy: USER, WORKSPACE, and SYSTEM scope support

Database Model

UserCredential

Field Type Description
id UUID Primary key
userId UUID Owner (FK to users)
workspaceId UUID? Optional workspace (FK to workspaces)
name String Display name (e.g., "GitHub Personal Token")
provider String Provider identifier (e.g., "github", "openai")
type CredentialType Type of credential (API_KEY, OAUTH_TOKEN, etc.)
scope CredentialScope Access scope (USER, WORKSPACE, SYSTEM)
encryptedValue Text Encrypted credential value
maskedValue String? Masked value for display (e.g., "****abcd")
description Text? Optional description
expiresAt Timestamp? Optional expiration date
lastUsedAt Timestamp? Last access timestamp
metadata JSONB Provider-specific metadata
isActive Boolean Soft delete flag (default: true)
rotatedAt Timestamp? Last rotation timestamp
createdAt Timestamp Creation timestamp
updatedAt Timestamp Last update timestamp

Enums

CredentialType

  • API_KEY - API keys (GitHub, GitLab, etc.)
  • OAUTH_TOKEN - OAuth tokens
  • ACCESS_TOKEN - Access tokens
  • SECRET - Generic secrets
  • PASSWORD - Passwords
  • CUSTOM - Custom credential types

CredentialScope

  • USER - Personal credentials (visible only to owner)
  • WORKSPACE - Workspace-scoped credentials (visible to workspace admins)
  • SYSTEM - System-level credentials (admin access only)

Security

Row-Level Security (RLS)

All credential access is enforced via PostgreSQL RLS policies:

-- USER scope: Owner access only
scope = 'USER' AND user_id = current_user_id()

-- WORKSPACE scope: Workspace admin access
scope = 'WORKSPACE' AND is_workspace_admin(workspace_id, current_user_id())

-- SYSTEM scope: Admin/migration bypass only
current_user_id() IS NULL

Encryption

Credentials are encrypted at rest using:

  1. OpenBao Transit (preferred)

    • Format: vault:v1:base64data
    • Named key: mosaic-credentials
    • Handled by VaultService
  2. AES-256-GCM (fallback)

    • Format: iv:authTag:encrypted
    • Handled by CryptoService
    • Uses ENCRYPTION_KEY environment variable

The encryption middleware automatically falls back to AES when OpenBao is unavailable.

Activity Logging

All credential operations are logged:

  • CREDENTIAL_CREATED - Credential created
  • CREDENTIAL_ACCESSED - Credential value decrypted
  • CREDENTIAL_ROTATED - Credential value rotated
  • CREDENTIAL_REVOKED - Credential soft-deleted

Usage

Creating a Credential

const credential = await prisma.userCredential.create({
  data: {
    userId: currentUserId,
    name: "GitHub Personal Token",
    provider: "github",
    type: CredentialType.API_KEY,
    scope: CredentialScope.USER,
    encryptedValue: await vaultService.encrypt(plainValue, TransitKey.CREDENTIALS),
    maskedValue: maskValue(plainValue), // "****abcd"
    metadata: { scopes: ["repo", "user"] },
  },
});

Retrieving a Credential

// List credentials (encrypted values NOT returned)
const credentials = await prisma.userCredential.findMany({
  where: { userId: currentUserId, scope: CredentialScope.USER },
  select: {
    id: true,
    name: true,
    provider: true,
    type: true,
    maskedValue: true,
    lastUsedAt: true,
  },
});

// Decrypt a specific credential (audit logged)
const credential = await prisma.userCredential.findUnique({
  where: { id: credentialId },
});

const plainValue = await vaultService.decrypt(credential.encryptedValue, TransitKey.CREDENTIALS);

// Update lastUsedAt
await prisma.userCredential.update({
  where: { id: credentialId },
  data: { lastUsedAt: new Date() },
});

// Log access
await activityLog.log({
  action: ActivityAction.CREDENTIAL_ACCESSED,
  entityType: EntityType.CREDENTIAL,
  entityId: credentialId,
});

Rotating a Credential

await prisma.userCredential.update({
  where: { id: credentialId },
  data: {
    encryptedValue: await vaultService.encrypt(newPlainValue, TransitKey.CREDENTIALS),
    maskedValue: maskValue(newPlainValue),
    rotatedAt: new Date(),
  },
});

Soft Deleting (Revoking)

await prisma.userCredential.update({
  where: { id: credentialId },
  data: { isActive: false },
});

API Endpoints (Planned - Issue #356)

POST   /api/credentials              Create credential
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
POST   /api/credentials/:id/rotate   Rotate credential value
DELETE /api/credentials/:id          Soft-delete (revoke)

Testing

Run tests with:

pnpm test apps/api/src/credentials/user-credential.model.spec.ts

Tests cover:

  • Model structure and constraints
  • Enum validation
  • Unique constraints
  • Foreign key relations
  • Cascade deletes
  • Timestamps
  • JSONB metadata
  • #355 - Create UserCredential Prisma model with RLS policies (this issue)
  • #356 - Build credential CRUD API endpoints
  • #353 - VaultService NestJS module for OpenBao Transit
  • #350 - Add RLS policies to auth tables with FORCE enforcement

Migration

Migration: 20260207_add_user_credentials

Apply with:

cd apps/api
pnpm prisma migrate deploy

References

  • Design doc: docs/design/credential-security.md
  • OpenBao setup: docs/OPENBAO.md
  • RLS context: apps/api/src/lib/db-context.ts
  • CryptoService: apps/api/src/federation/crypto.service.ts