DevChalk

Goravel series

Gates & Policies

Krishan Kumar · · 9 min See the changes ↗

In Task CRUD, any signed-in user can do everything, even delete a task a manager planned. In this part only managers create, assign and delete tasks, and members can only change the status of their own. Anything else gets a 403 that says why.

CodeStart from the Task CRUD code
Terminal window
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-11
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
go install github.com/air-verse/air@latest
air

The rules

Who can do what:

manager member
read tasks yes yes
create or delete tasks yes no
change a task’s details or assignee yes no
change a task’s status yes own tasks only
list or look up users yes no

Goravel checks permissions with gates and policies. A gate is a function with a name, like view-users, that answers yes or no. A policy is a struct that groups those functions for one model, like TaskPolicy with Create, Update and Delete. The users list needs a single check, so a gate is enough. Tasks need one per action, so they get a policy.

Testing the rules needs a manager and a member. Ana is the manager from the seeders.

  1. Reset the database with migrate:fresh --seed.
  2. Register Ben. New accounts are members, and Ben gets id 5.
  3. Log in as Ana and as Ben, and save each token in a variable, ANA and BEN.
Terminal window
go run . artisan migrate:fresh --seed
SUCCESS Dropped all tables successfully
INFO Running migration
INFO Running: 20210101000002_create_jobs_table
INFO Running: 20261022221906_create_users_table
INFO Running: 20261023063944_create_tasks_table
SUCCESS Migration success
SUCCESS Database seeding completed successfully.
SUCCESS Migration fresh success
curl -X POST http://localhost:3000/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Ben","email":"ben@example.com","password":"secret123"}'
{"created_at":"2026-10-25 10:43:01","updated_at":"2026-10-25 10:43:01","id":5,"name":"Ben","email":"ben@example.com","role":"member","created_tasks":null,"assigned_tasks":null}
curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"password"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
ANA=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"ben@example.com","password":"secret123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"}
BEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…

A gate for the users list

Members don’t assign tasks, so they have no use for the users list. Only managers should see it.

Each gate is checked by its name. If a handler misspells the name, Goravel doesn’t report an error. It finds no gate with that name and refuses the request. To catch that mistake when the code compiles, the names are constants:

app/policies/abilities.go
package policies
const (
ViewUsers = "view-users"
)

Register the gate in bootstrap/app.go, next to the login limit from Auth Middleware:

bootstrap/app.go
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
23 collapsed lines
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return access.NewDenyResponse("Only managers can see the users.")
})
}).
Create()
}
  • Define takes the name and a function. The function gets a context and a map of arguments, and returns an answer.
  • authuser.From(ctx) is the signed-in user, the one the middleware loaded in Auth Middleware.
  • access.NewAllowResponse() allows. access.NewDenyResponse denies, with a message for the client, like “Only managers can see the users.”

Registering a gate doesn’t protect a route by itself. A handler has to ask the gate, so both user handlers ask it before reading anything:

app/http/controllers/user_controller.go
6 collapsed lines
package controllers
import (
"errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/database/orm"
9 collapsed lines
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
)
type UserController struct{}
func NewUserController() *UserController {
return &UserController{}
}
func (r *UserController) Index(ctx http.Context) http.Response {
if response := facades.Gate().WithContext(ctx).Inspect(policies.ViewUsers, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var users []models.User
err := facades.Orm().Query().
err := facades.Orm().WithContext(ctx).Query().
With("AssignedTasks", func(query orm.Query) orm.Query {
return query.Where("status <> ?", enums.StatusDone)
12 collapsed lines
}).
With("AssignedTasks.Creator").
Find(&users)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the users.",
})
}
return ctx.Response().Json(200, users)
}
func (r *UserController) Show(ctx http.Context) http.Response {
if response := facades.Gate().WithContext(ctx).Inspect(policies.ViewUsers, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var user models.User
err := facades.Orm().WithContext(ctx).Query().
17 collapsed lines
With("CreatedTasks").
With("AssignedTasks").
FindOrFail(&user, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The user does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the user.",
})
}
return ctx.Response().Json(200, user)
}
  • Inspect runs the gate named ViewUsers and returns its answer. Allowed() says yes or no, and Message() gives the reason. Allows would only give the yes or no, and the 403 needs the reason too.
  • WithContext(ctx) passes the request’s context to the gate, so authuser.From can find the signed-in user.
  • A refusal answers 403 with that reason.
  • Separately, Index’s query now passes WithContext(ctx) too, as Show’s already did.

