feat(#355): Create UserCredential model with RLS and encryption support
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

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>
This commit is contained in:
2026-02-07 16:39:15 -06:00
parent 1f86c36cc1
commit 864c23dc94
8 changed files with 1480 additions and 34 deletions

View File

@@ -0,0 +1,228 @@
# 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:
```sql
-- 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
```typescript
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
```typescript
// 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
```typescript
await prisma.userCredential.update({
where: { id: credentialId },
data: {
encryptedValue: await vaultService.encrypt(newPlainValue, TransitKey.CREDENTIALS),
maskedValue: maskValue(newPlainValue),
rotatedAt: new Date(),
},
});
```
### Soft Deleting (Revoking)
```typescript
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:
```bash
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:
```bash
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`