All checks were successful
ci/woodpecker/push/build Pipeline was successful
CredentialsController uses AuthGuard which depends on AuthService. NestJS resolves guard dependencies in the module context, so CredentialsModule needs to import AuthModule directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 tokensACCESS_TOKEN- Access tokensSECRET- Generic secretsPASSWORD- PasswordsCUSTOM- 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:
-
OpenBao Transit (preferred)
- Format:
vault:v1:base64data - Named key:
mosaic-credentials - Handled by
VaultService
- Format:
-
AES-256-GCM (fallback)
- Format:
iv:authTag:encrypted - Handled by
CryptoService - Uses
ENCRYPTION_KEYenvironment variable
- Format:
The encryption middleware automatically falls back to AES when OpenBao is unavailable.
Activity Logging
All credential operations are logged:
CREDENTIAL_CREATED- Credential createdCREDENTIAL_ACCESSED- Credential value decryptedCREDENTIAL_ROTATED- Credential value rotatedCREDENTIAL_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
Related Issues
- #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