From 622836ad780c47c49f4b582cbf20b67770a60380 Mon Sep 17 00:00:00 2001 From: johnabass Date: Mon, 19 Aug 2024 12:46:37 -0700 Subject: [PATCH 1/7] chore: clarified comment --- capabilities.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/capabilities.go b/capabilities.go index b6a08d9..20968b4 100644 --- a/capabilities.go +++ b/capabilities.go @@ -8,6 +8,8 @@ package bascule // Capabilities do not make sense for all tokens, e.g. simple basic auth tokens. type CapabilitiesAccessor interface { // Capabilities returns the set of capabilities associated with this token. + // The exact format and application of capabilities is left up to specific + // implementations. Capabilities() []string } From fb457ec1528dfcaff6e5af0d309025a7383d3b92 Mon Sep 17 00:00:00 2001 From: johnabass Date: Mon, 19 Aug 2024 13:12:28 -0700 Subject: [PATCH 2/7] added a general purpose unauthorized error --- authorizer.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/authorizer.go b/authorizer.go index 6cad639..c1c1931 100644 --- a/authorizer.go +++ b/authorizer.go @@ -3,7 +3,17 @@ package bascule -import "context" +import ( + "context" + "errors" +) + +var ( + // ErrUnauthorized is a general error indicating that a token was unauthorized + // for a particular resource. Most authorizers and approvers should use this + // error or wrap this error to indicate failed authorization. + ErrUnauthorized = errors.New("unauthorized") +) // AuthorizeEvent represents the result of bascule's authorize workflow. type AuthorizeEvent[R any] struct { From e19d904d316d7864187d0ca6a076bf38511b5945 Mon Sep 17 00:00:00 2001 From: johnabass Date: Tue, 20 Aug 2024 08:50:23 -0700 Subject: [PATCH 3/7] capability support --- basculehttp/capabilities.go | 243 ++++++++++++++++++++++++++++++++++++ basculehttp/error.go | 31 +++-- basculehttp/error_test.go | 16 ++- 3 files changed, 278 insertions(+), 12 deletions(-) create mode 100644 basculehttp/capabilities.go diff --git a/basculehttp/capabilities.go b/basculehttp/capabilities.go new file mode 100644 index 0000000..4e2b55a --- /dev/null +++ b/basculehttp/capabilities.go @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package basculehttp + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/xmidt-org/bascule" + "go.uber.org/multierr" +) + +const ( + // DefaultAllMethod is one of the default method strings that will match any HTTP method. + DefaultAllMethod = "all" + + // DefaultWildcardMethod is one of the default method strings that will match any HTTP method. + DefaultWildcardMethod = "*" +) + +var ( + // ErrMissingCapabilities indicates that a token had no capabilities + // and thus is unauthorized. + ErrMissingCapabilities = errors.New("no capabilities in token") +) + +// urlPathNormalization ensures that the given URL has a leading slash. +func urlPathNormalization(url string) string { + if url[0] == '/' { + return url + } + + return "/" + url +} + +// CapabilityUnauthorizedError indicates that a given capability was +// rejected and the token is unauthorized. +type CapabilityUnauthorizedError struct { + // Match is the match string in : format + // that matched the capability but did not match the resource request. + Match string + + // Capability is the capability string from the token that was rejected. + Capability string + + // Err is any error that occurred. This will be returned from Unwrap. + Err error +} + +func (cue *CapabilityUnauthorizedError) Unwrap() error { + return cue.Err +} + +func (cue *CapabilityUnauthorizedError) StatusCode() int { + return http.StatusForbidden +} + +func (cue *CapabilityUnauthorizedError) Error() string { + var o strings.Builder + o.WriteString(`Capability [`) + o.WriteString(cue.Capability) + o.WriteString(`] was rejected due to [`) + o.WriteString(cue.Match) + o.WriteRune(']') + + if cue.Err != nil { + o.WriteString(`: `) + o.WriteString(cue.Err.Error()) + } + + return o.String() +} + +// CapabilityApproverOption is a configurable option used to create a CapabilityApprover. +type CapabilityApproverOption interface { + apply(*CapabilityApprover) error +} + +type capabilityApproverOptionFunc func(*CapabilityApprover) error + +func (caof capabilityApproverOptionFunc) apply(ca *CapabilityApprover) error { return caof(ca) } + +// WithCapabilityPrefixes adds several prefixes used to match capabilities, e.g. x1:webpa:foo:. Only +// the first prefix found during matching is considered for authorization. If no prefixes +// are set via this option, the resulting approver will not authorize any requests. +// +// Note that a prefix can itself be a regular expression, but may not have any subexpressions. +func WithCapabilityPrefixes(prefixes ...string) CapabilityApproverOption { + return capabilityApproverOptionFunc(func(ca *CapabilityApprover) error { + for _, p := range prefixes { + re, err := regexp.Compile("^" + p + "(.+):(.+?)$") + switch { + case err != nil: + return fmt.Errorf("Unable to compile capability prefix [%s]: %s", p, err) + + case re.NumSubexp() != 2: + return fmt.Errorf("The prefix [%s] cannot have subexpressions", p) + + default: + ca.matchers = append(ca.matchers, re) + } + } + + return nil + }) +} + +// WithCapabilityAllMethods changes the values used to signal a match of all HTTP methods. +// By default, both DefaultAllMethod and DefaultWildcardMethod, if present in a capability, +// will match any HTTP method. This option overwrites the default, and is cumulative. +// However, a caller can add values to the default by using +// WithCapabilityAllMethods(DefaultAllMethod, DefaultWildcardMethod, "myvalue", ...). +func WithCapabilityAllMethods(v ...string) CapabilityApproverOption { + return capabilityApproverOptionFunc(func(ca *CapabilityApprover) error { + if ca.allMethods == nil { + ca.allMethods = make(map[string]bool, len(v)) + } + + for _, matchAll := range v { + ca.allMethods[matchAll] = true + } + + return nil + }) +} + +// CapabilityApprover is a bascule HTTP approver that authorizes tokens +// with capabilities against requests. +// +// This approver expects capabilities in tokens to be of the form :. +// +// The allowed prefixes must be set via one or more WithCapabilityPrefixes options. Prefixes +// may themselves contain colon delimiters and can be regular expressions without subexpressions. +type CapabilityApprover struct { + matchers []*regexp.Regexp + allMethods map[string]bool +} + +// NewCapabilityApprover creates a CapabilityApprover using the supplied options. +// At least (1) of the configured prefixes must match an HTTP request's URL in +// ordered for a token to be authorized. +// +// If no prefixes are added via WithCapabilityPrefix, then the returned approver +// will not authorize any requests. +func NewCapabilityApprover(opts ...CapabilityApproverOption) (ca *CapabilityApprover, err error) { + ca = new(CapabilityApprover) + for _, o := range opts { + err = multierr.Append(err, o.apply(ca)) + } + + switch { + case err != nil: + ca = nil + + default: + if len(ca.allMethods) == 0 { + // enforce the defaults + ca.allMethods = map[string]bool{ + DefaultAllMethod: true, + DefaultWildcardMethod: true, + } + } + } + + return +} + +// Approve attempts to match each capability to a configured prefix. Then, for any matched prefix, +// the URL regexp and method in the capability must match the resource. URLs are normalized +// with a leading '/'. +// +// This method returns success (i.e. a nil error) when the first matching capability is found. +func (ca *CapabilityApprover) Approve(_ context.Context, resource *http.Request, token bascule.Token) error { + capabilities, ok := bascule.GetCapabilities(token) + if len(capabilities) == 0 || !ok { + return ErrMissingCapabilities + } + + for _, matcher := range ca.matchers { + for _, capability := range capabilities { + substrings := matcher.FindStringSubmatch(capability) + if len(substrings) < 2 { + // no match + continue + } + + // the format of capabilities is : + // and will be substrings + err := ca.approveURL(resource, substrings[1]) + if err == nil { + err = ca.approveMethod(resource, substrings[2]) + } + + if err != nil { + err = &CapabilityUnauthorizedError{ + Match: matcher.String(), + Capability: capability, + Err: err, + } + } + + // stop at the first match, regardless of result + return err + } + } + + // none of the matchers matched any capability, OR there were no matchers configured + return bascule.ErrUnauthorized +} + +func (ca *CapabilityApprover) approveMethod(resource *http.Request, capabilityMethod string) error { + switch { + case ca.allMethods[capabilityMethod]: + return nil + + case capabilityMethod == strings.ToLower(resource.Method): + return nil + + default: + return fmt.Errorf("method does not match request method [%s]", resource.Method) + } +} + +func (ca *CapabilityApprover) approveURL(resource *http.Request, capabilityURL string) error { + resourcePath := resource.URL.EscapedPath() + + re, err := regexp.Compile(urlPathNormalization(capabilityURL)) + if err != nil { + return err + } + + indices := re.FindStringIndex(urlPathNormalization(resourcePath)) + if len(indices) < 1 || indices[0] != 0 { + return fmt.Errorf("url does not match request URL [%s]", resourcePath) + } + + return nil +} diff --git a/basculehttp/error.go b/basculehttp/error.go index 62f663f..8299c25 100644 --- a/basculehttp/error.go +++ b/basculehttp/error.go @@ -17,17 +17,23 @@ import ( type ErrorStatusCoder func(request *http.Request, err error) int // DefaultErrorStatusCoder is the strategy used when no ErrorStatusCoder is supplied. +// The following tests are done in order: // -// If err has bascule.ErrMissingCredentials in its chain, this function returns +// (1) First, if err is nil, this method returns 0. +// +// (2) If any error in the chain provides a 'StatusCode() int' method, the result +// from that method is returned. +// +// (3) If err has bascule.ErrMissingCredentials in its chain, this function returns // http.StatusUnauthorized. // -// If err has bascule.ErrInvalidCredentials in its chain, this function returns -// http.StatusBadRequest. +// (4) If err has bascule.ErrUnauthorized in its chain, this function returns +// http.StatusForbidden. // -// Failing the previous two checks, if the error provides a StatusCode() method, -// the return value from that method is used. +// (5) If err has bascule.ErrInvalidCredentials in its chain, this function returns +// http.StatusBadRequest. // -// Otherwise, this method returns 0 to indicate that it doesn't know how to +// (6) Otherwise, this method returns 0 to indicate that it doesn't know how to // produce a status code from the error. func DefaultErrorStatusCoder(_ *http.Request, err error) int { type statusCoder interface { @@ -37,19 +43,24 @@ func DefaultErrorStatusCoder(_ *http.Request, err error) int { var sc statusCoder switch { - // check if it's a status coder first, so that we can - // override status codes for built-in errors. + case err == nil: + return 0 + case errors.As(err, &sc): return sc.StatusCode() case errors.Is(err, bascule.ErrMissingCredentials): return http.StatusUnauthorized + case errors.Is(err, bascule.ErrUnauthorized): + return http.StatusForbidden + case errors.Is(err, bascule.ErrInvalidCredentials): return http.StatusBadRequest - } - return 0 + default: + return 0 + } } // ErrorMarshaler is a strategy for marshaling an error's contents, particularly to diff --git a/basculehttp/error_test.go b/basculehttp/error_test.go index 974b1e2..fba79bf 100644 --- a/basculehttp/error_test.go +++ b/basculehttp/error_test.go @@ -18,6 +18,12 @@ type ErrorTestSuite struct { } func (suite *ErrorTestSuite) TestDefaultErrorStatusCoder() { + suite.Run("Nil", func() { + suite.Zero( + DefaultErrorStatusCoder(nil, nil), + ) + }) + suite.Run("ErrMissingCredentials", func() { suite.Equal( http.StatusUnauthorized, @@ -25,6 +31,13 @@ func (suite *ErrorTestSuite) TestDefaultErrorStatusCoder() { ) }) + suite.Run("ErrUnauthorized", func() { + suite.Equal( + http.StatusForbidden, + DefaultErrorStatusCoder(nil, bascule.ErrUnauthorized), + ) + }) + suite.Run("ErrInvalidCredentials", func() { suite.Equal( http.StatusBadRequest, @@ -53,8 +66,7 @@ func (suite *ErrorTestSuite) TestDefaultErrorStatusCoder() { }) suite.Run("Unrecognized", func() { - suite.Equal( - 0, + suite.Zero( DefaultErrorStatusCoder(nil, errors.New("unrecognized error")), ) }) From 92ae145aa24584fd4719d01861bf7beffa7a04e9 Mon Sep 17 00:00:00 2001 From: johnabass Date: Tue, 20 Aug 2024 11:57:24 -0700 Subject: [PATCH 4/7] custom HTTP capabilities moved to their own subpackage --- basculehttp/basculecaps/approver.go | 238 +++++++++++++++++++++++ basculehttp/basculecaps/approver_test.go | 209 ++++++++++++++++++++ basculehttp/basculecaps/doc.go | 21 ++ 3 files changed, 468 insertions(+) create mode 100644 basculehttp/basculecaps/approver.go create mode 100644 basculehttp/basculecaps/approver_test.go create mode 100644 basculehttp/basculecaps/doc.go diff --git a/basculehttp/basculecaps/approver.go b/basculehttp/basculecaps/approver.go new file mode 100644 index 0000000..38a8fd5 --- /dev/null +++ b/basculehttp/basculecaps/approver.go @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package basculecaps + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/xmidt-org/bascule" + "go.uber.org/multierr" +) + +const ( + // DefaultAllMethod is one of the default method strings that will match any HTTP method. + DefaultAllMethod = "all" +) + +var ( + // ErrMissingCapabilities indicates that a token had no capabilities + // and thus is unauthorized. + ErrMissingCapabilities = &UnauthorizedError{ + Err: errors.New("no capabilities in token"), + } +) + +// urlPathNormalization ensures that the given URL has a leading slash. +func urlPathNormalization(url string) string { + if url[0] == '/' { + return url + } + + return "/" + url +} + +// UnauthorizedError indicates that a given capability was rejected and +// that the token is unauthorized. +type UnauthorizedError struct { + // Match is the regular expression that matched the capability. + // This will be unset if no match occurred, i.e. if there were + // no capabilities in the token. + Match string + + // Capability is the capability string from the token that was rejected. + // This will be unset if there were no capabilities in the token. + Capability string + + // Err is any error that occurred. This is NOT returned by Unwrap. + Err error +} + +// Unwrap always returns bascule.ErrUnauthorized, even if the Err field is set. +func (ue *UnauthorizedError) Unwrap() error { + return bascule.ErrUnauthorized +} + +// StatusCode always returns http.StatusForbidden. +func (*UnauthorizedError) StatusCode() int { + return http.StatusForbidden +} + +func (ue *UnauthorizedError) Error() string { + var o strings.Builder + o.WriteString(`Capability [`) + o.WriteString(ue.Capability) + o.WriteString(`] was rejected due to [`) + o.WriteString(ue.Match) + o.WriteRune(']') + + if ue.Err != nil { + o.WriteString(`: `) + o.WriteString(ue.Err.Error()) + } + + return o.String() +} + +// ApproverOption is a configurable option used to create an Approver. +type ApproverOption interface { + apply(*Approver) error +} + +type approverOptionFunc func(*Approver) error + +func (aof approverOptionFunc) apply(a *Approver) error { return aof(a) } + +// WithPrefixes adds several prefixes used to match capabilities, e.g. x1:webpa:foo:. Only +// the first prefix found during matching is considered for authorization. If no prefixes +// are set via this option, the resulting approver will not authorize any requests. +// +// Note that a prefix can itself be a regular expression, but may not have any subexpressions. +func WithPrefixes(prefixes ...string) ApproverOption { + return approverOptionFunc(func(a *Approver) error { + for _, p := range prefixes { + re, err := regexp.Compile("^" + p + "(.+):(.+?)$") + switch { + case err != nil: + return fmt.Errorf("Unable to compile capability prefix [%s]: %s", p, err) + + case re.NumSubexp() != 2: + return fmt.Errorf("The prefix [%s] cannot have subexpressions", p) + + default: + a.matchers = append(a.matchers, re) + } + } + + return nil + }) +} + +// WithAllMethod changes the value used to signal a match of all HTTP methods. +// By default, DefaultAllMethod is used. +func WithAllMethod(allMethod string) ApproverOption { + return approverOptionFunc(func(a *Approver) error { + if len(allMethod) == 0 { + return errors.New("the all method expression cannot be blank") + } + + a.allMethod = allMethod + return nil + }) +} + +// Approver is a bascule HTTP approver that authorizes tokens +// with capabilities against requests. +// +// This approver expects capabilities in tokens to be of the form :. +// +// The allowed prefixes must be set via one or more WithCapabilityPrefixes options. Prefixes +// may themselves contain colon delimiters and can be regular expressions without subexpressions. +type Approver struct { + matchers []*regexp.Regexp + allMethod string +} + +// NewApprover creates a Approver using the supplied options. At least (1) of the configured +// prefixes must match an HTTP request's URL in ordered for a token to be authorized. +// +// If no prefixes are added via WithPrefixes, then the returned approver +// will not authorize any requests. +func NewApprover(opts ...ApproverOption) (a *Approver, err error) { + a = new(Approver) + for _, o := range opts { + err = multierr.Append(err, o.apply(a)) + } + + switch { + case err != nil: + a = nil + + default: + if len(a.allMethod) == 0 { + a.allMethod = DefaultAllMethod + } + } + + return +} + +// Approve attempts to match each capability to a configured prefix. Then, for any matched prefix, +// the URL regexp and method in the capability must match the resource. URLs are normalized +// with a leading '/'. +// +// This method returns success (i.e. a nil error) when the first matching capability is found. +// +// This method always returns either bascule.ErrUnauthorized or an *UnauthorizedError, which wraps +// bascule.ErrUnauthorized. +func (a *Approver) Approve(_ context.Context, resource *http.Request, token bascule.Token) error { + capabilities, ok := bascule.GetCapabilities(token) + if len(capabilities) == 0 || !ok { + return ErrMissingCapabilities + } + + for _, matcher := range a.matchers { + for _, capability := range capabilities { + substrings := matcher.FindStringSubmatch(capability) + if len(substrings) < 2 { + // no match + continue + } + + // the format of capabilities is : + // and will be substrings + err := a.approveURL(resource, substrings[1]) + if err == nil { + err = a.approveMethod(resource, substrings[2]) + } + + if err != nil { + err = &UnauthorizedError{ + Match: matcher.String(), + Capability: capability, + Err: err, + } + } + + // stop at the first match, regardless of result + return err + } + } + + // none of the matchers matched any capability, OR there were no matchers configured + return bascule.ErrUnauthorized +} + +func (a *Approver) approveMethod(resource *http.Request, capabilityMethod string) error { + switch { + case a.allMethod == capabilityMethod: + return nil + + case capabilityMethod == strings.ToLower(resource.Method): + return nil + + default: + return fmt.Errorf("method does not match request method [%s]", resource.Method) + } +} + +func (a *Approver) approveURL(resource *http.Request, capabilityURL string) error { + resourcePath := resource.URL.EscapedPath() + + re, err := regexp.Compile(urlPathNormalization(capabilityURL)) + if err != nil { + return err + } + + indices := re.FindStringIndex(urlPathNormalization(resourcePath)) + if len(indices) < 1 || indices[0] != 0 { + return fmt.Errorf("url does not match request URL [%s]", resourcePath) + } + + return nil +} diff --git a/basculehttp/basculecaps/approver_test.go b/basculehttp/basculecaps/approver_test.go new file mode 100644 index 0000000..5240ca8 --- /dev/null +++ b/basculehttp/basculecaps/approver_test.go @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +package basculecaps + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/xmidt-org/bascule" +) + +type testToken struct { + principal string + capabilities []string +} + +func (tt *testToken) Principal() string { + return tt.principal +} + +func (tt *testToken) Capabilities() []string { + return tt.capabilities +} + +type ApproverTestSuite struct { + suite.Suite +} + +// newRequest creates an HTTP request with an empty body, since these +// tests do not need to use any entity bodies. +func (suite *ApproverTestSuite) newRequest(method, url string) *http.Request { + return httptest.NewRequest(method, url, nil) +} + +// newToken creates a stub token that has the given capabilities. +func (suite *ApproverTestSuite) newToken(capabilities ...string) bascule.Token { + return &testToken{ + principal: "test", + capabilities: append([]string{}, capabilities...), + } +} + +// newApprover creates a Approver from a set of options that +// must be valid. +func (suite *ApproverTestSuite) newApprover(opts ...ApproverOption) *Approver { + ca, err := NewApprover(opts...) + suite.Require().NoError(err) + suite.Require().NotNil(ca) + return ca +} + +func (suite *ApproverTestSuite) TestInvalidPrefix() { + invalidPrefixes := []string{ + "(.*):foo:", // subexpressions aren't allowed + "(?!foo)", + } + + for i, invalid := range invalidPrefixes { + suite.Run(strconv.Itoa(i), func() { + ca, err := NewApprover( + WithPrefixes(invalid), + ) + + suite.Error(err) + suite.Nil(ca) + }) + } +} + +func (suite *ApproverTestSuite) TestInvalidAllMethod() { + ca, err := NewApprover( + WithAllMethod(""), // blanks aren't allowed + ) + + suite.Error(err) + suite.Nil(ca) +} + +func (suite *ApproverTestSuite) testApproveMissingCapabilities() { + ca := suite.newApprover() // don't need any options for this case + err := ca.Approve(context.Background(), suite.newRequest("GET", "/test"), new(testToken)) + suite.ErrorIs(err, ErrMissingCapabilities) +} + +func (suite *ApproverTestSuite) testApproveSuccess() { + testCases := []struct { + capabilities []string + request *http.Request + options []ApproverOption + }{ + { + capabilities: []string{"x1:webpa:api:.*:all"}, + request: suite.newRequest("GET", "/test"), + options: []ApproverOption{ + WithPrefixes("x1:webpa:api:"), + }, + }, + { + capabilities: []string{"x1:webpa:api:device/.*/config:all"}, + request: suite.newRequest("GET", "/device/DEADBEEF/config"), + options: []ApproverOption{ + WithPrefixes("x1:xmidt:api:", "x1:webpa:api:"), + }, + }, + { + capabilities: []string{"x1:webpa:api:/test/.*:put"}, + request: suite.newRequest("PUT", "/test/foo"), + options: []ApproverOption{ + WithPrefixes("x1:xmidt:api:", "x1:webpa:api:"), + }, + }, + { + capabilities: []string{"x1:webpa:api:/test/.*:custom"}, + request: suite.newRequest("PATCH", "/test/foo"), + options: []ApproverOption{ + WithPrefixes("x1:xmidt:api:", "x1:webpa:api:"), + WithAllMethod("custom"), + }, + }, + } + + for i, testCase := range testCases { + suite.Run(strconv.Itoa(i), func() { + var ( + token = suite.newToken(testCase.capabilities...) + ca = suite.newApprover(testCase.options...) + ) + + suite.NoError( + ca.Approve(context.Background(), testCase.request, token), + ) + }) + } +} + +func (suite *ApproverTestSuite) testApproveUnauthorized() { + testCases := []struct { + capabilities []string + request *http.Request + options []ApproverOption + }{ + { + capabilities: []string{"x1:xmidt:api:.*:all"}, + request: suite.newRequest("GET", "/"), + options: nil, // will reject all tokens + }, + { + capabilities: []string{"x1:webpa:api:.*:put"}, + request: suite.newRequest("GET", "/test"), + options: []ApproverOption{ + WithPrefixes("x1:webpa:api:"), + }, + }, + { + capabilities: []string{"x1:webpa:api:/doesnotmatch:get"}, + request: suite.newRequest("GET", "/test"), + options: []ApproverOption{ + WithPrefixes("x1:webpa:api:"), + }, + }, + { + capabilities: []string{"x1:webpa:api:(?!foo):put"}, // bad expression + request: suite.newRequest("GET", "/test"), + options: []ApproverOption{ + WithPrefixes("x1:webpa:api:"), + }, + }, + } + + for i, testCase := range testCases { + suite.Run(strconv.Itoa(i), func() { + var ( + token = suite.newToken(testCase.capabilities...) + ca = suite.newApprover(testCase.options...) + ) + + err := ca.Approve(context.Background(), testCase.request, token) + suite.ErrorIs(err, bascule.ErrUnauthorized) + suite.NotEmpty(err.Error()) + + // if the returned error provides a 'StatusCode() int' method, + // it must return http.StatusForbidden. + type statusCoder interface { + StatusCode() int + } + + var sc statusCoder + if errors.As(err, &sc) { + suite.Equal(http.StatusForbidden, sc.StatusCode()) + } + }) + } +} + +func (suite *ApproverTestSuite) TestApprove() { + suite.Run("MissingCapabilities", suite.testApproveMissingCapabilities) + suite.Run("Success", suite.testApproveSuccess) + suite.Run("Unauthorized", suite.testApproveUnauthorized) +} + +func TestApprover(t *testing.T) { + suite.Run(t, new(ApproverTestSuite)) +} diff --git a/basculehttp/basculecaps/doc.go b/basculehttp/basculecaps/doc.go new file mode 100644 index 0000000..3422499 --- /dev/null +++ b/basculehttp/basculecaps/doc.go @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC +// SPDX-License-Identifier: Apache-2.0 + +/* +Package basculecaps provide a standard format for token capabilities in the +context of HTTP-based workflow. Capabilities handled by this package are +expected to be of the format {prefix}{url pattern}:{method}. + +The prefix can be a string literal or a regular expression. If it is a regular +expression, it must not contain subexpressions. A prefix may also be the empty string. + +The url pattern is expected to be a regular expression that matches request URLs +that the token is authorized to access. This pattern may also be a string literal, +but it cannot be blank and cannot contain subexpressions. + +The method portion of the capability is a string literal that matches the request's +method. The special token "all" is used to designate any regular expression. This +special "all" token may be altered through configuration, but it cannot be an +empty string. +*/ +package basculecaps From caaa6272eb9fca249c6bfaccb520d7547d002c3d Mon Sep 17 00:00:00 2001 From: johnabass Date: Tue, 20 Aug 2024 11:58:58 -0700 Subject: [PATCH 5/7] custom HTTP capabilities moved to their own subpackage --- basculehttp/capabilities.go | 243 --------------------------------- basculehttp/middleware_test.go | 18 --- basculehttp/testSuite_test.go | 18 +++ 3 files changed, 18 insertions(+), 261 deletions(-) delete mode 100644 basculehttp/capabilities.go diff --git a/basculehttp/capabilities.go b/basculehttp/capabilities.go deleted file mode 100644 index 4e2b55a..0000000 --- a/basculehttp/capabilities.go +++ /dev/null @@ -1,243 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC -// SPDX-License-Identifier: Apache-2.0 - -package basculehttp - -import ( - "context" - "errors" - "fmt" - "net/http" - "regexp" - "strings" - - "github.com/xmidt-org/bascule" - "go.uber.org/multierr" -) - -const ( - // DefaultAllMethod is one of the default method strings that will match any HTTP method. - DefaultAllMethod = "all" - - // DefaultWildcardMethod is one of the default method strings that will match any HTTP method. - DefaultWildcardMethod = "*" -) - -var ( - // ErrMissingCapabilities indicates that a token had no capabilities - // and thus is unauthorized. - ErrMissingCapabilities = errors.New("no capabilities in token") -) - -// urlPathNormalization ensures that the given URL has a leading slash. -func urlPathNormalization(url string) string { - if url[0] == '/' { - return url - } - - return "/" + url -} - -// CapabilityUnauthorizedError indicates that a given capability was -// rejected and the token is unauthorized. -type CapabilityUnauthorizedError struct { - // Match is the match string in : format - // that matched the capability but did not match the resource request. - Match string - - // Capability is the capability string from the token that was rejected. - Capability string - - // Err is any error that occurred. This will be returned from Unwrap. - Err error -} - -func (cue *CapabilityUnauthorizedError) Unwrap() error { - return cue.Err -} - -func (cue *CapabilityUnauthorizedError) StatusCode() int { - return http.StatusForbidden -} - -func (cue *CapabilityUnauthorizedError) Error() string { - var o strings.Builder - o.WriteString(`Capability [`) - o.WriteString(cue.Capability) - o.WriteString(`] was rejected due to [`) - o.WriteString(cue.Match) - o.WriteRune(']') - - if cue.Err != nil { - o.WriteString(`: `) - o.WriteString(cue.Err.Error()) - } - - return o.String() -} - -// CapabilityApproverOption is a configurable option used to create a CapabilityApprover. -type CapabilityApproverOption interface { - apply(*CapabilityApprover) error -} - -type capabilityApproverOptionFunc func(*CapabilityApprover) error - -func (caof capabilityApproverOptionFunc) apply(ca *CapabilityApprover) error { return caof(ca) } - -// WithCapabilityPrefixes adds several prefixes used to match capabilities, e.g. x1:webpa:foo:. Only -// the first prefix found during matching is considered for authorization. If no prefixes -// are set via this option, the resulting approver will not authorize any requests. -// -// Note that a prefix can itself be a regular expression, but may not have any subexpressions. -func WithCapabilityPrefixes(prefixes ...string) CapabilityApproverOption { - return capabilityApproverOptionFunc(func(ca *CapabilityApprover) error { - for _, p := range prefixes { - re, err := regexp.Compile("^" + p + "(.+):(.+?)$") - switch { - case err != nil: - return fmt.Errorf("Unable to compile capability prefix [%s]: %s", p, err) - - case re.NumSubexp() != 2: - return fmt.Errorf("The prefix [%s] cannot have subexpressions", p) - - default: - ca.matchers = append(ca.matchers, re) - } - } - - return nil - }) -} - -// WithCapabilityAllMethods changes the values used to signal a match of all HTTP methods. -// By default, both DefaultAllMethod and DefaultWildcardMethod, if present in a capability, -// will match any HTTP method. This option overwrites the default, and is cumulative. -// However, a caller can add values to the default by using -// WithCapabilityAllMethods(DefaultAllMethod, DefaultWildcardMethod, "myvalue", ...). -func WithCapabilityAllMethods(v ...string) CapabilityApproverOption { - return capabilityApproverOptionFunc(func(ca *CapabilityApprover) error { - if ca.allMethods == nil { - ca.allMethods = make(map[string]bool, len(v)) - } - - for _, matchAll := range v { - ca.allMethods[matchAll] = true - } - - return nil - }) -} - -// CapabilityApprover is a bascule HTTP approver that authorizes tokens -// with capabilities against requests. -// -// This approver expects capabilities in tokens to be of the form :. -// -// The allowed prefixes must be set via one or more WithCapabilityPrefixes options. Prefixes -// may themselves contain colon delimiters and can be regular expressions without subexpressions. -type CapabilityApprover struct { - matchers []*regexp.Regexp - allMethods map[string]bool -} - -// NewCapabilityApprover creates a CapabilityApprover using the supplied options. -// At least (1) of the configured prefixes must match an HTTP request's URL in -// ordered for a token to be authorized. -// -// If no prefixes are added via WithCapabilityPrefix, then the returned approver -// will not authorize any requests. -func NewCapabilityApprover(opts ...CapabilityApproverOption) (ca *CapabilityApprover, err error) { - ca = new(CapabilityApprover) - for _, o := range opts { - err = multierr.Append(err, o.apply(ca)) - } - - switch { - case err != nil: - ca = nil - - default: - if len(ca.allMethods) == 0 { - // enforce the defaults - ca.allMethods = map[string]bool{ - DefaultAllMethod: true, - DefaultWildcardMethod: true, - } - } - } - - return -} - -// Approve attempts to match each capability to a configured prefix. Then, for any matched prefix, -// the URL regexp and method in the capability must match the resource. URLs are normalized -// with a leading '/'. -// -// This method returns success (i.e. a nil error) when the first matching capability is found. -func (ca *CapabilityApprover) Approve(_ context.Context, resource *http.Request, token bascule.Token) error { - capabilities, ok := bascule.GetCapabilities(token) - if len(capabilities) == 0 || !ok { - return ErrMissingCapabilities - } - - for _, matcher := range ca.matchers { - for _, capability := range capabilities { - substrings := matcher.FindStringSubmatch(capability) - if len(substrings) < 2 { - // no match - continue - } - - // the format of capabilities is : - // and will be substrings - err := ca.approveURL(resource, substrings[1]) - if err == nil { - err = ca.approveMethod(resource, substrings[2]) - } - - if err != nil { - err = &CapabilityUnauthorizedError{ - Match: matcher.String(), - Capability: capability, - Err: err, - } - } - - // stop at the first match, regardless of result - return err - } - } - - // none of the matchers matched any capability, OR there were no matchers configured - return bascule.ErrUnauthorized -} - -func (ca *CapabilityApprover) approveMethod(resource *http.Request, capabilityMethod string) error { - switch { - case ca.allMethods[capabilityMethod]: - return nil - - case capabilityMethod == strings.ToLower(resource.Method): - return nil - - default: - return fmt.Errorf("method does not match request method [%s]", resource.Method) - } -} - -func (ca *CapabilityApprover) approveURL(resource *http.Request, capabilityURL string) error { - resourcePath := resource.URL.EscapedPath() - - re, err := regexp.Compile(urlPathNormalization(capabilityURL)) - if err != nil { - return err - } - - indices := re.FindStringIndex(urlPathNormalization(resourcePath)) - if len(indices) < 1 || indices[0] != 0 { - return fmt.Errorf("url does not match request URL [%s]", resourcePath) - } - - return nil -} diff --git a/basculehttp/middleware_test.go b/basculehttp/middleware_test.go index f7ec5dd..57f185e 100644 --- a/basculehttp/middleware_test.go +++ b/basculehttp/middleware_test.go @@ -19,24 +19,6 @@ type MiddlewareTestSuite struct { TestSuite } -// newAuthenticator creates a bascule.Authenticator that is expected to be valid. -// Assertions as to validity are made prior to returning. -func (suite *MiddlewareTestSuite) newAuthenticator(opts ...bascule.AuthenticatorOption[*http.Request]) *bascule.Authenticator[*http.Request] { - a, err := NewAuthenticator(opts...) - suite.Require().NoError(err) - suite.Require().NotNil(a) - return a -} - -// newAuthorizer creates a bascule.Authorizer that is expected to be valid. -// Assertions as to validity are made prior to returning. -func (suite *MiddlewareTestSuite) newAuthorizer(opts ...bascule.AuthorizerOption[*http.Request]) *bascule.Authorizer[*http.Request] { - a, err := NewAuthorizer(opts...) - suite.Require().NoError(err) - suite.Require().NotNil(a) - return a -} - // newMiddleware creates a Middleware that is expected to be valid. // Assertions as to validity are made prior to returning. func (suite *MiddlewareTestSuite) newMiddleware(opts ...MiddlewareOption) *Middleware { diff --git a/basculehttp/testSuite_test.go b/basculehttp/testSuite_test.go index de1f17f..2452e38 100644 --- a/basculehttp/testSuite_test.go +++ b/basculehttp/testSuite_test.go @@ -62,3 +62,21 @@ func (suite *TestSuite) newAuthorizationParser(opts ...AuthorizationParserOption suite.Require().NotNil(ap) return ap } + +// newAuthenticator creates a bascule.Authenticator that is expected to be valid. +// Assertions as to validity are made prior to returning. +func (suite *MiddlewareTestSuite) newAuthenticator(opts ...bascule.AuthenticatorOption[*http.Request]) *bascule.Authenticator[*http.Request] { + a, err := NewAuthenticator(opts...) + suite.Require().NoError(err) + suite.Require().NotNil(a) + return a +} + +// newAuthorizer creates a bascule.Authorizer that is expected to be valid. +// Assertions as to validity are made prior to returning. +func (suite *MiddlewareTestSuite) newAuthorizer(opts ...bascule.AuthorizerOption[*http.Request]) *bascule.Authorizer[*http.Request] { + a, err := NewAuthorizer(opts...) + suite.Require().NoError(err) + suite.Require().NotNil(a) + return a +} From 2307f48782a434344e5930bd972733133085b631 Mon Sep 17 00:00:00 2001 From: johnabass Date: Tue, 20 Aug 2024 12:32:30 -0700 Subject: [PATCH 6/7] made the API and its implementation easier to reason about; fixed a bug with multiple capabilities --- basculehttp/basculecaps/approver.go | 81 +++--------------------- basculehttp/basculecaps/approver_test.go | 14 +++- 2 files changed, 22 insertions(+), 73 deletions(-) diff --git a/basculehttp/basculecaps/approver.go b/basculehttp/basculecaps/approver.go index 38a8fd5..1ec3908 100644 --- a/basculehttp/basculecaps/approver.go +++ b/basculehttp/basculecaps/approver.go @@ -20,14 +20,6 @@ const ( DefaultAllMethod = "all" ) -var ( - // ErrMissingCapabilities indicates that a token had no capabilities - // and thus is unauthorized. - ErrMissingCapabilities = &UnauthorizedError{ - Err: errors.New("no capabilities in token"), - } -) - // urlPathNormalization ensures that the given URL has a leading slash. func urlPathNormalization(url string) string { if url[0] == '/' { @@ -37,48 +29,6 @@ func urlPathNormalization(url string) string { return "/" + url } -// UnauthorizedError indicates that a given capability was rejected and -// that the token is unauthorized. -type UnauthorizedError struct { - // Match is the regular expression that matched the capability. - // This will be unset if no match occurred, i.e. if there were - // no capabilities in the token. - Match string - - // Capability is the capability string from the token that was rejected. - // This will be unset if there were no capabilities in the token. - Capability string - - // Err is any error that occurred. This is NOT returned by Unwrap. - Err error -} - -// Unwrap always returns bascule.ErrUnauthorized, even if the Err field is set. -func (ue *UnauthorizedError) Unwrap() error { - return bascule.ErrUnauthorized -} - -// StatusCode always returns http.StatusForbidden. -func (*UnauthorizedError) StatusCode() int { - return http.StatusForbidden -} - -func (ue *UnauthorizedError) Error() string { - var o strings.Builder - o.WriteString(`Capability [`) - o.WriteString(ue.Capability) - o.WriteString(`] was rejected due to [`) - o.WriteString(ue.Match) - o.WriteRune(']') - - if ue.Err != nil { - o.WriteString(`: `) - o.WriteString(ue.Err.Error()) - } - - return o.String() -} - // ApproverOption is a configurable option used to create an Approver. type ApproverOption interface { apply(*Approver) error @@ -88,9 +38,8 @@ type approverOptionFunc func(*Approver) error func (aof approverOptionFunc) apply(a *Approver) error { return aof(a) } -// WithPrefixes adds several prefixes used to match capabilities, e.g. x1:webpa:foo:. Only -// the first prefix found during matching is considered for authorization. If no prefixes -// are set via this option, the resulting approver will not authorize any requests. +// WithPrefixes adds several prefixes used to match capabilities, e.g. x1:webpa:foo:. +// If no prefixes are set via this option, the approver rejects all tokens. // // Note that a prefix can itself be a regular expression, but may not have any subexpressions. func WithPrefixes(prefixes ...string) ApproverOption { @@ -166,16 +115,11 @@ func NewApprover(opts ...ApproverOption) (a *Approver, err error) { // the URL regexp and method in the capability must match the resource. URLs are normalized // with a leading '/'. // -// This method returns success (i.e. a nil error) when the first matching capability is found. -// -// This method always returns either bascule.ErrUnauthorized or an *UnauthorizedError, which wraps -// bascule.ErrUnauthorized. +// This method returns success (i.e. a nil error) when the first matching capability is found. If +// the token provided no capabilities, or if none of the token's capabilities authorized the request, +// this method returns bascule.ErrUnauthorized. func (a *Approver) Approve(_ context.Context, resource *http.Request, token bascule.Token) error { - capabilities, ok := bascule.GetCapabilities(token) - if len(capabilities) == 0 || !ok { - return ErrMissingCapabilities - } - + capabilities, _ := bascule.GetCapabilities(token) for _, matcher := range a.matchers { for _, capability := range capabilities { substrings := matcher.FindStringSubmatch(capability) @@ -191,20 +135,13 @@ func (a *Approver) Approve(_ context.Context, resource *http.Request, token basc err = a.approveMethod(resource, substrings[2]) } - if err != nil { - err = &UnauthorizedError{ - Match: matcher.String(), - Capability: capability, - Err: err, - } + if err == nil { + // success! + return nil } - - // stop at the first match, regardless of result - return err } } - // none of the matchers matched any capability, OR there were no matchers configured return bascule.ErrUnauthorized } diff --git a/basculehttp/basculecaps/approver_test.go b/basculehttp/basculecaps/approver_test.go index 5240ca8..b698775 100644 --- a/basculehttp/basculecaps/approver_test.go +++ b/basculehttp/basculecaps/approver_test.go @@ -85,7 +85,7 @@ func (suite *ApproverTestSuite) TestInvalidAllMethod() { func (suite *ApproverTestSuite) testApproveMissingCapabilities() { ca := suite.newApprover() // don't need any options for this case err := ca.Approve(context.Background(), suite.newRequest("GET", "/test"), new(testToken)) - suite.ErrorIs(err, ErrMissingCapabilities) + suite.ErrorIs(err, bascule.ErrUnauthorized) } func (suite *ApproverTestSuite) testApproveSuccess() { @@ -115,6 +115,18 @@ func (suite *ApproverTestSuite) testApproveSuccess() { WithPrefixes("x1:xmidt:api:", "x1:webpa:api:"), }, }, + { + capabilities: []string{ + "x1:xmidt:api:/device/.*/config:all", + "x1:webpa:api:/something/else:get", + "x1:doesnot:apply:.*:all", + "x1:webpa:api:/test/.*:put", // this should match + }, + request: suite.newRequest("PUT", "/test/foo"), + options: []ApproverOption{ + WithPrefixes("x1:xmidt:api:", "x1:webpa:api:"), + }, + }, { capabilities: []string{"x1:webpa:api:/test/.*:custom"}, request: suite.newRequest("PATCH", "/test/foo"), From 9fd9b365e9f1651a6c5755bad12f1f9d780abcbc Mon Sep 17 00:00:00 2001 From: johnabass Date: Tue, 20 Aug 2024 12:33:13 -0700 Subject: [PATCH 7/7] chore: pruned test assertions that no longer apply --- basculehttp/basculecaps/approver_test.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/basculehttp/basculecaps/approver_test.go b/basculehttp/basculecaps/approver_test.go index b698775..f4def5d 100644 --- a/basculehttp/basculecaps/approver_test.go +++ b/basculehttp/basculecaps/approver_test.go @@ -5,7 +5,6 @@ package basculecaps import ( "context" - "errors" "net/http" "net/http/httptest" "strconv" @@ -194,18 +193,6 @@ func (suite *ApproverTestSuite) testApproveUnauthorized() { err := ca.Approve(context.Background(), testCase.request, token) suite.ErrorIs(err, bascule.ErrUnauthorized) - suite.NotEmpty(err.Error()) - - // if the returned error provides a 'StatusCode() int' method, - // it must return http.StatusForbidden. - type statusCoder interface { - StatusCode() int - } - - var sc statusCoder - if errors.As(err, &sc) { - suite.Equal(http.StatusForbidden, sc.StatusCode()) - } }) } }