RBAC + invitations

Roles, ownership, team invites.

8 minmedium

Authentication tells you who they are. Authorization tells you what they can do. Grit splits this into two primitives: roles (system-wide groups) and ownership (per-row access). Both ship as middleware.

Roles — the system-wide group

Every User has a Role field (default user). Grit ships three roles to start: user, staff, admin. Add your own (auditor, support, etc.) by editing the User model.

apps/api/internal/routes/routes.go
adminGroup := api.Group("/admin")
adminGroup.Use(middleware.Auth(authService))
adminGroup.Use(middleware.RequireRoles("admin"))
{
adminGroup.GET("/users", userHandler.AdminList)
adminGroup.DELETE("/users/:id", userHandler.AdminDelete)
}

RequireRoles reads role from the JWT and returns 404 (not 403) if the user isn't in the allow-list. 404 is deliberate — it doesn't leak the route's existence to unauthorized users.

Ownership — the per-row check (IDOR defence)

Roles answer "can this user touch admin routes?". Ownership answers "can this user touch this specific row?". Without it, alice can read bob's order by changing the URL from /api/orders/123 to /api/orders/456. That's the OWASP A01:2025 #1 risk class — IDOR.

apps/api/internal/handlers/order.go
func (h *OrderHandler) GetByID(c *gin.Context) {
var order models.Order
if err := authz.MustOwn(c, h.db, &order, c.Param("id")); err != nil {
return // helper already wrote 404 — never 403
}
c.JSON(http.StatusOK, gin.H{"data": order})
}
// Model must implement Ownable
func (o *Order) GetOwnerID() string { return o.UserID }

authz.MustOwn loads the row, checks GetOwnerID() matches the authenticated user, returns 404 on mismatch. The 404 (not 403) closes the existence-leak side channel.

The pattern is so important that the code generator wires it for you. When you run grit generate resource Order, the generated handler already calls authz.MustOwn. IDOR is closed by the generator, not by the developer remembering.

Tenant scoping

Multi-tenant apps go one step further — a row belongs to a tenant, and users only see rows for their tenant. Grit's authz.CheckScope handles this:

func (h *OrderHandler) List(c *gin.Context) {
tenantID := c.GetString("tenant_id") // set by middleware
var orders []models.Order
h.db.Where("tenant_id = ?", tenantID).Find(&orders)
c.JSON(200, gin.H{"data": orders})
}

Every query is tenant-scoped. There's no way for a handler to forget — code review (or a custom lint rule) catches db.Find() without a tenant filter.

The invitation flow

For team-based products: invite a user to a tenant + role.

Admin clicks "Invite member" → enters email + role
→ POST /api/invitations { email, role, tenant_id }
→ Grit creates an invitation row + sends an email with a one-time link
Invitee clicks the link → /api/invitations/:token/accept
→ Grit creates the user (if new), assigns the role + tenant, signs them in

Single-use, time-bound (7 days default). Stored in the activity log so you can audit who invited whom.

Quick check

Alex (regular user) sends `GET /api/admin/users/123` to inspect another user. What does Grit do?

Try it

Implement the chapter assignment: add /api/admin/stats that returns user counts. Protect it with both Auth and RequireRoles. Verify the right responses:

  1. Without a token: 401
  2. With a regular-user token: 404 (RequireRoles rejected you)
  3. With an admin token (manually set role=admin in your DB row): 200 + the stats

Paste all three curl responses in notes.md.

What's next

Chapter 4 — Batteries: jobs, mail, storage, AI. The included primitives that ship with every Grit API and save you a week of integration work.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub