diff --git a/authz_test.go b/authz_test.go new file mode 100644 index 0000000..8c80b67 --- /dev/null +++ b/authz_test.go @@ -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) + } + } +} diff --git a/go.mod b/go.mod index 2ff1f3a..688b31a 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 4c9c307..3e39b7d 100644 --- a/go.sum +++ b/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= diff --git a/options.go b/options.go index 9dac80e..4e9a92d 100644 --- a/options.go +++ b/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)) + } +}