diff --git a/cmd/serve.go b/cmd/serve.go index 5568ca1e6..82c86b8b3 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -93,12 +93,13 @@ func serve() { logger.Info("Authorization is disabled, using noop authorizer") authzClient = openfga.NewNoopClient(tracer, monitor, logger) } + authorizer := authorization.NewAuthorizer(authzClient, tracer, monitor, logger) if authorizer.ValidateModel(context.Background()) != nil { panic("Invalid authorization model provided") } - router := web.NewRouter(idpConfig, schemasConfig, rulesConfig, hAdminClient, kAdminClient, tracer, monitor, logger) + router := web.NewRouter(idpConfig, schemasConfig, rulesConfig, hAdminClient, kAdminClient, authzClient, tracer, monitor, logger) logger.Infof("Starting server on port %v", specs.Port) diff --git a/go.mod b/go.mod index 317ce203b..1ee3ade34 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/go-chi/cors v1.2.1 github.com/google/uuid v1.4.0 github.com/kelseyhightower/envconfig v1.4.0 - github.com/openfga/go-sdk v0.3.3 + github.com/openfga/go-sdk v0.3.4 github.com/openfga/language/pkg/go v0.0.0-20240122114256-aaa86ab89379 github.com/ory/hydra-client-go/v2 v2.1.1 github.com/ory/kratos-client-go v1.0.0 @@ -79,7 +79,7 @@ require ( golang.org/x/exp v0.0.0-20231226003508-02704c960a9b // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/oauth2 v0.13.0 // indirect - golang.org/x/sync v0.5.0 // indirect + golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/term v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index 1348a8108..30e164ba2 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,8 @@ github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= github.com/openfga/api/proto v0.0.0-20231222042535-3037910c90c0 h1:FW+CjkR6GrvJcUj3EZZ75q1Otog4sy+FOwHLeqZ3DKo= github.com/openfga/api/proto v0.0.0-20231222042535-3037910c90c0/go.mod h1:YSbEQDNGnVlThfExHQ3zDNd+puWXf4zzfL0ms2VbIwI= -github.com/openfga/go-sdk v0.3.3 h1:eMZGCEDW/sz9S3gNxbpO5rNpDezz7cjT+zwjpDgqTt0= -github.com/openfga/go-sdk v0.3.3/go.mod h1:W4SNYMSxptGOtA9aGYxsYUmSC7LaZYP7y9qbT36ouCc= +github.com/openfga/go-sdk v0.3.4 h1:5VsDSmkXUP/XH9L4BtztYVPuthH5Pd3h1QZfqzZttL0= +github.com/openfga/go-sdk v0.3.4/go.mod h1:u1iErzj5E9/bhe+8nsMv0gigcYbJtImcdgcE5DmpbBg= github.com/openfga/language/pkg/go v0.0.0-20240122114256-aaa86ab89379 h1:j42rKsj3rt2TvTBcEoiWkZ8MFKzRFUA04WD4b/f/KNY= github.com/openfga/language/pkg/go v0.0.0-20240122114256-aaa86ab89379/go.mod h1:dHJaJ7H5tViBCPidTsfl3IOd152FhYxWFQmZXOhZ2pw= github.com/ory/hydra-client-go/v2 v2.1.1 h1:3JatU9uFbw5XhF3lgPCas1l1Kok2v5Mq1p26zZwGHNg= @@ -190,8 +190,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/authorization/schema.openfga b/internal/authorization/schema.openfga index b591ed72d..c6834ded1 100644 --- a/internal/authorization/schema.openfga +++ b/internal/authorization/schema.openfga @@ -12,17 +12,73 @@ type role define privileged: [privileged] define assignee: [user, group#member] or admin from privileged + define can_create: [user, role#assignee, group#member] or admin from privileged + define can_delete: [user, role#assignee, group#member] or admin from privileged + define can_edit: [user, role#assignee, group#member] or admin from privileged + define can_view: [user, user:*, role#assignee, group#member] or admin from privileged + type group - relations + relations + define privileged: [privileged] define member: [user, group#member] -type api - relations - define admin: [role#assignee, group#member] - define can_create: editor or admin - define can_delete: [user, role#assignee, group#member] or admin - define can_edit: editor or admin - define can_list: viewer or admin - define can_view: viewer or admin - define editor: [user, role#assignee, group#member] - define viewer: [user, role#assignee, group#member] or editor + define can_create: [user, role#assignee, group#member] or admin from privileged + define can_delete: [user, role#assignee, group#member] or admin from privileged + define can_edit: [user, role#assignee, group#member] or admin from privileged + define can_view: [user, user:*, role#assignee, group#member] or admin from privileged + +type identity + relations + define privileged: [privileged] + + define can_create: [user, role#assignee, group#member] or admin from privileged + define can_delete: [user, role#assignee, group#member] or admin from privileged + define can_edit: [user, role#assignee, group#member] or admin from privileged + define can_view: [user, user:*, role#assignee, group#member] or admin from privileged + +type scheme + relations + define privileged: [privileged] + + define can_create: [user, role#assignee, group#member] or admin from privileged + define can_delete: [user, role#assignee, group#member] or admin from privileged + define can_edit: [user, role#assignee, group#member] or admin from privileged + define can_view: [user, user:*, role#assignee, group#member] or admin from privileged + +type client + relations + define privileged: [privileged] + + define can_create: [user, role#assignee, group#member] or admin from privileged + define can_delete: [user, role#assignee, group#member] or admin from privileged + define can_edit: [user, role#assignee, group#member] or admin from privileged + define can_view: [user, user:*, role#assignee, group#member] or admin from privileged + +type provider + relations + define privileged: [privileged] + + define can_create: [user, role#assignee, group#member] or admin from privileged + define can_delete: [user, role#assignee, group#member] or admin from privileged + define can_edit: [user, role#assignee, group#member] or admin from privileged + define can_view: [user, user:*, role#assignee, group#member] or admin from privileged + +type rule + relations + define privileged: [privileged] + + define can_create: [user, role#assignee, group#member] or admin from privileged + define can_delete: [user, role#assignee, group#member] or admin from privileged + define can_edit: [user, role#assignee, group#member] or admin from privileged + define can_view: [user, user:*, role#assignee, group#member] or admin from privileged + +# need to model how to assign applications for the login UI, if copying current model or adjusting it +type application + relations + define privileged: [privileged] + + define can_create: [user, role#assignee, group#member] or admin from privileged + define can_delete: [user, role#assignee, group#member] or admin from privileged + define can_edit: [user, role#assignee, group#member] or admin from privileged + define can_view: [user, user:*, role#assignee, group#member] or admin from privileged + diff --git a/internal/authorization/types.go b/internal/authorization/types.go new file mode 100644 index 000000000..9341b1688 --- /dev/null +++ b/internal/authorization/types.go @@ -0,0 +1,51 @@ +// Copyright 2024 Canonical Ltd. +// SPDX-License-Identifier: AGPL + +package authorization + +import ( + "fmt" + "strings" +) + +const ( + PERMISSION_SEPARATOR = "::" +) + +type Urn struct { + relation string + object string +} + +func (a *Urn) ID() string { + return fmt.Sprintf("%s%s%s", a.relation, PERMISSION_SEPARATOR, a.object) +} + +func (a *Urn) Relation() string { + return a.relation +} + +func (a *Urn) Object() string { + return a.object +} + +func NewUrn(relation, object string) *Urn { + u := new(Urn) + + u.relation = relation + u.object = object + + return u +} + +func NewUrnFromURLParam(ID string) *Urn { + values := strings.Split(ID, PERMISSION_SEPARATOR) + + if len(values) < 2 { + // not a valid Urn + return nil + } + + // use only first two elements + return NewUrn(values[0], values[1]) +} diff --git a/internal/http/types/token.go b/internal/http/types/token.go new file mode 100644 index 000000000..32c14bd2e --- /dev/null +++ b/internal/http/types/token.go @@ -0,0 +1,102 @@ +// Copyright 2024 Canonical Ltd. +// SPDX-License-Identifier: AGPL + +package types + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + + "github.com/canonical/identity-platform-admin-ui/internal/logging" + "github.com/canonical/identity-platform-admin-ui/internal/tracing" + "go.opentelemetry.io/otel/trace" +) + +const ( + PAGINATION_HEADER = "X-Token-Pagination" +) + +type TokenPaginator struct { + tokens map[string]string + + tracer tracing.TracingInterface + logger logging.LoggerInterface +} + +func (p *TokenPaginator) LoadFromRequest(ctx context.Context, r *http.Request) error { + _, span := p.tracer.Start(ctx, "types.TokenPaginator.LoadFromRequest") + defer span.End() + + header := r.Header.Get(PAGINATION_HEADER) + + if header == "" { + return nil + } + + tokenMap, err := base64.StdEncoding.DecodeString(header) + + if err != nil { + p.logger.Errorf("issues decoding header: %s", err) + return err + } + + tokens := map[string]string{} + + err = json.Unmarshal(tokenMap, &tokens) + + if err != nil { + p.logger.Errorf("issues parsing header: %s", err) + return err + } + + p.tokens = tokens + + return nil +} + +func (p *TokenPaginator) SetToken(ctx context.Context, key, value string) { + p.tokens[key] = value +} + +func (p *TokenPaginator) GetToken(ctx context.Context, key string) string { + if token, ok := p.tokens[key]; ok { + return token + } + + return "" +} + +func (p *TokenPaginator) GetAllTokens(ctx context.Context) map[string]string { + return p.tokens +} + +func (p *TokenPaginator) PaginationHeader(ctx context.Context) (string, error) { + _, span := p.tracer.Start(ctx, "types.TokenPaginator.PaginationHeader") + defer span.End() + + if len(p.tokens) == 0 { + return "", nil + } + + tokenMap, err := json.Marshal(p.tokens) + + if err != nil { + p.logger.Errorf("issues parsing tokens: %s", err) + return "", err + } + + return base64.StdEncoding.EncodeToString(tokenMap), nil +} + +func NewTokenPaginator(tracer trace.Tracer, logger logging.LoggerInterface) *TokenPaginator { + p := new(TokenPaginator) + + p.logger = logger + p.tracer = tracer + p.tokens = make(map[string]string) + + return p + +} diff --git a/pkg/roles/handlers.go b/pkg/roles/handlers.go new file mode 100644 index 000000000..f89dbfb7d --- /dev/null +++ b/pkg/roles/handlers.go @@ -0,0 +1,472 @@ +// Copyright 2024 Canonical Ltd. +// SPDX-License-Identifier: AGPL + +package roles + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/canonical/identity-platform-admin-ui/internal/authorization" + "github.com/canonical/identity-platform-admin-ui/internal/http/types" + "github.com/canonical/identity-platform-admin-ui/internal/logging" + "github.com/canonical/identity-platform-admin-ui/internal/monitoring" + "github.com/canonical/identity-platform-admin-ui/internal/tracing" + + "github.com/go-chi/chi/v5" +) + +const ( + ROLE_TOKEN_KEY = "roles" +) + +type Permission struct { + Relation string `json:"relation" validate:"required"` + Object string `json:"object" validate:"required"` +} + +type UpdatePermissionsRequest struct { + Permissions []Permission `json:"permissions" validate:"required"` +} + +type RoleRequest struct { + ID string `json:"id" validate:"required"` +} + +// API is the core HTTP object that implements all the HTTP and business logic for the roles +// HTTP API functionality +type API struct { + service ServiceInterface + + logger logging.LoggerInterface + tracer tracing.TracingInterface + monitor monitoring.MonitorInterface +} + +// RegisterEndpoints hooks up all the endpoints to the server mux passed via the arg +func (a *API) RegisterEndpoints(mux *chi.Mux) { + mux.Get("/api/v0/roles", a.handleList) + mux.Get("/api/v0/roles/{id}", a.handleDetail) + mux.Post("/api/v0/roles", a.handleCreate) + mux.Patch("/api/v0/roles/{id}", a.handleUpdate) + mux.Delete("/api/v0/roles/{id}", a.handleRemove) + mux.Get("/api/v0/roles/{id}/entitlements", a.handleListPermission) + mux.Patch("/api/v0/roles/{id}/entitlements", a.handleAssignPermission) // this can only work for assignment unless payload includes add and remove + mux.Delete("/api/v0/roles/{id}/entitlements/{e_id}", a.handleRemovePermission) + mux.Get("/api/v0/roles/{id}/groups", a.handleListRoleGroup) + +} + +func (a *API) userFromContext(ctx context.Context) *authorization.User { + // TODO @shipperizer implement the FromContext and NewContext in authorization package + // see snippet below copied from https://pkg.go.dev/context#Context + // NewContext returns a new Context that carries value u. + // func NewContext(ctx context.Context, u *User) context.Context { + // return context.WithValue(ctx, userKey, u) + // } + + // // FromContext returns the User value stored in ctx, if any. + // func FromContext(ctx context.Context) (*User, bool) { + // u, ok := ctx.Value(userKey).(*User) + // return u, ok + // } + if user := ctx.Value(authorization.USER_CTX); user != nil { + return user.(*authorization.User) + } + + user := new(authorization.User) + user.ID = "anonymous" + + return user +} + +func (a *API) handleList(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + user := a.userFromContext(r.Context()) + + roles, err := a.service.ListRoles( + r.Context(), + user.ID, + ) + + if err != nil { + rr := types.Response{ + Status: http.StatusInternalServerError, + Message: err.Error(), + } + + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(rr) + + return + } + + w.WriteHeader(http.StatusOK) + + json.NewEncoder(w).Encode( + types.Response{ + Data: roles, + Message: "List of roles", + Status: http.StatusOK, + }, + ) +} + +func (a *API) handleDetail(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + ID := chi.URLParam(r, "id") + user := a.userFromContext(r.Context()) + role, err := a.service.GetRole(r.Context(), user.ID, ID) + + if err != nil { + + rr := types.Response{ + Status: http.StatusInternalServerError, + Message: err.Error(), + } + + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(rr) + + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode( + types.Response{ + Data: []string{role}, + Message: "Rule detail", + Status: http.StatusOK, + }, + ) +} + +func (a *API) handleCreate(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + + if err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode( + types.Response{ + Message: "Error parsing request payload", + Status: http.StatusBadRequest, + }, + ) + + return + } + + role := new(RoleRequest) + if err := json.Unmarshal(body, role); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode( + types.Response{ + Message: "Error parsing JSON payload", + Status: http.StatusBadRequest, + }, + ) + + return + + } + user := a.userFromContext(r.Context()) + err = a.service.CreateRole(r.Context(), user.ID, role.ID) + + if err != nil { + + rr := types.Response{ + Status: http.StatusInternalServerError, + Message: err.Error(), + } + + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(rr) + + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode( + types.Response{ + Message: fmt.Sprintf("Created role %s", role.ID), + Status: http.StatusCreated, + }, + ) +} + +// handleUpdate is not implemented by choice, product might decide to do it to enhcance +// role metadata, we do not support anything on top of simple ID attribute and this is +// not changeable right now due to coupled implementation with OpenFGA +func (a *API) handleUpdate(w http.ResponseWriter, r *http.Request) { + ID := chi.URLParam(r, "id") + + w.Header().Set("Content-Type", "application/json") + + w.WriteHeader(http.StatusNotImplemented) + json.NewEncoder(w).Encode( + types.Response{ + Message: fmt.Sprintf("use /api/v0/roles/%s/entitlements to assign permissions", ID), + Status: http.StatusNotImplemented, + }, + ) +} + +// TODO @shipperizer we need to remove all relationships leading to the role +func (a *API) handleRemove(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + ID := chi.URLParam(r, "id") + + err := a.service.DeleteRole(r.Context(), ID) + + if err != nil { + + rr := types.Response{ + Status: http.StatusInternalServerError, + Message: err.Error(), + } + + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(rr) + + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode( + types.Response{ + Message: fmt.Sprintf("Deleted role %s", ID), + Status: http.StatusOK, + }, + ) +} + +func (a *API) handleListPermission(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + ID := chi.URLParam(r, "id") + + paginator := types.NewTokenPaginator(a.tracer, a.logger) + + if err := paginator.LoadFromRequest(r.Context(), r); err != nil { + a.logger.Error(err) + } + + permissions, pageTokens, err := a.service.ListPermissions( + r.Context(), + ID, + paginator.GetAllTokens(r.Context()), + ) + + if err != nil { + rr := types.Response{ + Status: http.StatusInternalServerError, + Message: err.Error(), + } + + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(rr) + + return + } + + for apiType, token := range pageTokens { + paginator.SetToken(r.Context(), apiType, token) + } + + pageHeader, err := paginator.PaginationHeader(r.Context()) + + if err != nil { + a.logger.Errorf("error producing pagination header: %s", err) + pageHeader = "" + } + + w.Header().Add(types.PAGINATION_HEADER, pageHeader) + w.WriteHeader(http.StatusOK) + + json.NewEncoder(w).Encode( + types.Response{ + Data: permissions, + Message: "List of entitlements", + Status: http.StatusOK, + }, + ) +} + +func (a *API) handleListRoleGroup(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + ID := chi.URLParam(r, "id") + + paginator := types.NewTokenPaginator(a.tracer, a.logger) + + if err := paginator.LoadFromRequest(r.Context(), r); err != nil { + a.logger.Error(err) + } + + roles, pageToken, err := a.service.ListRoleGroups( + r.Context(), + ID, + paginator.GetToken(r.Context(), ROLE_TOKEN_KEY), + ) + + if err != nil { + rr := types.Response{ + Status: http.StatusInternalServerError, + Message: err.Error(), + } + + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(rr) + + return + } + + if pageToken != "" { + paginator.SetToken(r.Context(), ROLE_TOKEN_KEY, pageToken) + } + + pageHeader, err := paginator.PaginationHeader(r.Context()) + + if err != nil { + a.logger.Errorf("error producing pagination header: %s", err) + pageHeader = "" + } + + w.Header().Add(types.PAGINATION_HEADER, pageHeader) + w.WriteHeader(http.StatusOK) + + json.NewEncoder(w).Encode( + types.Response{ + Data: roles, + Message: "List of groups", + Status: http.StatusOK, + }, + ) +} + +func (a *API) handleAssignPermission(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + ID := chi.URLParam(r, "id") + + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + + if err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode( + types.Response{ + Message: "Error parsing request payload", + Status: http.StatusBadRequest, + }, + ) + + return + } + + // we might want to switch to an UpdatePermissionsRequest with additions and removals + permissions := new(UpdatePermissionsRequest) + if err := json.Unmarshal(body, permissions); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode( + types.Response{ + Message: "Error parsing JSON payload", + Status: http.StatusBadRequest, + }, + ) + + return + + } + + err = a.service.AssignPermissions(r.Context(), ID, permissions.Permissions...) + + if err != nil { + + rr := types.Response{ + Status: http.StatusInternalServerError, + Message: err.Error(), + } + + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(rr) + + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode( + types.Response{ + Message: fmt.Sprintf("Updated permissions for role %s", ID), + Status: http.StatusCreated, + }, + ) +} + +func (a *API) handleRemovePermission(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + ID := chi.URLParam(r, "id") + permissionUrn := authorization.NewUrnFromURLParam(chi.URLParam(r, "e_id")) + + if permissionUrn == nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode( + types.Response{ + Message: "Error parsing entitlement ID", + Status: http.StatusBadRequest, + }, + ) + + return + } + + err := a.service.RemovePermissions( + r.Context(), + ID, + Permission{Relation: permissionUrn.Relation(), Object: permissionUrn.Object()}, + ) + + if err != nil { + + rr := types.Response{ + Status: http.StatusInternalServerError, + Message: err.Error(), + } + + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(rr) + + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode( + types.Response{ + Message: fmt.Sprintf("Removed permission %s for role %s", permissionUrn.ID(), ID), + Status: http.StatusOK, + }, + ) +} + +// NewAPI returns an API object responsible for all the roles HTTP handlers +func NewAPI(service ServiceInterface, tracer tracing.TracingInterface, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *API { + a := new(API) + + a.service = service + + a.logger = logger + a.tracer = tracer + a.monitor = monitor + + return a +} diff --git a/pkg/roles/handlers_test.go b/pkg/roles/handlers_test.go new file mode 100644 index 000000000..c6bba66c8 --- /dev/null +++ b/pkg/roles/handlers_test.go @@ -0,0 +1,1329 @@ +// Copyright 2024 Canonical Ltd. +// SPDX-License-Identifier: AGPL + +package roles + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + trace "go.opentelemetry.io/otel/trace" + "go.uber.org/mock/gomock" + + "github.com/canonical/identity-platform-admin-ui/internal/authorization" + "github.com/canonical/identity-platform-admin-ui/internal/http/types" + "github.com/canonical/identity-platform-admin-ui/internal/monitoring" +) + +//go:generate mockgen -build_flags=--mod=mod -package roles -destination ./mock_logger.go -source=../../internal/logging/interfaces.go +//go:generate mockgen -build_flags=--mod=mod -package roles -destination ./mock_interfaces.go -source=./interfaces.go +//go:generate mockgen -build_flags=--mod=mod -package roles -destination ./mock_monitor.go -source=../../internal/monitoring/interfaces.go +//go:generate mockgen -build_flags=--mod=mod -package roles -destination ./mock_tracing.go go.opentelemetry.io/otel/trace Tracer + +// + http :8000/api/v0/roles X-Authorization:c2hpcHBlcml6ZXI= +// HTTP/1.1 200 OK +// Content-Length: 97 +// Content-Type: application/json +// Date: Tue, 20 Feb 2024 22:10:32 GMT + +// { +// "_meta": null, +// "data": [ +// "global", +// "administrator", +// "viewer" +// ], +// "message": "List of roles", +// "status": 200 +// } + +func TestHandleList(t *testing.T) { + type expected struct { + err error + roles []string + } + + tests := []struct { + name string + expected expected + output *types.Response + }{ + { + name: "empty result", + expected: expected{ + roles: []string{}, + err: nil, + }, + output: &types.Response{ + Data: []string{}, + Message: "List of roles", + Status: http.StatusOK, + }, + }, + { + name: "error", + expected: expected{ + roles: []string{}, + err: fmt.Errorf("error"), + }, + output: &types.Response{ + Data: []string{}, + Message: "error", + Status: http.StatusInternalServerError, + }, + }, + { + name: "full result", + expected: expected{ + roles: []string{"global", "administrator", "viewer"}, + err: nil, + }, + + output: &types.Response{ + Data: []string{"global", "administrator", "viewer"}, + Message: "List of roles", + Status: http.StatusOK, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + req := httptest.NewRequest(http.MethodGet, "/api/v0/roles", nil) + + mockService.EXPECT().ListRoles(gomock.Any(), "anonymous").Return(test.expected.roles, test.expected.err) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != test.output.Status { + t.Errorf("expected HTTP status code %v got %v", test.output.Status, res.StatusCode) + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if test.expected.err == nil && !reflect.DeepEqual(rr.Data, test.output.Data) { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Data, rr.Data) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +// + http :8000/api/v0/roles/administrator X-Authorization:c2hpcHBlcml6ZXI= +// HTTP/1.1 200 OK +// Content-Length: 77 +// Content-Type: application/json +// Date: Tue, 20 Feb 2024 22:10:32 GMT + +// { +// "_meta": null, +// "data": [ +// "administrator" +// ], +// "message": "Rule detail", +// "status": 200 +// } + +func TestHandleDetail(t *testing.T) { + tests := []struct { + name string + input string + expected error + output *types.Response + }{ + { + name: "unknown role", + input: "unknown", + expected: fmt.Errorf("role does not exist"), + output: &types.Response{ + Message: "role does not exist", + Status: http.StatusInternalServerError, + }, + }, + { + name: "found", + input: "administrator", + expected: nil, + output: &types.Response{ + Data: []string{"administrator"}, + Message: "Rule detail", + Status: http.StatusOK, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v0/roles/%s", test.input), nil) + + mockService.EXPECT().GetRole(gomock.Any(), "anonymous", test.input).DoAndReturn( + func(context.Context, string, string) (string, error) { + if test.expected != nil { + return "", test.expected + } + + return test.input, nil + + }, + ) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != test.output.Status { + t.Errorf("expected HTTP status code %v got %v", test.output.Status, res.StatusCode) + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if test.expected == nil && !reflect.DeepEqual(rr.Data, test.output.Data) { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Data, rr.Data) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +func TestHandleUpdate(t *testing.T) { + tests := []struct { + name string + input string + expected error + output *types.Response + }{ + { + name: "unknown role", + input: "unknown", + expected: fmt.Errorf("role does not exist"), + output: &types.Response{ + Message: "use /api/v0/roles/unknown/entitlements to assign permissions", + Status: http.StatusNotImplemented, + }, + }, + { + name: "found", + input: "administrator", + expected: nil, + output: &types.Response{ + Message: "use /api/v0/roles/administrator/entitlements to assign permissions", + Status: http.StatusNotImplemented, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v0/roles/%s", test.input), nil) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != test.output.Status { + t.Errorf("expected HTTP status code %v got %v", test.output.Status, res.StatusCode) + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Message string `json:"message"` + Status int `json:"status"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +// + http :8000/api/v0/roles/administrator/entitlements X-Authorization:c2hpcHBlcml6ZXI= +// HTTP/1.1 200 OK +// Content-Length: 156 +// Content-Type: application/json +// Date: Tue, 20 Feb 2024 22:10:33 GMT + +// { +// "_meta": null, +// "data": [ +// "can_view::client:github-canonical", +// "can_delete::client:okta", +// "can_edit::client:okta" +// ], +// "message": "List of entitlements", +// "status": 200 +// } + +func TestHandleListPermissionsSuccess(t *testing.T) { + type expected struct { + permissions []string + cTokens map[string]string + } + + tests := []struct { + name string + expected expected + output *types.Response + }{ + { + name: "no permissions", + expected: expected{permissions: []string{}}, + output: &types.Response{ + Data: []string{}, + Message: "List of entitlements", + Status: http.StatusOK, + }, + }, + { + name: "full permissions", + expected: expected{ + permissions: []string{ + "can_view::client:github-canonical", + "can_delete::client:okta", + "can_edit::client:okta", + }, + cTokens: map[string]string{"client": "test"}, + }, + output: &types.Response{ + Data: []string{ + "can_view::client:github-canonical", + "can_delete::client:okta", + "can_edit::client:okta", + }, + Message: "List of entitlements", + Status: http.StatusOK, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + roleID := "administrator" + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v0/roles/%s/entitlements", roleID), nil) + + mockTracer.EXPECT().Start(gomock.Any(), "types.TokenPaginator.LoadFromRequest").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + mockTracer.EXPECT().Start(gomock.Any(), "types.TokenPaginator.PaginationHeader").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + + mockService.EXPECT().ListPermissions(gomock.Any(), roleID, map[string]string{}).Return(test.expected.permissions, test.expected.cTokens, nil) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != http.StatusOK { + t.Errorf("expected HTTP status code 200 got %v", res.StatusCode) + } + + tokenMap, err := base64.StdEncoding.DecodeString(res.Header.Get(types.PAGINATION_HEADER)) + + if test.expected.cTokens != nil { + if err != nil { + t.Errorf("expected continuation token in headers") + } + + tokens := map[string]string{} + + _ = json.Unmarshal(tokenMap, &tokens) + + if !reflect.DeepEqual(tokens, test.expected.cTokens) { + t.Errorf("expected continuation tokens to match: %v - %v", tokens, test.expected.cTokens) + } + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if !reflect.DeepEqual(rr.Data, test.output.Data) { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Data, rr.Data) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +// + http :8000/api/v0/roles/administrator/groups X-Authorization:c2hpcHBlcml6ZXI= +// HTTP/1.1 200 OK +// Content-Length: 87 +// Content-Type: application/json +// Date: Tue, 20 Feb 2024 22:10:35 GMT + +// { +// "_meta": null, +// "data": [ +// "group:c-level#member" +// ], +// "message": "List of groups", +// "status": 200 +// } + +func TestHandleListRoleGroupsSuccess(t *testing.T) { + type expected struct { + groups []string + cTokens map[string]string + } + + tests := []struct { + name string + expected expected + output *types.Response + }{ + { + name: "no groups", + expected: expected{groups: []string{}}, + output: &types.Response{ + Data: []string{}, + Message: "List of groups", + Status: http.StatusOK, + }, + }, + { + name: "full groups", + expected: expected{ + groups: []string{ + "group:c-level#member", + "group:it-admin#member", + }, + cTokens: map[string]string{"roles": "test"}, + }, + output: &types.Response{ + Data: []string{ + "group:c-level#member", + "group:it-admin#member", + }, + Message: "List of groups", + Status: http.StatusOK, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + roleID := "administrator" + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v0/roles/%s/groups", roleID), nil) + + mockTracer.EXPECT().Start(gomock.Any(), "types.TokenPaginator.LoadFromRequest").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + mockTracer.EXPECT().Start(gomock.Any(), "types.TokenPaginator.PaginationHeader").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + + mockService.EXPECT().ListRoleGroups(gomock.Any(), roleID, "").Return(test.expected.groups, test.expected.cTokens["roles"], nil) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != http.StatusOK { + t.Errorf("expected HTTP status code 200 got %v", res.StatusCode) + } + + tokenMap, err := base64.StdEncoding.DecodeString(res.Header.Get(types.PAGINATION_HEADER)) + + if test.expected.cTokens != nil { + if err != nil { + t.Errorf("expected continuation token in headers") + } + + tokens := map[string]string{} + + _ = json.Unmarshal(tokenMap, &tokens) + + if !reflect.DeepEqual(tokens, test.expected.cTokens) { + t.Errorf("expected continuation tokens to match: %v - %v", tokens, test.expected.cTokens) + } + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if !reflect.DeepEqual(rr.Data, test.output.Data) { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Data, rr.Data) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +// + http DELETE :8000/api/v0/roles/administrator/entitlements/can_edit::client:okta X-Authorization:c2hpcHBlcml6ZXI= +// HTTP/1.1 200 OK +// Content-Length: 116 +// Content-Type: application/json +// Date: Tue, 20 Feb 2024 22:10:33 GMT + +// { +// "_meta": null, +// "data": null, +// "message": "Removed permission can_edit::client:okta for role administrator", +// "status": 200 +// } + +func TestHandleRemovePermissionBadPermissionFormat(t *testing.T) { + type input struct { + roleID string + permissionID string + } + + tests := []struct { + name string + input input + expected error + output *types.Response + }{ + { + name: "wrong permission format", + input: input{ + roleID: "administrator", + permissionID: "can_edit-something-wrong:okta", + }, + expected: fmt.Errorf("role does not exist"), + output: &types.Response{ + Message: "Error parsing entitlement ID", + Status: http.StatusBadRequest, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v0/roles/%s/entitlements/%s", test.input.roleID, test.input.permissionID), nil) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != test.output.Status { + t.Errorf("expected HTTP status code %v got %v", test.output.Status, res.StatusCode) + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if test.expected == nil && !reflect.DeepEqual(rr.Data, test.output.Data) { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Data, rr.Data) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +func TestHandleRemovePermission(t *testing.T) { + type input struct { + roleID string + permissionID string + } + + tests := []struct { + name string + input input + expected error + output *types.Response + }{ + { + name: "unknown role", + input: input{ + roleID: "unknown", + permissionID: "can_edit::client::okta", + }, + expected: fmt.Errorf("role does not exist"), + output: &types.Response{ + Message: "role does not exist", + Status: http.StatusInternalServerError, + }, + }, + { + name: "found", + input: input{ + roleID: "administrator", + permissionID: "can_edit::client:okta", + }, + expected: nil, + output: &types.Response{ + Status: http.StatusOK, + Message: "Removed permission can_edit::client:okta for role administrator", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v0/roles/%s/entitlements/%s", test.input.roleID, test.input.permissionID), nil) + + mockService.EXPECT().RemovePermissions( + gomock.Any(), + test.input.roleID, + Permission{ + Relation: strings.Split(test.input.permissionID, authorization.PERMISSION_SEPARATOR)[0], + Object: strings.Split(test.input.permissionID, authorization.PERMISSION_SEPARATOR)[1], + }, + ).Return(test.expected) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != test.output.Status { + t.Errorf("expected HTTP status code %v got %v", test.output.Status, res.StatusCode) + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if test.expected == nil && len(rr.Data) != 0 { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Data, rr.Data) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +// + http PATCH :8000/api/v0/roles/administrator/entitlements 'permissions:=[{"relation":"can_delete","object":"scheme:superman"},{"relation":"can_view","object":"client:aws"}]' X-Authorization:c2hpcHBlcml6ZXI= +// HTTP/1.1 201 Created +// Content-Length: 95 +// Content-Type: application/json +// Date: Tue, 20 Feb 2024 22:10:34 GMT + +// { +// "_meta": null, +// "data": null, +// "message": "Updated permissions for role administrator", +// "status": 201 +// } +func TestHandleAssignPermissions(t *testing.T) { + type input struct { + permissions []Permission + roleID string + } + + tests := []struct { + name string + input input + expected error + output *types.Response + }{ + { + name: "multiple permissions", + expected: nil, + input: input{ + roleID: "administrator", + permissions: []Permission{ + { + Relation: "can_view", + Object: "client:github-canonical", + }, + { + Relation: "can_delete", + Object: "client:okta", + }, + { + Relation: "can_edit", + Object: "client:okta", + }, + }, + }, + output: &types.Response{ + Message: "Updated permissions for role administrator", + Status: http.StatusCreated, + }, + }, + { + name: "multiple permissions with error", + expected: fmt.Errorf("error"), + input: input{ + roleID: "administrator", + permissions: []Permission{ + { + Relation: "can_view", + Object: "client:github-canonical", + }, + { + Relation: "can_delete", + Object: "client:okta", + }, + { + Relation: "can_edit", + Object: "client:okta", + }, + }, + }, + output: &types.Response{ + Message: "error", + Status: http.StatusInternalServerError, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + upr := new(UpdatePermissionsRequest) + upr.Permissions = test.input.permissions + payload, _ := json.Marshal(upr) + + req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v0/roles/%s/entitlements", test.input.roleID), bytes.NewReader(payload)) + + mockService.EXPECT().AssignPermissions(gomock.Any(), test.input.roleID, test.input.permissions).Return(test.expected) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != test.output.Status { + t.Errorf("expected HTTP status code %v got %v", test.output.Status, res.StatusCode) + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if test.expected == nil && len(rr.Data) != 0 { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Data, rr.Data) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +func TestHandleAssignPermissionsBadPermissionFormat(t *testing.T) { + + tests := []struct { + name string + input string + expected error + output *types.Response + }{ + { + name: "no permissions", + expected: nil, + input: "administrator", + output: &types.Response{ + Message: "Error parsing JSON payload", + Status: http.StatusBadRequest, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v0/roles/%s/entitlements", test.input), nil) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != test.output.Status { + t.Errorf("expected HTTP status code %v got %v", test.output.Status, res.StatusCode) + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +// + http DELETE :8000/api/v0/roles/viewer X-Authorization:c2hpcHBlcml6ZXI= +// HTTP/1.1 200 OK +// Content-Length: 72 +// Content-Type: application/json +// Date: Tue, 20 Feb 2024 22:10:36 GMT + +// { +// "_meta": null, +// "data": null, +// "message": "Deleted role viewer", +// "status": 200 +// } +func TestHandleRemove(t *testing.T) { + tests := []struct { + name string + input string + expected error + output *types.Response + }{ + { + name: "unknown role", + input: "unknown", + expected: fmt.Errorf("role does not exist"), + output: &types.Response{ + Message: "role does not exist", + Status: http.StatusInternalServerError, + }, + }, + { + name: "found", + input: "administrator", + expected: nil, + output: &types.Response{ + Status: http.StatusOK, + Message: "Deleted role administrator", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v0/roles/%s", test.input), nil) + + mockService.EXPECT().DeleteRole( + gomock.Any(), + test.input, + ).Return(test.expected) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != test.output.Status { + t.Errorf("expected HTTP status code %v got %v", test.output.Status, res.StatusCode) + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if test.expected == nil && len(rr.Data) != 0 { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Data, rr.Data) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +func TestHandleCreate(t *testing.T) { + tests := []struct { + name string + input string + expected error + output *types.Response + }{ + { + name: "success", + expected: nil, + input: "administrator", + + output: &types.Response{ + Message: "Created role administrator", + Status: http.StatusCreated, + }, + }, + { + name: "fail", + expected: fmt.Errorf("error"), + input: "administrator", + output: &types.Response{ + Message: "error", + Status: http.StatusInternalServerError, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + upr := new(RoleRequest) + upr.ID = test.input + payload, _ := json.Marshal(upr) + + req := httptest.NewRequest(http.MethodPost, "/api/v0/roles", bytes.NewReader(payload)) + + mockService.EXPECT().CreateRole(gomock.Any(), "anonymous", test.input).Return(test.expected) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != test.output.Status { + t.Errorf("expected HTTP status code %v got %v", test.output.Status, res.StatusCode) + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if test.expected == nil && len(rr.Data) != 0 { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Data, rr.Data) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} + +func TestHandleCreateBadRoleFormat(t *testing.T) { + + tests := []struct { + name string + input string + expected error + output *types.Response + }{ + { + name: "no permissions", + expected: nil, + input: "administrator", + output: &types.Response{ + Message: "Error parsing JSON payload", + Status: http.StatusBadRequest, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockService := NewMockServiceInterface(ctrl) + + req := httptest.NewRequest(http.MethodPost, "/api/v0/roles", nil) + + w := httptest.NewRecorder() + mux := chi.NewMux() + NewAPI(mockService, mockTracer, mockMonitor, mockLogger).RegisterEndpoints(mux) + + mux.ServeHTTP(w, req) + + res := w.Result() + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + + if err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if res.StatusCode != test.output.Status { + t.Errorf("expected HTTP status code %v got %v", test.output.Status, res.StatusCode) + } + + // duplicate types.Response attribute we care and assign the proper type instead of interface{} + type Response struct { + Data []string `json:"data"` + Message string `json:"message"` + Status int `json:"status"` + Meta *types.Pagination `json:"_meta"` + } + + rr := new(Response) + + if err := json.Unmarshal(data, rr); err != nil { + t.Errorf("expected error to be nil got %v", err) + } + + if rr.Message != test.output.Message { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Message, rr.Message) + } + + if rr.Status != test.output.Status { + t.Errorf("invalid result, expected: %v, got: %v", test.output.Status, rr.Status) + } + + }) + } +} diff --git a/pkg/roles/interfaces.go b/pkg/roles/interfaces.go new file mode 100644 index 000000000..f4728f383 --- /dev/null +++ b/pkg/roles/interfaces.go @@ -0,0 +1,33 @@ +// Copyright 2024 Canonical Ltd. +// SPDX-License-Identifier: AGPL + +package roles + +import ( + "context" + + "github.com/openfga/go-sdk/client" + + ofga "github.com/canonical/identity-platform-admin-ui/internal/openfga" +) + +// ServiceInterface is the interface that each business logic service needs to implement +type ServiceInterface interface { + ListRoles(context.Context, string) ([]string, error) + GetRole(context.Context, string, string) (string, error) + CreateRole(context.Context, string, string) error + DeleteRole(context.Context, string) error + ListRoleGroups(context.Context, string, string) ([]string, string, error) + ListPermissions(context.Context, string, map[string]string) ([]string, map[string]string, error) + AssignPermissions(context.Context, string, ...Permission) error + RemovePermissions(context.Context, string, ...Permission) error +} + +// OpenFGAClientInterface is the interface used to decouple the OpenFGA store implementation +type OpenFGAClientInterface interface { + ListObjects(context.Context, string, string, string) ([]string, error) + ReadTuples(context.Context, string, string, string, string) (*client.ClientReadResponse, error) + WriteTuples(context.Context, ...ofga.Tuple) error + DeleteTuples(context.Context, ...ofga.Tuple) error + Check(context.Context, string, string, string) (bool, error) +} diff --git a/pkg/roles/service.go b/pkg/roles/service.go new file mode 100644 index 000000000..bf01850cf --- /dev/null +++ b/pkg/roles/service.go @@ -0,0 +1,324 @@ +// Copyright 2024 Canonical Ltd. +// SPDX-License-Identifier: AGPL + +package roles + +import ( + "context" + "fmt" + "strings" + "sync" + + "go.opentelemetry.io/otel/trace" + + "github.com/canonical/identity-platform-admin-ui/internal/authorization" + "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" +) + +const ( + ASSIGNEE_RELATION = "assignee" +) + +// Service contains the business logic to deal with roles on the Admin UI OpenFGA model +type Service struct { + ofga OpenFGAClientInterface + + tracer trace.Tracer + monitor monitoring.MonitorInterface + logger logging.LoggerInterface +} + +// ListRoles returns all the roles a specific user can see (using "can_view" OpenFGA relation) +func (s *Service) ListRoles(ctx context.Context, userID string) ([]string, error) { + ctx, span := s.tracer.Start(ctx, "roles.Service.ListRoles") + defer span.End() + + roles, err := s.ofga.ListObjects(ctx, fmt.Sprintf("user:%s", userID), "can_view", "role") + + if err != nil { + s.logger.Error(err.Error()) + return nil, err + } + + return roles, nil +} + +// ListRoleGroups returns all the groups associated to a specific role +// method relies on the /read endpoint which allows for pagination via the token +// unfortunately we are not able to distinguish between types assigned on the OpenFGA side, +// so we'll have to filter here based on the user, this leads to unrealiable object counts +// TODO @shipperizer a more complex pagination system can be implemented by keeping track of the +// latest index in the current "page" and encode it in the pagination token header returned to +// the UI +func (s *Service) ListRoleGroups(ctx context.Context, ID, continuationToken string) ([]string, string, error) { + ctx, span := s.tracer.Start(ctx, "roles.Service.ListRoleGroups") + defer span.End() + + r, err := s.ofga.ReadTuples(ctx, "", ASSIGNEE_RELATION, fmt.Sprintf("role:%s", ID), continuationToken) + + if err != nil { + s.logger.Error(err.Error()) + return nil, "", err + } + + groups := make([]string, 0) + + for _, t := range r.GetTuples() { + if strings.HasPrefix(t.Key.User, "group:") { + groups = append(groups, t.Key.User) + } + } + + return groups, r.GetContinuationToken(), nil +} + +// ListPermissions returns all the permissions associated to a specific role +func (s *Service) ListPermissions(ctx context.Context, ID string, continuationTokens map[string]string) ([]string, map[string]string, error) { + ctx, span := s.tracer.Start(ctx, "roles.Service.ListPermissions") + defer span.End() + + permissionsMap := sync.Map{} + tokensMap := sync.Map{} + + var wg sync.WaitGroup + + wg.Add(len(s.permissionTypes())) + + // TODO @shipperizer use a background operator + for _, t := range s.permissionTypes() { + go func(pType string) { + defer wg.Done() + p, t, err := s.listPermissionsByType(ctx, fmt.Sprintf("role:%s#%s", ID, ASSIGNEE_RELATION), pType, continuationTokens[pType]) + + permissionsMap.Store(pType, p) + tokensMap.Store(pType, t) + + // TODO @shipperizer handle errors better + // chain them and return at the end of the function + if err != nil { + s.logger.Error(err) + } + }(t) + } + + wg.Wait() + + permissions := make([]string, 0) + tokens := make(map[string]string) + + permissionsMap.Range( + func(key any, value any) bool { + permissions = append(permissions, value.([]string)...) + + return true + }, + ) + + tokensMap.Range( + func(key any, value any) bool { + tokens[key.(string)] = value.(string) + + return true + }, + ) + + // TODO @shipperizer right now the function fails silently, chain errors from the goroutines + // and return + return permissions, tokens, nil +} + +// GetRole returns the specified role using the ID argument, userID is used to validate the visibility by the user +// making the call +func (s *Service) GetRole(ctx context.Context, userID, ID string) (string, error) { + ctx, span := s.tracer.Start(ctx, "roles.Service.GetRole") + defer span.End() + + exists, err := s.ofga.Check(ctx, fmt.Sprintf("user:%s", userID), "can_view", fmt.Sprintf("role:%s", ID)) + + if err != nil { + + s.logger.Error(err.Error()) + return "", err + } + + if exists { + return ID, nil + } + + // if we got here it means authorization check hasn't worked + return "", nil +} + +// CreateRole creates a role and associates it with the userID passed as argument +// an extra tuple is created to estabilish the "privileged" relatin for admin users +func (s *Service) CreateRole(ctx context.Context, userID, ID string) error { + ctx, span := s.tracer.Start(ctx, "roles.Service.CreateRole") + defer span.End() + + // TODO @shipperizer will we need also the can_view? + // does creating a role mean that you are the owner, therefore u get all the permissions on it? + // right now assumption is only admins will be able to do this + // potentially changing the model to say + // `define can_view: [user, user:*, role#assignee, group#member] or assignee or admin from privileged` + // might sort the problem + + // TODO @shipperizer offload to privileged creator object + err := s.ofga.WriteTuples( + ctx, + *ofga.NewTuple(fmt.Sprintf("user:%s", userID), ASSIGNEE_RELATION, fmt.Sprintf("role:%s", ID)), + *ofga.NewTuple(authorization.ADMIN_PRIVILEGE, "privileged", fmt.Sprintf("role:%s", ID)), + ) + + if err != nil { + s.logger.Error(err.Error()) + return err + } + + return nil +} + +// AssignPermissions assigns permissions to a role +// TODO @shipperizer see if it's worth using only one between Permission and ofga.Tuple +func (s *Service) AssignPermissions(ctx context.Context, ID string, permissions ...Permission) error { + ctx, span := s.tracer.Start(ctx, "roles.Service.AssignPermissions") + defer span.End() + + // preemptive check to verify if all permissions to be assigned are accessible by the user + // needs to happen separately + + ps := make([]ofga.Tuple, 0) + + for _, p := range permissions { + ps = append(ps, *ofga.NewTuple(fmt.Sprintf("role:%s#%s", ID, ASSIGNEE_RELATION), p.Relation, p.Object)) + } + + err := s.ofga.WriteTuples(ctx, ps...) + + if err != nil { + s.logger.Error(err.Error()) + return err + } + + return nil +} + +// RemovePermissions removes permissions from a role +// TODO @shipperizer see if it's worth using only one between Permission and ofga.Tuple +func (s *Service) RemovePermissions(ctx context.Context, ID string, permissions ...Permission) error { + ctx, span := s.tracer.Start(ctx, "roles.Service.RemovePermissions") + defer span.End() + + // preemptive check to verify if all permissions to be assigned are accessible by the user + // needs to happen separately + + ps := make([]ofga.Tuple, 0) + + for _, p := range permissions { + ps = append(ps, *ofga.NewTuple(fmt.Sprintf("role:%s#%s", ID, ASSIGNEE_RELATION), p.Relation, p.Object)) + } + + err := s.ofga.DeleteTuples(ctx, ps...) + + if err != nil { + s.logger.Error(err.Error()) + return err + } + + return nil +} + +// DeleteRole deletes a role and all the related tuples +func (s *Service) DeleteRole(ctx context.Context, ID string) error { + ctx, span := s.tracer.Start(ctx, "roles.Service.DeleteRole") + defer span.End() + + var wg sync.WaitGroup + + wg.Add(len(s.permissionTypes())) + + // TODO @shipperizer use a background operator + for _, t := range s.permissionTypes() { + go func(pType string) { + defer wg.Done() + s.removePermissionsByType(ctx, ID, pType) + }(t) + } + + wg.Wait() + + return s.ofga.DeleteTuples(ctx, *ofga.NewTuple(authorization.ADMIN_PRIVILEGE, "privileged", fmt.Sprintf("role:%s", ID))) +} + +// TODO @shipperizer make this more scalable by pushing to a channel and using goroutine pool +// potentially create a background operator that can pipe results to an on demand channel and works off a +// set amount of goroutines +func (s *Service) listPermissionsByType(ctx context.Context, roleIDAssignee, pType, continuationToken string) ([]string, string, error) { + ctx, span := s.tracer.Start(ctx, "roles.Service.listPermissionsByType") + defer span.End() + + r, err := s.ofga.ReadTuples(ctx, roleIDAssignee, "", fmt.Sprintf("%s:", pType), continuationToken) + + if err != nil { + s.logger.Error(err.Error()) + return nil, "", err + } + + permissions := make([]string, 0) + + for _, t := range r.GetTuples() { + permissions = append(permissions, authorization.NewUrn(t.Key.Relation, t.Key.Object).ID()) + } + + return permissions, r.GetContinuationToken(), nil +} + +func (s *Service) removePermissionsByType(ctx context.Context, ID, pType string) { + ctx, span := s.tracer.Start(ctx, "roles.Service.removePermissionsByType") + defer span.End() + + cToken := "" + assigneeRelation := fmt.Sprintf("role:%s#%s", ID, ASSIGNEE_RELATION) + permissions := make([]ofga.Tuple, 0) + for { + r, err := s.ofga.ReadTuples(ctx, assigneeRelation, "", fmt.Sprintf("%s:", pType), cToken) + + if err != nil { + s.logger.Errorf("error when retrieving tuples for %s %s", assigneeRelation, pType) + return + } + + for _, t := range r.Tuples { + permissions = append(permissions, *ofga.NewTuple(assigneeRelation, t.Key.Relation, t.Key.Object)) + } + + // if there are more pages, keep going with the loop + if cToken = r.ContinuationToken; cToken != "" { + continue + } + + // TODO @shipperizer understand if better breaking at every cycle or reverting if clause + break + } + + if err := s.ofga.DeleteTuples(ctx, permissions...); err != nil { + s.logger.Error(err.Error()) + } +} + +func (s *Service) permissionTypes() []string { + return []string{"role", "group", "identity", "scheme", "provider", "client"} +} + +// NewService returns the implementtation of the business logic for the roles API +func NewService(ofga OpenFGAClientInterface, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *Service { + s := new(Service) + + s.ofga = ofga + s.monitor = monitor + s.tracer = tracer + s.logger = logger + + return s +} diff --git a/pkg/roles/service_test.go b/pkg/roles/service_test.go new file mode 100644 index 000000000..36e87f366 --- /dev/null +++ b/pkg/roles/service_test.go @@ -0,0 +1,786 @@ +// Copyright 2024 Canonical Ltd. +// SPDX-License-Identifier: AGPL + +package roles + +import ( + "context" + "fmt" + "reflect" + "sort" + "strings" + "testing" + "time" + + openfga "github.com/openfga/go-sdk" + "github.com/openfga/go-sdk/client" + trace "go.opentelemetry.io/otel/trace" + "go.uber.org/mock/gomock" + + "github.com/canonical/identity-platform-admin-ui/internal/authorization" + "github.com/canonical/identity-platform-admin-ui/internal/monitoring" + ofga "github.com/canonical/identity-platform-admin-ui/internal/openfga" +) + +//go:generate mockgen -build_flags=--mod=mod -package roles -destination ./mock_logger.go -source=../../internal/logging/interfaces.go +//go:generate mockgen -build_flags=--mod=mod -package roles -destination ./mock_interfaces.go -source=./interfaces.go +//go:generate mockgen -build_flags=--mod=mod -package roles -destination ./mock_monitor.go -source=../../internal/monitoring/interfaces.go +//go:generate mockgen -build_flags=--mod=mod -package roles -destination ./mock_tracing.go go.opentelemetry.io/otel/trace Tracer + +func TestServiceListRoles(t *testing.T) { + type expected struct { + err error + roles []string + } + + tests := []struct { + name string + input string + expected expected + }{ + { + name: "empty result", + input: "administrator", + expected: expected{ + roles: []string{}, + err: nil, + }, + }, + { + name: "error", + input: "administrator", + expected: expected{ + roles: []string{}, + err: fmt.Errorf("error"), + }, + }, + { + name: "full result", + input: "administrator", + expected: expected{ + roles: []string{"global", "administrator", "viewer"}, + err: nil, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockOpenFGA := NewMockOpenFGAClientInterface(ctrl) + + svc := NewService(mockOpenFGA, mockTracer, mockMonitor, mockLogger) + + mockTracer.EXPECT().Start(gomock.Any(), "roles.Service.ListRoles").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + mockOpenFGA.EXPECT().ListObjects(gomock.Any(), fmt.Sprintf("user:%s", test.input), "can_view", "role").Return(test.expected.roles, test.expected.err) + + if test.expected.err != nil { + mockLogger.EXPECT().Error(gomock.Any()).Times(1) + } + + roles, err := svc.ListRoles(context.Background(), test.input) + + if err != test.expected.err { + t.Errorf("expected error to be %v got %v", test.expected.err, err) + } + + if test.expected.err == nil && !reflect.DeepEqual(roles, test.expected.roles) { + t.Errorf("invalid result, expected: %v, got: %v", test.expected.roles, roles) + } + }) + } +} + +func TestServiceListRoleGroups(t *testing.T) { + type expected struct { + err error + tuples []string + token string + } + + type input struct { + role string + token string + } + + tests := []struct { + name string + input input + expected expected + output []string + }{ + { + name: "empty result", + input: input{ + role: "administrator", + }, + expected: expected{ + tuples: []string{}, + token: "", + err: nil, + }, + output: []string{}, + }, + { + name: "error", + input: input{ + role: "administrator", + }, + expected: expected{ + tuples: []string{}, + token: "", + err: fmt.Errorf("error"), + }, + }, + { + name: "full result without token", + input: input{ + role: "administrator", + }, + expected: expected{ + tuples: []string{ + "group:c-level#member", + "group:it-admin#member", + "user:joe", + "user:test", + }, + token: "test", + err: nil, + }, + output: []string{ + "group:c-level#member", + "group:it-admin#member", + }, + }, + { + name: "full result with token", + input: input{ + role: "administrator", + token: "test", + }, + expected: expected{ + tuples: []string{ + "group:c-level#member", + "group:it-admin#member", + }, + token: "", + err: nil, + }, + output: []string{ + "group:c-level#member", + "group:it-admin#member", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockOpenFGA := NewMockOpenFGAClientInterface(ctrl) + + r := new(client.ClientReadResponse) + + tuples := []openfga.Tuple{} + for _, t := range test.expected.tuples { + tuples = append( + tuples, + *openfga.NewTuple( + *openfga.NewTupleKey( + t, ASSIGNEE_RELATION, fmt.Sprintf("role:%s", test.input.role), + ), + time.Now(), + ), + ) + } + + r.SetContinuationToken(test.expected.token) + r.SetTuples(tuples) + + svc := NewService(mockOpenFGA, mockTracer, mockMonitor, mockLogger) + + mockTracer.EXPECT().Start(gomock.Any(), "roles.Service.ListRoleGroups").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + mockOpenFGA.EXPECT().ReadTuples(gomock.Any(), "", ASSIGNEE_RELATION, fmt.Sprintf("role:%s", test.input.role), test.input.token).Return(r, test.expected.err) + + if test.expected.err != nil { + mockLogger.EXPECT().Error(gomock.Any()).Times(1) + } + + groups, token, err := svc.ListRoleGroups(context.Background(), test.input.role, test.input.token) + + if err != test.expected.err { + t.Errorf("expected error to be %v got %v", test.expected.err, err) + } + + if test.expected.err == nil && token != test.expected.token { + t.Errorf("invalid result, expected: %v, got: %v", test.expected.token, token) + } + + if test.expected.err == nil && !reflect.DeepEqual(groups, test.output) { + t.Errorf("invalid result, expected: %v, got: %v", test.output, groups) + } + }) + } +} + +func TestServiceGetRole(t *testing.T) { + type expected struct { + err error + check bool + } + + type input struct { + role string + user string + } + + tests := []struct { + name string + input input + expected expected + }{ + { + name: "not found", + input: input{ + role: "administrator", + user: "admin", + }, + expected: expected{ + check: false, + err: nil, + }, + }, + { + name: "error", + input: input{ + role: "administrator", + user: "admin", + }, + expected: expected{ + check: false, + err: fmt.Errorf("error"), + }, + }, + { + name: "found", + input: input{ + role: "administrator", + user: "admin", + }, + expected: expected{ + check: true, + err: nil, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockOpenFGA := NewMockOpenFGAClientInterface(ctrl) + + svc := NewService(mockOpenFGA, mockTracer, mockMonitor, mockLogger) + + mockTracer.EXPECT().Start(gomock.Any(), "roles.Service.GetRole").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + mockOpenFGA.EXPECT().Check(gomock.Any(), fmt.Sprintf("user:%s", test.input.user), "can_view", fmt.Sprintf("role:%s", test.input.role)).Return(test.expected.check, test.expected.err) + + if test.expected.err != nil { + mockLogger.EXPECT().Error(gomock.Any()).Times(1) + } + + role, err := svc.GetRole(context.Background(), test.input.user, test.input.role) + + if err != test.expected.err { + t.Errorf("expected error to be %v got %v", test.expected.err, err) + } + + if test.expected.err == nil && test.expected.check && role != test.input.role { + t.Errorf("invalid result, expected: %v, got: %v", test.input.role, role) + } + }) + } +} + +func TestServiceCreateRole(t *testing.T) { + type input struct { + role string + user string + } + + tests := []struct { + name string + input input + expected error + }{ + { + name: "error", + input: input{ + role: "administrator", + user: "admin", + }, + expected: fmt.Errorf("error"), + }, + { + name: "found", + input: input{ + role: "administrator", + user: "admin", + }, + expected: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockOpenFGA := NewMockOpenFGAClientInterface(ctrl) + + svc := NewService(mockOpenFGA, mockTracer, mockMonitor, mockLogger) + + mockTracer.EXPECT().Start(gomock.Any(), "roles.Service.CreateRole").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + + mockOpenFGA.EXPECT().WriteTuples(gomock.Any(), gomock.Any()).Times(1).DoAndReturn( + func(ctx context.Context, tuples ...ofga.Tuple) error { + ps := make([]ofga.Tuple, 0) + + ps = append( + ps, + *ofga.NewTuple(fmt.Sprintf("user:%s", test.input.user), ASSIGNEE_RELATION, fmt.Sprintf("role:%s", test.input.role)), + *ofga.NewTuple(authorization.ADMIN_PRIVILEGE, "privileged", fmt.Sprintf("role:%s", test.input.role)), + ) + + if !reflect.DeepEqual(ps, tuples) { + t.Errorf("expected tuples to be %v got %v", ps, tuples) + } + + return test.expected + }, + ) + + if test.expected != nil { + mockLogger.EXPECT().Error(gomock.Any()).Times(1) + } + + err := svc.CreateRole(context.Background(), test.input.user, test.input.role) + + if err != test.expected { + t.Errorf("expected error to be %v got %v", test.expected, err) + } + }) + } +} + +// TODO @shipperizer split this test in 2, test only specific ofga client calls in each +func TestServiceDeleteRole(t *testing.T) { + tests := []struct { + name string + input string + expected error + }{ + { + name: "error", + input: "administrator", + expected: fmt.Errorf("error"), + }, + { + name: "found", + input: "administrator", + expected: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockOpenFGA := NewMockOpenFGAClientInterface(ctrl) + + svc := NewService(mockOpenFGA, mockTracer, mockMonitor, mockLogger) + + mockTracer.EXPECT().Start(gomock.Any(), "roles.Service.DeleteRole").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + mockTracer.EXPECT().Start(gomock.Any(), "roles.Service.removePermissionsByType").Times(6).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + + pTypes := []string{"role", "group", "identity", "scheme", "provider", "client"} + + calls := []*gomock.Call{} + + for _, pType := range pTypes { + + calls = append( + calls, + mockOpenFGA.EXPECT().ReadTuples(gomock.Any(), fmt.Sprintf("role:%s#%s", test.input, ASSIGNEE_RELATION), "", fmt.Sprintf("%s:", pType), "").Times(1).DoAndReturn( + func(ctx context.Context, user, relation, object, continuationToken string) (*client.ClientReadResponse, error) { + if test.expected != nil { + return nil, test.expected + } + + tuples := []openfga.Tuple{ + *openfga.NewTuple( + *openfga.NewTupleKey( + user, "can_edit", fmt.Sprintf("%s:test", pType), + ), + time.Now(), + ), + } + + r := new(client.ClientReadResponse) + r.SetContinuationToken("") + r.SetTuples(tuples) + + return r, nil + }, + ), + ) + + } + + if test.expected == nil { + mockOpenFGA.EXPECT().DeleteTuples( + gomock.Any(), + gomock.Any(), + ).Times(7).DoAndReturn( + func(ctx context.Context, tuples ...ofga.Tuple) error { + if len(tuples) != 1 { + t.Errorf("too many tuples") + } + + tuple := tuples[0] + + if tuple.User != fmt.Sprintf("role:%s#%s", test.input, ASSIGNEE_RELATION) && tuple.User != authorization.ADMIN_PRIVILEGE { + t.Errorf("expected user to be one of %v got %v", []string{fmt.Sprintf("role:%s#%s", test.input, ASSIGNEE_RELATION), authorization.ADMIN_PRIVILEGE}, tuple.User) + } + + if tuple.Relation != "privileged" && tuple.Relation != "can_edit" { + t.Errorf("expected relation to be one of %v got %v", []string{"privileged", "can_edit"}, tuple.Relation) + } + + if tuple.Object != fmt.Sprintf("role:%s", test.input) && !strings.HasSuffix(tuple.Object, ":test") { + t.Errorf("expected object to be one of %v got %v", []string{fmt.Sprintf("role:%s", test.input), "<*>:test"}, tuple.Object) + } + + return nil + }, + ) + } else { + // TODO @shipperizer fix this so that we can pin it down to the error case only + mockLogger.EXPECT().Errorf(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockOpenFGA.EXPECT().DeleteTuples( + gomock.Any(), + *ofga.NewTuple(authorization.ADMIN_PRIVILEGE, "privileged", fmt.Sprintf("role:%s", test.input)), + ).Return(test.expected) + } + + gomock.InAnyOrder(calls) + err := svc.DeleteRole(context.Background(), test.input) + + if err != test.expected { + t.Errorf("expected error to be %v got %v", test.expected, err) + } + }) + } +} + +func TestServiceListPermissions(t *testing.T) { + type input struct { + role string + cTokens map[string]string + } + + tests := []struct { + name string + input input + expected error + }{ + { + name: "error", + input: input{ + role: "administrator", + }, + expected: fmt.Errorf("error"), + }, + { + name: "found", + input: input{ + role: "administrator", + cTokens: map[string]string{ + "role": "test", + }, + }, + expected: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockOpenFGA := NewMockOpenFGAClientInterface(ctrl) + + svc := NewService(mockOpenFGA, mockTracer, mockMonitor, mockLogger) + + mockTracer.EXPECT().Start(gomock.Any(), "roles.Service.ListPermissions").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + mockTracer.EXPECT().Start(gomock.Any(), "roles.Service.listPermissionsByType").Times(6).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + + pTypes := []string{"role", "group", "identity", "scheme", "provider", "client"} + expCTokens := map[string]string{ + "role": "", + "group": "", + "identity": "", + "scheme": "", + "provider": "", + "client": "", + } + + expPermissions := []string{ + "can_edit::role:test", + "can_edit::group:test", + "can_edit::identity:test", + "can_edit::scheme:test", + "can_edit::provider:test", + "can_edit::client:test", + } + + calls := []*gomock.Call{} + + for _, _ = range pTypes { + calls = append( + calls, + mockOpenFGA.EXPECT().ReadTuples(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, user, relation, object, continuationToken string) (*client.ClientReadResponse, error) { + if test.expected != nil { + return nil, test.expected + } + + if user != fmt.Sprintf("role:%s#%s", test.input.role, ASSIGNEE_RELATION) { + t.Errorf("wrong user parameter expected %s got %s", fmt.Sprintf("role:%s#%s", test.input.role, ASSIGNEE_RELATION), user) + } + + if object == "role:" && continuationToken != "test" { + t.Errorf("missing continuation token %s", test.input.cTokens["roles"]) + } + + tuples := []openfga.Tuple{ + *openfga.NewTuple( + *openfga.NewTupleKey( + user, "can_edit", fmt.Sprintf("%stest", object), + ), + time.Now(), + ), + } + + r := new(client.ClientReadResponse) + r.SetContinuationToken("") + r.SetTuples(tuples) + + return r, nil + }, + ), + ) + } + + if test.expected != nil { + // TODO @shipperizer fix this so that we can pin it down to the error case only + mockLogger.EXPECT().Error(gomock.Any()).Times(12) + } + + gomock.InAnyOrder(calls) + permissions, cTokens, err := svc.ListPermissions(context.Background(), test.input.role, test.input.cTokens) + + if err != nil && test.expected != nil { + t.Errorf("expected error to be silenced and return nil got %v instead", err) + } + + sort.Strings(permissions) + sort.Strings(expPermissions) + + if err == nil && test.expected == nil && !reflect.DeepEqual(permissions, expPermissions) { + t.Errorf("expected permissions to be %v got %v", expPermissions, permissions) + } + + if err == nil && test.expected == nil && !reflect.DeepEqual(cTokens, expCTokens) { + t.Errorf("expected continuation tokens to be %v got %v", expCTokens, cTokens) + } + }) + } +} + +func TestServiceAssignPermissions(t *testing.T) { + type input struct { + role string + permissions []Permission + } + + tests := []struct { + name string + input input + expected error + }{ + { + name: "error", + input: input{ + role: "administrator", + permissions: []Permission{ + {Relation: "can_delete", Object: "role:admin"}, + }, + }, + expected: fmt.Errorf("error"), + }, + { + name: "multiple permissions", + input: input{ + role: "administrator", + permissions: []Permission{ + {Relation: "can_view", Object: "client:okta"}, + {Relation: "can_edit", Object: "client:okta"}, + {Relation: "can_delete", Object: "group:admin"}, + }, + }, + expected: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockOpenFGA := NewMockOpenFGAClientInterface(ctrl) + + svc := NewService(mockOpenFGA, mockTracer, mockMonitor, mockLogger) + + mockTracer.EXPECT().Start(gomock.Any(), "roles.Service.AssignPermissions").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + mockOpenFGA.EXPECT().WriteTuples(gomock.Any(), gomock.Any()).Times(1).DoAndReturn( + func(ctx context.Context, tuples ...ofga.Tuple) error { + ps := make([]ofga.Tuple, 0) + + for _, p := range test.input.permissions { + ps = append(ps, *ofga.NewTuple(fmt.Sprintf("role:%s#%s", test.input.role, ASSIGNEE_RELATION), p.Relation, p.Object)) + } + + if !reflect.DeepEqual(ps, tuples) { + t.Errorf("expected tuples to be %v got %v", ps, tuples) + } + + return test.expected + }, + ) + + if test.expected != nil { + mockLogger.EXPECT().Error(gomock.Any()).Times(1) + } + + err := svc.AssignPermissions(context.Background(), test.input.role, test.input.permissions...) + + if err != test.expected { + t.Errorf("expected error to be %v got %v", test.expected, err) + } + }) + } +} + +func TestServiceRemovePermissions(t *testing.T) { + type input struct { + role string + permissions []Permission + } + + tests := []struct { + name string + input input + expected error + }{ + { + name: "error", + input: input{ + role: "administrator", + permissions: []Permission{ + {Relation: "can_delete", Object: "role:admin"}, + }, + }, + expected: fmt.Errorf("error"), + }, + { + name: "multiple permissions", + input: input{ + role: "administrator", + permissions: []Permission{ + {Relation: "can_view", Object: "client:okta"}, + {Relation: "can_edit", Object: "client:okta"}, + {Relation: "can_delete", Object: "group:admin"}, + }, + }, + expected: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLogger := NewMockLoggerInterface(ctrl) + mockTracer := NewMockTracer(ctrl) + mockMonitor := monitoring.NewMockMonitorInterface(ctrl) + mockOpenFGA := NewMockOpenFGAClientInterface(ctrl) + + svc := NewService(mockOpenFGA, mockTracer, mockMonitor, mockLogger) + + mockTracer.EXPECT().Start(gomock.Any(), "roles.Service.RemovePermissions").Times(1).Return(context.TODO(), trace.SpanFromContext(context.TODO())) + mockOpenFGA.EXPECT().DeleteTuples(gomock.Any(), gomock.Any()).Times(1).DoAndReturn( + func(ctx context.Context, tuples ...ofga.Tuple) error { + ps := make([]ofga.Tuple, 0) + + for _, p := range test.input.permissions { + ps = append(ps, *ofga.NewTuple(fmt.Sprintf("role:%s#%s", test.input.role, ASSIGNEE_RELATION), p.Relation, p.Object)) + } + + if !reflect.DeepEqual(ps, tuples) { + t.Errorf("expected tuples to be %v got %v", ps, tuples) + } + + return test.expected + }, + ) + + if test.expected != nil { + mockLogger.EXPECT().Error(gomock.Any()).Times(1) + } + + err := svc.RemovePermissions(context.Background(), test.input.role, test.input.permissions...) + + if err != test.expected { + t.Errorf("expected error to be %v got %v", test.expected, err) + } + }) + } +} diff --git a/pkg/web/interfaces.go b/pkg/web/interfaces.go index e8299be58..7b444c1a0 100644 --- a/pkg/web/interfaces.go +++ b/pkg/web/interfaces.go @@ -2,3 +2,22 @@ // SPDX-License-Identifier: AGPL package web + +import ( + "context" + + fga "github.com/openfga/go-sdk" +) + +type OpenFGAClientInterface interface { + ReadModel(context.Context) (*fga.AuthorizationModel, error) + CompareModel(context.Context, fga.AuthorizationModel) (bool, error) + WriteTuple(context.Context, string, string, string) error + DeleteTuple(context.Context, string, string, string) error + Check(context.Context, string, string, string) (bool, error) + ListObjects(context.Context, string, string, string) ([]string, error) + // WriteTuples(context.Context, ...ofga.Tuple) error + // DeleteTuples(context.Context, ...ofga.Tuple) error + // BatchCheck(context.Context, ...ofga.Tuple) (bool, error) + // ReadTuples(context.Context, string, string, string, string) (openfga.ReadResponse, error) +} diff --git a/pkg/web/router.go b/pkg/web/router.go index b6662e135..ae2c77c46 100644 --- a/pkg/web/router.go +++ b/pkg/web/router.go @@ -10,22 +10,25 @@ import ( middleware "github.com/go-chi/chi/v5/middleware" trace "go.opentelemetry.io/otel/trace" + "github.com/canonical/identity-platform-admin-ui/internal/authorization" ih "github.com/canonical/identity-platform-admin-ui/internal/hydra" ik "github.com/canonical/identity-platform-admin-ui/internal/kratos" "github.com/canonical/identity-platform-admin-ui/internal/logging" "github.com/canonical/identity-platform-admin-ui/internal/monitoring" + iofga "github.com/canonical/identity-platform-admin-ui/internal/openfga" "github.com/canonical/identity-platform-admin-ui/internal/tracing" "github.com/canonical/identity-platform-admin-ui/pkg/clients" "github.com/canonical/identity-platform-admin-ui/pkg/identities" "github.com/canonical/identity-platform-admin-ui/pkg/idp" "github.com/canonical/identity-platform-admin-ui/pkg/metrics" + "github.com/canonical/identity-platform-admin-ui/pkg/roles" "github.com/canonical/identity-platform-admin-ui/pkg/rules" "github.com/canonical/identity-platform-admin-ui/pkg/schemas" "github.com/canonical/identity-platform-admin-ui/pkg/status" ) -func NewRouter(idpConfig *idp.Config, schemasConfig *schemas.Config, rulesConfig *rules.Config, hydraClient *ih.Client, kratos *ik.Client, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) http.Handler { +func NewRouter(idpConfig *idp.Config, schemasConfig *schemas.Config, rulesConfig *rules.Config, hydraClient *ih.Client, kratos *ik.Client, ofga OpenFGAClientInterface, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) http.Handler { router := chi.NewMux() middlewares := make(chi.Middlewares, 0) @@ -46,6 +49,12 @@ func NewRouter(idpConfig *idp.Config, schemasConfig *schemas.Config, rulesConfig router.Use(middlewares...) + // apply authorization middleware using With to overcome issue with URLParams not available + router = router.With( + authorization.NewMiddleware( + authorization.NewAuthorizer(ofga, tracer, monitor, logger), monitor, logger).Authorize(), + ).(*chi.Mux) + status.NewAPI(tracer, monitor, logger).RegisterEndpoints(router) metrics.NewAPI(logger).RegisterEndpoints(router) identities.NewAPI( @@ -68,5 +77,11 @@ func NewRouter(idpConfig *idp.Config, schemasConfig *schemas.Config, rulesConfig rules.NewService(rulesConfig, tracer, monitor, logger), logger, ).RegisterEndpoints(router) + roles.NewAPI( + roles.NewService(ofga.(*iofga.Client), tracer, monitor, logger), + tracer, + monitor, + logger, + ).RegisterEndpoints(router) return tracing.NewMiddleware(monitor, logger).OpenTelemetry(router) }