Role Providers
Workloom uses a plugin-based role resolution system. Multiple providers can resolve roles from external systems (IdPs, databases, LDAP), and results are merged to produce a single internal role per user.
Internal Roles
Workloom has four internal roles, in priority order:
| Role | Key | Permissions |
|---|---|---|
| Org Admin | org_admin | read, write, publish, approve, admin |
| Package Owner | package_owner | read, write, publish, approve |
| Workspace Admin | workspace_admin | read, write |
| Developer | developer | read |
These are static TypeScript types defined in packages/core/src/rbac.ts. They cannot be extended at runtime.
Architecture
┌─────────────────────────────────────┐
│ resolveUserRole() │
│ (server-side, per-request) │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ resolveFromProviders() │
│ 1. Filter enabled providers │
│ 2. Call all in parallel │
│ 3. Merge results (highest wins) │
└───┬──────────────┬──────────────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────────┐ ┌────────────┐
│ OIDC │ │ Database │ │ Custom │
│ Provider │ │ Provider │ │ Provider │
└──────────┘ └──────────────┘ └────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────┐
│ RoleMappingService │
│ "platform-admins" → org_admin │
│ "engineers" → developer │
└─────────────────────────────────────────────┘
Role Mapping
External systems use arbitrary group/role names. The RoleMappingService maps these to internal Workloom roles.
Environment Variable Defaults
Set defaults via ROLE_MAP_* environment variables:
ROLE_MAP_ORG_ADMIN=org_admin,platform-admins,super-admin
ROLE_MAP_PACKAGE_OWNER=package_owner,release-managers
ROLE_MAP_WORKSPACE_ADMIN=workspace_admin,team-leads,project-admins
ROLE_MAP_DEVELOPER=developer,engineers,devs,contractors
Format: ROLE_MAP_<UPPER_SNAKE_INTERNAL_ROLE>=comma,separated,external,names
When no ROLE_MAP_* variables are set, each internal role maps to itself (backward compatible).
Database Overrides
The role_mappings table allows per-org runtime overrides that take precedence over env vars:
| Column | Type | Description |
|---|---|---|
external_role | text | The external group/role name |
internal_role | text | Target Workloom role |
provider | text | Which provider this applies to (oidc, database, *) |
org_id | uuid | Optional org scope (null = global) |
Database overrides are cached for 5 minutes via Next.js unstable_cache.
Built-in Providers
OIDC Provider
Resolves roles from the IdP's userinfo endpoint. Enabled when OIDC_ISSUER or OIDC_USERINFO_ENDPOINT is configured.
How it works:
- Calls the userinfo endpoint with the user's access token
- Extracts role claims from the configured claim key
- Maps claim keys through
RoleMappingService - Caches results per-user (default: 10 minutes)
Configuration:
| Variable | Default | Description |
|---|---|---|
OIDC_ISSUER | — | IdP issuer URL (appends /userinfo) |
OIDC_USERINFO_ENDPOINT | — | Explicit userinfo URL (overrides issuer) |
OIDC_ROLE_CLAIM | urn:zitadel:iam:org:project:roles | Claim key in userinfo response |
OIDC_ROLE_CACHE_TTL_MS | 600000 | Cache duration (milliseconds) |
Database Provider
Resolves roles from the organization_memberships table. Always enabled.
How it works:
- Queries
organization_membershipsfor rows matching the user ID - Maps each
rolecolumn value throughRoleMappingService - Caches results per-user (default: 5 minutes)
Configuration:
| Variable | Default | Description |
|---|---|---|
DB_ROLE_CACHE_TTL_MS | 300000 | Cache duration (milliseconds) |
Merge Strategy
When multiple providers return roles, Workloom uses merge-all with priority tiebreaking:
- All enabled providers are called in parallel
- Failed providers are skipped (graceful degradation)
- All returned roles are collected
- The highest-priority role across all providers wins
Example: OIDC returns developer, Database returns workspace_admin → user gets workspace_admin.
If no provider returns a recognized role, the user falls back to developer.
Writing a Custom Provider
Implement the RoleProvider interface from @workloom/auth:
import type { RoleProvider, RoleProviderContext, RoleProviderResult } from '@workloom/auth';
export class LdapRoleProvider implements RoleProvider {
readonly name = 'ldap';
enabled(): boolean {
return !!process.env.LDAP_URL;
}
async resolveRoles(context: RoleProviderContext): Promise<RoleProviderResult> {
if (!context.userId) {
return { roles: [], source: this.name, cached: false };
}
// Your LDAP lookup logic here
const groups = await ldapClient.getGroupsForUser(context.userId);
const roles = this.mappingService.mapExternalMany(groups);
return { roles, source: this.name, cached: false };
}
}
Register it in apps/web/src/server/roleResolver.ts by adding it to the providers array returned by getProviders().
Interface Reference
interface RoleProviderContext {
userId: string | null;
accessToken: string | null;
orgId?: string | null;
}
interface RoleProviderResult {
roles: string[]; // Internal Workloom role strings
source: string; // Provider name (for debugging)
cached: boolean; // Whether result came from cache
}
interface RoleProvider {
readonly name: string;
enabled(): boolean;
resolveRoles(context: RoleProviderContext): Promise<RoleProviderResult>;
}
Caching
Each provider manages its own cache. This allows:
- OIDC provider to cache based on access token lifetime
- Database provider to cache with a shorter TTL (data changes more often)
- Custom providers to use Redis, filesystem, or no cache as appropriate
The mapping service itself is rebuilt on each request but sources its DB overrides from a 5-minute Next.js cache.
Admin Route Protection
Server-side route guards use requireSession() which calls resolveUserRole() internally:
// apps/web/src/app/(dashboard)/admin/layout.tsx
export default async function AdminLayout({ children }) {
const session = await requireSession();
if (session.role !== 'org_admin') {
redirect('/');
}
return <>{children}</>;
}
Client-Side Role Access
Use the useRole() hook to access the resolved role on the client:
import { useRole } from '@/hooks/useRole';
function MyComponent() {
const { role, permissions, isLoading } = useRole();
if (isLoading) return <Spinner />;
if (!permissions?.includes('admin')) return <Forbidden />;
return <AdminPanel />;
}
The hook calls a server action under the hood, caching results for 5 minutes via React Query.