Overview
Teams are the unit Snapp uses to scope access inside an organization.
A team is created under an organization and then referenced by:
- membership (who is part of the team)
- policies (what each role can do within that team context)
- resource bindings (which URLs are shared with that team)
Snapp uses Better-Auth organizations + access-control statements to enforce all of this.
Concepts
Organization
An organization is the boundary for identity and permissions. In Snapp, organizations are created and selected per host domain.
Teams only exist inside an organization.
Team
A team is a container that can be referenced as a permission context.
In your policy model, the context key is the team id:
permissions: {
[teamId]: ['create', 'read', 'update', 'delete']
}
This allows per-team rules without hardcoding team names in code.
Roles
Snapp uses organization roles:
owneradminmember
The role defines the default organization-level capabilities and is also used as the axis for team policies.
Permission model
Statements
Snapp defines a small set of actions:
createreadupdatedeletecancel
A policy is stored as:
type TPermissions = Record<string, ('create' | 'read' | 'update' | 'delete' | 'cancel')[]>
And the persisted structure is a role → teamId → actions map:
Record<Role, Record<TeamId, Action[]>>
This structure is stored in the database in organizationRole.permission.
Where policies live
Policies are stored per organization in the organizationRole table:
organizationId(the org)role(owner|admin|member)permission(JSON blob)
At runtime, Snapp loads policies for the current organization and builds the effective role graph.
Creating teams
UI flow
Teams are created from the organization panel.
The creation form collects:
- team name
- initial permissions per role (matrix editor)
The submitted payload includes:
organizationIdnamepermissions(JSON string)
Server flow
Team creation does three things:
Authorization check
- The caller must have permission to create teams:
permissions: { team: ['create'] }Create the team in Better-Auth.
Materialize the policy
- For each stored role in
organizationRole, Snapp updates the permission JSON by adding:
permission[team.id] = permissions[roleName]- For each stored role in
After creation, caches are invalidated:
- auth client cache (per host)
- role cache (per organization)
This ensures any subsequent request sees the updated policy graph.
Editing team policies
Policy editor
The UI shows a permission matrix:
- rows = roles (
owner,admin,member) - columns = actions (
create,read,update,delete) - target = a specific team id
Toggling a checkbox updates the local permissions map and triggers a save.
owner is locked to full access in the UI.
Save operation
When saving, Snapp sends a batch update:
[{ id: roleRowId, p: permissionsForThatRole }]
Server-side, this updates organizationRole.permission for each role row.
After update:
- the role cache is invalidated
- the auth client cache is invalidated
So the next permission check uses the new policy graph.
How enforcement works
Permission checks are server-side
Snapp does not trust the UI. The UI only controls convenience; enforcement happens on the server through hasPermission.
Permission checks use a context key
When a resource is shared with a team, the team id becomes the permission context.
Examples from URL operations:
- Creating a URL with team bindings requires the caller to have
createon at least one selected team. - Updating or deleting a URL owned by someone else requires the caller to have the matching permission for the teams that URL is assigned to.
This is enforced by evaluating:
hasPermission({
organizationId,
context: teamId,
permissions: ['create' | 'update' | 'delete']
})
Create URL enforcement
When a URL is created with teams:
- Snapp checks the caller against every selected team
- required action:
create - the operation is allowed if at least one team check succeeds
If none succeed, the request is rejected.
Update URL enforcement
When updating a URL:
- if caller is owner or admin → allowed
- otherwise, Snapp computes the union of:
- teams already assigned to the URL
- teams requested in the update
Then checks permission:
- required action:
update - allowed if any team check succeeds
This supports incremental changes without letting non-members bypass policy by removing teams.
Delete URL enforcement
When deleting URLs:
- owners and admins can delete their own / all (depending on role logic)
- non-owners can delete only if all team checks pass for
delete
This yields a stricter rule for destructive operations.
Team assignment to resources
Snapp links teams to URLs through a join table (teamToUrl).
This has two purposes:
- sharing: team members can operate on shared URLs if policy allows
- enforcement context: team id becomes the policy key for authorization
The join table is updated during URL create/update inside the same DB transaction as the URL mutation.
Cache behavior
Two caches are involved:
- auth instance cache (
authCache.auth) keyed by host origin - role cache (
authCache.roles) keyed by organization id (slugify(host.origin))
Any operation that mutates teams or policies clears the relevant cache keys so permission checks remain current.
Summary
- Teams are permission contexts inside an organization.
- Policies are stored as role → teamId → actions in
organizationRole.permission. - The UI edits policies, but enforcement happens server-side using
hasPermission. - URL operations use team assignments to decide which policies apply.
- Cache invalidation keeps the effective permission graph consistent across requests.