A role says "a user can read orders." But that doesn't mean they should read someone else's orders. Let's look at how to add a second line of defense — a resource ownership check.
The problem: RBAC is not enough
RBAC (Role-Based Access Control) answers the question "can this role call this endpoint." For example, the customer role is allowed to make GET /orders/{id}.
But what stops one customer from substituting the id of someone else's order and getting its data? Nothing — if we only check the role and not whose order it is. This vulnerability is called IDOR (Insecure Direct Object Reference): we swap the identifier in the URL and read someone else's data.
ABAC (Attribute-Based Access Control) adds a second layer: "this specific customer is allowed to read only their own orders." The check is built on attributes — fields of the object and data about the user.
How to get the user data
In Go, the user who made the request is described by the Principal struct. It appears in context.Context after the JWT middleware has validated the token:
// adapters/in/http/security/principal.go
type Principal struct {
Sub string // user identifier
Roles []string // roles: "customer", "admin", ...
}
func PrincipalFrom(ctx context.Context) *Principal {
p, _ := ctx.Value(principalKey{}).(*Principal)
return p
}
Important: Principal is always taken from the context — not manually from request headers. The middleware did that before you.
The AccessPolicy type: one place for the ownership logic
You shouldn't write the ownership check directly in the HTTP handler. If you copy it into every endpoint (GET, PATCH, DELETE), then any change to the rules requires updating N places, and it's easy to miss one.
The solution is to extract an AccessPolicy type into the domain layer, next to the aggregate:
// core/order/access.go
package order
import (
"github.com/example/app/core/apperr"
"github.com/example/app/adapters/in/http/security"
)
type AccessPolicy struct{}
func (p *AccessPolicy) CheckOwnership(order *Order, principal *security.Principal) error {
for _, role := range principal.Roles {
if role == "admin" {
return nil
}
}
if order.CustomerID != principal.Sub {
return &apperr.ForbiddenError{Resource: "order", ResourceID: order.ID}
}
return nil
}
The handler loads the aggregate from the database and passes it to the policy:
// core/order/handler/get_order.go
func (h *GetOrderHandler) Handle(ctx context.Context, cmd GetOrderCommand) (OrderView, error) {
o, err := h.repo.ByID(ctx, cmd.OrderID)
if err != nil {
return OrderView{}, fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}
principal := security.PrincipalFrom(ctx)
if err := h.policy.CheckOwnership(o, principal); err != nil {
return OrderView{}, err
}
return toView(o), nil
}
This approach fits read operations and simple cases: we compare one field, no complex business logic.
A check inside the Handler — for writes with locking
Write operations often load the aggregate under a lock (FOR UPDATE) to avoid concurrent modifications. Here the order is this: first we lock the row in the database, then we check the right.
// core/order/handler/cancel_order.go
func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
principal := security.PrincipalFrom(ctx)
o, err := h.repo.ByIDForUpdate(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}
if err := h.policy.CheckOwnership(o, principal); err != nil {
return err
}
if err := o.Cancel(cmd.Reason); err != nil {
return err
}
return h.repo.Save(ctx, o)
}
The same AccessPolicy is called inside the Handler — after the aggregate is in memory. The choice of approach depends on the complexity of the operation, not on where it's more convenient.
When to use which approach
| Situation | Where to do the check |
|---|---|
| Read, aggregate doesn't change | AccessPolicy in the Handler |
Write, FOR UPDATE needed | AccessPolicy inside the Handler after loading |
| Checking several aggregates | Inside the Handler |
The key point — don't duplicate: either AccessPolicy or a check in the Handler, not both at once. Duplication leads to logic diverging when the model changes.
A common mistake: a check in the controller
It's tempting to write the check directly in the HTTP handler — you already have access to both the request and the context there:
// Bad: a check in the HTTP layer
func (h *OrderHTTPHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
o, _ := h.repo.ByID(r.Context(), id)
p := security.PrincipalFrom(r.Context())
if o.CustomerID != p.Sub { // domain logic in the HTTP layer
httperr.Write(w, r, &apperr.ForbiddenError{Resource: "order", ResourceID: id})
return
}
}
The problem: the logic gets duplicated on every endpoint (GET, PATCH, DELETE), and the controller starts to know about domain rules. Adding a new rule (for example, shared access for several owners) would require changes in several places.
The right way: AccessPolicy lives in core/order/access.go, and the controller only calls the Handler.
Admin bypasses the check but leaves a trace
An administrator can work with any resource regardless of its owner — this is needed for support and fixing mistakes. That's why CheckOwnership lets admins through without checking CustomerID.
But every such action must be written to the journal:
if isAdmin(principal) {
_ = h.audit.Log(ctx, audit.LogEntry{
ActorID: principal.Sub,
OccurredAt: time.Now().UTC(),
Action: "order.cancel",
AggregateID: o.ID,
Metadata: map[string]any{"reason": cmd.Reason},
})
}
func isAdmin(p *security.Principal) bool {
for _, r := range p.Roles {
if r == "admin" {
return true
}
}
return false
}
The audit journal records: which administrator, when, and what they did with someone else's resource. Without it, transparency is lost — it becomes impossible to investigate incidents.
ForbiddenError as an error value
In Go, an access violation is expressed as a regular error, not a panic:
// core/apperr/forbidden.go
type ForbiddenError struct {
Resource string
ResourceID string
}
func (e *ForbiddenError) Error() string {
return fmt.Sprintf("forbidden: %s id=%s", e.Resource, e.ResourceID)
}
func (e *ForbiddenError) Kind() Kind { return Forbidden }
The error-handling layer (edge renderer) sees Kind() == Forbidden and responds with HTTP 403. There's no need to write w.WriteHeader(403) manually in the Handler or controller.
The same approach for different aggregates
The AccessPolicy structure is the same — only the field names change:
// core/product/access.go — a seller edits only their own products
func (p *AccessPolicy) CheckEditAccess(product *Product, principal *security.Principal) error {
for _, role := range principal.Roles {
if role == "admin" {
return nil
}
}
if product.SellerID != principal.Sub {
return &apperr.ForbiddenError{Resource: "product", ResourceID: product.ID}
}
return nil
}
// core/customer/access.go — a customer sees only their own profile
func (p *AccessPolicy) CheckProfileAccess(customer *Customer, principal *security.Principal) error {
for _, role := range principal.Roles {
if role == "admin" {
return nil
}
}
if customer.ID != principal.Sub {
return &apperr.ForbiddenError{Resource: "customer", ResourceID: customer.ID}
}
return nil
}
In short
- RBAC checks the role, ABAC checks the specific resource. Without ABAC, any customer reads someone else's orders (IDOR).
- The ownership check lives in
AccessPolicyinside the domain layer, not in the HTTP controller. - For simple read operations —
AccessPolicyis called in the Handler. For writes with locking — after loading the aggregate underFOR UPDATE. - An access violation is the error value
*ForbiddenError, not a panic. The edge renderer turns it into HTTP 403. - Admin passes without an owner check, but every such action is written to the audit journal.
- One
AccessPolicyper aggregate — don't copy the checks across controllers.
What to read next
- Auditing admin commands — a detailed look at the mandatory journal for admin overrides.
- RBAC: mapping roles — the layer before ABAC, middleware for role checks.
- JWT validation — how
Principalends up incontext.Context.