Policy-k
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:
- TypeSpec: a
@permission("...")/@policy("...")dekorátorokat teszed a műveletre. A dekorátorokat a projekt-scaffold hozza (spec/typespec/lib/policy.tsp, amain.tspimportálja). - OpenAPI: az emitter
x-permission/x-policyextensionként írja ki őket a spec-be. - Generált kód: az oapi-codegen sablonok műveletenként rögzítik őket a
PermissionByOperation/PolicyByOperationmapekben. - Futásidő: a
policy.Enforcerstrict-middleware ezekből a mapekből dolgozik, és aRegistry-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.
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.)
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:
| Helyzet | Eredmény |
|---|---|
a műveleten nincs se @permission, se @policy | átengedés, identity sem kell |
| taggelt művelet, nincs identity a contextben | 401 authentication required |
@permission van, de a user nem birtokolja | 403 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-ben | fail-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ás | Válasz |
|---|---|---|
| token nélkül | taggelt művelet, nincs identity | 401 problem |
| a 42-es rendelés gazdája | orders.view → nem admin → GetByID → UserID == Subject | 200, a handler fut |
| másik bejelentkezett user | ugyanez, de UserID != Subject → Deny | 403 You can only view your own orders. |
admin szerepű user | orders.view → HasRole("admin") → azonnali engedély | 200, a handler fut |
bárki, ha az orders.view nincs regisztrálva | fail-closed error | 500 + 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).