RBAC + invitations
Roles, ownership, team invites.
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.
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.
func (h *OrderHandler) GetByID(c *gin.Context) {var order models.Orderif 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 Ownablefunc (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.
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 middlewarevar orders []models.Orderh.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 linkInvitee 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
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:
- Without a token: 401
- With a regular-user token: 404 (RequireRoles rejected you)
- With an admin token (manually set
role=adminin 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