-
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.
- Loading branch information
1 parent
da6ffba
commit 0e0981a
Showing
3 changed files
with
364 additions
and
0 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,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) | ||
} |
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,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 | ||
} |
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,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 | ||
} |