Claude
Skills
Sign in
Back

multi-tenant

Included with Lifetime
$97 forever

Multi-tenant architecture patterns including org_id claim management, JWT token structure with organization context, database isolation strategies for MongoDB and PostgreSQL, theme switching per organization, tenant provisioning workflows, data isolation patterns, and cross-tenant security. Activate for multi-tenancy implementation, tenant isolation, and organization-scoped data access.

Security

What this skill does


# 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

```json
{
  "sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "email": "[email protected]",
  "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": "[email protected]",
  "iss": "http://localhost:8080/realms/lobbi",
  "aud": "account",
  "exp": 1702000000,
  "iat": 1701999700,
  "jti": "unique-token-id"
}
```

### Configure org_id Claim Mapper

```bash
# 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

```javascript
// 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

```javascript
// 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

```javascript
// 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: {
        p

Related in Security