Enterprise customers want control over who in their organization can do what in your application. They do not want to contact your support team to grant a user admin access. They want a self-service admin portal where their IT team can manage roles, grant specific permissions, and review access history. Building this correctly requires a delegation model: org admins can manage permissions within the bounds of what they themselves have been granted, and no further.
The delegation model
The core constraint in delegated authorization: you cannot grant permissions you do not have. An org admin who has members:manage can grant that permission to others. An org admin who does not have billing:manage cannot grant it to anyone, regardless of whether they have the general admin role. This constraint prevents privilege escalation through the delegation chain.
// Permission grant: check that granting user has the permission they're granting
async function grantPermission(
grantorId: string,
targetUserId: string,
orgId: string,
permission: string
): Promise<void> {
// Grantor must have the permission they are trying to grant
const grantorHasPermission = await checkPermission(grantorId, orgId, permission);
if (!grantorHasPermission) {
throw new Error(`grantor_lacks_permission: ${permission}`);
}
// Grantor must have the meta-permission to manage members
const canManageMembers = await checkPermission(grantorId, orgId, 'members:manage');
if (!canManageMembers) {
throw new Error('grantor_cannot_manage_members');
}
await db.permissionGrants.create({
orgId,
userId: targetUserId,
permission,
grantedBy: grantorId,
grantedAt: new Date(),
expiresAt: null, // Permanent unless specified
});
await auditLog.record({
event: 'permission.granted',
orgId,
actor: grantorId,
target: targetUserId,
permission,
});
}
Scoped admin portals
The admin portal UI should reflect the grantor's actual permission scope. An org admin who only has members:manage and settings:read should see only the relevant sections of the admin UI. Showing them billing settings they cannot access creates confusion and is a signal to users that they are missing access they might want to request.
// Build the admin portal navigation based on the current user's permissions
async function getAdminPortalNavigation(userId: string, orgId: string) {
const permissions = await getUserPermissions(userId, orgId);
const navSections = [
{
slug: 'members',
label: 'Members',
requiredPermission: 'members:read',
subItems: [
{ slug: 'invite', label: 'Invite', requiredPermission: 'members:invite' },
{ slug: 'roles', label: 'Roles', requiredPermission: 'members:manage' },
],
},
{
slug: 'billing',
label: 'Billing',
requiredPermission: 'billing:read',
subItems: [
{ slug: 'plan', label: 'Plan', requiredPermission: 'billing:read' },
{ slug: 'payment', label: 'Payment Methods', requiredPermission: 'billing:manage' },
],
},
{
slug: 'security',
label: 'Security',
requiredPermission: 'settings:read',
subItems: [
{ slug: 'sso', label: 'SSO', requiredPermission: 'settings:manage' },
{ slug: 'audit', label: 'Audit Log', requiredPermission: 'audit_logs:read' },
],
},
];
// Filter sections to only those the user has access to
return navSections
.filter(section => permissions.includes(section.requiredPermission))
.map(section => ({
...section,
subItems: section.subItems.filter(item => permissions.includes(item.requiredPermission)),
}));
}
Audit trail for delegation
A complete audit trail for permissions must answer: who has what permission, who granted it, when, and who revoked it if applicable. This is a hard requirement for enterprise compliance reviews. The data model needs to preserve this history even after a permission is revoked.
-- Permission grants with full audit trail
CREATE TABLE permission_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permission TEXT NOT NULL,
granted_by TEXT NOT NULL REFERENCES users(id),
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_by TEXT REFERENCES users(id),
revoked_at TIMESTAMPTZ,
revoke_reason TEXT,
-- Never delete rows — revoke in place for audit trail
CONSTRAINT valid_state CHECK (
(revoked_at IS NULL) OR (revoked_at > granted_at)
)
);
-- Query: full permission history for a user
SELECT
pg.permission,
pg.granted_at,
g.email AS granted_by_email,
pg.expires_at,
pg.revoked_at,
r.email AS revoked_by_email,
pg.revoke_reason,
CASE
WHEN pg.revoked_at IS NOT NULL THEN 'revoked'
WHEN pg.expires_at IS NOT NULL AND pg.expires_at < NOW() THEN 'expired'
ELSE 'active'
END AS status
FROM permission_grants pg
LEFT JOIN users g ON pg.granted_by = g.id
LEFT JOIN users r ON pg.revoked_by = r.id
WHERE pg.user_id = $1 AND pg.org_id = $2
ORDER BY pg.granted_at DESC;
Temporary privilege grants
Temporary grants are useful for time-boxed elevated access: an on-call engineer gets production:deploy for the duration of their shift, a contractor gets projects:write for the length of their engagement. The expires_at field handles this. A background job enforces expiry — or more correctly, the permission check simply treats expired grants as inactive.
// Check if a user has a permission (checks grants, not just roles)
async function checkPermission(userId: string, orgId: string, permission: string): Promise<boolean> {
// Check role-based permissions first
const membership = await db.orgMembers.findOne({ userId, orgId });
if (membership && roleHasPermission(membership.role, permission)) {
return true;
}
// Check explicit grants (including time-limited ones)
const grant = await db.permissionGrants.findOne({
userId,
orgId,
permission,
revokedAt: null,
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
});
return grant !== null;
}
// Issue a temporary grant
async function grantTemporaryPermission(
grantorId: string,
targetUserId: string,
orgId: string,
permission: string,
durationHours: number,
reason: string
) {
const expiresAt = new Date(Date.now() + durationHours * 3600 * 1000);
await grantPermission(grantorId, targetUserId, orgId, permission);
await db.permissionGrants.update(
{ userId: targetUserId, orgId, permission, revokedAt: null },
{ expiresAt, reason }
);
}