Skip to main content

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:

RoleKeyPermissions
Org Adminorg_adminread, write, publish, approve, admin
Package Ownerpackage_ownerread, write, publish, approve
Workspace Adminworkspace_adminread, write
Developerdeveloperread

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:

ColumnTypeDescription
external_roletextThe external group/role name
internal_roletextTarget Workloom role
providertextWhich provider this applies to (oidc, database, *)
org_iduuidOptional 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:

  1. Calls the userinfo endpoint with the user's access token
  2. Extracts role claims from the configured claim key
  3. Maps claim keys through RoleMappingService
  4. Caches results per-user (default: 10 minutes)

Configuration:

VariableDefaultDescription
OIDC_ISSUERIdP issuer URL (appends /userinfo)
OIDC_USERINFO_ENDPOINTExplicit userinfo URL (overrides issuer)
OIDC_ROLE_CLAIMurn:zitadel:iam:org:project:rolesClaim key in userinfo response
OIDC_ROLE_CACHE_TTL_MS600000Cache duration (milliseconds)

Database Provider

Resolves roles from the organization_memberships table. Always enabled.

How it works:

  1. Queries organization_memberships for rows matching the user ID
  2. Maps each role column value through RoleMappingService
  3. Caches results per-user (default: 5 minutes)

Configuration:

VariableDefaultDescription
DB_ROLE_CACHE_TTL_MS300000Cache duration (milliseconds)

Merge Strategy

When multiple providers return roles, Workloom uses merge-all with priority tiebreaking:

  1. All enabled providers are called in parallel
  2. Failed providers are skipped (graceful degradation)
  3. All returned roles are collected
  4. 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.