Delegated authorization: building a permissions API your enterprise customers can manage

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 }
  );
}
Build a "permissions as of date" query so enterprise customers can answer compliance questions like "who had billing:manage access on December 15th?" Immutable audit records with timestamps make this query trivial. Without immutable history, you can only answer what is currently true, not what was true at a point in time.
← Back to blog Try Bastionary free →