Skip to content

Commit

Permalink
Merge pull request #279 from xmidt-org/feature/generic-workflow
Browse files Browse the repository at this point in the history
Feature/generic workflow
  • Loading branch information
johnabass authored Aug 15, 2024
2 parents 6c3d833 + 06d72f6 commit 58b9aab
Show file tree
Hide file tree
Showing 16 changed files with 935 additions and 455 deletions.
91 changes: 91 additions & 0 deletions approver.go
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...,
),
}
}
162 changes: 162 additions & 0 deletions approver_test.go
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))
}
124 changes: 124 additions & 0 deletions authenticator.go
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
}
Loading

0 comments on commit 58b9aab

Please sign in to comment.