-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #279 from xmidt-org/feature/generic-workflow
Feature/generic workflow
- Loading branch information
Showing
16 changed files
with
935 additions
and
455 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package bascule | ||
|
||
import ( | ||
"context" | ||
|
||
"go.uber.org/multierr" | ||
) | ||
|
||
// Approver is a strategy for determining if a given token represents | ||
// adequate permissions to access a resource. Approvers are used | ||
// as part of bascule's authorization workflow. | ||
type Approver[R any] interface { | ||
// Approve tests if a given token holds the correct permissions to | ||
// access a given resource. If this method needs to access external | ||
// systems, it should pass the supplied context to honor context | ||
// cancelation semantics. | ||
// | ||
// If this method doesn't support the given token, it should return nil. | ||
Approve(ctx context.Context, resource R, token Token) error | ||
} | ||
|
||
// ApproverFunc is a closure type that implements Approver. | ||
type ApproverFunc[R any] func(context.Context, R, Token) error | ||
|
||
func (af ApproverFunc[R]) Approve(ctx context.Context, resource R, token Token) error { | ||
return af(ctx, resource, token) | ||
} | ||
|
||
// Approvers is an aggregate Approver. | ||
type Approvers[R any] []Approver[R] | ||
|
||
// Append tacks on one or more approvers to this collection. The possibly | ||
// new Approvers instance is returned. The semantics of this method are | ||
// the same as the built-in append. | ||
func (as Approvers[R]) Append(more ...Approver[R]) Approvers[R] { | ||
return append(as, more...) | ||
} | ||
|
||
// Approve requires all approvers in this sequence to allow access. This | ||
// method supplies a logical AND. | ||
// | ||
// Because authorization can be arbitrarily expensive, execution halts at the first failed | ||
// authorization attempt. | ||
func (as Approvers[R]) Approve(ctx context.Context, resource R, token Token) error { | ||
for _, a := range as { | ||
if err := a.Approve(ctx, resource, token); err != nil { | ||
return err | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
type requireAny[R any] struct { | ||
a Approvers[R] | ||
} | ||
|
||
// Approve returns nil at the first approver that returns nil, i.e. accepts the access. | ||
// Otherwise, this method returns an aggregate error of all the authorization errors. | ||
func (ra requireAny[R]) Approve(ctx context.Context, resource R, token Token) error { | ||
var err error | ||
for _, a := range ra.a { | ||
authErr := a.Approve(ctx, resource, token) | ||
if authErr == nil { | ||
return nil | ||
} | ||
|
||
err = multierr.Append(err, authErr) | ||
} | ||
|
||
return err | ||
} | ||
|
||
// Any returns an Approver which is a logical OR: each approver is executed in | ||
// order, and any approver that allows access results in an immediate return. The | ||
// returned Approver's state is distinct and is unaffected by subsequent changes | ||
// to the Approvers set. | ||
// | ||
// Any error returns from the returned Approver will be an aggregate of all the errors | ||
// returned from each element. | ||
func (as Approvers[R]) Any() Approver[R] { | ||
return requireAny[R]{ | ||
a: append( | ||
make(Approvers[R], 0, len(as)), | ||
as..., | ||
), | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package bascule | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/suite" | ||
) | ||
|
||
type ApproversTestSuite struct { | ||
TestSuite | ||
} | ||
|
||
func (suite *ApproversTestSuite) TestAuthorize() { | ||
const placeholderResource = "placeholder resource" | ||
approveErr := errors.New("expected Authorize error") | ||
|
||
testCases := []struct { | ||
name string | ||
results []error | ||
expectedErr error | ||
}{ | ||
{ | ||
name: "EmptyApprovers", | ||
results: nil, | ||
}, | ||
{ | ||
name: "OneSuccess", | ||
results: []error{nil}, | ||
}, | ||
{ | ||
name: "OneFailure", | ||
results: []error{approveErr}, | ||
expectedErr: approveErr, | ||
}, | ||
{ | ||
name: "FirstFailure", | ||
results: []error{approveErr, errors.New("should not be called")}, | ||
expectedErr: approveErr, | ||
}, | ||
{ | ||
name: "MiddleFailure", | ||
results: []error{nil, approveErr, errors.New("should not be called")}, | ||
expectedErr: approveErr, | ||
}, | ||
{ | ||
name: "AllSuccess", | ||
results: []error{nil, nil, nil}, | ||
}, | ||
} | ||
|
||
for _, testCase := range testCases { | ||
suite.Run(testCase.name, func() { | ||
var ( | ||
testCtx = suite.testContext() | ||
testToken = suite.testToken() | ||
as Approvers[string] | ||
) | ||
|
||
for _, err := range testCase.results { | ||
err := err | ||
as = as.Append( | ||
ApproverFunc[string](func(ctx context.Context, resource string, token Token) error { | ||
suite.Same(testCtx, ctx) | ||
suite.Equal(testToken, token) | ||
suite.Equal(placeholderResource, resource) | ||
return err | ||
}), | ||
) | ||
} | ||
|
||
suite.Equal( | ||
testCase.expectedErr, | ||
as.Approve(testCtx, placeholderResource, testToken), | ||
) | ||
}) | ||
} | ||
} | ||
|
||
func (suite *ApproversTestSuite) TestAny() { | ||
const placeholderResource = "placeholder resource" | ||
approveErr := errors.New("expected Authorize error") | ||
|
||
testCases := []struct { | ||
name string | ||
results []error | ||
expectedErr error | ||
}{ | ||
{ | ||
name: "EmptyApprovers", | ||
results: nil, | ||
}, | ||
{ | ||
name: "OneSuccess", | ||
results: []error{nil, errors.New("should not be called")}, | ||
}, | ||
{ | ||
name: "OnlyFailure", | ||
results: []error{approveErr}, | ||
expectedErr: approveErr, | ||
}, | ||
{ | ||
name: "FirstFailure", | ||
results: []error{approveErr, nil}, | ||
}, | ||
{ | ||
name: "LastSuccess", | ||
results: []error{approveErr, approveErr, nil}, | ||
}, | ||
} | ||
|
||
for _, testCase := range testCases { | ||
suite.Run(testCase.name, func() { | ||
var ( | ||
testCtx = suite.testContext() | ||
testToken = suite.testToken() | ||
as Approvers[string] | ||
) | ||
|
||
for _, err := range testCase.results { | ||
err := err | ||
as = as.Append( | ||
ApproverFunc[string](func(ctx context.Context, resource string, token Token) error { | ||
suite.Same(testCtx, ctx) | ||
suite.Equal(testToken, token) | ||
suite.Equal(placeholderResource, resource) | ||
return err | ||
}), | ||
) | ||
} | ||
|
||
anyAs := as.Any() | ||
suite.Equal( | ||
testCase.expectedErr, | ||
anyAs.Approve(testCtx, placeholderResource, testToken), | ||
) | ||
|
||
if len(as) > 0 { | ||
// the any instance should be distinct | ||
as[0] = ApproverFunc[string]( | ||
func(context.Context, string, Token) error { | ||
suite.Fail("should not have been called") | ||
return nil | ||
}, | ||
) | ||
|
||
suite.Equal( | ||
testCase.expectedErr, | ||
anyAs.Approve(testCtx, placeholderResource, testToken), | ||
) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestApprovers(t *testing.T) { | ||
suite.Run(t, new(ApproversTestSuite)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
// SPDX-FileCopyrightText: 2024 Comcast Cable Communications Management, LLC | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package bascule | ||
|
||
import "context" | ||
|
||
// AuthenticateEvent represents the result of bascule's authenticate workflow. | ||
type AuthenticateEvent[S any] struct { | ||
// Source is the object that was parsed to produce the token. | ||
// This field is always set. | ||
Source S | ||
|
||
// Token is the token that resulted from parsing the source. This field | ||
// will only be set if parsing was successful. | ||
Token Token | ||
|
||
// Err is the error that resulted from authentication. This field will be | ||
// nil for a successful authentication. | ||
Err error | ||
} | ||
|
||
// AuthenticatorOption is a configurable option for an Authenticator. | ||
type AuthenticatorOption[S any] interface { | ||
apply(*Authenticator[S]) error | ||
} | ||
|
||
type authenticatorOptionFunc[S any] func(*Authenticator[S]) error | ||
|
||
//nolint:unused | ||
func (aof authenticatorOptionFunc[S]) apply(a *Authenticator[S]) error { return aof(a) } | ||
|
||
// WithAuthenticateListeners adds listeners to the Authenticator being built. | ||
// Multiple calls for this option are cumulative. | ||
func WithAuthenticateListeners[S any](more ...Listener[AuthenticateEvent[S]]) AuthenticatorOption[S] { | ||
return authenticatorOptionFunc[S]( | ||
func(a *Authenticator[S]) error { | ||
a.listeners = a.listeners.Append(more...) | ||
return nil | ||
}, | ||
) | ||
} | ||
|
||
// WithAuthenticateListenerFuncs is a closure variant of WithAuthenticateListeners. | ||
func WithAuthenticateListenerFuncs[S any](more ...ListenerFunc[AuthenticateEvent[S]]) AuthenticatorOption[S] { | ||
return authenticatorOptionFunc[S]( | ||
func(a *Authenticator[S]) error { | ||
a.listeners = a.listeners.AppendFunc(more...) | ||
return nil | ||
}, | ||
) | ||
} | ||
|
||
// WithTokenParsers adds token parsers to the Authenticator being built. | ||
// Multiple calls for this option are cumulative. | ||
func WithTokenParsers[S any](more ...TokenParser[S]) AuthenticatorOption[S] { | ||
return authenticatorOptionFunc[S]( | ||
func(a *Authenticator[S]) error { | ||
a.parsers = a.parsers.Append(more...) | ||
return nil | ||
}, | ||
) | ||
} | ||
|
||
// WithValidators adds validators to the Authenticator being built. | ||
// Multiple calls for this option are cumulative. | ||
func WithValidators[S any](more ...Validator[S]) AuthenticatorOption[S] { | ||
return authenticatorOptionFunc[S]( | ||
func(a *Authenticator[S]) error { | ||
a.validators = a.validators.Append(more...) | ||
return nil | ||
}, | ||
) | ||
} | ||
|
||
// NewAuthenticator constructs an Authenticator workflow using the supplied options. | ||
// | ||
// At least (1) token parser must be supplied in the options, or this | ||
// function returns ErrNoTokenParsers. | ||
func NewAuthenticator[S any](opts ...AuthenticatorOption[S]) (a *Authenticator[S], err error) { | ||
a = new(Authenticator[S]) | ||
for i := 0; err == nil && i < len(opts); i++ { | ||
err = opts[i].apply(a) | ||
} | ||
|
||
if a.parsers.Len() == 0 { | ||
return nil, ErrNoTokenParsers | ||
} | ||
|
||
return | ||
} | ||
|
||
// Authenticator provides bascule's authentication workflow. This type handles | ||
// parsing tokens, validating them, and dispatching authentication events to listeners. | ||
type Authenticator[S any] struct { | ||
listeners Listeners[AuthenticateEvent[S]] | ||
parsers TokenParsers[S] | ||
validators Validators[S] | ||
} | ||
|
||
// Authenticate implements bascule's authentication pipeline. The following steps are | ||
// performed: | ||
// | ||
// (1) The token is extracted from the source using the configured parser(s) | ||
// (2) The token is validated using any configured validator(s) | ||
// (3) Appropriate events are dispatched to listeners after either of steps (1) or (2) | ||
func (a *Authenticator[S]) Authenticate(ctx context.Context, source S) (token Token, err error) { | ||
token, err = a.parsers.Parse(ctx, source) | ||
if err == nil { | ||
var next Token | ||
next, err = a.validators.Validate(ctx, source, token) | ||
if next != nil { | ||
token = next | ||
} | ||
} | ||
|
||
a.listeners.OnEvent(AuthenticateEvent[S]{ | ||
Source: source, | ||
Token: token, | ||
Err: err, | ||
}) | ||
|
||
return | ||
} |
Oops, something went wrong.