Agent skill
permissions
Multi-tenant permission checking for Wasp applications. Use when implementing authorization, access control, or role-based permissions. Includes organization/department/role patterns and permission helper functions.
Stars
163
Forks
31
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/development/permissions
SKILL.md
Permissions Skill
Quick Reference
When to use this skill:
- Implementing permission checks in operations
- Setting up role-based access control
- Working with multi-tenant data (organizations/departments)
- Checking organization or department access
- Filtering data by user permissions
- Enforcing hierarchical access (parent/child departments)
Key patterns:
canAccessDocument()- Check if user can view documentcanEditDocument()- Check if user can edit documentcanDeleteDocument()- Check if user can delete documentgetUserOrgRole()- Get user's role in organizationgetUserRoleInDepartment()- Get user's role in departmentisDepartmentManager()- Check if user manages departmentisOrgAdmin()- Check if user is org owner/admin
Multi-Tenancy Architecture
Structure:
Organization (many) → Departments (hierarchical tree via parentId)
↓
Users ↔ Departments (many-to-many via UserDepartment)
↓
DepartmentRole: MANAGER | MEMBER | VIEWER
OrganizationRole: OWNER | ADMIN | MEMBER
Key concepts:
- Organizations contain multiple departments
- Departments can have parent/child relationships (hierarchical)
- Users belong to departments via UserDepartment junction table
- Each user has a role per department (MANAGER, MEMBER, or VIEWER)
- Users can have roles in multiple departments
- Organization-level roles (OWNER, ADMIN) grant access to all departments
Database Schema
prisma
model Organization {
id String @id @default(uuid())
name String
departments Department[]
members OrganizationMember[]
}
model OrganizationMember {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
role OrganizationRole
@@unique([userId, organizationId])
}
enum OrganizationRole {
OWNER
ADMIN
MEMBER
}
model Department {
id String @id @default(uuid())
name String
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
parentId String?
parent Department? @relation("DepartmentHierarchy", fields: [parentId], references: [id])
children Department[] @relation("DepartmentHierarchy")
userDepartments UserDepartment[]
}
model UserDepartment {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
departmentId String
department Department @relation(fields: [departmentId], references: [id])
role DepartmentRole
@@unique([userId, departmentId])
}
enum DepartmentRole {
MANAGER
MEMBER
VIEWER
}
Core Permission Helpers
Organization-Level Permissions
typescript
import { HttpError } from "wasp/server";
/**
* Get user's role in organization
* @returns 'OWNER' | 'ADMIN' | 'MEMBER' | 'NONE'
*/
async function getUserOrgRole(
userId: string,
organizationId: string,
context,
): Promise<string> {
const membership = await context.entities.OrganizationMember.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
});
return membership?.role || "NONE";
}
/**
* Check if user is organization owner or admin
*/
async function isOrgAdmin(
userId: string,
organizationId: string,
context,
): Promise<boolean> {
const role = await getUserOrgRole(userId, organizationId, context);
return ["OWNER", "ADMIN"].includes(role);
}
/**
* Check if user can access organization
*/
async function canAccessOrganization(
userId: string,
organizationId: string,
context,
): Promise<boolean> {
const role = await getUserOrgRole(userId, organizationId, context);
return role !== "NONE";
}
Department-Level Permissions
typescript
/**
* Get user's role in specific department
* @returns 'MANAGER' | 'MEMBER' | 'VIEWER' | null
*/
async function getUserRoleInDepartment(
userId: string,
departmentId: string,
context,
): Promise<string | null> {
const membership = await context.entities.UserDepartment.findUnique({
where: {
userId_departmentId: {
userId,
departmentId,
},
},
});
return membership?.role || null;
}
/**
* Check if user is department manager
*/
async function isDepartmentManager(
userId: string,
departmentId: string,
context,
): Promise<boolean> {
const role = await getUserRoleInDepartment(userId, departmentId, context);
return role === "MANAGER";
}
/**
* Check if user can access department
* Includes hierarchical access (parent departments)
*/
async function canAccessDepartment(
userId: string,
departmentId: string,
context,
): Promise<boolean> {
// Get department with parent chain
const department = await context.entities.Department.findUnique({
where: { id: departmentId },
include: { parent: true },
});
if (!department) return false;
// Check organization access
const hasOrgAccess = await canAccessOrganization(
userId,
department.organizationId,
context,
);
if (!hasOrgAccess) return false;
// Org admins can access all departments
const isAdmin = await isOrgAdmin(userId, department.organizationId, context);
if (isAdmin) return true;
// Check direct membership
const role = await getUserRoleInDepartment(userId, departmentId, context);
if (role) return true;
// Check parent department membership (hierarchical)
if (department.parentId) {
return await canAccessDepartment(userId, department.parentId, context);
}
return false;
}
Resource-Level Permissions (A3 Example)
typescript
/**
* Check if user can access document
* Access granted if:
* - User is the author
* - User is org OWNER/ADMIN
* - User is in department (any role: MANAGER, MEMBER, VIEWER)
*/
async function canAccessDocument(
userId: string,
a3: Document,
context,
): Promise<boolean> {
// 1. Author can always access
if (a3.authorId === userId) return true;
// 2. Org admins can access
const orgRole = await getUserOrgRole(userId, a3.organizationId, context);
if (["OWNER", "ADMIN"].includes(orgRole)) return true;
// 3. Department members can access (any role)
const deptRole = await getUserRoleInDepartment(
userId,
a3.departmentId,
context,
);
return deptRole !== null;
}
/**
* Check if user can edit document
* Edit permissions:
* - Author can edit
* - Org OWNER/ADMIN can edit
* - Department MANAGER can edit
* - Department MEMBER can edit their own
* - VIEWER cannot edit
*/
async function canEditDocument(
userId: string,
a3: Document,
context,
): Promise<boolean> {
// Author can always edit
if (a3.authorId === userId) return true;
// Org admins can edit
const orgRole = await getUserOrgRole(userId, a3.organizationId, context);
if (["OWNER", "ADMIN"].includes(orgRole)) return true;
// Department managers can edit
const deptRole = await getUserRoleInDepartment(
userId,
a3.departmentId,
context,
);
if (deptRole === "MANAGER") return true;
// Members cannot edit others' A3s
// Viewers cannot edit
return false;
}
/**
* Check if user can delete document
* Delete permissions:
* - Author can delete
* - Org OWNER/ADMIN can delete
* - Department MANAGER can delete
*/
async function canDeleteDocument(
userId: string,
a3: Document,
context,
): Promise<boolean> {
// Author can delete
if (a3.authorId === userId) return true;
// Org admins can delete
const orgRole = await getUserOrgRole(userId, a3.organizationId, context);
if (["OWNER", "ADMIN"].includes(orgRole)) return true;
// Department managers can delete
const deptRole = await getUserRoleInDepartment(
userId,
a3.departmentId,
context,
);
if (deptRole === "MANAGER") return true;
return false;
}
Usage in Operations
Standard Operation Pattern
ALWAYS follow this order:
- Auth check (401)
- Fetch resource
- Existence check (404)
- Permission check (403)
- Validation (400)
- Perform operation
Query with Permission Check
typescript
import type { GetDocument } from "wasp/server/operations";
import type { Document } from "wasp/entities";
import { HttpError } from "wasp/server";
export const getDocument: GetDocument<{ id: string }, Document> = async (
args,
context,
) => {
// 1. Auth check
if (!context.user) throw new HttpError(401);
// 2. Fetch resource
const a3 = await context.entities.Document.findUnique({
where: { id: args.id },
include: {
author: { select: { id: true, username: true } },
department: true,
organization: true,
},
});
// 3. Existence check
if (!a3) throw new HttpError(404, "document not found");
// 4. Permission check using helper
const hasAccess = await canAccessDocument(context.user.id, a3, context);
if (!hasAccess) {
throw new HttpError(403, "Not authorized to access this document");
}
// 5. Return resource
return a3;
};
Query with Permission Filtering
typescript
import type { GetDocuments } from "wasp/server/operations";
import type { Document } from "wasp/entities";
export const getDocuments: GetDocuments<void, Document[]> = async (
args,
context,
) => {
if (!context.user) throw new HttpError(401);
// Get all departments user has access to
const userDepts = await context.entities.UserDepartment.findMany({
where: { userId: context.user.id },
});
const deptIds = userDepts.map((ud) => ud.departmentId);
// Return A3s from accessible departments
return context.entities.Document.findMany({
where: {
OR: [
// Own documents
{ authorId: context.user.id },
// Department documents
{ departmentId: { in: deptIds } },
],
},
include: {
department: true,
author: { select: { id: true, username: true } },
},
orderBy: { createdAt: "desc" },
});
};
Action with Edit Permission Check
typescript
import type { UpdateA3 } from "wasp/server/operations";
export const updateA3: UpdateA3 = async (args, context) => {
// 1. Auth check
if (!context.user) throw new HttpError(401);
// 2. Fetch resource
const a3 = await context.entities.Document.findUnique({
where: { id: args.id },
});
// 3. Existence check
if (!a3) throw new HttpError(404, "document not found");
// 4. Permission check (can edit?)
const canEdit = await canEditDocument(context.user.id, a3, context);
if (!canEdit) {
throw new HttpError(403, "Not authorized to edit this document");
}
// 5. Update
return context.entities.Document.update({
where: { id: args.id },
data: args.data,
});
};
Action with Manager-Only Permission
typescript
import type { DeleteA3 } from "wasp/server/operations";
export const deleteA3: DeleteA3 = async (args, context) => {
if (!context.user) throw new HttpError(401);
const a3 = await context.entities.Document.findUnique({
where: { id: args.id },
});
if (!a3) throw new HttpError(404, "document not found");
// Only MANAGER can delete
const canDelete = await canDeleteDocument(context.user.id, a3, context);
if (!canDelete) {
throw new HttpError(403, "Only department managers can delete documents");
}
return context.entities.Document.delete({
where: { id: args.id },
});
};
Create Resource with Role Check
typescript
import type { CreateA3 } from "wasp/server/operations";
export const createA3: CreateA3 = async (args, context) => {
if (!context.user) throw new HttpError(401);
// Check user has at least MEMBER role in department
const role = await getUserRoleInDepartment(
context.user.id,
args.departmentId,
context,
);
if (role !== "MANAGER" && role !== "MEMBER") {
throw new HttpError(403, "Need MEMBER or MANAGER role to create documents");
}
return context.entities.Document.create({
data: {
...args.data,
departmentId: args.departmentId,
authorId: context.user.id,
},
});
};
Advanced Patterns
Batch Permission Checks
typescript
/**
* Get all permissions for A3 at once
* Useful for UI rendering (show/hide buttons)
*/
async function getDocumentPermissions(
userId: string,
a3: Document,
context,
): Promise<{
canView: boolean;
canEdit: boolean;
canDelete: boolean;
canShare: boolean;
}> {
const [canView, canEdit, canDelete] = await Promise.all([
canAccessDocument(userId, a3, context),
canEditDocument(userId, a3, context),
canDeleteDocument(userId, a3, context),
]);
return {
canView,
canEdit,
canDelete,
canShare: canEdit, // Share requires edit permission
};
}
Filter Multiple Resources
typescript
/**
* Filter A3 IDs to only those user can access
* More efficient than checking one by one
*/
async function filterAccessibleA3s(
userId: string,
a3Ids: string[],
context,
): Promise<string[]> {
const a3Documents = await context.entities.Document.findMany({
where: { id: { in: a3Ids } },
});
const accessChecks = await Promise.all(
a3Documents.map(async (a3) => ({
id: a3.id,
hasAccess: await canAccessDocument(userId, a3, context),
})),
);
return accessChecks
.filter((check) => check.hasAccess)
.map((check) => check.id);
}
Query-Level Filtering (Optimal)
typescript
/**
* Get all documents user can access
* Uses database-level filtering for efficiency
*/
async function getAccessibleDocuments(
userId: string,
context,
): Promise<Document[]> {
// Get user's organizations
const orgMemberships = await context.entities.OrganizationMember.findMany({
where: { userId },
});
const orgIds = orgMemberships.map((m) => m.organizationId);
// Get user's departments
const deptMemberships = await context.entities.UserDepartment.findMany({
where: { userId },
});
const deptIds = deptMemberships.map((m) => m.departmentId);
// Query with permission filter
return await context.entities.Document.findMany({
where: {
OR: [
// Own documents
{ authorId: userId },
// Organization documents (if admin)
{
AND: [
{ organizationId: { in: orgIds } },
{
organization: {
members: {
some: {
userId,
role: { in: ["OWNER", "ADMIN"] },
},
},
},
},
],
},
// Department documents
{ departmentId: { in: deptIds } },
],
},
include: {
author: { select: { id: true, username: true } },
department: true,
organization: true,
},
orderBy: { updatedAt: "desc" },
});
}
Permission Patterns by Role
Organization Roles
OWNER:
- Full organization access
- Can manage all departments
- Can edit/delete all resources
- Can manage organization settings
- Can add/remove admins
ADMIN:
- Full organization access
- Can manage all departments
- Can edit/delete all resources
- Cannot modify owner permissions
MEMBER:
- Basic organization access
- Access determined by department roles
- Cannot manage organization settings
Department Roles
MANAGER:
- View all department resources
- Edit all department resources
- Delete department resources
- Manage department members
- Access child departments (hierarchical)
MEMBER:
- View department resources
- Edit own resources
- Create new resources
- Cannot delete resources (manager only)
- Cannot manage members (manager only)
VIEWER:
- View department resources
- Cannot edit resources
- Cannot create resources
- Cannot delete resources
- Cannot manage members
Common Permission Patterns
Owner Can Edit Their Own
typescript
// Users can edit their own resources, managers can edit all
export const updateA3 = async (args, context) => {
if (!context.user) throw new HttpError(401);
const a3 = await context.entities.Document.findUnique({
where: { id: args.id },
});
if (!a3) throw new HttpError(404);
const isOwner = a3.authorId === context.user.id;
const isManager = await isDepartmentManager(
context.user.id,
a3.departmentId,
context,
);
if (!isOwner && !isManager) {
throw new HttpError(403, "Can only edit your own documents");
}
return context.entities.Document.update({
where: { id: args.id },
data: args.data,
});
};
Manager-Only Actions
typescript
// Only department managers can perform this action
export const archiveA3 = async (args, context) => {
if (!context.user) throw new HttpError(401);
const a3 = await context.entities.Document.findUnique({
where: { id: args.id },
});
if (!a3) throw new HttpError(404);
const isManager = await isDepartmentManager(
context.user.id,
a3.departmentId,
context,
);
if (!isManager) {
throw new HttpError(403, "Only department managers can archive documents");
}
return context.entities.Document.update({
where: { id: args.id },
data: { status: "ARCHIVED" },
});
};
Hierarchical Department Access
typescript
/**
* Get all child departments recursively
*/
async function getChildDepartments(deptId: string, context): Promise<string[]> {
const dept = await context.entities.Department.findUnique({
where: { id: deptId },
include: {
children: {
include: { children: true },
},
},
});
if (!dept) return [];
const childIds: string[] = [];
function collectChildren(dept: any) {
if (dept.children) {
dept.children.forEach((child) => {
childIds.push(child.id);
collectChildren(child);
});
}
}
collectChildren(dept);
return [deptId, ...childIds];
}
Critical Rules
DO:
- ✅ Check permissions AFTER auth and existence checks
- ✅ Use permission helpers for consistency
- ✅ Filter queries by accessible departments
- ✅ Include VIEWER role in read operations
- ✅ Check role hierarchy (OWNER > ADMIN > MANAGER > MEMBER > VIEWER)
- ✅ Handle hierarchical departments (parent access)
- ✅ Enforce permissions server-side ALWAYS
- ✅ Return 403 for permission failures (not 404)
NEVER:
- ❌ Skip permission checks in operations
- ❌ Hardcode role checks (use helpers)
- ❌ Forget VIEWER role in read operations
- ❌ Allow clients to bypass permissions
- ❌ Trust client-side permission checks (cosmetic only!)
- ❌ Return 404 when resource exists but user lacks permission (use 403)
- ❌ Implement permission logic in client code
HTTP Status Code Usage
Permission-related status codes:
- 401 Unauthorized - Not authenticated (
!context.user) - 403 Forbidden - Authenticated but lacks permission
- 404 Not Found - Resource doesn't exist OR user lacks permission to know it exists
Best practice:
- Use 403 when you want user to know resource exists but they can't access it
- Use 404 when you want to hide resource existence from unauthorized users
- Always use 401 for missing authentication
Client-Side Usage
typescript
// React component using permission helpers
import { useQuery } from 'wasp/client/operations'
import { getDocument, getDocumentPermissions } from 'wasp/client/operations'
function DocumentPage({ a3Id }: { a3Id: string }) {
// Fetch document
const { data: a3, isLoading } = useQuery(getDocument, { id: a3Id })
// Fetch permissions
const { data: permissions } = useQuery(getDocumentPermissions, { a3Id })
if (isLoading) return <div>Loading...</div>
if (!a3) return <div>Not found</div>
return (
<div>
<h1>{a3.title}</h1>
{/* Conditionally render based on permissions */}
{permissions?.canEdit && (
<button onClick={() => editA3()}>Edit</button>
)}
{permissions?.canDelete && (
<button onClick={() => deleteA3()}>Delete</button>
)}
{permissions?.canShare && (
<button onClick={() => shareA3()}>Share</button>
)}
{!permissions?.canEdit && (
<div className="text-gray-500">Read-only access</div>
)}
</div>
)
}
Remember: Client-side checks are for UX only. Always enforce permissions server-side.
References
Complete implementation examples:
.claude/templates/permission-helpers.ts(623 lines)- Lines 1-150: Core helpers
- Lines 151-330: Organization/department helpers
- Lines 331-427: Usage in operations
- Lines 428-539: Advanced patterns
- Lines 540-583: Client-side usage
Related documentation:
CLAUDE.md#architecture- Multi-tenancy architecture overviewCLAUDE.md#error-handling- HTTP status codes and error patterns.claude/templates/operations-patterns.ts- Complete operation examples
Didn't find tool you were looking for?