Skip to content

RBAC

Steward enforces access in three layers, from coarse to fine. Each layer can only narrow what the previous one allowed.

LayerLives inGranularityChanged by
Permissionsdatabase, admin-editedroute patternspanel operators, at runtime
Policy[T]your Go codeper action, per rowdevelopers, at deploy time
RowScoperyour Go codequery scopingdevelopers, 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 administrator role bypasses all permission checks (but not policies — see below).
  • Permissions are HTTP rules: a method list (GET,POST or ANY) 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:

MethodGates
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.