Authorization
Authorization in gin-kit is explicit and allowlist-shaped: policies are plain
structs in internal/policy whose methods return an authz.Decision, and
nothing is granted implicitly. Runtime projects import
runtime/authz; standalone projects vendor the same package as
internal/platform/authz. The HTTP contract is identical in both project types.
The Decision contract
Section titled “The Decision contract”type Decision struct { Allowed bool Reason string // internal; logged, never serialized}
authz.Allow() // grant the actionauthz.Deny("not the owner") // reject it with an internal reasonThe zero value denies, so a forgotten rule fails closed. A denied decision
always renders the same envelope — status 403 with the stable code
forbidden and the message “You are not allowed to perform this action.”
The deny reason exists for operators: it is logged and carried as the error
cause, and never reaches the response body.
Generating a policy
Section titled “Generating a policy”gin-kit generate policy Ticketwrites internal/policy/ticket_policy.go with CanView, CanCreate,
CanUpdate, and CanDelete methods plus a table test. Each method ships a
placeholder rule (deny empty subjects, allow everyone else) and a comment
telling you to replace it with the real ownership or role check. The policy
references domain.Ticket, so generate the domain first if it is missing.
The generator works in both project types; standalone projects scaffolded before the
authz package existed get internal/platform/authz back-filled
automatically.
Enforcing in handlers
Section titled “Enforcing in handlers”authz.Authorize returns true and writes nothing when the decision
allows. When it denies, it logs the internal reason, writes the canonical
403 forbidden envelope, aborts the request, and returns false:
p := policy.NewTicketPolicy()if !authz.Authorize(c, p.CanUpdate(c.Request.Context(), auth.UserID(c), ticket)) { return}Standalone projects read the subject from wherever their middleware stored it:
if !authz.Authorize(c, p.CanUpdate(c.Request.Context(), c.GetString("user_id"), ticket)) { return}Denying from services
Section titled “Denying from services”When the check belongs in the service layer, convert the decision into an
error with Decision.Err(). It returns nil when allowed; otherwise a
*httpx.Error that httpx.DefaultMapper passes through unchanged, so a
denied service call renders the same 403 forbidden envelope with the
reason preserved as the wrapped cause for logs:
func (s *TicketService) Update(ctx context.Context, subjectID, id string, request dto.UpdateTicketRequest) (domain.Ticket, error) { ticket, err := s.repository.Find(ctx, id) if err != nil { return domain.Ticket{}, err } if err := s.policy.CanUpdate(ctx, subjectID, ticket).Err(); err != nil { return domain.Ticket{}, err } // ...}Works without authentication
Section titled “Works without authentication”Policies do not require the --auth vertical — the subject is just a
string. With authentication enabled, use
auth.UserID(c) (Runtime) or the user_id context value (Standalone);
without it, pass an API-key ID, a tenant, or whatever identifier your own
middleware resolves. The generated placeholder rules deny empty subjects
either way.

