Backend guides
Adding a permission
Define a new permission in the Allow catalogue, assign it to roles, and enforce it on endpoints and in the UI.
On this page
Permissions in Slicekit are owned by code, not by the database. Every protected endpoint chains
.RequirePermission(Allow.<Name>), and the SPA mirrors the same catalogue to hide UI the caller
cannot use. This guide walks the full loop: define the permission, add it to a role catalogue,
enforce it on the API, and gate the matching frontend surface. For the request pipeline that runs
the filter, see authentication; for how the slice itself is built, see
adding a vertical slice.
The catalogue
api/src/Slicekit.Core/Permissions/Allow.cs holds every permission as a strongly-typed
PermissionDefinition constant. Naming is Area.Action in PascalCase, one permission per action
(User.GetMe, User.CreateApiKey, Admin.ListUsers). Resist bundling read and write into a single
Manage* permission: the per-action shape lets API keys be scoped to read-only access.
public sealed record PermissionDefinition(string Name, bool IsReadOnly = false)
{
public static implicit operator string(PermissionDefinition p) => p.Name;
}
Two arrays group the catalogue into roles:
Allow.UserPermissionCatalog: granted to every new user on first login.Allow.AdminPermissions: granted on top, to users flaggedIsAdmin.
Allow.RequiredPermissionsFor(user) returns the set a caller should hold based on their admin flag,
and the sign-in path uses it to backfill missing grants.
1. Define the permission
Add a PermissionDefinition constant under the matching area block in Allow.cs. Pass
IsReadOnly: true when the permission only reads state: both permission-picker UIs use that flag to
power their “Read-only” preset.
public static readonly PermissionDefinition ProjectList =
new("Project.List", IsReadOnly: true);
public static readonly PermissionDefinition ProjectCreate =
new("Project.Create");
2. Add it to a role catalogue
Append the constant to UserPermissionCatalog or AdminPermissions in the same file:
public static readonly PermissionDefinition[] UserPermissionCatalog =
[
UserGetMe,
// ...
ProjectList,
ProjectCreate
];
On the next API startup, PermissionSyncService (a hosted service in Slicekit.Api) reconciles the
Permissions lookup table to match the catalogue: it inserts the new row and removes any row whose
name no longer appears in code. No EF migration is needed for the catalogue itself, permissions are
owned by code.
PermissionSyncService only maintains the lookup table. It never touches UserPermissions. Existing
users pick up the new permission on their next successful sign-in, when
PermissionBackfill.EnsureCatalogPermissionsAsync diffs their grants against
Allow.RequiredPermissionsFor(user) and inserts what is missing. A signed-in session sees the new
claim at the next JWT refresh, or within the two-minute UserCache TTL, whichever comes first.
3. Enforce it on the endpoint
Chain .RequirePermission(...) onto the route in api/src/Slicekit.Api/Endpoints/v1/. The filter
rejects the request with a 403 ForbiddenError before the handler runs if the caller’s claims do not
include the named permission.
internal sealed class CreateProjectEndpoint : IEndpoint
{
public static void Map(IEndpointRouteBuilder routes) =>
routes.Projects().MapPost("/", HandleAsync)
.RequirePermission(Allow.ProjectCreate)
.RequireRateLimiting(RateLimitPolicies.Default)
.RequireCsrf();
}
The filter itself is small. It reads the principal’s permission claims, which the authentication
middleware projects at sign-in time (both the JWT bearer and the API-key path):
internal sealed class PermissionEndpointFilter(PermissionDefinition permission) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
if (!ctx.HttpContext.User.HasPermission(permission))
return new ForbiddenError(permission).ToProblem();
return await next(ctx);
}
}
Anonymous routes (/auth/register, /auth/login) chain .AllowAnonymous() instead. The
RequirePermission filter only runs on authenticated requests.
4. Mirror it in the SPA
The SPA exposes Permission as a const map in frontend/src/shared/auth/permissions.ts. It must
mirror Allow.cs exactly: same names, same casing. Add the matching entry whenever you add or rename
a permission server-side.
export const Permission = {
// ...
ProjectList: 'Project.List',
ProjectCreate: 'Project.Create',
} as const;
Gate UI with the usePermissions hook in frontend/src/shared/hooks/use-permissions.ts, which reads
the current user’s permissions array and exposes a has(...) check:
import { usePermissions } from '@/shared/hooks/use-permissions';
import { Permission } from '@/shared/auth/permissions';
function ProjectsToolbar() {
const { has } = usePermissions();
return (
<div>
{has(Permission.ProjectCreate) && <CreateProjectButton />}
</div>
);
}
The frontend gate is a UX nicety, not a security boundary: the API filter is the real enforcement, so a hidden button still cannot reach a protected endpoint. See frontend permissions for the consumption pattern in routes and menus.
5. Verify
dotnet build api/slicekit.slnx
The build fails if you reference an Allow.<X> that does not exist, so a typo in the endpoint surfaces
immediately. Then start the API and hit the protected endpoint without the relevant claim: expect a 403
problem-details response with "detail": "Missing permission: Project.Create". Restart the API and
check the Permissions table: the new row appears via PermissionSyncService with no migration.
On the frontend:
cd frontend && pnpm typecheck
This passes once the matching Permission entry lands, and fails if a has(Permission.X) call
references a name you forgot to add.
Checklist
-
PermissionDefinitionconstant added toAllow.cs(IsReadOnly: trueif read-only). - Appended to
UserPermissionCatalogorAdminPermissions. -
.RequirePermission(Allow.<Name>)chained on the endpoint. - Matching entry added to
frontend/src/shared/auth/permissions.ts. - UI gated with
usePermissions().has(Permission.<Name>)where relevant. -
dotnet buildandpnpm typecheckpass; lookup row appears after restart.