diff --git a/CHANGELOG.md b/CHANGELOG.md index ce6f0b583..ef7f4fb4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Unreleased changes are available as `avenga/couper:edge` container. * `Accept: application/json` request header to the OAuth2 token request, in order to make the Github token endpoint respond with a JSON token response ([#307](https://github.com/avenga/couper/pull/307)) * Documentation of [logs](docs/LOGS.md) ([#310](https://github.com/avenga/couper/pull/310)) * `signing_ttl` and `signing_key`/`signing_key_file` to [`jwt` block](./docs/REFERENCE.md#jwt-block) for use with [`jwt_sign()` function](#functions) ([#309](https://github.com/avenga/couper/pull/309)) + * `beta_scope_claim` attribute to [`jwt` block](./docs/REFERENCE.md#jwt-block); `beta_scope` attribute to [`api`](./docs/REFERENCE.md#api-block) and [`endpoint` block](./docs/REFERENCE.md#endpoint-block)s; [error types](./docs/ERRORS.md#error-types) `beta_operation_denied` and `beta_insufficient_scope` ([#315](https://github.com/avenga/couper/pull/315)) * **Changed** * Organized log format fields for uniform access and upstream log ([#300](https://github.com/avenga/couper/pull/300)) diff --git a/accesscontrol/jwt.go b/accesscontrol/jwt.go index b1cc2f4aa..8b752811b 100644 --- a/accesscontrol/jwt.go +++ b/accesscontrol/jwt.go @@ -16,7 +16,7 @@ import ( acjwt "github.com/avenga/couper/accesscontrol/jwt" "github.com/avenga/couper/config/request" "github.com/avenga/couper/errors" - "github.com/avenga/couper/eval/content" + "github.com/avenga/couper/eval" "github.com/avenga/couper/internal/seetie" ) @@ -44,6 +44,7 @@ type JWT struct { hmacSecret []byte name string pubKey *rsa.PublicKey + scopeClaim string } type JWTOptions struct { @@ -51,6 +52,7 @@ type JWTOptions struct { Claims hcl.Expression ClaimsRequired []string Name string // TODO: more generic (validate) + ScopeClaim string Source JWTSource Key []byte } @@ -82,6 +84,7 @@ func NewJWT(options *JWTOptions) (*JWT, error) { claims: options.Claims, claimsRequired: options.ClaimsRequired, name: options.Name, + scopeClaim: options.ScopeClaim, source: options.Source, } @@ -109,14 +112,6 @@ func NewJWT(options *JWTOptions) (*JWT, error) { // Validate reading the token from configured source and validates against the key. func (j *JWT) Validate(req *http.Request) error { - ctx := req.Context() - cctx := ctx.Value(request.ContextType).(content.Context) - evalCtx := cctx.HCLContext() - claims, diags := seetie.ExpToMap(evalCtx, j.claims) - if diags != nil { - return diags - } - var tokenValue string var err error @@ -143,6 +138,15 @@ func (j *JWT) Validate(req *http.Request) error { return errors.JwtTokenMissing.Message("token required") } + claims := make(map[string]interface{}) + var diags hcl.Diagnostics + if j.claims != nil { + claims, diags = seetie.ExpToMap(eval.ContextFromRequest(req).HCLContext(), j.claims) + if diags != nil { + return diags + } + } + parser, err := newParser(j.algorithm, claims) if err != nil { return err @@ -163,13 +167,30 @@ func (j *JWT) Validate(req *http.Request) error { return err } + ctx := req.Context() acMap, ok := ctx.Value(request.AccessControls).(map[string]interface{}) if !ok { acMap = make(map[string]interface{}) } acMap[j.name] = tokenClaims - ctx = context.WithValue(ctx, request.AccessControls, acMap) + + scopesValues, err := j.getScopeValues(tokenClaims) + if err != nil { + return err + } + + if len(scopesValues) > 0 { + scopes, ok := ctx.Value(request.Scopes).([]string) + if !ok { + scopes = []string{} + } + for _, sc := range scopesValues { + scopes = append(scopes, sc) + } + ctx = context.WithValue(ctx, request.Scopes, scopes) + } + *req = *req.WithContext(ctx) return nil @@ -220,6 +241,36 @@ func (j *JWT) validateClaims(token *jwt.Token, claims map[string]interface{}) (m return tokenClaims, nil } +func (j *JWT) getScopeValues(tokenClaims map[string]interface{}) ([]string, error) { + if j.scopeClaim == "" { + return []string{}, nil + } + scopesFromClaim, exists := tokenClaims[j.scopeClaim] + if !exists { + return nil, fmt.Errorf("Missing expected scope claim %q", j.scopeClaim) + } + + scopeValues := []string{} + // ["foo", "bar"] is stored as []interface{}, not []string, unfortunately + scopesArray, ok := scopesFromClaim.([]interface{}) + if ok { + for _, v := range scopesArray { + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("value of scope claim must either be a string containing a space-separated list of scope values or a list of string scope values") + } + scopeValues = append(scopeValues, s) + } + } else { + scopesString, ok := scopesFromClaim.(string) + if !ok { + return nil, fmt.Errorf("value of scope claim must either be a string containing a space-separated list of scope values or a list of string scope values") + } + scopeValues = strings.Split(scopesString, " ") + } + return scopeValues, nil +} + func getBearer(val string) (string, error) { const bearer = "bearer " if strings.HasPrefix(strings.ToLower(val), bearer) { diff --git a/accesscontrol/jwt_test.go b/accesscontrol/jwt_test.go index 94813de71..a97de1bf4 100644 --- a/accesscontrol/jwt_test.go +++ b/accesscontrol/jwt_test.go @@ -182,27 +182,27 @@ func Test_JWT_Validate(t *testing.T) { algorithm: algo, source: ac.NewJWTSource("", "Authorization"), pubKey: pubKeyBytes, - }, setContext(httptest.NewRequest(http.MethodGet, "/", nil)), true}, + }, httptest.NewRequest(http.MethodGet, "/", nil), true}, {"src: header /w valid bearer", fields{ algorithm: algo, source: ac.NewJWTSource("", "Authorization"), pubKey: pubKeyBytes, - }, setContext(setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "Authorization", "BeAreR "+token)), false}, + }, setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "Authorization", "BeAreR "+token), false}, {"src: header /w no cookie", fields{ algorithm: algo, source: ac.NewJWTSource("token", ""), pubKey: pubKeyBytes, - }, setContext(httptest.NewRequest(http.MethodGet, "/", nil)), true}, + }, httptest.NewRequest(http.MethodGet, "/", nil), true}, {"src: header /w empty cookie", fields{ algorithm: algo, source: ac.NewJWTSource("token", ""), pubKey: pubKeyBytes, - }, setContext(setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "token", "")), true}, + }, setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "token", ""), true}, {"src: header /w valid cookie", fields{ algorithm: algo, source: ac.NewJWTSource("token", ""), pubKey: pubKeyBytes, - }, setContext(setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "token", token)), false}, + }, setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "token", token), false}, {"src: header /w valid bearer & claims", fields{ algorithm: algo, claims: map[string]string{ @@ -274,6 +274,113 @@ func Test_JWT_Validate(t *testing.T) { } } +func Test_JWT_yields_scopes(t *testing.T) { + signingMethod := jwt.SigningMethodHS256 + algo := acjwt.NewAlgorithm(signingMethod.Alg()) + expScopes := []string{"foo", "bar"} + + tests := []struct { + name string + scopeClaim string + scope interface{} + wantErr bool + }{ + { + "space-separated list", + "scp", + "foo bar", + false, + }, + { + "list of string", + "scoop", + []string{"foo", "bar"}, + false, + }, + { + "error: boolean", + "scope", + true, + true, + }, + { + "error: number", + "scope", + 1.23, + true, + }, + { + "error: list of bool", + "scope", + []bool{true, false}, + true, + }, + { + "error: list of number", + "scope", + []int{1, 2}, + true, + }, + { + "error: mixed list", + "scope", + []interface{}{"eins", 2}, + true, + }, + { + "error: object", + "scope", + map[string]interface{}{"foo": 1, "bar": 1}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims := jwt.MapClaims{} + claims[tt.scopeClaim] = tt.scope + tok := jwt.NewWithClaims(signingMethod, claims) + pubKeyBytes := []byte("mySecretK3y") + token, tokenErr := tok.SignedString(pubKeyBytes) + if tokenErr != nil { + t.Error(tokenErr) + } + + source := ac.NewJWTSource("", "Authorization") + j, err := ac.NewJWT(&ac.JWTOptions{ + Algorithm: algo.String(), + Name: "test_ac", + ScopeClaim: tt.scopeClaim, + Source: source, + Key: pubKeyBytes, + }) + if err != nil { + t.Error(err) + return + } + + req := setCookieAndHeader(httptest.NewRequest(http.MethodGet, "/", nil), "Authorization", "BeAreR "+token) + + if err = j.Validate(req); (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + scopesList, ok := req.Context().Value(request.Scopes).([]string) + if !ok { + t.Errorf("Expected scopes within request context") + } else { + for i, v := range expScopes { + if scopesList[i] != v { + t.Errorf("Scopes do not match, want: %v, got: %v", v, scopesList[i]) + } + } + } + + } + }) + } +} + func newRSAKeyPair() (pubKeyBytes []byte, privKey *rsa.PrivateKey) { privKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { diff --git a/accesscontrol/scope.go b/accesscontrol/scope.go new file mode 100644 index 000000000..e2cb70aaa --- /dev/null +++ b/accesscontrol/scope.go @@ -0,0 +1,110 @@ +package accesscontrol + +import ( + "fmt" + "net/http" + + "github.com/avenga/couper/config/request" + "github.com/avenga/couper/errors" +) + +var supportedOperations = []string{ + http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodConnect, + http.MethodOptions, + http.MethodTrace, +} + +type requiredScope struct { + scopes map[string][]string +} + +func newRequiredScope() requiredScope { + return requiredScope{scopes: make(map[string][]string)} +} + +func (r *requiredScope) addScopeMap(scopeMap map[string]string) { + otherScope, otherMethodExists := scopeMap["*"] + for _, op := range supportedOperations { + scope, exists := scopeMap[op] + if exists { + r.addScopeForOperation(op, scope) + } else if otherMethodExists { + r.addScopeForOperation(op, otherScope) + } else { + delete(r.scopes, op) + } + } +} + +func (r *requiredScope) addScopeForOperation(operation, scope string) { + scopes, exists := r.scopes[operation] + if !exists { + if scope == "" { + // method permitted without scope + r.scopes[operation] = []string{} + return + } + // method permitted with required scope + r.scopes[operation] = []string{scope} + return + } + // no additional scope required + if scope == "" { + return + } + // add scope to required scopes for this operation + r.scopes[operation] = append(scopes, scope) +} + +var _ AccessControl = &ScopeControl{} + +type ScopeControl struct { + required requiredScope +} + +func NewScopeControl(scopeMaps []map[string]string) *ScopeControl { + rs := newRequiredScope() + for _, scopeMap := range scopeMaps { + if scopeMap != nil { + rs.addScopeMap(scopeMap) + } + } + return &ScopeControl{required: rs} +} + +// Validate validates the scope values provided by access controls against the required scope values. +func (s *ScopeControl) Validate(req *http.Request) error { + if len(s.required.scopes) == 0 { + return nil + } + requiredScopes, exists := s.required.scopes[req.Method] + if !exists { + return errors.BetaOperationDenied.With(fmt.Errorf("operation %s not permitted", req.Method)) + } + ctx := req.Context() + grantedScope, ok := ctx.Value(request.Scopes).([]string) + if !ok && len(requiredScopes) > 0 { + return errors.BetaInsufficientScope.With(fmt.Errorf("no scope granted")) + } + for _, rs := range requiredScopes { + if !hasGrantedScope(grantedScope, rs) { + return errors.BetaInsufficientScope.With(fmt.Errorf("required scope %q not granted", rs)) + } + } + return nil +} + +func hasGrantedScope(grantedScope []string, scope string) bool { + for _, gs := range grantedScope { + if gs == scope { + return true + } + } + return false +} diff --git a/accesscontrol/scope_test.go b/accesscontrol/scope_test.go new file mode 100644 index 000000000..41765402c --- /dev/null +++ b/accesscontrol/scope_test.go @@ -0,0 +1,198 @@ +package accesscontrol + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/avenga/couper/config/request" + "github.com/avenga/couper/errors" +) + +func Test_requiredScope(t *testing.T) { + tests := []struct { + name string + base map[string]string + sm map[string]string + want map[string][]string + }{ + { + "only default no scope", + map[string]string{}, + map[string]string{"*": ""}, + map[string][]string{"CONNECT": []string{}, "DELETE": []string{}, "GET": []string{}, "HEAD": []string{}, "OPTIONS": []string{}, "PATCH": []string{}, "POST": []string{}, "PUT": []string{}, "TRACE": []string{}}, + }, + { + "only default read", + map[string]string{}, + map[string]string{"*": "read"}, + map[string][]string{"CONNECT": []string{"read"}, "DELETE": []string{"read"}, "GET": []string{"read"}, "HEAD": []string{"read"}, "OPTIONS": []string{"read"}, "PATCH": []string{"read"}, "POST": []string{"read"}, "PUT": []string{"read"}, "TRACE": []string{"read"}}, + }, + { + "simple scope, simple no scope", + map[string]string{}, + map[string]string{"POST": "write", "PUT": ""}, + map[string][]string{"POST": []string{"write"}, "PUT": []string{}}, + }, + { + "simple scope, simple no scope, with default", + map[string]string{}, + map[string]string{"POST": "write", "PUT": "", "*": "read"}, + map[string][]string{"CONNECT": []string{"read"}, "DELETE": []string{"read"}, "GET": []string{"read"}, "HEAD": []string{"read"}, "OPTIONS": []string{"read"}, "PATCH": []string{"read"}, "POST": []string{"write"}, "PUT": []string{}, "TRACE": []string{"read"}}, + }, + { + "default / simple scope, simple no scope", + map[string]string{"*": "read"}, + map[string]string{"POST": "write", "PUT": ""}, + map[string][]string{"POST": []string{"read", "write"}, "PUT": []string{"read"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(subT *testing.T) { + rs := newRequiredScope() + rs.addScopeMap(tt.base) + rs.addScopeMap(tt.sm) + if len(rs.scopes) != len(tt.want) { + t.Errorf("unexpected scopes: %#v, want: %#v", rs.scopes, tt.want) + return + } + for op, wantScopes := range tt.want { + scopes, exists := rs.scopes[op] + if !exists { + t.Errorf("no scopes for operation %q", op) + return + } + if len(scopes) != len(wantScopes) { + t.Errorf("unexpected scopes for %q: %#v, want: %#v", op, scopes, wantScopes) + return + } + for i, s := range wantScopes { + if scopes[i] != s { + t.Errorf("unexpected scopes for %q: %#v, want: %#v", op, scopes, wantScopes) + return + } + } + } + }) + } +} + +func Test_ScopeControl(t *testing.T) { + tests := []struct { + name string + scopeMaps []map[string]string + method string + grantedScope []string + wantErrorString string + }{ + { + "no method restrictions, no scope required, no scope granted", + []map[string]string{}, + http.MethodGet, + nil, + "", + }, + { + "method permitted, no scope required, no scope granted", + []map[string]string{map[string]string{http.MethodGet: ""}}, + http.MethodGet, + nil, + "", + }, + { + "method permitted, scope required, scope granted", + []map[string]string{map[string]string{http.MethodGet: "read"}}, + http.MethodGet, + []string{"read"}, + "", + }, + { + "method permitted, scope required, scopes granted", + []map[string]string{map[string]string{http.MethodPost: "write"}}, + http.MethodPost, + []string{"read", "write"}, + "", + }, + { + "method permitted, scopes required, scopes granted", + []map[string]string{map[string]string{"*": "read"}, map[string]string{http.MethodPost: "write"}}, + http.MethodPost, + []string{"read", "write"}, + "", + }, + { + "all methods permitted, scope required, scope granted", + []map[string]string{map[string]string{"*": "read"}}, + http.MethodPost, + []string{"read"}, + "", + }, + { + "method not permitted", + []map[string]string{map[string]string{http.MethodGet: ""}}, + http.MethodPost, + nil, + "access control error: operation POST not permitted", + }, + { + "method permitted, scope required, no scope granted", + []map[string]string{map[string]string{http.MethodGet: "read"}}, + http.MethodGet, + nil, + "access control error: no scope granted", + }, + { + "method permitted, scope required, wrong scope granted", + []map[string]string{map[string]string{http.MethodPost: "write"}}, + http.MethodPost, + []string{"read"}, + `access control error: required scope "write" not granted`, + }, + { + "method permitted, scopes required, missing granted scope", + []map[string]string{map[string]string{"*": "read"}, map[string]string{http.MethodPost: "write"}}, + http.MethodPost, + []string{"read"}, + `access control error: required scope "write" not granted`, + }, + { + "method permitted, scopes required, missing granted scopes", + []map[string]string{map[string]string{"*": "read"}, map[string]string{http.MethodPost: "write"}}, + http.MethodPost, + []string{"foo"}, + `access control error: required scope "read" not granted`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(subT *testing.T) { + sc := NewScopeControl(tt.scopeMaps) + req := httptest.NewRequest(tt.method, "/", nil) + if tt.grantedScope != nil { + ctx := req.Context() + ctx = context.WithValue(ctx, request.Scopes, tt.grantedScope) + *req = *req.WithContext(ctx) + } + err := sc.Validate(req) + if tt.wantErrorString == "" && err == nil { + return + } + if tt.wantErrorString == "" && err != nil { + logErr := err.(errors.GoError) + t.Errorf("no error expected, was: %#q", logErr.LogError()) + return + } + if tt.wantErrorString != "" && err == nil { + t.Errorf("no error thrown, expected: %q", tt.wantErrorString) + return + } + logErr := err.(errors.GoError) + if tt.wantErrorString != logErr.LogError() { + t.Errorf("unexpected error thrown, expected: %q, was: %q", tt.wantErrorString, logErr.LogError()) + return + } + }) + } +} diff --git a/config/ac_jwt.go b/config/ac_jwt.go index 416773e96..1709efb1e 100644 --- a/config/ac_jwt.go +++ b/config/ac_jwt.go @@ -22,6 +22,7 @@ type JWT struct { Name string `hcl:"name,label"` PostParam string `hcl:"post_param,optional"` QueryParam string `hcl:"query_param,optional"` + ScopeClaim string `hcl:"beta_scope_claim,optional"` SignatureAlgorithm string `hcl:"signature_algorithm"` SigningKey string `hcl:"signing_key,optional"` SigningKeyFile string `hcl:"signing_key_file,optional"` diff --git a/config/api.go b/config/api.go index 93a501273..18b220ec7 100644 --- a/config/api.go +++ b/config/api.go @@ -3,6 +3,7 @@ package config import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" + "github.com/zclconf/go-cty/cty" ) var _ Inline = &API{} @@ -16,6 +17,8 @@ type API struct { Endpoints Endpoints `hcl:"endpoint,block"` ErrorFile string `hcl:"error_file,optional"` Remain hcl.Body `hcl:",remain"` + Scope cty.Value `hcl:"beta_scope,optional"` + // internally used CatchAllEndpoint *Endpoint } diff --git a/config/endpoint.go b/config/endpoint.go index 2cde495f1..609656bb6 100644 --- a/config/endpoint.go +++ b/config/endpoint.go @@ -3,6 +3,7 @@ package config import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" + "github.com/zclconf/go-cty/cty" "github.com/avenga/couper/config/meta" ) @@ -18,6 +19,7 @@ type Endpoint struct { Remain hcl.Body `hcl:",remain"` RequestBodyLimit string `hcl:"request_body_limit,optional"` Response *Response `hcl:"response,block"` + Scope cty.Value `hcl:"beta_scope,optional"` // internally configured due to multi-label options Proxies Proxies diff --git a/config/request/context_key.go b/config/request/context_key.go index 45d51d751..4d78fade3 100644 --- a/config/request/context_key.go +++ b/config/request/context_key.go @@ -17,6 +17,7 @@ const ( ResponseWriter RoundTripName RoundTripProxy + Scopes ServerName StartTime TokenRequest diff --git a/config/runtime/access_control.go b/config/runtime/access_control.go index bed284a1a..a316e46f9 100644 --- a/config/runtime/access_control.go +++ b/config/runtime/access_control.go @@ -21,8 +21,11 @@ func (m ACDefinitions) Add(name string, ac accesscontrol.AccessControl, eh []*co if n == "" { return errors.Configuration.Message("accessControl requires a label") } + if n == "scopes" { + return errors.Configuration.Message("accessControl uses reserved name as label") + } if _, ok := m[n]; ok { - return errors.Configuration.Message("accessControl already defined: " + n) + return errors.Configuration.Message("accessControl already defined") } m[n] = &AccessControl{ diff --git a/config/runtime/access_control_test.go b/config/runtime/access_control_test.go new file mode 100644 index 000000000..24b8463a7 --- /dev/null +++ b/config/runtime/access_control_test.go @@ -0,0 +1,107 @@ +package runtime + +import ( + "testing" + + "github.com/avenga/couper/config/configload" + "github.com/avenga/couper/errors" +) + +func TestACDefinitions_errors(t *testing.T) { + tests := []struct { + name string + hcl string + expectedMsg string + }{ + { + "collision: basic_auth/jwt", + ` + server "test" { + } + definitions { + basic_auth "foo" { + } + jwt "foo" { + signature_algorithm = "HS256" + key = "$3cRe4" + header = "Authorization" + } + } + `, + "configuration error: foo: accessControl already defined", + }, + { + "collision: jwt reserved label", + ` + server "test" { + } + definitions { + jwt "scopes" { + signature_algorithm = "HS256" + key = "$3cRe4" + header = "Authorization" + } + } + `, + "configuration error: scopes: accessControl uses reserved name as label", + }, + { + "collision: basic_auth reserved label", + ` + server "test" { + } + definitions { + basic_auth "scopes" { + } + } + `, + "configuration error: scopes: accessControl uses reserved name as label", + }, + { + "jwt with empty label", + ` + server "test" { + } + definitions { + jwt "" { + signature_algorithm = "HS256" + key = "$3cRe4" + header = "Authorization" + } + } + `, + "configuration error: accessControl requires a label", + }, + { + "basic_auth with empty label", + ` + server "test" { + } + definitions { + basic_auth "" { + } + } + `, + "configuration error: accessControl requires a label", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cf, err := configload.LoadBytes([]byte(tt.hcl), "couper.hcl") + if err != nil { + t.Fatal(err) + } + _, err = NewServerConfiguration(cf, nil, nil) + if err == nil { + t.Errorf("Expected error") + } + logErr, _ := err.(errors.GoError) + if logErr == nil { + t.Error("logErr should not be nil") + } else if logErr.LogError() != tt.expectedMsg { + t.Errorf("\nwant:\t%s\ngot:\t%v", tt.expectedMsg, logErr.LogError()) + } + }) + } +} diff --git a/config/runtime/server.go b/config/runtime/server.go index ab802e94f..6bd0a406b 100644 --- a/config/runtime/server.go +++ b/config/runtime/server.go @@ -30,6 +30,7 @@ import ( "github.com/avenga/couper/handler/middleware" "github.com/avenga/couper/handler/transport" "github.com/avenga/couper/handler/validation" + "github.com/avenga/couper/internal/seetie" "github.com/avenga/couper/oauth2" "github.com/avenga/couper/oauth2/oidc" "github.com/avenga/couper/utils" @@ -165,8 +166,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca memStore: memStore, proxyFromEnv: conf.Settings.NoProxyFromEnv, srvOpts: serverOptions, - }, - log) + }, nil, log) if err != nil { return nil, err @@ -202,7 +202,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca memStore: memStore, proxyFromEnv: conf.Settings.NoProxyFromEnv, srvOpts: serverOptions, - }, log) + }, nil, log) if err != nil { return nil, err @@ -266,6 +266,20 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca if parentAPI != nil && parentAPI.CatchAllEndpoint == endpointConf { protectedHandler = epOpts.Error.ServeError(errors.RouteNotFound) } + scopeMaps := []map[string]string{} + if parentAPI != nil { + apiScopeMap, err := seetie.ValueToScopeMap(parentAPI.Scope) + if err != nil { + return nil, err + } + scopeMaps = append(scopeMaps, apiScopeMap) + } + endpointScopeMap, err := seetie.ValueToScopeMap(endpointConf.Scope) + if err != nil { + return nil, err + } + scopeMaps = append(scopeMaps, endpointScopeMap) + scopeControl := ac.NewScopeControl(scopeMaps) endpointHandlers[endpointConf], err = configureProtectedHandler(accessControls, confCtx, accessControl, config.NewAccessControl(endpointConf.AccessControl, endpointConf.DisableAccessControl), &protectedOptions{ @@ -274,7 +288,7 @@ func NewServerConfiguration(conf *config.Couper, log *logrus.Entry, memStore *ca memStore: memStore, proxyFromEnv: conf.Settings.NoProxyFromEnv, srvOpts: serverOptions, - }, log) + }, scopeControl, log) if err != nil { return nil, err } @@ -463,6 +477,7 @@ func configureAccessControls(conf *config.Couper, confCtx *hcl.EvalContext, log ClaimsRequired: jwtConf.ClaimsRequired, Key: key, Name: jwtConf.Name, + ScopeClaim: jwtConf.ScopeClaim, Source: ac.NewJWTSource(jwtConf.Cookie, jwtConf.Header), }) if err != nil { @@ -552,7 +567,7 @@ type protectedOptions struct { } func configureProtectedHandler(m ACDefinitions, ctx *hcl.EvalContext, parentAC, handlerAC config.AccessControl, - opts *protectedOptions, log *logrus.Entry) (http.Handler, error) { + opts *protectedOptions, scopeControl *ac.ScopeControl, log *logrus.Entry) (http.Handler, error) { var list ac.List for _, acName := range parentAC.Merge(handlerAC).List() { if e := m.MustExist(acName); e != nil { @@ -567,6 +582,10 @@ func configureProtectedHandler(m ACDefinitions, ctx *hcl.EvalContext, parentAC, ac.NewItem(acName, m[acName].Control, eh), ) } + if scopeControl != nil { + // TODO properly create error handler + list = append(list, ac.NewItem("scope", scopeControl, handler.NewErrorHandler(nil, opts.epOpts.Error))) + } if len(list) > 0 { return handler.NewAccessControl(opts.handler, list), nil diff --git a/docs/ERRORS.md b/docs/ERRORS.md index 7cb70a60b..c6ba2c953 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -56,3 +56,5 @@ All errors have a specific type. You can find it in the log field `error_type`. | `jwt_token_invalid` (`jwt`) | The token is not sufficient, e.g. because required claims are missing or have unexpected values. | Send error template with status `403`. | | `saml2` | All `saml2` related errors | Send error template with status `403`. | | `oauth2` | All `beta_oauth2`/`beta_oidc` related errors | Send error template with status `403`. | +| `beta_insufficient_scope` | The request is not in the scope granted to the requester. | Send error template with status `403`. | +| `beta_operation_denied` | The request method is not permitted. | Send error template with status `403`. | diff --git a/docs/README.md b/docs/README.md index aadb49383..717fc315d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -275,7 +275,6 @@ api "my_api" { base_path = "/api/v1" endpoint "/login/**" { - proxy { backend { origin = "http://identityprovider:8080" @@ -284,18 +283,18 @@ api "my_api" { } endpoint "/cart/**" { - - path = "/api/v1/**" - proxy { - url = "http://cartservice:8080" - } + path = "/api/v1/**" + proxy { + url = "http://cartservice:8080" + } + } endpoint "/account/{id}" { proxy { backend { path = "/user/${request.param.id}/info" origin = "http://accountservice:8080" - } + } } } } diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index e5d1e80de..f5af79b98 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -105,6 +105,7 @@ as json error with an error body payload. This can be customized via `error_file |`base_path`|string|-|Configures the path prefix for all requests.|⚠ Must be unique if multiple `api` blocks are defined.| `base_path = "/v1"`| | `error_file` |string|-|Location of the error file template.|-|`error_file = "./my_error_body.json"`| | `access_control` |list|-|Sets predefined [Access Control](#access-control) for `api` block context.|⚠ Inherited by nested blocks.| `access_control = ["foo"]`| +| `beta_scope` |string or object|-|Scope value required to use this API (see [error type](../ERRORS.md#error-types) `beta_insufficient_scope`).|If the value is a string, the same scope value applies to all request methods. If there are different scope values for different request methods, use an object with the request methods as keys and string values. Methods not specified in this object are not permitted (see [error type](../ERRORS.md#error-types) `beta_operation_denied`). `"*"` is the key for "all other methods". A value `""` means "no (additional) scope required".| `beta_scope = "read"` or `beta_scope = { post = "write", "*" = "" }`| ### Endpoint Block @@ -125,6 +126,7 @@ produce an explicit or implicit client response. |`request_body_limit` |string|`64MiB`|Configures the maximum buffer size while accessing `request.form_body` or `request.json_body` content.|⚠ Valid units are: `KiB, MiB, GiB`|`request_body_limit = "200KiB"`| | `path`|string|-|Changeable part of the upstream URL. Changes the path suffix of the outgoing request.|-|-| |`access_control` |list|-|Sets predefined [Access Control](#access-control) for `endpoint` block context.|-| `access_control = ["foo"]`| +| `beta_scope` |string or object|-|Scope value required to use this endpoint (see [error type](../ERRORS.md#error-types) `beta_insufficient_scope`).|If the value is a string, the same scope value applies to all request methods. If there are different scope values for different request methods, use an object with the request methods as keys and string values. Methods not specified in this object are not permitted (see [error type](../ERRORS.md#error-types) `beta_operation_denied`). `"*"` is the key for "all other methods". A value `""` means "no (additional) scope required".| `beta_scope = "read"` or `beta_scope = { post = "write", "*" = "" }`| |[Modifiers](#modifiers) |-|-|-|-|-| ### Proxy Block @@ -349,6 +351,7 @@ required _label_. | `signature_algorithm` |string|-|-|⚠ required. Valid values are: `RS256` `RS384` `RS512` `HS256` `HS384` `HS512`.|-| | `claims` |object|-|Object with claims that must be given for a valid token (equals comparison with JWT payload).| The claim values are evaluated per request. | `claims = { pid = request.path_params.pid }` | | `required_claims` |string|-|List of claim names that must be given for a valid token |-|`required_claims = ["role"]`| +| `beta_scope_claim` |string|-|name of claim specifying the scope of token|The claim value must either be a string containing a space-separated list of scope values or a list of string scope values|`beta_scope_claim = "scope"`| The `jwt` block may also be referenced by the [`jwt_sign()` function](#functions), if it has a `signing_ttl` defined. For `HS*` algorithms the signing key is taken from `key`/`key_file`, for `RS*` algorithms, `signing_key` or `signing_key_file` have to be specified. @@ -507,7 +510,7 @@ Examples: ## Access Control The configuration of access control is twofold in Couper: You define the particular -type (such as `jwt` or `basic_auth`) in `definitions`, each with a distinct label. +type (such as `jwt` or `basic_auth`) in `definitions`, each with a distinct label (must not be one of the reserved names: `scopes`). Anywhere in the `server` block those labels can be used in the `access_control` list to protect that block. ⚠ access rights are inherited by nested blocks. You can also disable `access_control` for blocks. By typing `disable_access_control = ["bar"]`, diff --git a/errors/type_defintions.go b/errors/type_defintions.go index 898dd3f2a..776b8d5aa 100644 --- a/errors/type_defintions.go +++ b/errors/type_defintions.go @@ -16,4 +16,7 @@ var Definitions = []*Error{ AccessControl.Kind("oauth2"), AccessControl.Kind("saml2"), + + AccessControl.Kind("scope").Kind("beta_operation_denied"), + AccessControl.Kind("scope").Kind("beta_insufficient_scope"), } diff --git a/errors/types_generated.go b/errors/types_generated.go index f74aa6075..0c0dfb836 100644 --- a/errors/types_generated.go +++ b/errors/types_generated.go @@ -11,6 +11,8 @@ var ( JwtTokenMissing = Definitions[5] Oauth2 = Definitions[6] Saml2 = Definitions[7] + BetaOperationDenied = Definitions[8] + BetaInsufficientScope = Definitions[9] ) // typeDefinitions holds all related error definitions which are @@ -28,6 +30,8 @@ var types = typeDefinitions{ "jwt_token_missing": JwtTokenMissing, "oauth2": Oauth2, "saml2": Saml2, + "beta_operation_denied": BetaOperationDenied, + "beta_insufficient_scope": BetaInsufficientScope, } // IsKnown tells the configuration callee if Couper diff --git a/eval/context.go b/eval/context.go index 9feeb628c..741bc5c15 100644 --- a/eval/context.go +++ b/eval/context.go @@ -423,6 +423,7 @@ func NewRawOrigin(u *url.URL) *url.URL { func newVariable(ctx context.Context, cookies []*http.Cookie, headers http.Header) ContextMap { acData, _ := ctx.Value(request.AccessControls).(map[string]interface{}) + scopeData, _ := ctx.Value(request.Scopes).([]string) ctxAcMap := make(map[string]cty.Value) for name, data := range acData { dataMap, ok := data.(map[string]interface{}) @@ -431,9 +432,12 @@ func newVariable(ctx context.Context, cookies []*http.Cookie, headers http.Heade } ctxAcMap[name] = seetie.MapToValue(dataMap) } + if len(scopeData) > 0 { + ctxAcMap["scopes"] = seetie.GoToValue(scopeData) + } var ctxAcMapValue cty.Value if len(ctxAcMap) > 0 { - ctxAcMapValue = cty.MapVal(ctxAcMap) + ctxAcMapValue = cty.ObjectVal(ctxAcMap) } else { ctxAcMapValue = cty.MapValEmpty(cty.String) } diff --git a/internal/seetie/convert.go b/internal/seetie/convert.go index 70e103160..150827a38 100644 --- a/internal/seetie/convert.go +++ b/internal/seetie/convert.go @@ -64,6 +64,28 @@ func ValueToMap(val cty.Value) map[string]interface{} { return result } +func ValueToScopeMap(val cty.Value) (map[string]string, error) { + scopeMap := make(map[string]string) + switch val.Type() { + case cty.NilType: + return nil, nil + case cty.String: + scopeMap["*"] = val.AsString() + return scopeMap, nil + default: + if val.Type().IsObjectType() { + for k, v := range val.AsValueMap() { + if v.Type() != cty.String { + return nil, fmt.Errorf("unsupported value for operation %q in beta_scope", k) + } + scopeMap[strings.ToUpper(k)] = v.AsString() + } + return scopeMap, nil + } + } + return nil, fmt.Errorf("unsupported value for beta_scope") +} + func ValuesMapToValue(m url.Values) cty.Value { result := make(map[string]interface{}) for k, v := range m { @@ -93,6 +115,12 @@ func GoToValue(v interface{}) cty.Value { return cty.NumberIntVal(v.(int64)) case float64: return cty.NumberFloatVal(v.(float64)) + case []string: + var list []interface{} + for _, s := range v.([]string) { + list = append(list, s) + } + return ListToValue(list) case []interface{}: return ListToValue(v.([]interface{})) case map[string]interface{}: diff --git a/server/http_integration_test.go b/server/http_integration_test.go index 6f8912b3b..37262fb5c 100644 --- a/server/http_integration_test.go +++ b/server/http_integration_test.go @@ -23,6 +23,7 @@ import ( "text/template" "time" + "github.com/dgrijalva/jwt-go/v4" "github.com/sirupsen/logrus" logrustest "github.com/sirupsen/logrus/hooks/test" @@ -2787,7 +2788,7 @@ func TestJWTAccessControl(t *testing.T) { for _, tc := range []testCase{ {"no token", "/jwt", http.Header{}, http.StatusUnauthorized, "access control error: JWTToken: token required"}, {"expired token", "/jwt", http.Header{"Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjEyMzQ1Njc4OX0.wLWj9XgBZAPoDYPXsmDrEBzR6BUWfwPqQNlR_F0naZA"}}, http.StatusForbidden, "access control error: JWTToken: token is expired by "}, - {"valid token", "/jwt", http.Header{"Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.Qf0lkeZKZ3NJrYm3VdgiQiQ6QTrjCvISshD_q9F8GAM"}}, http.StatusOK, ""}, + {"valid token", "/jwt", http.Header{"Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwic2NvcGUiOiJmb28gYmFyIiwiaWF0IjoxNTE2MjM5MDIyfQ.7wz7Z7IajfEpwYayfshag6tQVS0e0zZJyjAhuFC0L-E"}}, http.StatusOK, ""}, } { t.Run(tc.path[1:], func(subT *testing.T) { helper := test.New(subT) @@ -2826,6 +2827,10 @@ func TestJWTAccessControl(t *testing.T) { t.Errorf("%q: unexpected sub: %q", tc.name, sub) return } + if scopes := res.Header.Get("X-Scopes"); scopes != `["foo","bar"]` { + t.Errorf("%q: unexpected scope: %q", tc.name, scopes) + return + } }) } } @@ -2935,6 +2940,73 @@ func getAccessControlMessages(hook *logrustest.Hook) string { return "" } +func Test_Scope(t *testing.T) { + h := test.New(t) + client := newClient() + + shutdown, hook := newCouper("testdata/integration/config/09_couper.hcl", test.New(t)) + defer shutdown() + + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "scp": "a", + }) + token, tokenErr := tok.SignedString([]byte("asdf")) + h.Must(tokenErr) + + type testCase struct { + name string + operation string + path string + authorize bool + status int + wantErrLog string + wantErrType string + } + + for _, tc := range []testCase{ + {"unauthorized", http.MethodGet, "/foo", false, http.StatusUnauthorized, "access control error: myjwt: token required", "jwt_token_missing"}, + {"sufficient scope", http.MethodGet, "/foo", true, http.StatusNoContent, "", ""}, + {"additional scope required: insufficient scope", http.MethodPost, "/foo", true, http.StatusForbidden, `access control error: scope: required scope "foo" not granted`, "beta_insufficient_scope"}, + {"operation not permitted", http.MethodDelete, "/foo", true, http.StatusForbidden, "access control error: scope: operation DELETE not permitted", "beta_operation_denied"}, + {"additional scope required by *: insufficient scope", http.MethodGet, "/bar", true, http.StatusForbidden, `access control error: scope: required scope "more" not granted`, "beta_insufficient_scope"}, + {"no additional scope required: sufficient scope", http.MethodDelete, "/bar", true, http.StatusNoContent, "", ""}, + } { + t.Run(fmt.Sprintf("%s_%s_%s", tc.name, tc.operation, tc.path), func(subT *testing.T) { + helper := test.New(subT) + hook.Reset() + + req, err := http.NewRequest(tc.operation, "http://back.end:8080"+tc.path, nil) + if tc.authorize { + req.Header.Set("Authorization", "Bearer "+token) + } + helper.Must(err) + + res, err := client.Do(req) + helper.Must(err) + + if res.StatusCode != tc.status { + t.Errorf("%q: expected Status %d, got: %d", tc.name, tc.status, res.StatusCode) + return + } + + message := getAccessControlMessages(hook) + if tc.wantErrLog == "" { + if message != "" { + t.Errorf("Expected error log: %q, actual: %#v", tc.wantErrLog, message) + } + } else { + if !strings.HasPrefix(message, tc.wantErrLog) { + t.Errorf("Expected error log message: %q, actual: %#v", tc.wantErrLog, message) + } + errorType := getAccessLogErrorType(hook) + if errorType != tc.wantErrType { + t.Errorf("Expected error type: %q, actual: %q", tc.wantErrType, errorType) + } + } + }) + } +} + func getAccessLogUrl(hook *logrustest.Hook) string { for _, entry := range hook.AllEntries() { if entry.Data["type"] == "couper_access" && entry.Data["url"] != "" { @@ -2947,6 +3019,18 @@ func getAccessLogUrl(hook *logrustest.Hook) string { return "" } +func getAccessLogErrorType(hook *logrustest.Hook) string { + for _, entry := range hook.AllEntries() { + if entry.Data["type"] == "couper_access" && entry.Data["error_type"] != "" { + if errorType, ok := entry.Data["error_type"].(string); ok { + return errorType + } + } + } + + return "" +} + func TestWrapperHiJack_WebsocketUpgrade(t *testing.T) { helper := test.New(t) shutdown, _ := newCouper("testdata/integration/api/04_couper.hcl", test.New(t)) diff --git a/server/testdata/integration/config/03_couper.hcl b/server/testdata/integration/config/03_couper.hcl index 3e84fc8e8..ba64e14b6 100644 --- a/server/testdata/integration/config/03_couper.hcl +++ b/server/testdata/integration/config/03_couper.hcl @@ -90,6 +90,7 @@ server "acs" { response { headers = { x-jwt-sub = request.context.JWTToken.sub + x-scopes = json_encode(request.context.scopes) } } } @@ -112,6 +113,7 @@ definitions { header = "Authorization" signature_algorithm = "HS256" key = "y0urS3cretT08eU5edF0rC0uPerInThe3xamp1e" + beta_scope_claim = "scope" } backend "test" { diff --git a/server/testdata/integration/config/09_couper.hcl b/server/testdata/integration/config/09_couper.hcl new file mode 100644 index 000000000..db6420432 --- /dev/null +++ b/server/testdata/integration/config/09_couper.hcl @@ -0,0 +1,32 @@ +server "scoped jwt" { + api { + access_control = ["myjwt"] + beta_scope = "a" + endpoint "/foo" { + beta_scope = { + get = "" + post = "foo" + } + response { + status = 204 + } + } + endpoint "/bar" { + beta_scope = { + delete = "" + "*" = "more" + } + response { + status = 204 + } + } + } +} +definitions { + jwt "myjwt" { + header = "authorization" + signature_algorithm = "HS256" + key = "asdf" + beta_scope_claim = "scp" + } +}