first commit
This commit is contained in:
205
packages/auth/src/rbac.ts
Normal file
205
packages/auth/src/rbac.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Role-Based Access Control
|
||||
*/
|
||||
|
||||
import type { ApiTokenScope } from "./tokens.js";
|
||||
import { Role, type RoleLevel } from "./types.js";
|
||||
|
||||
/**
|
||||
* Permission definitions with minimum role required
|
||||
*/
|
||||
export const Permissions = {
|
||||
// Content
|
||||
"content:read": Role.SUBSCRIBER,
|
||||
"content:create": Role.CONTRIBUTOR,
|
||||
"content:edit_own": Role.AUTHOR,
|
||||
"content:edit_any": Role.EDITOR,
|
||||
"content:delete_own": Role.AUTHOR,
|
||||
"content:delete_any": Role.EDITOR,
|
||||
"content:publish_own": Role.AUTHOR,
|
||||
"content:publish_any": Role.EDITOR,
|
||||
|
||||
// Media
|
||||
"media:read": Role.SUBSCRIBER,
|
||||
"media:upload": Role.CONTRIBUTOR,
|
||||
"media:edit_own": Role.AUTHOR,
|
||||
"media:edit_any": Role.EDITOR,
|
||||
"media:delete_own": Role.AUTHOR,
|
||||
"media:delete_any": Role.EDITOR,
|
||||
|
||||
// Taxonomies
|
||||
"taxonomies:read": Role.SUBSCRIBER,
|
||||
"taxonomies:manage": Role.EDITOR,
|
||||
|
||||
// Comments
|
||||
"comments:read": Role.SUBSCRIBER,
|
||||
"comments:moderate": Role.EDITOR,
|
||||
"comments:delete": Role.ADMIN,
|
||||
"comments:settings": Role.ADMIN,
|
||||
|
||||
// Menus
|
||||
"menus:read": Role.SUBSCRIBER,
|
||||
"menus:manage": Role.EDITOR,
|
||||
|
||||
// Widgets
|
||||
"widgets:read": Role.SUBSCRIBER,
|
||||
"widgets:manage": Role.EDITOR,
|
||||
|
||||
// Sections
|
||||
"sections:read": Role.SUBSCRIBER,
|
||||
"sections:manage": Role.EDITOR,
|
||||
|
||||
// Redirects
|
||||
"redirects:read": Role.EDITOR,
|
||||
"redirects:manage": Role.ADMIN,
|
||||
|
||||
// Users
|
||||
"users:read": Role.ADMIN,
|
||||
"users:invite": Role.ADMIN,
|
||||
"users:manage": Role.ADMIN,
|
||||
|
||||
// Settings
|
||||
"settings:read": Role.EDITOR,
|
||||
"settings:manage": Role.ADMIN,
|
||||
|
||||
// Schema (content types)
|
||||
"schema:read": Role.EDITOR,
|
||||
"schema:manage": Role.ADMIN,
|
||||
|
||||
// Plugins
|
||||
"plugins:read": Role.EDITOR,
|
||||
"plugins:manage": Role.ADMIN,
|
||||
|
||||
// Import
|
||||
"import:execute": Role.ADMIN,
|
||||
|
||||
// Search
|
||||
"search:read": Role.SUBSCRIBER,
|
||||
"search:manage": Role.ADMIN,
|
||||
|
||||
// Auth
|
||||
"auth:manage_own_credentials": Role.SUBSCRIBER,
|
||||
"auth:manage_connections": Role.ADMIN,
|
||||
} as const;
|
||||
|
||||
export type Permission = keyof typeof Permissions;
|
||||
|
||||
/**
|
||||
* Check if a user has a specific permission
|
||||
*/
|
||||
export function hasPermission(
|
||||
user: { role: RoleLevel } | null | undefined,
|
||||
permission: Permission,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
return user.role >= Permissions[permission];
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a permission, throwing if not met
|
||||
*/
|
||||
export function requirePermission(
|
||||
user: { role: RoleLevel } | null | undefined,
|
||||
permission: Permission,
|
||||
): asserts user is { role: RoleLevel } {
|
||||
if (!user) {
|
||||
throw new PermissionError("unauthorized", "Authentication required");
|
||||
}
|
||||
if (!hasPermission(user, permission)) {
|
||||
throw new PermissionError("forbidden", `Missing permission: ${permission}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can perform action on a resource they own
|
||||
*/
|
||||
export function canActOnOwn(
|
||||
user: { role: RoleLevel; id: string } | null | undefined,
|
||||
ownerId: string,
|
||||
ownPermission: Permission,
|
||||
anyPermission: Permission,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (user.id === ownerId) {
|
||||
return hasPermission(user, ownPermission);
|
||||
}
|
||||
return hasPermission(user, anyPermission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Require permission on a resource, checking ownership
|
||||
*/
|
||||
export function requirePermissionOnResource(
|
||||
user: { role: RoleLevel; id: string } | null | undefined,
|
||||
ownerId: string,
|
||||
ownPermission: Permission,
|
||||
anyPermission: Permission,
|
||||
): asserts user is { role: RoleLevel; id: string } {
|
||||
if (!user) {
|
||||
throw new PermissionError("unauthorized", "Authentication required");
|
||||
}
|
||||
if (!canActOnOwn(user, ownerId, ownPermission, anyPermission)) {
|
||||
throw new PermissionError("forbidden", `Missing permission: ${anyPermission}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionError extends Error {
|
||||
constructor(
|
||||
public code: "unauthorized" | "forbidden",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PermissionError";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API Token Scope ↔ Role mapping
|
||||
//
|
||||
// Maps each API token scope to the minimum RBAC role required to hold it.
|
||||
// Used at token issuance time to clamp granted scopes to the user's role.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Minimum role required for each API token scope.
|
||||
*
|
||||
* This is the authoritative mapping between the two authorization systems
|
||||
* (RBAC roles and API token scopes). When issuing a token, the granted
|
||||
* scopes must be intersected with the scopes allowed by the user's role.
|
||||
*/
|
||||
const SCOPE_MIN_ROLE: Record<ApiTokenScope, RoleLevel> = {
|
||||
"content:read": Role.SUBSCRIBER,
|
||||
"content:write": Role.CONTRIBUTOR,
|
||||
"media:read": Role.SUBSCRIBER,
|
||||
"media:write": Role.CONTRIBUTOR,
|
||||
"schema:read": Role.EDITOR,
|
||||
"schema:write": Role.ADMIN,
|
||||
admin: Role.ADMIN,
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the maximum set of API token scopes a given role level may hold.
|
||||
*
|
||||
* Used at token issuance time (device flow, authorization code exchange)
|
||||
* to enforce: effective_scopes = requested_scopes ∩ scopesForRole(role).
|
||||
*/
|
||||
export function scopesForRole(role: RoleLevel): ApiTokenScope[] {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Object.entries loses tuple types; SCOPE_MIN_ROLE keys are ApiTokenScope by construction
|
||||
const entries = Object.entries(SCOPE_MIN_ROLE) as [ApiTokenScope, RoleLevel][];
|
||||
return entries.reduce<ApiTokenScope[]>((acc, [scope, minRole]) => {
|
||||
if (role >= minRole) acc.push(scope);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a set of requested scopes to those permitted by a user's role.
|
||||
*
|
||||
* Returns the intersection of `requested` and the scopes the role allows.
|
||||
* This is the central policy enforcement point: effective permissions =
|
||||
* role permissions ∩ token scopes.
|
||||
*/
|
||||
export function clampScopes(requested: string[], role: RoleLevel): string[] {
|
||||
const allowed = new Set<string>(scopesForRole(role));
|
||||
return requested.filter((s) => allowed.has(s));
|
||||
}
|
||||
Reference in New Issue
Block a user