Access Control
Group permissions
Model a workspace with role-based access control using a members table and existence-based permissions.
This recipe shows how to build a workspace where users have different levels of access depending on their role. The same pattern applies to any group-like concept — teams, projects, channels, organisations.
Roles at a glance
| Role | Read | Create | Edit own | Edit any | Manage members |
|---|---|---|---|---|---|
reader | ✓ | ||||
contributor | ✓ | ✓ | ✓ | ||
writer | ✓ | ✓ | ✓ | ✓ | |
admin | ✓ | ✓ | ✓ | ✓ | ✓ |
Schema
Three tables: the workspace itself, a members join table that records each user's role, and the documents that belong to the workspace.
const schema = {
workspaces: s.table({
name: s.string(),
}),
workspaceMembers: s.table({
workspaceId: s.ref("workspaces"),
user_id: s.string(),
role: s.enum(["reader", "writer", "contributor", "admin"]),
}),
documents: s.table({
title: s.string(),
content: s.string(),
workspaceId: s.ref("workspaces"),
}),
};
type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);Permissions
type Role = "reader" | "writer" | "contributor" | "admin";
s.definePermissions(app, ({ policy, session, anyOf, allOf }) => {
// Re-usable helpers to improve readability
const isMember = (workspaceId: string) =>
policy.workspaceMembers.exists.where({ workspaceId, user_id: session.user_id });
const hasRole = (workspaceId: string, role: Role) =>
policy.workspaceMembers.exists.where({ workspaceId, user_id: session.user_id, role });
const isAdmin = (workspaceId: string) => hasRole(workspaceId, "admin");
// --- documents ---
policy.documents.allowRead.where((doc) => isMember(doc.workspaceId));
policy.documents.allowInsert.where((doc) =>
anyOf([
hasRole(doc.workspaceId, "writer"),
hasRole(doc.workspaceId, "contributor"),
hasRole(doc.workspaceId, "admin"),
]),
);
// Writers and admins can edit any document; contributors can only edit their own
policy.documents.allowUpdate.where((doc) =>
anyOf([
hasRole(doc.workspaceId, "writer"),
hasRole(doc.workspaceId, "admin"),
allOf([{ $createdBy: session.user_id }, hasRole(doc.workspaceId, "contributor")]),
]),
);
// Writers and admins can delete any document; contributors can delete their own
policy.documents.allowDelete.where((doc) =>
anyOf([
hasRole(doc.workspaceId, "writer"),
isAdmin(doc.workspaceId),
allOf([{ $createdBy: session.user_id }, hasRole(doc.workspaceId, "contributor")]),
]),
);
// --- workspaces ---
policy.workspaces.allowRead.where((workspace) => isMember(workspace.id));
policy.workspaces.allowInsert.always();
policy.workspaces.allowUpdate.where((workspace) => isAdmin(workspace.id));
policy.workspaces.allowDelete.where((workspace) => isAdmin(workspace.id));
// --- workspaceMembers ---
policy.workspaceMembers.allowRead.where((member) => isMember(member.workspaceId));
// Admins can add members; workspace creators can bootstrap themselves as the first admin
policy.workspaceMembers.allowInsert.where((member) =>
anyOf([
isAdmin(member.workspaceId),
allOf([
{ user_id: session.user_id, role: "admin" },
policy.workspaces.exists.where({ id: member.workspaceId, $createdBy: session.user_id }),
]),
]),
);
policy.workspaceMembers.allowUpdate.where((member) => isAdmin(member.workspaceId));
// Admins can remove any member; members can leave on their own
policy.workspaceMembers.allowDelete.where((member) =>
anyOf([isAdmin(member.workspaceId), { user_id: session.user_id }]),
);
});- Contributor edit access uses
allOfto require both$createdBy: session.user_id(the row belongs to this user) and a matching contributor membership. Writers and admins bypass the creator check entirely. - Bootstrap insert: the second branch of
allowInsertlets the workspace creator add themselves as the first admin — otherwiseisAdminwould block everyone, since there are no members yet. - Leave on your own: members can delete their own membership row regardless of role. Admins can remove anyone.
See Permissions for more on exists.where, anyOf, allOf, and $createdBy.
Creating a workspace
export function createWorkspace(db: ReturnType<typeof useDb>, name: string, creatorId: string) {
const workspace = db.insert(app.workspaces, { name });
// Add the creator as admin immediately so they can manage the workspace
db.insert(app.workspaceMembers, {
workspaceId: workspace.id,
user_id: creatorId,
role: "admin",
});
return workspace;
}Managing members
Adding a member
export function addMember(
db: ReturnType<typeof useDb>,
workspaceId: string,
userId: string,
role: "reader" | "writer" | "contributor" | "admin",
) {
db.insert(app.workspaceMembers, { workspaceId, user_id: userId, role });
}Listing members
export function WorkspaceMembers({ workspaceId }: { workspaceId: string }) {
const members = useAll(app.workspaceMembers.where({ workspaceId }));
if (!members) return <p>Loading…</p>;
return (
<ul>
{members.map((member) => (
<li key={member.id}>
{member.user_id} — {member.role}
</li>
))}
</ul>
);
}Changing a member's role
export function changeRole(
db: ReturnType<typeof useDb>,
memberId: string,
newRole: "reader" | "contributor" | "writer" | "admin",
) {
db.update(app.workspaceMembers, memberId, { role: newRole });
}Removing a member
export async function removeMember(
db: ReturnType<typeof useDb>,
workspaceId: string,
userId: string,
) {
const member = await db.one(app.workspaceMembers.where({ workspaceId, user_id: userId }));
if (member) db.delete(app.workspaceMembers, member.id);
}Querying documents
export function WorkspaceDocuments({ workspaceId }: { workspaceId: string }) {
const docs = useAll(app.documents.where({ workspaceId }));
if (!docs) return <p>Loading…</p>;
return (
<ul>
{docs.map((doc) => (
<li key={doc.id}>{doc.title}</li>
))}
</ul>
);
}