Permissions, not roles, but we stopped short of Zanzibar
Slicekit sits between coarse RBAC and relationship-based auth: a flat permission catalogue enforced on the API and mirrored in the UI. Here is what that buys you, and the day you should outgrow it.
“Roles” feel tidy right up until the day someone needs a support agent who can read users but not delete
them. There is no role for that, so you invent a fifth one. Then a sixth, for the agent who can also unlock
accounts but not reset passwords. Soon every new hire is a new role, the role list is longer than the
feature list, and nobody can say from memory what support_tier_2 is actually allowed to touch. This is
role explosion, and it is the predictable end state of treating the role as the unit of authorization.
The reflex fix is “more roles” or, worse, the catch-all admin role handed out the moment fine-grained ones get tedious. That one role can delete users, impersonate them, and read everything, all bundled with the harmless thing someone actually needed. The grant stops meaning anything, and you audit it by hoping. The fix is not more roles. It is to stop making the role the thing you enforce.
But “permissions, not roles” is only half a position. A flat catalogue does not touch a second axis of authorization, and being honest about where Slicekit stops on it is more useful than pretending it does everything. So: permissions, not roles, but deliberately stopping short of Zanzibar.
Why permissions beat roles
Be fair to role-based access control first. RBAC exists because reasoning about a handful of named roles is genuinely simpler than reasoning about hundreds of capabilities. For a two-audience app, “admin” and “everyone else” is the right amount of structure, and anything finer is over-engineering.
The trouble is that a role is a bundle decided up front, and real org charts do not stay simple. The moment
someone needs a capability the bundle does not match, you get two bad options: widen the role for everyone
who holds it, or fork a new role and maintain it forever. Both end the same way. Grants drift from what
people actually do, and the gap fills with conditionals scattered across handlers: if (role == "admin" || role == "support"). That line is the real authorization rule, and it lives nowhere you can see it.
Slicekit keeps roles but demotes them. Authorization is decided per capability. The catalogue is a flat list
of fine-grained permissions, each a single Area.Action like User.CreateApiKey or Admin.ListUsers, all
declared in one file, api/src/Slicekit.Core/Permissions/Allow.cs. There are 33 of them today: 16 in
UserPermissionCatalog that every signed-in user holds, and 17 more in AdminPermissions layered on top
for admins. Roles survive only as those two named bundles. The permission, not the role, is what gets
enforced.
The deliberate rule is one permission per action, and the guide is blunt about resisting the urge to bundle
reads and writes into a single Manage* permission. Each definition carries an IsReadOnly flag, so a
write capability is never silently granted alongside a harmless read. That granularity is what lets an API
key be scoped to read-only access, and it is what turns the “support agent who can read but not delete” case
into a non-event: grant Admin.ListUsers, withhold Admin.DeleteUser, done. No new role, no conditional,
no widening anyone else’s access.
One catalogue, mirrored on the API and the UI
Granularity alone does not stop the other classic failure: the UI and the API quietly disagreeing about who may do what. The button still shows after the endpoint was locked down, or a role means one thing to the backend and another to the frontend. Slicekit avoids the drift by making both sides read the same catalogue rather than each keeping their own copy.
API · enforced
.RequirePermission(Allow.UserCreateApiKey)
PermissionEndpointFilter returns 403 before the handler
Allow.cs
Allow.UserCreateApiKey
"User.CreateApiKey"
UI · mirrored
has(Permission.UserCreateApiKey)
usePermissions() hides UI the caller cannot use
On the server, every protected route chains its permission onto the endpoint, and a small
PermissionEndpointFilter reads the caller’s permission claims and returns a 403 before the handler
runs:
routes.ApiKeys().MapPost("/", HandleAsync)
.RequirePermission(Allow.UserCreateApiKey);
That filter is the real gate. The SPA mirrors the exact same name in
frontend/src/shared/auth/permissions.ts and uses it only to hide UI the caller could never use anyway:
const { has } = usePermissions();
return has(Permission.UserCreateApiKey) && <CreateApiKeyButton />;
Same string, same casing, two sides. The button is a courtesy; the API filter is the boundary. A hidden
button still cannot reach a protected endpoint, so getting the UI gate wrong is a UX bug, never a security
hole. Be honest about how tight that coupling actually is: the SPA’s permissions.ts is a hand-maintained
mirror that lists only the names the UI gates on, not a generated copy of the catalogue. The compiler
catches the mistakes it can see: dotnet build fails on an endpoint referencing a missing Allow.<X>, and
pnpm typecheck fails if the UI gates on a Permission.X that is not in the mirror. What neither catches
is a backend permission nobody mirrored, which is why the API filter, not the typecheck, stays the real
boundary. A new permission needs no migration either; PermissionSyncService reconciles the lookup table
to the code on the next API start.
Where Slicekit deliberately stops
Here is the honest part most “permissions, not roles” pitches skip. A flat global catalogue answers exactly
one question: can this user perform this action? It does not answer on which specific objects?
Admin.ListUsers either lets you list users or it does not. There is no permission shape in Slicekit for
“user X can edit document Y because someone shared it with them.”
That second question is relationship-based access control, ReBAC, the model
Google’s Zanzibar paper popularized for authorizing
billions of per-object decisions across Docs, Drive, and Calendar. Instead of asking what role a user holds,
a Zanzibar-style system stores relationship tuples like document:readme#editor@user:anne and answers
checks by walking that graph: Anne can edit the readme
because she is its editor, or because she belongs to a group that owns the folder it lives in. Sharing,
hierarchy, and inheritance become first-class, and the model travels with the
resource, not with a global grant.
Slicekit does not do this, on purpose. Per-instance, relationship-driven authorization is a different engine with its own storage, its own consistency story, and its own operational weight. Bolting a half-version onto a flat claim check would give you the cost without the guarantees, so a template should pick a lane and be clear about it.
When should a buyer outgrow the catalogue? Watch for the signal: decisions that depend on which object, not just which action. Documents shared between users, projects with per-member roles, multi-tenant data where one tenant’s records must stay invisible to another, folder trees where access inherits downward. The moment you want to store “who can touch what” as data rather than declare it in code, you have left what a flat catalogue can honestly model. That is the cue to reach for a Zanzibar-style system such as OpenFGA or SpiceDB and let it own the per-object decisions, while Slicekit’s catalogue keeps gating actions.
For most B2B SaaS starting out, that day is further off than it feels. “Can this user invite teammates, manage API keys, or reach the admin console?” is answered cleanly by 33 action permissions and no graph database. You reach for ReBAC when per-object sharing becomes the product, not before.
Roles told you who someone was. Permissions tell you what they can do. Relationship-based auth tells you what they can do to a specific thing. Slicekit ships the first two and points clearly at the third. For the full loop with exact files, see Adding a permission and Permissions in the UI.