feat: add WithAuthz Casbin authorisation middleware
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
e00ef00db8
commit
67dcc83a37
4 changed files with 256 additions and 0 deletions
222
authz_test.go
Normal file
222
authz_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/casbin/casbin/v2"
|
||||
"github.com/casbin/casbin/v2/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/go-api"
|
||||
)
|
||||
|
||||
// casbinModel is a minimal RESTful ACL model for testing authorisation.
|
||||
const casbinModel = `
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = r.sub == p.sub && keyMatch(r.obj, p.obj) && r.act == p.act
|
||||
`
|
||||
|
||||
// newTestEnforcer creates a Casbin enforcer from the inline model and adds
|
||||
// the given policies programmatically. Each policy is a [subject, object, action] triple.
|
||||
func newTestEnforcer(t *testing.T, policies [][3]string) *casbin.Enforcer {
|
||||
t.Helper()
|
||||
|
||||
m, err := model.NewModelFromString(casbinModel)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create casbin model: %v", err)
|
||||
}
|
||||
|
||||
e, err := casbin.NewEnforcer(m)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create casbin enforcer: %v", err)
|
||||
}
|
||||
|
||||
for _, p := range policies {
|
||||
if _, err := e.AddPolicy(p[0], p[1], p[2]); err != nil {
|
||||
t.Fatalf("failed to add policy %v: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// setBasicAuth sets the HTTP Basic Authentication header on a request.
|
||||
func setBasicAuth(req *http.Request, user, pass string) {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
// ── WithAuthz ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestWithAuthz_Good_AllowsPermittedRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
enforcer := newTestEnforcer(t, [][3]string{
|
||||
{"alice", "/stub/*", "GET"},
|
||||
})
|
||||
|
||||
e, _ := api.New(api.WithAuthz(enforcer))
|
||||
e.Register(&stubGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
||||
setBasicAuth(req, "alice", "secret")
|
||||
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for permitted request, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAuthz_Bad_DeniesUnpermittedRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Only alice is permitted; bob has no policy entry.
|
||||
enforcer := newTestEnforcer(t, [][3]string{
|
||||
{"alice", "/stub/*", "GET"},
|
||||
})
|
||||
|
||||
e, _ := api.New(api.WithAuthz(enforcer))
|
||||
e.Register(&stubGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
||||
setBasicAuth(req, "bob", "secret")
|
||||
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for unpermitted request, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAuthz_Good_DifferentMethodsEvaluatedSeparately(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// alice can GET but not DELETE.
|
||||
enforcer := newTestEnforcer(t, [][3]string{
|
||||
{"alice", "/stub/*", "GET"},
|
||||
})
|
||||
|
||||
e, _ := api.New(api.WithAuthz(enforcer))
|
||||
e.Register(&stubGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
|
||||
// GET should succeed.
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
||||
setBasicAuth(req, "alice", "secret")
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for GET, got %d", w.Code)
|
||||
}
|
||||
|
||||
// DELETE should be denied (no policy for DELETE).
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodDelete, "/stub/ping", nil)
|
||||
setBasicAuth(req, "alice", "secret")
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for DELETE, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAuthz_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
enforcer := newTestEnforcer(t, [][3]string{
|
||||
{"alice", "/stub/*", "GET"},
|
||||
})
|
||||
|
||||
e, _ := api.New(
|
||||
api.WithRequestID(),
|
||||
api.WithAuthz(enforcer),
|
||||
)
|
||||
e.Register(&stubGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
||||
setBasicAuth(req, "alice", "secret")
|
||||
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Both authz (allowed) and request ID should be active.
|
||||
if w.Header().Get("X-Request-ID") == "" {
|
||||
t.Fatal("expected X-Request-ID header from WithRequestID")
|
||||
}
|
||||
}
|
||||
|
||||
// casbinWildcardModel extends the base model with a matcher that treats
|
||||
// "*" as a wildcard subject, allowing any authenticated user through.
|
||||
const casbinWildcardModel = `
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = (r.sub == p.sub || p.sub == "*") && keyMatch(r.obj, p.obj) && r.act == p.act
|
||||
`
|
||||
|
||||
func TestWithAuthz_Ugly_WildcardPolicyAllowsAll(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Use a model whose matcher treats "*" as a wildcard subject.
|
||||
m, err := model.NewModelFromString(casbinWildcardModel)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create casbin model: %v", err)
|
||||
}
|
||||
|
||||
enforcer, err := casbin.NewEnforcer(m)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create casbin enforcer: %v", err)
|
||||
}
|
||||
|
||||
if _, err := enforcer.AddPolicy("*", "/stub/*", "GET"); err != nil {
|
||||
t.Fatalf("failed to add wildcard policy: %v", err)
|
||||
}
|
||||
|
||||
e, _ := api.New(api.WithAuthz(enforcer))
|
||||
e.Register(&stubGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
|
||||
// Any user should be allowed by the wildcard policy.
|
||||
for _, user := range []string{"alice", "bob", "charlie"} {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
||||
setBasicAuth(req, user, "secret")
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for user %q with wildcard policy, got %d", user, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
5
go.mod
5
go.mod
|
|
@ -4,7 +4,9 @@ go 1.25.5
|
|||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/casbin/casbin/v2 v2.135.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/gin-contrib/authz v1.0.6
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
github.com/gin-contrib/secure v1.1.2
|
||||
|
|
@ -23,9 +25,11 @@ require (
|
|||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
|
|
@ -39,6 +43,7 @@ require (
|
|||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
|
|
|
|||
16
go.sum
16
go.sum
|
|
@ -6,12 +6,20 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV
|
|||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
|
|
@ -22,6 +30,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+5k=
|
||||
github.com/gin-contrib/authz v1.0.6/go.mod h1:A2B5Im1M/HIoHPjLc31j3RlENSE6j8euJY9NFdzZeYo=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
|
|
@ -64,11 +74,15 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
|
|
@ -150,6 +164,7 @@ golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvm
|
|||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
|
|
@ -186,6 +201,7 @@ golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
|||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
|
|
|
|||
13
options.go
13
options.go
|
|
@ -8,6 +8,8 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/casbin/casbin/v2"
|
||||
"github.com/gin-contrib/authz"
|
||||
"github.com/gin-contrib/cors"
|
||||
gingzip "github.com/gin-contrib/gzip"
|
||||
"github.com/gin-contrib/secure"
|
||||
|
|
@ -219,3 +221,14 @@ func WithSessions(name string, secret []byte) Option {
|
|||
e.middlewares = append(e.middlewares, sessions.Sessions(name, store))
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthz adds Casbin policy-based authorisation middleware via
|
||||
// gin-contrib/authz. The caller provides a pre-configured Casbin enforcer
|
||||
// holding the desired model and policy rules. The middleware extracts the
|
||||
// subject from HTTP Basic Authentication, evaluates it against the request
|
||||
// method and path, and returns 403 Forbidden when the policy denies access.
|
||||
func WithAuthz(enforcer *casbin.Enforcer) Option {
|
||||
return func(e *Engine) {
|
||||
e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue