diff --git a/internal/ent/generated/group/group.go b/internal/ent/generated/group/group.go index c01b2e1b..eb7713c7 100644 --- a/internal/ent/generated/group/group.go +++ b/internal/ent/generated/group/group.go @@ -444,7 +444,7 @@ func ValidColumn(column string) bool { // // import _ "github.com/theopenlane/core/internal/ent/generated/runtime" var ( - Hooks [6]ent.Hook + Hooks [7]ent.Hook Interceptors [3]ent.Interceptor Policy ent.Policy // DefaultCreatedAt holds the default value on creation for the "created_at" field. diff --git a/internal/ent/generated/runtime/runtime.go b/internal/ent/generated/runtime/runtime.go index 0151e92f..bb74b19c 100644 --- a/internal/ent/generated/runtime/runtime.go +++ b/internal/ent/generated/runtime/runtime.go @@ -1196,6 +1196,8 @@ func init() { group.Hooks[4] = groupHooks[0] group.Hooks[5] = groupHooks[1] + + group.Hooks[6] = groupHooks[2] groupMixinInters1 := groupMixin[1].Interceptors() groupMixinInters4 := groupMixin[4].Interceptors() groupInters := schema.Group{}.Interceptors() diff --git a/internal/ent/hooks/errors.go b/internal/ent/hooks/errors.go index 1fc6df8f..b7e30c5d 100644 --- a/internal/ent/hooks/errors.go +++ b/internal/ent/hooks/errors.go @@ -42,6 +42,8 @@ var ( ErrNoControls = errors.New("subcontrol must have at least one control assigned") // ErrUnableToCast is returned when a type assertion fails ErrUnableToCast = errors.New("unable to cast") + // ErrManagedGroup is returned when a user attempts to modify a managed group + ErrManagedGroup = errors.New("managed groups cannot be modified") ) // IsUniqueConstraintError reports if the error resulted from a DB uniqueness constraint violation. diff --git a/internal/ent/hooks/group.go b/internal/ent/hooks/group.go index fc350d12..2915da2e 100644 --- a/internal/ent/hooks/group.go +++ b/internal/ent/hooks/group.go @@ -9,6 +9,7 @@ import ( "github.com/theopenlane/entx" "github.com/theopenlane/iam/auth" "github.com/theopenlane/iam/fgax" + "github.com/theopenlane/utils/contextx" "github.com/theopenlane/utils/gravatar" "github.com/theopenlane/core/internal/ent/generated" @@ -53,6 +54,34 @@ func HookGroup() ent.Hook { }, ent.OpCreate|ent.OpUpdateOne) } +// HookManagedGroups runs on group mutations to prevent updates to managed groups +func HookManagedGroups() ent.Hook { + return func(next ent.Mutator) ent.Mutator { + return hook.GroupFunc(func(ctx context.Context, m *generated.GroupMutation) (ent.Value, error) { + if m.Op().Is(ent.OpCreate) { + return next.Mutate(ctx, m) + } + + groupID, ok := m.ID() + if ok && groupID != "" { + group, err := m.Client().Group.Get(ctx, groupID) + if err != nil { + log.Error().Err(err).Msg("failed to get group") + + return nil, err + } + + _, allowCtx := contextx.From[ManagedContextKey](ctx) + if group.IsManaged && !allowCtx { + return nil, ErrManagedGroup + } + } + + return next.Mutate(ctx, m) + }) + } +} + // HookGroupAuthz runs on group mutations to setup or remove relationship tuples func HookGroupAuthz() ent.Hook { return func(next ent.Mutator) ent.Mutator { @@ -124,6 +153,7 @@ func createGroupMember(ctx context.Context, gID string, m *generated.GroupMutati managed, _ := m.IsManaged() role := enums.RoleAdmin + if managed { name, _ := m.Name() // do not add the owner to the Members group diff --git a/internal/ent/hooks/groupmembers.go b/internal/ent/hooks/groupmembers.go index cd030676..6354f5de 100644 --- a/internal/ent/hooks/groupmembers.go +++ b/internal/ent/hooks/groupmembers.go @@ -5,6 +5,8 @@ import ( "entgo.io/ent" + "github.com/theopenlane/utils/contextx" + "github.com/theopenlane/core/internal/ent/generated" "github.com/theopenlane/core/internal/ent/generated/hook" "github.com/theopenlane/core/internal/ent/generated/orgmembership" @@ -30,6 +32,11 @@ func HookGroupMembers() ent.Hook { return next.Mutate(ctx, m) } + _, allowCtx := contextx.From[ManagedContextKey](ctx) + if group.IsManaged && !allowCtx { + return nil, ErrManagedGroup + } + // ensure user is a member of the organization exists, err := m.Client().OrgMembership.Query(). Where(orgmembership.UserID(userID)). diff --git a/internal/ent/hooks/managedgroups.go b/internal/ent/hooks/managedgroups.go new file mode 100644 index 00000000..5c48e17a --- /dev/null +++ b/internal/ent/hooks/managedgroups.go @@ -0,0 +1,222 @@ +package hooks + +import ( + "context" + + "entgo.io/ent" + "github.com/rs/zerolog/log" + "github.com/theopenlane/entx" + "github.com/theopenlane/utils/contextx" + + "github.com/theopenlane/core/internal/ent/generated" + "github.com/theopenlane/core/internal/ent/generated/group" + "github.com/theopenlane/core/internal/ent/generated/groupmembership" + "github.com/theopenlane/core/pkg/enums" +) + +// ManagedContextKey is the key name managed group updates +type ManagedContextKey struct{} + +const ( + // AdminsGroup is the group name for all organization admins and owner, these users have full read and write access in the organization + AdminsGroup = "Admins" + // ViewersGroup is the group name for all organization members that only have view access in the organization + ViewersGroup = "Viewers" + // AllMembersGroup is the group name for all members of the organization, no matter their role + AllMembersGroup = "All Members" +) + +// defaultGroups are the default groups created for an organization that are managed by the system +var defaultGroups = map[string]string{ + AdminsGroup: "Openlane managed group containing all organization admins with full access", + ViewersGroup: "Openlane managed group containing all organization members with only view access", + AllMembersGroup: "Openlane managed group containing all members of the organization", +} + +// generateOrganizationGroups creates the default groups for an organization that are managed by Openlane +// this includes the Admins, Viewers, and All Members groups where users are automatically added based on their role +func generateOrganizationGroups(ctx context.Context, m *generated.OrganizationMutation, orgID string) error { + // skip group creation for personal orgs + if isPersonal, _ := m.PersonalOrg(); isPersonal { + log.Debug().Msg("skipping group creation for personal org") + + return nil + } + + builders := make([]*generated.GroupCreate, 0, len(defaultGroups)) + + for name, desc := range defaultGroups { + groupInput := generated.CreateGroupInput{ + Name: name, + Description: &desc, + } + + builders = append(builders, m.Client().Group.Create(). + SetInput(groupInput). + SetIsManaged(true). + SetOwnerID(orgID), + ) + } + + if err := m.Client().Group.CreateBulk(builders...).Exec(ctx); err != nil { + log.Error().Err(err).Msg("error creating system managed groups") + + return err + } + + log.Debug().Str("organization", orgID).Msg("created system managed groups") + + return nil +} + +// updateManagedGroupMembers groups adds or removes the org members to the managed system groups +func updateManagedGroupMembers(ctx context.Context, m *generated.OrgMembershipMutation) error { + // set a context key to indicate that this is a managed group update + // and allowed to skip the check for managed groups + managedCtx := contextx.With(ctx, ManagedContextKey{}) + + op := m.Op() + + switch op { + case ent.OpCreate: + return addToManagedGroups(managedCtx, m) + case ent.OpDelete, ent.OpDeleteOne: + return removeFromManagedGroups(managedCtx, m) + case ent.OpUpdate, ent.OpUpdateOne: + if entx.CheckIsSoftDelete(managedCtx) { + return removeFromManagedGroups(managedCtx, m) + } + + return updateManagedGroups(managedCtx, m) + } + + return nil +} + +// updateManagedGroups updates the managed groups based on the role of the user for update requests +func updateManagedGroups(ctx context.Context, m *generated.OrgMembershipMutation) error { + role, ok := m.Role() + if !ok { + role = enums.RoleMember + } + + oldRole, _ := m.OldRole(ctx) + + if oldRole != role { + if err := removeFromManagedGroups(ctx, m); err != nil { + return err + } + } + + return addToManagedGroups(ctx, m) +} + +// addToManagedGroups adds the user to the system managed groups based on their role on creation or update +func addToManagedGroups(ctx context.Context, m *generated.OrgMembershipMutation) error { + role, ok := m.Role() + if !ok { + role = enums.RoleMember + } + + userID, _ := m.UserID() + + switch role { + case enums.RoleMember: + if err := addMemberToManagedGroup(ctx, m, userID, ViewersGroup); err != nil { + return err + } + case enums.RoleAdmin: + if err := addMemberToManagedGroup(ctx, m, userID, AdminsGroup); err != nil { + return err + } + } + + // add all users to the all users group + if m.Op() == ent.OpCreate { + return addMemberToManagedGroup(ctx, m, userID, AllMembersGroup) + } + + return nil +} + +// removeFromManagedGroups removes the user from the system managed groups when they are removed from the organization or their role changes +func removeFromManagedGroups(ctx context.Context, m *generated.OrgMembershipMutation) error { + role, ok := m.Role() + if !ok { + role = enums.RoleMember + } + + userID, _ := m.UserID() + + switch role { + case enums.RoleMember: + if err := removeMemberFromManagedGroup(ctx, m, userID, ViewersGroup); err != nil { + return err + } + case enums.RoleAdmin: + if err := removeMemberFromManagedGroup(ctx, m, userID, AdminsGroup); err != nil { + return err + } + } + + // remove from the all users group if they are removed from the organization + if entx.CheckIsSoftDelete(ctx) { + return removeMemberFromManagedGroup(ctx, m, userID, AllMembersGroup) + } + + return nil +} + +// addMemberToManagedGroup adds the user to the system managed groups +func addMemberToManagedGroup(ctx context.Context, m *generated.OrgMembershipMutation, userID, groupName string) error { + // get the group to update + groupID, err := m.Client().Group.Query().Where( + group.IsManaged(true), // grab the managed group + group.Name(groupName), + ).Only(ctx) + if err != nil { + log.Error().Err(err).Msgf("error getting managed group: %s", groupName) + return err + } + + input := generated.CreateGroupMembershipInput{ + Role: &enums.RoleMember, + UserID: userID, + GroupID: groupID.ID, + } + + if err := m.Client().GroupMembership.Create().SetInput(input).Exec(ctx); err != nil { + log.Error().Err(err).Msg("error adding user to managed group") + return err + } + + log.Debug().Str("user_id", userID).Str("group", groupName).Msg("user added to managed group") + + return nil +} + +func removeMemberFromManagedGroup(ctx context.Context, m *generated.OrgMembershipMutation, userID, groupName string) error { + // get the group to update + group, err := m.Client().Group.Query().Where( + group.IsManaged(true), // grab the managed group + group.Name(groupName), + ).Only(ctx) + if err != nil { + log.Error().Err(err).Msgf("error getting managed group: %s", groupName) + return err + } + + if _, err := m.Client().GroupMembership.Delete().Where( + groupmembership.ID(group.ID), + groupmembership.UserID(userID), + groupmembership.RoleEQ(enums.RoleMember), + ).Exec(ctx); err != nil { + log.Error().Err(err).Msg("error removing user from managed group") + + return err + } + + log.Debug().Str("user_id", userID).Str("group", groupName).Msg("user removed from managed group") + + return nil +} diff --git a/internal/ent/hooks/organization.go b/internal/ent/hooks/organization.go index b03d7415..252cf219 100644 --- a/internal/ent/hooks/organization.go +++ b/internal/ent/hooks/organization.go @@ -463,52 +463,3 @@ func updateDefaultOrgIfPersonal(ctx context.Context, userID, orgID string, clien return nil } - -const ( - AdminsGroup = "Admins" - ViewersGroup = "Viewers" - AllMembersGroup = "All Members" -) - -// defaultGroups are the default groups created for an organization -var defaultGroups = map[string]string{ - AdminsGroup: "Openlane managed group containing all organization admins with full access", - ViewersGroup: "Openlane managed group containing all organization members with only view access", - AllMembersGroup: "Openlane managed group containing all members of the organization", -} - -// generateOrganizationGroups creates the default groups for an organization that are managed by Openlane -// this includes the Admins, Members, and All Users groups where users are automatically added based on their role -func generateOrganizationGroups(ctx context.Context, m *generated.OrganizationMutation, orgID string) error { - // skip group creation for personal orgs - if isPersonal, _ := m.PersonalOrg(); isPersonal { - log.Debug().Msg("skipping group creation for personal org") - - return nil - } - - builders := make([]*generated.GroupCreate, 0, len(defaultGroups)) - - for name, desc := range defaultGroups { - groupInput := generated.CreateGroupInput{ - Name: name, - Description: &desc, - } - - builders = append(builders, m.Client().Group.Create(). - SetInput(groupInput). - SetIsManaged(true). - SetOwnerID(orgID), - ) - } - - if err := m.Client().Group.CreateBulk(builders...).Exec(ctx); err != nil { - log.Error().Err(err).Msg("error creating system managed groups") - - return err - } - - log.Debug().Str("organization", orgID).Msg("created system managed groups") - - return nil -} diff --git a/internal/ent/hooks/orgmembers.go b/internal/ent/hooks/orgmembers.go index ee063318..e47cbe16 100644 --- a/internal/ent/hooks/orgmembers.go +++ b/internal/ent/hooks/orgmembers.go @@ -8,12 +8,9 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/rs/zerolog/log" - "github.com/theopenlane/entx" "github.com/theopenlane/iam/auth" "github.com/theopenlane/core/internal/ent/generated" - "github.com/theopenlane/core/internal/ent/generated/group" - "github.com/theopenlane/core/internal/ent/generated/groupmembership" "github.com/theopenlane/core/internal/ent/generated/hook" "github.com/theopenlane/core/internal/ent/generated/privacy" "github.com/theopenlane/core/pkg/enums" @@ -59,7 +56,7 @@ func HookOrgMembers() ent.Hook { return nil, err } - // TODO: confirm this is working with logs + tests + // update the managed group members when members are added if err := updateManagedGroupMembers(ctx, m); err != nil { return nil, err } @@ -130,151 +127,3 @@ func updateOrgMemberDefaultOrgOnCreate(ctx context.Context, m *generated.OrgMemb return updateDefaultOrgIfPersonal(allowCtx, userID, orgID, m.Client()) } - -// updateManagedGroupMembers groups adds or removes the org members to the managed system groups -func updateManagedGroupMembers(ctx context.Context, m *generated.OrgMembershipMutation) error { - op := m.Op() - - switch op { - case ent.OpCreate: - return addToManagedGroups(ctx, m) - case ent.OpDelete, ent.OpDeleteOne: - return removeFromManagedGroups(ctx, m) - case ent.OpUpdate, ent.OpUpdateOne: - if entx.CheckIsSoftDelete(ctx) { - return removeFromManagedGroups(ctx, m) - } - - return updateManagedGroups(ctx, m) - } - - return nil -} - -// updateManagedGroups updates the managed groups based on the role of the user for update requests -func updateManagedGroups(ctx context.Context, m *generated.OrgMembershipMutation) error { - role, ok := m.Role() - if !ok { - role = enums.RoleMember - } - - oldRole, _ := m.OldRole(ctx) - - if oldRole != role { - if err := removeFromManagedGroups(ctx, m); err != nil { - return err - } - } - - return addToManagedGroups(ctx, m) -} - -// addToManagedGroups adds the user to the system managed groups based on their role on creation or update -func addToManagedGroups(ctx context.Context, m *generated.OrgMembershipMutation) error { - role, ok := m.Role() - if !ok { - role = enums.RoleMember - } - - userID, _ := m.UserID() - - switch role { - case enums.RoleMember: - if err := addMemberToManagedGroup(ctx, m, userID, ViewersGroup); err != nil { - return err - } - case enums.RoleAdmin: - if err := addMemberToManagedGroup(ctx, m, userID, AdminsGroup); err != nil { - return err - } - } - - // add all users to the all users group - if m.Op() == ent.OpCreate { - return addMemberToManagedGroup(ctx, m, userID, AllMembersGroup) - } - - return nil -} - -// removeFromManagedGroups removes the user from the system managed groups when they are removed from the organization or their role changes -func removeFromManagedGroups(ctx context.Context, m *generated.OrgMembershipMutation) error { - role, ok := m.Role() - if !ok { - role = enums.RoleMember - } - - userID, _ := m.UserID() - - switch role { - case enums.RoleMember: - if err := removeMemberFromManagedGroup(ctx, m, userID, ViewersGroup); err != nil { - return err - } - case enums.RoleAdmin: - if err := removeMemberFromManagedGroup(ctx, m, userID, AdminsGroup); err != nil { - return err - } - } - - // remove from the all users group if they are removed from the organization - if entx.CheckIsSoftDelete(ctx) { - return removeMemberFromManagedGroup(ctx, m, userID, AllMembersGroup) - } - - return nil -} - -// addMemberToManagedGroup adds the user to the system managed groups -func addMemberToManagedGroup(ctx context.Context, m *generated.OrgMembershipMutation, userID, groupName string) error { - // get the group to update - groupID, err := m.Client().Group.Query().Where( - group.IsManaged(true), // grab the managed group - group.Name(groupName), - ).Only(ctx) - if err != nil { - log.Error().Err(err).Msgf("error getting managed group: %s", groupName) - return err - } - - input := generated.CreateGroupMembershipInput{ - Role: &enums.RoleMember, - UserID: userID, - GroupID: groupID.ID, - } - - if err := m.Client().GroupMembership.Create().SetInput(input).Exec(ctx); err != nil { - log.Error().Err(err).Msg("error adding user to managed group") - return err - } - - log.Debug().Str("user_id", userID).Str("group", groupName).Msg("user added to managed group") - - return nil -} - -func removeMemberFromManagedGroup(ctx context.Context, m *generated.OrgMembershipMutation, userID, groupName string) error { - // get the group to update - groupID, err := m.Client().Group.Query().Where( - group.IsManaged(true), // grab the managed group - group.Name(groupName), - ).OnlyID(ctx) - if err != nil { - log.Error().Err(err).Msgf("error getting managed group: %s", groupName) - return err - } - - if _, err := m.Client().GroupMembership.Delete().Where( - groupmembership.ID(groupID), - groupmembership.UserID(userID), - groupmembership.RoleEQ(enums.RoleMember), - ).Exec(ctx); err != nil { - log.Error().Err(err).Msg("error removing user from managed group") - - return err - } - - log.Debug().Str("user_id", userID).Str("group", groupName).Msg("user removed from managed group") - - return nil -} diff --git a/internal/ent/schema/group.go b/internal/ent/schema/group.go index 5804aa60..66160112 100644 --- a/internal/ent/schema/group.go +++ b/internal/ent/schema/group.go @@ -185,6 +185,7 @@ func (Group) Hooks() []ent.Hook { return []ent.Hook{ hooks.HookGroupAuthz(), hooks.HookGroup(), + hooks.HookManagedGroups(), } } diff --git a/internal/graphapi/organization_test.go b/internal/graphapi/organization_test.go index 852b73d9..4fb98501 100644 --- a/internal/graphapi/organization_test.go +++ b/internal/graphapi/organization_test.go @@ -7,6 +7,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/brianvoe/gofakeit/v7" + "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/theopenlane/entx" @@ -340,6 +341,14 @@ func (suite *GraphTestSuite) TestMutationCreateOrganization() { assert.Equal(t, "vendor", et.EntityTypes.Edges[0].Node.Name) assert.Equal(t, resp.CreateOrganization.Organization.ID, *et.EntityTypes.Edges[0].Node.OwnerID) + // ensure managed groups are created + managedGroups, err := suite.client.api.GetGroups(newCtx, &openlaneclient.GroupWhereInput{ + IsManaged: lo.ToPtr(true), + }) + + // admins, viewers, all users should be created + require.Len(t, managedGroups.Groups.Edges, 3) + // cleanup org (&OrganizationCleanup{client: suite.client, ID: resp.CreateOrganization.Organization.ID}).MustDelete(testUser1.UserCtx, t) }) diff --git a/pkg/openlaneclient/models.go b/pkg/openlaneclient/models.go index 2e552cb8..e9af323c 100644 --- a/pkg/openlaneclient/models.go +++ b/pkg/openlaneclient/models.go @@ -3309,6 +3309,8 @@ type CreateGroupInput struct { Name string `json:"name"` // the groups description Description *string `json:"description,omitempty"` + // whether the group is managed by the system + IsManaged *bool `json:"isManaged,omitempty"` // the URL to an auto generated gravatar image for the group GravatarLogoURL *string `json:"gravatarLogoURL,omitempty"` // the URL to an image uploaded by the customer for the groups avatar image @@ -18909,6 +18911,9 @@ type UpdateGroupInput struct { // the groups description Description *string `json:"description,omitempty"` ClearDescription *bool `json:"clearDescription,omitempty"` + // whether the group is managed by the system + IsManaged *bool `json:"isManaged,omitempty"` + ClearIsManaged *bool `json:"clearIsManaged,omitempty"` // the URL to an auto generated gravatar image for the group GravatarLogoURL *string `json:"gravatarLogoURL,omitempty"` ClearGravatarLogoURL *bool `json:"clearGravatarLogoURL,omitempty"`