Ana gets the list. Ben is refused with the reason, though he can still read his own account at /me:

Terminal window
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/users \
-H "Authorization: Bearer $ANA"
200
curl http://localhost:3000/api/v1/users -H "Authorization: Bearer $BEN"
{"error":"Only managers can see the users."}

Gotcha If a handler calls the gate without WithContext(ctx), the gate can’t see who is signed in. authuser.From finds no user, so the gate refuses everyone, even Ana, the manager:

Terminal window
curl http://localhost:3000/api/v1/users -H "Authorization: Bearer $ANA"
{"error":"Only managers can see the users."}

So when a manager is refused for no clear reason, check that the call passes WithContext(ctx).

Gotcha If a handler writes the name as a plain string instead of the constant, a typo isn’t caught when the code compiles or when the app starts. It only shows up when a request comes in. Here the handler asks for "view-user", one letter short, and Goravel refuses even Ana:

Terminal window
curl http://localhost:3000/api/v1/users -H "Authorization: Bearer $ANA"
{"error":"ability doesn't exist: view-user"}

The message names the missing gate, but it arrives as a 403, so it looks like a permission problem rather than a bug.

Letting managers through every check

Every rule in TaskFlow lets a manager do anything. Instead of checking for a manager in every gate and policy method, Before checks it once, for every ability:

bootstrap/app.go
44 collapsed lines
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
}).
Create()
}

Before runs before every check. If it returns a response, that response is the answer, and the gate never runs. If it returns nil, the gate decides. Here it allows managers and returns nil for everyone else, so the users gate only has to refuse members.

A policy for tasks

Tasks need three rules, one each for creating, changing and deleting. A policy keeps them together in one struct, and make:policy starts it:

Terminal window
go run . artisan make:policy TaskPolicy
SUCCESS Policy created successfully

It writes app/policies/task_policy.go:

app/policies/task_policy.go
package policies
import (
"context"
"github.com/goravel/framework/contracts/auth/access"
)
type TaskPolicy struct {
}
func NewTaskPolicy() *TaskPolicy {
return &TaskPolicy{}
}
func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) access.Response {
return nil
}

Creating and deleting are the two actions only managers may take, so both get a method now. Fill in the generated Create, and add Delete. Each only refuses, with a message. Managers never reach them, because Before has already let them through. Changing a task is different, since members may change the status, so Update gets its own rule afterwards:

app/policies/task_policy.go
package policies
import (
"context"
"github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
)
type TaskPolicy struct {
}
func NewTaskPolicy() *TaskPolicy {
return &TaskPolicy{}
}
func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) access.Response {
return nil
func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can create tasks.")
}
func (r *TaskPolicy) Delete(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can delete tasks.")
}

The file now uses two packages called access, so the contracts one is imported as contractsaccess, as in bootstrap/app.go.

Name the two new abilities:

app/policies/abilities.go
package policies
const (
ViewUsers = "view-users"
ViewUsers = "view-users"
CreateTask = "create-task"
DeleteTask = "delete-task"
)

In bootstrap/app.go, register the policy methods:

bootstrap/app.go
58 collapsed lines
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
}).
Create()
}
  • A policy method has the same signature as a gate function, so taskPolicy.Create and taskPolicy.Delete go straight into Define.

Store and Destroy check the abilities before they do anything:

app/http/controllers/task_controller.go
8 collapsed lines
package controllers
import (
"errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
13 collapsed lines
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/validation"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
178 collapsed lines
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(201, saved)
}
func (r *TaskController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().
With("Creator").
With("Assignee").
FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(200, task)
}
func (r *TaskController) Index(ctx http.Context) http.Response {
validator, err := ctx.Request().Validate(map[string]any{
"status": "in:pending,in-progress,done",
"page": "bail|filled|integer|min:1",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *TaskController) Update(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
var request requests.UpdateTaskRequest
if err := request.Authorize(ctx); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
validator, err := ctx.Request().Validate(
request.Rules(ctx),
validation.Filters(request.Filters(ctx)),
validation.Attributes(request.Attributes(ctx)),
)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
if changes := validator.Validated(); len(changes) > 0 {
if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(200, saved)
}
func (r *TaskController) Destroy(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
facades.Log().Error(err)
7 collapsed lines
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the task.",
})
}
return ctx.Response().NoContent()
}
  • Store asks before it reads the body, so a member gets a 403 even when the body is wrong.
  • Destroy finds the task first, so a missing task is still a 404. Then it asks the policy, and deletes only if allowed.

