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.
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
ID
Priority
Requirement
AUTHZ-01
P0
definePermissions creates a validated, serialisable permission registry.
AUTHZ-02
P0
requirePermission(user, permission, registry) throws AuthzError with code authz.permission_denied and HTTP 403 on denial.
AUTHZ-03
P0
requireTenantAccess(user, resourceTenantId) throws AuthzError with code authz.tenant_mismatch when tenant IDs do not match.
AUTHZ-04
P0
Super-admin bypass: users with the super_admin role pass all tenant-access checks.
AUTHZ-05
P0
Astro route guard (withAuthz) and middleware guard (createRouteGuard).
AuthzProvider accepts AuthenticatedUser and permission registry.
AUTHZ-08
P1
hasAnyRole and hasAllRoles convenience methods.
AUTHZ-09
P0
Denial-state tests: every require* path has a test confirming the error code and status.
AUTHZ-10
P1
Integration example showing usePermission gating a UI element and rendering AccessDeniedState on denial.
Functional Requirements
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.
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).
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.
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.
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).
Error types: AuthzError extends Error with code, status, and details fields, following the same pattern as AuthError.
Non-Functional Requirements
Category
Requirement
Performance
Permission 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 size
The client-side hooks and provider add less than 2 KB gzipped. The permission registry is tree-shakeable from server-only code.
Testability
All authz functions are pure and accept the user and registry as arguments (no global state).
Type safety
definePermissions returns a type-safe registry where permission keys are inferred as a string literal union. requirePermission accepts only keys from that union.
Zero runtime dependencies
The authz library depends only on zod (already in the project) and the existing src/lib/auth/ types.
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
Dependency
Type
Notes
src/lib/auth/
Internal
Consumes AuthenticatedUser, AuthError pattern.
zod
Existing
Schema validation for the permission registry.
react
Existing
Hooks and context provider.
src/components/ui/access-denied-state.tsx
Internal
Integration target for denial rendering.
ADR-025
Governance
JWT claim set defines the roles used in authz checks.
Risks and Tradeoffs
Risk
Likelihood
Impact
Mitigation
Static permission registry may be too rigid for apps with dynamic, database-driven permissions.
Medium
Medium
Design 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.
Low
Low
Registries are typically fewer than 50 permissions. Monitor TS compiler performance.
Moving requireRole out of current-user.ts is a breaking change.
High
Low
Re-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.
Medium
Medium
Provide requireTenantAccess as the base primitive. Apps can compose it with resource-ownership queries. Document this pattern in the guide.
Open Questions
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.
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.
Do we need a denyPermission (explicit deny) capability, or is the fail-closed model (permission not listed means denied) sufficient?
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:
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).
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.
definePermissions should use Zod to validate the input and return a frozen object with a Map<string, Set<string>> internally for O(1) lookups.
The AuthzContext is a plain object (not a class) created by createAuthzContext. All methods close over the user and registry.
For React hooks, use React.createContext<AuthzContext | null>(null) with a guard in useAuthz that throws if used outside the provider.
Re-export requireRole from both src/lib/auth/current-user.ts (with @deprecated) and src/lib/authz/index.ts.
Route guard withAuthz should internally call resolveCurrentUserFromAuthorizationHeader then createAuthzContext then the specified checks.
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).
Run pnpm typecheck and pnpm vitest run --project unit before declaring complete.
Decision Log
Date
Decision
Rationale
2026-05-26
RBAC with static permission registry, not ABAC.
Keeps the library simple and auditable. ABAC can be layered on later if needed.
2026-05-26
Fail-closed: unknown permissions return false.
Safer default than fail-open. Prevents typos in permission strings from granting access.
2026-05-26
requireRole re-exported with deprecation, not removed.
Avoids a breaking change for existing consumers of src/lib/auth/.
2026-05-26
Tenant 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.