Skip to content

FRD: Authorization Library

FieldValue
IDFRD-011
OwnerDavid Holmes
StatusDraft
Last Updated2026-05-26
Open Source Librarieszod, jose
RelatedADR-027 (Default Tech Stack)
Target Releasev2.0.0
TypeLibrary
ComplexityL

Document Summary

This FRD defines a shared authorization library that provides role-checking, fine-grained permission evaluation, tenant/org scoping, server-side route guards, and client-side React hooks. The goal is to eliminate per-app reimplementation of access-control logic by shipping a single canonical authz layer that works with the existing AuthenticatedUser context from src/lib/auth/ and pairs with existing UI components like AccessDeniedState and the RBAC role table widget.


Introduction

The platform currently has auth identity (who you are) handled by src/lib/auth/, and auth presentation (permission UI components, access-denied states, RBAC table widgets) handled by the component layer. The critical middle layer — authorization logic (what you are allowed to do) — is missing. Each application reinvents role checking, permission evaluation, tenant scoping, and route guarding. This leads to inconsistent access-control semantics, untested edge cases around multi-tenant isolation, and duplicated boilerplate. This library fills that gap.


Scope

In scope

  • requireRole(user, role) — already exists in current-user.ts; refactor into the new authz module and extend with multi-role variants.
  • requirePermission(user, permission, resource?) — fine-grained permission check against a permission registry.
  • Permission registry: a declarative way to define permissions per feature/resource and the roles that grant them.
  • Tenant/org scoping: helpers that ensure a user can only access resources within their tenant, with explicit cross-tenant override for super-admin roles.
  • Server-side route guards for Astro API routes and middleware.
  • Client-side React hooks: usePermission(permission, resource?), useRole(role), useAuthz().
  • Integration with existing AccessDeniedState component for denial rendering.
  • Denial-state tests: unit tests that verify every denial path returns the correct error code and HTTP status.

Out of scope

  • Attribute-based access control (ABAC) or policy engines (e.g., OPA, Cedar). This is RBAC with permission mapping.
  • Database-backed dynamic role assignment. Roles come from JWT claims.
  • Admin UI for managing roles and permissions (separate widget work).

Users and Pain Points

UserPain Point
App developerWrites ad-hoc if (user.roles.includes("admin")) checks scattered across route handlers with no centralized permission model.
App developerCannot check permissions on the client side without duplicating server-side logic.
App developerMulti-tenant resource isolation requires manual tenantId comparisons on every query, with no shared helper to enforce it.
Platform maintainerSecurity audits must trace permission logic through each app individually; no single source of truth.
QA engineerNo standard way to test denial states because each app handles 403s differently.

Definitions

TermDefinition
PermissionA fine-grained capability string (e.g., orders:read, users:manage) that maps to one or more roles.
RoleA coarse grouping from the JWT roles claim (e.g., admin, member, viewer).
Permission registryA static, declarative mapping of permissions to the roles that grant them.
Tenant scopeThe constraint that a user’s actions are restricted to resources belonging to their tenantId.
Route guardA middleware or wrapper that checks authorization before allowing a request handler to execute.

Current State

  • requireRole(user, role) exists in src/lib/auth/current-user.ts but only checks a single role and throws AuthError with code auth.missing_role.
  • AccessDeniedState component exists at src/components/ui/access-denied-state.tsx for rendering denial states in the UI.
  • RBAC role table widget exists at src/components/widgets/sre-devops/rbac-role-table.stories.tsx for displaying role assignments.
  • No shared permission model, no permission registry, no tenant-scoping helpers, no client-side hooks, no route guards.

Proposed Solution

Permission registry

A definePermissions() function accepts a declarative configuration object:

const permissions = definePermissions({
"orders:read": { roles: ["admin", "member", "viewer"] },
"orders:write": { roles: ["admin", "member"] },
"orders:delete": { roles: ["admin"] },
"users:manage": { roles: ["admin"] },
"billing:read": { roles: ["admin", "billing"] },
});

The registry is a plain object, serialisable to JSON, and shareable between server and client. It is validated at creation time with Zod.

Authorization context

A createAuthzContext(user, registry) function produces an AuthzContext with methods:

  • hasRole(role) / hasAnyRole(roles) / hasAllRoles(roles) — boolean role checks.
  • hasPermission(permission) — looks up the permission in the registry and checks the user’s roles.
  • requirePermission(permission) — throws AuthzError on denial.
  • requireTenantAccess(resourceTenantId) — throws if the user’s tenantId does not match, unless the user has a super_admin role.

Route guards

// Astro API route guard
export const GET = withAuthz({ permission: "orders:read" }, async (ctx, user) => {
// user is guaranteed to have the permission
});
// Middleware-style guard
const guard = createRouteGuard(registry, { permission: "users:manage" });

Guards return a 403 JSON response using the canonical ApiError envelope on denial.

React hooks

