diff --git a/api/adapter.go b/api/adapter.go index 08a9959..646f881 100644 --- a/api/adapter.go +++ b/api/adapter.go @@ -4,3 +4,10 @@ import "net/http" // Adapter to encapsulate incoming requests type Adapter func(http.HandlerFunc) http.HandlerFunc + +// AuthHandlerFunc is http.HandlerFunc with additional Authorization information +type AuthHandlerFunc func(Auth, http.ResponseWriter, *http.Request) + +// AuthAdapter to encapsulate incoming request with access to api.Auth +// to manage permissions +type AuthAdapter func(AuthHandlerFunc) AuthHandlerFunc diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 0000000..ff2699a --- /dev/null +++ b/api/auth.go @@ -0,0 +1,62 @@ +package api + +// Auth can be used by http middleware to +// 1) consult required roles in @Auth.Required +// 2) update active roles in @Auth.Active +type Auth struct { + // required roles for this request + // - the first dimension of the array reads as a OR + // - the second dimension reads as a AND + // + // Example: + // [ [A, B], [C, D] ] reads: roles (A and B) or (C and D) are required + // + // Warning: must not be mutated + Required [][]string + + // active roles to be updated by authentication + // procedures (e.g. jwt) + Active []string +} + +// Granted returns whether the authorization is granted +// i.e. Auth.Active fulfills Auth.Required +func (a Auth) Granted() bool { + var nothingRequired = true + + // first dimension: OR ; at least one is valid + for _, required := range a.Required { + // empty list + if len(required) < 1 { + continue + } + + nothingRequired = false + + // second dimension: AND ; all required must be fulfilled + if a.fulfills(required) { + return true + } + } + + return nothingRequired +} + +// returns whether Auth.Active fulfills (contains) all @required roles +func (a Auth) fulfills(required []string) bool { + for _, requiredRole := range required { + var found = false + for _, activeRole := range a.Active { + if activeRole == requiredRole { + found = true + break + } + } + // missing role -> fail + if !found { + return false + } + } + // all @required are fulfilled + return true +} diff --git a/api/auth_test.go b/api/auth_test.go new file mode 100644 index 0000000..a230b59 --- /dev/null +++ b/api/auth_test.go @@ -0,0 +1,108 @@ +package api + +import ( + "testing" +) + +func TestCombination(t *testing.T) { + tcases := []struct { + Name string + Required [][]string + Active []string + Granted bool + }{ + { + Name: "no requirement none given", + Required: [][]string{}, + Active: []string{}, + Granted: true, + }, + { + Name: "no requirement 1 given", + Required: [][]string{}, + Active: []string{"a"}, + Granted: true, + }, + { + Name: "no requirement some given", + Required: [][]string{}, + Active: []string{"a", "b"}, + Granted: true, + }, + + { + Name: "1 required none given", + Required: [][]string{{"a"}}, + Active: []string{}, + Granted: false, + }, + { + Name: "1 required fulfilled", + Required: [][]string{{"a"}}, + Active: []string{"a"}, + Granted: true, + }, + { + Name: "1 required mismatch", + Required: [][]string{{"a"}}, + Active: []string{"b"}, + Granted: false, + }, + { + Name: "2 required none gien", + Required: [][]string{{"a", "b"}}, + Active: []string{}, + Granted: false, + }, + { + Name: "2 required other given", + Required: [][]string{{"a", "b"}}, + Active: []string{"c"}, + Granted: false, + }, + { + Name: "2 required one given", + Required: [][]string{{"a", "b"}}, + Active: []string{"a"}, + Granted: false, + }, + { + Name: "2 required fulfilled", + Required: [][]string{{"a", "b"}}, + Active: []string{"a", "b"}, + Granted: true, + }, + { + Name: "2 or 2 required first fulfilled", + Required: [][]string{{"a", "b"}, {"c", "d"}}, + Active: []string{"a", "b"}, + Granted: true, + }, + { + Name: "2 or 2 required second fulfilled", + Required: [][]string{{"a", "b"}, {"c", "d"}}, + Active: []string{"c", "d"}, + Granted: true, + }, + } + + for _, tcase := range tcases { + t.Run(tcase.Name, func(t *testing.T) { + + auth := Auth{ + Required: tcase.Required, + Active: tcase.Active, + } + + // all right + if tcase.Granted == auth.Granted() { + return + } + + if tcase.Granted && !auth.Granted() { + t.Fatalf("expected granted authorization") + } + t.Fatalf("unexpected granted authorization") + }) + } +} diff --git a/builder.go b/builder.go index 224ce7c..15d94e4 100644 --- a/builder.go +++ b/builder.go @@ -13,9 +13,10 @@ import ( // Builder for an aicra server type Builder struct { - conf *config.Server - handlers []*apiHandler - adapters []api.Adapter + conf *config.Server + handlers []*apiHandler + adapters []api.Adapter + authAdapters []api.AuthAdapter } // represents an api handler (method-pattern combination) @@ -40,8 +41,8 @@ func (b *Builder) AddType(t datatype.T) error { return nil } -// Use adds an http adapter (middleware) -func (b *Builder) Use(adapter api.Adapter) { +// With adds an http adapter (middleware) +func (b *Builder) With(adapter api.Adapter) { if b.conf == nil { b.conf = &config.Server{} } @@ -51,6 +52,17 @@ func (b *Builder) Use(adapter api.Adapter) { b.adapters = append(b.adapters, adapter) } +// WithAuth adds an http adapter with auth capabilities (middleware) +func (b *Builder) WithAuth(adapter api.AuthAdapter) { + if b.conf == nil { + b.conf = &config.Server{} + } + if b.authAdapters == nil { + b.authAdapters = make([]api.AuthAdapter, 0) + } + b.authAdapters = append(b.authAdapters, adapter) +} + // Setup the builder with its api definition file // panics if already setup func (b *Builder) Setup(r io.Reader) error { diff --git a/builder_test.go b/builder_test.go index 8e3358d..ab98dca 100644 --- a/builder_test.go +++ b/builder_test.go @@ -1,12 +1,8 @@ package aicra import ( - "bytes" - "context" "errors" - "fmt" "net/http" - "net/http/httptest" "strings" "testing" @@ -52,92 +48,6 @@ func TestAddType(t *testing.T) { } } -func TestUse(t *testing.T) { - builder := &Builder{} - if err := addBuiltinTypes(builder); err != nil { - t.Fatalf("unexpected error <%v>", err) - } - - // build @n middlewares that take data from context and increment it - n := 1024 - - type ckey int - const key ckey = 0 - - middleware := func(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - newr := r - - // first time -> store 1 - value := r.Context().Value(key) - if value == nil { - newr = r.WithContext(context.WithValue(r.Context(), key, int(1))) - next(w, newr) - return - } - - // get value and increment - cast, ok := value.(int) - if !ok { - t.Fatalf("value is not an int") - } - cast++ - newr = r.WithContext(context.WithValue(r.Context(), key, cast)) - next(w, newr) - } - } - - // add middleware @n times - for i := 0; i < n; i++ { - builder.Use(middleware) - } - - config := strings.NewReader(`[ { "method": "GET", "path": "/path", "scope": [[]], "info": "info", "in": {}, "out": {} } ]`) - err := builder.Setup(config) - if err != nil { - t.Fatalf("setup: unexpected error <%v>", err) - } - - pathHandler := func(ctx api.Ctx) (*struct{}, api.Err) { - // write value from middlewares into response - value := ctx.Req.Context().Value(key) - if value == nil { - t.Fatalf("nothing found in context") - } - cast, ok := value.(int) - if !ok { - t.Fatalf("cannot cast context data to int") - } - // write to response - ctx.Res.Write([]byte(fmt.Sprintf("#%d#", cast))) - - return nil, api.ErrSuccess - } - - if err := builder.Bind(http.MethodGet, "/path", pathHandler); err != nil { - t.Fatalf("bind: unexpected error <%v>", err) - } - - handler, err := builder.Build() - if err != nil { - t.Fatalf("build: unexpected error <%v>", err) - } - - response := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, "/path", &bytes.Buffer{}) - - // test request - handler.ServeHTTP(response, request) - if response.Body == nil { - t.Fatalf("response has no body") - } - token := fmt.Sprintf("#%d#", n) - if !strings.Contains(response.Body.String(), token) { - t.Fatalf("expected '%s' to be in response <%s>", token, response.Body.String()) - } - -} - func TestBind(t *testing.T) { tcases := []struct { Name string diff --git a/handler.go b/handler.go index 662daf3..95c478c 100644 --- a/handler.go +++ b/handler.go @@ -13,7 +13,7 @@ type Handler Builder // ServeHTTP implements http.Handler and wraps it in middlewares (adapters) func (s Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - var h = http.HandlerFunc(s.handleRequest) + var h = http.HandlerFunc(s.resolve) for _, adapter := range s.adapters { h = adapter(h) @@ -21,7 +21,7 @@ func (s Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h(w, r) } -func (s Handler) handleRequest(w http.ResponseWriter, r *http.Request) { +func (s Handler) resolve(w http.ResponseWriter, r *http.Request) { // 1. find a matching service from config var service = s.conf.Find(r) if service == nil { @@ -50,6 +50,29 @@ func (s Handler) handleRequest(w http.ResponseWriter, r *http.Request) { return } + var auth = api.Auth{ + Required: service.Scope, + Active: []string{}, + } + + // 5. run auth-aware middlewares + var h = api.AuthHandlerFunc(func(a api.Auth, w http.ResponseWriter, r *http.Request) { + if !a.Granted() { + handleError(api.ErrPermission, w, r) + return + } + + s.handle(input, handler, service, w, r) + }) + + for _, adapter := range s.authAdapters { + h = adapter(h) + } + h(auth, w, r) + +} + +func (s *Handler) handle(input *reqdata.T, handler *apiHandler, service *config.Service, w http.ResponseWriter, r *http.Request) { // 5. pass execution to the handler ctx := api.Ctx{Res: w, Req: r} var outData, outErr = handler.dyn.Handle(ctx, input.Data) diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..a888a2e --- /dev/null +++ b/handler_test.go @@ -0,0 +1,241 @@ +package aicra + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.xdrm.io/go/aicra/api" +) + +func TestWith(t *testing.T) { + builder := &Builder{} + if err := addBuiltinTypes(builder); err != nil { + t.Fatalf("unexpected error <%v>", err) + } + + // build @n middlewares that take data from context and increment it + n := 1024 + + type ckey int + const key ckey = 0 + + middleware := func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + newr := r + + // first time -> store 1 + value := r.Context().Value(key) + if value == nil { + newr = r.WithContext(context.WithValue(r.Context(), key, int(1))) + next(w, newr) + return + } + + // get value and increment + cast, ok := value.(int) + if !ok { + t.Fatalf("value is not an int") + } + cast++ + newr = r.WithContext(context.WithValue(r.Context(), key, cast)) + next(w, newr) + } + } + + // add middleware @n times + for i := 0; i < n; i++ { + builder.With(middleware) + } + + config := strings.NewReader(`[ { "method": "GET", "path": "/path", "scope": [[]], "info": "info", "in": {}, "out": {} } ]`) + err := builder.Setup(config) + if err != nil { + t.Fatalf("setup: unexpected error <%v>", err) + } + + pathHandler := func(ctx api.Ctx) (*struct{}, api.Err) { + // write value from middlewares into response + value := ctx.Req.Context().Value(key) + if value == nil { + t.Fatalf("nothing found in context") + } + cast, ok := value.(int) + if !ok { + t.Fatalf("cannot cast context data to int") + } + // write to response + ctx.Res.Write([]byte(fmt.Sprintf("#%d#", cast))) + + return nil, api.ErrSuccess + } + + if err := builder.Bind(http.MethodGet, "/path", pathHandler); err != nil { + t.Fatalf("bind: unexpected error <%v>", err) + } + + handler, err := builder.Build() + if err != nil { + t.Fatalf("build: unexpected error <%v>", err) + } + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/path", &bytes.Buffer{}) + + // test request + handler.ServeHTTP(response, request) + if response.Body == nil { + t.Fatalf("response has no body") + } + token := fmt.Sprintf("#%d#", n) + if !strings.Contains(response.Body.String(), token) { + t.Fatalf("expected '%s' to be in response <%s>", token, response.Body.String()) + } + +} + +func TestWithAuth(t *testing.T) { + + tt := []struct { + name string + manifest string + permissions []string + granted bool + }{ + { + name: "provide only requirement A", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{"A"}, + granted: true, + }, + { + name: "missing requirement", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{}, + granted: false, + }, + { + name: "missing requirements", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{}, + granted: false, + }, + { + name: "missing some requirements", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{"A"}, + granted: false, + }, + { + name: "provide requirements", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{"A", "B"}, + granted: true, + }, + { + name: "missing OR requirements", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"], ["B"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{"C"}, + granted: false, + }, + { + name: "provide 1 OR requirement", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"], ["B"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{"A"}, + granted: true, + }, + { + name: "provide both OR requirements", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A"], ["B"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{"A", "B"}, + granted: true, + }, + { + name: "missing composite OR requirements", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{}, + granted: false, + }, + { + name: "missing partial composite OR requirements", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{"A", "C"}, + granted: false, + }, + { + name: "provide 1 composite OR requirement", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{"A", "B", "C"}, + granted: true, + }, + { + name: "provide both composite OR requirements", + manifest: `[ { "method": "GET", "path": "/path", "scope": [["A", "B"], ["C", "D"]], "info": "info", "in": {}, "out": {} } ]`, + permissions: []string{"A", "B", "C", "D"}, + granted: true, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + builder := &Builder{} + if err := addBuiltinTypes(builder); err != nil { + t.Fatalf("unexpected error <%v>", err) + } + + // tester middleware (last executed) + builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc { + return func(a api.Auth, w http.ResponseWriter, r *http.Request) { + if a.Granted() == tc.granted { + return + } + if a.Granted() { + t.Fatalf("unexpected granted auth") + } else { + t.Fatalf("expected granted auth") + } + } + }) + + builder.WithAuth(func(next api.AuthHandlerFunc) api.AuthHandlerFunc { + return func(a api.Auth, w http.ResponseWriter, r *http.Request) { + a.Active = tc.permissions + next(a, w, r) + } + }) + + err := builder.Setup(strings.NewReader(tc.manifest)) + if err != nil { + t.Fatalf("setup: unexpected error <%v>", err) + } + + pathHandler := func(ctx api.Ctx) (*struct{}, api.Err) { + return nil, api.ErrNotImplemented + } + + if err := builder.Bind(http.MethodGet, "/path", pathHandler); err != nil { + t.Fatalf("bind: unexpected error <%v>", err) + } + + handler, err := builder.Build() + if err != nil { + t.Fatalf("build: unexpected error <%v>", err) + } + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/path", &bytes.Buffer{}) + + // test request + handler.ServeHTTP(response, request) + if response.Body == nil { + t.Fatalf("response has no body") + } + + }) + } + +}