Security

Roles & Permissions

Grit guards routes by permission, not by role name. Roles are named bags of permissions you edit at runtime, so adding a role never means editing routes and redeploying.

The shape of a permission

A permission key is <resource>.<action>, where the action is one of create, view, edit or delete:

products.create
users.delete
uploads.view

Grants may use wildcards:

* every permission (superuser)
products.* every action on products
*.view view on every resource

Roles hold grants

Three roles are seeded on first migrate: ADMIN (holds *), EDITOR (content) and USER (no administrative permissions). Seeding is idempotent and never overwrites grants you have edited.

Grants are stored as authored. If a role holds products.* and you later add a new action to that resource, the role picks it up automatically — nothing to re-grant.

Guarding a route

RequireRole accepts role names, permissions (prefixed perm:), or both. Access is granted if any argument matches:

internal/routes/routes.go
// Permission-based (preferred)
admin.DELETE("/products/:id",
middleware.RequireRole("perm:products.delete"),
productHandler.Delete)
// Mixed — useful while migrating. Either passes.
admin.PUT("/users/:id",
middleware.RequireRole("ADMIN", "perm:users.edit"),
userHandler.Update)
// Role-only still works. Nothing existing had to change.
admin.Use(middleware.RequireRole("ADMIN"))

Inside a handler, use authz.Can for conditional logic:

if authz.Can(h.DB, userID, "products.delete") {
// show the destructive action
}

Generated resources register themselves

grit generate resource Product adds products.create|view|edit|delete to the catalog automatically, so a new resource is grantable immediately. grit remove resource takes them back out. Hand-written permissions belong in coreModules() in internal/authz/permissions.go; machine-written entries live in generatedModules() between the marker comments.

Managing roles

The admin panel has a Roles & permissions screen under System (/system/roles): create roles, tick permissions in a tree with a CRUD matrix, copy permissions from an existing role, and see how many users hold each one.

Built-in roles can have their permissions edited, but they cannot be renamed or deleted — routes and the upgrade path resolve them by name. This is enforced by the API, not just hidden in the UI.

In the frontend

The API returns the caller's permissions already expanded, so the client never re-implements wildcard matching:

const { can, isSuper } = usePermissions()
{can("products.delete") && <DeleteButton />}
{can("products.*") && <ProductsMenu />}
Hiding UI is not access control

can() is a convenience for hiding buttons and nav items. It is not a security boundary — anyone can call your API directly. Always guard the route as well.

Upgrading an existing app

Nothing breaks, by construction:

  • every existing RequireRole("ADMIN") call site keeps working — the signature did not change
  • if a user has no role assignment yet, grants resolve from the legacy users.role string, so admins do not lose access
  • changing a user's role in the admin keeps the assignment in step, so the dropdown still takes effect

Adopt permissions route by route: add a perm: argument alongside the role name, then drop the role name once every caller has a role that grants it.

Multiple roles per user

Assignment is many-to-many. The admin's user form edits a single primary role; assign several with:

PUT /api/users/:id/roles
{ "role_ids": ["...", "..."] }

A user's grants are the union of every role they hold.