const { hasPermission, hasRole } = useAuthz();
const canEdit = hasPermission("orders:write");
const isAdmin = hasRole("admin");

The hooks read from an AuthzProvider that accepts the AuthenticatedUser and the permission registry. When a permission check fails, components render AccessDeniedState directly.


Requirements

IDPriorityRequirement
AUTHZ-01P0definePermissions creates a validated, serialisable permission registry.
AUTHZ-02P0requirePermission(user, permission, registry) throws AuthzError with code authz.permission_denied and HTTP 403 on denial.
AUTHZ-03P0requireTenantAccess(user, resourceTenantId) throws AuthzError with code authz.tenant_mismatch when tenant IDs do not match.
AUTHZ-04P0Super-admin bypass: users with the super_admin role pass all tenant-access checks.
AUTHZ-05P0Astro route guard (withAuthz) and middleware guard (createRouteGuard).
AUTHZ-06P0React hooks: useAuthz(), usePermission(), useRole().
AUTHZ-07P0AuthzProvider accepts AuthenticatedUser and permission registry.
AUTHZ-08P1hasAnyRole and hasAllRoles convenience methods.
AUTHZ-09P0Denial-state tests: every require* path has a test confirming the error code and status.
AUTHZ-10P1Integration example showing usePermission gating a UI element and rendering AccessDeniedState on denial.

Functional Requirements

  1. Permission registry validation: definePermissions throws at creation time if a permission key is empty, if a role array is empty, or if the same permission is defined twice.
  2. Permission resolution: hasPermission(permission) returns true if the user has at least one role listed in the registry entry for that permission. Returns false if the permission is not in the registry (fail-closed).
  3. Tenant scoping: requireTenantAccess(user, resourceTenantId) compares user.tenantId to resourceTenantId. If they differ and the user does not have the super_admin role, it throws AuthzError.
  4. Route guard composition: withAuthz accepts an options object with optional permission, role, and/or tenantExtractor (a function that extracts the resource tenant ID from the request). All specified checks must pass.
  5. React provider: AuthzProvider must be a controlled component that accepts user and registry as props. It must not re-render children when neither prop changes (memoised context value).
  6. Error types: AuthzError extends Error with code, status, and details fields, following the same pattern as AuthError.

Non-Functional Requirements

CategoryRequirement
PerformancePermission lookups are O(1) hash-map access. Role checks are O(n) where n is the number of user roles (typically less than 5).
Bundle sizeThe client-side hooks and provider add less than 2 KB gzipped. The permission registry is tree-shakeable from server-only code.
TestabilityAll authz functions are pure and accept the user and registry as arguments (no global state).
Type safetydefinePermissions returns a type-safe registry where permission keys are inferred as a string literal union. requirePermission accepts only keys from that union.
Zero runtime dependenciesThe authz library depends only on zod (already in the project) and the existing src/lib/auth/ types.

API/Interface Requirements

Core authz functions

function definePermissions<P extends string>(
config: Record<P, { roles: string[] }>
): PermissionRegistry<P>;
function createAuthzContext<P extends string>(
user: AuthenticatedUser,
registry: PermissionRegistry<P>,
): AuthzContext<P>;
interface AuthzContext<P extends string> {
user: AuthenticatedUser;
hasRole(role: string): boolean;
hasAnyRole(roles: string[]): boolean;
hasAllRoles(roles: string[]): boolean;
hasPermission(permission: P): boolean;
requirePermission(permission: P): void;
requireTenantAccess(resourceTenantId: string): void;
}

Route guards

function withAuthz<P extends string>(
options: RouteGuardOptions<P>,
handler: (ctx: APIContext, user: AuthenticatedUser) => Response | Promise<Response>,
): (ctx: APIContext) => Response | Promise<Response>;
function createRouteGuard<P extends string>(
registry: PermissionRegistry<P>,
options: RouteGuardOptions<P>,
): AstroMiddleware;

React hooks

function AuthzProvider<P extends string>(props: {
children: React.ReactNode;
registry: PermissionRegistry<P>;
user: AuthenticatedUser;
}): React.ReactElement;
function useAuthz<P extends string>(): AuthzContext<P>;
function usePermission<P extends string>(permission: P): boolean;
function useRole(role: string): boolean;

Accessibility Requirements

  • React hooks must not alter the DOM. Denial rendering is the responsibility of consuming components (e.g., AccessDeniedState).
  • Error messages from AuthzError must be screen-reader-safe (no jargon, no internal codes in the user-facing message).
  • The AccessDeniedState integration example must use the existing component, which already handles ARIA attributes.

Content and Documentation Requirements

  • TSDoc on every exported function, type, and interface.
  • A docs/best-practices/authorization.mdx guide covering: defining a permission registry, server-side route guards, client-side hooks, tenant scoping, and testing denial states.
  • Storybook docs page under Docs/Best Practices/Authorization that renders the guide.
  • A Storybook story demonstrating usePermission toggling a UI element between the allowed state and AccessDeniedState.

