RBAC
Steward enforces access in three layers, from coarse to fine. Each layer can only narrow what the previous one allowed.
| Layer | Lives in | Granularity | Changed by |
|---|---|---|---|
| Permissions | database, admin-edited | route patterns | panel operators, at runtime |
Policy[T] | your Go code | per action, per row | developers, at deploy time |
RowScoper | your Go code | query scoping | developers, at deploy time |
Users, roles, permissions
The built-in resources under Admin → Administrators / Roles / Permissions manage the classic dcat-admin model:
- Users hold credentials and role assignments.
- Roles group permissions. The seeded
administratorrole bypasses all permission checks (but not policies — see below). - Permissions are HTTP rules: a method list (
GET,POSTorANY) plus one or more path patterns with*globs, matched against every request:
GET
/posts
/posts/*A signed-in user may perform a request when any permission attached to any
of their roles matches it. Menu entries the user cannot GET are hidden
automatically — there is no separate role↔menu bookkeeping to maintain.
Because permissions live in the database, operators can create a
“Content editor” role and grant it /posts* without a deploy.
Policies
Policy[T] is the code layer: type-safe, reviewable, testable, and aware of
the actual row being touched. Attach one per resource:
type PostPolicy struct{ steward.AllowAll[Post] }
// Only the author may edit a post.
func (PostPolicy) Update(c *steward.Context, p *Post) bool {
return c.User != nil && p.AuthorID == c.User.ID
}
// Nobody deletes published posts.
func (PostPolicy) Delete(c *steward.Context, p *Post) bool {
return p.Status != "published"
}
steward.Register[Post](app).Policy(PostPolicy{})The five methods and what they gate:
| Method | Gates |
|---|---|
ViewAny(c) | the grid page, JSON listing, CSV export, _schema and _options, custom actions, relation grids on other resources’ detail pages — and the sidebar entry |
View(c, m) | the detail page and single-record JSON |
Create(c) | the create form and POST submissions; also hides the “New” button |
Update(c, m) | the edit form, PUT/PATCH submissions, and inline grid edits |
Delete(c, m) | single and batch deletes (each row is checked) |
Embed steward.AllowAll[T] and override only what you need.
Two properties worth internalizing:
Important
Policies bind administrators too. Permissions answer “does this role allow this route?”; policies answer “does this action make sense for this user on this row?” — a business rule like “published posts are immutable” should hold for everyone. Don’t re-implement role checks in policies; that’s what permissions are for.
Note
View, Update, and Delete receive the loaded model, so ownership
checks (p.AuthorID == c.User.ID) need no extra queries.
Row scoping
Implement RowScoper on the same policy value to narrow every listing
(grid, JSON, export, relation grids):
func (PostPolicy) Scope(c *steward.Context, db *gorm.DB) *gorm.DB {
if c.User.IsAdministrator() {
return db
}
return db.Where("author_id = ?", c.User.ID)
}Scoping applies to lists only — a row outside the scope is still reachable
by its direct URL if View(c, m) allows it. Use scope for “what appears
in listings”, and the per-row methods for “what may be opened or changed”;
for full row-level security, implement both with the same predicate.
Choosing a layer
- Operators need to change it at runtime → permission (DB).
- It depends on the row or expresses a business rule → policy (code).
- It should filter listings per user → RowScoper (code).
The layers compose: permissions decide whether the route is reachable at all; the policy then has the final word on the specific action and row.