Database
This guide covers database setup, schema management, migrations, and Prisma usage in Cerberus IAM API.
Overview
Cerberus uses:
- Database: PostgreSQL 14+
- ORM: Prisma 5
- Migrations: Prisma Migrate
- Schema: Multi-tenant with organization isolation
Quick Start
1. Install PostgreSQL
macOS (Homebrew):
brew install postgresql@14
brew services start postgresql@14Ubuntu/Debian:
sudo apt install postgresql-14
sudo systemctl start postgresqlDocker:
docker run --name cerberus-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=cerberus_iam \
-p 5432:5432 \
-d postgres:14-alpine2. Create Database
# Connect to PostgreSQL
psql -U postgres
# Create database
CREATE DATABASE cerberus_iam;
# Create user (optional)
CREATE USER cerberus WITH PASSWORD 'secure_password';
GRANT ALL PRIVILEGES ON DATABASE cerberus_iam TO cerberus;3. Configure Connection
Update .env:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cerberus_iam?schema=public4. Run Migrations
# Run all migrations
npx prisma migrate deploy
# Or in development (with Prisma Studio access)
npx prisma migrate dev5. Seed Database (Optional)
# Generate initial data
npx prisma db seedDatabase Schema
Schema Overview
The database uses a multi-tenant architecture centered around Organizations:
Organization (tenant root)
├── Users
│ ├── Roles
│ ├── Teams
│ └── Sessions
├── OAuth Clients
│ ├── Tokens
│ ├── Refresh Tokens
│ └── Authorization Codes
├── API Keys
├── Webhooks
└── Audit LogsGlobal Entities
Not scoped to organizations:
Permissions
model Permission {
id String @id @default(uuid())
name String @unique
slug String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles Role[]
}Examples:
users:read,users:write,users:deleteclients:manage,roles:assign*(super admin wildcard)
Scopes
OAuth2/OIDC scopes:
model Scope {
id String @id @default(uuid())
name String @unique
displayName String
description String
isDefault Boolean @default(false)
isSystem Boolean @default(false)
category String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Examples:
openid,profile,email,offline_access- Custom application scopes
JWK (Signing Keys)
model JWK {
id String @id @default(uuid())
kid String @unique
alg String
publicKey String
privateKey String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Tenant-Scoped Entities
Organization
model Organisation {
id String @id @default(uuid())
name String @unique
slug String @unique
email String @unique
status OrganisationStatus @default(trial)
sessionLifetime Int @default(3600)
sessionIdleTimeout Int @default(1800)
requireMfa Boolean @default(false)
passwordPolicy Json?
tokenLifetimePolicy Json?
branding Json?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
}Features:
- Soft delete support (
deletedAt) - Configurable session policies
- Custom branding and metadata
- Trial/Active/Suspended/Cancelled status
User
model User {
id String @id @default(uuid())
organisationId String
firstName String
lastName String
name String
email String @unique
emailVerifiedAt DateTime?
password String?
mfaEnabled Boolean @default(false)
mfaMethods Json?
totpSecret String?
backupCodes Json?
lastLoginAt DateTime?
lastLoginIp String?
loginCount Int @default(0)
blockedAt DateTime?
blockedReason String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
organisation Organisation @relation(fields: [organisationId], references: [id])
roles Role[]
teams Team[]
sessions Session[]
}Features:
- MFA support (TOTP, backup codes)
- Email verification
- Account blocking
- Soft delete
- Login tracking
Role
model Role {
id String @id @default(uuid())
organisationId String
name String
slug String
description String?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organisation Organisation @relation(fields: [organisationId], references: [id])
permissions Permission[]
users User[]
}Organization-Scoped:
- Each organization has its own roles
- Slug unique per organization:
@@unique([organisationId, slug])
OAuth Client
model Client {
id String @id @default(uuid())
organisationId String
name String
clientId String @unique
clientSecret String?
clientType ClientType
grantTypes Json
redirectUris Json
scopes Json
requirePkce Boolean @default(true)
requireConsent Boolean @default(true)
tokenEndpointAuthMethod TokenEndpointAuthMethod
accessTokenLifetime Int
refreshTokenLifetime Int
idTokenLifetime Int
isActive Boolean @default(true)
isFirstParty Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
organisation Organisation @relation(fields: [organisationId], references: [id])
}Client Types:
confidential- Server-side apps with client secretpublic- SPAs, mobile apps (PKCE required)
Session Management
Session
model Session {
id String @id @default(uuid())
userId String
organisationId String
sessionToken String @unique
ipAddress String
userAgent String
lastActivityAt DateTime
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
organisation Organisation @relation(fields: [organisationId], references: [id])
}Features:
- Hashed session tokens (SHA-256)
- Absolute expiration (
expiresAt) - Idle timeout (
lastActivityAt) - IP and user agent tracking
OAuth2 Tokens
Access Token
model Token {
id String @id @default(uuid())
jti String @unique
clientId String
userId String?
organisationId String
tokenType String @default("Bearer")
scopes Json
issuedAt DateTime
expiresAt DateTime
revokedAt DateTime?
lastUsedAt DateTime?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id])
user User? @relation(fields: [userId], references: [id])
organisation Organisation @relation(fields: [organisationId], references: [id])
}Refresh Token
model RefreshToken {
id String @id @default(uuid())
token String @unique
accessTokenId String @unique
clientId String
userId String
organisationId String
scopes Json
expiresAt DateTime
revokedAt DateTime?
familyId String
parentTokenId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accessToken Token @relation(fields: [accessTokenId], references: [id])
client Client @relation(fields: [clientId], references: [id])
user User @relation(fields: [userId], references: [id])
organisation Organisation @relation(fields: [organisationId], references: [id])
parent RefreshToken? @relation("RefreshTokenFamily", fields: [parentTokenId], references: [id])
children RefreshToken[] @relation("RefreshTokenFamily")
}Features:
- Refresh token rotation
- Token family tracking
- Automatic revocation on reuse detection
Audit & Security
Audit Log
model AuditLog {
id String @id @default(uuid())
organisationId String
userId String?
clientId String?
eventType String
eventCategory EventCategory
action EventAction
resourceType String?
resourceId String?
ipAddress String
userAgent String
metadata Json?
success Boolean
errorMessage String?
createdAt DateTime @default(now())
organisation Organisation @relation(fields: [organisationId], references: [id])
user User? @relation(fields: [userId], references: [id])
client Client? @relation(fields: [clientId], references: [id])
}Indexed Fields:
organisationId,userId,clientIdeventType,createdAt
Prisma Usage
Prisma Client
Import the shared instance:
import { prisma } from '@/db/prisma';
// Query users
const users = await prisma.user.findMany({
where: { organisationId: 'org-id' },
include: {
roles: {
include: {
permissions: true,
},
},
},
});Common Patterns
Organization Scoping
Always scope queries to organization:
// Find user within organization
const user = await prisma.user.findFirst({
where: {
id: userId,
organisationId: orgId,
deletedAt: null,
},
});
// Create organization-scoped entity
const role = await prisma.role.create({
data: {
organisationId: orgId,
name: 'Manager',
slug: 'manager',
},
});Soft Deletes
Use deletedAt for soft deletes:
// Soft delete
await prisma.user.update({
where: { id: userId },
data: { deletedAt: new Date() },
});
// Exclude deleted
const activeUsers = await prisma.user.findMany({
where: {
organisationId: orgId,
deletedAt: null,
},
});Transactions
Use transactions for multi-step operations:
await prisma.$transaction(async (tx) => {
// Delete user
await tx.user.update({
where: { id: userId },
data: { deletedAt: new Date() },
});
// Revoke sessions
await tx.session.deleteMany({
where: { userId },
});
// Revoke tokens
await tx.token.updateMany({
where: { userId },
data: { revokedAt: new Date() },
});
});Include Relations
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
organisation: true,
roles: {
include: {
permissions: true,
},
},
teams: true,
},
});Select Specific Fields
const users = await prisma.user.findMany({
where: { organisationId: orgId },
select: {
id: true,
name: true,
email: true,
roles: {
select: {
id: true,
name: true,
},
},
},
});Migrations
Development Workflow
Create Migration
# Make schema changes in prisma/schema.prisma
# Generate migration
npx prisma migrate dev --name add_user_phone_field
# This will:
# 1. Create migration file in prisma/migrations/
# 2. Apply migration to database
# 3. Regenerate Prisma ClientReset Database
# Drop database and reapply all migrations
npx prisma migrate reset
# Warning: This deletes all data!Generate Prisma Client
# Regenerate client after schema changes
npx prisma generateProduction Deployment
Apply Migrations
# In production, use migrate deploy
npx prisma migrate deploy
# This applies pending migrations without promptsMigration Safety
Safe Operations:
- Adding new tables
- Adding nullable columns
- Adding indexes
- Creating new relations
Unsafe Operations (require care):
- Dropping columns
- Changing column types
- Adding NOT NULL columns
- Renaming tables/columns
For Unsafe Operations:
- Create multi-step migration:
-- Step 1: Add new column (nullable)
ALTER TABLE users ADD COLUMN new_field TEXT;
-- Step 2: Backfill data
UPDATE users SET new_field = old_field;
-- Step 3: Make NOT NULL (next migration)
ALTER TABLE users ALTER COLUMN new_field SET NOT NULL;
-- Step 4: Drop old column (next migration)
ALTER TABLE users DROP COLUMN old_field;- Deploy incrementally
- Monitor for errors
Migration Files
Located in prisma/migrations/:
prisma/migrations/
├── 20240101000000_initial/
│ └── migration.sql
├── 20240102000000_add_mfa/
│ └── migration.sql
└── migration_lock.tomlEach migration contains:
- Timestamp prefix
- Descriptive name
- SQL migration file
Connection Pooling
Prisma Connection Pool
Prisma manages connections automatically:
// Default configuration
const prisma = new PrismaClient({
log: ['query', 'error', 'warn'],
});Custom Pool Configuration
Via DATABASE_URL parameters:
DATABASE_URL=postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=20Parameters:
connection_limit- Max connections (default: unlimited)pool_timeout- Connection timeout in seconds (default: 10)
Production Recommendations
# Recommended production settings
DATABASE_URL=postgresql://user:pass@host:5432/db?connection_limit=20&pool_timeout=30&sslmode=requireGuidelines:
- Set
connection_limitbased on available connections - Formula:
(number_of_instances * connection_limit) < max_db_connections - Leave headroom for maintenance connections
Performance Optimization
Indexes
Schema includes indexes on:
- Foreign keys
- Frequently queried fields
- Unique constraints
model User {
@@index([email])
@@index([organisationId])
@@index([deletedAt])
}Query Optimization
Use Select for Large Objects:
// Bad: Fetches all fields
const users = await prisma.user.findMany();
// Good: Only needed fields
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
},
});Pagination:
// Cursor-based (recommended)
const users = await prisma.user.findMany({
take: 20,
skip: 1,
cursor: {
id: lastUserId,
},
});
// Offset-based
const users = await prisma.user.findMany({
take: 20,
skip: (page - 1) * 20,
});Batch Operations:
// Instead of multiple creates
for (const data of items) {
await prisma.item.create({ data });
}
// Use createMany
await prisma.item.createMany({
data: items,
});Query Logging
Enable in development:
const prisma = new PrismaClient({
log: [
{ level: 'query', emit: 'event' },
{ level: 'error', emit: 'stdout' },
],
});
prisma.$on('query', (e) => {
console.log('Query: ' + e.query);
console.log('Duration: ' + e.duration + 'ms');
});Backup & Recovery
Automated Backups
Using pg_dump:
# Daily backup
pg_dump -U postgres -d cerberus_iam -F c -b -v -f backup_$(date +%Y%m%d).dump
# Restore
pg_restore -U postgres -d cerberus_iam -v backup_20240101.dumpDocker Backup:
docker exec cerberus-postgres pg_dump -U postgres cerberus_iam > backup.sqlContinuous Backups
For production, use:
- AWS RDS Automated Backups
- Google Cloud SQL Automated Backups
- PostgreSQL WAL archiving
- Third-party backup solutions
Troubleshooting
Connection Refused
Problem: Can't connect to database
Solutions:
Verify PostgreSQL is running:
bashpg_isready -h localhost -p 5432Check connection string format
Test with psql:
bashpsql $DATABASE_URLVerify network/firewall rules
Migration Fails
Problem: Migration fails during deployment
Solutions:
- Check migration SQL for errors
- Verify database permissions
- Check for conflicting data
- Rollback and fix:bash
npx prisma migrate resolve --rolled-back <migration-name>
Slow Queries
Problem: Database queries taking too long
Solutions:
- Enable query logging
- Add indexes for frequent queries
- Use
EXPLAIN ANALYZE:sqlEXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]'; - Optimize with
selectinstead of full fetches - Consider read replicas for read-heavy workloads
Connection Pool Exhausted
Problem: "Can't reach database server"
Solutions:
- Increase
connection_limit - Reduce connection usage (use connection pooling)
- Check for connection leaks
- Monitor active connections:sql
SELECT count(*) FROM pg_stat_activity WHERE datname = 'cerberus_iam';
Next Steps
- Authentication - User authentication and sessions
- Multi-Tenancy - Organization isolation patterns
- Production Deployment - Production database setup