Chapter 29: Authorization & RBAC
Authentication answers "who are you?" Authorization answers "what can you do?" This chapter implements Role-Based Access Control (RBAC) with Effect services.
Authorization Model​
TaskForge uses organization-scoped roles:
| Role | Permissions |
|---|---|
| Owner | Everything. Manage billing, delete org, manage roles. |
| Admin | Manage projects, manage members (except owners). |
| Member | Create/edit projects and tasks, comment. |
| Viewer | Read-only access. |
Permission Definitions​
// features/auth/domain/permissions.ts
export const Permission = {
// Organization
"org:manage": "org:manage",
"org:delete": "org:delete",
"org:members:manage": "org:members:manage",
// Projects
"project:create": "project:create",
"project:read": "project:read",
"project:update": "project:update",
"project:delete": "project:delete",
// Tasks
"task:create": "task:create",
"task:read": "task:read",
"task:update": "task:update",
"task:delete": "task:delete",
"task:assign": "task:assign",
// Comments
"comment:create": "comment:create",
"comment:read": "comment:read",
"comment:delete:own": "comment:delete:own",
"comment:delete:any": "comment:delete:any",
} as const;
export type Permission = (typeof Permission)[keyof typeof Permission];
// Role → Permission mapping
const rolePermissions: Record<string, Permission[]> = {
OWNER: Object.values(Permission), // All permissions
ADMIN: [
Permission["project:create"], Permission["project:read"],
Permission["project:update"], Permission["project:delete"],
Permission["task:create"], Permission["task:read"],
Permission["task:update"], Permission["task:delete"],
Permission["task:assign"],
Permission["comment:create"], Permission["comment:read"],
Permission["comment:delete:own"], Permission["comment:delete:any"],
Permission["org:members:manage"],
],
MEMBER: [
Permission["project:create"], Permission["project:read"],
Permission["project:update"],
Permission["task:create"], Permission["task:read"],
Permission["task:update"], Permission["task:assign"],
Permission["comment:create"], Permission["comment:read"],
Permission["comment:delete:own"],
],
VIEWER: [
Permission["project:read"],
Permission["task:read"],
Permission["comment:read"],
],
};
export function hasPermission(role: string, permission: Permission): boolean {
return rolePermissions[role]?.includes(permission) ?? false;
}
Authorization Service​
// features/auth/services/authorization-service.ts
import { Context, Effect, Layer, Data } from "effect";
import { PrismaClient } from "@/shared/services/prisma-service";
import { hasPermission, type Permission } from "../domain/permissions";
export class InsufficientPermissionsError extends Data.TaggedError("InsufficientPermissionsError")<{
readonly userId: string;
readonly permission: Permission;
readonly organizationId: string;
}> {}
export class AuthorizationService extends Context.Tag("AuthorizationService")<
AuthorizationService,
{
readonly checkPermission: (
userId: string,
permission: Permission,
organizationId: string
) => Effect.Effect<boolean>;
readonly requirePermission: (
userId: string,
permission: Permission,
organizationId: string
) => Effect.Effect<void, InsufficientPermissionsError>;
readonly getUserRole: (
userId: string,
organizationId: string
) => Effect.Effect<string | null>;
}
>() {
static Live = Layer.effect(
this,
Effect.gen(function* () {
const prisma = yield* PrismaClient;
const getUserRole = (userId: string, organizationId: string) =>
Effect.tryPromise({
try: () => prisma.organizationMember.findUnique({
where: { userId_organizationId: { userId, organizationId } },
select: { role: true },
}),
catch: (e) => new DatabaseError({ operation: "authz.getUserRole", cause: e }),
}).pipe(Effect.map((member) => member?.role ?? null));
return {
getUserRole,
checkPermission: (userId, permission, organizationId) =>
getUserRole(userId, organizationId).pipe(
Effect.map((role) => role !== null && hasPermission(role, permission))
),
requirePermission: (userId, permission, organizationId) =>
getUserRole(userId, organizationId).pipe(
Effect.flatMap((role) => {
if (role === null || !hasPermission(role, permission)) {
return Effect.fail(new InsufficientPermissionsError({
userId,
permission,
organizationId,
}));
}
return Effect.void;
})
),
};
})
);
}
Using Authorization in Use Cases​
// features/projects/application/use-cases/delete-project.ts
export const deleteProject = (projectId: string, userId: string) =>
Effect.gen(function* () {
const repo = yield* ProjectRepository;
const authz = yield* AuthorizationService;
// Load the project to get its organization
const project = yield* repo.findById(projectId);
// Check permission
yield* authz.requirePermission(userId, "project:delete", project.organizationId);
// Soft delete
yield* repo.softDelete(projectId);
// Audit
const audit = yield* AuditService;
yield* audit.record({
action: "project:deleted",
userId,
resourceId: projectId,
details: { name: project.name },
});
});
Frontend Permission Checks​
Permission Hook​
// features/auth/hooks/use-permissions.ts
export function usePermission(permission: Permission): boolean {
const { user } = useAuth();
const { currentOrganization } = useCurrentOrganization();
const { data: hasAccess = false } = useQuery({
queryKey: ["permissions", user?.id, currentOrganization?.id, permission],
queryFn: async () => {
if (!user || !currentOrganization) return false;
// Use the cached membership role
const membership = currentOrganization.membership;
return hasPermission(membership.role, permission);
},
enabled: !!user && !!currentOrganization,
staleTime: Infinity, // Permissions rarely change during a session
});
return hasAccess;
}
Permission-Gated Components​
// shared/components/auth/require-permission.tsx
interface RequirePermissionProps {
permission: Permission;
fallback?: React.ReactNode;
children: React.ReactNode;
}
export function RequirePermission({
permission,
fallback = null,
children,
}: RequirePermissionProps) {
const allowed = usePermission(permission);
return allowed ? <>{children}</> : <>{fallback}</>;
}
// Usage
function ProjectActions({ project }: { project: Project }) {
return (
<div className="flex gap-2">
<RequirePermission permission="project:update">
<Button onClick={handleEdit}>Edit</Button>
</RequirePermission>
<RequirePermission permission="project:delete">
<Button variant="destructive" onClick={handleDelete}>Delete</Button>
</RequirePermission>
</div>
);
}
OWASP Authorization Best Practices​
- Deny by default — if a permission is not explicitly granted, access is denied
- Enforce on every request — check authorization server-side for every API call, not just on the frontend
- Centralize authorization logic — use the
AuthorizationService, not ad-hoc checks scattered through code - Log authorization failures — every denied request should be logged for security monitoring
- Fail securely — when the authorization system errors, deny access (do not default to allow)
- Validate resource ownership — verify the user has access to the specific resource, not just the resource type
- Re-validate for long sessions — periodically check that the user still has the required role
Summary​
- ✅ Role-Based Access Control with organization-scoped roles
- ✅ Permission mapping from roles to granular permissions
- ✅ AuthorizationService as an Effect service with
requirePermission - ✅ Use case integration — permission checks before business logic
- ✅ Frontend permission hooks for conditional UI rendering
- ✅
RequirePermissioncomponent for declarative permission gating - ✅ OWASP best practices — deny by default, centralize, log, enforce everywhere