Policies
import "github.com/gp-system/gpsystem/policy"
RBAC is coarse-grained: it says whether the caller may perform the operation in general. A policy decides whether they may perform it on this particular request (are they the owner, are they in the same tenant, is the record in the right state). A role cannot express this: having the orders.view permission does not tell you whether they may view order 42.
The policy is attached to the operation by the TypeSpec contract, and the generated enforcer middleware enforces it: you cannot skip it, and a missing implementation does not let requests through: it errors (fail-closed).
A policy is a function
A policy is a named Func:
type Func func(ctx context.Context, id *rbac.Identity, req any) error
It receives the authenticated caller (*rbac.Identity) and, as req, the generated, fully-bound <Op>Request struct (body, query and path parameters together), which you unpack with a type assertion. It allows with nil and blocks with an error; policy.Deny(detail) is a 403 problem with a meaningful message:
func(ctx context.Context, id *rbac.Identity, req any) error {
r, ok := req.(shopapigen.GetOrderRequest)
if !ok {
return policy.Deny("unexpected request type")
}
// ... decide based on r and id
return nil // allowed
}
Policies live by name in a Registry:
reg := policy.NewRegistry()
reg.Register("orders.view", ordersViewFn) // registering the same name twice: panic
fn, ok := reg.Get("orders.view")
Register panics on a duplicate name: that is a wiring bug, not a runtime condition. Get is nil-receiver-safe: a nil Registry reports every name as unknown, which with the semantics below gives a fail-closed default.
The declarative chain: from contract to enforcer
You do not call the policy in the handler; you declare it in the contract, and it reaches the running middleware in four steps:
- TypeSpec: you put the
@permission("...")/@policy("...")decorators on the operation. The decorators come with the project scaffold (spec/typespec/lib/policy.tsp, imported bymain.tsp). - OpenAPI: the emitter writes them into the spec as
x-permission/x-policyextensions. - Generated code: the oapi-codegen templates record them per operation in the
PermissionByOperation/PolicyByOperationmaps. - Runtime: the
policy.FiberEnforcer/policy.HTTPEnforcerstrict middlewares consult those maps and run the named policy from theRegistry.
The order operations of the shop's api surface are tagged like this:
// spec/typespec/modules/shop/api.tsp
@post
@route("/orders")
@operationId("PlaceOrder")
@permission("orders.place") // coarse filter: do they hold the right
@policy("orders.place") // fine filter: are they ordering on their own behalf
placeOrder(@body body: PlaceOrderInput): Order | Unauthorized | Forbidden;
@get
@route("/orders/{orderId}")
@operationId("GetOrder")
@policy("orders.view") // owner or admin
getOrder(@path orderId: string): Order | NotFound | Unauthorized | Forbidden;
After mise run generate the maps appear in the generated package:
// internal/modules/shop/surfaces/api/http/gen (generated, do not edit)
var PermissionByOperation = map[string]string{
"PlaceOrder": "orders.place",
}
var PolicyByOperation = map[string]string{
"PlaceOrder": "orders.place",
"GetOrder": "orders.view",
}
The enforcer wiring is generated by add surface into the Register<Surface> function: there is nothing to wire by hand.
func RegisterApi(router fiber.Router, deps Dependencies) {
apiSvc := apiservice.New()
apiHandler := apihttp.New(apiSvc)
apiPolicies := apipolicy.New()
apiGroup := router.Group("/shop")
shopapigen.RegisterHandlersWithOptions(apiGroup, shopapigen.NewStrictHandler(apiHandler, []shopapigen.StrictMiddlewareFunc{
kitpolicy.FiberEnforcer[shopapigen.StrictHandlerFunc](apiPolicies, shopapigen.PermissionByOperation, shopapigen.PolicyByOperation),
}), shopapigen.FiberServerOptions{})
}
func RegisterApi(router chi.Router, deps Dependencies) {
apiSvc := apiservice.New()
apiHandler := apihttp.New(apiSvc)
apiPolicies := apipolicy.New()
router.Route("/shop", func(r chi.Router) {
shopapigen.HandlerWithOptions(shopapigen.NewStrictHandlerWithOptions(
apiHandler,
[]shopapigen.StrictMiddlewareFunc{
chix.StrictValidator[shopapigen.StrictHandlerFunc](nil),
// Last = outermost: authorization runs before validation.
kitpolicy.HTTPEnforcer[shopapigen.StrictHandlerFunc](apiPolicies, shopapigen.PermissionByOperation, shopapigen.PolicyByOperation),
},
shopapigen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: httperr.WriteBadRequest,
ResponseErrorHandlerFunc: httperr.WriteError,
},
), shopapigen.ChiServerOptions{
BaseRouter: r,
ErrorHandlerFunc: httperr.WriteBadRequest,
})
})
}
In the strict-middleware slice the last element is outermost, so the enforcer runs before the validators: the 401/403 precedes body validation, meaning a policy sees a decoded but not-yet-validated body. (The generic type parameter exists because oapi-codegen defines the StrictHandlerFunc type per generated package.)
api), make token parsing conditional; the pattern is shown on the authentication page; tagged operations without an identity get their 401 here, from the enforcer.Semantics, precisely
Per operation, the enforcer decides as follows (identically on both engines):
| Situation | Result |
|---|---|
the operation has neither @permission nor @policy | pass, no identity needed |
| tagged operation, no identity in the context | 401 authentication required |
@permission present but not held by the user | 403 insufficient privileges |
the policy returns an error (typically policy.Deny(...)) | that error goes out (Deny is 403, with the detail message) |
the @policy name is not registered in the Registry | fail-closed: plain error → 500 (server misconfiguration, not a client error) |
When both tags are on the operation, the permission runs first: the policy only runs once the right is held. An unregistered policy is deliberately not a 403: that is not the caller's fault but yours, and you want to notice it as a 500 (with a stack, with a Sentry alert), not as a silent denial.
The shop's OrderPolicy, end to end
add surface scaffolds a policy/ package with a registry stub for every surface: you only write the Register calls at the gpsystem:policies anchor. The shop's orders.view policy is the owner-or-admin rule; orders.place rules out placing an order on someone else's behalf:
// internal/modules/shop/surfaces/api/policy/policy.go
package policy
import (
"context"
kitpolicy "github.com/gp-system/gpsystem/policy"
"github.com/gp-system/gpsystem/rbac"
gen "github.com/acme/shop/internal/modules/shop/surfaces/api/http/gen"
"github.com/acme/shop/internal/modules/shop/repository"
)
func New(orders *repository.OrderRepo) *kitpolicy.Registry {
reg := kitpolicy.NewRegistry()
// gpsystem:policies
reg.Register("orders.view", func(ctx context.Context, id *rbac.Identity, req any) error {
if id.HasRole("admin") {
return nil
}
r, ok := req.(gen.GetOrderRequest)
if !ok {
return kitpolicy.Deny("unexpected request type")
}
order, err := orders.GetByID(ctx, r.OrderId) // DB access via closure
if err != nil {
return err
}
if order.UserID != id.Subject {
return kitpolicy.Deny("You can only view your own orders.")
}
return nil
})
reg.Register("orders.place", func(ctx context.Context, id *rbac.Identity, req any) error {
r, ok := req.(gen.PlaceOrderRequest)
if !ok {
return kitpolicy.Deny("unexpected request type")
}
if r.Body.CustomerId != id.Subject {
return kitpolicy.Deny("You can only place orders on your own behalf.")
}
return nil
})
return reg
}
The generated stub's New() takes no parameters; when a policy needs a repository (as here), extend the signature and adjust its single call site in register.go (apipolicy.New(orderRepo)). The Register<Surface> function is yours, the generator only wrote it at creation time.
This is how a GET /api/v1/shop/orders/42 plays out in each case:
| Caller | Path taken | Response |
|---|---|---|
| without a token | tagged operation, no identity | 401 problem |
| the owner of order 42 | orders.view → not admin → GetByID → UserID == Subject | 200, the handler runs |
| another logged-in user | same, but UserID != Subject → Deny | 403 You can only view your own orders. |
a user with the admin role | orders.view → HasRole("admin") → immediate allow | 200, the handler runs |
anyone, if orders.view is not registered | fail-closed error | 500 + stack, policy "orders.view" ... is not registered |
For PlaceOrder the same chain is one step longer: first the orders.place permission (not held → 403), and only then the orders.place policy.
A new operation with a policy
The add handler command generates an already-tagged operation into the contract via its --permission / --policy flags, and reminds you to register:
go tool gpsystem add handler shop api getOrder --method get --path "/orders/{orderId}" \
--policy "orders.view"
# ...
# # register policy "orders.view" in internal/modules/shop/surfaces/api/policy/policy.go
The rest of the chain: authentication (where the identity comes from), RBAC (role and permission checks), and the codegen pipeline (how .tsp becomes running code).