diff --git a/internal/dbtools/hooks.go b/internal/dbtools/hooks.go index 600f7c4..f13c51d 100644 --- a/internal/dbtools/hooks.go +++ b/internal/dbtools/hooks.go @@ -225,6 +225,66 @@ func AuditGroupDeleted(ctx context.Context, exec boil.ContextExecutor, pID strin return &event, event.Insert(ctx, exec, boil.Infer()) } +// AuditGroupHierarchyCreated inserts an event representing group hierarchy creation into the events table +func AuditGroupHierarchyCreated(ctx context.Context, exec boil.ContextExecutor, pID string, actor *models.User, m *models.GroupHierarchy) (*models.AuditEvent, error) { + // TODO non-user API actors don't exist in the governor database, + // we need to figure out how to handle that relationship in the audit table + var actorID null.String + if actor != nil { + actorID = null.StringFrom(actor.ID) + } + + event := models.AuditEvent{ + ParentID: null.StringFrom(pID), + ActorID: actorID, + SubjectGroupID: null.StringFrom(m.ParentGroupID), + Action: "group.hierarchy.added", + Changeset: calculateChangeset(&models.GroupHierarchy{}, m), + } + + return &event, event.Insert(ctx, exec, boil.Infer()) +} + +// AuditGroupHierarchyUpdated inserts an event representing group hierarchy update into the events table +func AuditGroupHierarchyUpdated(ctx context.Context, exec boil.ContextExecutor, pID string, actor *models.User, m *models.GroupHierarchy) (*models.AuditEvent, error) { + // TODO non-user API actors don't exist in the governor database, + // we need to figure out how to handle that relationship in the audit table + var actorID null.String + if actor != nil { + actorID = null.StringFrom(actor.ID) + } + + event := models.AuditEvent{ + ParentID: null.StringFrom(pID), + ActorID: actorID, + SubjectGroupID: null.StringFrom(m.ParentGroupID), + Action: "group.hierarchy.updated", + Changeset: calculateChangeset(&models.GroupHierarchy{}, m), + } + + return &event, event.Insert(ctx, exec, boil.Infer()) +} + +// AuditGroupHierarchyDeleted inserts an event representing group hierarchy deletion into the events table +func AuditGroupHierarchyDeleted(ctx context.Context, exec boil.ContextExecutor, pID string, actor *models.User, m *models.GroupHierarchy) (*models.AuditEvent, error) { + // TODO non-user API actors don't exist in the governor database, + // we need to figure out how to handle that relationship in the audit table + var actorID null.String + if actor != nil { + actorID = null.StringFrom(actor.ID) + } + + event := models.AuditEvent{ + ParentID: null.StringFrom(pID), + ActorID: actorID, + SubjectGroupID: null.StringFrom(m.ParentGroupID), + Action: "group.hierarchy.removed", + Changeset: []string{}, + } + + return &event, event.Insert(ctx, exec, boil.Infer()) +} + // AuditGroupMembershipCreated inserts an event representing group membership creation into the events table func AuditGroupMembershipCreated(ctx context.Context, exec boil.ContextExecutor, pID string, actor *models.User, m *models.GroupMembership) (*models.AuditEvent, error) { // TODO non-user API actors don't exist in the governor database, diff --git a/internal/dbtools/membership_enumeration.go b/internal/dbtools/membership_enumeration.go index d4731cc..49db895 100644 --- a/internal/dbtools/membership_enumeration.go +++ b/internal/dbtools/membership_enumeration.go @@ -213,7 +213,7 @@ func GetMembersOfGroup(ctx context.Context, db *sql.DB, groupID string, shouldPo func GetAllGroupMemberships(ctx context.Context, db *sql.DB, shouldPopulateAllModels bool) ([]EnumeratedMembership, error) { enumeratedMemberships := []EnumeratedMembership{} - err := queries.Raw(membershipsByGroupQuery).Bind(ctx, db, &enumeratedMemberships) + err := queries.Raw(allMembershipsQuery).Bind(ctx, db, &enumeratedMemberships) if err != nil { if !errors.Is(err, sql.ErrNoRows) { return []EnumeratedMembership{}, err @@ -230,6 +230,82 @@ func GetAllGroupMemberships(ctx context.Context, db *sql.DB, shouldPopulateAllMo return enumeratedMemberships, nil } +// HierarchyWouldCreateCycle returns true if a given new parent->member relationship would create a cycle in the database +func HierarchyWouldCreateCycle(ctx context.Context, db *sql.DB, parentGroupID, memberGroupID string) (bool, error) { + hierarchies := make(map[string][]string) + + hierarchyRows, err := models.GroupHierarchies().All(ctx, db) + if err != nil { + return false, err + } + + for _, row := range hierarchyRows { + hierarchies[row.ParentGroupID] = append(hierarchies[row.ParentGroupID], row.MemberGroupID) + } + + hierarchies[parentGroupID] = append(hierarchies[parentGroupID], memberGroupID) + + var walkNode func(startingID string, hierarchies map[string][]string, visited []string) bool + walkNode = func(startingID string, hierarchies map[string][]string, visited []string) bool { + if _, exists := hierarchies[startingID]; !exists { + return false + } + + if contains(visited, startingID) { + return true + } + + for _, e := range hierarchies[startingID] { + if walkNode(e, hierarchies, append(visited, startingID)) { + return true + } + } + + return false + } + + for i := range hierarchies { + if walkNode(i, hierarchies, []string{}) { + return true, nil + } + } + + return false, nil +} + +// FindMemberDiff finds members present in the second EnumeratedMembership which are not present in the first +func FindMemberDiff(before, after []EnumeratedMembership) []EnumeratedMembership { + type key struct { + groupID string + userID string + } + + beforeMap := make(map[key]bool) + + for _, e := range before { + k := key{ + groupID: e.GroupID, + userID: e.UserID, + } + beforeMap[k] = true + } + + uniqueMembersAfter := make([]EnumeratedMembership, 0) + + for _, e := range after { + k := key{ + groupID: e.GroupID, + userID: e.UserID, + } + + if _, exists := beforeMap[k]; !exists { + uniqueMembersAfter = append(uniqueMembersAfter, e) + } + } + + return uniqueMembersAfter +} + func populateModels(ctx context.Context, db *sql.DB, memberships []EnumeratedMembership) ([]EnumeratedMembership, error) { groupIDSet := make(map[string]bool) userIDSet := make(map[string]bool) @@ -315,3 +391,13 @@ func stringMapToKeySlice(m map[string]bool) []string { return keys } + +func contains(list []string, item string) bool { + for _, i := range list { + if i == item { + return true + } + } + + return false +} diff --git a/internal/dbtools/membership_enumeration_test.go b/internal/dbtools/membership_enumeration_test.go index cddfbdb..3d7bf98 100644 --- a/internal/dbtools/membership_enumeration_test.go +++ b/internal/dbtools/membership_enumeration_test.go @@ -224,6 +224,31 @@ func TestGetMembersOfGroup(t *testing.T) { } } +func TestHierarchyWouldCreateCycle(t *testing.T) { + type testCase struct { + parent string + member string + } + + testCases := map[testCase]bool{ + {parent: "00000002-0000-0000-0000-000000000001", member: "00000002-0000-0000-0000-000000000002"}: false, + {parent: "00000002-0000-0000-0000-000000000002", member: "00000002-0000-0000-0000-000000000003"}: false, + {parent: "00000002-0000-0000-0000-000000000003", member: "00000002-0000-0000-0000-000000000001"}: true, + {parent: "00000002-0000-0000-0000-000000000003", member: "00000002-0000-0000-0000-000000000002"}: true, + {parent: "00000002-0000-0000-0000-000000000003", member: "00000002-0000-0000-0000-000000000003"}: true, + } + + for test, expect := range testCases { + t.Run(fmt.Sprintf("test for cycle: %s member of %s", test.member, test.parent), func(t *testing.T) { + result, err := HierarchyWouldCreateCycle(context.TODO(), db, test.parent, test.member) + + assert.NoError(t, err) + + assert.Equal(t, expect, result) + }) + } +} + // Sets this up: // // ┌──────┐ diff --git a/pkg/api/v1alpha1/group_hierarchies.go b/pkg/api/v1alpha1/group_hierarchies.go new file mode 100644 index 0000000..f8761df --- /dev/null +++ b/pkg/api/v1alpha1/group_hierarchies.go @@ -0,0 +1,511 @@ +package v1alpha1 + +import ( + "database/sql" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/metal-toolbox/auditevent/ginaudit" + "github.com/volatiletech/null/v8" + "github.com/volatiletech/sqlboiler/v4/boil" + "github.com/volatiletech/sqlboiler/v4/queries/qm" + + "github.com/metal-toolbox/governor-api/internal/dbtools" + "github.com/metal-toolbox/governor-api/internal/models" + events "github.com/metal-toolbox/governor-api/pkg/events/v1alpha1" +) + +// GroupHierarchy is the relationship between a parent group and a member group +type GroupHierarchy struct { + ID string `json:"id"` + ParentGroupID string `json:"parent_group_id"` + ParentGroupSlug string `json:"parent_group_slug"` + MemberGroupID string `json:"member_group_id"` + MemberGroupSlug string `json:"member_group_slug"` + ExpiresAt null.Time `json:"expires_at"` +} + +// listMemberGroups returns a list of member groups in a parent +func (r *Router) listMemberGroups(c *gin.Context) { + gid := c.Param("id") + + queryMods := []qm.QueryMod{ + qm.Load(models.GroupHierarchyRels.ParentGroup), + qm.Load(models.GroupHierarchyRels.MemberGroup), + } + + q := qm.Where("parent_group_id = ?", gid) + + if _, err := uuid.Parse(gid); err != nil { + sendError(c, http.StatusNotFound, "could not parse uuid: "+err.Error()) + + return + } + + queryMods = append(queryMods, q) + + groups, err := models.GroupHierarchies(queryMods...).All(c.Request.Context(), r.DB) + if err != nil { + sendError(c, http.StatusInternalServerError, "error getting member groups: "+err.Error()) + + return + } + + hierarchies := make([]GroupHierarchy, len(groups)) + for i, h := range groups { + hierarchies[i] = GroupHierarchy{ + ID: h.ID, + ParentGroupID: h.ParentGroupID, + ParentGroupSlug: h.R.ParentGroup.Slug, + MemberGroupID: h.MemberGroupID, + MemberGroupSlug: h.R.MemberGroup.Slug, + ExpiresAt: h.ExpiresAt, + } + } + + c.JSON(http.StatusOK, hierarchies) +} + +// addMemberGroup adds a member group to a parent group +func (r *Router) addMemberGroup(c *gin.Context) { + parentGroupID := c.Param("id") + + req := struct { + ExpiresAt null.Time `json:"expires_at"` + MemberGroupID string `json:"member_group_id"` + }{} + + if err := c.BindJSON(&req); err != nil { + sendError(c, http.StatusBadRequest, "unable to bind request: "+err.Error()) + return + } + + parentGroup, err := models.FindGroup(c.Request.Context(), r.DB, parentGroupID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + sendError(c, http.StatusNotFound, "group not found: "+err.Error()) + return + } + + sendError(c, http.StatusInternalServerError, "error getting group"+err.Error()) + + return + } + + memberGroup, err := models.FindGroup(c.Request.Context(), r.DB, req.MemberGroupID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + sendError(c, http.StatusNotFound, "group not found: "+err.Error()) + return + } + + sendError(c, http.StatusInternalServerError, "error getting group"+err.Error()) + + return + } + + exists, err := models.GroupHierarchies( + qm.Where("parent_group_id = ?", parentGroup.ID), + qm.And("member_group_id = ?", memberGroup.ID), + ).Exists(c.Request.Context(), r.DB) + if err != nil { + sendError(c, http.StatusInternalServerError, "error checking group hierarchy exists: "+err.Error()) + return + } + + if exists { + sendError(c, http.StatusConflict, "group is already a member") + return + } + + createsCycle, err := dbtools.HierarchyWouldCreateCycle(c, r.DB.DB, parentGroup.ID, memberGroup.ID) + if err != nil { + sendError(c, http.StatusInternalServerError, "could not determine whether the desired hierarchy creates a cycle") + return + } + + if createsCycle { + sendError(c, http.StatusBadRequest, "invalid relationship: hierarchy would create a cycle") + return + } + + groupHierarchy := &models.GroupHierarchy{ + ParentGroupID: parentGroup.ID, + MemberGroupID: memberGroup.ID, + ExpiresAt: req.ExpiresAt, + } + + tx, err := r.DB.BeginTx(c.Request.Context(), nil) + if err != nil { + sendError(c, http.StatusBadRequest, "error starting add group hierarchy transaction: "+err.Error()) + return + } + + membershipsBefore, err := dbtools.GetAllGroupMemberships(c, r.DB.DB, false) + if err != nil { + msg := "failed to compute new effective memberships: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + if err := groupHierarchy.Insert(c.Request.Context(), r.DB, boil.Infer()); err != nil { + msg := "failed to update group hierarchy: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + event, err := dbtools.AuditGroupHierarchyCreated(c.Request.Context(), tx, getCtxAuditID(c), getCtxUser(c), groupHierarchy) + if err != nil { + msg := "error creating groups hierarchy (audit): " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + if err := updateContextWithAuditEventData(c, event); err != nil { + msg := "error creating groups hierarchy (audit): " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + membershipsAfter, err := dbtools.GetAllGroupMemberships(c, r.DB.DB, false) + if err != nil { + msg := "failed to compute new effective memberships: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + if err := tx.Commit(); err != nil { + msg := "error committing groups hierarchy, rolling back: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg = msg + "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + membersAdded := dbtools.FindMemberDiff(membershipsBefore, membershipsAfter) + + for _, enumeratedMembership := range membersAdded { + if err := r.EventBus.Publish(c.Request.Context(), events.GovernorMembersEventSubject, &events.Event{ + Version: events.Version, + Action: events.GovernorEventCreate, + AuditID: c.GetString(ginaudit.AuditIDContextKey), + GroupID: enumeratedMembership.GroupID, + UserID: enumeratedMembership.UserID, + ActorID: getCtxActorID(c), + }); err != nil { + sendError(c, http.StatusBadRequest, "failed to publish members create event, downstream changes may be delayed "+err.Error()) + return + } + } + + if err := r.EventBus.Publish(c.Request.Context(), events.GovernorHierarchiesEventSubject, &events.Event{ + Version: events.Version, + Action: events.GovernorEventCreate, + AuditID: c.GetString(ginaudit.AuditIDContextKey), + GroupID: parentGroupID, + ActorID: getCtxActorID(c), + }); err != nil { + sendError(c, http.StatusBadRequest, "failed to publish hierarchy create event, downstream changes may be delayed "+err.Error()) + return + } + + c.JSON(http.StatusNoContent, nil) +} + +// updateMemberGroup sets expiration on a group hierarchy +func (r *Router) updateMemberGroup(c *gin.Context) { + parentGroupID := c.Param("id") + memberGroupID := c.Param("member_id") + + hierarchy, err := models.GroupHierarchies( + qm.Where("parent_group_id = ?", parentGroupID), + qm.And("member_group_id = ?", memberGroupID), + ).One(c.Request.Context(), r.DB) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + sendError(c, http.StatusNotFound, "hierarchy not found: "+err.Error()) + return + } + + sendError(c, http.StatusInternalServerError, "error getting hierarchy"+err.Error()) + + return + } + + req := struct { + ExpiresAt null.Time `json:"expires_at"` + }{} + + if err := c.BindJSON(&req); err != nil { + sendError(c, http.StatusBadRequest, "unable to bind request: "+err.Error()) + return + } + + hierarchy.ExpiresAt = req.ExpiresAt + + tx, err := r.DB.BeginTx(c.Request.Context(), nil) + if err != nil { + sendError(c, http.StatusBadRequest, "error starting update hierarchy transaction: "+err.Error()) + return + } + + if _, err := hierarchy.Update(c.Request.Context(), r.DB, boil.Infer()); err != nil { + msg := "failed to update hierarchy: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + var event *models.AuditEvent + + event, err = dbtools.AuditGroupHierarchyUpdated(c.Request.Context(), tx, getCtxAuditID(c), getCtxUser(c), hierarchy) + if err != nil { + msg := "error creating hierarchy (audit): " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + if err := updateContextWithAuditEventData(c, event); err != nil { + msg := "error updating hierarchy (audit): " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + if err := tx.Commit(); err != nil { + msg := "error committing hierarchy update, rolling back: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg = msg + "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + if err := r.EventBus.Publish(c.Request.Context(), events.GovernorHierarchiesEventSubject, &events.Event{ + Version: events.Version, + Action: events.GovernorEventUpdate, + AuditID: c.GetString(ginaudit.AuditIDContextKey), + GroupID: hierarchy.ParentGroupID, + ActorID: getCtxActorID(c), + }); err != nil { + sendError(c, http.StatusBadRequest, "failed to publish hierarchy update event, downstream changes may be delayed "+err.Error()) + return + } + + c.JSON(http.StatusNoContent, nil) +} + +// removeGroupMember removes a user from a group +func (r *Router) removeMemberGroup(c *gin.Context) { + parentGroupID := c.Param("id") + memberGroupID := c.Param("member_id") + + hierarchy, err := models.GroupHierarchies( + qm.Where("parent_group_id = ?", parentGroupID), + qm.And("member_group_id = ?", memberGroupID), + ).One(c.Request.Context(), r.DB) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + sendError(c, http.StatusNotFound, "hierarchy not found: "+err.Error()) + return + } + + sendError(c, http.StatusInternalServerError, "error getting hierarchy"+err.Error()) + + return + } + + tx, err := r.DB.BeginTx(c.Request.Context(), nil) + if err != nil { + sendError(c, http.StatusBadRequest, "error starting delete group hierarchy transaction: "+err.Error()) + return + } + + membershipsBefore, err := dbtools.GetAllGroupMemberships(c, r.DB.DB, false) + if err != nil { + msg := "failed to compute new effective memberships: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + if _, err := hierarchy.Delete(c.Request.Context(), r.DB); err != nil { + msg := "error removing hierarchy: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + event, err := dbtools.AuditGroupHierarchyDeleted(c.Request.Context(), tx, getCtxAuditID(c), getCtxUser(c), hierarchy) + if err != nil { + msg := "error deleting groups hierarchy (audit): " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + if err := updateContextWithAuditEventData(c, event); err != nil { + msg := "error deleting group hierarchy (audit): " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + membershipsAfter, err := dbtools.GetAllGroupMemberships(c, r.DB.DB, false) + if err != nil { + msg := "failed to compute new effective memberships: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + if err := tx.Commit(); err != nil { + msg := "error committing hierarchy delete, rolling back: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg = msg + "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + + membersAdded := dbtools.FindMemberDiff(membershipsAfter, membershipsBefore) + + for _, enumeratedMembership := range membersAdded { + if err := r.EventBus.Publish(c.Request.Context(), events.GovernorMembersEventSubject, &events.Event{ + Version: events.Version, + Action: events.GovernorEventDelete, + AuditID: c.GetString(ginaudit.AuditIDContextKey), + GroupID: enumeratedMembership.GroupID, + UserID: enumeratedMembership.UserID, + ActorID: getCtxActorID(c), + }); err != nil { + sendError(c, http.StatusBadRequest, "failed to publish members delete event, downstream changes may be delayed "+err.Error()) + return + } + } + + if err := r.EventBus.Publish(c.Request.Context(), events.GovernorHierarchiesEventSubject, &events.Event{ + Version: events.Version, + Action: events.GovernorEventDelete, + AuditID: c.GetString(ginaudit.AuditIDContextKey), + GroupID: parentGroupID, + ActorID: getCtxActorID(c), + }); err != nil { + sendError(c, http.StatusBadRequest, "failed to publish hierarchy delete event, downstream changes may be delayed "+err.Error()) + return + } + + c.JSON(http.StatusNoContent, nil) +} + +// getGroupHierarchiesAll returns all group hierarchies for all groups +func (r *Router) getGroupHierarchiesAll(c *gin.Context) { + queryMods := []qm.QueryMod{ + qm.Load("ParentGroup"), + qm.Load("MemberGroup"), + } + + hierarchies, err := models.GroupHierarchies(queryMods...).All(c.Request.Context(), r.DB) + if err != nil { + sendError(c, http.StatusInternalServerError, "error getting group hierarchies: "+err.Error()) + + return + } + + hierarchiesResponse := make([]GroupHierarchy, len(hierarchies)) + for i, h := range hierarchies { + hierarchiesResponse[i] = GroupHierarchy{ + ID: h.ID, + ParentGroupID: h.ParentGroupID, + ParentGroupSlug: h.R.ParentGroup.Slug, + MemberGroupID: h.MemberGroupID, + MemberGroupSlug: h.R.MemberGroup.Slug, + ExpiresAt: h.ExpiresAt, + } + } + + c.JSON(http.StatusOK, hierarchiesResponse) +} diff --git a/pkg/api/v1alpha1/group_membership.go b/pkg/api/v1alpha1/group_membership.go index fbe82dd..71d499b 100644 --- a/pkg/api/v1alpha1/group_membership.go +++ b/pkg/api/v1alpha1/group_membership.go @@ -182,6 +182,19 @@ func (r *Router) addGroupMember(c *gin.Context) { return } + membershipsBefore, err := dbtools.GetMembershipsForUser(c, r.DB.DB, user.ID, false) + if err != nil { + msg := "failed to compute new effective memberships: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + if err := groupMem.Insert(c.Request.Context(), r.DB, boil.Infer()); err != nil { msg := "failed to update group membership: " + err.Error() @@ -219,6 +232,19 @@ func (r *Router) addGroupMember(c *gin.Context) { return } + membershipsAfter, err := dbtools.GetMembershipsForUser(c, r.DB.DB, user.ID, false) + if err != nil { + msg := "failed to compute new effective memberships: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + if err := tx.Commit(); err != nil { msg := "error committing groups membership, rolling back: " + err.Error() @@ -237,16 +263,20 @@ func (r *Router) addGroupMember(c *gin.Context) { return } - if err := r.EventBus.Publish(c.Request.Context(), events.GovernorMembersEventSubject, &events.Event{ - Version: events.Version, - Action: events.GovernorEventCreate, - AuditID: c.GetString(ginaudit.AuditIDContextKey), - GroupID: group.ID, - UserID: user.ID, - ActorID: getCtxActorID(c), - }); err != nil { - sendError(c, http.StatusBadRequest, "failed to publish members create event, downstream changes may be delayed "+err.Error()) - return + groupsDiff := dbtools.FindMemberDiff(membershipsBefore, membershipsAfter) + + for _, enumeratedMembership := range groupsDiff { + if err := r.EventBus.Publish(c.Request.Context(), events.GovernorMembersEventSubject, &events.Event{ + Version: events.Version, + Action: events.GovernorEventCreate, + AuditID: c.GetString(ginaudit.AuditIDContextKey), + GroupID: enumeratedMembership.GroupID, + UserID: enumeratedMembership.UserID, + ActorID: getCtxActorID(c), + }); err != nil { + sendError(c, http.StatusBadRequest, "failed to publish members create event, downstream changes may be delayed "+err.Error()) + return + } } c.JSON(http.StatusNoContent, nil) @@ -488,6 +518,19 @@ func (r *Router) removeGroupMember(c *gin.Context) { return } + membershipsBefore, err := dbtools.GetMembershipsForUser(c, r.DB.DB, user.ID, false) + if err != nil { + msg := "failed to compute new effective memberships: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + if _, err := membership.Delete(c.Request.Context(), r.DB); err != nil { msg := "error removing membership: " + err.Error() @@ -525,6 +568,19 @@ func (r *Router) removeGroupMember(c *gin.Context) { return } + membershipsAfter, err := dbtools.GetMembershipsForUser(c, r.DB.DB, user.ID, false) + if err != nil { + msg := "failed to compute new effective memberships: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + if err := tx.Commit(); err != nil { msg := "error committing membership delete, rolling back: " + err.Error() @@ -543,16 +599,20 @@ func (r *Router) removeGroupMember(c *gin.Context) { return } - if err := r.EventBus.Publish(c.Request.Context(), events.GovernorMembersEventSubject, &events.Event{ - Version: events.Version, - Action: events.GovernorEventDelete, - AuditID: c.GetString(ginaudit.AuditIDContextKey), - GroupID: group.ID, - UserID: user.ID, - ActorID: getCtxActorID(c), - }); err != nil { - sendError(c, http.StatusBadRequest, "failed to publish members delete event, downstream changes may be delayed "+err.Error()) - return + groupsDiff := dbtools.FindMemberDiff(membershipsAfter, membershipsBefore) + + for _, enumeratedMembership := range groupsDiff { + if err := r.EventBus.Publish(c.Request.Context(), events.GovernorMembersEventSubject, &events.Event{ + Version: events.Version, + Action: events.GovernorEventDelete, + AuditID: c.GetString(ginaudit.AuditIDContextKey), + GroupID: enumeratedMembership.GroupID, + UserID: enumeratedMembership.UserID, + ActorID: getCtxActorID(c), + }); err != nil { + sendError(c, http.StatusBadRequest, "failed to publish members delete event, downstream changes may be delayed "+err.Error()) + return + } } c.JSON(http.StatusNoContent, nil) @@ -968,6 +1028,19 @@ func (r *Router) processGroupRequest(c *gin.Context) { return } + membershipsBefore, err := dbtools.GetMembershipsForUser(c, r.DB.DB, user.ID, false) + if err != nil { + msg := "failed to compute new effective memberships: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + if err := groupMem.Insert(c.Request.Context(), tx, boil.Infer()); err != nil { msg := "error approving group membership request , rolling back: " + err.Error() @@ -1017,6 +1090,19 @@ func (r *Router) processGroupRequest(c *gin.Context) { return } + membershipsAfter, err := dbtools.GetMembershipsForUser(c, r.DB.DB, user.ID, false) + if err != nil { + msg := "failed to compute new effective memberships: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } + if err := tx.Commit(); err != nil { msg := "error committing group membership approval, rolling back: " + err.Error() @@ -1047,16 +1133,20 @@ func (r *Router) processGroupRequest(c *gin.Context) { return } - if err := r.EventBus.Publish(c.Request.Context(), events.GovernorMembersEventSubject, &events.Event{ - Version: events.Version, - Action: events.GovernorEventCreate, - AuditID: c.GetString(ginaudit.AuditIDContextKey), - GroupID: groupMem.GroupID, - UserID: groupMem.UserID, - ActorID: getCtxActorID(c), - }); err != nil { - sendError(c, http.StatusBadRequest, "failed to publish members create event, downstream changes may be delayed "+err.Error()) - return + groupsDiff := dbtools.FindMemberDiff(membershipsBefore, membershipsAfter) + + for _, enumeratedMembership := range groupsDiff { + if err := r.EventBus.Publish(c.Request.Context(), events.GovernorMembersEventSubject, &events.Event{ + Version: events.Version, + Action: events.GovernorEventCreate, + AuditID: c.GetString(ginaudit.AuditIDContextKey), + GroupID: enumeratedMembership.GroupID, + UserID: enumeratedMembership.UserID, + ActorID: getCtxActorID(c), + }); err != nil { + sendError(c, http.StatusBadRequest, "failed to publish members create event, downstream changes may be delayed "+err.Error()) + return + } } c.JSON(http.StatusNoContent, nil) diff --git a/pkg/api/v1alpha1/router.go b/pkg/api/v1alpha1/router.go index 3acca60..70755c6 100644 --- a/pkg/api/v1alpha1/router.go +++ b/pkg/api/v1alpha1/router.go @@ -148,6 +148,13 @@ func (r *Router) Routes(rg *gin.RouterGroup) { r.getGroupMembershipsAll, ) + rg.GET( + "/groups/hierarchies", + r.AuditMW.AuditWithType("GetGroupHierarchiesAll"), + r.AuthMW.AuthRequired(readScopesWithOpenID("governor:groups")), + r.getGroupHierarchiesAll, + ) + rg.GET( "/groups/:id", r.AuditMW.AuditWithType("GetGroup"), @@ -304,6 +311,37 @@ func (r *Router) Routes(rg *gin.RouterGroup) { r.removeGroupOrganization, ) + rg.GET( + "/groups/:id/hierarchies", + r.AuditMW.AuditWithType("GetGroupHierarchies"), + r.AuthMW.AuthRequired(readScopesWithOpenID("governor:groups")), + r.listMemberGroups, + ) + + rg.POST( + "/groups/:id/hierarchies", + r.AuditMW.AuditWithType("CreateGroupHierarchy"), + r.AuthMW.AuthRequired(readScopesWithOpenID("governor:groups")), + r.mwGroupAuthRequired(AuthRoleGroupAdmin), + r.addMemberGroup, + ) + + rg.PATCH( + "/groups/:id/hierarchies/:member_id", + r.AuditMW.AuditWithType("UpdateGroupHierarchy"), + r.AuthMW.AuthRequired(readScopesWithOpenID("governor:groups")), + r.mwGroupAuthRequired(AuthRoleGroupAdmin), + r.updateMemberGroup, + ) + + rg.DELETE( + "/groups/:id/hierarchies/:member_id", + r.AuditMW.AuditWithType("DeleteGroupHierarchy"), + r.AuthMW.AuthRequired(readScopesWithOpenID("governor:groups")), + r.mwGroupAuthRequired(AuthRoleGroupAdmin), + r.removeMemberGroup, + ) + rg.GET( "/events", r.AuditMW.AuditWithType("ListEvents"), diff --git a/pkg/events/v1alpha1/events.go b/pkg/events/v1alpha1/events.go index e4d7bc2..b560756 100644 --- a/pkg/events/v1alpha1/events.go +++ b/pkg/events/v1alpha1/events.go @@ -25,6 +25,8 @@ const ( GovernorMembersEventSubject = "members" // GovernorMemberRequestsEventSubject is the subject name for member request events (minus the subject prefix) GovernorMemberRequestsEventSubject = "members.requests" + // GovernorHierarchiesEventSubject is the subject name for group hierarchy events (minus the subject prefix) + GovernorHierarchiesEventSubject = "hierarchies" // GovernorApplicationsEventSubject is the subject name for application events (minus the subject prefix) GovernorApplicationsEventSubject = "apps" // GovernorApplicationLinksEventSubject is the subject name for application link events (minus the subject prefix)