From 0e0981a6ced1ff2a8822e9589a2bf05069a29c8c Mon Sep 17 00:00:00 2001 From: shipperizer Date: Wed, 11 Sep 2024 17:01:55 +0100 Subject: [PATCH] feat: add ResourcesService --- pkg/resources/interfaces.go | 14 +++ pkg/resources/service.go | 125 +++++++++++++++++++ pkg/resources/service_test.go | 225 ++++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 pkg/resources/interfaces.go create mode 100644 pkg/resources/service.go create mode 100644 pkg/resources/service_test.go diff --git a/pkg/resources/interfaces.go b/pkg/resources/interfaces.go new file mode 100644 index 000000000..e844a45d7 --- /dev/null +++ b/pkg/resources/interfaces.go @@ -0,0 +1,14 @@ +// Copyright 2024 Canonical Ltd. +// SPDX-License-Identifier: AGPL-3.0 + +package resources + +import ( + "context" + + ofga "github.com/canonical/identity-platform-admin-ui/internal/openfga" +) + +type OpenFGAStoreInterface interface { + ListPermissionsWithFilters(context.Context, string, ...ofga.ListPermissionsFiltersInterface) ([]ofga.Permission, map[string]string, error) +} diff --git a/pkg/resources/service.go b/pkg/resources/service.go new file mode 100644 index 000000000..4d9c586da --- /dev/null +++ b/pkg/resources/service.go @@ -0,0 +1,125 @@ +// Copyright 2024 Canonical Ltd. +// SPDX-License-Identifier: AGPL-3.0 + +package resources + +import ( + "context" + "fmt" + "strings" + + "github.com/canonical/identity-platform-admin-ui/internal/http/types" + "github.com/canonical/identity-platform-admin-ui/pkg/authentication" + v1 "github.com/canonical/rebac-admin-ui-handlers/v1" + v1Resources "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + + "go.opentelemetry.io/otel/trace" + + "github.com/canonical/identity-platform-admin-ui/internal/logging" + "github.com/canonical/identity-platform-admin-ui/internal/monitoring" + ofga "github.com/canonical/identity-platform-admin-ui/internal/openfga" +) + +// V1Service contains the business logic to deal with resoruces on the Admin UI OpenFGA model +type V1Service struct { + store OpenFGAStoreInterface + + tracer trace.Tracer + monitor monitoring.MonitorInterface + logger logging.LoggerInterface +} + +// ListResources returns a page of Resource objects of at least `size` elements if available. +func (s *V1Service) ListResources(ctx context.Context, params *v1Resources.GetResourcesParams) (*v1Resources.PaginatedResponse[v1Resources.Resource], error) { + ctx, span := s.tracer.Start(ctx, "resources.V1Service.ListResources") + defer span.End() + + principal := authentication.PrincipalFromContext(ctx) + if principal == nil { + return nil, v1.NewAuthorizationError("unauthorized") + } + + paginator := types.NewTokenPaginator(s.tracer, s.logger) + filters := make([]ofga.ListPermissionsFiltersInterface, 0) + + if params != nil { + if eType := params.EntityType; eType != nil { + filters = append(filters, + ofga.NewTypesFilter(*eType), + ) + } + + if token := params.NextToken; token != nil { + err := paginator.LoadFromString(ctx, *token) + + if err == nil { + filters = append( + filters, + ofga.NewTokenMapFilter(paginator.GetAllTokens(ctx)), + ) + } + + } + + } + + filters = append(filters, ofga.NewRelationFilter(ofga.CAN_VIEW_RELATION)) + + // TODO using params.EntityId requires a different OpenFGA operation (namely the Check) + // not implementing that for now + resources, pageTokens, err := s.store.ListPermissionsWithFilters( + ctx, + principal.Identifier(), + filters..., + ) + + if err != nil { + return nil, v1.NewUnknownError(fmt.Sprintf("failed to get resources for user %s: %v", principal.Identifier(), err)) + } + + paginator.SetTokens(ctx, pageTokens) + metaParam, err := paginator.PaginationHeader(ctx) + + if err != nil { + s.logger.Errorf("error producing pagination meta param: %s", err) + metaParam = "" + } + + r := new(v1Resources.PaginatedResponse[v1Resources.Resource]) + r.Meta = v1Resources.ResponseMeta{Size: len(resources)} + r.Data = make([]v1Resources.Resource, 0) + r.Next.PageToken = &metaParam + + for _, resource := range resources { + res := strings.Split(resource.Object, ":") + + if len(res) != 2 { + s.logger.Warnf("invalid permission object %v", resource) + continue + + } + r.Data = append( + r.Data, + v1Resources.Resource{ + Entity: v1Resources.Entity{ + Id: res[1], + Name: res[1], + Type: res[0], + }, + }, + ) + } + + return r, nil +} + +func NewV1Service(store OpenFGAStoreInterface, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *V1Service { + s := new(V1Service) + + s.store = store + s.tracer = tracer + s.monitor = monitor + s.logger = logger + + return s +} diff --git a/pkg/resources/service_test.go b/pkg/resources/service_test.go new file mode 100644 index 000000000..ff8ec4545 --- /dev/null +++ b/pkg/resources/service_test.go @@ -0,0 +1,225 @@ +// Copyright 2024 Canonical Ltd. +// SPDX-License-Identifier: AGPL-3.0 + +package resources + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/canonical/identity-platform-admin-ui/internal/http/types" + "github.com/canonical/identity-platform-admin-ui/internal/monitoring" + ofga "github.com/canonical/identity-platform-admin-ui/internal/openfga" + "github.com/canonical/identity-platform-admin-ui/pkg/authentication" + v1 "github.com/canonical/rebac-admin-ui-handlers/v1" + "github.com/canonical/rebac-admin-ui-handlers/v1/interfaces" + v1Resources "github.com/canonical/rebac-admin-ui-handlers/v1/resources" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/trace" + "go.uber.org/mock/gomock" +) + +//go:generate mockgen -build_flags=--mod=mod -package resources -destination ./mock_logger.go -source=../../internal/logging/interfaces.go +//go:generate mockgen -build_flags=--mod=mod -package resources -destination ./mock_interfaces.go -source=./interfaces.go +//go:generate mockgen -build_flags=--mod=mod -package resources -destination ./mock_monitor.go -source=../../internal/monitoring/interfaces.go +//go:generate mockgen -build_flags=--mod=mod -package resources -destination ./mock_tracing.go go.opentelemetry.io/otel/trace Tracer +//go:generate mockgen -build_flags=--mod=mod -package resources -destination ./mock_pool.go -source=../../internal/pool/interfaces.go +//go:generate mockgen -build_flags=--mod=mod -package resources -destination ./mock_authentication.go -source=../authentication/interfaces.go + +func TestV1ServiceImplementsRebacServiceInterface(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var svc interface{} = new(V1Service) + + if _, ok := svc.(interfaces.ResourcesService); !ok { + t.Fatalf("V1Service doesnt implement interfaces.ResourcesService") + } +} + +func TestV1ServiceListResources(t *testing.T) { + ctrl, mockStore, mockLogger, mockTracer, mockMonitor, principal := setupTest(t) + defer ctrl.Finish() + + permissions := []ofga.Permission{ + {Relation: "can_view", Object: "client:grafana"}, + {Relation: "can_view", Object: "client:prometheus"}, + {Relation: "can_view", Object: "group:admin"}, + {Relation: "can_edit", Object: "group:random"}, + } + + currPageToken := map[string]string{ + "clients": "page-token", + } + + nextPageToken := map[string]string{ + "clients": "new-page-token", + } + + paginator := types.NewTokenPaginator(mockTracer, mockLogger) + paginator.SetTokens(context.Background(), currPageToken) + header, _ := paginator.PaginationHeader(context.Background()) + type testCase struct { + name string + contextSetup func() context.Context + input *v1Resources.GetResourcesParams + expectedResult *v1Resources.PaginatedResponse[v1Resources.Resource] + expectedError error + } + clientType := "client" + + tests := []testCase{ + { + name: "Successfully retrieves user resources with type", + + contextSetup: func() context.Context { + ctx := context.Background() + return authentication.PrincipalContext(ctx, principal) + }, + input: &v1Resources.GetResourcesParams{ + NextToken: &header, + EntityType: &clientType, + }, + expectedResult: &v1Resources.PaginatedResponse[v1Resources.Resource]{ + Meta: v1Resources.ResponseMeta{Size: 2}, + Data: []v1Resources.Resource{ + {Entity: v1Resources.Entity{Id: "grafana", Name: "grafana", Type: "client"}}, + {Entity: v1Resources.Entity{Id: "prometheus", Name: "prometheus", Type: "client"}}, + }, + }, + expectedError: nil, + }, + { + name: "Error while retrieving permissions", + contextSetup: func() context.Context { + ctx := context.Background() + return authentication.PrincipalContext(ctx, principal) + }, + expectedResult: nil, + expectedError: v1.NewUnknownError("failed to get resources for user mock-subject: error"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := test.contextSetup() + + mockStore.EXPECT().ListPermissionsWithFilters(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, ID string, opts ...ofga.ListPermissionsFiltersInterface) ([]ofga.Permission, map[string]string, error) { + + if ID != "mock-subject" { + t.Errorf("expecting ID to be %s not %s", "mock-subject", ID) + } + + ptypes := []string{"group", "role", "identity", "scheme", "provider", "client"} + for _, opt := range opts { + switch o := opt.(type) { + case *ofga.TypesFilter: + if test.input != nil && test.input.EntityType != nil { + if !reflect.DeepEqual(o.WithFilter().([]string), []string{*test.input.EntityType}) { + t.Errorf("expecting type filter to be %v not %s", test.input.EntityType, o.WithFilter()) + } + } + ptypes = o.WithFilter().([]string) + case *ofga.RelationFilter: + if !reflect.DeepEqual(o.WithFilter().(string), ofga.CAN_VIEW_RELATION) { + t.Errorf("expecting relation filter to be %v not %s", ofga.CAN_VIEW_RELATION, o.WithFilter()) + } + + case *ofga.TokenMapFilter: + if test.input != nil && test.input.NextToken != nil { + p := types.NewTokenPaginator(mockTracer, mockLogger) + p.SetTokens(context.Background(), o.WithFilter().(map[string]string)) + h, _ := paginator.PaginationHeader(ctx) + if !reflect.DeepEqual(h, *test.input.NextToken) { + t.Errorf("expecting token map filter to be %s not %s", *test.input.NextToken, h) + } + } + + } + } + + if test.expectedError != nil { + return nil, nextPageToken, fmt.Errorf("error") + } + + ps := make([]ofga.Permission, 0) + types := strings.Join(ptypes, ",") + + for _, p := range permissions { + rc := p.Relation == ofga.CAN_VIEW_RELATION + + pType := strings.Split(p.Object, ":")[0] + tc := strings.Contains(types, pType) + + if tc && rc { + ps = append(ps, p) + } + } + + return ps, nextPageToken, nil + }, + ) + + s := NewV1Service(mockStore, mockTracer, mockMonitor, mockLogger) + + result, err := s.ListResources(ctx, test.input) + + if test.expectedError != nil && err == nil { + t.Errorf("expected error to be %s not %s", test.expectedError, err) + } + + if err != nil { + return + } + + assert.Equal(t, test.expectedResult.Meta, result.Meta) + assert.Equal(t, test.expectedResult.Data, result.Data) + + paginator.SetTokens(ctx, nextPageToken) + expectedToken, _ := paginator.PaginationHeader(ctx) + assert.Equal(t, expectedToken, *result.Next.PageToken) + }) + } +} + +func setupTest(t *testing.T) ( + *gomock.Controller, + *MockOpenFGAStoreInterface, + *MockLoggerInterface, + *MockTracer, + *monitoring.MockMonitorInterface, + *authentication.ServicePrincipal, +) { + ctrl := gomock.NewController(t) + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockStore := NewMockOpenFGAStoreInterface(ctrl) + mockProvider := NewMockProviderInterface(ctrl) + + mockLogger.EXPECT().Errorf(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Error(gomock.Any()).AnyTimes() + mockTracer.EXPECT().Start(gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( + func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + return ctx, trace.SpanFromContext(ctx) + }, + ) + mockProvider.EXPECT().Verifier(gomock.Any()).Return( + oidc.NewVerifier("", nil, &oidc.Config{ + ClientID: "mock-client-id", + SkipExpiryCheck: true, + SkipIssuerCheck: true, + InsecureSkipSignatureCheck: true, + }), + ) + + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtb2NrLXN1YmplY3QiLCJhdWQiOiJtb2NrLWNsaWVudC1pZCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.BdspASNsnxeXnqZXZnFnkvv-ClMq0U6X1gCIUrh9V7c" + principal, _ := authentication.NewJWKSTokenVerifier(mockProvider, "mock-client-id", mockTracer, mockLogger, mockMonitor).VerifyAccessToken(context.TODO(), token) + + return ctrl, mockStore, mockLogger, mockTracer, mockMonitor, principal +}