From 91f09563611d66925f0f31c09b902cddddd08781 Mon Sep 17 00:00:00 2001 From: Bailin He Date: Thu, 25 Apr 2024 21:41:13 +0000 Subject: [PATCH] add rolebinding storage Signed-off-by: Bailin He --- cmd/createrole.go | 2 +- internal/api/rolebindings.go | 116 ++++--- internal/api/types.go | 12 +- internal/query/mock/mock.go | 11 +- internal/query/rolebindings.go | 279 +++++++++++++++- internal/query/rolebindings_test.go | 91 +++-- internal/query/roles_v2.go | 4 + internal/query/roles_v2_test.go | 12 +- internal/query/service.go | 9 +- internal/storage/errors.go | 3 + .../20240425000000_role_bindings.sql | 33 ++ internal/storage/rolebinding.go | 297 ++++++++++++++++ internal/storage/rolebinding_test.go | 316 ++++++++++++++++++ internal/storage/roles.go | 15 +- internal/storage/storage.go | 1 + internal/types/types.go | 12 +- 16 files changed, 1078 insertions(+), 135 deletions(-) create mode 100644 internal/storage/migrations/20240425000000_role_bindings.sql create mode 100644 internal/storage/rolebinding.go create mode 100644 internal/storage/rolebinding_test.go diff --git a/cmd/createrole.go b/cmd/createrole.go index f6a63a55..dff4dc6f 100644 --- a/cmd/createrole.go +++ b/cmd/createrole.go @@ -141,7 +141,7 @@ func createRole(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("error creating role resource", "error", err) } - rb, err := engine.CreateRoleBinding(ctx, resource, roleres, rbsubj) + rb, err := engine.CreateRoleBinding(ctx, subjectResource, resource, roleres, rbsubj) if err != nil { logger.Fatalw("error creating role binding", "error", err) } diff --git a/internal/api/rolebindings.go b/internal/api/rolebindings.go index 911990a3..db4566d2 100644 --- a/internal/api/rolebindings.go +++ b/internal/api/rolebindings.go @@ -3,6 +3,7 @@ package api import ( "fmt" "net/http" + "time" "github.com/labstack/echo/v4" "go.infratographer.com/x/gidx" @@ -52,13 +53,13 @@ func (r *Router) roleBindingCreate(c echo.Context) error { return r.errorResponse("error creating resource", err) } - subjectResource, err := r.currentSubject(c) + 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, subjectResource, string(iapl.RoleBindingActionCreate), resource); err != nil { + if err := r.checkActionWithResponse(ctx, actor, string(iapl.RoleBindingActionCreate), resource); err != nil { return err } @@ -86,7 +87,7 @@ func (r *Router) roleBindingCreate(c echo.Context) error { } } - rb, err := r.engine.CreateRoleBinding(ctx, resource, roleResource, subjects) + rb, err := r.engine.CreateRoleBinding(ctx, actor, resource, roleResource, subjects) if err != nil { return r.errorResponse("error creating role-binding", err) } @@ -94,12 +95,18 @@ func (r *Router) roleBindingCreate(c echo.Context) error { return c.JSON( http.StatusOK, roleBindingResponse{ - ID: rb.ID, - Subjects: resourceToSubject(rb.Subjects), + ID: rb.ID, + ResourceID: rb.ResourceID, + Subjects: resourceToSubject(rb.Subjects), Role: roleBindingResponseRole{ ID: rb.Role.ID, Name: rb.Role.Name, }, + + CreatedBy: rb.CreatedBy, + UpdatedBy: rb.UpdatedBy, + CreatedAt: rb.CreatedAt.Format(time.RFC3339), + UpdatedAt: rb.UpdatedAt.Format(time.RFC3339), }, ) } @@ -160,12 +167,18 @@ func (r *Router) roleBindingsList(c echo.Context) error { for i, rb := range rbs { resp.Data[i] = roleBindingResponse{ - ID: rb.ID, - Subjects: resourceToSubject(rb.Subjects), + ID: rb.ID, + ResourceID: rb.ResourceID, + Subjects: resourceToSubject(rb.Subjects), Role: roleBindingResponseRole{ ID: rb.Role.ID, Name: rb.Role.Name, }, + + CreatedBy: rb.CreatedBy, + UpdatedBy: rb.UpdatedBy, + CreatedAt: rb.CreatedAt.Format(time.RFC3339), + UpdatedAt: rb.UpdatedAt.Format(time.RFC3339), } } @@ -173,7 +186,6 @@ func (r *Router) roleBindingsList(c echo.Context) error { } func (r *Router) roleBindingsDelete(c echo.Context) error { - resID := c.Param("id") rbID := c.Param("rb_id") ctx, span := tracer.Start( @@ -182,17 +194,6 @@ func (r *Router) roleBindingsDelete(c echo.Context) error { ) defer span.End() - // resource - resourceID, err := gidx.Parse(resID) - if err != nil { - return r.errorResponse("error parsing resource ID", fmt.Errorf("%w: %s", ErrInvalidID, err.Error())) - } - - resource, err := r.engine.NewResourceFromID(resourceID) - if err != nil { - return r.errorResponse("error creating resource", err) - } - // role-binding rolebindingID, err := gidx.Parse(rbID) if err != nil { @@ -209,12 +210,18 @@ func (r *Router) roleBindingsDelete(c echo.Context) error { return err } + // resource + resource, err := r.engine.GetRoleBindingResource(ctx, rbRes) + if err != nil { + return r.errorResponse("error getting role-binding owner resource", 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 { + if err := r.engine.DeleteRoleBinding(ctx, rbRes); err != nil { return r.errorResponse("error updating role-binding", err) } @@ -224,7 +231,6 @@ func (r *Router) roleBindingsDelete(c echo.Context) error { } func (r *Router) roleBindingGet(c echo.Context) error { - resID := c.Param("id") rbID := c.Param("rb_id") ctx, span := tracer.Start( @@ -233,17 +239,6 @@ func (r *Router) roleBindingGet(c echo.Context) error { ) defer span.End() - // resource - resourceID, err := gidx.Parse(resID) - if err != nil { - return r.errorResponse("error parsing resource ID", fmt.Errorf("%w: %s", ErrInvalidID, err.Error())) - } - - resource, err := r.engine.NewResourceFromID(resourceID) - if err != nil { - return r.errorResponse("error creating resource", err) - } - // role-binding rolebindingID, err := gidx.Parse(rbID) if err != nil { @@ -260,50 +255,52 @@ func (r *Router) roleBindingGet(c echo.Context) error { 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) } + // permissions on role binding actions, similar to roles v1, are granted on the resources + // since the rolebinding is returning the resource ID that it belongs to, we + // will use this resource ID to check the permissions + resource, err := r.engine.NewResourceFromID(rb.ResourceID) + if err != nil { + return r.errorResponse("error creating resource", err) + } + + if err := r.checkActionWithResponse(ctx, actor, string(iapl.RoleBindingActionGet), resource); err != nil { + return err + } + return c.JSON( http.StatusOK, roleBindingResponse{ - ID: rb.ID, - Subjects: resourceToSubject(rb.Subjects), + ID: rb.ID, + ResourceID: rb.ResourceID, + Subjects: resourceToSubject(rb.Subjects), Role: roleBindingResponseRole{ ID: rb.Role.ID, Name: rb.Role.Name, }, + + CreatedBy: rb.CreatedBy, + UpdatedBy: rb.UpdatedBy, + CreatedAt: rb.CreatedAt.Format(time.RFC3339), + UpdatedAt: rb.UpdatedAt.Format(time.RFC3339), }, ) } 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 r.errorResponse("error parsing resource ID", fmt.Errorf("%w: %s", ErrInvalidID, err.Error())) - } - - resource, err := r.engine.NewResourceFromID(resourceID) - if err != nil { - return r.errorResponse("error creating resource", err) - } // role-binding rolebindingID, err := gidx.Parse(rbID) @@ -321,6 +318,12 @@ func (r *Router) roleBindingUpdate(c echo.Context) error { return err } + // resource + resource, err := r.engine.GetRoleBindingResource(ctx, rbRes) + if err != nil { + return r.errorResponse("error getting role-binding owner resource", 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 @@ -347,7 +350,7 @@ func (r *Router) roleBindingUpdate(c echo.Context) error { } } - rb, err := r.engine.UpdateRoleBinding(ctx, rbRes, subjects) + rb, err := r.engine.UpdateRoleBinding(ctx, actor, rbRes, subjects) if err != nil { return r.errorResponse("error updating role-binding", err) } @@ -355,12 +358,19 @@ func (r *Router) roleBindingUpdate(c echo.Context) error { return c.JSON( http.StatusOK, roleBindingResponse{ - ID: rb.ID, - Subjects: resourceToSubject(rb.Subjects), + ID: rb.ID, + ResourceID: rb.ResourceID, + Subjects: resourceToSubject(rb.Subjects), + Role: roleBindingResponseRole{ ID: rb.Role.ID, Name: rb.Role.Name, }, + + CreatedBy: rb.CreatedBy, + UpdatedBy: rb.UpdatedBy, + CreatedAt: rb.CreatedAt.Format(time.RFC3339), + UpdatedAt: rb.UpdatedAt.Format(time.RFC3339), }, ) } diff --git a/internal/api/types.go b/internal/api/types.go index 380ffa7a..33e75627 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -106,9 +106,15 @@ type rolebindingUpdateRequest struct { } type roleBindingResponse struct { - ID gidx.PrefixedID `json:"id"` - Role roleBindingResponseRole `json:"role"` - Subjects []roleBindingSubject `json:"subjects"` + ID gidx.PrefixedID `json:"id"` + ResourceID gidx.PrefixedID `json:"resource_id"` + Role roleBindingResponseRole `json:"role"` + Subjects []roleBindingSubject `json:"subjects"` + + CreatedBy gidx.PrefixedID `json:"created_by"` + UpdatedBy gidx.PrefixedID `json:"updated_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type listRoleBindingsResponse struct { diff --git a/internal/query/mock/mock.go b/internal/query/mock/mock.go index d64da981..71d42c40 100644 --- a/internal/query/mock/mock.go +++ b/internal/query/mock/mock.go @@ -204,7 +204,7 @@ func (e *Engine) SubjectHasPermission(context.Context, types.Resource, string, t } // CreateRoleBinding returns nothing but satisfies the Engine interface. -func (e *Engine) CreateRoleBinding(context.Context, types.Resource, types.Resource, []types.RoleBindingSubject) (types.RoleBinding, error) { +func (e *Engine) CreateRoleBinding(context.Context, types.Resource, types.Resource, types.Resource, []types.RoleBindingSubject) (types.RoleBinding, error) { return types.RoleBinding{}, nil } @@ -219,15 +219,20 @@ func (e *Engine) GetRoleBinding(context.Context, types.Resource) (types.RoleBind } // DeleteRoleBinding returns nothing but satisfies the Engine interface. -func (e *Engine) DeleteRoleBinding(context.Context, types.Resource, types.Resource) error { +func (e *Engine) DeleteRoleBinding(context.Context, 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) { +func (e *Engine) UpdateRoleBinding(context.Context, types.Resource, types.Resource, []types.RoleBindingSubject) (types.RoleBinding, error) { return types.RoleBinding{}, nil } +// GetRoleBindingResource returns nothing but satisfies the Engine interface. +func (e *Engine) GetRoleBindingResource(context.Context, types.Resource) (types.Resource, error) { + return types.Resource{}, nil +} + // AllActions returns nothing but satisfies the Engine interface. func (e *Engine) AllActions() []string { return nil diff --git a/internal/query/rolebindings.go b/internal/query/rolebindings.go index 39cf1108..a1c9f43e 100644 --- a/internal/query/rolebindings.go +++ b/internal/query/rolebindings.go @@ -12,6 +12,7 @@ import ( "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" ) @@ -22,6 +23,18 @@ func (e *engine) GetRoleBinding(ctx context.Context, roleBinding types.Resource) ) defer span.End() + rb, err := e.store.GetRoleBindingByID(ctx, roleBinding.ID) + if err != nil { + if errors.Is(err, storage.ErrRoleBindingNotFound) { + err = fmt.Errorf("%w: role-binding: %s", ErrRoleBindingNotFound, err) + } + + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + // gather all relationships from this role-binding rbRelFilter := &pb.RelationshipFilter{ ResourceType: e.namespaced(e.rbac.RoleBindingResource.Name), @@ -45,10 +58,7 @@ func (e *engine) GetRoleBinding(ctx context.Context, roleBinding types.Resource) return types.RoleBinding{}, err } - rb := types.RoleBinding{ - ID: roleBinding.ID, - Subjects: make([]types.RoleBindingSubject, 0, len(rbRel)), - } + rb.Subjects = make([]types.RoleBindingSubject, 0, len(rbRel)) for _, rel := range rbRel { // process subject relationships @@ -92,7 +102,11 @@ func (e *engine) GetRoleBinding(ctx context.Context, roleBinding types.Resource) return rb, nil } -func (e *engine) CreateRoleBinding(ctx context.Context, resource, roleResource types.Resource, subjects []types.RoleBindingSubject) (types.RoleBinding, error) { +func (e *engine) CreateRoleBinding( + ctx context.Context, + actor, resource, roleResource types.Resource, + subjects []types.RoleBindingSubject, +) (types.RoleBinding, error) { ctx, span := e.tracer.Start( ctx, "engine.CreateRoleBinding", trace.WithAttributes( @@ -111,6 +125,10 @@ func (e *engine) CreateRoleBinding(ctx context.Context, resource, roleResource t dbrole, err := e.store.GetRoleByID(ctx, roleResource.ID) if err != nil { + if errors.Is(err, storage.ErrNoRoleFound) { + err = fmt.Errorf("%w: role %s", ErrRoleNotFound, roleResource.ID) + } + span.RecordError(err) span.SetStatus(codes.Error, err.Error()) @@ -122,6 +140,11 @@ func (e *engine) CreateRoleBinding(ctx context.Context, resource, roleResource t Name: dbrole.Name, } + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + return types.RoleBinding{}, nil + } + rbResourceType, ok := e.schemaTypeMap[e.rbac.RoleBindingResource.Name] if !ok { return types.RoleBinding{}, fmt.Errorf( @@ -130,13 +153,33 @@ func (e *engine) CreateRoleBinding(ctx context.Context, resource, roleResource t ) } - rb := newRoleBindingWithPrefix(rbResourceType.IDPrefix, role) + rbid, err := gidx.NewID(rbResourceType.IDPrefix) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.RoleBinding{}, err + } + + rb, err := e.store.CreateRoleBinding(dbCtx, actor.ID, rbid, resource.ID) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.RoleBinding{}, err + } + + rb.Role = 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()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) return types.RoleBinding{}, err } @@ -160,6 +203,7 @@ func (e *engine) CreateRoleBinding(ctx context.Context, resource, roleResource t if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) return types.RoleBinding{}, err } @@ -176,6 +220,16 @@ func (e *engine) CreateRoleBinding(ctx context.Context, resource, roleResource t }); err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.RoleBinding{}, 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)) + logRollbackErr(e.logger, e.rollbackRoleBindingUpdates(ctx, updates)) return types.RoleBinding{}, err } @@ -183,7 +237,7 @@ func (e *engine) CreateRoleBinding(ctx context.Context, resource, roleResource t return rb, nil } -func (e *engine) DeleteRoleBinding(ctx context.Context, rb, res types.Resource) error { +func (e *engine) DeleteRoleBinding(ctx context.Context, rb types.Resource) error { ctx, span := e.tracer.Start( ctx, "engine.DeleteRoleBinding", trace.WithAttributes( @@ -192,6 +246,38 @@ func (e *engine) DeleteRoleBinding(ctx context.Context, rb, res types.Resource) ) defer span.End() + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + + if err := e.store.LockRoleBindingForUpdate(dbCtx, rb.ID); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err + } + + rbFromDB, err := e.store.GetRoleBindingByID(dbCtx, rb.ID) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + + res, err := e.NewResourceFromID(rbFromDB.ResourceID) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + // gather all relationships from the role-binding resource fromRels, err := e.readRelationships(ctx, &pb.RelationshipFilter{ ResourceType: e.namespaced(e.rbac.RoleBindingResource.Name), @@ -200,6 +286,7 @@ func (e *engine) DeleteRoleBinding(ctx context.Context, rb, res types.Resource) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) return err } @@ -216,6 +303,7 @@ func (e *engine) DeleteRoleBinding(ctx context.Context, rb, res types.Resource) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) return err } @@ -234,6 +322,25 @@ func (e *engine) DeleteRoleBinding(ctx context.Context, rb, res types.Resource) if _, err := e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{Updates: updates}); err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err + } + + if err := e.store.DeleteRoleBinding(dbCtx, rb.ID); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + logRollbackErr(e.logger, e.rollbackRoleBindingUpdates(ctx, updates)) + + 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)) + logRollbackErr(e.logger, e.rollbackRoleBindingUpdates(ctx, updates)) return err } @@ -264,6 +371,9 @@ func (e *engine) ListRoleBindings(ctx context.Context, resource types.Resource, grantRel, err := e.readRelationships(ctx, listRbFilter) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } @@ -311,6 +421,9 @@ func (e *engine) ListRoleBindings(ctx context.Context, resource types.Resource, for _, err := range errs { if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } } @@ -318,7 +431,7 @@ func (e *engine) ListRoleBindings(ctx context.Context, resource types.Resource, return bindings, nil } -func (e *engine) UpdateRoleBinding(ctx context.Context, rb types.Resource, subjects []types.RoleBindingSubject) (types.RoleBinding, error) { +func (e *engine) UpdateRoleBinding(ctx context.Context, actor, rb types.Resource, subjects []types.RoleBindingSubject) (types.RoleBinding, error) { ctx, span := e.tracer.Start( ctx, "engine.UpdateRoleBindings", trace.WithAttributes( @@ -327,10 +440,27 @@ func (e *engine) UpdateRoleBinding(ctx context.Context, rb types.Resource, subje ) defer span.End() - rolebinding, err := e.GetRoleBinding(ctx, rb) + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.RoleBinding{}, err + } + + if err := e.store.LockRoleBindingForUpdate(dbCtx, rb.ID); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.RoleBinding{}, err + } + + rolebinding, err := e.GetRoleBinding(dbCtx, rb) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) return types.RoleBinding{}, err } @@ -363,6 +493,7 @@ func (e *engine) UpdateRoleBinding(ctx context.Context, rb types.Resource, subje if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) return types.RoleBinding{}, err } @@ -376,6 +507,7 @@ func (e *engine) UpdateRoleBinding(ctx context.Context, rb types.Resource, subje if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) return types.RoleBinding{}, err } @@ -388,15 +520,49 @@ func (e *engine) UpdateRoleBinding(ctx context.Context, rb types.Resource, subje if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.RoleBinding{}, err + } + + // 3. update the role-binding in the database to record latest `updatedBy` and `updatedAt` + rbFromDB, err := e.store.UpdateRoleBinding(dbCtx, actor.ID, rb.ID) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + logRollbackErr(e.logger, e.rollbackRoleBindingUpdates(ctx, updates)) + } + + if err := e.store.CommitContext(dbCtx); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + logRollbackErr(e.logger, e.rollbackRoleBindingUpdates(ctx, updates)) return types.RoleBinding{}, err } rolebinding.Subjects = subjects + rolebinding.UpdatedAt = rbFromDB.UpdatedAt + rolebinding.UpdatedBy = rbFromDB.UpdatedBy return rolebinding, nil } +func (e *engine) GetRoleBindingResource(ctx context.Context, rb types.Resource) (types.Resource, error) { + rbFromDB, err := e.store.GetRoleBindingByID(ctx, rb.ID) + if err != nil { + if errors.Is(err, storage.ErrRoleBindingNotFound) { + err = fmt.Errorf("%w: %s", ErrRoleBindingNotFound, err) + } + + return types.Resource{}, err + } + + return e.NewResourceFromID(rbFromDB.ResourceID) +} + // isRoleBindable checks if a role is available for a resource. a role is not // available to a resource if its owner is not associated with the resource // in any way. @@ -458,6 +624,40 @@ func (e *engine) deleteRoleBindingsForRole(ctx context.Context, roleResource typ return err } + if len(bindings) == 0 { + return nil + } + + rbIDs := make([]gidx.PrefixedID, len(bindings)) + + for i, rel := range bindings { + id, err := gidx.Parse(rel.Resource.ObjectId) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + + rbIDs[i] = id + } + + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err + } + + if err := e.store.BatchLockRoleBindingForUpdate(dbCtx, rbIDs); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err + } + // 2. Gather all the relationships to be deleted // 2.1 build a list of requests to get all the subject, role and grant @@ -492,6 +692,7 @@ func (e *engine) deleteRoleBindingsForRole(ctx context.Context, roleResource typ if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) return err } @@ -511,10 +712,29 @@ func (e *engine) deleteRoleBindingsForRole(ctx context.Context, roleResource typ e.logger.Debugf("%d relationships will be deleted", len(updates)) - // 3. delete all the relationships + // 3.1 delete all records in permissions-api DB + if err := e.store.BatchDeleteRoleBindings(dbCtx, rbIDs); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err + } + + // 3.2 delete all the relationships if _, err := e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{Updates: updates}); 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)) + logRollbackErr(e.logger, e.rollbackRoleBindingUpdates(ctx, updates)) return err } @@ -614,11 +834,40 @@ func (e *engine) rolebindingRelationshipUpdateForSubject( return &pb.RelationshipUpdate{Operation: op, Relationship: rel}, nil } -func newRoleBindingWithPrefix(prefix string, role types.Role) types.RoleBinding { - rb := types.RoleBinding{ - ID: gidx.MustNewID(prefix), - Role: role, +// rollbackRoleBindingUpdates is a helper function that rolls back a list of +// relationship updates on spiceDB. +func (e *engine) rollbackRoleBindingUpdates(ctx context.Context, updates []*pb.RelationshipUpdate) error { + updatesLen := len(updates) + rollbacks := make([]*pb.RelationshipUpdate, 0, updatesLen) + + for i := range updates { + // reversed order + u := updates[updatesLen-i-1] + + if u == nil { + continue + } + + var op pb.RelationshipUpdate_Operation + + switch u.Operation { + case pb.RelationshipUpdate_OPERATION_CREATE: + fallthrough + case pb.RelationshipUpdate_OPERATION_TOUCH: + op = pb.RelationshipUpdate_OPERATION_DELETE + case pb.RelationshipUpdate_OPERATION_DELETE: + op = pb.RelationshipUpdate_OPERATION_TOUCH + default: + continue + } + + rollbacks = append(rollbacks, &pb.RelationshipUpdate{ + Operation: op, + Relationship: u.Relationship, + }) } - return rb + _, err := e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{Updates: rollbacks}) + + return err } diff --git a/internal/query/rolebindings_test.go b/internal/query/rolebindings_test.go index 1422ef0e..33137c6a 100644 --- a/internal/query/rolebindings_test.go +++ b/internal/query/rolebindings_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "go.infratographer.com/permissions-api/internal/iapl" + "go.infratographer.com/permissions-api/internal/storage" "go.infratographer.com/permissions-api/internal/testingx" "go.infratographer.com/permissions-api/internal/types" ) @@ -41,10 +42,12 @@ func TestCreateRoleBinding(t *testing.T) { require.NoError(t, err) orphan, err := e.NewResourceFromIDString("tnntten-orphan") require.NoError(t, err) + subj, err := e.NewResourceFromIDString("idntusr-subj") + 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"}) + role, err := e.CreateRoleV2(ctx, subj, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) require.NoError(t, err) roleRes, err := e.NewResourceFromID(role.ID) @@ -73,10 +76,10 @@ func TestCreateRoleBinding(t *testing.T) { Input: input{ resource: root, role: notfoundRole, - subjects: []types.RoleBindingSubject{{SubjectResource: actor}}, + subjects: []types.RoleBindingSubject{{SubjectResource: subj}}, }, CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { - assert.ErrorContains(t, res.Err, ErrRoleNotFound.Error()) + assert.ErrorIs(t, res.Err, ErrRoleNotFound) }, }, { @@ -84,10 +87,10 @@ func TestCreateRoleBinding(t *testing.T) { Input: input{ resource: root, role: v1role, - subjects: []types.RoleBindingSubject{{SubjectResource: actor}}, + subjects: []types.RoleBindingSubject{{SubjectResource: subj}}, }, CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { - assert.ErrorContains(t, res.Err, ErrRoleNotFound.Error()) + assert.ErrorIs(t, res.Err, ErrRoleNotFound) }, }, { @@ -95,7 +98,7 @@ func TestCreateRoleBinding(t *testing.T) { Input: input{ resource: child, role: roleRes, - subjects: []types.RoleBindingSubject{{SubjectResource: actor}}, + subjects: []types.RoleBindingSubject{{SubjectResource: subj}}, }, CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { assert.NoError(t, res.Err) @@ -112,10 +115,10 @@ func TestCreateRoleBinding(t *testing.T) { Input: input{ resource: orphan, role: roleRes, - subjects: []types.RoleBindingSubject{{SubjectResource: actor}}, + subjects: []types.RoleBindingSubject{{SubjectResource: subj}}, }, CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { - assert.ErrorContains(t, res.Err, ErrRoleNotFound.Error()) + assert.ErrorIs(t, res.Err, ErrRoleNotFound) }, }, { @@ -123,23 +126,26 @@ func TestCreateRoleBinding(t *testing.T) { Input: input{ resource: root, role: roleRes, - subjects: []types.RoleBindingSubject{{SubjectResource: actor}}, + subjects: []types.RoleBindingSubject{{SubjectResource: subj}}, }, 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) + assert.Equal(t, role.ID, res.Success.Role.ID) + assert.Equal(t, root.ID, res.Success.ResourceID) + assert.Equal(t, subj.ID, res.Success.Subjects[0].SubjectResource.ID) + assert.Equal(t, actor.ID, res.Success.CreatedBy) - rb, err := e.ListRoleBindings(ctx, root, nil) + rbs, err := e.ListRoleBindings(ctx, root, nil) assert.NoError(t, err) - assert.Len(t, rb, 1) + assert.Len(t, rbs, 1) }, }, } testFn := func(ctx context.Context, in input) testingx.TestResult[types.RoleBinding] { - rb, err := e.CreateRoleBinding(ctx, in.resource, in.role, in.subjects) + rb, err := e.CreateRoleBinding(ctx, actor, in.resource, in.role, in.subjects) return testingx.TestResult[types.RoleBinding]{Success: rb, Err: err} } @@ -155,13 +161,15 @@ func TestListRoleBindings(t *testing.T) { require.NoError(t, err) child, err := e.NewResourceFromIDString("tnntten-child") require.NoError(t, err) + subj, err := e.NewResourceFromIDString("idntusr-subj") + 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"}) + viewer, err := e.CreateRoleV2(ctx, subj, 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"}) + editor, err := e.CreateRoleV2(ctx, subj, root, "lb_editor", []string{"loadbalancer_list", "loadbalancer_get", "loadbalancer_create", "loadbalancer_update"}) require.NoError(t, err) viewerRes, err := e.NewResourceFromID(viewer.ID) @@ -173,10 +181,10 @@ func TestListRoleBindings(t *testing.T) { notfoundRole, err := e.NewResourceFromIDString("permrv2-notfound") require.NoError(t, err) - _, err = e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + _, err = e.CreateRoleBinding(ctx, actor, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: subj}}) require.NoError(t, err) - _, err = e.CreateRoleBinding(ctx, root, editorRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + _, err = e.CreateRoleBinding(ctx, actor, root, editorRes, []types.RoleBindingSubject{{SubjectResource: subj}}) require.NoError(t, err) _, err = e.client.WriteRelationships(ctx, &pb.WriteRelationshipsRequest{ @@ -257,10 +265,12 @@ func TestGetRoleBinding(t *testing.T) { root, err := e.NewResourceFromIDString("tnntten-root") require.NoError(t, err) + subj, err := e.NewResourceFromIDString("idntusr-subj") + 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"}) + viewer, err := e.CreateRoleV2(ctx, subj, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) require.NoError(t, err) viewerRes, err := e.NewResourceFromID(viewer.ID) @@ -269,7 +279,7 @@ func TestGetRoleBinding(t *testing.T) { notfoundRB, err := e.NewResourceFromIDString("permrbn-notfound") require.NoError(t, err) - rb, err := e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + rb, err := e.CreateRoleBinding(ctx, actor, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: subj}}) require.NoError(t, err) rbRes, err := e.NewResourceFromID(rb.ID) @@ -283,7 +293,9 @@ func TestGetRoleBinding(t *testing.T) { 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) + assert.Equal(t, subj.ID, res.Success.Subjects[0].SubjectResource.ID) + assert.Equal(t, actor.ID, res.Success.CreatedBy) + assert.Equal(t, root.ID, res.Success.ResourceID) }, }, { @@ -310,15 +322,17 @@ func TestUpdateRoleBinding(t *testing.T) { root, err := e.NewResourceFromIDString("tnntten-root") require.NoError(t, err) + subj, err := e.NewResourceFromIDString("idntusr-subj") + 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"}) + viewer, err := e.CreateRoleV2(ctx, subj, 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}}) + rb, err := e.CreateRoleBinding(ctx, subj, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: subj}}) require.NoError(t, err) rbRes, err := e.NewResourceFromID(rb.ID) require.NoError(t, err) @@ -342,11 +356,12 @@ func TestUpdateRoleBinding(t *testing.T) { Name: "UpdateRoleBindingNotFound", Input: input{ rb: notfoundRB, - subj: []types.RoleBindingSubject{{SubjectResource: actor}}, + subj: []types.RoleBindingSubject{{SubjectResource: subj}}, }, CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { assert.ErrorContains(t, res.Err, ErrRoleBindingNotFound.Error()) }, + Sync: true, }, { Name: "UpdateRoleBindingInvalidSubject", @@ -357,6 +372,7 @@ func TestUpdateRoleBinding(t *testing.T) { CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { assert.ErrorContains(t, res.Err, ErrInvalidArgument.Error()) }, + Sync: true, }, { Name: "UpdateRoleBindingSuccess", @@ -370,13 +386,18 @@ func TestUpdateRoleBinding(t *testing.T) { 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}) + assert.NotContains(t, res.Success.Subjects, types.RoleBindingSubject{SubjectResource: subj}) + + assert.Equal(t, actor.ID, res.Success.UpdatedBy) + assert.Equal(t, root.ID, res.Success.ResourceID) + assert.Equal(t, subj.ID, res.Success.CreatedBy) }, + Sync: true, }, } testFn := func(ctx context.Context, in input) testingx.TestResult[types.RoleBinding] { - rb, err := e.UpdateRoleBinding(ctx, in.rb, in.subj) + rb, err := e.UpdateRoleBinding(ctx, actor, in.rb, in.subj) return testingx.TestResult[types.RoleBinding]{Success: rb, Err: err} } @@ -398,7 +419,7 @@ func TestDeleteRoleBinding(t *testing.T) { viewerRes, err := e.NewResourceFromID(viewer.ID) require.NoError(t, err) - rb, err := e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + rb, err := e.CreateRoleBinding(ctx, actor, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: actor}}) require.NoError(t, err) rbRes, err := e.NewResourceFromID(rb.ID) require.NoError(t, err) @@ -411,7 +432,7 @@ func TestDeleteRoleBinding(t *testing.T) { Name: "DeleteRoleBindingNotFound", Input: notfoundRB, CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { - assert.NoError(t, res.Err) + assert.ErrorIs(t, res.Err, storage.ErrRoleBindingNotFound) rb, err := e.ListRoleBindings(ctx, root, nil) assert.NoError(t, err) @@ -434,7 +455,7 @@ func TestDeleteRoleBinding(t *testing.T) { } testFn := func(ctx context.Context, in types.Resource) testingx.TestResult[types.RoleBinding] { - err := e.DeleteRoleBinding(ctx, in, root) + err := e.DeleteRoleBinding(ctx, in) return testingx.TestResult[types.RoleBinding]{Err: err} } @@ -510,7 +531,7 @@ func TestPermissions(t *testing.T) { }) require.Error(t, err) - _, err = e.CreateRoleBinding(ctx, lb1, viewerRes, []types.RoleBindingSubject{{SubjectResource: user1}}) + _, err = e.CreateRoleBinding(ctx, user1, lb1, viewerRes, []types.RoleBindingSubject{{SubjectResource: user1}}) require.NoError(t, err) return ctx @@ -528,7 +549,7 @@ func TestPermissions(t *testing.T) { rbs, _ := e.ListRoleBindings(ctx, lb1, nil) for _, rb := range rbs { rbRes, _ := e.NewResourceFromID(rb.ID) - _ = e.DeleteRoleBinding(ctx, rbRes, lb1) + _ = e.DeleteRoleBinding(ctx, rbRes) } }, Sync: true, @@ -544,7 +565,7 @@ func TestPermissions(t *testing.T) { }) require.Error(t, err) - _, err = e.CreateRoleBinding(ctx, child, viewerRes, []types.RoleBindingSubject{{SubjectResource: user1}}) + _, err = e.CreateRoleBinding(ctx, user1, child, viewerRes, []types.RoleBindingSubject{{SubjectResource: user1}}) require.NoError(t, err) return ctx @@ -562,7 +583,7 @@ func TestPermissions(t *testing.T) { rbs, _ := e.ListRoleBindings(ctx, child, nil) for _, rb := range rbs { rbRes, _ := e.NewResourceFromID(rb.ID) - _ = e.DeleteRoleBinding(ctx, rbRes, child) + _ = e.DeleteRoleBinding(ctx, rbRes) } }, Sync: true, @@ -578,7 +599,7 @@ func TestPermissions(t *testing.T) { }) require.Error(t, err) - _, err = e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: user1}}) + _, err = e.CreateRoleBinding(ctx, user1, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: user1}}) require.NoError(t, err) return ctx @@ -596,7 +617,7 @@ func TestPermissions(t *testing.T) { rbs, _ := e.ListRoleBindings(ctx, root, nil) for _, rb := range rbs { rbRes, _ := e.NewResourceFromID(rb.ID) - _ = e.DeleteRoleBinding(ctx, rbRes, root) + _ = e.DeleteRoleBinding(ctx, rbRes) } }, Sync: true, @@ -612,7 +633,7 @@ func TestPermissions(t *testing.T) { }) require.Error(t, err) - _, err = e.CreateRoleBinding(ctx, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: group1}}) + _, err = e.CreateRoleBinding(ctx, user1, root, viewerRes, []types.RoleBindingSubject{{SubjectResource: group1}}) require.NoError(t, err) return ctx diff --git a/internal/query/roles_v2.go b/internal/query/roles_v2.go index f22adf98..83211db8 100644 --- a/internal/query/roles_v2.go +++ b/internal/query/roles_v2.go @@ -51,6 +51,10 @@ func (e *engine) CreateRoleV2(ctx context.Context, actor, owner types.Resource, dbRole, err := e.store.CreateRole(dbCtx, actor.ID, role.ID, roleName, owner.ID) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + return types.Role{}, err } diff --git a/internal/query/roles_v2_test.go b/internal/query/roles_v2_test.go index 0726e0fc..f09eb022 100644 --- a/internal/query/roles_v2_test.go +++ b/internal/query/roles_v2_test.go @@ -409,10 +409,12 @@ func TestDeleteRolesV2(t *testing.T) { require.NoError(t, err) theotherchild, err := e.NewResourceFromIDString("tnntten-theotherchild") require.NoError(t, err) + subj, err := e.NewResourceFromIDString("idntusr-subj") + 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"}) + role, err := e.CreateRoleV2(ctx, subj, root, "lb_viewer", []string{"loadbalancer_list", "loadbalancer_get"}) require.NoError(t, err) roleRes, err := e.NewResourceFromID(role.ID) @@ -432,13 +434,13 @@ func TestDeleteRolesV2(t *testing.T) { 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}}) + _, err = e.CreateRoleBinding(ctx, actor, root, roleRes, []types.RoleBindingSubject{{SubjectResource: subj}}) require.NoError(t, err) - _, err = e.CreateRoleBinding(ctx, child, roleRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + _, err = e.CreateRoleBinding(ctx, actor, child, roleRes, []types.RoleBindingSubject{{SubjectResource: subj}}) require.NoError(t, err) - _, err = e.CreateRoleBinding(ctx, theotherchild, roleRes, []types.RoleBindingSubject{{SubjectResource: actor}}) + _, err = e.CreateRoleBinding(ctx, actor, theotherchild, roleRes, []types.RoleBindingSubject{{SubjectResource: subj}}) require.NoError(t, err) rb, err := e.ListRoleBindings(ctx, root, &roleRes) @@ -464,7 +466,7 @@ func TestDeleteRolesV2(t *testing.T) { }, { Name: "DeleteRoleInvalidInput", - Input: actor, + Input: subj, CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { assert.Error(t, res.Err) }, diff --git a/internal/query/service.go b/internal/query/service.go index 80c68f84..dadb860d 100644 --- a/internal/query/service.go +++ b/internal/query/service.go @@ -61,16 +61,19 @@ type Engine interface { // 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) + CreateRoleBinding(ctx context.Context, actor, 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) + UpdateRoleBinding(ctx context.Context, actor, 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 + DeleteRoleBinding(ctx context.Context, rolebinding types.Resource) error + // GetRoleBindingResource fetches the resource to which a role-binding + // belongs + GetRoleBindingResource(ctx context.Context, rb types.Resource) (types.Resource, error) AllActions() []string } diff --git a/internal/storage/errors.go b/internal/storage/errors.go index 111134fb..61c00464 100644 --- a/internal/storage/errors.go +++ b/internal/storage/errors.go @@ -25,6 +25,9 @@ var ( // ErrorInvalidContextTx represents an error where the given context transaction is of the wrong type. ErrorInvalidContextTx = errors.New("invalid type for transaction context") + + // ErrRoleBindingNotFound is returned when no role binding is found when retrieving or deleting a role binding. + ErrRoleBindingNotFound = errors.New("role binding not found") ) const ( diff --git a/internal/storage/migrations/20240425000000_role_bindings.sql b/internal/storage/migrations/20240425000000_role_bindings.sql new file mode 100644 index 00000000..9a9ce9cf --- /dev/null +++ b/internal/storage/migrations/20240425000000_role_bindings.sql @@ -0,0 +1,33 @@ +-- +goose Up + +-- create "rolebindings" table +CREATE TABLE "rolebindings" ( + "id" character varying NOT NULL, + "resource_id" character varying NOT NULL, + "created_by" character varying NOT NULL, + "updated_by" character varying NOT NULL, + "created_at" timestamptz NOT NULL, + "updated_at" timestamptz NOT NULL, + PRIMARY KEY ("id") +); + +-- create index "rolebindings_created_by" to table: "rolebindings" +CREATE INDEX "rolebindings_created_by" ON "rolebindings" ("created_by"); +-- create index "rolebindings_created_by" to table: "rolebindings" +CREATE INDEX "rolebindings_updated_by" ON "rolebindings" ("updated_by"); +-- create index "rolebindings_created_at" to table: "rolebindings" +CREATE INDEX "rolebindings_created_at" ON "rolebindings" ("created_at"); +-- create index "rolebindings_updated_at" to table: "rolebindings" +CREATE INDEX "rolebindings_updated_at" ON "rolebindings" ("updated_at"); + +-- +goose Down +-- reverse: create index "rolebindings_updated_at" to table: "rolebindings" +DROP INDEX "rolebindings_updated_at"; +-- reverse: create index "rolebindings_created_at" to table: "rolebindings" +DROP INDEX "rolebindings_created_at"; +-- reverse: create index "rolebindings_updated_by" to table: "rolebindings" +DROP INDEX "rolebindings_updated_by"; +-- reverse: create index "rolebindings_created_by" to table: "rolebindings" +DROP INDEX "rolebindings_created_by"; +-- reverse: create "rolebindings" table +DROP TABLE "rolebindings"; diff --git a/internal/storage/rolebinding.go b/internal/storage/rolebinding.go new file mode 100644 index 00000000..906464ce --- /dev/null +++ b/internal/storage/rolebinding.go @@ -0,0 +1,297 @@ +package storage + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "go.infratographer.com/permissions-api/internal/types" + + "go.infratographer.com/x/gidx" +) + +// RoleBindingService represents a service for managing role bindings in the +// permissions API storage +type RoleBindingService interface { + // ListResourceRoleBindings returns all role bindings for a given resource + // an empty slice is returned if no role bindings are found + ListResourceRoleBindings(ctx context.Context, resourceID gidx.PrefixedID) ([]types.RoleBinding, error) + + // GetRoleBindingByID returns a role binding by its prefixed ID + // an ErrRoleBindingNotFound error is returned if no role binding is found + GetRoleBindingByID(ctx context.Context, id gidx.PrefixedID) (types.RoleBinding, error) + + // CreateRoleBinding creates a new role binding in the database + // This method must be called with a context returned from BeginContext. + // CommitContext or RollbackContext must be called afterwards if this method returns no error. + CreateRoleBinding(ctx context.Context, actorID, rbID, resourceID gidx.PrefixedID) (types.RoleBinding, error) + + // UpdateRoleBinding updates a role binding in the database + // Note that this method only updates the updated_at and updated_by fields + // and do not provide a way to update the resource_id field. + // + // This method must be called with a context returned from BeginContext. + // CommitContext or RollbackContext must be called afterwards if this method returns no error. + UpdateRoleBinding(ctx context.Context, actorID, rbID gidx.PrefixedID) (types.RoleBinding, error) + + // DeleteRoleBinding deletes a role binding from the database + // This method must be called with a context returned from BeginContext. + // CommitContext or RollbackContext must be called afterwards if this method returns no error. + DeleteRoleBinding(ctx context.Context, id gidx.PrefixedID) error + + // BatchDeleteRoleBinding deletes multiple role bindings from the database + // This method must be called with a context returned from BeginContext. + // CommitContext or RollbackContext must be called afterwards if this method returns no error. + BatchDeleteRoleBindings(ctx context.Context, ids []gidx.PrefixedID) error + + // LockRoleBindingForUpdate locks a role binding record to be updated to ensure consistency. + // If the role binding is not found, an ErrRoleBindingNotFound error is returned. + LockRoleBindingForUpdate(ctx context.Context, id gidx.PrefixedID) error + + // BatchLockRoleBindingForUpdate locks multiple role binding records to be updated to ensure consistency. + BatchLockRoleBindingForUpdate(ctx context.Context, ids []gidx.PrefixedID) error +} + +func (e *engine) GetRoleBindingByID(ctx context.Context, id gidx.PrefixedID) (types.RoleBinding, error) { + db, err := getContextDBQuery(ctx, e) + if err != nil { + return types.RoleBinding{}, err + } + + var roleBinding types.RoleBinding + + err = db.QueryRowContext(ctx, ` + SELECT id, resource_id, created_by, updated_by, created_at, updated_at + FROM rolebindings WHERE id = $1 + `, id.String(), + ).Scan( + &roleBinding.ID, + &roleBinding.ResourceID, + &roleBinding.CreatedBy, + &roleBinding.UpdatedBy, + &roleBinding.CreatedAt, + &roleBinding.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return types.RoleBinding{}, fmt.Errorf("%w: %s", ErrRoleBindingNotFound, id.String()) + } + + return types.RoleBinding{}, fmt.Errorf("%w: %s", err, id.String()) + } + + return roleBinding, nil +} + +func (e *engine) ListResourceRoleBindings(ctx context.Context, resourceID gidx.PrefixedID) ([]types.RoleBinding, error) { + db, err := getContextDBQuery(ctx, e) + if err != nil { + return nil, err + } + + rows, err := db.QueryContext(ctx, ` + SELECT id, resource_id, created_by, updated_by, created_at, updated_at + FROM rolebindings WHERE resource_id = $1 + `, resourceID.String(), + ) + if err != nil { + return nil, fmt.Errorf("%w: %s", err, resourceID.String()) + } + defer rows.Close() + + var roleBindings []types.RoleBinding + + for rows.Next() { + var roleBinding types.RoleBinding + + err = rows.Scan( + &roleBinding.ID, + &roleBinding.ResourceID, + &roleBinding.CreatedBy, + &roleBinding.UpdatedBy, + &roleBinding.CreatedAt, + &roleBinding.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("%w: %s", err, resourceID.String()) + } + + roleBindings = append(roleBindings, roleBinding) + } + + return roleBindings, nil +} + +func (e *engine) CreateRoleBinding(ctx context.Context, actorID, rbID, resourceID gidx.PrefixedID) (types.RoleBinding, error) { + tx, err := getContextTx(ctx) + if err != nil { + return types.RoleBinding{}, err + } + + var rb types.RoleBinding + + err = tx.QueryRowContext(ctx, ` + INSERT INTO rolebindings (id, resource_id, created_by, updated_by, created_at, updated_at) + VALUES ($1, $2, $3, $3, now(), now()) + RETURNING id, resource_id, created_by, updated_by, created_at, updated_at + `, rbID.String(), resourceID.String(), actorID.String(), + ).Scan( + &rb.ID, + &rb.ResourceID, + &rb.CreatedBy, + &rb.UpdatedBy, + &rb.CreatedAt, + &rb.UpdatedAt, + ) + if err != nil { + return types.RoleBinding{}, fmt.Errorf("%w: %s", err, rbID.String()) + } + + return rb, nil +} + +func (e *engine) UpdateRoleBinding(ctx context.Context, actorID, rbID gidx.PrefixedID) (types.RoleBinding, error) { + tx, err := getContextTx(ctx) + if err != nil { + return types.RoleBinding{}, err + } + + var rb types.RoleBinding + + err = tx.QueryRowContext(ctx, ` + UPDATE rolebindings + SET updated_by = $1, updated_at = now() + WHERE id = $2 + RETURNING id, resource_id, created_by, updated_by, created_at, updated_at + `, + actorID.String(), rbID.String(), + ).Scan( + &rb.ID, + &rb.ResourceID, + &rb.CreatedBy, + &rb.UpdatedBy, + &rb.CreatedAt, + &rb.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return types.RoleBinding{}, fmt.Errorf("%w: %s", ErrRoleBindingNotFound, rbID.String()) + } + + return types.RoleBinding{}, fmt.Errorf("%w: %s", err, rbID.String()) + } + + return rb, nil +} + +func (e *engine) DeleteRoleBinding(ctx context.Context, id gidx.PrefixedID) error { + tx, err := getContextTx(ctx) + if err != nil { + return err + } + + result, err := tx.ExecContext(ctx, ` + DELETE FROM rolebindings WHERE id = $1 + `, id.String(), + ) + if err != nil { + return fmt.Errorf("%w: %s", err, id.String()) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("%w: %s", err, id.String()) + } + + if rowsAffected == 0 { + return fmt.Errorf("%w: %s", ErrRoleBindingNotFound, id.String()) + } + + return nil +} + +func (e *engine) BatchDeleteRoleBindings(ctx context.Context, ids []gidx.PrefixedID) error { + tx, err := getContextTx(ctx) + if err != nil { + return err + } + + inClause, args := e.buildBatchInClauseWithIDs(ids) + q := fmt.Sprintf("DELETE FROM rolebindings WHERE id IN (%s)", inClause) + + _, err = tx.ExecContext(ctx, q, args...) + if err != nil { + return err + } + + return nil +} + +func (e *engine) LockRoleBindingForUpdate(ctx context.Context, id gidx.PrefixedID) error { + db, err := getContextDBQuery(ctx, e) + if err != nil { + return err + } + + result, err := db.ExecContext(ctx, `SELECT 1 FROM rolebindings WHERE id = $1 FOR UPDATE`, id.String()) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return ErrRoleBindingNotFound + } + + return nil +} + +func (e *engine) BatchLockRoleBindingForUpdate(ctx context.Context, ids []gidx.PrefixedID) error { + db, err := getContextDBQuery(ctx, e) + if err != nil { + return err + } + + inClause, args := e.buildBatchInClauseWithIDs(ids) + q := fmt.Sprintf("SELECT 1 FROM rolebindings WHERE id IN (%s) FOR UPDATE", inClause) + + result, err := db.ExecContext(ctx, q, args...) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if int(rowsAffected) != len(ids) { + return fmt.Errorf("%w: %d of role-bindings not found", ErrRoleBindingNotFound, len(ids)-int(rowsAffected)) + } + + return nil +} + +// buildBatchInClauseWithIDs is a helper function that builds an IN clause for +// a batch query with the provided prefixed IDs. +func (e *engine) buildBatchInClauseWithIDs(ids []gidx.PrefixedID) (clause string, args []any) { + args = make([]any, len(ids)) + + for i, id := range ids { + fmtStr := "$%d" + + if i > 0 { + fmtStr = ", $%d" + } + + clause += fmt.Sprintf(fmtStr, i+1) + args[i] = id.String() + } + + return clause, args +} diff --git a/internal/storage/rolebinding_test.go b/internal/storage/rolebinding_test.go new file mode 100644 index 00000000..2d3e99c6 --- /dev/null +++ b/internal/storage/rolebinding_test.go @@ -0,0 +1,316 @@ +package storage_test + +import ( + "context" + "testing" + + "go.infratographer.com/permissions-api/internal/storage" + "go.infratographer.com/permissions-api/internal/storage/teststore" + "go.infratographer.com/permissions-api/internal/testingx" + "go.infratographer.com/permissions-api/internal/types" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.infratographer.com/x/gidx" +) + +func TestGetRoleBindingByID(t *testing.T) { + store, closeStore := teststore.NewTestStorage(t) + t.Cleanup(closeStore) + + ctx := context.Background() + actorID := gidx.PrefixedID("idntusr-user") + resourceID := gidx.PrefixedID("tentten-tenant") + rbID := gidx.MustNewID("permrbn") + + dbCtx, err := store.BeginContext(ctx) + require.NoError(t, err, "no error expected beginning transaction context") + + rb, err := store.CreateRoleBinding(dbCtx, actorID, rbID, resourceID) + require.NoError(t, err, "no error expected creating role binding") + + err = store.CommitContext(dbCtx) + require.NoError(t, err, "no error expected committing transaction context") + + tc := []testingx.TestCase[gidx.PrefixedID, types.RoleBinding]{ + { + Name: "NotFound", + Input: "permrbn-definitely_not_exists", + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + require.ErrorIs(t, res.Err, storage.ErrRoleBindingNotFound, "expected error to be role binding not found") + assert.ErrorIs(t, res.Err, storage.ErrRoleBindingNotFound) + require.Empty(t, res.Success.ID) + }, + }, + { + Name: "ok", + Input: rbID, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + require.NoError(t, res.Err, "no error expected") + + assert.Equal(t, rb.ID, res.Success.ID) + assert.Equal(t, rb.CreatedAt, res.Success.CreatedAt) + assert.Equal(t, rb.UpdatedAt, res.Success.UpdatedAt) + assert.Equal(t, rb.CreatedBy, res.Success.CreatedBy) + assert.Equal(t, rb.UpdatedBy, res.Success.UpdatedBy) + }, + }, + } + + testfn := func(ctx context.Context, input gidx.PrefixedID) testingx.TestResult[types.RoleBinding] { + rb, err := store.GetRoleBindingByID(ctx, input) + + return testingx.TestResult[types.RoleBinding]{Success: rb, Err: err} + } + + testingx.RunTests(ctx, t, tc, testfn) +} + +func TestListResourceRoleBindings(t *testing.T) { + store, closeStore := teststore.NewTestStorage(t) + t.Cleanup(closeStore) + + ctx := context.Background() + actorID := gidx.PrefixedID("idntusr-user") + resourceID := gidx.PrefixedID("tentten-tenant") + + rbIDs := []gidx.PrefixedID{ + gidx.MustNewID("permrbn"), + gidx.MustNewID("permrbn"), + } + + rbs := map[gidx.PrefixedID]types.RoleBinding{} + + dbCtx, err := store.BeginContext(ctx) + require.NoError(t, err, "no error expected beginning transaction context") + + for _, rbID := range rbIDs { + rbs[rbID], err = store.CreateRoleBinding(dbCtx, actorID, rbID, resourceID) + require.NoError(t, err, "no error expected creating role binding") + } + + err = store.CommitContext(dbCtx) + require.NoError(t, err, "no error expected committing transaction context") + + tc := []testingx.TestCase[gidx.PrefixedID, []types.RoleBinding]{ + { + Name: "NotFound", + Input: "tentten-definitely_not_exists", + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]types.RoleBinding]) { + assert.NoError(t, res.Err, "no error expected") + assert.Len(t, res.Success, 0, "an empty list is expected") + }, + }, + { + Name: "ok", + Input: resourceID, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[[]types.RoleBinding]) { + assert.NoError(t, res.Err, "no error expected") + assert.Len(t, res.Success, len(rbs), "expected number of role bindings") + + for _, rb := range res.Success { + assert.Equal(t, rb.ID, rbs[rb.ID].ID) + assert.Equal(t, rb.CreatedAt, rbs[rb.ID].CreatedAt) + assert.Equal(t, rb.UpdatedAt, rbs[rb.ID].UpdatedAt) + assert.Equal(t, rb.CreatedBy, rbs[rb.ID].CreatedBy) + assert.Equal(t, rb.UpdatedBy, rbs[rb.ID].UpdatedBy) + } + }, + }, + } + + testfn := func(ctx context.Context, input gidx.PrefixedID) testingx.TestResult[[]types.RoleBinding] { + rb, err := store.ListResourceRoleBindings(ctx, input) + + return testingx.TestResult[[]types.RoleBinding]{Success: rb, Err: err} + } + + testingx.RunTests(ctx, t, tc, testfn) +} + +func TestCreateRoleBinding(t *testing.T) { + store, closeStore := teststore.NewTestStorage(t) + t.Cleanup(closeStore) + + ctx := context.Background() + actorID := gidx.PrefixedID("idntusr-user") + resourceID := gidx.PrefixedID("tentten-tenant") + rbID := gidx.MustNewID("permrbn") + + tc := []testingx.TestCase[gidx.PrefixedID, types.RoleBinding]{ + { + Name: "ok", + Input: rbID, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + require.NoError(t, res.Err, "no error expected") + + assert.Equal(t, rbID, res.Success.ID) + assert.NotZero(t, res.Success.CreatedAt, "expected created at to be set") + assert.NotZero(t, res.Success.UpdatedAt, "expected updated at to be set") + assert.Equal(t, actorID, res.Success.CreatedBy) + assert.Equal(t, actorID, res.Success.UpdatedBy) + }, + Sync: true, + }, + { + Name: "IDConflict", + Input: rbID, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.Error(t, res.Err) + require.Empty(t, res.Success.ID) + }, + Sync: true, + }, + } + + testfn := func(ctx context.Context, input gidx.PrefixedID) testingx.TestResult[types.RoleBinding] { + result := testingx.TestResult[types.RoleBinding]{} + + dbCtx, err := store.BeginContext(ctx) + if err != nil { + result.Err = err + + return result + } + + result.Success, result.Err = store.CreateRoleBinding(dbCtx, actorID, input, resourceID) + if result.Err != nil { + store.RollbackContext(dbCtx) //nolint:errcheck // skip check in test + + return result + } + + result.Err = store.CommitContext(dbCtx) + + return result + } + + testingx.RunTests(ctx, t, tc, testfn) +} + +func TestUpdateRoleBinding(t *testing.T) { + store, closeStore := teststore.NewTestStorage(t) + t.Cleanup(closeStore) + + ctx := context.Background() + actorID := gidx.PrefixedID("idntusr-user") + theOtherGuy := gidx.PrefixedID("idntusr-the_other_guy") + resourceID := gidx.PrefixedID("tentten-tenant") + rbID := gidx.MustNewID("permrbn") + + dbCtx, err := store.BeginContext(ctx) + require.NoError(t, err, "no error expected beginning transaction context") + + _, err = store.CreateRoleBinding(dbCtx, actorID, rbID, resourceID) + require.NoError(t, err, "no error expected creating role binding") + + err = store.CommitContext(dbCtx) + require.NoError(t, err, "no error expected committing transaction context") + + tc := []testingx.TestCase[gidx.PrefixedID, types.RoleBinding]{ + { + Name: "ok", + Input: rbID, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + require.NoError(t, res.Err, "no error expected") + + assert.Equal(t, rbID, res.Success.ID) + assert.NotZero(t, res.Success.CreatedAt, "expected created at to be set") + assert.NotZero(t, res.Success.UpdatedAt, "expected updated at to be set") + assert.Equal(t, actorID, res.Success.CreatedBy) + assert.Equal(t, theOtherGuy, res.Success.UpdatedBy) + }, + }, + { + Name: "NotFound", + Input: "permrbn-definitely_not_exists", + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[types.RoleBinding]) { + assert.ErrorIs(t, res.Err, storage.ErrRoleBindingNotFound) + require.Empty(t, res.Success.ID) + }, + }, + } + + testfn := func(ctx context.Context, input gidx.PrefixedID) testingx.TestResult[types.RoleBinding] { + result := testingx.TestResult[types.RoleBinding]{} + + dbCtx, err := store.BeginContext(ctx) + if err != nil { + result.Err = err + + return result + } + + result.Success, result.Err = store.UpdateRoleBinding(dbCtx, theOtherGuy, input) + if result.Err != nil { + store.RollbackContext(dbCtx) //nolint:errcheck // skip check in + return result + } + + result.Err = store.CommitContext(dbCtx) + + return result + } + + testingx.RunTests(ctx, t, tc, testfn) +} + +func TestDeleteRoleBinding(t *testing.T) { + store, closeStore := teststore.NewTestStorage(t) + t.Cleanup(closeStore) + + ctx := context.Background() + actorID := gidx.PrefixedID("idntusr-user") + resourceID := gidx.PrefixedID("tentten-tenant") + rbID := gidx.MustNewID("permrbn") + + dbCtx, err := store.BeginContext(ctx) + require.NoError(t, err, "no error expected beginning transaction context") + + _, err = store.CreateRoleBinding(dbCtx, actorID, rbID, resourceID) + require.NoError(t, err, "no error expected creating role binding") + + err = store.CommitContext(dbCtx) + require.NoError(t, err, "no error expected committing transaction context") + + tc := []testingx.TestCase[gidx.PrefixedID, error]{ + { + Name: "ok", + Input: rbID, + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[error]) { + assert.NoError(t, res.Err, "no error expected") + }, + }, + { + Name: "NotFound", + Input: "permrbn-definitely_not_exists", + CheckFn: func(_ context.Context, t *testing.T, res testingx.TestResult[error]) { + assert.ErrorIs(t, res.Err, storage.ErrRoleBindingNotFound) + }, + }, + } + + testfn := func(ctx context.Context, input gidx.PrefixedID) testingx.TestResult[error] { + result := testingx.TestResult[error]{} + + dbCtx, err := store.BeginContext(ctx) + if err != nil { + result.Err = err + + return result + } + + result.Err = store.DeleteRoleBinding(dbCtx, input) + if result.Err != nil { + store.RollbackContext(dbCtx) //nolint:errcheck // skip check in test + + return result + } + + result.Err = store.CommitContext(dbCtx) + + return result + } + + testingx.RunTests(ctx, t, tc, testfn) +} diff --git a/internal/storage/roles.go b/internal/storage/roles.go index f65ffe16..767d063f 100644 --- a/internal/storage/roles.go +++ b/internal/storage/roles.go @@ -313,20 +313,7 @@ func (e *engine) BatchGetRoleByID(ctx context.Context, ids []gidx.PrefixedID) ([ return nil, err } - inClause := "" - args := make([]any, len(ids)) - - for i, id := range ids { - fmtStr := "$%d" - - if i > 0 { - fmtStr = ", $%d" - } - - inClause += fmt.Sprintf(fmtStr, i+1) - args[i] = id.String() - } - + inClause, args := e.buildBatchInClauseWithIDs(ids) q := fmt.Sprintf(` SELECT id, name, resource_id, diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ea1d64dc..95b60dc9 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -11,6 +11,7 @@ import ( // Storage defines the interface the engine exposes. type Storage interface { RoleService + RoleBindingService TransactionManager HealthCheck(ctx context.Context) error diff --git a/internal/types/types.go b/internal/types/types.go index 52d2f55e..b22ca92e 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -100,7 +100,13 @@ type Relationship struct { // RoleBinding represents a role binding between a role and a resource. type RoleBinding struct { - ID gidx.PrefixedID - Role Role - Subjects []RoleBindingSubject + ID gidx.PrefixedID + ResourceID gidx.PrefixedID + Role Role + Subjects []RoleBindingSubject + + CreatedBy gidx.PrefixedID + UpdatedBy gidx.PrefixedID + CreatedAt time.Time + UpdatedAt time.Time }