From f75e4d626e477b7805d06c94665473cab383890a Mon Sep 17 00:00:00 2001 From: Bailin He Date: Fri, 2 Feb 2024 21:10:15 +0000 Subject: [PATCH] update query pkg to support role-binding V2, add v2 APIs Signed-off-by: Bailin He --- Makefile | 4 +- cmd/createrole.go | 58 ++- cmd/server.go | 6 +- internal/api/response.go | 33 ++ internal/api/rolebindings.go | 365 ++++++++++++++ internal/api/roles.go | 190 +++++-- internal/api/router.go | 41 ++ internal/api/types.go | 38 ++ internal/iapl/default.go | 301 ++++++++++++ internal/query/errors.go | 24 +- internal/query/mock/mock.go | 60 ++- internal/query/relations.go | 28 +- internal/query/relations_test.go | 24 +- internal/query/rolebindings.go | 635 ++++++++++++++++++++++++ internal/query/rolebindings_test.go | 734 ++++++++++++++++++++++++++++ internal/query/roles.go | 8 + internal/query/roles_v2.go | 686 ++++++++++++++++++++++++++ internal/query/roles_v2_test.go | 497 +++++++++++++++++++ internal/query/service.go | 60 ++- internal/query/zedtokens_test.go | 2 +- openapi-v2.yaml | 456 +++++++++++++++++ 21 files changed, 4152 insertions(+), 98 deletions(-) create mode 100644 internal/api/rolebindings.go create mode 100644 internal/query/rolebindings.go create mode 100644 internal/query/rolebindings_test.go create mode 100644 internal/query/roles_v2.go create mode 100644 internal/query/roles_v2_test.go create mode 100644 openapi-v2.yaml diff --git a/Makefile b/Makefile index c031035ff..041750a27 100644 --- a/Makefile +++ b/Makefile @@ -52,12 +52,12 @@ ci: | golint test coverage ## Setup dev database and run tests. .PHONY: test test: ## Runs unit tests. @echo Running unit tests... - @go test -v -timeout 30s -cover -short -tags testtools ./... + @go test -v -timeout 60s -cover -short -tags testtools ./... .PHONY: coverage coverage: ## Generates a test coverage report. @echo Generating coverage report... - @go test -timeout 30s -tags testtools ./... -coverprofile=coverage.out -covermode=atomic + @go test -timeout 60s -tags testtools ./... -coverprofile=coverage.out -covermode=atomic @go tool cover -func=coverage.out @go tool cover -html=coverage.out diff --git a/cmd/createrole.go b/cmd/createrole.go index 22ad554d9..ef4dcef19 100644 --- a/cmd/createrole.go +++ b/cmd/createrole.go @@ -15,6 +15,7 @@ import ( "go.infratographer.com/permissions-api/internal/query" "go.infratographer.com/permissions-api/internal/spicedbx" "go.infratographer.com/permissions-api/internal/storage" + "go.infratographer.com/permissions-api/internal/types" ) const ( @@ -22,17 +23,16 @@ const ( createRoleFlagResource = "resource" createRoleFlagActions = "actions" createRoleFlagName = "name" + createRoleFlagIsV2 = "v2" ) -var ( - createRoleCmd = &cobra.Command{ - Use: "create-role", - Short: "create role in SpiceDB directly", - Run: func(cmd *cobra.Command, args []string) { - createRole(cmd.Context(), globalCfg) - }, - } -) +var createRoleCmd = &cobra.Command{ + Use: "create-role", + Short: "create role in SpiceDB directly", + Run: func(cmd *cobra.Command, _ []string) { + createRole(cmd.Context(), globalCfg) + }, +} func init() { rootCmd.AddCommand(createRoleCmd) @@ -42,6 +42,7 @@ func init() { flags.StringSlice(createRoleFlagActions, []string{}, "actions to assign to created role") flags.String(createRoleFlagResource, "", "resource to bind to created role") flags.String(createRoleFlagName, "", "name of role to create") + flags.Bool(createRoleFlagIsV2, false, "create a v2 role") v := viper.GetViper() @@ -49,6 +50,7 @@ func init() { viperx.MustBindFlag(v, createRoleFlagActions, flags.Lookup(createRoleFlagActions)) viperx.MustBindFlag(v, createRoleFlagResource, flags.Lookup(createRoleFlagResource)) viperx.MustBindFlag(v, createRoleFlagName, flags.Lookup(createRoleFlagName)) + viperx.MustBindFlag(v, createRoleFlagIsV2, flags.Lookup(createRoleFlagIsV2)) } func createRole(ctx context.Context, cfg *config.AppConfig) { @@ -56,6 +58,7 @@ func createRole(ctx context.Context, cfg *config.AppConfig) { actions := viper.GetStringSlice(createRoleFlagActions) resourceIDStr := viper.GetString(createRoleFlagResource) name := viper.GetString(createRoleFlagName) + v2 := viper.GetBool(createRoleFlagIsV2) if subjectIDStr == "" || len(actions) == 0 || resourceIDStr == "" || name == "" { logger.Fatal("invalid config") @@ -125,14 +128,35 @@ func createRole(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("error creating subject resource", "error", err) } - role, err := engine.CreateRole(ctx, subjectResource, resource, name, actions) - if err != nil { - logger.Fatalw("error creating role", "error", err) - } + if v2 { + role, err := engine.CreateRoleV2(ctx, subjectResource, resource, name, actions) + if err != nil { + logger.Fatalw("error creating role", "error", err) + } - if err = engine.AssignSubjectRole(ctx, subjectResource, role); err != nil { - logger.Fatalw("error creating role", "error", err) - } + rbsubj := []types.RoleBindingSubject{{SubjectResource: subjectResource}} + + roleres, err := engine.NewResourceFromID(role.ID) + if err != nil { + logger.Fatalw("error creating role resource", "error", err) + } - logger.Infow("role successfully created", "role_id", role.ID) + rb, err := engine.CreateRoleBinding(ctx, resource, roleres, rbsubj) + if err != nil { + logger.Fatalw("error creating role binding", "error", err) + } + + logger.Infof("created role %s[%s] and role-binding %s", role.Name, role.ID, rb.ID) + } else { + role, err := engine.CreateRole(ctx, subjectResource, resource, name, actions) + if err != nil { + logger.Fatalw("error creating role", "error", err) + } + + if err = engine.AssignSubjectRole(ctx, subjectResource, role); err != nil { + logger.Fatalw("error creating role", "error", err) + } + + logger.Infow("role successfully created", "role_id", role.ID) + } } diff --git a/cmd/server.go b/cmd/server.go index 41170326d..e756a2379 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -21,9 +21,7 @@ import ( "go.infratographer.com/permissions-api/internal/storage" ) -var ( - apiDefaultListen = "0.0.0.0:7602" -) +var apiDefaultListen = "0.0.0.0:7602" var serverCmd = &cobra.Command{ Use: "server", @@ -89,7 +87,7 @@ func serve(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("invalid spicedb policy", "error", err) } - engine, err := query.NewEngine("infratographer", spiceClient, kv, store, query.WithPolicy(policy)) + engine, err := query.NewEngine("infratographer", spiceClient, kv, store, query.WithPolicy(policy), query.WithLogger(logger)) if err != nil { logger.Fatalw("error creating engine", "error", err) } diff --git a/internal/api/response.go b/internal/api/response.go index a06b8c243..c342b92f4 100644 --- a/internal/api/response.go +++ b/internal/api/response.go @@ -2,6 +2,15 @@ package api import ( "errors" + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "go.infratographer.com/permissions-api/internal/query" + "go.infratographer.com/permissions-api/internal/storage" ) // ErrorResponse represents the data that the server will return on any given call @@ -20,3 +29,27 @@ var ( // ErrResourceAlreadyExists is returned when the resource already exists ErrResourceAlreadyExists = errors.New("resource already exists") ) + +func (r *Router) errorResponse(basemsg string, err error) *echo.HTTPError { + msg := fmt.Sprintf("%s: %s", basemsg, err.Error()) + httpstatus := http.StatusInternalServerError + + switch { + case + errors.Is(err, storage.ErrRoleNameTaken), + errors.Is(err, query.ErrInvalidType), + errors.Is(err, query.ErrInvalidArgument), + status.Code(err) == codes.InvalidArgument, + status.Code(err) == codes.FailedPrecondition: + httpstatus = http.StatusBadRequest + case + errors.Is(err, storage.ErrNoRoleFound), + errors.Is(err, query.ErrRoleNotFound), + errors.Is(err, query.ErrRoleBindingNotFound): + httpstatus = http.StatusNotFound + default: + msg = basemsg + } + + return echo.NewHTTPError(httpstatus, msg).SetInternal(err) +} diff --git a/internal/api/rolebindings.go b/internal/api/rolebindings.go new file mode 100644 index 000000000..f8440cb0b --- /dev/null +++ b/internal/api/rolebindings.go @@ -0,0 +1,365 @@ +package api + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "go.infratographer.com/x/gidx" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "go.infratographer.com/permissions-api/internal/iapl" + "go.infratographer.com/permissions-api/internal/types" +) + +func resourceToSubject(subjects []types.RoleBindingSubject) []roleBindingSubject { + resp := make([]roleBindingSubject, len(subjects)) + for i, subj := range subjects { + resp[i] = roleBindingSubject{ + ID: subj.SubjectResource.ID, + Type: subj.SubjectResource.Type, + Condition: nil, + } + } + + return resp +} + +func (r *Router) roleBindingCreate(c echo.Context) error { + resourceIDStr := c.Param("id") + + ctx, span := tracer.Start( + c.Request().Context(), "api.roleBindingCreate", + trace.WithAttributes(attribute.String("id", resourceIDStr)), + ) + defer span.End() + + resourceID, err := gidx.Parse(resourceIDStr) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing resource ID").SetInternal(err) + } + + var body roleBindingRequest + + err = c.Bind(&body) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing request body").SetInternal(err) + } + + resource, err := r.engine.NewResourceFromID(resourceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating resource").SetInternal(err) + } + + subjectResource, err := r.currentSubject(c) + if err != nil { + return err + } + + // permissions on role binding actions, similar to roles v1, are granted on the resources + if err := r.checkActionWithResponse(ctx, subjectResource, string(iapl.RoleBindingActionCreate), resource); err != nil { + return err + } + + roleID, err := gidx.Parse(body.RoleID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing role ID").SetInternal(err) + } + + roleResource, err := r.engine.NewResourceFromID(roleID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating role resource").SetInternal(err) + } + + subjects := make([]types.RoleBindingSubject, len(body.Subjects)) + + for i, s := range body.Subjects { + subj, err := r.engine.NewResourceFromID(s.ID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating subject resource").SetInternal(err) + } + + subjects[i] = types.RoleBindingSubject{ + SubjectResource: subj, + Condition: nil, + } + } + + rb, err := r.engine.CreateRoleBinding(ctx, resource, roleResource, subjects) + if err != nil { + return r.errorResponse("error creating role-binding", err) + } + + return c.JSON( + http.StatusOK, + roleBindingResponse{ + ID: rb.ID, + Subjects: resourceToSubject(rb.Subjects), + Role: roleBindingResponseRole{ + ID: rb.Role.ID, + Name: rb.Role.Name, + }, + }, + ) +} + +func (r *Router) roleBindingsList(c echo.Context) error { + resourceIDStr := c.Param("id") + roleIDStr := c.QueryParam("role_id") + + ctx, span := tracer.Start( + c.Request().Context(), "api.roleBindingList", + trace.WithAttributes(attribute.String("id", resourceIDStr)), + ) + defer span.End() + + resourceID, err := gidx.Parse(resourceIDStr) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing resource ID").SetInternal(err) + } + + resource, err := r.engine.NewResourceFromID(resourceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) + } + + subjectResource, err := r.currentSubject(c) + if err != nil { + return err + } + + if err := r.checkActionWithResponse(ctx, subjectResource, string(iapl.RoleBindingActionList), resource); err != nil { + return err + } + + roleFilter := (*types.Resource)(nil) + + if roleIDStr != "" { + roleID, err := gidx.Parse(roleIDStr) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing role ID").SetInternal(err) + } + + roleResource, err := r.engine.NewResourceFromID(roleID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating role resource").SetInternal(err) + } + + roleFilter = &roleResource + } + + rbs, err := r.engine.ListRoleBindings(ctx, resource, roleFilter) + if err != nil { + return r.errorResponse("error listing role-binding", err) + } + + resp := listRoleBindingsResponse{ + Data: make([]roleBindingResponse, len(rbs)), + } + + for i, rb := range rbs { + resp.Data[i] = roleBindingResponse{ + ID: rb.ID, + Subjects: resourceToSubject(rb.Subjects), + Role: roleBindingResponseRole{ + ID: rb.Role.ID, + Name: rb.Role.Name, + }, + } + } + + return c.JSON(http.StatusOK, resp) +} + +func (r *Router) roleBindingsDelete(c echo.Context) error { + resID := c.Param("id") + rbID := c.Param("rb_id") + + ctx, span := tracer.Start( + c.Request().Context(), "api.roleBindingDelete", + trace.WithAttributes(attribute.String("id", rbID)), + ) + defer span.End() + + // resource + resourceID, err := gidx.Parse(resID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing resource ID").SetInternal(err) + } + + resource, err := r.engine.NewResourceFromID(resourceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating resource").SetInternal(err) + } + + // role-binding + rolebindingID, err := gidx.Parse(rbID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing resource ID").SetInternal(err) + } + + rbRes, err := r.engine.NewResourceFromID(rolebindingID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating resource").SetInternal(err) + } + + actor, err := r.currentSubject(c) + if err != nil { + return err + } + + // permissions on role binding actions, similar to roles v1, are granted on the resources + if err := r.checkActionWithResponse(ctx, actor, string(iapl.RoleBindingActionDelete), resource); err != nil { + return err + } + + if err := r.engine.DeleteRoleBinding(ctx, rbRes, resource); err != nil { + return r.errorResponse("error updating role-binding", err) + } + + resp := deleteRoleBindingResponse{Success: true} + + return c.JSON(http.StatusOK, resp) +} + +func (r *Router) roleBindingGet(c echo.Context) error { + resID := c.Param("id") + rbID := c.Param("rb_id") + + ctx, span := tracer.Start( + c.Request().Context(), "api.roleBindingGet", + trace.WithAttributes(attribute.String("id", rbID)), + ) + defer span.End() + + // resource + resourceID, err := gidx.Parse(resID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing resource ID").SetInternal(err) + } + + resource, err := r.engine.NewResourceFromID(resourceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating resource").SetInternal(err) + } + + // role-binding + rolebindingID, err := gidx.Parse(rbID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing resource ID").SetInternal(err) + } + + rbRes, err := r.engine.NewResourceFromID(rolebindingID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating resource").SetInternal(err) + } + + actor, err := r.currentSubject(c) + if err != nil { + return err + } + + // permissions on role binding actions, similar to roles v1, are granted on the resources + if err := r.checkActionWithResponse(ctx, actor, string(iapl.RoleBindingActionGet), resource); err != nil { + return err + } + + rb, err := r.engine.GetRoleBinding(ctx, rbRes) + if err != nil { + return r.errorResponse("error getting role-binding", err) + } + + return c.JSON( + http.StatusOK, + roleBindingResponse{ + ID: rb.ID, + Subjects: resourceToSubject(rb.Subjects), + Role: roleBindingResponseRole{ + ID: rb.Role.ID, + Name: rb.Role.Name, + }, + }, + ) +} + +func (r *Router) roleBindingUpdate(c echo.Context) error { + resID := c.Param("id") + rbID := c.Param("rb_id") + + ctx, span := tracer.Start( + c.Request().Context(), "api.roleBindingUpdate", + trace.WithAttributes(attribute.String("id", resID)), + trace.WithAttributes(attribute.String("rolebinding_id", rbID)), + ) + defer span.End() + + // resource + resourceID, err := gidx.Parse(resID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing resource ID").SetInternal(err) + } + + resource, err := r.engine.NewResourceFromID(resourceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating resource").SetInternal(err) + } + + // role-binding + rolebindingID, err := gidx.Parse(rbID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing resource ID").SetInternal(err) + } + + rbRes, err := r.engine.NewResourceFromID(rolebindingID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating resource").SetInternal(err) + } + + actor, err := r.currentSubject(c) + if err != nil { + return err + } + + // permissions on role binding actions, similar to roles v1, are granted on the resources + if err := r.checkActionWithResponse(ctx, actor, string(iapl.RoleBindingActionUpdate), resource); err != nil { + return err + } + + body := &rolebindingUpdateRequest{} + + err = c.Bind(body) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing request body").SetInternal(err) + } + + subjects := make([]types.RoleBindingSubject, len(body.Subjects)) + + for i, s := range body.Subjects { + subj, err := r.engine.NewResourceFromID(s.ID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error creating subject resource").SetInternal(err) + } + + subjects[i] = types.RoleBindingSubject{ + SubjectResource: subj, + Condition: nil, + } + } + + rb, err := r.engine.UpdateRoleBinding(ctx, rbRes, subjects) + if err != nil { + return r.errorResponse("error updating role-binding", err) + } + + return c.JSON( + http.StatusOK, + roleBindingResponse{ + ID: rb.ID, + Subjects: resourceToSubject(rb.Subjects), + Role: roleBindingResponseRole{ + ID: rb.Role.ID, + Name: rb.Role.Name, + }, + }, + ) +} diff --git a/internal/api/roles.go b/internal/api/roles.go index 50166100c..83430517d 100644 --- a/internal/api/roles.go +++ b/internal/api/roles.go @@ -1,7 +1,7 @@ package api import ( - "errors" + "context" "net/http" "time" @@ -10,7 +10,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "go.infratographer.com/permissions-api/internal/query" + "go.infratographer.com/permissions-api/internal/types" ) const ( @@ -53,9 +53,19 @@ func (r *Router) roleCreate(c echo.Context) error { return err } - role, err := r.engine.CreateRole(ctx, subjectResource, resource, reqBody.Name, reqBody.Actions) + apiversion := r.getAPIVersion(c) + + var role types.Role + + switch apiversion { + case "v2": + role, err = r.engine.CreateRoleV2(ctx, subjectResource, resource, reqBody.Name, reqBody.Actions) + default: + role, err = r.engine.CreateRole(ctx, subjectResource, resource, reqBody.Name, reqBody.Actions) + } + if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "error creating resource").SetInternal(err) + return r.errorResponse("error creating role", err) } resp := roleResponse{ @@ -95,29 +105,55 @@ func (r *Router) roleUpdate(c echo.Context) error { return err } - roleResource, err := r.engine.NewResourceFromID(roleID) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "error updating role").SetInternal(err) - } + apiversion := r.getAPIVersion(c) - // Roles belong to resources by way of the actions they can perform; do the permissions - // check on the role resource. - resource, err := r.engine.GetRoleResource(ctx, roleResource) - if err != nil { - if errors.Is(err, query.ErrRoleNotFound) { - return echo.NewHTTPError(http.StatusNotFound, "resource not found").SetInternal(err) + var ( + role types.Role + resource types.Resource + roleResource types.Resource + + updateRoleFn func(ctx context.Context, actor types.Resource, roleResource types.Resource, newName string, newActions []string) (types.Role, error) + ) + + switch apiversion { + case "v2": + // for v2 roles + // Roles themselves are the resource, permissions check should be performed + // on the roles themselves. + roleResource, err = r.engine.NewResourceFromID(roleID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) } - return echo.NewHTTPError(http.StatusInternalServerError, "error getting resource").SetInternal(err) + resource = roleResource + updateRoleFn = r.engine.UpdateRoleV2 + + default: + // for v1 roles + // Roles belong to resources by way of the actions they can perform; do the permissions + // check on the role resource. + roleResource, err = r.engine.NewResourceFromID(roleID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error getting role resource").SetInternal(err) + } + + resource, err = r.engine.GetRoleResource(ctx, roleResource) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) + } + + updateRoleFn = r.engine.UpdateRole } - if err := r.checkActionWithResponse(ctx, subjectResource, actionRoleUpdate, resource); err != nil { + // TODO: This shows an error for the role's resource, not the role. Determine if that + // matters. + if err := r.checkActionWithResponse(ctx, subjectResource, actionRoleGet, resource); err != nil { return err } - role, err := r.engine.UpdateRole(ctx, subjectResource, roleResource, reqBody.Name, reqBody.Actions) + role, err = updateRoleFn(ctx, subjectResource, roleResource, reqBody.Name, reqBody.Actions) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "error updating resource").SetInternal(err) + return r.errorResponse("error updating role", err) } resp := roleResponse{ @@ -150,16 +186,44 @@ func (r *Router) roleGet(c echo.Context) error { return err } - roleResource, err := r.engine.NewResourceFromID(roleResourceID) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) - } + apiversion := r.getAPIVersion(c) - // Roles belong to resources by way of the actions they can perform; do the permissions - // check on the role resource. - resource, err := r.engine.GetRoleResource(ctx, roleResource) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) + var ( + role types.Role + resource types.Resource + roleResource types.Resource + + getRoleFn func(ctx context.Context, roleResource types.Resource) (types.Role, error) + ) + + switch apiversion { + case "v2": + // for v2 roles + // Roles themselves are the resource, permissions check should be performed + // on the roles themselves. + roleResource, err = r.engine.NewResourceFromID(roleResourceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) + } + + resource = roleResource + getRoleFn = r.engine.GetRoleV2 + + default: + // for v1 roles + // Roles belong to resources by way of the actions they can perform; do the permissions + // check on the role resource. + roleResource, err = r.engine.NewResourceFromID(roleResourceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error getting role resource").SetInternal(err) + } + + resource, err = r.engine.GetRoleResource(ctx, roleResource) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) + } + + getRoleFn = r.engine.GetRole } // TODO: This shows an error for the role's resource, not the role. Determine if that @@ -168,9 +232,9 @@ func (r *Router) roleGet(c echo.Context) error { return err } - role, err := r.engine.GetRole(ctx, roleResource) + role, err = getRoleFn(ctx, roleResource) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "error getting resource").SetInternal(err) + return r.errorResponse("error getting role", err) } resp := roleResponse{ @@ -212,9 +276,19 @@ func (r *Router) rolesList(c echo.Context) error { return err } - roles, err := r.engine.ListRoles(ctx, resource) + apiversion := r.getAPIVersion(c) + + var roles []types.Role + + switch apiversion { + case "v2": + roles, err = r.engine.ListRolesV2(ctx, resource) + default: + roles, err = r.engine.ListRoles(ctx, resource) + } + if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "error getting role").SetInternal(err) + return r.errorResponse("error getting roles", err) } resp := listRolesResponse{ @@ -254,24 +328,52 @@ func (r *Router) roleDelete(c echo.Context) error { return err } - roleResource, err := r.engine.NewResourceFromID(roleResourceID) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "error deleting resource").SetInternal(err) - } + apiversion := r.getAPIVersion(c) - // Roles belong to resources by way of the actions they can perform; do the permissions - // check on the role resource. - resource, err := r.engine.GetRoleResource(ctx, roleResource) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) + var ( + resource types.Resource + roleResource types.Resource + + DeleteRoleFn func(ctx context.Context, roleResource types.Resource) error + ) + + switch apiversion { + case "v2": + // for v2 roles + // Roles themselves are the resource, permissions check should be performed + // on the roles themselves. + roleResource, err = r.engine.NewResourceFromID(roleResourceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) + } + + resource = roleResource + DeleteRoleFn = r.engine.DeleteRoleV2 + + default: + // for v1 roles + // Roles belong to resources by way of the actions they can perform; do the permissions + // check on the role resource. + roleResource, err = r.engine.NewResourceFromID(roleResourceID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error getting role resource").SetInternal(err) + } + + resource, err = r.engine.GetRoleResource(ctx, roleResource) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) + } + + DeleteRoleFn = r.engine.DeleteRole } if err := r.checkActionWithResponse(ctx, subjectResource, actionRoleDelete, resource); err != nil { return err } - if err = r.engine.DeleteRole(ctx, roleResource); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "error deleting resource").SetInternal(err) + err = DeleteRoleFn(ctx, roleResource) + if err != nil { + return r.errorResponse("error deleting role", err) } resp := deleteRoleResponse{ @@ -319,3 +421,7 @@ func (r *Router) roleGetResource(c echo.Context) error { return c.JSON(http.StatusOK, resp) } + +func (r *Router) listActions(c echo.Context) error { + return c.JSON(http.StatusOK, r.engine.AllActions()) +} diff --git a/internal/api/router.go b/internal/api/router.go index bf53a6ffd..7e64e0361 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -54,6 +54,7 @@ func (r *Router) Routes(rg *echo.Group) { v1 := rg.Group("api/v1") { v1.Use(r.authMW) + v1.Use(r.injectAPIVersionMW("v1")) v1.POST("/resources/:id/roles", r.roleCreate) v1.GET("/resources/:id/roles", r.rolesList) @@ -72,6 +73,46 @@ func (r *Router) Routes(rg *echo.Group) { v1.GET("/allow", r.checkAction) v1.POST("/allow", r.checkAllActions) } + + v2 := rg.Group("api/v2") + { + v2.Use(r.authMW) + v2.Use(r.injectAPIVersionMW("v2")) + + v2.POST("/resources/:id/roles", r.roleCreate) + v2.GET("/resources/:id/roles", r.rolesList) + v2.GET("/roles/:role_id", r.roleGet) + v2.PATCH("/roles/:role_id", r.roleUpdate) + v2.DELETE("/roles/:id", r.roleDelete) + + v2.GET("/resources/:id/role-bindings", r.roleBindingsList) + v2.GET("/resources/:id/role-bindings/:rb_id", r.roleBindingGet) + v2.POST("/resources/:id/role-bindings", r.roleBindingCreate) + v2.DELETE("/resources/:id/role-bindings/:rb_id", r.roleBindingsDelete) + v2.PATCH("/resources/:id/role-bindings/:rb_id", r.roleBindingUpdate) + + v2.GET("/actions", r.listActions) + } +} + +// middleware and utils functions to set and get the API version +const apiVersionContextKey = "apiVersion" + +func (r *Router) setAPIVersion(c echo.Context, version string) { + c.Set(apiVersionContextKey, version) +} + +func (r *Router) getAPIVersion(c echo.Context) string { + return c.Get(apiVersionContextKey).(string) +} + +func (r *Router) injectAPIVersionMW(ver string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + r.setAPIVersion(c, ver) + return next(c) + } + } } // Option defines a router option function. diff --git a/internal/api/types.go b/internal/api/types.go index e02e96c71..2b91a59ae 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -71,3 +71,41 @@ type assignmentItem struct { type listAssignmentsResponse struct { Data []assignmentItem `json:"data"` } + +// RoleBindings + +type roleBindingResponseRole struct { + ID gidx.PrefixedID `json:"id"` + Name string `json:"name"` +} + +type roleBindingSubjectCondition struct{} + +type roleBindingSubject struct { + ID gidx.PrefixedID `json:"id" binding:"required"` + Type string `json:"type,omitempty"` + Condition *roleBindingSubjectCondition `json:"condition,omitempty"` +} + +type roleBindingRequest struct { + RoleID string `json:"role_id" binding:"required"` + Subjects []roleBindingSubject `json:"subjects" binding:"required"` +} + +type rolebindingUpdateRequest struct { + Subjects []roleBindingSubject `json:"subjects" binding:"required"` +} + +type roleBindingResponse struct { + ID gidx.PrefixedID `json:"id"` + Role roleBindingResponseRole `json:"role"` + Subjects []roleBindingSubject `json:"subjects"` +} + +type listRoleBindingsResponse struct { + Data []roleBindingResponse `json:"data"` +} + +type deleteRoleBindingResponse struct { + Success bool `json:"success"` +} diff --git a/internal/iapl/default.go b/internal/iapl/default.go index e9454d3f6..d9bfec2cd 100644 --- a/internal/iapl/default.go +++ b/internal/iapl/default.go @@ -1,5 +1,7 @@ package iapl +import "go.infratographer.com/permissions-api/internal/types" + // DefaultPolicyDocument returns the default policy document for permissions-api. func DefaultPolicyDocument() PolicyDocument { return PolicyDocument{ @@ -217,3 +219,302 @@ func DefaultPolicy() Policy { return policy } + +// DefaultPolicyDocumentV2 returns the default policy document that supports +// RBAC V2 +func DefaultPolicyDocumentV2() PolicyDocument { + rbv2WithInheritFromOwner := Condition{ + RoleBindingV2: &ConditionRoleBindingV2{ + InheritGrantsFrom: []string{"owner"}, + }, + } + + rbv2WithInheritFromParent := Condition{ + RoleBindingV2: &ConditionRoleBindingV2{ + InheritGrantsFrom: []string{"parent"}, + }, + } + + return PolicyDocument{ + RBAC: &RBAC{ + RoleResource: "rolev2", + RoleSubjectTypes: []string{"user", "client"}, + RoleOwners: []string{"tenant"}, + RoleBindingResource: "role_binding", + RoleBindingSubjects: []types.TargetType{ + {Name: "user"}, + {Name: "client"}, + {Name: "group", SubjectRelation: "member"}, + }, + }, + Unions: []Union{ + { + Name: "group_member", + ResourceTypes: []types.TargetType{ + {Name: "user"}, + {Name: "client"}, + {Name: "group", SubjectRelation: "member"}, + }, + }, + { + Name: "tenant_member", + ResourceTypes: []types.TargetType{ + {Name: "user"}, + {Name: "client"}, + {Name: "group", SubjectRelation: "member"}, + {Name: "tenant", SubjectRelation: "member"}, + }, + }, + { + Name: "resourceowner", + ResourceTypes: []types.TargetType{ + {Name: "tenant"}, + }, + }, + { + Name: "resourceowner_relationship", + ResourceTypes: []types.TargetType{ + {Name: "tenant"}, {Name: "group", SubjectRelation: "parent"}, + }, + }, + { + Name: "subject", + ResourceTypeNames: []string{"user", "client"}, + }, + { + Name: "group_parent", + ResourceTypes: []types.TargetType{ + {Name: "group"}, + {Name: "group", SubjectRelation: "parent"}, + {Name: "tenant"}, + {Name: "tenant", SubjectRelation: "parent"}, + }, + }, + { + Name: "tenant_parent", + ResourceTypes: []types.TargetType{ + {Name: "tenant"}, {Name: "tenant", SubjectRelation: "parent"}, + }, + }, + }, + ResourceTypes: []ResourceType{ + {Name: "rolev2", IDPrefix: "permrv2"}, + {Name: "role_binding", IDPrefix: "permrbn"}, + {Name: "user", IDPrefix: "idntusr"}, + {Name: "client", IDPrefix: "idntclt"}, + { + Name: "group", + IDPrefix: "idntgrp", + RoleBindingV2: &ResourceRoleBindingV2{ + AvailableRolesFrom: []string{"parent"}, + }, + Relationships: []Relationship{ + {Relation: "parent", TargetTypeNames: []string{"group_parent"}}, + {Relation: "member", TargetTypeNames: []string{"group_member"}}, + {Relation: "grant", TargetTypeNames: []string{"role_binding"}}, + }, + }, + { + Name: "tenant", + IDPrefix: "tnntten", + RoleBindingV2: &ResourceRoleBindingV2{ + AvailableRolesFrom: []string{"parent"}, + }, + Relationships: []Relationship{ + {Relation: "parent", TargetTypeNames: []string{"tenant_parent"}}, + {Relation: "member", TargetTypeNames: []string{"tenant_member"}}, + {Relation: "grant", TargetTypeNames: []string{"role_binding"}}, + }, + }, + { + Name: "loadbalancer", + IDPrefix: "loadbal", + RoleBindingV2: &ResourceRoleBindingV2{ + AvailableRolesFrom: []string{"owner"}, + }, + Relationships: []Relationship{ + {Relation: "owner", TargetTypeNames: []string{"resourceowner_relationship"}}, + {Relation: "grant", TargetTypeNames: []string{"role_binding"}}, + }, + }, + }, + Actions: []Action{ + {Name: "role_create"}, + {Name: "role_get"}, + {Name: "role_list"}, + {Name: "role_update"}, + {Name: "role_delete"}, + {Name: "loadbalancer_create"}, + {Name: "loadbalancer_get"}, + {Name: "loadbalancer_list"}, + {Name: "loadbalancer_update"}, + {Name: "loadbalancer_delete"}, + }, + ActionBindings: []ActionBinding{ + { + ActionName: "role_get", + TypeName: "rolev2", + Conditions: []Condition{ + { + RelationshipAction: &ConditionRelationshipAction{ + Relation: "owner", + ActionName: "role_get", + }, + }, + }, + }, + { + ActionName: "role_update", + TypeName: "rolev2", + Conditions: []Condition{ + { + RelationshipAction: &ConditionRelationshipAction{ + Relation: "owner", + ActionName: "role_update", + }, + }, + }, + }, + { + ActionName: "role_delete", + TypeName: "rolev2", + Conditions: []Condition{ + { + RelationshipAction: &ConditionRelationshipAction{ + Relation: "owner", + ActionName: "role_delete", + }, + }, + }, + }, + { + ActionName: "role_create", + TypeName: "resourceowner", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "role_create", + TypeName: "group", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "role_get", + TypeName: "resourceowner", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "role_get", + TypeName: "group", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "role_list", + TypeName: "resourceowner", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "role_list", + TypeName: "group", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "role_update", + TypeName: "resourceowner", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "role_update", + TypeName: "group", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "role_delete", + TypeName: "resourceowner", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "role_delete", + TypeName: "group", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "loadbalancer_get", + TypeName: "loadbalancer", + Conditions: []Condition{rbv2WithInheritFromOwner}, + }, + { + ActionName: "loadbalancer_update", + TypeName: "loadbalancer", + Conditions: []Condition{rbv2WithInheritFromOwner}, + }, + { + ActionName: "loadbalancer_delete", + TypeName: "loadbalancer", + Conditions: []Condition{rbv2WithInheritFromOwner}, + }, + { + ActionName: "loadbalancer_create", + TypeName: "resourceowner", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "loadbalancer_create", + TypeName: "group", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "loadbalancer_get", + TypeName: "resourceowner", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "loadbalancer_get", + TypeName: "group", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "loadbalancer_list", + TypeName: "resourceowner", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "loadbalancer_list", + TypeName: "group", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "loadbalancer_update", + TypeName: "resourceowner", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "loadbalancer_update", + TypeName: "group", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "loadbalancer_delete", + TypeName: "resourceowner", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + { + ActionName: "loadbalancer_delete", + TypeName: "group", + Conditions: []Condition{rbv2WithInheritFromParent}, + }, + }, + } +} + +// DefaultPolicyV2 generates the default policy for permissions-api that supports +// RBAC V2 +func DefaultPolicyV2() Policy { + policyDocument := DefaultPolicyDocumentV2() + + policy := NewPolicy(policyDocument) + if err := policy.Validate(); err != nil { + panic(err) + } + + return policy +} diff --git a/internal/query/errors.go b/internal/query/errors.go index 7373102ee..961f7bf8d 100644 --- a/internal/query/errors.go +++ b/internal/query/errors.go @@ -1,6 +1,9 @@ package query -import "errors" +import ( + "errors" + "fmt" +) var ( // ErrActionNotAssigned represents an error condition where the subject is not able to complete @@ -22,6 +25,25 @@ var ( // ErrRoleNotFound represents an error when no matching role was found on resource ErrRoleNotFound = errors.New("role not found") + // ErrResourceNotFound represents an error when no matching resource was found + ErrResourceNotFound = errors.New("resource not found") + + // ErrRoleBindingNotFound represents an error when no matching role-binding was found + ErrRoleBindingNotFound = errors.New("role-binding not found") + // ErrRoleHasTooManyResources represents an error which a role has too many resources ErrRoleHasTooManyResources = errors.New("role has too many resources") + + // ErrInvalidArgument represents an error when there is an invalid argument passed to a function + ErrInvalidArgument = errors.New("invalid argument") + + // ErrRoleAlreadyExists represents an error when a role already exists + ErrRoleAlreadyExists = fmt.Errorf("%w: role already exists", ErrInvalidArgument) + + // ErrInvalidRoleBindingSubjectType represents an error when a role binding subject type is invalid + ErrInvalidRoleBindingSubjectType = fmt.Errorf("%w: invalid role binding subject type", ErrInvalidArgument) + + // ErrResourceDoesNotSupportRoleBindingV2 represents an error when a role binding + // request attempts to use a resource that does not support role binding v2 + ErrResourceDoesNotSupportRoleBindingV2 = fmt.Errorf("%w: resource does not support role binding v2", ErrInvalidArgument) ) diff --git a/internal/query/mock/mock.go b/internal/query/mock/mock.go index c9e9a5897..3a52c2796 100644 --- a/internal/query/mock/mock.go +++ b/internal/query/mock/mock.go @@ -13,9 +13,7 @@ import ( "go.infratographer.com/x/gidx" ) -var ( - errorInvalidNamespace = errors.New("invalid namespace") -) +var errorInvalidNamespace = errors.New("invalid namespace") var _ query.Engine = &Engine{} @@ -63,6 +61,17 @@ func (e *Engine) CreateRole(ctx context.Context, actor, res types.Resource, name return role, nil } +// CreateRoleV2 creates a v2 role object +// TODO: Implement this +func (e *Engine) CreateRoleV2(ctx context.Context, actor, owner types.Resource, roleName string, actions []string) (types.Role, error) { + return types.Role{}, nil +} + +// ListRolesV2 list roles +func (e *Engine) ListRolesV2(ctx context.Context, owner types.Resource) ([]types.Role, error) { + return nil, nil +} + // UpdateRole returns the provided mock results. func (e *Engine) UpdateRole(ctx context.Context, actor, roleResource types.Resource, newName string, newActions []string) (types.Role, error) { args := e.Called(actor, roleResource, newName, newActions) @@ -72,11 +81,21 @@ func (e *Engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou return retRole, args.Error(1) } +// UpdateRoleV2 returns nothing but satisfies the Engine interface. +func (e *Engine) UpdateRoleV2(ctx context.Context, actor, roleResource types.Resource, newName string, newActions []string) (types.Role, error) { + return types.Role{}, nil +} + // GetRole returns nothing but satisfies the Engine interface. func (e *Engine) GetRole(ctx context.Context, roleResource types.Resource) (types.Role, error) { return types.Role{}, nil } +// GetRoleV2 returns nothing but satisfies the Engine interface. +func (e *Engine) GetRoleV2(ctx context.Context, owner types.Resource) (types.Role, error) { + return types.Role{}, nil +} + // GetRoleResource returns nothing but satisfies the Engine interface. func (e *Engine) GetRoleResource(ctx context.Context, roleResource types.Resource) (types.Resource, error) { return types.Resource{}, nil @@ -116,6 +135,11 @@ func (e *Engine) DeleteRole(ctx context.Context, roleResource types.Resource) er return args.Error(0) } +// DeleteRoleV2 does nothing but satisfies the Engine interface. +func (e *Engine) DeleteRoleV2(ctx context.Context, roleResource types.Resource) error { + return nil +} + // DeleteResourceRelationships does nothing but satisfies the Engine interface. func (e *Engine) DeleteResourceRelationships(ctx context.Context, resource types.Resource) error { args := e.Called() @@ -174,3 +198,33 @@ func (e *Engine) SubjectHasPermission(ctx context.Context, subject types.Resourc return nil } + +// CreateRoleBinding returns nothing but satisfies the Engine interface. +func (e *Engine) CreateRoleBinding(ctx context.Context, resource, role types.Resource, subjects []types.RoleBindingSubject) (types.RoleBinding, error) { + return types.RoleBinding{}, nil +} + +// ListRoleBindings returns nothing but satisfies the Engine interface. +func (e *Engine) ListRoleBindings(ctx context.Context, resource types.Resource, optionalRole *types.Resource) ([]types.RoleBinding, error) { + return nil, nil +} + +// GetRoleBinding returns nothing but satisfies the Engine interface. +func (e *Engine) GetRoleBinding(ctx context.Context, resource types.Resource) (types.RoleBinding, error) { + return types.RoleBinding{}, nil +} + +// DeleteRoleBinding returns nothing but satisfies the Engine interface. +func (e *Engine) DeleteRoleBinding(ctx context.Context, rb, res types.Resource) error { + return nil +} + +// UpdateRoleBinding returns nothing but satisfies the Engine interface. +func (e *Engine) UpdateRoleBinding(context.Context, types.Resource, []types.RoleBindingSubject) (types.RoleBinding, error) { + return types.RoleBinding{}, nil +} + +// AllActions returns nothing but satisfies the Engine interface. +func (e *Engine) AllActions() []string { + return nil +} diff --git a/internal/query/relations.go b/internal/query/relations.go index 7f845d81e..655a4ebf2 100644 --- a/internal/query/relations.go +++ b/internal/query/relations.go @@ -330,34 +330,34 @@ func (e *engine) CreateRole(ctx context.Context, actor, res types.Resource, role return role, nil } -// actionsDiff determines which actions needs to be added and removed. -// If no new actions are provided it is assumed no changes are requested. -func actionsDiff(oldActions, newActions []string) ([]string, []string) { - if len(newActions) == 0 { +// diff determines which entities needs to be added and removed. +// If no new entity are provided it is assumed no changes are requested. +func diff(current, incoming []string) ([]string, []string) { + if len(incoming) == 0 { return nil, nil } - old := make(map[string]struct{}, len(oldActions)) - new := make(map[string]struct{}, len(newActions)) + curr := make(map[string]struct{}, len(current)) + in := make(map[string]struct{}, len(incoming)) var add, rem []string - for _, action := range oldActions { - old[action] = struct{}{} + for _, entity := range current { + curr[entity] = struct{}{} } - for _, action := range newActions { - new[action] = struct{}{} + for _, action := range incoming { + in[action] = struct{}{} // If the new action is not in the old actions, then we need to add the action. - if _, ok := old[action]; !ok { + if _, ok := curr[action]; !ok { add = append(add, action) } } - for _, action := range oldActions { + for _, action := range current { // If the old action is not in the new actions, then we need to remove it. - if _, ok := new[action]; !ok { + if _, ok := in[action]; !ok { rem = append(rem, action) } } @@ -403,7 +403,7 @@ func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou newName = role.Name } - addActions, remActions := actionsDiff(role.Actions, newActions) + addActions, remActions := diff(role.Actions, newActions) // If no changes, return existing role with no changes. if newName == role.Name && len(addActions) == 0 && len(remActions) == 0 { diff --git a/internal/query/relations_test.go b/internal/query/relations_test.go index 2235c6123..30e13fa7b 100644 --- a/internal/query/relations_test.go +++ b/internal/query/relations_test.go @@ -21,7 +21,7 @@ import ( "go.infratographer.com/permissions-api/internal/types" ) -func testEngine(ctx context.Context, t *testing.T, namespace string) *engine { +func testEngine(ctx context.Context, t *testing.T, namespace string, policy iapl.Policy) *engine { config := spicedbx.Config{ Endpoint: "spicedb:50051", Key: "infradev", @@ -33,8 +33,6 @@ func testEngine(ctx context.Context, t *testing.T, namespace string) *engine { store, cleanStore := teststore.NewTestStorage(t) - policy := testPolicy() - schema, err := spicedbx.GenerateSchema(namespace, policy.Schema()) require.NoError(t, err) @@ -107,7 +105,7 @@ func cleanDB(ctx context.Context, t *testing.T, client *authzed.Client, namespac func TestCreateRoles(t *testing.T) { namespace := "testroles" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) testCases := []testingx.TestCase[[]string, []types.Role]{ { @@ -167,7 +165,7 @@ func TestCreateRoles(t *testing.T) { func TestGetRoles(t *testing.T) { namespace := "testroles" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) tenID, err := gidx.NewID("tnntten") require.NoError(t, err) tenRes, err := e.NewResourceFromID(tenID) @@ -222,7 +220,7 @@ func TestGetRoles(t *testing.T) { func TestRoleUpdate(t *testing.T) { namespace := "testroles" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) tenID, err := gidx.NewID("tnntten") require.NoError(t, err) @@ -292,7 +290,7 @@ func TestRoleUpdate(t *testing.T) { func TestListRoles(t *testing.T) { namespace := "testroles" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) actorRes, err := e.NewResourceFromID(gidx.MustNewID("idntusr")) require.NoError(t, err) @@ -377,7 +375,7 @@ func TestListRoles(t *testing.T) { func TestRoleDelete(t *testing.T) { namespace := "testroles" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) tenID, err := gidx.NewID("tnntten") require.NoError(t, err) @@ -439,7 +437,7 @@ func TestRoleDelete(t *testing.T) { func TestAssignments(t *testing.T) { namespace := "testassignments" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) tenID, err := gidx.NewID("tnntten") require.NoError(t, err) @@ -499,7 +497,7 @@ func TestAssignments(t *testing.T) { func TestUnassignments(t *testing.T) { namespace := "testassignments" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) tenID, err := gidx.NewID("tnntten") require.NoError(t, err) @@ -581,7 +579,7 @@ func TestUnassignments(t *testing.T) { func TestRelationships(t *testing.T) { namespace := "testrelationships" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) parentID, err := gidx.NewID("tnntten") require.NoError(t, err) @@ -672,7 +670,7 @@ func TestRelationships(t *testing.T) { func TestRelationshipDelete(t *testing.T) { namespace := "testrelationships" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) parentID, err := gidx.NewID("tnntten") require.NoError(t, err) @@ -744,7 +742,7 @@ func TestRelationshipDelete(t *testing.T) { func TestSubjectActions(t *testing.T) { namespace := "infratestactions" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) parentID, err := gidx.NewID("tnntten") require.NoError(t, err) diff --git a/internal/query/rolebindings.go b/internal/query/rolebindings.go new file mode 100644 index 000000000..b9104890f --- /dev/null +++ b/internal/query/rolebindings.go @@ -0,0 +1,635 @@ +package query + +import ( + "context" + "errors" + "fmt" + "sync" + + pb "github.com/authzed/authzed-go/proto/authzed/api/v1" + "go.infratographer.com/x/gidx" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "go.infratographer.com/permissions-api/internal/iapl" + "go.infratographer.com/permissions-api/internal/types" +) + +func (e *engine) CreateRoleBinding(ctx context.Context, resource, roleResource types.Resource, subjects []types.RoleBindingSubject) (types.RoleBinding, error) { + ctx, span := e.tracer.Start( + ctx, "engine.BindRole", + trace.WithAttributes( + attribute.Stringer("role_id", roleResource.ID), + attribute.Stringer("resource_id", resource.ID), + ), + ) + defer span.End() + + if err := e.isRoleBindable(ctx, roleResource, resource); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + dbrole, err := e.store.GetRoleByID(ctx, roleResource.ID) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + role := types.Role{ + ID: roleResource.ID, + Name: dbrole.Name, + } + + rbResourceType, ok := e.schemaTypeMap[e.rbac.RoleBindingResource] + if !ok { + return types.RoleBinding{}, fmt.Errorf( + "%w: invalid role-binding resource type: %s", + ErrInvalidType, e.rbac.RoleBindingResource, + ) + } + + rb := newRoleBindingWithPrefix(rbResourceType.IDPrefix, role) + roleRel := e.rolebindingRoleRelationship(role.ID.String(), rb.ID.String()) + + grantRel, err := e.rolebindingGrantResourceRelationship(resource, rb.ID.String()) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + updates := []*pb.RelationshipUpdate{ + { + Operation: pb.RelationshipUpdate_OPERATION_TOUCH, + Relationship: roleRel, + }, + { + Operation: pb.RelationshipUpdate_OPERATION_TOUCH, + Relationship: grantRel, + }, + } + + subjUpdates := make([]*pb.RelationshipUpdate, len(subjects)) + rb.Subjects = make([]types.RoleBindingSubject, len(subjects)) + + for i, subj := range subjects { + rel, err := e.rolebindingSubjectRelationship(subj.SubjectResource, rb.ID.String()) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + rb.Subjects[i] = subj + subjUpdates[i] = &pb.RelationshipUpdate{ + Operation: pb.RelationshipUpdate_OPERATION_TOUCH, + Relationship: rel, + } + } + + if _, err := e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{ + Updates: append(updates, subjUpdates...), + }); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + return rb, nil +} + +func (e *engine) DeleteRoleBinding(ctx context.Context, rb, res types.Resource) error { + ctx, span := e.tracer.Start( + ctx, "engine.UnbindRole", + trace.WithAttributes( + attribute.Stringer("role_binding_id", rb.ID), + ), + ) + defer span.End() + + // delete relationships from the role-binding + if _, err := e.client.DeleteRelationships(ctx, &pb.DeleteRelationshipsRequest{ + RelationshipFilter: &pb.RelationshipFilter{ + ResourceType: e.namespaced(e.rbac.RoleBindingResource), + OptionalResourceId: rb.ID.String(), + }, + }); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + + // delete relationships to the role-binding + if _, err := e.client.DeleteRelationships(ctx, &pb.DeleteRelationshipsRequest{ + RelationshipFilter: &pb.RelationshipFilter{ + ResourceType: e.namespaced(res.Type), + OptionalRelation: iapl.GrantRelationship, + OptionalSubjectFilter: &pb.SubjectFilter{ + SubjectType: e.namespaced(e.rbac.RoleBindingResource), + OptionalSubjectId: rb.ID.String(), + }, + }, + }); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + + return nil +} + +func (e *engine) ListRoleBindings(ctx context.Context, resource types.Resource, optionalRole *types.Resource) ([]types.RoleBinding, error) { + ctx, span := e.tracer.Start( + ctx, "engine.ListRoleBinding", + trace.WithAttributes( + attribute.Stringer("resource_id", resource.ID), + ), + ) + defer span.End() + + e.logger.Debugf("listing role-bindings for resource: %s, optionalRole: %v", resource.ID, optionalRole) + + // 1. list all grants on the resource + listRbFilter := &pb.RelationshipFilter{ + ResourceType: e.namespaced(resource.Type), + OptionalResourceId: resource.ID.String(), + OptionalRelation: iapl.GrantRelationship, + OptionalSubjectFilter: &pb.SubjectFilter{ + SubjectType: e.namespaced(e.rbac.RoleBindingResource), + }, + } + + grantRel, err := e.readRelationships(ctx, listRbFilter) + if err != nil { + return nil, err + } + + // 2. fetch role-binding details for each grant + bindings := make(chan types.RoleBinding, len(grantRel)) + errs := make(chan error, len(grantRel)) + wg := &sync.WaitGroup{} + + for _, rel := range grantRel { + wg.Add(1) + + go func(grant *pb.Relationship) { + defer wg.Done() + + rbRes, err := e.NewResourceFromIDString(grant.Subject.Object.ObjectId) + if err != nil { + errs <- err + return + } + + rb, err := e.fetchRoleBinding(ctx, rbRes) + if err != nil { + if errors.Is(err, ErrRoleBindingNotFound) { + err := fmt.Errorf("%w: dangling grant relationship: %s", err, grant.String()) + + span.RecordError(err) + e.logger.Warnf(err.Error()) + + return + } + errs <- err + + return + } + + if optionalRole != nil && rb.Role.ID.String() != optionalRole.ID.String() { + return + } + + if len(rb.Subjects) == 0 { + return + } + + bindings <- rb + }(rel) + } + + wg.Wait() + close(errs) + close(bindings) + + for err := range errs { + if err != nil { + return nil, err + } + } + + resp := make([]types.RoleBinding, 0, len(bindings)) + + for rb := range bindings { + resp = append(resp, rb) + } + + return resp, nil +} + +func (e *engine) UpdateRoleBinding(ctx context.Context, rb types.Resource, subjects []types.RoleBindingSubject) (types.RoleBinding, error) { + ctx, span := e.tracer.Start( + ctx, "engine.UpdateRoleBindings", + trace.WithAttributes( + attribute.Stringer("rolebinding_id", rb.ID), + ), + ) + defer span.End() + + rolebinding, err := e.fetchRoleBinding(ctx, rb) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + // 1. find the subjects to add or remove + current := make([]string, len(rolebinding.Subjects)) + incoming := make([]string, len(subjects)) + + for i, subj := range rolebinding.Subjects { + current[i] = subj.SubjectResource.ID.String() + } + + for i, subj := range subjects { + incoming[i] = subj.SubjectResource.ID.String() + } + + add, remove := diff(current, incoming) + + // return if there are no changes + if (len(add) + len(remove)) == 0 { + return rolebinding, nil + } + + // 2. create relationship updates + updates := make([]*pb.RelationshipUpdate, len(add)+len(remove)) + i := 0 + + mkupdate := func(id string, op pb.RelationshipUpdate_Operation) (*pb.RelationshipUpdate, error) { + subjRes, err := e.NewResourceFromIDString(id) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return nil, err + } + + rel, err := e.rolebindingSubjectRelationship(subjRes, rb.ID.String()) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return nil, err + } + + return &pb.RelationshipUpdate{Operation: op, Relationship: rel}, nil + } + + for _, id := range add { + update, err := mkupdate(id, pb.RelationshipUpdate_OPERATION_TOUCH) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + updates[i] = update + i++ + } + + for _, id := range remove { + update, err := mkupdate(id, pb.RelationshipUpdate_OPERATION_DELETE) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + updates[i] = update + i++ + } + + _, err = e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{Updates: updates}) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + rolebinding.Subjects = subjects + + return rolebinding, nil +} + +func (e *engine) GetRoleBinding(ctx context.Context, rolebinding types.Resource) (types.RoleBinding, error) { + ctx, span := e.tracer.Start( + ctx, "engine.GetRoleBinding", + trace.WithAttributes(attribute.Stringer("role_binding_id", rolebinding.ID)), + ) + defer span.End() + + return e.fetchRoleBinding(ctx, rolebinding) +} + +func (e *engine) isRoleBindable(ctx context.Context, role, res types.Resource) error { + req := &pb.CheckPermissionRequest{ + Resource: &pb.ObjectReference{ + ObjectType: e.namespaced(res.Type), + ObjectId: res.ID.String(), + }, + Subject: &pb.SubjectReference{ + Object: &pb.ObjectReference{ + ObjectType: e.namespaced(e.rbac.RoleResource), + ObjectId: role.ID.String(), + }, + }, + Permission: iapl.AvailableRoleRelation, + Consistency: &pb.Consistency{ + Requirement: &pb.Consistency_FullyConsistent{FullyConsistent: true}, + }, + } + + err := e.checkPermission(ctx, req) + + switch { + case err == nil: + return nil + case errors.Is(err, ErrActionNotAssigned): + return fmt.Errorf("%w: role: %s is not available for resource: %s", ErrRoleNotFound, role.ID, res.ID) + default: + return err + } +} + +// deleteRoleBindingForRole deletes all role-binding relationships with a given role. +func (e *engine) deleteRoleBindingForRole(ctx context.Context, roleResource types.Resource) error { + ctx, span := e.tracer.Start( + ctx, "engine.deleteRoleBinding", + trace.WithAttributes( + attribute.Stringer("role_id", roleResource.ID), + ), + ) + defer span.End() + + requests := []*pb.DeleteRelationshipsRequest{} + + // 1. find all the bindings for the role + findBindingsFilter := &pb.RelationshipFilter{ + ResourceType: e.namespaced(e.rbac.RoleBindingResource), + OptionalRelation: iapl.RolebindingRoleRelation, + OptionalSubjectFilter: &pb.SubjectFilter{ + SubjectType: e.namespaced(e.rbac.RoleResource), + OptionalSubjectId: roleResource.ID.String(), + }, + } + + bindings, err := e.readRelationships(ctx, findBindingsFilter) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + + // 2. build a list of delete request for the subject and role relationship + // at the same time build a list of delete requests for all the grant + // relationships for all bindable resources + for _, rb := range bindings { + delSubjReq := &pb.DeleteRelationshipsRequest{ + RelationshipFilter: &pb.RelationshipFilter{ + ResourceType: rb.Resource.ObjectType, + OptionalResourceId: rb.Resource.ObjectId, + }, + } + + requests = append(requests, delSubjReq) + + for _, res := range e.rolebindingV2Resources { + delGrantReq := &pb.DeleteRelationshipsRequest{ + RelationshipFilter: &pb.RelationshipFilter{ + ResourceType: e.namespaced(res.Name), + OptionalRelation: iapl.GrantRelationship, + OptionalSubjectFilter: &pb.SubjectFilter{ + SubjectType: rb.Resource.ObjectType, + OptionalSubjectId: rb.Resource.ObjectId, + }, + }, + } + + requests = append(requests, delGrantReq) + } + } + + e.logger.Debugf("%d delete requests created", len(requests)) + + // 3. delete all the relationships + wg := &sync.WaitGroup{} + errs := make(chan error, len(requests)) + + for _, req := range requests { + wg.Add(1) + + go func(req *pb.DeleteRelationshipsRequest) { + defer wg.Done() + + if _, err := e.client.DeleteRelationships(ctx, req); err != nil { + errs <- err + } + }(req) + } + + wg.Wait() + close(errs) + + for err := range errs { + if err != nil { + return err + } + } + + return nil +} + +func (e *engine) fetchRoleBinding(ctx context.Context, roleBinding types.Resource) (types.RoleBinding, error) { + ctx, span := e.tracer.Start( + ctx, "engine.fetchRoleBinding", + trace.WithAttributes(attribute.Stringer("role_binding_id", roleBinding.ID)), + ) + defer span.End() + + // get all role-bindings relationships + rbRelFilter := &pb.RelationshipFilter{ + ResourceType: e.namespaced(e.rbac.RoleBindingResource), + OptionalResourceId: roleBinding.ID.String(), + } + + rbRel, err := e.readRelationships(ctx, rbRelFilter) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + if len(rbRel) < 1 { + err := fmt.Errorf("%w: role binding: %s", ErrRoleBindingNotFound, roleBinding.ID.String()) + + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + rb := types.RoleBinding{ + ID: roleBinding.ID, + Subjects: make([]types.RoleBindingSubject, 0, len(rbRel)), + } + + for _, rel := range rbRel { + // process subject relationships + if rel.Relation == iapl.RolebindingSubjectRelation { + subjectRes, err := e.NewResourceFromIDString(rel.Subject.Object.ObjectId) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + rb.Subjects = append(rb.Subjects, types.RoleBindingSubject{SubjectResource: subjectRes}) + + continue + } + + // process role relationships + roleID, err := gidx.Parse(rel.Subject.Object.ObjectId) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + dbRole, err := e.store.GetRoleByID(ctx, roleID) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + rb.Role = types.Role{ + ID: roleID, + Name: dbRole.Name, + } + } + + return rb, nil +} + +func (e *engine) rolebindingSubjectRelationship(subj types.Resource, rbID string) (*pb.Relationship, error) { + subjConf, ok := e.rolebindingV2SubjectsMap[subj.Type] + if !ok { + return nil, fmt.Errorf( + "%w: subject: %s, subject type: %s", ErrInvalidRoleBindingSubjectType, + subj.ID, subj.Type, + ) + } + + relationshipSubject := &pb.SubjectReference{ + Object: &pb.ObjectReference{ + ObjectType: e.namespaced(subjConf.Name), + ObjectId: subj.ID.String(), + }, + } + + // for grants like "group#member" + if subjConf.SubjectRelation != "" { + relationshipSubject.OptionalRelation = subjConf.SubjectRelation + } + + relationship := &pb.Relationship{ + Resource: &pb.ObjectReference{ + ObjectType: e.namespaced(e.rbac.RoleBindingResource), + ObjectId: rbID, + }, + Relation: iapl.RolebindingSubjectRelation, + Subject: relationshipSubject, + } + + return relationship, nil +} + +func (e *engine) rolebindingRoleRelationship(roleID, rbID string) *pb.Relationship { + return &pb.Relationship{ + Resource: &pb.ObjectReference{ + ObjectType: e.namespaced(e.rbac.RoleBindingResource), + ObjectId: rbID, + }, + Relation: iapl.RolebindingRoleRelation, + Subject: &pb.SubjectReference{ + Object: &pb.ObjectReference{ + ObjectType: e.namespaced(e.rbac.RoleResource), + ObjectId: roleID, + }, + }, + } +} + +func (e *engine) rolebindingGrantResourceRelationship(resource types.Resource, rbID string) (*pb.Relationship, error) { + rel := &pb.Relationship{ + Resource: &pb.ObjectReference{ + ObjectType: e.namespaced(resource.Type), + ObjectId: resource.ID.String(), + }, + Relation: iapl.GrantRelationship, + Subject: &pb.SubjectReference{ + Object: &pb.ObjectReference{ + ObjectType: e.namespaced(e.rbac.RoleBindingResource), + ObjectId: rbID, + }, + }, + } + + return rel, nil +} + +// NewResourceFromIDString creates a new resource from a string. +func (e *engine) NewResourceFromIDString(id string) (types.Resource, error) { + subjID, err := gidx.Parse(id) + if err != nil { + return types.Resource{}, err + } + + subject, err := e.NewResourceFromID(subjID) + if err != nil { + return types.Resource{}, err + } + + return subject, nil +} + +func newRoleBindingWithPrefix(prefix string, role types.Role) types.RoleBinding { + rb := types.RoleBinding{ + ID: gidx.MustNewID(prefix), + Role: role, + } + + return rb +} diff --git a/internal/query/rolebindings_test.go b/internal/query/rolebindings_test.go new file mode 100644 index 000000000..003ba5da1 --- /dev/null +++ b/internal/query/rolebindings_test.go @@ -0,0 +1,734 @@ +package query + +import ( + "context" + "testing" + + pb "github.com/authzed/authzed-go/proto/authzed/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.infratographer.com/permissions-api/internal/iapl" + "go.infratographer.com/permissions-api/internal/testingx" + "go.infratographer.com/permissions-api/internal/types" +) + +func TestCreateRoleBinding(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + + doc := iapl.DefaultPolicyDocumentV2() + doc.ResourceTypes = append(doc.ResourceTypes, iapl.ResourceType{ + Name: "role", + IDPrefix: "permrol", + Relationships: []iapl.Relationship{ + { + Relation: "subject", + TargetTypeNames: []string{"subject"}, + }, + }, + }) + + policy := iapl.NewPolicy(doc) + err := policy.Validate() + require.NoError(t, err) + + e := testEngine(ctx, t, namespace, policy) + + root, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + child, err := e.NewResourceFromIDString("tnntten-child") + require.NoError(t, err) + orphan, err := e.NewResourceFromIDString("tnntten-orphan") + require.NoError(t, err) + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + role, err := e.CreateRoleV2(ctx, actor, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + require.NoError(t, err) + + roleRes, err := e.NewResourceFromID(role.ID) + require.NoError(t, err) + + notfoundRole, err := e.NewResourceFromIDString("permrv2-notfound") + require.NoError(t, err) + + v1role, err := e.NewResourceFromIDString("permrol-v1role") + require.NoError(t, err) + + _, err = e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{ + Updates: rbacV2CreateParentRel(root, child, namespace), + }) + require.NoError(t, err) + + type input struct { + resource types.Resource + role types.Resource + subjects []types.RoleBindingSubject + } + + tc := []testingx.TestCase[input, types.RoleBinding]{ + { + Name: "CreateRoleBindingRoleNotFound", + Input: input{ + resource: root, + role: notfoundRole, + subjects: []types.RoleBindingSubject{{SubjectResource: actor}}, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.ErrorContains(t, res.Err, ErrRoleNotFound.Error()) + }, + }, + { + Name: "CreateRoleBindingV1Role", + Input: input{ + resource: root, + role: v1role, + subjects: []types.RoleBindingSubject{{SubjectResource: actor}}, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.ErrorContains(t, res.Err, ErrRoleNotFound.Error()) + }, + }, + { + Name: "CreateRoleBindingChild", + Input: input{ + resource: child, + role: roleRes, + subjects: []types.RoleBindingSubject{{SubjectResource: actor}}, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.NoError(t, res.Err) + assert.Equal(t, role.ID, res.Success.Role.ID) + assert.Len(t, res.Success.Subjects, 1) + + rb, err := e.ListRoleBindings(ctx, child, nil) + assert.NoError(t, err) + assert.Len(t, rb, 1) + }, + }, + { + Name: "CreateRoleBindingOrphan", + Input: input{ + resource: orphan, + role: roleRes, + subjects: []types.RoleBindingSubject{{SubjectResource: actor}}, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.ErrorContains(t, res.Err, ErrRoleNotFound.Error()) + }, + }, + { + Name: "CreateRoleBindingSuccess", + Input: input{ + resource: root, + role: roleRes, + subjects: []types.RoleBindingSubject{{SubjectResource: actor}}, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.NoError(t, res.Err) + + assert.Equal(t, role.ID, res.Success.Role.ID) + assert.Len(t, res.Success.Subjects, 1) + + rb, err := e.ListRoleBindings(ctx, root, nil) + assert.NoError(t, err) + assert.Len(t, rb, 1) + }, + }, + } + + testFn := func(ctx context.Context, in input) testingx.TestResult[types.RoleBinding] { + rb, err := e.CreateRoleBinding(ctx, in.resource, in.role, in.subjects) + return testingx.TestResult[types.RoleBinding]{Success: rb, Err: err} + } + + testingx.RunTests(ctx, t, tc, testFn) +} + +func TestListRoleBindings(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace, rbacv2TestPolicy()) + + root, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + child, err := e.NewResourceFromIDString("tnntten-child") + require.NoError(t, err) + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + viewer, err := e.CreateRoleV2(ctx, actor, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + require.NoError(t, err) + + editor, err := e.CreateRoleV2(ctx, actor, root, "lb_editor", []string{"loadbalancer_list", "loadbalancer_get", "loadbalancer_create", "loadbalancer_update"}) + require.NoError(t, err) + + viewerRes, err := e.NewResourceFromID(viewer.ID) + require.NoError(t, err) + + editorRes, err := e.NewResourceFromID(editor.ID) + require.NoError(t, err) + + notfoundRole, err := e.NewResourceFromIDString("permrv2-notfound") + require.NoError(t, err) + + _, err = e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + require.NoError(t, err) + + _, err = e.CreateRoleBinding(ctx, root, editorRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + require.NoError(t, err) + + _, err = e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{ + Updates: rbacV2CreateParentRel(root, child, namespace), + }) + require.NoError(t, err) + + type input struct { + resource types.Resource + role *types.Resource + } + + tc := []testingx.TestCase[input, []types.RoleBinding]{ + { + Name: "ListAll", + Input: input{ + resource: root, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.RoleBinding]) { + assert.Len(t, res.Success, 2) + }, + }, + { + Name: "ListWithViewerRole", + Input: input{ + resource: root, + role: &viewerRes, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.RoleBinding]) { + assert.Len(t, res.Success, 1) + assert.Equal(t, viewer.ID, res.Success[0].Role.ID) + }, + }, + { + Name: "ListWithEditorRole", + Input: input{ + resource: root, + role: &editorRes, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.RoleBinding]) { + assert.Len(t, res.Success, 1) + assert.Equal(t, editor.ID, res.Success[0].Role.ID) + }, + }, + { + Name: "ListChildTenant", + Input: input{ + resource: child, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.RoleBinding]) { + assert.Len(t, res.Success, 0) + }, + }, + { + Name: "ListWithNonExistentRole", + Input: input{ + resource: root, + role: ¬foundRole, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.RoleBinding]) { + assert.Len(t, res.Success, 0) + }, + }, + } + + testFn := func(ctx context.Context, in input) testingx.TestResult[[]types.RoleBinding] { + rb, err := e.ListRoleBindings(ctx, in.resource, in.role) + return testingx.TestResult[[]types.RoleBinding]{Success: rb, Err: err} + } + + testingx.RunTests(ctx, t, tc, testFn) +} + +func TestGetRoleBinding(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace, rbacv2TestPolicy()) + + root, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + viewer, err := e.CreateRoleV2(ctx, actor, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + require.NoError(t, err) + + viewerRes, err := e.NewResourceFromID(viewer.ID) + require.NoError(t, err) + + notfoundRB, err := e.NewResourceFromIDString("permrbn-notfound") + require.NoError(t, err) + + rb, err := e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + require.NoError(t, err) + + rbRes, err := e.NewResourceFromID(rb.ID) + require.NoError(t, err) + + tc := []testingx.TestCase[types.Resource, types.RoleBinding]{ + { + Name: "GetRoleBindingSuccess", + Input: rbRes, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.NoError(t, res.Err) + assert.Equal(t, viewer.ID, res.Success.Role.ID) + assert.Len(t, res.Success.Subjects, 1) + assert.Equal(t, actor.ID, res.Success.Subjects[0].SubjectResource.ID) + }, + }, + { + Name: "GetRoleBindingNotFound", + Input: notfoundRB, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.ErrorContains(t, res.Err, ErrRoleBindingNotFound.Error()) + }, + }, + } + + testFn := func(ctx context.Context, in types.Resource) testingx.TestResult[types.RoleBinding] { + rb, err := e.GetRoleBinding(ctx, in) + return testingx.TestResult[types.RoleBinding]{Success: rb, Err: err} + } + + testingx.RunTests(ctx, t, tc, testFn) +} + +func TestUpdateRoleBinding(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace, rbacv2TestPolicy()) + + root, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + viewer, err := e.CreateRoleV2(ctx, actor, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + require.NoError(t, err) + viewerRes, err := e.NewResourceFromID(viewer.ID) + require.NoError(t, err) + + rb, err := e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + require.NoError(t, err) + rbRes, err := e.NewResourceFromID(rb.ID) + require.NoError(t, err) + notfoundRB, err := e.NewResourceFromIDString("permrbn-notfound") + require.NoError(t, err) + + user1, err := e.NewResourceFromIDString("idntusr-user1") + require.NoError(t, err) + group1, err := e.NewResourceFromIDString("idntgrp-group1") + require.NoError(t, err) + invalidsubj, err := e.NewResourceFromIDString("loadbal-lb") + require.NoError(t, err) + + type input struct { + rb types.Resource + subj []types.RoleBindingSubject + } + + tc := []testingx.TestCase[input, types.RoleBinding]{ + { + Name: "UpdateRoleBindingNotFound", + Input: input{ + rb: notfoundRB, + subj: []types.RoleBindingSubject{{SubjectResource: actor}}, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.ErrorContains(t, res.Err, ErrRoleBindingNotFound.Error()) + }, + }, + { + Name: "UpdateRoleBindingInvalidSubject", + Input: input{ + rb: rbRes, + subj: []types.RoleBindingSubject{{SubjectResource: invalidsubj}}, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.ErrorContains(t, res.Err, ErrInvalidArgument.Error()) + }, + }, + { + Name: "UpdateRoleBindingSuccess", + Input: input{ + rb: rbRes, + subj: []types.RoleBindingSubject{{SubjectResource: user1}, {SubjectResource: group1}}, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.NoError(t, res.Err) + + assert.Len(t, res.Success.Subjects, 2) + assert.Contains(t, res.Success.Subjects, types.RoleBindingSubject{SubjectResource: user1}) + assert.Contains(t, res.Success.Subjects, types.RoleBindingSubject{SubjectResource: group1}) + assert.NotContains(t, res.Success.Subjects, types.RoleBindingSubject{SubjectResource: actor}) + }, + }, + } + + testFn := func(ctx context.Context, in input) testingx.TestResult[types.RoleBinding] { + rb, err := e.UpdateRoleBinding(ctx, in.rb, in.subj) + return testingx.TestResult[types.RoleBinding]{Success: rb, Err: err} + } + + testingx.RunTests(ctx, t, tc, testFn) +} + +func TestDeleteRoleBinding(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace, rbacv2TestPolicy()) + + root, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + viewer, err := e.CreateRoleV2(ctx, actor, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + require.NoError(t, err) + viewerRes, err := e.NewResourceFromID(viewer.ID) + require.NoError(t, err) + + rb, err := e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + require.NoError(t, err) + rbRes, err := e.NewResourceFromID(rb.ID) + require.NoError(t, err) + + notfoundRB, err := e.NewResourceFromIDString("permrbn-notfound") + require.NoError(t, err) + + tc := []testingx.TestCase[types.Resource, types.RoleBinding]{ + { + Name: "DeleteRoleBindingNotFound", + Input: notfoundRB, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.NoError(t, res.Err) + + rb, err := e.ListRoleBindings(ctx, root, nil) + assert.NoError(t, err) + assert.Len(t, rb, 1) + }, + Sync: true, + }, + { + Name: "DeleteRoleBindingSuccess", + Input: rbRes, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.NoError(t, res.Err) + + rb, err := e.ListRoleBindings(ctx, root, nil) + assert.NoError(t, err) + assert.Len(t, rb, 0) + }, + Sync: true, + }, + } + + testFn := func(ctx context.Context, in types.Resource) testingx.TestResult[types.RoleBinding] { + err := e.DeleteRoleBinding(ctx, in, root) + return testingx.TestResult[types.RoleBinding]{Err: err} + } + + testingx.RunTests(ctx, t, tc, testFn) +} + +func TestPermissions(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace, rbacv2TestPolicy()) + + root, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + child, err := e.NewResourceFromIDString("tnntten-child") + require.NoError(t, err) + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + // create child tenant relationships + _, err = e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{ + Updates: rbacV2CreateParentRel(root, child, namespace), + }) + require.NoError(t, err) + + // role + viewer, err := e.CreateRoleV2(ctx, actor, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + require.NoError(t, err) + viewerRes, err := e.NewResourceFromID(viewer.ID) + require.NoError(t, err) + + // subjects + user1, err := e.NewResourceFromIDString("idntusr-user1") + require.NoError(t, err) + user2, err := e.NewResourceFromIDString("idntusr-user2") + require.NoError(t, err) + group1, err := e.NewResourceFromIDString("idntgrp-group1") + require.NoError(t, err) + + err = e.CreateRelationships(ctx, []types.Relationship{{ + Resource: group1, + Relation: "member", + Subject: user2, + }}) + require.NoError(t, err) + + _, err = e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{ + Updates: rbacV2CreateParentRel(root, group1, namespace), + }) + require.NoError(t, err) + + // resources + lb1, err := e.NewResourceFromIDString("loadbal-lb1") + require.NoError(t, err) + + err = e.CreateRelationships(ctx, []types.Relationship{{ + Resource: lb1, + Relation: "owner", + Subject: child, + }}) + require.NoError(t, err) + + fullconsistency := &pb.Consistency{Requirement: &pb.Consistency_FullyConsistent{FullyConsistent: true}} + + tc := []testingx.TestCase[any, any]{ + { + Name: "PermissionsOnResource", + SetupFn: func(ctx context.Context, t *testing.T) context.Context { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user1)}, + }) + require.Error(t, err) + + _, err = e.CreateRoleBinding(ctx, lb1, viewerRes, []types.RoleBindingSubject{{SubjectResource: user1}}) + require.NoError(t, err) + + return ctx + }, + CheckFn: func(ctx context.Context, t *testing.T, _ testingx.TestResult[any]) { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user1)}, + }) + assert.NoError(t, err) + }, + CleanupFn: func(ctx context.Context) { + rbs, _ := e.ListRoleBindings(ctx, lb1, nil) + for _, rb := range rbs { + rbRes, _ := e.NewResourceFromID(rb.ID) + _ = e.DeleteRoleBinding(ctx, rbRes, lb1) + } + }, + Sync: true, + }, + { + Name: "PermissionsOnOwner", + SetupFn: func(ctx context.Context, t *testing.T) context.Context { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user1)}, + }) + require.Error(t, err) + + _, err = e.CreateRoleBinding(ctx, child, viewerRes, []types.RoleBindingSubject{{SubjectResource: user1}}) + require.NoError(t, err) + + return ctx + }, + CheckFn: func(ctx context.Context, t *testing.T, _ testingx.TestResult[any]) { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user1)}, + }) + assert.NoError(t, err) + }, + CleanupFn: func(ctx context.Context) { + rbs, _ := e.ListRoleBindings(ctx, child, nil) + for _, rb := range rbs { + rbRes, _ := e.NewResourceFromID(rb.ID) + _ = e.DeleteRoleBinding(ctx, rbRes, child) + } + }, + Sync: true, + }, + { + Name: "PermissionsOnOwnerParent", + SetupFn: func(ctx context.Context, t *testing.T) context.Context { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user1)}, + }) + require.Error(t, err) + + _, err = e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: user1}}) + require.NoError(t, err) + + return ctx + }, + CheckFn: func(ctx context.Context, t *testing.T, _ testingx.TestResult[any]) { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user1)}, + }) + assert.NoError(t, err) + }, + CleanupFn: func(ctx context.Context) { + rbs, _ := e.ListRoleBindings(ctx, root, nil) + for _, rb := range rbs { + rbRes, _ := e.NewResourceFromID(rb.ID) + _ = e.DeleteRoleBinding(ctx, rbRes, root) + } + }, + Sync: true, + }, + { + Name: "PermissionsOnGroups", + SetupFn: func(ctx context.Context, t *testing.T) context.Context { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user2)}, + }) + require.Error(t, err) + + _, err = e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: group1}}) + require.NoError(t, err) + + return ctx + }, + CheckFn: func(ctx context.Context, t *testing.T, _ testingx.TestResult[any]) { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user2)}, + }) + assert.NoError(t, err) + }, + // No cleanup + Sync: true, + }, + { + Name: "GroupMembershipRemoval", + SetupFn: func(ctx context.Context, t *testing.T) context.Context { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user2)}, + }) + require.NoError(t, err) + + err = e.DeleteRelationships(ctx, types.Relationship{ + Resource: group1, + Relation: "member", + Subject: user2, + }) + require.NoError(t, err) + + return ctx + }, + CheckFn: func(ctx context.Context, t *testing.T, _ testingx.TestResult[any]) { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user2)}, + }) + assert.Error(t, err) + }, + CleanupFn: func(ctx context.Context) { + _ = e.CreateRelationships(ctx, []types.Relationship{{ + Resource: group1, + Relation: "member", + Subject: user2, + }}) + }, + Sync: true, + }, + { + Name: "RoleActionRemoval", + SetupFn: func(ctx context.Context, t *testing.T) context.Context { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user2)}, + }) + require.NoError(t, err) + + _, err = e.UpdateRoleV2(ctx, root, viewerRes, "lb_viewer", []string{"loadbalancer_list"}) + require.NoError(t, err) + + return ctx + }, + CheckFn: func(ctx context.Context, t *testing.T, _ testingx.TestResult[any]) { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user2)}, + }) + assert.Error(t, err) + }, + CleanupFn: func(ctx context.Context) { + _, _ = e.UpdateRoleV2(ctx, root, viewerRes, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + }, + Sync: true, + }, + { + Name: "RoleRemoval", + SetupFn: func(ctx context.Context, t *testing.T) context.Context { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user2)}, + }) + require.NoError(t, err) + + err = e.DeleteRoleV2(ctx, viewerRes) + require.NoError(t, err) + + return ctx + }, + CheckFn: func(ctx context.Context, t *testing.T, _ testingx.TestResult[any]) { + err := e.checkPermission(ctx, &pb.CheckPermissionRequest{ + Consistency: fullconsistency, + Resource: resourceToSpiceDBRef(namespace, lb1), + Permission: "loadbalancer_get", + Subject: &pb.SubjectReference{Object: resourceToSpiceDBRef(namespace, user2)}, + }) + assert.Error(t, err) + }, + Sync: true, + }, + } + + testFn := func(ctx context.Context, in any) testingx.TestResult[any] { + return testingx.TestResult[any]{} + } + + testingx.RunTests(ctx, t, tc, testFn) +} diff --git a/internal/query/roles.go b/internal/query/roles.go index 5cddb45c2..c50323846 100644 --- a/internal/query/roles.go +++ b/internal/query/roles.go @@ -20,3 +20,11 @@ func newRole(name string, actions []string) types.Role { Actions: actions, } } + +func newRoleWithPrefix(prefix string, name string, actions []string) types.Role { + return types.Role{ + ID: gidx.MustNewID(prefix), + Name: name, + Actions: actions, + } +} diff --git a/internal/query/roles_v2.go b/internal/query/roles_v2.go new file mode 100644 index 000000000..289bf6929 --- /dev/null +++ b/internal/query/roles_v2.go @@ -0,0 +1,686 @@ +package query + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + + pb "github.com/authzed/authzed-go/proto/authzed/api/v1" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "go.infratographer.com/permissions-api/internal/iapl" + "go.infratographer.com/permissions-api/internal/storage" + "go.infratographer.com/permissions-api/internal/types" +) + +// V2 Role and Role Bindings + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + + return false +} + +func (e *engine) namespaced(name string) string { + return e.namespace + "/" + name +} + +func (e *engine) CreateRoleV2(ctx context.Context, actor, owner types.Resource, roleName string, actions []string) (types.Role, error) { + ctx, span := e.tracer.Start(ctx, "engine.CreateRoleV2") + + defer span.End() + + roleName = strings.TrimSpace(roleName) + + role := newRoleWithPrefix(e.schemaTypeMap[e.rbac.RoleResource].IDPrefix, roleName, actions) + roleRels := e.roleV2Relationships(role) + roleRels = append(roleRels, e.roleV2OwnerRelationship(role, owner)...) + + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + return types.Role{}, nil + } + + dbRole, err := e.store.CreateRole(dbCtx, actor.ID, role.ID, roleName, owner.ID) + if err != nil { + return types.Role{}, err + } + + request := &pb.WriteRelationshipsRequest{Updates: roleRels} + + if _, err := e.client.WriteRelationships(ctx, request); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.Role{}, err + } + + if err = e.store.CommitContext(dbCtx); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + // No rollback of spicedb relations are done here. + // This does result in dangling unused entries in spicedb, + // however there are no assignments to these newly created + // and now discarded roles and so they won't be used. + + return types.Role{}, err + } + + role.CreatedBy = dbRole.CreatedBy + role.UpdatedBy = dbRole.UpdatedBy + role.ResourceID = dbRole.ResourceID + role.CreatedAt = dbRole.CreatedAt + role.UpdatedAt = dbRole.UpdatedAt + + return role, nil +} + +func (e *engine) ListRolesV2(ctx context.Context, owner types.Resource) ([]types.Role, error) { + ctx, span := e.tracer.Start( + ctx, + "engine.ListRolesV2", + trace.WithAttributes( + attribute.Stringer( + "owner", + owner.ID, + ), + ), + ) + defer span.End() + + if !contains(e.rbac.RoleOwners, owner.Type) { + err := fmt.Errorf("%w: %s is not a valid role owner", ErrInvalidType, owner.Type) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return nil, err + } + + lookupClient, err := e.client.LookupSubjects(ctx, &pb.LookupSubjectsRequest{ + Consistency: &pb.Consistency{ + Requirement: &pb.Consistency_FullyConsistent{ + FullyConsistent: true, + }, + }, + Resource: resourceToSpiceDBRef(e.namespace, owner), + Permission: iapl.AvailableRoleRelation, + SubjectObjectType: e.namespaced(e.rbac.RoleResource), + }) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return nil, err + } + + roleIDs := []string{} + + for { + lookup, err := lookupClient.Recv() + if err != nil { + if !errors.Is(err, io.EOF) { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + break + } + + subj := lookup.GetSubject() + roleIDs = append(roleIDs, subj.SubjectObjectId) + } + + roles := make([]types.Role, len(roleIDs)) + errsChan := make(chan error, len(roleIDs)) + wg := &sync.WaitGroup{} + + for i, id := range roleIDs { + wg.Add(1) + + roleRes, err := e.NewResourceFromIDString(id) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + e.logger.Error(err.Error()) + + wg.Done() + + continue + } + + go func(ctx context.Context, res types.Resource, i int) { + defer wg.Done() + + role, err := e.GetRoleV2(ctx, res) + if err != nil { + errsChan <- err + return + } + + roles[i] = role + }(ctx, roleRes, i) + } + + wg.Wait() + close(errsChan) + + for err := range errsChan { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + } + + return roles, nil +} + +func (e *engine) GetRoleV2(ctx context.Context, role types.Resource) (types.Role, error) { + const ReadRolesErrBufLen = 2 + + var ( + actions []string + dbrole storage.Role + err error + errs = make(chan error, ReadRolesErrBufLen) + wg = &sync.WaitGroup{} + ) + + ctx, span := e.tracer.Start( + ctx, + "engine.GetRoleV2", + trace.WithAttributes(attribute.Stringer("role", role.ID)), + ) + defer span.End() + + // check if the role is a valid v2 role + if role.Type != e.rbac.RoleResource { + err := fmt.Errorf("%w: %s is not a valid v2 Role", ErrInvalidType, role.Type) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.Role{}, err + } + + // 1. Get role actions from spice DB + wg.Add(1) + + go func() { + defer wg.Done() + + spicedbctx, span := e.tracer.Start(ctx, "listRoleV2Actions") + defer span.End() + + actions, err = e.listRoleV2Actions(spicedbctx, types.Role{ID: role.ID}) + if err != nil { + errs <- err + return + } + }() + + // 2. Get role from permissions API DB + wg.Add(1) + + go func() { + defer wg.Done() + + apidbctx, span := e.tracer.Start(ctx, "getRoleFromPermissionAPI") + defer span.End() + + dbrole, err = e.store.GetRoleByID(apidbctx, role.ID) + if err != nil { + errs <- err + return + } + }() + + wg.Wait() + close(errs) + + for err := range errs { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.Role{}, err + } + } + + resp := types.Role{ + ID: dbrole.ID, + Name: dbrole.Name, + Actions: actions, + + ResourceID: dbrole.ResourceID, + CreatedBy: dbrole.CreatedBy, + UpdatedBy: dbrole.UpdatedBy, + CreatedAt: dbrole.CreatedAt, + UpdatedAt: dbrole.UpdatedAt, + } + + return resp, nil +} + +func (e *engine) UpdateRoleV2(ctx context.Context, actor, roleResource types.Resource, newName string, newActions []string) (types.Role, error) { + ctx, span := e.tracer.Start(ctx, "engine.UpdateRoleV2") + defer span.End() + + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + return types.Role{}, err + } + + err = e.store.LockRoleForUpdate(dbCtx, roleResource.ID) + if err != nil { + sErr := fmt.Errorf("failed to lock role: %s: %w", roleResource.ID, err) + + span.RecordError(sErr) + span.SetStatus(codes.Error, sErr.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.Role{}, err + } + + role, err := e.GetRoleV2(dbCtx, roleResource) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.Role{}, err + } + + newName = strings.TrimSpace(newName) + + if newName == "" { + newName = role.Name + } + + addActions, rmActions := diff(role.Actions, newActions) + + // If no changes, return existing role with no changes. + if newName == role.Name && len(addActions) == 0 && len(rmActions) == 0 { + if err = e.store.CommitContext(dbCtx); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return role, nil + } + + // 1. update role in permission-api DB + dbRole, err := e.store.UpdateRole(dbCtx, actor.ID, role.ID, newName) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.Role{}, err + } + + // 2. update permissions relationships in spice db + updates := []*pb.RelationshipUpdate{} + roleRef := resourceToSpiceDBRef(e.namespace, roleResource) + + // 2.a remove old actions + for _, action := range rmActions { + updates = append( + updates, + e.createRoleV2RelationshipUpdatesForAction( + action, roleRef, + pb.RelationshipUpdate_OPERATION_DELETE, + )..., + ) + } + + // 2.b add new actions + for _, action := range addActions { + updates = append( + updates, + e.createRoleV2RelationshipUpdatesForAction( + action, roleRef, + pb.RelationshipUpdate_OPERATION_TOUCH, + )..., + ) + } + + // 2.c write updates to spicedb + request := &pb.WriteRelationshipsRequest{Updates: updates} + + if _, err := e.client.WriteRelationships(ctx, request); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.Role{}, err + } + + if err = e.store.CommitContext(dbCtx); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + // At this point, spicedb changes have already been applied. + // Attempting to rollback could result in failures that could result in the same situation. + // + // TODO: add spicedb rollback logic along with rollback failure scenarios. + + return types.Role{}, err + } + + role.Name = dbRole.Name + role.CreatedBy = dbRole.CreatedBy + role.UpdatedBy = dbRole.UpdatedBy + role.ResourceID = dbRole.ResourceID + role.CreatedAt = dbRole.CreatedAt + role.UpdatedAt = dbRole.UpdatedAt + role.Actions = newActions + + return role, nil +} + +func (e *engine) DeleteRoleV2(ctx context.Context, roleResource types.Resource) error { + ctx, span := e.tracer.Start(ctx, "engine.DeleteRoleV2") + defer span.End() + + dbRole, err := e.store.GetRoleByID(ctx, roleResource.ID) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + + roleOwner, err := e.NewResourceFromID(dbRole.ResourceID) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + + err = e.store.LockRoleForUpdate(dbCtx, roleResource.ID) + if err != nil { + sErr := fmt.Errorf("failed to lock role: %s: %w", roleResource.ID, err) + + span.RecordError(sErr) + span.SetStatus(codes.Error, sErr.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err + } + + // 1. delete role from permission-api DB + if _, err = e.store.DeleteRole(dbCtx, roleResource.ID); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err + } + + // 2. delete role relationships from spice db + const deleteErrsBufferSize = 2 + + wg := &sync.WaitGroup{} + errs := make(chan error, deleteErrsBufferSize) + + // 2.a remove all relationships from this role + wg.Add(1) + + go func() { + defer wg.Done() + + delRoleRelationshipReq := &pb.DeleteRelationshipsRequest{ + RelationshipFilter: &pb.RelationshipFilter{ + ResourceType: e.namespaced(e.rbac.RoleResource), + OptionalResourceId: roleResource.ID.String(), + }, + } + + if _, err := e.client.DeleteRelationships(ctx, delRoleRelationshipReq); err != nil { + errs <- err + } + }() + + // 2.b remove all relationships to this role from its owner + wg.Add(1) + + go func() { + defer wg.Done() + + ownerRelReq := &pb.DeleteRelationshipsRequest{ + RelationshipFilter: &pb.RelationshipFilter{ + ResourceType: e.namespaced(roleOwner.Type), + OptionalSubjectFilter: &pb.SubjectFilter{ + SubjectType: e.namespaced(e.rbac.RoleResource), + OptionalSubjectId: roleResource.ID.String(), + }, + }, + } + + if _, err := e.client.DeleteRelationships(ctx, ownerRelReq); err != nil { + errs <- err + } + }() + + // 2.c remove all role relationships in role bindings associated with this role + wg.Add(1) + + go func() { + defer wg.Done() + + if err := e.deleteRoleBindingForRole(ctx, roleResource); err != nil { + errs <- err + } + }() + + wg.Wait() + close(errs) + + for err := range errs { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err + } + } + + if err = e.store.CommitContext(dbCtx); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + // At this point, spicedb changes have already been applied. + // Attempting to rollback could result in failures that could result in the same situation. + // + // TODO: add spicedb rollback logic along with rollback failure scenarios. + + return err + } + + return nil +} + +// roleV2OwnerRelationship creates a relationships between a V2 role and its owner. +func (e *engine) roleV2OwnerRelationship(role types.Role, owner types.Resource) []*pb.RelationshipUpdate { + roleResource, err := e.NewResourceFromID(role.ID) + if err != nil { + panic(err) + } + + roleResourceType := e.GetResourceType(e.rbac.RoleResource) + if roleResourceType == nil { + return nil + } + + roleRef := resourceToSpiceDBRef(e.namespace, roleResource) + ownerRef := resourceToSpiceDBRef(e.namespace, owner) + + // e.g., rolev:super-admin#owner@tenant:tnntten-root + ownerRel := &pb.RelationshipUpdate{ + Operation: pb.RelationshipUpdate_OPERATION_TOUCH, + Relationship: &pb.Relationship{ + Resource: roleRef, + Relation: iapl.RoleOwnerRelation, + Subject: &pb.SubjectReference{ + Object: ownerRef, + }, + }, + } + + // e.g., tenant:tnntten-root#member_role@rolev:super-admin + memberRel := &pb.RelationshipUpdate{ + Operation: pb.RelationshipUpdate_OPERATION_TOUCH, + Relationship: &pb.Relationship{ + Resource: ownerRef, + Relation: iapl.RoleOwnerMemberRoleRelation, + Subject: &pb.SubjectReference{ + Object: roleRef, + }, + }, + } + + return []*pb.RelationshipUpdate{ownerRel, memberRel} +} + +// createRoleV2RelationshipUpdatesForAction creates permission relationship lines in role +// i.e., role:#_rel@/:* +func (e *engine) createRoleV2RelationshipUpdatesForAction( + action string, + roleRef *pb.ObjectReference, + op pb.RelationshipUpdate_Operation, +) []*pb.RelationshipUpdate { + rels := make([]*pb.RelationshipUpdate, len(e.rbac.RoleSubjectTypes)) + + for i, subjType := range e.rbac.RoleSubjectTypes { + rels[i] = &pb.RelationshipUpdate{ + Operation: op, + Relationship: &pb.Relationship{ + Resource: roleRef, + Relation: actionToRelation(action), + Subject: &pb.SubjectReference{ + Object: &pb.ObjectReference{ + ObjectType: e.namespaced(subjType), + ObjectId: "*", + }, + }, + }, + } + } + + return rels +} + +// roleV2Relationships creates relationships between a V2 role and its permissions. +func (e *engine) roleV2Relationships(role types.Role) []*pb.RelationshipUpdate { + var rels []*pb.RelationshipUpdate + + roleResource, err := e.NewResourceFromID(role.ID) + if err != nil { + panic(err) + } + + roleResourceType := e.GetResourceType(e.rbac.RoleResource) + if roleResourceType == nil { + return rels + } + + roleRef := resourceToSpiceDBRef(e.namespace, roleResource) + + for _, action := range role.Actions { + rels = append( + rels, + e.createRoleV2RelationshipUpdatesForAction( + action, roleRef, + pb.RelationshipUpdate_OPERATION_TOUCH, + )..., + ) + } + + return rels +} + +func (e *engine) listRoleV2Actions(ctx context.Context, role types.Role) ([]string, error) { + if len(e.rbac.RoleSubjectTypes) == 0 { + return nil, nil + } + + // there could be multiple subjects for a permission, + // e.g. + // infratographer/rolev2:lb_viewer#loadbalancer_get_rel@infratographer/user:* + // infratographer/rolev2:lb_viewer#loadbalancer_get_rel@infratographer/client:* + // here we only need one of them + permRelationshipSubjType := e.namespaced(e.rbac.RoleSubjectTypes[0]) + + rid := role.ID.String() + filter := &pb.RelationshipFilter{ + ResourceType: e.namespaced(e.rbac.RoleResource), + OptionalResourceId: rid, + OptionalSubjectFilter: &pb.SubjectFilter{ + SubjectType: permRelationshipSubjType, + OptionalSubjectId: "*", + }, + } + + relationships, err := e.readRelationships(ctx, filter) + if err != nil { + return nil, err + } + + actions := make([]string, len(relationships)) + + for i, rel := range relationships { + actions[i] = relationToAction(rel.Relation) + } + + return actions, nil +} + +// AllActions list all available actions for a role +func (e *engine) AllActions() []string { + rbv2, ok := e.schemaTypeMap[e.rbac.RoleBindingResource] + if !ok { + return nil + } + + actions := make([]string, len(rbv2.Actions)) + + for i, action := range rbv2.Actions { + actions[i] = action.Name + } + + return actions +} diff --git a/internal/query/roles_v2_test.go b/internal/query/roles_v2_test.go new file mode 100644 index 000000000..4a273b9cf --- /dev/null +++ b/internal/query/roles_v2_test.go @@ -0,0 +1,497 @@ +package query + +import ( + "context" + "testing" + + pb "github.com/authzed/authzed-go/proto/authzed/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.infratographer.com/permissions-api/internal/iapl" + "go.infratographer.com/permissions-api/internal/testingx" + "go.infratographer.com/permissions-api/internal/types" +) + +func rbacv2TestPolicy() iapl.Policy { + p := iapl.DefaultPolicyV2() + + if err := p.Validate(); err != nil { + panic(err) + } + + return p +} + +func rbacV2CreateParentRel(parent, child types.Resource, namespace string) []*pb.RelationshipUpdate { + return []*pb.RelationshipUpdate{ + { + Operation: pb.RelationshipUpdate_OPERATION_TOUCH, + Relationship: &pb.Relationship{ + Resource: resourceToSpiceDBRef(namespace, child), + Relation: "parent", + Subject: &pb.SubjectReference{ + Object: resourceToSpiceDBRef(namespace, parent), + }, + }, + }, + { + Operation: pb.RelationshipUpdate_OPERATION_TOUCH, + Relationship: &pb.Relationship{ + Resource: resourceToSpiceDBRef(namespace, child), + Relation: "parent", + Subject: &pb.SubjectReference{ + Object: resourceToSpiceDBRef(namespace, parent), + OptionalRelation: "parent", + }, + }, + }, + { + Operation: pb.RelationshipUpdate_OPERATION_TOUCH, + Relationship: &pb.Relationship{ + Resource: resourceToSpiceDBRef(namespace, parent), + Relation: "member", + Subject: &pb.SubjectReference{ + Object: resourceToSpiceDBRef(namespace, child), + OptionalRelation: "member", + }, + }, + }, + } +} + +func TestCreateRolesV2(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace, rbacv2TestPolicy()) + + tenant, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + // group is not a role owner in this policy + invalidOwner, err := e.NewResourceFromIDString("idntgrp-group") + require.NoError(t, err) + + type input struct { + name string + actions []string + owner types.Resource + } + + tc := []testingx.TestCase[input, []types.Role]{ + { + Name: "InvalidActions", + Input: input{ + name: "role1", + actions: []string{"action1", "action2"}, + owner: tenant, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.Role]) { + require.Error(t, res.Err) + assert.Len(t, res.Success, 0) + }, + }, + { + Name: "InvalidOwner", + Input: input{ + name: "lb_viewer", + owner: invalidOwner, + actions: []string{ + "loadbalancer_list", + "loadbalancer_get", + }, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.Role]) { + require.Error(t, res.Err) + assert.ErrorContains(t, res.Err, "not allowed on relation") + assert.Len(t, res.Success, 0) + }, + }, + { + Name: "CreateSuccess", + Input: input{ + name: "lb_viewer", + owner: tenant, + actions: []string{ + "loadbalancer_list", + "loadbalancer_get", + }, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.Role]) { + require.NoError(t, res.Err) + require.Len(t, res.Success, 1) + + role := res.Success[0] + require.Equal(t, "lb_viewer", role.Name) + require.Len(t, role.Actions, 2) + }, + }, + } + + testFn := func(ctx context.Context, in input) testingx.TestResult[[]types.Role] { + if _, err := e.CreateRoleV2(ctx, actor, in.owner, in.name, in.actions); err != nil { + return testingx.TestResult[[]types.Role]{Err: err} + } + + roles, err := e.ListRolesV2(ctx, tenant) + + return testingx.TestResult[[]types.Role]{Success: roles, Err: err} + } + + testingx.RunTests(ctx, t, tc, testFn) +} + +func TestGetRoleV2(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace, rbacv2TestPolicy()) + + tenant, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + role, err := e.CreateRoleV2(ctx, actor, tenant, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + require.NoError(t, err) + + roleRes, err := e.NewResourceFromID(role.ID) + require.NoError(t, err) + + missingRes, err := e.NewResourceFromIDString("permrv2-notfound") + require.NoError(t, err) + + invalidInput, err := e.NewResourceFromIDString("idntgrp-group") + require.NoError(t, err) + + tc := []testingx.TestCase[types.Resource, types.Role]{ + { + Name: "GetRoleNotFound", + Input: missingRes, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + assert.ErrorContains(t, res.Err, ErrRoleNotFound.Error()) + }, + }, + { + Name: "GetRoleInvalidInput", + Input: invalidInput, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + assert.ErrorContains(t, res.Err, ErrInvalidType.Error()) + }, + }, + { + Name: "GetRoleSuccess", + Input: roleRes, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + require.NoError(t, res.Err) + + resp := res.Success + + require.Equal(t, role.Name, resp.Name) + require.Len(t, resp.Actions, len(role.Actions)) + }, + }, + } + + testFn := func(ctx context.Context, in types.Resource) testingx.TestResult[types.Role] { + role, err := e.GetRoleV2(ctx, in) + if err != nil { + return testingx.TestResult[types.Role]{Err: err} + } + + return testingx.TestResult[types.Role]{Success: role, Err: err} + } + + testingx.RunTests(ctx, t, tc, testFn) +} + +func TestListRolesV2(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace, rbacv2TestPolicy()) + + root, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + child, err := e.NewResourceFromIDString("tnntten-child") + require.NoError(t, err) + orphan, err := e.NewResourceFromIDString("tnntten-orphan") + require.NoError(t, err) + + _, err = e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{ + Updates: rbacV2CreateParentRel(root, child, namespace), + }) + require.NoError(t, err) + + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + _, err = e.CreateRoleV2(ctx, actor, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + require.NoError(t, err) + + _, err = e.CreateRoleV2(ctx, actor, root, "lb_editor", []string{"loadbalancer_list", "loadbalancer_get", "loadbalancer_update"}) + require.NoError(t, err) + + _, err = e.CreateRoleV2(ctx, actor, child, "custom_role", []string{"loadbalancer_list"}) + require.NoError(t, err) + + invalidOwner, err := e.NewResourceFromIDString("idntgrp-group") + require.NoError(t, err) + + tc := []testingx.TestCase[types.Resource, []types.Role]{ + { + Name: "InvalidOwner", + Input: invalidOwner, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.Role]) { + assert.ErrorContains(t, res.Err, ErrInvalidType.Error()) + }, + }, + { + Name: "ListParentRoles", + Input: root, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.Role]) { + assert.NoError(t, res.Err) + assert.Len(t, res.Success, 2) + }, + }, + { + Name: "ListInheritedRoles", + Input: child, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.Role]) { + assert.NoError(t, res.Err) + assert.Len(t, res.Success, 3) + }, + }, + { + Name: "ListNoRoles", + Input: orphan, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.Role]) { + require.NoError(t, res.Err) + assert.Len(t, res.Success, 0) + }, + }, + } + + testFn := func(ctx context.Context, in types.Resource) testingx.TestResult[[]types.Role] { + roles, err := e.ListRolesV2(ctx, in) + if err != nil { + return testingx.TestResult[[]types.Role]{Err: err} + } + + return testingx.TestResult[[]types.Role]{Success: roles, Err: err} + } + + testingx.RunTests(ctx, t, tc, testFn) +} + +func TestUpdateRolesV2(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace, rbacv2TestPolicy()) + + tenant, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + role, err := e.CreateRoleV2(ctx, actor, tenant, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + require.NoError(t, err) + + roleRes, err := e.NewResourceFromID(role.ID) + require.NoError(t, err) + + notfoundRes, err := e.NewResourceFromIDString("permrv2-notfound") + require.NoError(t, err) + + type input struct { + name string + actions []string + role types.Resource + } + + tc := []testingx.TestCase[input, types.Role]{ + { + Name: "UpdateRoleNotFound", + Input: input{ + name: "lb_viewer", + actions: []string{"loadbalancer_list", "loadbalancer_get"}, + role: notfoundRes, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + assert.ErrorContains(t, res.Err, ErrRoleNotFound.Error()) + }, + Sync: true, + }, + { + Name: "UpdateRoleInvalidInput", + Input: input{ + name: "lb_viewer", + actions: []string{"loadbalancer_list", "loadbalancer_get"}, + role: actor, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + require.Error(t, res.Err) + }, + Sync: true, + }, + { + Name: "UpdateRoleActionNotFound", + Input: input{ + name: "lb_viewer", + actions: []string{"notfound"}, + role: roleRes, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + assert.ErrorContains(t, res.Err, "not found") + }, + Sync: true, + }, + { + Name: "UpdateNoChange", + Input: input{ + actions: []string{"loadbalancer_list", "loadbalancer_get"}, + role: roleRes, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + assert.NoError(t, res.Err) + assert.Equal(t, role.Name, res.Success.Name) + assert.Len(t, res.Success.Actions, len(role.Actions)) + }, + Sync: true, + }, + { + Name: "UpdateSuccess", + Input: input{ + name: "new_name", + actions: []string{"loadbalancer_get", "loadbalancer_update"}, + role: roleRes, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + require.NoError(t, res.Err) + + assert.Equal(t, "new_name", res.Success.Name) + assert.Len(t, res.Success.Actions, 2) + assert.Contains(t, res.Success.Actions, "loadbalancer_update") + assert.Contains(t, res.Success.Actions, "loadbalancer_get") + }, + Sync: true, + }, + } + + testFn := func(ctx context.Context, in input) testingx.TestResult[types.Role] { + if _, err := e.UpdateRoleV2(ctx, actor, in.role, in.name, in.actions); err != nil { + return testingx.TestResult[types.Role]{Err: err} + } + + role, err := e.GetRoleV2(ctx, in.role) + + return testingx.TestResult[types.Role]{Success: role, Err: err} + } + + testingx.RunTests(ctx, t, tc, testFn) +} + +func TestDeleteRolesV2(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace, rbacv2TestPolicy()) + + root, err := e.NewResourceFromIDString("tnntten-root") + require.NoError(t, err) + child, err := e.NewResourceFromIDString("tnntten-child") + require.NoError(t, err) + theotherchild, err := e.NewResourceFromIDString("tnntten-theotherchild") + require.NoError(t, err) + actor, err := e.NewResourceFromIDString("idntusr-actor") + require.NoError(t, err) + + role, err := e.CreateRoleV2(ctx, actor, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) + require.NoError(t, err) + + roleRes, err := e.NewResourceFromID(role.ID) + require.NoError(t, err) + + notfoundRes, err := e.NewResourceFromIDString("permrv2-notfound") + require.NoError(t, err) + + _, err = e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{ + Updates: rbacV2CreateParentRel(root, child, namespace), + }) + require.NoError(t, err) + + _, err = e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{ + Updates: rbacV2CreateParentRel(root, theotherchild, namespace), + }) + require.NoError(t, err) + + // these bindings are expected to be deleted after the role is deleted + _, err = e.CreateRoleBinding(ctx, root, roleRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + require.NoError(t, err) + + _, err = e.CreateRoleBinding(ctx, child, roleRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + require.NoError(t, err) + + _, err = e.CreateRoleBinding(ctx, theotherchild, roleRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + require.NoError(t, err) + + rb, err := e.ListRoleBindings(ctx, root, &roleRes) + require.NoError(t, err) + require.Len(t, rb, 1) + + rb, err = e.ListRoleBindings(ctx, child, &roleRes) + require.NoError(t, err) + require.Len(t, rb, 1) + + rb, err = e.ListRoleBindings(ctx, theotherchild, &roleRes) + require.NoError(t, err) + require.Len(t, rb, 1) + + tc := []testingx.TestCase[types.Resource, types.Role]{ + { + Name: "DeleteRoleNotFound", + Input: notfoundRes, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + assert.ErrorContains(t, res.Err, ErrRoleNotFound.Error()) + }, + Sync: true, + }, + { + Name: "DeleteRoleInvalidInput", + Input: actor, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + assert.Error(t, res.Err) + }, + }, + { + Name: "DeleteRoleSuccess", + Input: roleRes, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + assert.NoError(t, res.Err) + + _, err := e.GetRoleV2(ctx, roleRes) + assert.ErrorContains(t, err, ErrRoleNotFound.Error()) + + // make sure the role bindings are also deleted + rb, err := e.ListRoleBindings(ctx, root, &roleRes) + assert.NoError(t, err) + assert.Len(t, rb, 0) + + rb, err = e.ListRoleBindings(ctx, child, &roleRes) + assert.NoError(t, err) + assert.Len(t, rb, 0) + + rb, err = e.ListRoleBindings(ctx, theotherchild, &roleRes) + assert.NoError(t, err) + assert.Len(t, rb, 0) + }, + Sync: true, + }, + } + + testFn := func(ctx context.Context, in types.Resource) testingx.TestResult[types.Role] { + err := e.DeleteRoleV2(ctx, in) + return testingx.TestResult[types.Role]{Err: err} + } + + testingx.RunTests(ctx, t, tc, testFn) +} diff --git a/internal/query/service.go b/internal/query/service.go index 64a3b9293..43c44b5f0 100644 --- a/internal/query/service.go +++ b/internal/query/service.go @@ -18,6 +18,11 @@ import ( const ( outcomeAllowed = "allowed" outcomeDenied = "denied" + + // DefaultRoleResourceName is the default name for a role resource + DefaultRoleResourceName = "role" + // DefaultRoleBindingResourceName is the default name for a role binding resource + DefaultRoleBindingResourceName = "role_binding" ) // Engine represents a client for making permissions queries. @@ -39,6 +44,35 @@ type Engine interface { NewResourceFromID(id gidx.PrefixedID) (types.Resource, error) GetResourceType(name string) *types.ResourceType SubjectHasPermission(ctx context.Context, subject types.Resource, action string, resource types.Resource) error + + // v2 functions, add role bindings support + + // CreateRoleV2 creates a v2 role scoped to the given resource with the given actions. + CreateRoleV2(ctx context.Context, actor, owner types.Resource, roleName string, actions []string) (types.Role, error) + // ListRolesV2 returns all V2 roles owned by the given resource. + ListRolesV2(ctx context.Context, owner types.Resource) ([]types.Role, error) + // GetRoleV2 returns a V2 role + GetRoleV2(ctx context.Context, role types.Resource) (types.Role, error) + // UpdateRoleV2 updates a V2 role with the given name and actions. + UpdateRoleV2(ctx context.Context, actor, roleResource types.Resource, newName string, newActions []string) (types.Role, error) + // DeleteRoleV2 deletes a V2 role. + DeleteRoleV2(ctx context.Context, roleResource types.Resource) error + + // CreateRoleBinding creates all the necessary relationships for a role binding. + // role binding here establishes a three-way relationship between a role, + // a resource, and the subjects. + CreateRoleBinding(ctx context.Context, resource, role types.Resource, subjects []types.RoleBindingSubject) (types.RoleBinding, error) + // ListRoleBindings lists all role-bindings for a resource, an optional Role + // can be provided to filter the role-bindings. + ListRoleBindings(ctx context.Context, resource types.Resource, optionalRole *types.Resource) ([]types.RoleBinding, error) + // GetRoleBinding fetches a role-binding by its ID. + GetRoleBinding(ctx context.Context, rolebinding types.Resource) (types.RoleBinding, error) + // UpdateRoleBinding updates the subjects of a role-binding. + UpdateRoleBinding(ctx context.Context, rolebinding types.Resource, subjects []types.RoleBindingSubject) (types.RoleBinding, error) + // DeleteRoleBinding removes subjects from a role-binding. + DeleteRoleBinding(ctx context.Context, rolebinding, resource types.Resource) error + + AllActions() []string } type engine struct { @@ -54,7 +88,9 @@ type engine struct { schemaSubjectRelationMap map[string]map[string][]string schemaRoleables []types.ResourceType - rbac iapl.RBAC + rbac iapl.RBAC + rolebindingV2SubjectsMap map[string]types.TargetType + rolebindingV2Resources []types.ResourceType } func (e *engine) cacheSchemaResources() { @@ -62,6 +98,8 @@ func (e *engine) cacheSchemaResources() { e.schemaTypeMap = make(map[string]types.ResourceType, len(e.schema)) e.schemaSubjectRelationMap = make(map[string]map[string][]string) e.schemaRoleables = []types.ResourceType{} + e.rolebindingV2SubjectsMap = make(map[string]types.TargetType, len(e.rbac.RoleBindingSubjects)) + e.rolebindingV2Resources = []types.ResourceType{} for _, res := range e.schema { e.schemaPrefixMap[res.IDPrefix] = res @@ -80,6 +118,14 @@ func (e *engine) cacheSchemaResources() { if resourceHasRoleBindings(res) { e.schemaRoleables = append(e.schemaRoleables, res) } + + if rb := resourceHasRoleBindingV2(res); rb != nil { + e.rolebindingV2Resources = append(e.rolebindingV2Resources, res) + } + } + + for _, subj := range e.rbac.RoleBindingSubjects { + e.rolebindingV2SubjectsMap[subj.Name] = subj } } @@ -95,6 +141,18 @@ func resourceHasRoleBindings(resType types.ResourceType) bool { return false } +func resourceHasRoleBindingV2(resType types.ResourceType) *types.ConditionRoleBindingV2 { + for _, action := range resType.Actions { + for _, cond := range action.Conditions { + if cond.RoleBindingV2 != nil { + return cond.RoleBindingV2 + } + } + } + + return nil +} + // NewEngine returns a new client for making permissions queries. func NewEngine(namespace string, client *authzed.Client, kv nats.KeyValue, store storage.Storage, options ...Option) (Engine, error) { tracer := otel.GetTracerProvider().Tracer("go.infratographer.com/permissions-api/internal/query") diff --git a/internal/query/zedtokens_test.go b/internal/query/zedtokens_test.go index 3fd1f23cb..aa5c3dbb6 100644 --- a/internal/query/zedtokens_test.go +++ b/internal/query/zedtokens_test.go @@ -15,7 +15,7 @@ import ( func TestConsistency(t *testing.T) { namespace := "testconsistency" ctx := context.Background() - e := testEngine(ctx, t, namespace) + e := testEngine(ctx, t, namespace, testPolicy()) tenantID, err := gidx.NewID("tnntten") require.NoError(t, err) diff --git a/openapi-v2.yaml b/openapi-v2.yaml new file mode 100644 index 000000000..ac4d739e3 --- /dev/null +++ b/openapi-v2.yaml @@ -0,0 +1,456 @@ +openapi: 3.0.3 +info: + version: 0.0.1 + title: Permissions API V2 + contact: + name: Infratographer Authors + url: http://github.com/infratographer + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +servers: + - url: http://localhost:7603/api/v2 +paths: + /resources/{id}/roles: + get: + tags: + - roles + summary: list-roles + description: | + list all available roles for a resource, including roles that are + inherited from parent resources + operationId: listRoles + responses: + "200": + description: "" + post: + tags: + - roles + summary: create-role + description: | + create a role for a resource. The role will be available for use in + role-bindings for the resource + operationId: createRole + requestBody: + content: + application/json: + schema: + type: object + properties: + actions: + type: array + items: + type: string + example: loadbalancer_list + example: + - loadbalancer_list + - loadbalancer_get + - loadbalancer_update + - loadbalancer_create + name: + type: string + example: lb_editor + examples: + create-role: + value: + actions: + - loadbalancer_list + - loadbalancer_get + - loadbalancer_update + - loadbalancer_create + name: lb_editor + responses: + "201": + description: "role object" + content: + application/json: + schema: + type: object + properties: + actions: + type: array + items: + type: string + example: role_list + example: + - role_list + - rolebinding_create + - loadbalancer_list + - loadbalancer_update + - loadbalancer_delete + - role_create + - role_delete + - loadbalancer_create + - loadbalancer_get + - rolebinding_list + - rolebinding_delete + - role_get + - role_update + created_at: + type: string + example: "2024-02-28T17:22:04Z" + created_by: + type: string + example: idntusr-bailin + id: + type: string + example: permrv2-ecBlNMsPrvVFgUUAUfmeY + name: + type: string + example: super_admin + resource_id: + type: string + example: tnntten-root + updated_at: + type: string + example: "2024-02-28T17:22:04Z" + updated_by: + type: string + example: idntusr-bailin + examples: + create-role: + value: + actions: + - loadbalancer_list + - loadbalancer_get + - loadbalancer_update + - loadbalancer_create + created_at: "2024-02-29T18:18:18Z" + created_by: idntusr-bailin + id: permrv2-IG7RfsYhyga0EwEbY4BKs + name: lb_editor + resource_id: tnntten-a + updated_at: "2024-02-29T18:18:18Z" + updated_by: idntusr-bailin + lb-viwer: + value: + actions: + - role_list + - rolebinding_create + - loadbalancer_list + - loadbalancer_update + - loadbalancer_delete + - role_create + - role_delete + - loadbalancer_create + - loadbalancer_get + - rolebinding_list + - rolebinding_delete + - role_get + - role_update + created_at: "2024-02-28T17:22:04Z" + created_by: idntusr-bailin + id: permrv2-ecBlNMsPrvVFgUUAUfmeY + name: super_admin + resource_id: tnntten-root + updated_at: "2024-02-28T17:22:04Z" + updated_by: idntusr-bailin + super-admin: + value: + actions: + - role_list + - rolebinding_create + - loadbalancer_list + - loadbalancer_update + - loadbalancer_delete + - role_create + - role_delete + - loadbalancer_create + - loadbalancer_get + - rolebinding_list + - rolebinding_delete + - role_get + - role_update + created_at: "2024-02-28T17:22:04Z" + created_by: idntusr-bailin + id: permrv2-ecBlNMsPrvVFgUUAUfmeY + name: super_admin + resource_id: tnntten-root + updated_at: "2024-02-28T17:22:04Z" + updated_by: idntusr-bailin + parameters: + - name: id + in: path + required: true + schema: + type: string + example: tnntten-root + /roles/{role_id}: + get: + tags: + - roles + summary: get-role + description: get role by ID + operationId: getRole + responses: + "200": + description: "" + delete: + tags: + - roles + summary: delete-role + description: | + delete role by ID, this will also remove any role-bindings that use + this role + operationId: deleteRole + responses: + "200": + description: "" + patch: + tags: + - roles + summary: update-role + description: | + update role by ID, both name and actions can be modified + operationId: updateRole + requestBody: + content: + application/json: + schema: + type: object + properties: + actions: + type: array + items: + type: string + example: rolebinding_get + example: + - rolebinding_get + - loadbalancer_create + - loadbalancer_get + - loadbalancer_delete + - rolebinding_list + - role_get + - rolebinding_update + - rolebinding_delete + - avail_role + - role_delete + - role_list + - role_update + - loadbalancer_list + - loadbalancer_update + - rolebinding_create + - role_create + name: + type: string + example: super_user + examples: + update-role: + value: + actions: + - rolebinding_get + - loadbalancer_create + - loadbalancer_get + - loadbalancer_delete + - rolebinding_list + - role_get + - rolebinding_update + - rolebinding_delete + - avail_role + - role_delete + - role_list + - role_update + - loadbalancer_list + - loadbalancer_update + - rolebinding_create + - role_create + name: super_user + responses: + "200": + description: "" + parameters: + - name: role_id + in: path + required: true + schema: + type: string + example: permrv2-zNYOpdL1RGyiSp0hgaIYB + /resources/{id}/role-bindings: + get: + tags: + - role-bindings + summary: list-role-bindings + description: | + list role-bindings for a resource. + an optional query parameter `role_id` can be used to filter the results. + operationId: listRoleBindings + parameters: + - name: role_id + in: query + schema: + type: string + example: permrv2-FQbFZMF74D0-WLNO-8MMb + responses: + "200": + description: "" + post: + tags: + - role-bindings + summary: create-role-binding + description: | + create a role-binding for a resource. The role-binding will grant the + specified role to the specified subjects. + operationId: createRoleBinding + requestBody: + content: + application/json: + schema: + type: object + properties: + role_id: + type: string + example: permrv2-FQbFZMF74D0-WLNO-8MMb + subjects: + type: array + items: + type: object + properties: + conditions: + type: object + properties: {} + id: + type: string + example: idntusr-bailin + example: + - conditions: {} + id: idntusr-bailin + - conditions: {} + id: idntusr-bailin-1 + - conditions: {} + id: idntusr-bailin-2 + - conditions: {} + id: idntusr-bailin-3 + - conditions: {} + id: idntusr-bailin-4 + - conditions: {} + id: idntusr-bailin-5 + examples: + create-role-binding: + value: + role_id: permrv2-FQbFZMF74D0-WLNO-8MMb + subjects: + - conditions: {} + id: idntusr-bailin + - conditions: {} + id: idntusr-bailin-1 + - conditions: {} + id: idntusr-bailin-2 + - conditions: {} + id: idntusr-bailin-3 + - conditions: {} + id: idntusr-bailin-4 + - conditions: {} + id: idntusr-bailin-5 + responses: + "200": + description: "" + parameters: + - name: id + in: path + required: true + schema: + type: string + example: tnntten-root + /resources/{id}/role-bindings/{rb-id}: + get: + tags: + - role-bindings + summary: get-role-binding + description: get-role-binding + operationId: getRoleBinding + responses: + "200": + description: "" + delete: + tags: + - role-bindings + summary: delete-role-binding + description: delete-role-binding + operationId: deleteRoleBinding + responses: + "200": + description: "" + patch: + tags: + - role-bindings + summary: update-role-binding + description: | + update a role-binding, this will replace the subjects with the new + subjects. note that role_id is immutable + operationId: updateRoleBinding + parameters: + - name: id + in: query + schema: + type: string + example: loadbal-test-1 + requestBody: + content: + application/json: + schema: + type: object + properties: + subjects: + type: array + items: + type: object + properties: + conditions: + type: object + properties: {} + id: + type: string + example: idntusr-bailin + example: + - conditions: {} + id: idntusr-bailin + - conditions: {} + id: idntusr-bailin-1 + examples: + update-role-binding: + value: + subjects: + - conditions: {} + id: idntusr-bailin + - conditions: {} + id: idntusr-bailin-1 + responses: + "200": + description: "" + parameters: + - name: id + in: path + required: true + schema: + type: string + example: loadbal-lb1 + - name: rb-id + in: path + required: true + schema: + type: string + example: permrbn-uRNlQEo6yh9DinKhSxL2z + /actions: + get: + summary: list-actions + description: list all available actions in the permission system + operationId: listActions + responses: + "200": + description: "" +components: + securitySchemes: + oauth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: http://localhost:8081/default/token + scopes: + openid: openid + permissions-api: permissions-api +security: + - oauth2: + - 'openid permissions-api' +tags: + - name: roles + - name: role-bindings