requests.http logs in as Ben, who can’t create tasks anymore, so add a request that logs in as Ana. Whichever login you run last sets {{token}}:

requests.http
4 collapsed lines
### Register
POST http://localhost:3000/api/v1/register
Content-Type: application/json
{
"name": "Ben",
"email": "ben@example.com",
"password": "secret123"
}
### Log in
### Log in as Ben
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ben@example.com",
"password": "secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Log in as Ana
POST http://localhost:3000/api/v1/login
Content-Type: application/json
{
"email": "ana@example.com",
"password": "password"
}
> {%
client.global.set("token", response.body.token);
%}
68 collapsed lines
### Me
GET http://localhost:3000/api/v1/me
Authorization: Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization: Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization: Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": "Ship v1",
"description": "The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
Authorization: Bearer {{token}}
Content-Type: application/json
{
"title": " ",
"assigned_to": 99,
"due_at": "2020-01-01"
}
### Show task
GET http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
### List tasks
GET http://localhost:3000/api/v1/tasks?status=pending&per_page=5
Authorization: Bearer {{token}}
### Update task
PATCH http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
Content-Type: application/json
{
"status": "in-progress"
}
### Delete task
DELETE http://localhost:3000/api/v1/tasks/1
Authorization: Bearer {{token}}
### List users
GET http://localhost:3000/api/v1/users
Authorization: Bearer {{token}}
### Show user
GET http://localhost:3000/api/v1/users/2
Authorization: Bearer {{token}}
### Hello
GET http://localhost:3000/hello

Ben is refused with “Only managers can create tasks.”, and the same for deleting. Ana gets through. Her new task is task 8, assigned to Ben:

Terminal window
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $BEN" \
-H "Content-Type: application/json" \
-d '{"title":"Ship v3","description":"The third release"}'
{"error":"Only managers can create tasks."}
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:3000/api/v1/tasks \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Ship v3","description":"The third release","assigned_to":5}'
201
curl -X DELETE http://localhost:3000/api/v1/tasks/1 -H "Authorization: Bearer $BEN"
{"error":"Only managers can delete tasks."}
curl -s -o /dev/null -w "%{http_code}\n" \
-X DELETE http://localhost:3000/api/v1/tasks/1 \
-H "Authorization: Bearer $ANA"
204

Members change the status of their own tasks

Ben should be able to change the status of his own task, and nothing else. The policy needs the task and the changed fields to decide, so the handler passes both:

app/policies/task_policy.go
package policies
import (
"context"
"taskflow/app/http/authuser"
"taskflow/app/models"
"github.com/goravel/framework/auth/access"
11 collapsed lines
contractsaccess "github.com/goravel/framework/contracts/auth/access"
)
type TaskPolicy struct {
}
func NewTaskPolicy() *TaskPolicy {
return &TaskPolicy{}
}
func (r *TaskPolicy) Create(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can create tasks.")
}
func (r *TaskPolicy) Update(ctx context.Context, arguments map[string]any) contractsaccess.Response {
user := authuser.From(ctx)
task := arguments["task"].(*models.Task)
changes := arguments["changes"].(map[string]any)
if user == nil || task.AssignedTo == nil || *task.AssignedTo != user.ID {
return access.NewDenyResponse("You can only change tasks assigned to you.")
}
for field := range changes {
if field != "status" {
return access.NewDenyResponse("You can only change the status of a task.")
}
}
return access.NewAllowResponse()
}
func (r *TaskPolicy) Delete(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can delete tasks.")
}
  • Managers never get here, because Before already let them through.
  • A task assigned to someone else, or to nobody, is denied.
  • Any changed field other than status is denied.
  • The type assertions can’t fail, because our handler builds the arguments. If a bug ever passed the wrong type, a member’s request would answer 500.

Name the ability and register it:

app/policies/abilities.go
package policies
const (
ViewUsers = "view-users"
CreateTask = "create-task"
UpdateTask = "update-task"
DeleteTask = "delete-task"
)
bootstrap/app.go
61 collapsed lines
package bootstrap
import (
"context"
"strings"
"github.com/goravel/framework/auth/access"
contractsaccess "github.com/goravel/framework/contracts/auth/access"
"github.com/goravel/framework/contracts/database/schema"
contractsfoundation "github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/foundation"
"github.com/goravel/framework/http/limit"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/models"
"taskflow/app/policies"
"taskflow/config"
"taskflow/routes"
)
func Boot() contractsfoundation.Application {
return foundation.Setup().
WithRules(Rules).
WithSeeders(Seeders).
WithMigrations(Migrations).
WithRouting(func() {
routes.Api()
routes.Web()
}).
WithProviders(Providers).
WithConfig(config.Boot).
WithCallback(func() {
facades.Schema().Extend(schema.Extension{
Models: []any{models.User{}, models.Task{}},
})
facades.RateLimiter().For("login", func(ctx http.Context) http.Limit {
email := strings.ToLower(strings.TrimSpace(ctx.Request().Input("email")))
return limit.PerMinute(5).By(email).Response(func(ctx http.Context) {
ctx.Response().Json(429, map[string]any{
"error": "Too many login attempts. Try again in a minute.",
}).Abort()
})
})
facades.Gate().Before(func(ctx context.Context, ability string, arguments map[string]any) contractsaccess.Response {
if user := authuser.From(ctx); user != nil && user.Role == enums.RoleManager {
return access.NewAllowResponse()
}
return nil
})
facades.Gate().Define(policies.ViewUsers, func(ctx context.Context, arguments map[string]any) contractsaccess.Response {
return access.NewDenyResponse("Only managers can see the users.")
})
taskPolicy := policies.NewTaskPolicy()
facades.Gate().Define(policies.CreateTask, taskPolicy.Create)
facades.Gate().Define(policies.UpdateTask, taskPolicy.Update)
facades.Gate().Define(policies.DeleteTask, taskPolicy.Delete)
}).
Create()
}

Update passes the task and the changes:

app/http/controllers/task_controller.go
178 collapsed lines
package controllers
import (
"errors"
"taskflow/app/enums"
"taskflow/app/facades"
"taskflow/app/http/authuser"
"taskflow/app/http/requests"
"taskflow/app/models"
"taskflow/app/policies"
"github.com/goravel/framework/contracts/http"
frameworkerrors "github.com/goravel/framework/errors"
"github.com/goravel/framework/validation"
)
type TaskController struct {
// Dependent services
}
func NewTaskController() *TaskController {
return &TaskController{
// Inject services
}
}
func (r *TaskController) Store(ctx http.Context) http.Response {
if response := facades.Gate().WithContext(ctx).Inspect(policies.CreateTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
var request requests.StoreTaskRequest
validationErrors, err := ctx.Request().ValidateRequest(&request)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validationErrors != nil {
return ctx.Response().Json(422, map[string]any{
"errors": validationErrors.All(),
})
}
task := models.Task{
CreatedBy: authuser.From(ctx).ID,
AssignedTo: request.AssignedTo,
Title: request.Title,
Description: request.Description,
Status: enums.StatusPending,
DueAt: request.DueAt,
}
if err := facades.Orm().WithContext(ctx).Query().Create(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(201, saved)
}
func (r *TaskController) Show(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().
With("Creator").
With("Assignee").
FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(200, task)
}
func (r *TaskController) Index(ctx http.Context) http.Response {
validator, err := ctx.Request().Validate(map[string]any{
"status": "in:pending,in-progress,done",
"page": "bail|filled|integer|min:1",
"per_page": "bail|filled|integer|min:1|max:100",
})
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
page := ctx.Request().QueryInt("page", 1)
perPage := ctx.Request().QueryInt("per_page", 15)
query := facades.Orm().WithContext(ctx).Query()
if status := ctx.Request().Query("status"); status != "" {
query = query.Where("status", status)
}
var tasks []models.Task
var total int64
err = query.With("Creator").With("Assignee").OrderByDesc("id").Paginate(page, perPage, &tasks, &total)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not list the tasks.",
})
}
return ctx.Response().Json(200, map[string]any{
"data": tasks,
"meta": map[string]any{
"page": page,
"per_page": perPage,
"total": total,
},
})
}
func (r *TaskController) Update(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
var request requests.UpdateTaskRequest
if err := request.Authorize(ctx); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
validator, err := ctx.Request().Validate(
request.Rules(ctx),
validation.Filters(request.Filters(ctx)),
validation.Attributes(request.Attributes(ctx)),
)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
if validator.Fails() {
return ctx.Response().Json(422, map[string]any{
"errors": validator.Errors().All(),
})
}
if changes := validator.Validated(); len(changes) > 0 {
changes := validator.Validated()
response := facades.Gate().WithContext(ctx).Inspect(policies.UpdateTask, map[string]any{
"task": &task,
"changes": changes,
})
if !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if len(changes) > 0 {
if _, err := facades.Orm().WithContext(ctx).Query().Model(&task).Update(changes); err != nil {
facades.Log().Error(err)
48 collapsed lines
return ctx.Response().Json(500, map[string]any{
"error": "Could not save the task.",
})
}
}
var saved models.Task
err = facades.Orm().WithContext(ctx).Query().With("Creator").With("Assignee").Find(&saved, task.ID)
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
return ctx.Response().Json(200, saved)
}
func (r *TaskController) Destroy(ctx http.Context) http.Response {
var task models.Task
err := facades.Orm().WithContext(ctx).Query().FindOrFail(&task, ctx.Request().RouteInt("id"))
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return ctx.Response().Json(404, map[string]any{
"error": "The task does not exist.",
})
}
if err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not load the task.",
})
}
if response := facades.Gate().WithContext(ctx).Inspect(policies.DeleteTask, nil); !response.Allowed() {
return ctx.Response().Json(403, map[string]any{
"error": response.Message(),
})
}
if _, err := facades.Orm().WithContext(ctx).Query().Delete(&task); err != nil {
facades.Log().Error(err)
return ctx.Response().Json(500, map[string]any{
"error": "Could not delete the task.",
})
}
return ctx.Response().NoContent()
}