Dependencies

DependencyTypeNotes
src/lib/auth/InternalConsumes AuthenticatedUser, AuthError pattern.
zodExistingSchema validation for the permission registry.
reactExistingHooks and context provider.
src/components/ui/access-denied-state.tsxInternalIntegration target for denial rendering.
ADR-025GovernanceJWT claim set defines the roles used in authz checks.

Risks and Tradeoffs

RiskLikelihoodImpactMitigation
Static permission registry may be too rigid for apps with dynamic, database-driven permissions.MediumMediumDesign the registry interface so apps can provide a custom PermissionResolver in v2.1 without breaking the v2.0 API.
Type-safe permission keys may produce long union types that slow down IDE auto-complete in large registries.LowLowRegistries are typically fewer than 50 permissions. Monitor TS compiler performance.
Moving requireRole out of current-user.ts is a breaking change.HighLowRe-export from the original location with a @deprecated annotation. Remove in v3.0.
Tenant scoping via tenantId comparison is simplistic; some apps need resource-level ownership checks.MediumMediumProvide requireTenantAccess as the base primitive. Apps can compose it with resource-ownership queries. Document this pattern in the guide.

Open Questions

  1. Should the super_admin bypass role name be configurable, or is it a platform constant? Leaning toward a configurable option with super_admin as the default.
  2. Should definePermissions support permission inheritance (e.g., admin inherits all member permissions)? This simplifies large registries but adds complexity. Leaning toward explicit listing in v2.0, with inheritance as a v2.1 feature.
  3. Do we need a denyPermission (explicit deny) capability, or is the fail-closed model (permission not listed means denied) sufficient?
  4. Should route guards support combining multiple permissions with AND/OR logic, or should apps compose multiple requirePermission calls?

Acceptance Criteria

  • definePermissions creates a type-safe registry and rejects invalid configurations at creation time.
  • requirePermission throws AuthzError with code authz.permission_denied and HTTP 403 when the user lacks the required role.
  • requireTenantAccess throws AuthzError with code authz.tenant_mismatch when tenant IDs differ and the user is not a super-admin.
  • Super-admin users bypass tenant-access checks.
  • hasPermission returns false (not throws) for unknown permissions (fail-closed).
  • Astro withAuthz route guard returns 403 JSON response on denial.
  • React usePermission hook returns a boolean that updates when the user or registry changes.
  • AuthzProvider memoises context value and does not cause unnecessary re-renders.
  • Denial-state tests: at least one test per require* function confirming error code and HTTP status.
  • requireRole re-exported from src/lib/auth/current-user.ts with @deprecated annotation pointing to the new module.
  • Integration story demonstrates usePermission with AccessDeniedState.

LLM Handoff Instructions

When implementing this FRD:

  1. Create src/lib/authz/ as a new module. Files: index.ts, permissions.ts (registry), context.ts (AuthzContext), errors.ts (AuthzError), guards.ts (route guards), hooks.tsx (React hooks and provider), tenant.ts (tenant-scoping helpers).
  2. AuthzError should follow the exact same pattern as AuthError in src/lib/auth/auth-errors.ts: a class extending Error with code, status, and details fields.
  3. definePermissions should use Zod to validate the input and return a frozen object with a Map&lt;string, Set&lt;string&gt;&gt; internally for O(1) lookups.
  4. The AuthzContext is a plain object (not a class) created by createAuthzContext. All methods close over the user and registry.
  5. For React hooks, use React.createContext&lt;AuthzContext | null&gt;(null) with a guard in useAuthz that throws if used outside the provider.
  6. Re-export requireRole from both src/lib/auth/current-user.ts (with @deprecated) and src/lib/authz/index.ts.
  7. Route guard withAuthz should internally call resolveCurrentUserFromAuthorizationHeader then createAuthzContext then the specified checks.
  8. Tests go in src/lib/authz/*.test.ts. Write at least: registry validation tests, permission check tests (has/lacks role), tenant scoping tests (match, mismatch, super-admin bypass), and route guard tests (mocked Astro context).
  9. Run pnpm typecheck and pnpm vitest run --project unit before declaring complete.

Decision Log

DateDecisionRationale
2026-05-26RBAC with static permission registry, not ABAC.Keeps the library simple and auditable. ABAC can be layered on later if needed.
2026-05-26Fail-closed: unknown permissions return false.Safer default than fail-open. Prevents typos in permission strings from granting access.
2026-05-26requireRole re-exported with deprecation, not removed.Avoids a breaking change for existing consumers of src/lib/auth/.
2026-05-26Tenant scoping as a separate helper, not baked into every permission check.Not all permission checks involve a resource with a tenant ID. Composition is more flexible.

Document History

VersionDateAuthorChanges
0.12026-05-26David HolmesInitial draft.