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

RoleReadCreateEdit ownEdit anyManage 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.

schema.ts
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

permissions.ts
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 allOf to 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 allowInsert lets the workspace creator add themselves as the first admin — otherwise isAdmin would 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

WorkspaceMembers.tsx
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

WorkspaceDocuments.tsx
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>
  );
}

On this page