From 2fe0cfb3da653208483116890bd950005a08f41e Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Wed, 2 Aug 2023 15:02:47 +0000 Subject: [PATCH] add create/delete of auth relationships to permissions package Signed-off-by: Mike Mason --- pkg/permissions/config.go | 6 + .../mockpermissions/permissions.go | 50 ++++ .../mockpermissions/permissions_test.go | 49 ++++ pkg/permissions/options.go | 10 + pkg/permissions/permissions.go | 32 ++- pkg/permissions/relationships.go | 110 ++++++++ pkg/permissions/relationships_test.go | 266 ++++++++++++++++++ 7 files changed, 512 insertions(+), 11 deletions(-) create mode 100644 pkg/permissions/mockpermissions/permissions.go create mode 100644 pkg/permissions/mockpermissions/permissions_test.go create mode 100644 pkg/permissions/relationships.go create mode 100644 pkg/permissions/relationships_test.go diff --git a/pkg/permissions/config.go b/pkg/permissions/config.go index 7bab9d962..906999601 100644 --- a/pkg/permissions/config.go +++ b/pkg/permissions/config.go @@ -10,10 +10,16 @@ import ( type Config struct { // URL is the URL checks should be executed against URL string + + // IgnoreNoResponders will ignore no responder errors when auth relationship requests are published. + IgnoreNoResponders bool } // MustViperFlags adds permissions config flags and viper bindings func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) { flags.String("permissions-url", "", "sets the permissions url checks should be run against") viperx.MustBindFlag(v, "permissions.url", flags.Lookup("permissions-url")) + + flags.String("permissions-ignore-no-responders", "", "ignores no responder errors when auth relationship requests are published") + viperx.MustBindFlag(v, "permissions.ignoreAuthRelationshipNoResponders", flags.Lookup("permissions-ignore-no-responders")) } diff --git a/pkg/permissions/mockpermissions/permissions.go b/pkg/permissions/mockpermissions/permissions.go new file mode 100644 index 000000000..7bb455ebd --- /dev/null +++ b/pkg/permissions/mockpermissions/permissions.go @@ -0,0 +1,50 @@ +// Package mockpermissions implements permissions.AuthRelationshipRequestHandler. +// Simplifying testing of relationship creation in applications. +package mockpermissions + +import ( + "context" + + "github.com/stretchr/testify/mock" + "go.infratographer.com/permissions-api/pkg/permissions" + "go.infratographer.com/x/events" + "go.infratographer.com/x/gidx" +) + +var _ permissions.AuthRelationshipRequestHandler = (*MockPermissions)(nil) + +// MockPermissions implements permissions.AuthRelationshipRequestHandler. +type MockPermissions struct { + mock.Mock +} + +// ContextWithHandler returns the context with the mock permissions handler defined. +func (p *MockPermissions) ContextWithHandler(ctx context.Context) context.Context { + return context.WithValue(ctx, permissions.AuthRelationshipRequestHandlerCtxKey, p) +} + +// CreateAuthRelationships implements permissions.AuthRelationshipRequestHandler. +func (p *MockPermissions) CreateAuthRelationships(ctx context.Context, topic string, resourceID gidx.PrefixedID, relations ...events.AuthRelationshipRelation) error { + calledArgs := []interface{}{topic, resourceID} + + for _, rel := range relations { + calledArgs = append(calledArgs, rel) + } + + args := p.Called(calledArgs...) + + return args.Error(0) +} + +// DeleteAuthRelationships implements permissions.AuthRelationshipRequestHandler. +func (p *MockPermissions) DeleteAuthRelationships(ctx context.Context, topic string, resourceID gidx.PrefixedID, relations ...events.AuthRelationshipRelation) error { + calledArgs := []interface{}{topic, resourceID} + + for _, rel := range relations { + calledArgs = append(calledArgs, rel) + } + + args := p.Called(calledArgs...) + + return args.Error(0) +} diff --git a/pkg/permissions/mockpermissions/permissions_test.go b/pkg/permissions/mockpermissions/permissions_test.go new file mode 100644 index 000000000..cfcea1def --- /dev/null +++ b/pkg/permissions/mockpermissions/permissions_test.go @@ -0,0 +1,49 @@ +package mockpermissions_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.infratographer.com/permissions-api/pkg/permissions" + "go.infratographer.com/permissions-api/pkg/permissions/mockpermissions" + "go.infratographer.com/x/events" + "go.infratographer.com/x/gidx" +) + +func TestPermissions(t *testing.T) { + t.Run("create", func(t *testing.T) { + mockPerms := new(mockpermissions.MockPermissions) + + ctx := mockPerms.ContextWithHandler(context.Background()) + + relation := events.AuthRelationshipRelation{ + Relation: "parent", + SubjectID: "tnntten-abc", + } + + mockPerms.On("CreateAuthRelationships", "test", gidx.PrefixedID("tnntten-abc123"), relation).Return(nil) + + err := permissions.CreateAuthRelationships(ctx, "test", "tnntten-abc123", relation) + require.NoError(t, err) + + mockPerms.AssertExpectations(t) + }) + t.Run("delete", func(t *testing.T) { + mockPerms := new(mockpermissions.MockPermissions) + + ctx := mockPerms.ContextWithHandler(context.Background()) + + relation := events.AuthRelationshipRelation{ + Relation: "parent", + SubjectID: "tnntten-abc", + } + + mockPerms.On("DeleteAuthRelationships", "test", gidx.PrefixedID("tnntten-abc123"), relation).Return(nil) + + err := permissions.DeleteAuthRelationships(ctx, "test", "tnntten-abc123", relation) + require.NoError(t, err) + + mockPerms.AssertExpectations(t) + }) +} diff --git a/pkg/permissions/options.go b/pkg/permissions/options.go index d1db8fff8..3103a097d 100644 --- a/pkg/permissions/options.go +++ b/pkg/permissions/options.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/labstack/echo/v4/middleware" + "go.infratographer.com/x/events" "go.uber.org/zap" ) @@ -19,6 +20,15 @@ func WithLogger(logger *zap.SugaredLogger) Option { } } +// WithEventsPublisher sets the underlying event publisher the auth handler uses +func WithEventsPublisher(publisher events.AuthRelationshipPublisher) Option { + return func(p *Permissions) error { + p.publisher = publisher + + return nil + } +} + // WithHTTPClient sets the underlying http client the auth handler uses func WithHTTPClient(client *http.Client) Option { return func(p *Permissions) error { diff --git a/pkg/permissions/permissions.go b/pkg/permissions/permissions.go index 33b64d472..0f0ec2fdb 100644 --- a/pkg/permissions/permissions.go +++ b/pkg/permissions/permissions.go @@ -15,6 +15,7 @@ import ( "github.com/labstack/echo/v4/middleware" "github.com/pkg/errors" "go.infratographer.com/x/echojwtx" + "go.infratographer.com/x/events" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -35,23 +36,31 @@ var ( } tracer = otel.GetTracerProvider().Tracer("go.infratographer.com/permissions-api/pkg/permissions") + + // ErrPermissionsMiddlewareMissing is returned when a permissions method has been called but the middleware is missing. + ErrPermissionsMiddlewareMissing = errors.New("permissions middleware missing") ) // Permissions handles supporting authorization checks type Permissions struct { - enabled bool - logger *zap.SugaredLogger - client *http.Client - url *url.URL - skipper middleware.Skipper - defaultChecker Checker + enableChecker bool + + logger *zap.SugaredLogger + publisher events.AuthRelationshipPublisher + client *http.Client + url *url.URL + skipper middleware.Skipper + defaultChecker Checker + ignoreNoResponders bool } // Middleware produces echo middleware to handle authorization checks func (p *Permissions) Middleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - if !p.enabled || p.skipper(c) { + setAuthRelationshipRequestHandler(c, p) + + if !p.enableChecker || p.skipper(c) { setCheckerContext(c, p.defaultChecker) return next(c) @@ -161,10 +170,11 @@ func (p *Permissions) checker(c echo.Context, actor, token string) Checker { // New creates a new Permissions instance func New(config Config, options ...Option) (*Permissions, error) { p := &Permissions{ - enabled: config.URL != "", - client: defaultClient, - skipper: middleware.DefaultSkipper, - defaultChecker: DefaultDenyChecker, + enableChecker: config.URL != "", + client: defaultClient, + skipper: middleware.DefaultSkipper, + defaultChecker: DefaultDenyChecker, + ignoreNoResponders: config.IgnoreNoResponders, } if config.URL != "" { diff --git a/pkg/permissions/relationships.go b/pkg/permissions/relationships.go new file mode 100644 index 000000000..78ed9d185 --- /dev/null +++ b/pkg/permissions/relationships.go @@ -0,0 +1,110 @@ +package permissions + +import ( + "context" + "errors" + + "github.com/labstack/echo/v4" + "go.infratographer.com/x/events" + "go.infratographer.com/x/gidx" + "go.uber.org/multierr" +) + +var ( + // AuthRelationshipRequestHandlerCtxKey is the context key used to set the auth relationship request handler. + AuthRelationshipRequestHandlerCtxKey = authRelationshipRequestHandlerCtxKey{} +) + +type authRelationshipRequestHandlerCtxKey struct{} + +func setAuthRelationshipRequestHandler(c echo.Context, requestHandler AuthRelationshipRequestHandler) { + req := c.Request().WithContext( + context.WithValue( + c.Request().Context(), + AuthRelationshipRequestHandlerCtxKey, + requestHandler, + ), + ) + + c.SetRequest(req) +} + +// AuthRelationshipRequestHandler defines the required methods to create or update an auth relationship. +type AuthRelationshipRequestHandler interface { + CreateAuthRelationships(ctx context.Context, topic string, resourceID gidx.PrefixedID, relations ...events.AuthRelationshipRelation) error + DeleteAuthRelationships(ctx context.Context, topic string, resourceID gidx.PrefixedID, relations ...events.AuthRelationshipRelation) error +} + +func (p *Permissions) submitAuthRelationshipRequest(ctx context.Context, topic string, request events.AuthRelationshipRequest) error { + if err := request.Validate(); err != nil { + return err + } + + // if no publisher is defined, requests are disabled. + if p.publisher == nil { + return nil + } + + var errs []error + + resp, err := p.publisher.PublishAuthRelationshipRequest(ctx, topic, request) + if err != nil { + if p.ignoreNoResponders && errors.Is(err, events.ErrRequestNoResponders) { + return nil + } + + errs = append(errs, err) + } + + if resp != nil { + if resp.Error() != nil { + errs = append(errs, err) + } + + errs = append(errs, resp.Message().Errors...) + } + + return multierr.Combine(errs...) +} + +// CreateAuthRelationships publishes a create auth relationship request, blocking until a response has been received. +func (p *Permissions) CreateAuthRelationships(ctx context.Context, topic string, resourceID gidx.PrefixedID, relations ...events.AuthRelationshipRelation) error { + request := events.AuthRelationshipRequest{ + Action: events.WriteAuthRelationshipAction, + ObjectID: resourceID, + Relations: relations, + } + + return p.submitAuthRelationshipRequest(ctx, topic, request) +} + +// DeleteAuthRelationships publishes a delete auth relationship request, blocking until a response has been received. +func (p *Permissions) DeleteAuthRelationships(ctx context.Context, topic string, resourceID gidx.PrefixedID, relations ...events.AuthRelationshipRelation) error { + request := events.AuthRelationshipRequest{ + Action: events.DeleteAuthRelationshipAction, + ObjectID: resourceID, + Relations: relations, + } + + return p.submitAuthRelationshipRequest(ctx, topic, request) +} + +// CreateAuthRelationships publishes a create auth relationship request, blocking until a response has been received. +func CreateAuthRelationships(ctx context.Context, topic string, resourceID gidx.PrefixedID, relations ...events.AuthRelationshipRelation) error { + handler, ok := ctx.Value(AuthRelationshipRequestHandlerCtxKey).(AuthRelationshipRequestHandler) + if !ok { + return ErrPermissionsMiddlewareMissing + } + + return handler.CreateAuthRelationships(ctx, topic, resourceID, relations...) +} + +// DeleteAuthRelationships publishes a delete auth relationship request, blocking until a response has been received. +func DeleteAuthRelationships(ctx context.Context, topic string, resourceID gidx.PrefixedID, relations ...events.AuthRelationshipRelation) error { + handler, ok := ctx.Value(AuthRelationshipRequestHandlerCtxKey).(AuthRelationshipRequestHandler) + if !ok { + return ErrPermissionsMiddlewareMissing + } + + return handler.DeleteAuthRelationships(ctx, topic, resourceID, relations...) +} diff --git a/pkg/permissions/relationships_test.go b/pkg/permissions/relationships_test.go new file mode 100644 index 000000000..75cd6a12f --- /dev/null +++ b/pkg/permissions/relationships_test.go @@ -0,0 +1,266 @@ +package permissions_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "go.infratographer.com/permissions-api/pkg/permissions" + "go.infratographer.com/x/events" + "go.infratographer.com/x/gidx" + "go.infratographer.com/x/testing/eventtools" +) + +func TestMiddlewareMissing(t *testing.T) { + ctx := context.Background() + + err := permissions.CreateAuthRelationships(ctx, "test", gidx.NullPrefixedID) + require.Error(t, err) + require.ErrorIs(t, err, permissions.ErrPermissionsMiddlewareMissing) + + err = permissions.DeleteAuthRelationships(ctx, "test", gidx.NullPrefixedID) + require.Error(t, err) + require.ErrorIs(t, err, permissions.ErrPermissionsMiddlewareMissing) +} + +func TestNoRespondersIgnore(t *testing.T) { + t.Run("not ignored", func(t *testing.T) { + ctx := context.Background() + + mockEvents := new(eventtools.MockConnection) + + config := permissions.Config{ + IgnoreNoResponders: false, + } + + perms, err := permissions.New(config, permissions.WithEventsPublisher(mockEvents)) + require.NoError(t, err) + + relation := events.AuthRelationshipRelation{ + Relation: "parent", + SubjectID: "testten-abc", + } + + expectRelationshipRequest := events.AuthRelationshipRequest{ + Action: events.WriteAuthRelationshipAction, + ObjectID: "testten-abc123", + Relations: []events.AuthRelationshipRelation{ + relation, + }, + } + + responseMessage := new(eventtools.MockMessage[events.AuthRelationshipResponse]) + + responseMessage.On("Message").Return(events.AuthRelationshipResponse{}) + responseMessage.On("Error").Return(nil) + + mockEvents.On("PublishAuthRelationshipRequest", "test", expectRelationshipRequest).Return(responseMessage, events.ErrRequestNoResponders) + + err = perms.CreateAuthRelationships(ctx, "test", "testten-abc123", relation) + require.Error(t, err) + require.ErrorIs(t, err, events.ErrRequestNoResponders) + + mockEvents.AssertExpectations(t) + }) + t.Run("ignored", func(t *testing.T) { + ctx := context.Background() + + mockEvents := new(eventtools.MockConnection) + + config := permissions.Config{ + IgnoreNoResponders: true, + } + + perms, err := permissions.New(config, permissions.WithEventsPublisher(mockEvents)) + require.NoError(t, err) + + relation := events.AuthRelationshipRelation{ + Relation: "parent", + SubjectID: "testten-abc", + } + + expectRelationshipRequest := events.AuthRelationshipRequest{ + Action: events.WriteAuthRelationshipAction, + ObjectID: "testten-abc123", + Relations: []events.AuthRelationshipRelation{ + relation, + }, + } + + responseMessage := new(eventtools.MockMessage[events.AuthRelationshipResponse]) + + mockEvents.On("PublishAuthRelationshipRequest", "test", expectRelationshipRequest).Return(responseMessage, events.ErrRequestNoResponders) + + err = perms.CreateAuthRelationships(ctx, "test", "testten-abc123", relation) + require.NoError(t, err) + + mockEvents.AssertExpectations(t) + }) +} + +func TestRelationshipCreate(t *testing.T) { + testCases := []struct { + name string + events bool + + resourceID gidx.PrefixedID + relation string + subjectID gidx.PrefixedID + + expectRequest *events.AuthRelationshipRequest + + responseErrors []error + + expectError error + }{ + { + "no events", + false, + "testten-abc123", + "parent", + "testten-abc", + nil, + nil, + nil, + }, + { + "missing resourceID", + true, + "", + "relation", + "subject", + nil, + nil, + events.ErrMissingAuthRelationshipRequestObjectID, + }, + { + "missing relation", + true, + "resource", + "", + "subject", + nil, + nil, + events.ErrMissingAuthRelationshipRequestRelationRelation, + }, + { + "missing subject", + true, + "resource", + "relation", + "", + nil, + nil, + events.ErrMissingAuthRelationshipRequestRelationSubjectID, + }, + { + "success", + true, + "testten-abc123", + "parent", + "testten-abc", + &events.AuthRelationshipRequest{ + Action: events.WriteAuthRelationshipAction, + ObjectID: "testten-abc123", + Relations: []events.AuthRelationshipRelation{ + { + Relation: "parent", + SubjectID: "testten-abc", + }, + }, + }, + nil, + nil, + }, + { + "response errors are returned", + true, + "testten-abc123", + "parent", + "testten-abc", + &events.AuthRelationshipRequest{ + Action: events.WriteAuthRelationshipAction, + ObjectID: "testten-abc123", + Relations: []events.AuthRelationshipRelation{ + { + Relation: "parent", + SubjectID: "testten-abc", + }, + }, + }, + []error{events.ErrProviderNotConfigured}, + events.ErrProviderNotConfigured, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockEvents := new(eventtools.MockConnection) + + var options []permissions.Option + + if tc.events { + options = append(options, permissions.WithEventsPublisher(mockEvents)) + } + + perms, err := permissions.New(permissions.Config{}, options...) + + require.NoError(t, err) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + + engine := echo.New() + + ctx := engine.NewContext(req, resp) + + var nextCalled bool + + nextFn := func(c echo.Context) error { + nextCalled = true + + return nil + } + + err = perms.Middleware()(nextFn)(ctx) + + require.NoError(t, err) + + require.True(t, nextCalled, "next should have been called") + + if tc.expectRequest != nil { + response := events.AuthRelationshipResponse{ + Errors: tc.responseErrors, + } + + respMsg := new(eventtools.MockMessage[events.AuthRelationshipResponse]) + + respMsg.On("Message").Return(response, nil) + respMsg.On("Error").Return(nil) + + mockEvents.On("PublishAuthRelationshipRequest", "test", *tc.expectRequest).Return(respMsg, nil) + } + + relation := events.AuthRelationshipRelation{ + Relation: tc.relation, + SubjectID: tc.subjectID, + } + + err = perms.CreateAuthRelationships(ctx.Request().Context(), "test", tc.resourceID, relation) + + mockEvents.AssertExpectations(t) + + if tc.expectError != nil { + require.Error(t, err, "expected error to be returned") + require.ErrorIs(t, err, tc.expectError, "unexpected error returned") + + return + } + + require.NoError(t, err) + }) + } +}