← Back to the section

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

SituationWhere to do the check
Read, aggregate doesn't changeAccessPolicy in the Handler
Write, FOR UPDATE neededAccessPolicy inside the Handler after loading
Checking several aggregatesInside 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 AccessPolicy inside the domain layer, not in the HTTP controller.
  • For simple read operations — AccessPolicy is called in the Handler. For writes with locking — after loading the aggregate under FOR 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 AccessPolicy per aggregate — don't copy the checks across controllers.
  • 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 Principal ends up in context.Context.