# Gates & Policies

> Managers create and assign, members update status.

Goravel series, part 12 of 20. By Krishan Kumar. Published 2026-10-25.
Source: https://devchalk.com/goravel/12-gates-policies
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-12

In [Task CRUD](https://devchalk.com/goravel/11-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`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403) that says why.

Start from the Task CRUD code:

```bash
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](https://www.goravel.dev/security/authorization#gates) and [policies](https://www.goravel.dev/security/authorization#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`.

```bash
$ 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:

```go title="app/policies/abilities.go" t1:viewusers=/(ViewUsers)/
package policies

const (
	ViewUsers = "view-users"
)
```

Register the gate in `bootstrap/app.go`, next to the login limit from [Auth Middleware & Rate Limits](https://devchalk.com/goravel/10-auth-middleware#limiting-login-attempts):

```diff lang="go" title="bootstrap/app.go" collapse={22-44} t1:viewusers=/policies[.](ViewUsers)/ t2:denied=/"(Only managers can see the users[.])"/
 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 {
+				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`](https://www.goravel.dev/security/authorization#writing-gates) 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 & Rate Limits](https://devchalk.com/goravel/10-auth-middleware#passing-the-user-to-handlers).
- `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:

```diff lang="go" title="app/http/controllers/user_controller.go" collapse={1-6} collapse={12-20} collapse={34-45} collapse={56-72} t1:viewusers=/policies[.](ViewUsers)/
 package controllers
 
 import (
 	"errors"
 
 	"taskflow/app/enums"
 	"taskflow/app/facades"
 	"taskflow/app/models"
+	"taskflow/app/policies"
 
 	"github.com/goravel/framework/contracts/database/orm"
 	"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)
 		}).
 		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().
 		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`](https://www.goravel.dev/security/authorization#gate-responses) 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)`](https://www.goravel.dev/security/authorization#inject-context) 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`:

```bash t2:denied=/"(Only managers can see the users[.])"/
$ 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:
>
> ```bash t2:denied=/"(Only managers can see the users[.])"/
> $ 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:
>
> ```bash
> $ 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`](https://www.goravel.dev/security/authorization#intercepting-gate-checks) checks it once, for every ability:

```diff lang="go" title="bootstrap/app.go" collapse={1-44} t2:before=/Gate[(][)][.](Before)[(]/
 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`](https://www.goravel.dev/security/authorization#generating-policies) starts it:

```bash
$ go run . artisan make:policy TaskPolicy
 SUCCESS  Policy created successfully
```

It writes `app/policies/task_policy.go`:

```go title="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:

```diff lang="go" title="app/policies/task_policy.go" t1:create=/[*]TaskPolicy[)] (Create)[(]/ t3:createdeny=/"(Only managers can create tasks[.])"/
 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:

```diff lang="go" title="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:

```diff lang="go" title="bootstrap/app.go" collapse={1-58} t1:create=/taskPolicy[.](Create)/
 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:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-8} collapse={14-26} collapse={37-214} collapse={227-233}
 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 {
 		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)
 		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}}`:

```diff lang="http" title="requests.http" collapse={1-4} collapse={38-105}
 ### 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);
 %}
 
 ### 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:

```bash t3:createdeny=/"(Only managers can create tasks[.])"/
$ 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:

```diff lang="go" title="app/policies/task_policy.go" collapse={10-20} t1:changes=/arguments."(changes)"/ t2:status=/field != "(status)"/ t3:assigned=/([*]task[.]AssignedTo != user[.]ID)/
 package policies
 
 import (
 	"context"
+
+	"taskflow/app/http/authuser"
+	"taskflow/app/models"
 
 	"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) 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:

```diff lang="go" title="app/policies/abilities.go"
 package policies
 
 const (
 	ViewUsers  = "view-users"
 	CreateTask = "create-task"
+	UpdateTask = "update-task"
 	DeleteTask = "delete-task"
 )
```

```diff lang="go" title="bootstrap/app.go" collapse={1-61}
 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:

```diff lang="go" title="app/http/controllers/task_controller.go" collapse={1-178} collapse={198-245} t1:changes=/"(changes)":/
 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)
 			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:

```bash t2:status=/(status) of a task/ t3:assigned=/(You can only change tasks assigned to you[.])/
$ 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.

After, Any and None:

- [`facades.Gate().After`](https://www.goravel.dev/security/authorization#intercepting-gate-checks) runs after every check. Its answer is used only when the check itself returned `nil`.
- [`Any`](https://www.goravel.dev/security/authorization#authorizing-actions) asks whether at least one of several abilities is allowed.
- [`None`](https://www.goravel.dev/security/authorization#authorizing-actions) asks whether none of them is.

Why not Authorize in the form request:

Every form request has an `Authorize` method, and [Validation with Form Requests](https://devchalk.com/goravel/08-validation-form-requests#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.

---

Previous: [11. Task CRUD](https://devchalk.com/goravel/11-task-crud.md)
Next: [13. Feature Testing](https://devchalk.com/goravel/13-feature-testing.md)
Series: [Goravel](https://devchalk.com/goravel.md)
