Policies: rules about a record
A permission says whether somebody may touch invoices at all. A policy says whether they may touch this invoice, now that the service has read it and can see that it is already paid. The rules live in internal/policies, one file per resource, and the generator writes and registers that file for you.
Three questions, three mechanisms
Authorization in a generated project is three layers, and the quickest way to put a check in the wrong one is not to know which answers what:
- May this user touch this resource at all? The permission catalogue, in
internal/permissions. Checked by the route's middleware, before the handler runs. No row has been read yet. - Is this their row?
--owned-byscoping, ininternal/authz, against the owner column. A row that is not theirs answers 404, because saying 403 would confirm it exists. - Is this row an exception? Policies, the subject of this page. Editable while it is a draft, approvable by anybody except the person who raised it, deletable only once archived. These need the record, so they run in the service, around the row it loaded.
authz.Inspect allows an ability nothing has defined a rule for. That is deliberate and it is the thing to hold on to: the route's permission has already said yes by the time a rule runs, so a policy is a narrowing, never a grant. Forgetting to write one leaves the project behaving exactly as it did before it had policies. If you want an ability that denies by default, write the rule that denies.Where a rule goes
grit generate resource Invoice writes apps/api/internal/policies/invoice.go with the rules commented out, registers it in internal/policies/policies.go, and makes main call policies.Register() before the server starts. You uncomment and edit. Re-running the generator to add a field does not rewrite the file: it is created once and then it is yours.
apps/api/internal/policies/├── policies.go Register(), called once from main. Blanket exemptions go here.├── invoice.go registerInvoices() <- your rules└── order.go registerOrders()
What a rule receives, and what it returns
A rule is a function of the actor and the record, returning a decision. The record arrives as any, because one registry holds the rules for every model, so the first thing a rule does is assert its own type:
type Rule func(actor Actor, record any) Decisionfunc Allow() Decision // let it throughfunc Deny(reason string) Decision // refuse, and say why
The reason is the part worth care. It reaches the user: a permission check can only say no, which is why a policy says why instead. Write it for the person who will read it, not for the log.
Actor is deliberately small, and knowing its two fields saves a compile error: UserID, and Admin. A rule receives no context.Context either, so it cannot read request-scoped facts like the active organisation. That is not an omission to work around: an organisation is a scoping question, and the tenancy callbacks have already narrowed the query before a rule ever sees a row. What a rule gets is who is asking and the row itself, which is what a rule about a record needs.
A worked example
Three rules on one resource, which is the shape people actually want. A draft is visible only to the person who raised it; only they may change it; and nobody at all may delete one that has been paid.
package policiesimport ("myapp/internal/authz""myapp/internal/models")func registerInvoices() {authz.Define("invoices.read", func(actor authz.Actor, record any) authz.Decision {item, ok := record.(*models.Invoice)if !ok {// Another model reached this rule, which is a wiring mistake and// not a refusal. Allow, and let the ability meant for it decide.return authz.Allow()}if item.Status != "draft" || item.RaisedByID == actor.UserID {return authz.Allow()}return authz.Deny("This invoice is still a draft")})authz.Define("invoices.update", func(actor authz.Actor, record any) authz.Decision {item, ok := record.(*models.Invoice)if !ok {return authz.Allow()}if item.RaisedByID == actor.UserID {return authz.Allow()}return authz.Deny("Only the person who raised this invoice can change it")})authz.Define("invoices.delete", func(actor authz.Actor, record any) authz.Decision {item, ok := record.(*models.Invoice)if !ok {return authz.Allow()}if item.PaidAt != nil {return authz.Deny("A paid invoice is kept. Credit it instead of deleting it.")}return authz.Allow()})}
Then register it, which the generator has already done if the resource was generated:
func Register() {registerInvoices()registerOrders()}
The three abilities, and which paths consult them
A generated service checks three abilities: read, update and delete, named <plural>.<ability>. Every write path resolves to one of them:
| Path | Ability | How |
|---|---|---|
| GET /:id | read | GetByID, after the row is loaded with its relations |
| PUT /:id | update | load(ctx, id, "update") |
| PATCH /:id | update | the same load |
| DELETE /:id | delete | load(ctx, id, "delete") |
| POST /bulk (delete) | delete | per row, through permitted() |
| POST /bulk (archive, restore, patch) | update | per row, through permitted() |
| POST /bulk-edit (the grid) | update | per row, through permitted() |
A list is not filtered by policy. Reads of a collection are scoped by ownership and by tenancy, not by a per-row rule, because a rule is a Go function and running it over a page means loading the page first, which breaks pagination counts. If a row should not appear in a list at all, that is an ownership or a tenancy question, and those have their own mechanisms.
Patch, Bulk and BulkEdit loaded rows with Find and wrote, so any rule here was evadable by selecting the rows and using a bulk action. And before that, policies.Register() had no caller at all, which meant every rule anybody wrote was registered into a map nothing filled. A page showing the loop end to end would not have worked, which is part of why this one exists.What a refusal looks like over HTTP
A refused decision becomes an authz.DeniedError on the way out of the service, which the generated handler turns into a 403 carrying the reason. Not a 500, and not the 404 that ownership gives: the caller is already allowed to see this row, so hiding it says nothing and withholds the reason, which is the thing the rule exists to provide.
{"error": {"code": "FORBIDDEN","message": "A paid invoice is kept. Credit it instead of deleting it."}}
A bulk action that any selected row fails is refused whole rather than partly applied, with a count of the rows that were refused. An action reporting twelve changes having made nine is worse than one that says no.
Blanket exemptions, and why an admin is not one
authz.Before runs ahead of every rule. Returning handled = true answers the question outright, which is where an exemption belongs so that it is written once rather than repeated in every rule:
authz.Before(func(actor authz.Actor, ability string, record any) (authz.Decision, bool) {if actor.Admin && strings.HasPrefix(ability, "drafts.") {return authz.Allow(), true}return authz.Decision{}, false})
An ADMIN is not exempt by default. It would be a one-line change and it is the wrong default: these rules are about the state of a record rather than the rank of a user, and "this invoice is already paid" is as true for an administrator as for anybody else. Add the exemption deliberately, for the abilities that want it.
Testing a rule
Rules are ordinary functions over a struct, so they test without a database or a server. authz.ResetForTest clears the registry, and authz.WithActor puts an actor on a context:
func TestAPaidInvoiceCannotBeDeleted(t *testing.T) {authz.ResetForTest()registerInvoices()paid := time.Now()ctx := authz.WithActor(context.Background(), authz.Actor{UserID: "u1"})d := authz.Inspect(ctx, "invoices.delete", &models.Invoice{PaidAt: &paid})if d.Allowed() {t.Fatal("a paid invoice was deletable")}if d.Reason() == "" {t.Error("the refusal gave no reason, so the user is told nothing")}}
authz.Abilities() returns every ability that has a rule, sorted. Printing it at startup, or asserting on it in a test, is the quickest way to find out that the rule you just wrote is not registered, which is the failure mode with no symptom: an unregistered rule does not error, it allows.
