Role-Based Permission System Design (ATB-17)#
Date: 2026-02-14 Author: Claude (with user approval) Linear Issue: ATB-17 Status: Approved, ready for implementation
Overview#
This design document describes the implementation of role-based access control (RBAC) for the atBB forum. The permission system restricts admin and moderation actions to users with appropriate roles, enforced at the AppView layer.
Goals#
- Enable role-based access control with 4 default roles (Owner, Admin, Moderator, Member)
- Enforce permissions on write operations (create topics/posts, manage roles, moderate content)
- Support priority hierarchy (prevents lower-authority users from acting on higher-authority users)
- Maintain the MVP trust model (AppView holds Forum DID keys for role assignment)
- Keep implementation simple (no caching, direct database queries)
Non-Goals (Post-MVP)#
- Permission caching (Redis/in-memory)
- Custom roles (beyond the 4 defaults)
- Per-category permissions
- Audit logging for permission checks
- AT Protocol privilege delegation (will replace MVP trust model later)
Design Decisions#
Based on design discussions, the following architectural choices were made:
- Performance vs Simplicity: Simple database queries (no caching) - acceptable for MVP with <1000 users
- Default Role Assignment: Configurable via
DEFAULT_MEMBER_ROLEenv var - supports both open and approval-required forums - Role Deletion Behavior: Treat missing roles as Guest (fail closed) - prevents unintended privilege escalation
- Priority Hierarchy: Full enforcement across all operations - not just role assignment, but all moderation actions
- Wildcard Permission: True wildcard (
"*") for Owner role - grants all current and future permissions - Self-Action Bypass: Users can edit/delete their own content without
moderatePostspermission
Architecture Overview#
High-Level Structure#
The permission system adds three new layers to the existing AppView architecture:
1. Data Layer (Database + Indexer)
- New
rolestable stores role definitions (name, permissions array, priority) - Role indexer watches
space.atbb.forum.rolerecords from firehose - Existing
membershipstable already hasroleUricolumn (points to user's assigned role)
2. Permission Layer (Middleware + Helpers)
requirePermission(permission)- Middleware that enforces specific permission tokensrequireRole(minRole)- Middleware that enforces role hierarchy- Helper functions (
checkPermission,checkMinRole,getUserRole) perform database lookups - Integrates with existing
requireAuth()- authentication happens first, then permission check
3. Admin Layer (API Endpoints)
POST /api/admin/members/:did/role- Assign role to a member (uses ForumAgent to update membership on user's PDS)GET /api/admin/roles- List available rolesGET /api/admin/members- List members with their assigned roles- Protected by
requirePermission("space.atbb.permission.manageRoles")
Request Flow Example#
User creates topic:
→ POST /api/topics
→ requireAuth(ctx) validates session → sets c.get("user")
→ requirePermission(ctx, "createTopics") checks permission:
1. Query memberships table for user's roleUri
2. Query roles table for role definition
3. Check if "space.atbb.permission.createTopics" in permissions array
4. Check for wildcard "*" (Owner role)
5. Return 403 if no permission, continue if authorized
→ Handler writes post to user's PDS
→ Return 201
Key Technical Choices#
- No caching - Simple database queries on every request (acceptable for MVP <1000 users)
- Factory functions - Middleware follows existing
requireAuth()pattern, not class-based - Fail closed - Missing role = Guest (no permissions)
- Priority enforcement - All mod actions check priority hierarchy, not just role assignment
- Self-action bypass - Users can edit/delete own content without
moderatePostspermission - Configurable defaults -
DEFAULT_MEMBER_ROLEenv var controls auto-assignment on membership creation
Database Schema#
Roles Table#
The roles table stores role definitions owned by the Forum DID. Each role defines a set of permissions and a priority level for hierarchy enforcement.
Schema Definition (Drizzle ORM):
// packages/db/src/schema.ts
export const roles = pgTable(
"roles",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
did: text("did").notNull(), // Forum DID (owner)
rkey: text("rkey").notNull(), // Record key (TID)
cid: text("cid").notNull(), // Content hash
name: text("name").notNull(), // "Admin", "Moderator", etc.
description: text("description"), // Optional description
permissions: text("permissions").array().notNull().default(sql`'{}'::text[]`), // Permission tokens
priority: integer("priority").notNull(), // Lower = higher authority
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(),
},
(table) => [
uniqueIndex("roles_did_rkey_idx").on(table.did, table.rkey),
index("roles_did_idx").on(table.did),
index("roles_did_name_idx").on(table.did, table.name), // Lookup by name
]
);
Migration SQL:
-- packages/db/drizzle/migrations/XXXX_add_roles_table.sql
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
did TEXT NOT NULL,
rkey TEXT NOT NULL,
cid TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
permissions TEXT[] NOT NULL DEFAULT '{}',
priority INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
indexed_at TIMESTAMP WITH TIME ZONE NOT NULL,
UNIQUE(did, rkey)
);
CREATE INDEX idx_roles_did ON roles(did);
CREATE INDEX idx_roles_did_name ON roles(did, name);
Design Rationale#
Why permissions as TEXT[] (PostgreSQL array)?
- Allows efficient containment checks:
'createTopics' = ANY(permissions) - Easier to query than JSONB for simple string lists
- Matches the lexicon's
permissionsfield structure
Why did + name index?
- Roles are often looked up by name ("Admin", "Moderator") rather than rkey
- Combined index supports queries like
WHERE did = ? AND name = ? - Used when assigning roles by name in admin UI
No foreign key from memberships.roleUri to roles?
- Correct - roleUri is an AT-URI string, not a foreign key
- Roles can be deleted; we handle missing roles with "fail closed" (treat as Guest)
- Foreign key would prevent role deletion when members exist
Role Indexer#
Indexer Integration#
The role indexer follows the established CollectionConfig pattern used for posts, forums, categories, etc. It watches for space.atbb.forum.role records from the firehose and indexes them into the roles table.
Collection Configuration:
// apps/appview/src/lib/indexer.ts
import { SpaceAtbbForumRole as Role } from "@atbb/lexicon";
private roleConfig: CollectionConfig<Role.Record> = {
name: "Role",
table: roles,
deleteStrategy: "hard", // DELETE FROM roles (not soft delete)
toInsertValues: async (event, record) => ({
did: event.did,
rkey: event.commit.rkey,
cid: event.commit.cid,
name: record.name,
description: record.description || null,
permissions: record.permissions, // Array of permission tokens
priority: record.priority,
createdAt: new Date(record.createdAt),
indexedAt: new Date(),
}),
toUpdateValues: async (event, record) => ({
cid: event.commit.cid,
name: record.name,
description: record.description || null,
permissions: record.permissions, // Can update permissions array
priority: record.priority, // Can change hierarchy
indexedAt: new Date(),
}),
};
Handler Methods:
async handleRoleCreate(event: CommitCreateEvent<Role.Record>) {
await this.genericCreate(this.roleConfig, event);
}
async handleRoleUpdate(event: CommitUpdateEvent<Role.Record>) {
await this.genericUpdate(this.roleConfig, event);
}
async handleRoleDelete(event: CommitDeleteEvent) {
await this.genericDelete(this.roleConfig, event);
}
Handler Registration:
// In createHandlerRegistry()
.register({
collection: "space.atbb.forum.role",
onCreate: this.createWrappedHandler("handleRoleCreate"),
onUpdate: this.createWrappedHandler("handleRoleUpdate"),
onDelete: this.createWrappedHandler("handleRoleDelete"),
})
Design Rationale#
Why hard delete instead of soft delete?
- Role definitions are metadata, not user content
- When a role is deleted, it's intentional (admin removing a deprecated role)
- Soft delete would leave orphaned records cluttering the database
- Members with deleted roles are handled by "fail closed" logic in permission checks
What happens when permissions array is updated?
- Existing members keep their
roleUriunchanged - Next permission check reads the updated permissions array
- Changes take effect immediately (no cache to invalidate)
- Example: Admin updates "Moderator" role to remove
banUserspermission - all moderators immediately lose ban ability
Permission Middleware & Helpers#
Middleware Factory Functions#
Two middleware functions enforce permissions, following the same pattern as requireAuth():
File: apps/appview/src/middleware/permissions.ts
import type { Context, Next } from "hono";
import type { AppContext } from "../lib/app-context.js";
import type { Variables } from "../types.js";
import { memberships, roles } from "@atbb/db";
import { eq, and } from "drizzle-orm";
/**
* Require specific permission middleware.
*
* Validates that the authenticated user has the required permission token.
* Returns 401 if not authenticated, 403 if authenticated but lacks permission.
*/
export function requirePermission(
ctx: AppContext,
permission: string
) {
return async (c: Context<{ Variables: Variables }>, next: Next) => {
const user = c.get("user");
if (!user) {
return c.json({ error: "Authentication required" }, 401);
}
const hasPermission = await checkPermission(ctx, user.did, permission);
if (!hasPermission) {
return c.json({
error: "Insufficient permissions",
required: permission
}, 403);
}
await next();
};
}
/**
* Require minimum role middleware.
*
* Validates that the authenticated user has a role with sufficient priority.
* Returns 401 if not authenticated, 403 if authenticated but insufficient role.
*/
export function requireRole(
ctx: AppContext,
minRole: "owner" | "admin" | "moderator" | "member"
) {
return async (c: Context<{ Variables: Variables }>, next: Next) => {
const user = c.get("user");
if (!user) {
return c.json({ error: "Authentication required" }, 401);
}
const hasRole = await checkMinRole(ctx, user.did, minRole);
if (!hasRole) {
return c.json({
error: "Insufficient role",
required: minRole
}, 403);
}
await next();
};
}
Helper Functions#
Permission lookup with wildcard support:
/**
* Check if a user has a specific permission.
*
* @returns true if user has permission, false otherwise
*
* Returns false (fail closed) if:
* - User has no membership
* - User has no role assigned (roleUri is null)
* - Role not found in database (deleted or invalid)
*/
async function checkPermission(
ctx: AppContext,
did: string,
permission: string
): Promise<boolean> {
try {
// 1. Get user's membership (includes roleUri)
const [membership] = await ctx.db
.select()
.from(memberships)
.where(eq(memberships.did, did))
.limit(1);
if (!membership || !membership.roleUri) {
return false; // No membership or no role assigned = Guest (no permissions)
}
// 2. Extract rkey from roleUri (e.g., "at://did:plc:xyz/space.atbb.forum.role/abc123" -> "abc123")
const roleRkey = membership.roleUri.split("/").pop();
if (!roleRkey) {
return false;
}
// 3. Fetch role definition from roles table
const [role] = await ctx.db
.select()
.from(roles)
.where(
and(
eq(roles.did, ctx.config.forumDid),
eq(roles.rkey, roleRkey)
)
)
.limit(1);
if (!role) {
return false; // Role not found = treat as Guest (fail closed)
}
// 4. Check for wildcard (Owner role)
if (role.permissions.includes("*")) {
return true;
}
// 5. Check if specific permission is in role's permissions array
return role.permissions.includes(permission);
} catch (error) {
console.error("Failed to check permissions", {
operation: "checkPermission",
did,
permission,
error: error instanceof Error ? error.message : String(error),
});
// Fail closed: deny access on database errors
return false;
}
}
Priority-based role comparison:
/**
* Check if a user has a minimum role level.
*
* @param minRole - Minimum required role name
* @returns true if user's role priority <= required priority (higher authority)
*/
async function checkMinRole(
ctx: AppContext,
did: string,
minRole: string
): Promise<boolean> {
const rolePriorities = {
owner: 0,
admin: 10,
moderator: 20,
member: 30,
};
const userRole = await getUserRole(ctx, did);
if (!userRole) {
return false; // No role = Guest (fails all role checks)
}
const userPriority = userRole.priority;
const requiredPriority = rolePriorities[minRole];
// Lower priority value = higher authority
// Owner (0) passes owner/admin/moderator/member checks
// Admin (10) passes admin/moderator/member checks but not owner
return userPriority <= requiredPriority;
}
Shared helper for role lookup:
/**
* Get a user's role definition.
*
* @returns Role object or null if user has no role
*/
async function getUserRole(
ctx: AppContext,
did: string
): Promise<{ id: bigint; name: string; priority: number; permissions: string[] } | null> {
const [membership] = await ctx.db
.select()
.from(memberships)
.where(eq(memberships.did, did))
.limit(1);
if (!membership || !membership.roleUri) {
return null;
}
const roleRkey = membership.roleUri.split("/").pop();
if (!roleRkey) {
return null;
}
const [role] = await ctx.db
.select({
id: roles.id,
name: roles.name,
priority: roles.priority,
permissions: roles.permissions,
})
.from(roles)
.where(
and(
eq(roles.did, ctx.config.forumDid),
eq(roles.rkey, roleRkey)
)
)
.limit(1);
return role || null;
}
Priority hierarchy check for moderation actions:
/**
* Check if an actor can perform moderation actions on a target user.
*
* Priority hierarchy enforcement:
* - Users can always act on themselves (self-action bypass)
* - Can only act on users with strictly lower authority (higher priority value)
* - Cannot act on users with equal or higher authority
*
* @returns true if actor can act on target, false otherwise
*/
export async function canActOnUser(
ctx: AppContext,
actorDid: string,
targetDid: string
): Promise<boolean> {
// Users can always act on themselves
if (actorDid === targetDid) {
return true;
}
const actorRole = await getUserRole(ctx, actorDid);
const targetRole = await getUserRole(ctx, targetDid);
// If actor has no role, they can't act on anyone else
if (!actorRole) {
return false;
}
// If target has no role (Guest), anyone with a role can act on them
if (!targetRole) {
return true;
}
// Lower priority = higher authority
// Can only act on users with strictly higher priority value (lower authority)
return actorRole.priority < targetRole.priority;
}
Design Rationale#
Self-action bypass implementation:
canActOnUser()returnstruewhenactorDid === targetDid- Moderation endpoints check this first before checking permissions
- Example: User can delete their own post even without
moderatePostspermission
Query efficiency:
- Each permission check = 2 queries (membership + role)
- With proper indexes (
memberships.did,roles.did + roles.rkey), completes in ~5-10ms - No N+1 problem since we always limit to 1 result
Error handling:
- Missing membership →
false(not an error, just Guest status) - Missing role →
false(fail closed) - Database errors bubble up to global error handler
Admin Endpoints#
API Routes#
Three new endpoints under the /api/admin/* namespace for role management:
File: apps/appview/src/routes/admin.ts
import { Hono } from "hono";
import type { AppContext } from "../lib/app-context.js";
import type { Variables } from "../types.js";
import { requirePermission } from "../middleware/permissions.js";
import { memberships, roles, users } from "@atbb/db";
import { eq, and, sql, asc } from "drizzle-orm";
import { getUserRole, canActOnUser } from "../middleware/permissions.js";
export function createAdminRoutes(ctx: AppContext) {
const app = new Hono<{ Variables: Variables }>();
/**
* POST /api/admin/members/:did/role
*
* Assign a role to a forum member.
*
* Requirements:
* - User must have manageRoles permission
* - Cannot assign roles with equal or higher authority than your own
* - Target user must be a member of the forum
* - Role must exist
*/
app.post(
"/members/:did/role",
requirePermission(ctx, "space.atbb.permission.manageRoles"),
async (c) => {
const targetDid = c.req.param("did");
const user = c.get("user")!;
// Parse and validate request body
let body: any;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON in request body" }, 400);
}
const { roleUri } = body;
if (typeof roleUri !== "string") {
return c.json({ error: "roleUri is required and must be a string" }, 400);
}
// Extract role rkey from roleUri
const roleRkey = roleUri.split("/").pop();
if (!roleRkey) {
return c.json({ error: "Invalid roleUri format" }, 400);
}
// Validate role exists
const [role] = await ctx.db
.select()
.from(roles)
.where(
and(
eq(roles.did, ctx.config.forumDid),
eq(roles.rkey, roleRkey)
)
)
.limit(1);
if (!role) {
return c.json({ error: "Role not found" }, 404);
}
// Priority check: Can't assign role with equal or higher authority
const assignerRole = await getUserRole(ctx, user.did);
if (!assignerRole) {
return c.json({ error: "You do not have a role assigned" }, 403);
}
if (role.priority <= assignerRole.priority) {
return c.json({
error: "Cannot assign role with equal or higher authority",
yourPriority: assignerRole.priority,
targetRolePriority: role.priority
}, 403);
}
// Get target user's membership
const [membership] = await ctx.db
.select()
.from(memberships)
.where(eq(memberships.did, targetDid))
.limit(1);
if (!membership) {
return c.json({ error: "User is not a member of this forum" }, 404);
}
try {
// Update membership record on user's PDS using ForumAgent
await ctx.forumAgent.agent.com.atproto.repo.putRecord({
repo: targetDid,
collection: "space.atbb.membership",
rkey: membership.rkey,
record: {
$type: "space.atbb.membership",
forum: { forum: { uri: membership.forumUri, cid: "" } }, // CID will be updated by PDS
role: { role: { uri: roleUri, cid: role.cid } },
joinedAt: membership.joinedAt?.toISOString(),
createdAt: membership.createdAt.toISOString(),
},
});
return c.json({
success: true,
roleAssigned: role.name,
targetDid,
});
} catch (error) {
console.error("Failed to assign role", {
operation: "POST /api/admin/members/:did/role",
targetDid,
roleUri,
error: error instanceof Error ? error.message : String(error),
});
return c.json({
error: "Failed to assign role. Please try again later.",
}, 500);
}
}
);
/**
* GET /api/admin/roles
*
* List all available roles for the forum.
* Sorted by priority (Owner first, Member last).
*/
app.get(
"/roles",
requirePermission(ctx, "space.atbb.permission.manageRoles"),
async (c) => {
try {
const rolesList = await ctx.db
.select({
id: roles.id,
name: roles.name,
description: roles.description,
permissions: roles.permissions,
priority: roles.priority,
})
.from(roles)
.where(eq(roles.did, ctx.config.forumDid))
.orderBy(asc(roles.priority)); // Owner first, Member last
return c.json({
roles: rolesList.map(role => ({
id: role.id.toString(),
name: role.name,
description: role.description,
permissions: role.permissions,
priority: role.priority,
})),
});
} catch (error) {
console.error("Failed to list roles", {
operation: "GET /api/admin/roles",
error: error instanceof Error ? error.message : String(error),
});
return c.json({
error: "Failed to retrieve roles. Please try again later.",
}, 500);
}
}
);
/**
* GET /api/admin/members
*
* List all forum members with their assigned roles.
* Includes DID, handle, role name, and join date.
* Paginated to 100 members (pagination not implemented in MVP).
*/
app.get(
"/members",
requirePermission(ctx, "space.atbb.permission.manageMembers"),
async (c) => {
try {
const membersList = await ctx.db
.select({
did: memberships.did,
handle: users.handle,
role: roles.name,
roleUri: memberships.roleUri,
joinedAt: memberships.joinedAt,
})
.from(memberships)
.leftJoin(users, eq(memberships.did, users.did))
.leftJoin(
roles,
sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}`
)
.where(eq(memberships.forumUri, `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`))
.orderBy(asc(roles.priority), asc(users.handle))
.limit(100); // Pagination not implemented in MVP
return c.json({
members: membersList.map(member => ({
did: member.did,
handle: member.handle || member.did,
role: member.role || "Guest",
roleUri: member.roleUri,
joinedAt: member.joinedAt?.toISOString(),
})),
});
} catch (error) {
console.error("Failed to list members", {
operation: "GET /api/admin/members",
error: error instanceof Error ? error.message : String(error),
});
return c.json({
error: "Failed to retrieve members. Please try again later.",
}, 500);
}
}
);
return app;
}
Design Rationale#
Why use ForumAgent for role assignment?
- Membership records live on the user's PDS, not the Forum's PDS
- Only the Forum DID has authority to assign roles
- ForumAgent authenticates as Forum DID and writes to user's PDS on behalf of admin
- This is the MVP trust model (AppView holds Forum DID keys)
Priority enforcement in role assignment:
- Admins (priority 10) can assign Moderator (20) and Member (30) roles
- Admins cannot assign Admin (10) or Owner (0) roles (prevents privilege escalation)
- This prevents a compromised admin account from promoting themselves or others
Member listing JOIN logic:
- LEFT JOIN on roles allows showing members with no role (Guests)
- Custom SQL JOIN condition matches roleUri to role's AT-URI pattern
- Ordered by priority (Admins first) then handle (alphabetical)
Default Role Seeding#
Seed Script#
A startup script creates the 4 default roles on the Forum DID's PDS if they don't already exist.
File: apps/appview/src/lib/seed-roles.ts
import type { AppContext } from "./app-context.js";
import { roles } from "@atbb/db";
import { eq } from "drizzle-orm";
interface DefaultRole {
name: string;
description: string;
permissions: string[];
priority: number;
}
const DEFAULT_ROLES: DefaultRole[] = [
{
name: "Owner",
description: "Forum owner with full control",
permissions: ["*"], // Wildcard grants all permissions
priority: 0,
},
{
name: "Admin",
description: "Can manage forum structure and users",
permissions: [
"space.atbb.permission.manageCategories",
"space.atbb.permission.manageRoles",
"space.atbb.permission.manageMembers",
"space.atbb.permission.moderatePosts",
"space.atbb.permission.banUsers",
"space.atbb.permission.pinTopics",
"space.atbb.permission.lockTopics",
"space.atbb.permission.createTopics",
"space.atbb.permission.createPosts",
],
priority: 10,
},
{
name: "Moderator",
description: "Can moderate content and users",
permissions: [
"space.atbb.permission.moderatePosts",
"space.atbb.permission.banUsers",
"space.atbb.permission.pinTopics",
"space.atbb.permission.lockTopics",
"space.atbb.permission.createTopics",
"space.atbb.permission.createPosts",
],
priority: 20,
},
{
name: "Member",
description: "Regular forum member",
permissions: [
"space.atbb.permission.createTopics",
"space.atbb.permission.createPosts",
],
priority: 30,
},
];
/**
* Seed default roles to Forum DID's PDS.
*
* Idempotent: Checks for existing roles by name before creating.
* Safe to run on every startup.
*
* @returns Summary of created and skipped roles
*/
export async function seedDefaultRoles(ctx: AppContext): Promise<{ created: number; skipped: number }> {
let created = 0;
let skipped = 0;
for (const defaultRole of DEFAULT_ROLES) {
try {
// Check if role already exists by name
const [existingRole] = await ctx.db
.select()
.from(roles)
.where(eq(roles.name, defaultRole.name))
.limit(1);
if (existingRole) {
console.log(`Role "${defaultRole.name}" already exists, skipping`, {
operation: "seedDefaultRoles",
roleName: defaultRole.name,
});
skipped++;
continue;
}
// Create role record on Forum DID's PDS
const result = await ctx.forumAgent.agent.com.atproto.repo.createRecord({
repo: ctx.config.forumDid,
collection: "space.atbb.forum.role",
record: {
$type: "space.atbb.forum.role",
name: defaultRole.name,
description: defaultRole.description,
permissions: defaultRole.permissions,
priority: defaultRole.priority,
createdAt: new Date().toISOString(),
},
});
console.log(`Created default role "${defaultRole.name}"`, {
operation: "seedDefaultRoles",
roleName: defaultRole.name,
uri: result.uri,
cid: result.cid,
});
created++;
} catch (error) {
console.error(`Failed to seed role "${defaultRole.name}"`, {
operation: "seedDefaultRoles",
roleName: defaultRole.name,
error: error instanceof Error ? error.message : String(error),
});
// Continue seeding other roles even if one fails
}
}
return { created, skipped };
}
Startup Integration#
// apps/appview/src/index.ts (in startup sequence, after ForumAgent is initialized)
// Seed default roles if enabled
if (process.env.SEED_DEFAULT_ROLES !== "false") {
console.log("Seeding default roles...");
const result = await seedDefaultRoles(ctx);
console.log("Default roles seeded", {
created: result.created,
skipped: result.skipped,
});
} else {
console.log("Role seeding disabled via SEED_DEFAULT_ROLES=false");
}
Environment Variables#
# .env
SEED_DEFAULT_ROLES=true # Set to "false" to disable auto-seeding
DEFAULT_MEMBER_ROLE=Member # Role name to auto-assign to new memberships (or empty for manual assignment)
Design Rationale#
Idempotent seeding:
- Checks database for existing roles by name before creating
- Safe to run on every startup (skips existing roles)
- Allows manual role deletion without preventing re-seeding
Why write to PDS instead of database directly?
- Roles are AT Protocol records, not just database entries
- Writing to PDS ensures proper AT-URI, CID, and firehose propagation
- Indexer picks up the records and populates the database
- Maintains consistency with the "PDS is source of truth" architecture
Failure handling:
- If one role fails to seed, continue with remaining roles
- Logs errors but doesn't crash startup
- Partial seeding is acceptable (admin can manually create missing roles)
Integration with membership auto-creation:
- When
DEFAULT_MEMBER_ROLEis set, the membership creation code (ATB-15) will look up this role by name - If found, set
roleUrion new membership record - If not found or empty, leave
roleUrias null (Guest status)
Integration with Existing Endpoints#
Updating Write Endpoints#
Replace requireAuth() with requirePermission() on existing write endpoints:
File: apps/appview/src/routes/topics.ts
// BEFORE:
app.post("/", requireAuth(ctx), async (c) => {
// ...
});
// AFTER:
app.post("/", requirePermission(ctx, "space.atbb.permission.createTopics"), async (c) => {
// Handler code stays the same - permission check happens before reaching here
});
File: apps/appview/src/routes/posts.ts
// BEFORE:
app.post("/", requireAuth(ctx), async (c) => {
// ...
});
// AFTER:
app.post("/", requirePermission(ctx, "space.atbb.permission.createPosts"), async (c) => {
// Handler code stays the same
});
Future Moderation Endpoints (Not in ATB-17 Scope)#
When implementing moderation endpoints (ATB-19+), they'll use both permission AND priority checks:
// Example future endpoint structure (NOT implemented in ATB-17)
app.post(
"/posts/:id/delete",
requirePermission(ctx, "space.atbb.permission.moderatePosts"),
async (c) => {
const postId = c.req.param("id");
const user = c.get("user")!;
// Get post author
const post = await getPostById(ctx, postId);
if (!post) {
return c.json({ error: "Post not found" }, 404);
}
// Self-action bypass: users can always delete their own posts
if (post.did === user.did) {
// Delete logic...
return c.json({ success: true });
}
// Priority check: can't moderate users with equal/higher authority
const canAct = await canActOnUser(ctx, user.did, post.did);
if (!canAct) {
return c.json({
error: "Cannot moderate posts from users with equal or higher authority"
}, 403);
}
// Delete logic...
}
);
Design Rationale#
Why requirePermission() instead of requireAuth()?
requirePermission()internally calls the same authentication logic- It checks session validity first (returns 401 if not authenticated)
- Then checks permission (returns 403 if authenticated but no permission)
- Single middleware replaces the auth check with a permission check
Backward compatibility:
- Before ATB-17: All authenticated users can post (no role required)
- After ATB-17: Only users with
createTopics/createPostspermission can post - Breaking change - requires roles to be seeded and members to be assigned roles
- Migration path: Seed roles → auto-assign "Member" role to existing memberships → deploy permission checks
Self-action bypass implementation:
- Not needed for create endpoints (users are always acting on their own behalf)
- Only needed for moderation endpoints (delete/edit other users' content)
- Future moderation endpoints check
post.did === user.didbefore priority check
Error messages:
- 401 "Authentication required" - not logged in
- 403 "Insufficient permissions" - logged in but no permission token
- 403 "Insufficient role" - logged in but wrong role level
- 403 "Cannot moderate..." - logged in with permission but blocked by priority hierarchy
Error Handling#
Error Categories#
The permission system introduces new error scenarios that need clear, actionable messages:
Authentication Errors (401):
- No session cookie present
- Session expired or invalid
- Returned BEFORE permission checks
Permission Errors (403):
- User authenticated but lacks required permission token
- User authenticated but role priority insufficient
- User authenticated but trying to act on higher-authority user
Not Found Errors (404):
- Target role doesn't exist (role assignment)
- Target user not a member (role assignment)
Server Errors (500):
- Database query failures
- PDS write failures (ForumAgent)
- Unexpected errors in helper functions
Logging Standards#
Structured logging with context:
console.error("Failed to check permissions", {
operation: "checkPermission",
did: did,
permission: permission,
error: error instanceof Error ? error.message : String(error),
errorId: "PERMISSION_CHECK_FAILED", // Optional: for tracking specific error types
});
What to log:
- ✅ Database errors in helper functions
- ✅ PDS write failures in admin endpoints
- ✅ Missing roles during permission checks (info level, not error)
- ❌ Don't log successful permission checks (too noisy)
- ❌ Don't log 403 responses (expected behavior, not errors)
Helper Function Error Handling#
Fail closed on errors:
async function checkPermission(
ctx: AppContext,
did: string,
permission: string
): Promise<boolean> {
try {
// Database queries...
} catch (error) {
console.error("Failed to check permissions", {
operation: "checkPermission",
did,
permission,
error: error instanceof Error ? error.message : String(error),
});
// Fail closed: deny access on database errors
return false;
}
}
Why fail closed?
- Database outage should not grant universal access
- Temporary denial of service is better than temporary privilege escalation
- Admin can investigate logs to diagnose issues
Error Message Guidelines#
Client-facing error messages:
- Generic for server errors: "Please try again later"
- Specific for client errors: "Role not found", "Insufficient permissions"
- Include helpful context:
required: permissionshows what they need - Never expose stack traces or internal details in production
Distinguishing 401 vs 403:
- 401 = "You need to log in" → redirect to login
- 403 = "You're logged in but can't do this" → show "contact admin" message
- Clear distinction helps users understand if they need to re-auth or escalate
Testing Strategy#
Unit Tests#
File: apps/appview/src/middleware/__tests__/permissions.test.ts
describe("checkPermission", () => {
it("returns true when user has required permission");
it("returns true for Owner role with wildcard permission");
it("returns false when user has no role assigned");
it("returns false when user's role is deleted (fail closed)");
it("returns false when user has no membership");
});
describe("checkMinRole", () => {
it("returns true when user has exact role match");
it("returns true when user has higher authority role");
it("returns false when user has lower authority role");
});
describe("canActOnUser", () => {
it("returns true when actor is acting on themselves");
it("returns true when actor has higher authority");
it("returns false when actor has equal authority");
it("returns false when actor has lower authority");
});
Integration Tests#
File: apps/appview/src/middleware/__tests__/permissions.integration.test.ts
describe("requirePermission middleware", () => {
it("allows authenticated user with permission");
it("returns 401 for unauthenticated user");
it("returns 403 for authenticated user without permission");
});
describe("requireRole middleware", () => {
it("allows user with sufficient role level");
it("returns 403 for user with insufficient role level");
});
File: apps/appview/src/routes/__tests__/admin.test.ts
describe("POST /api/admin/members/:did/role", () => {
it("assigns role successfully when admin has authority");
it("returns 403 when assigning role with equal authority");
it("returns 403 when assigning role with higher authority");
it("returns 404 when role not found");
it("returns 404 when target user not a member");
it("returns 403 when user lacks manageRoles permission");
});
describe("GET /api/admin/roles", () => {
it("lists all roles sorted by priority");
it("returns 403 for non-admin users");
});
describe("GET /api/admin/members", () => {
it("lists members with assigned roles");
it("shows Guest for members with no role");
});
File: apps/appview/src/lib/__tests__/indexer.test.ts
describe("Role indexer", () => {
it("indexes role create event");
it("updates role on update event");
it("deletes role on delete event");
});
Manual Testing Checklist#
Initial Setup:
- Start fresh database, seed roles on startup
- Verify 4 default roles created (Owner, Admin, Moderator, Member)
- Check role priorities and permissions in database
Role Assignment:
- Assign "Member" role to test user via
POST /api/admin/members/:did/role - Verify user can create topics and posts
- Assign "Moderator" role to test user
- Verify user can create but cannot manage roles (403)
Priority Hierarchy:
- As Admin, try to assign Admin role to another user (should fail with 403)
- As Admin, assign Moderator role to user (should succeed)
- As Admin, try to assign Owner role (should fail with 403)
Permission Enforcement:
- Create user with no role (Guest)
- Try to create topic as Guest (should fail with 403)
- Assign Member role, retry (should succeed)
Wildcard Permission:
- Assign Owner role to test user
- Verify user can access all admin endpoints
- Verify
checkPermission()returns true for any permission
Test Coverage Goals#
- Helper functions: 100% coverage (critical permission logic)
- Middleware: 100% coverage (all error paths)
- Admin endpoints: All success + error cases
- Role indexer: Create, update, delete events
- Integration: End-to-end permission enforcement on real routes
Implementation Checklist#
This checklist matches the Linear issue's acceptance criteria:
1. Database Schema: Roles Table#
- Create migration
packages/db/drizzle/migrations/XXX_add_roles_table.sql - Define
rolestable inpackages/db/src/schema.ts - Export
rolesfor use in indexer and queries - Add to cleanup in
test-context.ts
2. Role Indexer#
- Add
roleConfig: CollectionConfig<Role.Record>toIndexerclass - Implement
handleRoleCreate(),handleRoleUpdate(),handleRoleDelete() - Register handlers in
createHandlerRegistry() - Add import for
SpaceAtbbForumRole
3. Permission Middleware#
- Create
apps/appview/src/middleware/permissions.ts - Implement
requirePermission()middleware - Implement
requireRole()middleware - Implement
checkPermission()helper - Implement
checkMinRole()helper - Implement
getUserRole()helper - Implement
canActOnUser()helper
4. Role Assignment Endpoint#
- Create
apps/appview/src/routes/admin.ts - Implement
POST /api/admin/members/:did/role - Implement
GET /api/admin/roles - Implement
GET /api/admin/members - Register admin routes in
apps/appview/src/index.ts
5. Default Roles Seeding#
- Create
apps/appview/src/lib/seed-roles.ts - Implement
seedDefaultRoles()function - Define
DEFAULT_ROLESarray (Owner, Admin, Moderator, Member) - Integrate seeding in AppView startup
- Add
SEED_DEFAULT_ROLESenv var to.env.example - Add
DEFAULT_MEMBER_ROLEenv var to.env.example
6. Integration with Existing Routes#
- Update
POST /api/topicsto userequirePermission() - Update
POST /api/poststo userequirePermission()
7. Testing Requirements#
- Unit tests for
checkPermission(),checkMinRole(),canActOnUser() - Integration tests for
requirePermissionandrequireRolemiddleware - Integration tests for admin endpoints
- Unit tests for role indexer
- Manual testing checklist completion
8. Documentation#
- Update
docs/atproto-forum-plan.mdwith ATB-17 completion - Mark Linear issue ATB-17 as Done
- Update CLAUDE.md if patterns change
Success Criteria#
The permission system is successful when:
- Only users with
createTopicspermission can create topics - Admins can assign roles to members via API
- Priority hierarchy prevents privilege escalation (Admin can't assign Admin role)
- Permission checks complete in <10ms (no performance impact)
- Default roles are seeded automatically on fresh install
- All tests passing (100% coverage on permission logic)
- Error messages are clear and actionable
Future Enhancements (Post-MVP)#
- Permission caching - Redis or in-memory cache for role lookups
- Custom roles - Allow admins to create new roles beyond the 4 defaults
- Per-category permissions - Different permissions for different categories
- Audit logging - Track all permission checks and role changes
- AT Protocol delegation - Replace ForumAgent key holding with proper delegation
- Role templates - Pre-defined role configurations for common forum types
- Bulk role assignment - Assign roles to multiple users at once
- Role inheritance - Roles that inherit permissions from other roles