Skip to main content

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:

RolePermissions
OwnerEverything. Manage billing, delete org, manage roles.
AdminManage projects, manage members (except owners).
MemberCreate/edit projects and tasks, comment.
ViewerRead-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​

  1. Deny by default — if a permission is not explicitly granted, access is denied
  2. Enforce on every request — check authorization server-side for every API call, not just on the frontend
  3. Centralize authorization logic — use the AuthorizationService, not ad-hoc checks scattered through code
  4. Log authorization failures — every denied request should be logged for security monitoring
  5. Fail securely — when the authorization system errors, deny access (do not default to allow)
  6. Validate resource ownership — verify the user has access to the specific resource, not just the resource type
  7. 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
  • ✅ RequirePermission component for declarative permission gating
  • ✅ OWASP best practices — deny by default, centralize, log, enforce everywhere