multi-tenant
Multi-Tenant Architecture Skill
Comprehensive multi-tenant architecture patterns for the keycloak-alpha platform with organization-based isolation.
When to Use This Skill
Activate this skill when:
- Implementing multi-tenant architecture with org_id claims
- Setting up database isolation strategies
- Configuring per-organization themes
- Building tenant provisioning workflows
- Ensuring data isolation and security
- Implementing cross-tenant access controls
- Managing organization-scoped resources
Multi-Tenant Architecture Overview
The keycloak-alpha platform uses shared database, isolated schema approach with org_id-based isolation:
┌─────────────────────────────────────────────┐
│ Keycloak (Identity Provider) │
│ - Manages users across all organizations │
│ - Issues JWT tokens with org_id claim │
│ - Handles authentication & SSO │
└─────────────────────────────────────────────┘
↓ JWT with org_id
┌─────────────────────────────────────────────┐
│ API Gateway │
│ - Validates tokens │
│ - Extracts org_id claim │
│ - Routes to microservices │
└─────────────────────────────────────────────┘
↓ org_id in headers
┌─────────────────────────────────────────────┐
│ Microservices (8 services) │
│ - Enforce org_id filtering │
│ - Isolate data by organization │
│ - Apply org-specific business logic │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ MongoDB / PostgreSQL │
│ - Shared database │
│ - org_id indexed on all collections/tables │
│ - Row-level security (PostgreSQL) │
└─────────────────────────────────────────────┘
JWT Token Structure with Organization Context
Token Claims
{
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "john.doe@acme.com",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"org_id": "org_acme",
"org_name": "ACME Corporation",
"realm_access": {
"roles": ["org_admin", "user"]
},
"resource_access": {
"lobbi-web-app": {
"roles": ["user"]
}
},
"email_verified": true,
"preferred_username": "john.doe@acme.com",
"iss": "http://localhost:8080/realms/lobbi",
"aud": "account",
"exp": 1702000000,
"iat": 1701999700,
"jti": "unique-token-id"
}
Configure org_id Claim Mapper
# Add protocol mapper to include org_id in tokens
TOKEN=$(curl -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \
-d "username=admin&password=admin&grant_type=password&client_id=admin-cli" \
| jq -r '.access_token')
CLIENT_UUID=$(curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/admin/realms/lobbi/clients?clientId=lobbi-web-app" \
| jq -r '.[0].id')
curl -X POST "http://localhost:8080/admin/realms/lobbi/clients/$CLIENT_UUID/protocol-mappers/models" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "org_id_mapper",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "org_id",
"claim.name": "org_id",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}'
curl -X POST "http://localhost:8080/admin/realms/lobbi/clients/$CLIENT_UUID/protocol-mappers/models" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "org_name_mapper",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"config": {
"user.attribute": "org_name",
"claim.name": "org_name",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "false"
}
}'
Token Verification Middleware
// services/api-gateway/src/middleware/auth.js
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { UnauthorizedError, ForbiddenError } from '../utils/AppError.js';
const client = jwksClient({
jwksUri: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/certs`,
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 10
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
export async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return next(new UnauthorizedError('No token provided'));
}
jwt.verify(token, getKey, {
audience: 'account',
issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
algorithms: ['RS256']
}, (err, decoded) => {
if (err) {
return next(new UnauthorizedError('Invalid token'));
}
// CRITICAL: Verify org_id claim exists
if (!decoded.org_id) {
return next(new ForbiddenError('Missing org_id claim in token'));
}
// Attach user context to request
req.user = {
sub: decoded.sub,
email: decoded.email,
name: decoded.name,
orgId: decoded.org_id,
orgName: decoded.org_name,
roles: decoded.realm_access?.roles || []
};
next();
});
}
// Optional: Verify org_id matches resource being accessed
export function requireOrgAccess(req, res, next) {
const resourceOrgId = req.params.orgId || req.query.org_id || req.body.org_id;
if (resourceOrgId && resourceOrgId !== req.user.orgId) {
// Allow super_admin to access any org
if (!req.user.roles.includes('super_admin')) {
return next(new ForbiddenError('Cannot access resources from different organization'));
}
}
next();
}
Database Isolation Strategies
MongoDB Isolation with org_id
// services/user-service/src/models/User.js
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema({
keycloakId: {
type: String,
required: true,
unique: true,
index: true
},
email: {
type: String,
required: true,
lowercase: true,
trim: true
},
org_id: {
type: String,
required: true,
index: true // CRITICAL: Always index org_id
},
firstName: String,
lastName: String,
metadata: {
type: Map,
of: String
}
}, {
timestamps: true
});
// CRITICAL: Compound index for org-scoped queries
userSchema.index({ org_id: 1, email: 1 }, { unique: true });
userSchema.index({ org_id: 1, createdAt: -1 });
// Pre-query hook to enforce org_id filtering
userSchema.pre(/^find/, function(next) {
// Only enforce if org_id is not already in query
if (!this.getQuery().org_id && this.options.orgId) {
this.where({ org_id: this.options.orgId });
}
next();
});
export const UserModel = mongoose.model('User', userSchema);
Repository Pattern with org_id Isolation
// services/user-service/src/repositories/user.repository.js
import { UserModel } from '../models/User.js';
import { ForbiddenError, NotFoundError } from '../utils/AppError.js';
export class UserRepository {
constructor(orgId) {
this.orgId = orgId;
}
async findAll(filter = {}, options = {}) {
// ALWAYS enforce org_id filtering
const query = {
...filter,
org_id: this.orgId
};
const { page = 1, limit = 20, sort = { createdAt: -1 } } = options;
const users = await UserModel.find(query)
.select('-password')
.limit(limit)
.skip((page - 1) * limit)
.sort(sort);
const total = await UserModel.countDocuments(query);
return {
data: users,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};
}
async findById(id) {
const user = await UserModel.findOne({
_id: id,
org_id: this.orgId // CRITICAL: Always filter by org_id
}).select('-password');
if (!user) {
throw new NotFoundError('User');
}
return user;
}
async create(userData) {
const user = new UserModel({
...userData,
org_id: this.orgId // CRITICAL: Always set org_id
});
await user.save();
return user;
}
async update(id, updates) {
// Prevent changing org_id
delete updates.org_id;
const user = await UserModel.findOneAndUpdate(
{ _id: id, org_id: this.orgId }, // CRITICAL: Filter by org_id
updates,
{ new: true, runValidators: true }
).select('-password');
if (!user) {
throw new NotFoundError('User');
}
return user;
}
async delete(id) {
const result = await UserModel.deleteOne({
_id: id,
org_id: this.orgId // CRITICAL: Filter by org_id
});
if (result.deletedCount === 0) {
throw new NotFoundError('User');
}
return true;
}
}
// Usage in controller
export async function listUsers(req, res, next) {
try {
const repository = new UserRepository(req.user.orgId);
const result = await repository.findAll(
{ status: 'active' },
{ page: req.query.page, limit: req.query.limit }
);
res.json(result);
} catch (error) {
next(error);
}
}
PostgreSQL Row-Level Security
-- services/billing-service/migrations/001_create_subscriptions.sql
-- Enable row-level security
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
-- Create policy for org isolation
CREATE POLICY org_isolation ON subscriptions
USING (org_id = current_setting('app.current_org_id')::text);
-- Grant access to application role
GRANT SELECT, INSERT, UPDATE, DELETE ON subscriptions TO app_user;
-- Function to set org context
CREATE OR REPLACE FUNCTION set_org_context(p_org_id text)
RETURNS void AS $$
BEGIN
PERFORM set_config('app.current_org_id', p_org_id, false);
END;
$$ LANGUAGE plpgsql;
// services/billing-service/src/config/postgres.js
import { Pool } from 'pg';
export class PostgresClient {
constructor() {
this.pool = new Pool({
host: process.env.POSTGRES_HOST,
port: process.env.POSTGRES_PORT,
database: process.env.POSTGRES_DB,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
max: 20,
idleTimeoutMillis: 30000
});
}
async query(orgId, text, params) {
const client = await this.pool.connect();
try {
// Set org context for row-level security
await client.query('SELECT set_org_context($1)', [orgId]);
// Execute query (RLS automatically filters by org_id)
const result = await client.query(text, params);
return result;
} finally {
client.release();
}
}
}
// Usage
const db = new PostgresClient();
export async function getSubscription(req, res, next) {
try {
const result = await db.query(
req.user.orgId,
'SELECT * FROM subscriptions WHERE id = $1',
[req.params.id]
);
if (result.rows.length === 0) {
throw new NotFoundError('Subscription');
}
res.json(result.rows[0]);
} catch (error) {
next(error);
}
}
Theme Switching Per Organization
Theme Mapping Configuration
// services/keycloak-service/src/config/theme-mapping.js
export const themeMapping = {
// Organization ID -> Theme name mapping
org_acme: 'acme-custom',
org_beta: 'beta-theme',
org_gamma: 'gamma-dark',
// Default theme for organizations without custom theme
default: 'lobbi-base'
};
export function getThemeForOrg(orgId) {
return themeMapping[orgId] || themeMapping.default;
}
export function getAllThemes() {
const themes = new Set(Object.values(themeMapping));
return Array.from(themes);
}
Dynamic Theme Application
// services/api-gateway/src/middleware/theme-redirect.js
import { getThemeForOrg } from '../config/theme-mapping.js';
export function themeRedirectMiddleware(req, res, next) {
// Extract org_id from token or session
const orgId = req.user?.orgId;
if (!orgId) {
return next();
}
// Get theme for organization
const theme = getThemeForOrg(orgId);
// If redirecting to Keycloak login, add theme parameter
if (req.path.includes('/auth') || req.path.includes('/login')) {
const keycloakUrl = new URL(process.env.KEYCLOAK_URL);
keycloakUrl.pathname = `/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/auth`;
keycloakUrl.searchParams.set('client_id', 'lobbi-web-app');
keycloakUrl.searchParams.set('redirect_uri', req.query.redirect_uri);
keycloakUrl.searchParams.set('response_type', 'code');
keycloakUrl.searchParams.set('scope', 'openid profile email');
keycloakUrl.searchParams.set('kc_theme', theme); // Apply theme
return res.redirect(keycloakUrl.toString());
}
// Store theme in session for frontend
req.session.theme = theme;
next();
}
Frontend Theme Consumption
// apps/web-app/src/contexts/ThemeContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import { useAuth } from '@hooks/useAuth';
import { getThemeForOrg } from '@/api/theme';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const { user } = useAuth();
const [theme, setTheme] = useState('lobbi-base');
const [themeConfig, setThemeConfig] = useState(null);
useEffect(() => {
if (user?.orgId) {
loadTheme(user.orgId);
}
}, [user?.orgId]);
async function loadTheme(orgId) {
try {
const config = await getThemeForOrg(orgId);
setTheme(config.name);
setThemeConfig(config);
// Apply CSS variables
if (config.branding) {
document.documentElement.style.setProperty('--primary-color', config.branding.primaryColor);
document.documentElement.style.setProperty('--secondary-color', config.branding.secondaryColor);
}
} catch (error) {
console.error('Failed to load theme:', error);
}
}
return (
<ThemeContext.Provider value={{ theme, themeConfig }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
Tenant Provisioning Workflow
Organization Creation Service
// services/org-service/src/services/provisioning.service.js
import { OrganizationModel } from '../models/Organization.js';
import { KeycloakService } from './keycloak.service.js';
import { DatabaseService } from './database.service.js';
import { ThemeService } from './theme.service.js';
import { BillingService } from './billing.service.js';
export class ProvisioningService {
async provisionOrganization(data) {
const {
name,
domain,
adminEmail,
adminFirstName,
adminLastName,
plan = 'free'
} = data;
// Generate org_id
const orgId = `org_${domain.replace(/[^a-z0-9]/gi, '_').toLowerCase()}`;
try {
// 1. Create organization in database
const org = await this.createOrganization({
orgId,
name,
domain,
plan
});
// 2. Create Keycloak group for organization
const keycloakService = new KeycloakService();
const groupId = await keycloakService.createOrganizationGroup(orgId, name);
// 3. Create admin user in Keycloak
const adminUserId = await keycloakService.createUser({
email: adminEmail,
firstName: adminFirstName,
lastName: adminLastName,
orgId,
roles: ['org_admin']
});
// 4. Add user to organization group
await keycloakService.addUserToGroup(adminUserId, groupId);
// 5. Initialize database schemas/collections
const databaseService = new DatabaseService();
await databaseService.initializeOrgCollections(orgId);
// 6. Set up default theme
const themeService = new ThemeService();
await themeService.createOrgTheme(orgId, {
parent: 'lobbi-base',
branding: {
logoUrl: null,
primaryColor: '#3182ce',
secondaryColor: '#805ad5'
}
});
// 7. Create billing customer (if not free plan)
if (plan !== 'free') {
const billingService = new BillingService();
await billingService.createCustomer({
orgId,
email: adminEmail,
name,
plan
});
}
// 8. Send welcome email
await this.sendWelcomeEmail(adminEmail, {
orgName: name,
loginUrl: process.env.APP_URL
});
return {
orgId,
organizationId: org._id,
adminUserId,
message: 'Organization provisioned successfully'
};
} catch (error) {
// Rollback on failure
await this.rollbackProvisioning(orgId);
throw error;
}
}
async createOrganization(data) {
const org = new OrganizationModel({
org_id: data.orgId,
name: data.name,
domain: data.domain,
settings: {
theme: 'lobbi-base',
features: new Map([
['sso', data.plan !== 'free'],
['advanced_analytics', data.plan === 'enterprise'],
['custom_branding', data.plan !== 'free']
])
},
subscription: {
plan: data.plan,
status: 'active',
billingCycle: 'monthly'
},
status: 'active'
});
await org.save();
return org;
}
async rollbackProvisioning(orgId) {
console.error(`Rolling back provisioning for ${orgId}`);
try {
// Delete organization from database
await OrganizationModel.deleteOne({ org_id: orgId });
// Delete Keycloak group and users
const keycloakService = new KeycloakService();
await keycloakService.deleteOrganizationGroup(orgId);
// Clean up database collections
const databaseService = new DatabaseService();
await databaseService.cleanupOrgCollections(orgId);
} catch (rollbackError) {
console.error('Rollback failed:', rollbackError);
}
}
}
Tenant Provisioning API Endpoint
// services/org-service/src/controllers/provisioning.controller.js
import { ProvisioningService } from '../services/provisioning.service.js';
import { asyncHandler } from '../middleware/errorHandler.js';
export const provisionOrganization = asyncHandler(async (req, res) => {
const {
name,
domain,
adminEmail,
adminFirstName,
adminLastName,
plan
} = req.body;
const provisioningService = new ProvisioningService();
const result = await provisioningService.provisionOrganization({
name,
domain,
adminEmail,
adminFirstName,
adminLastName,
plan
});
res.status(201).json(result);
});
export const deprovisionOrganization = asyncHandler(async (req, res) => {
const { orgId } = req.params;
// Only super_admin can deprovision
if (!req.user.roles.includes('super_admin')) {
throw new ForbiddenError('Insufficient permissions');
}
const provisioningService = new ProvisioningService();
await provisioningService.deprovisionOrganization(orgId);
res.json({ message: 'Organization deprovisioned successfully' });
});
Data Isolation Patterns
Query Middleware for Automatic org_id Filtering
// shared/middleware/org-scope.middleware.js
export function orgScopeMiddleware(Model) {
// Pre-find hooks
Model.schema.pre(/^find/, function(next) {
if (this.options.skipOrgFilter) {
return next();
}
// Automatically add org_id filter if not present
if (!this.getQuery().org_id && this.options.orgId) {
this.where({ org_id: this.options.orgId });
}
next();
});
// Pre-update hooks
Model.schema.pre('updateOne', function(next) {
if (this.options.skipOrgFilter) {
return next();
}
if (!this.getQuery().org_id && this.options.orgId) {
this.where({ org_id: this.options.orgId });
}
next();
});
// Pre-delete hooks
Model.schema.pre('deleteOne', function(next) {
if (this.options.skipOrgFilter) {
return next();
}
if (!this.getQuery().org_id && this.options.orgId) {
this.where({ org_id: this.options.orgId });
}
next();
});
}
Service-Level Isolation
// services/user-service/src/services/user.service.js
export class UserService {
constructor(orgId) {
if (!orgId) {
throw new Error('orgId is required for UserService');
}
this.orgId = orgId;
}
async findAll(filter = {}, options = {}) {
// ALWAYS enforce org_id
return await UserModel.find({
...filter,
org_id: this.orgId
}, null, {
orgId: this.orgId,
...options
});
}
async findById(id) {
const user = await UserModel.findOne({
_id: id,
org_id: this.orgId
});
if (!user) {
throw new NotFoundError('User');
}
return user;
}
// Prevent cross-org data leaks
async bulkUpdate(userIds, updates) {
// First verify all users belong to this org
const count = await UserModel.countDocuments({
_id: { $in: userIds },
org_id: this.orgId
});
if (count !== userIds.length) {
throw new ForbiddenError('Some users do not belong to this organization');
}
// Proceed with update
return await UserModel.updateMany(
{
_id: { $in: userIds },
org_id: this.orgId
},
updates
);
}
}
Cross-Tenant Security Considerations
Preventing Cross-Org Data Access
// services/api-gateway/src/middleware/org-validation.middleware.js
export function validateOrgAccess(extractOrgId) {
return (req, res, next) => {
// Extract org_id from request (params, query, or body)
const resourceOrgId = extractOrgId(req);
if (!resourceOrgId) {
return next();
}
// Verify user has access to this org
if (resourceOrgId !== req.user.orgId) {
// Super admins can access any org
if (req.user.roles.includes('super_admin')) {
return next();
}
// Log potential security violation
console.warn('Cross-org access attempt:', {
userId: req.user.sub,
userOrgId: req.user.orgId,
attemptedOrgId: resourceOrgId,
path: req.path,
method: req.method,
ip: req.ip
});
return next(new ForbiddenError('Access denied to organization resources'));
}
next();
};
}
// Usage in routes
router.get('/organizations/:orgId/users',
validateOrgAccess(req => req.params.orgId),
listUsers
);
Audit Logging for Cross-Org Access
// services/analytics-service/src/services/audit.service.js
export class AuditService {
async logAccess(event) {
const log = {
timestamp: new Date(),
userId: event.userId,
userOrgId: event.userOrgId,
resourceOrgId: event.resourceOrgId,
action: event.action,
resource: event.resource,
resourceId: event.resourceId,
success: event.success,
ipAddress: event.ipAddress,
userAgent: event.userAgent
};
// Flag suspicious cross-org access
if (event.userOrgId !== event.resourceOrgId && !event.isSuperAdmin) {
log.suspicious = true;
log.severity = 'high';
// Alert security team
await this.sendSecurityAlert(log);
}
await AuditLogModel.create(log);
}
}
Best Practices
- ALWAYS include org_id in JWT tokens via Keycloak protocol mapper
- NEVER trust client-provided org_id - always use token claim
- INDEX org_id on ALL collections/tables for query performance
- Use repository pattern to enforce org_id filtering
- Implement row-level security in PostgreSQL for additional safety
- Validate org_id in middleware before reaching controllers
- Audit cross-org access attempts for security monitoring
- Test isolation thoroughly with automated tests
- Use compound indexes for org_id + frequently queried fields
- Prevent org_id modification in update operations
- Implement graceful tenant deprovisioning with cleanup
- Version control theme mappings for traceability
- Monitor query performance by org_id to detect issues
- Implement rate limiting per org to prevent abuse
- Use separate database connections per org for critical isolation (optional)
File Locations in keycloak-alpha
| Path | Purpose |
|---|---|
services/org-service/ |
Organization provisioning and management |
services/api-gateway/src/middleware/auth.js |
Token validation and org_id extraction |
services/keycloak-service/src/config/theme-mapping.js |
Theme per organization mapping |
shared/middleware/org-scope.middleware.js |
Automatic org_id filtering |
services/analytics-service/src/services/audit.service.js |
Cross-org access auditing |
Testing Multi-Tenancy
Test Organization Isolation
// services/user-service/tests/isolation.test.js
describe('Multi-tenant isolation', () => {
it('should prevent cross-org data access', async () => {
// Create users in two different orgs
const org1User = await createUser({ org_id: 'org_1', email: 'user1@org1.com' });
const org2User = await createUser({ org_id: 'org_2', email: 'user2@org2.com' });
// Try to access org_2 user with org_1 token
const org1Token = generateToken({ org_id: 'org_1' });
const response = await request(app)
.get(`/api/users/${org2User._id}`)
.set('Authorization', `Bearer ${org1Token}`)
.expect(403);
expect(response.body.error.message).toContain('Access denied');
});
it('should allow super_admin cross-org access', async () => {
const org2User = await createUser({ org_id: 'org_2' });
const superAdminToken = generateToken({
org_id: 'org_1',
roles: ['super_admin']
});
await request(app)
.get(`/api/users/${org2User._id}`)
.set('Authorization', `Bearer ${superAdminToken}`)
.expect(200);
});
});
More from lobbi-docs/claude
vision-multimodal
Vision and multimodal capabilities for Claude including image analysis, PDF processing, and document understanding. Activate for image input, base64 encoding, multiple images, and visual analysis.
242design-system
Apply and manage the AI-powered design system with 50+ curated styles
126complex-reasoning
Multi-step reasoning patterns and frameworks for systematic problem solving. Activate for Chain-of-Thought, Tree-of-Thought, hypothesis-driven debugging, and structured analytical approaches that leverage extended thinking.
105gcp
Google Cloud Platform services including GKE, Cloud Run, Cloud Storage, BigQuery, and Pub/Sub. Activate for GCP infrastructure, Google Cloud deployment, and GCP integration.
73kanban
Kanban methodology including boards, WIP limits, flow metrics, and continuous delivery. Activate for Kanban boards, workflow visualization, and lean project management.
62debugging
Debugging techniques for Python, JavaScript, and distributed systems. Activate for troubleshooting, error analysis, log investigation, and performance debugging. Includes extended thinking integration for complex debugging scenarios.
59