The policy needs the validated changes, so it runs after the rules:

  1. Find the task, or answer 404.
  2. Check the body, or answer 422.
  3. Ask the policy, or answer 403.
  4. Save the changes.

Ben tries his own task 8, then task 2, which is assigned to someone else. Then Ana tries task 2:

Terminal window
curl -s -o /dev/null -w "%{http_code}\n" \
-X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $BEN" \
-H "Content-Type: application/json" \
-d '{"status":"in-progress"}'
200
curl -X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $BEN" \
-H "Content-Type: application/json" \
-d '{"title":"Ship v3.0"}'
{"error":"You can only change the status of a task."}
curl -X PATCH http://localhost:3000/api/v1/tasks/2 \
-H "Authorization: Bearer $BEN" \
-H "Content-Type: application/json" \
-d '{"status":"done"}'
{"error":"You can only change tasks assigned to you."}
curl -X PATCH http://localhost:3000/api/v1/tasks/8 \
-H "Authorization: Bearer $BEN" \
-H "Content-Type: application/json" \
-d '{"status":"finished"}'
{"errors":{"status":{"in":"The selected status is invalid."}}}
curl -s -o /dev/null -w "%{http_code}\n" \
-X PATCH http://localhost:3000/api/v1/tasks/2 \
-H "Authorization: Bearer $ANA" \
-H "Content-Type: application/json" \
-d '{"title":"Renamed by Ana","status":"done"}'
200
  • Ben’s status change on his own task goes through.
  • Renaming it, or touching task 2, gets a 403.
  • A bad status is still a 422, because the rules run before the policy.
  • Ana can change any task.
MoreAfter, Any and None
  • facades.Gate().After runs after every check. Its answer is used only when the check itself returned nil.
  • Any asks whether at least one of several abilities is allowed.
  • None asks whether none of them is.
MoreWhy not Authorize in the form request

Every form request has an Authorize method, and Validation with Form Requests left it returning nil. It runs before the handler loads the task, so it can’t check the update rule, which needs the task and the changes. The checks go in the handlers instead, where the task is already loaded. Store’s check could go in Authorize, but keeping every check in the handlers means there is one place to look.

Members can read every task, and change only the status of their own.