auth

Policy-k

Kérésenkénti, user-szintű engedélyezés a szerepek fölött: a kontraktusban deklarálva, generált kódból kikényszerítve.

Importáld a policy alcsomagot, hogy kérésenkénti authorizációs ellenőrzéseket deklarálj:

import "github.com/gp-system/auth/policy"

A policy az auth modul kérésenkénti authorizációs alcsomagja. Az RBAC durva szemcséjű: azt mondja meg, a hívó általában végrehajthatja-e a műveletet. A policy azt dönti el, hogy ezen a konkrét kérésen végrehajthatja-e (tulajdonos-e, egy tenantban vannak-e, megfelelő állapotban van-e a rekord). Erre a szerep nem elég: az, hogy valakinek van orders.view permissionje, nem mondja meg, hogy a 42-es rendelést megnézheti-e.

A policy-t a TypeSpec-kontraktus rendeli a művelethez, és a generált enforcer-middleware kényszeríti ki: nem tudod kihagyni, és a hiányzó implementáció nem átenged, hanem hibázik (fail-closed).

A policy mint függvény

Egy policy egy nevesített Func:

type Func func(ctx context.Context, id *rbac.Identity, req any) error

Megkapja a belépett hívót (*rbac.Identity) és req-ként a generált, teljesen bekötött <Op>Request structot (body, query- és path-paraméterek együtt), amit type-assertionnel bontasz ki. nil-lel engedélyez, hibával tilt; a policy.Deny(detail) egy *policy.Denial-t ad vissza, egy sima Go errort, amire errors.Is(err, rbac.ErrForbidden) igaz, és aminek a Detail-je biztonságosan megmutatható a kliensnek. A kit server.WriteError-ja 403-as problem-ként rendereli, ezzel az üzenettel:

func(ctx context.Context, id *rbac.Identity, req any) error {
    r, ok := req.(shopapigen.GetOrderRequest)
    if !ok {
        return policy.Deny("unexpected request type")
    }
    // ... döntés r és id alapján
    return nil // engedélyezve
}

A policy-k név szerint egy Registry-ben élnek:

reg := policy.NewRegistry()
reg.Register("orders.view", ordersViewFn) // ugyanarra a névre másodszor: panic
fn, ok := reg.Get("orders.view")

A Register duplikált névre panickel: az bekötési hiba, nem futásidejű állapot. A Get nil-receiver-biztos: egy nil Registry minden nevet ismeretlennek jelent, ami a lenti szemantikával fail-closed alapértelmezést ad.

A deklaratív lánc: kontraktustól az enforcerig

A policy-t nem a handlerben hívod, hanem a kontraktusban deklarálod, és négy lépésben jut el a futó middleware-ig:

  1. TypeSpec: a @permission("...") / @policy("...") dekorátorokat teszed a műveletre. A dekorátorokat a projekt-scaffold hozza (spec/typespec/lib/policy.tsp, a main.tsp importálja).
  2. OpenAPI: az emitter x-permission / x-policy extensionként írja ki őket a spec-be.
  3. Generált kód: az oapi-codegen sablonok műveletenként rögzítik őket a PermissionByOperation / PolicyByOperation mapekben.
  4. Futásidő: a policy.Enforcer strict-middleware ezekből a mapekből dolgozik, és a Registry-ből futtatja a nevesített policy-t.

Egy engine van és egy enforcer: az Enforcer chi/net-http strict-middleware-ként fut, nincs engine-specifikus változat, amik közül választani kellene.

A shop api surface-ének rendelés-műveletei így vannak megjelölve:

// spec/typespec/modules/shop/api.tsp
@post
@route("/orders")
@operationId("PlaceOrder")
@permission("orders.place")   // durva szűrés: megvan-e a jog
@policy("orders.place")       // finom szűrés: a saját nevében adja-e fel
placeOrder(@body body: PlaceOrderInput): Order | Unauthorized | Forbidden;

@get
@route("/orders/{orderId}")
@operationId("GetOrder")
@policy("orders.view")        // tulajdonos vagy admin
getOrder(@path orderId: string): Order | NotFound | Unauthorized | Forbidden;

mise run generate után a generált csomagban megjelennek a mapek:

// internal/modules/shop/surfaces/api/http/gen (generált, ne szerkeszd)
var PermissionByOperation = map[string]string{
    "PlaceOrder": "orders.place",
}

var PolicyByOperation = map[string]string{
    "PlaceOrder": "orders.place",
    "GetOrder":   "orders.view",
}

Az enforcer bekötését az add surface generálja a Register<Surface> függvénybe: kézzel semmit nem kell drótoznod.

internal/modules/shop/register.go
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{
                server.StrictValidator[shopapigen.StrictHandlerFunc](nil),
                // Az utolsó elem a legkülső: az authorizáció a validáció előtt fut.
                kitpolicy.Enforcer[shopapigen.StrictHandlerFunc](apiPolicies, shopapigen.PermissionByOperation, shopapigen.PolicyByOperation),
            },
            shopapigen.StrictHTTPServerOptions{
                RequestErrorHandlerFunc:  httperr.WriteBadRequest,
                ResponseErrorHandlerFunc: server.WriteError,
            },
        ), shopapigen.ChiServerOptions{
            BaseRouter:       r,
            ErrorHandlerFunc: httperr.WriteBadRequest,
        })
    })
}

(A kitpolicy itt a github.com/gp-system/auth/policy aliasa: a surface-en belüli generált policy/ csomag maga is policy névre hallgat, ezért a modul-importnak más lokális név kell az ütközés elkerüléséhez.)

A strict-middleware szeletben az utolsó elem a legkülső, ezért az enforcer a validátorok előtt fut: a 401/403 megelőzi a body-validációt, a policy tehát dekódolt, de még nem validált body-t lát. (A generikus típusparaméter azért kell, mert az oapi-codegen generált csomagonként definiálja a StrictHandlerFunc típust.)

Az enforcer csak ellenőrzi az identityt: előállítani az auth middleware állítja elő a Bearer tokenből. Vegyes surface-nél (publikus + védett műveletek, mint a shop api-ja) a token-parseolást tedd feltételessé, a mintát az autentikáció oldal mutatja; a taggelt műveletek identity nélkül itt, az enforcerben kapnak 401-et.

Szemantika, pontosan

Az enforcer műveletenként a következők szerint dönt:

HelyzetEredmény
a műveleten nincs se @permission, se @policyátengedés, identity sem kell
taggelt művelet, nincs identity a contextben401 authentication required
@permission van, de a user nem birtokolja403 insufficient privileges
a policy hibát ad (tipikusan policy.Deny(...))az a hiba megy ki (a Deny 403, a detail üzenettel)
a @policy név nincs regisztrálva a Registry-benfail-closed: sima error → 500 (szerver-félrekonfiguráció, nem kliens-hiba)

Ha mindkét tag rajta van a műveleten, a permission fut előbb: a policy már csak akkor, ha a jog megvan. A regisztrálatlan policy nem 403: az nem a hívó hibája, hanem a tiéd, és 500-ként (stackkel, Sentry-riasztással) akarod észrevenni, nem néma tiltásként.

A shop OrderPolicy végigjátszva

Az add surface minden surface-hez scaffoldol egy policy/ csomagot registry-stubbal: neked csak a Register hívásokat kell megírnod a gpsystem:policies anchornál. A shop orders.view policy-je a tulajdonos-vagy-admin szabály; az orders.place azt zárja ki, hogy valaki más nevében adj fel rendelést:

// internal/modules/shop/surfaces/api/policy/policy.go
package policy

import (
    "context"

    kitpolicy "github.com/gp-system/auth/policy"
    "github.com/gp-system/auth/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-hozzáférés closure-rel
        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
}

A generált stub New()-ja paraméter nélküli; ha a policy-nak repository kell (mint itt), bővítsd a szignatúrát, és igazítsd az egyetlen hívóhelyét a register.go-ban (apipolicy.New(orderRepo)). A Register<Surface> függvény a tiéd, a generátor csak létrehozáskor írta.

Így fut le egy GET /api/v1/shop/orders/42 az egyes esetekben:

HívóLefutásVálasz
token nélkültaggelt művelet, nincs identity401 problem
a 42-es rendelés gazdájaorders.view → nem admin → GetByIDUserID == Subject200, a handler fut
másik bejelentkezett userugyanez, de UserID != SubjectDeny403 You can only view your own orders.
admin szerepű userorders.viewHasRole("admin") → azonnali engedély200, a handler fut
bárki, ha az orders.view nincs regisztrálvafail-closed error500 + stack, policy "orders.view" ... is not registered

A PlaceOrder-nél ugyanez a lánc egy lépéssel hosszabb: előbb az orders.place permission (nincs meg → 403), és csak utána az orders.place policy.

Új művelet policy-val

Az add handler parancs --permission / --policy flagekkel eleve megjelölt műveletet generál a kontraktusba, és emlékeztet a regisztrációra:

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

A lánc többi állomása: autentikáció (honnan lesz identity), RBAC (szerep- és permission-ellenőrzés), és a codegen-pipeline (hogyan lesz a .tsp-ből futó kód).

Copyright © 2026