Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto admin-app compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "6de0e105ca8f1cb3719d35970bf54af1019f0048"
PROTON_COMMIT := "0c494253d5e417797d5226d27879bbe49a744bc7"

admin-app:
@echo " > generating admin build"
Expand Down
10 changes: 6 additions & 4 deletions core/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ const (
ServiceUserCreatedEvent EventName = "app.serviceuser.created"
ServiceUserDeletedEvent EventName = "app.serviceuser.deleted"

GroupCreatedEvent EventName = "app.group.created"
GroupUpdatedEvent EventName = "app.group.updated"
GroupDeletedEvent EventName = "app.group.deleted"
GroupMemberRemovedEvent EventName = "app.group.members.removed"
GroupCreatedEvent EventName = "app.group.created"
GroupUpdatedEvent EventName = "app.group.updated"
GroupDeletedEvent EventName = "app.group.deleted"
GroupMemberCreatedEvent EventName = "app.group.member.created"
GroupMemberRoleChangedEvent EventName = "app.group.member.role_changed"
GroupMemberRemovedEvent EventName = "app.group.members.removed"

RoleCreatedEvent EventName = "app.role.created"
RoleUpdatedEvent EventName = "app.role.updated"
Expand Down
2 changes: 2 additions & 0 deletions core/membership/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ var (
ErrNotOrgMember = errors.New("principal is not a member of the organization")
ErrInvalidProjectRole = errors.New("role is not valid for project scope")
ErrInvalidResourceType = errors.New("unsupported resource type")
ErrInvalidGroupRole = errors.New("role is not valid for group scope")
ErrLastGroupOwnerRole = errors.New("cannot change role: this is the last owner of the group")
)
314 changes: 314 additions & 0 deletions core/membership/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1019,3 +1019,317 @@ func (s *Service) ListPrincipalsByResource(ctx context.Context, resourceID, reso

return members, nil
}

// AddGroupMember adds a principal as a member of a group with an explicit role.
// Returns ErrAlreadyMember if the principal already has a policy on this group.
// The principal must be a member of the group's parent organization.
func (s *Service) AddGroupMember(ctx context.Context, groupID, principalID, principalType, roleID string) error {
grp, err := s.groupService.Get(ctx, groupID)
if err != nil {
return err
}

principal, err := s.validateGroupPrincipal(ctx, principalID, principalType)
if err != nil {
return err
}

fetchedRole, err := s.validateGroupRole(ctx, roleID, grp.OrganizationID)
if err != nil {
return err
}

if err := s.validateOrgMembership(ctx, grp.OrganizationID, principalID, principalType); err != nil {
return err
}

existing, err := s.policyService.List(ctx, policy.Filter{
GroupID: groupID,
PrincipalID: principalID,
PrincipalType: principalType,
})
if err != nil {
return fmt.Errorf("list existing policies: %w", err)
}
if len(existing) > 0 {
return ErrAlreadyMember
}

createdPolicy, err := s.createPolicy(ctx, groupID, schema.GroupNamespace, principalID, principalType, fetchedRole.ID)
if err != nil {
return err
}

relationName := groupRoleToRelation(fetchedRole)
if err := s.createRelation(ctx, groupID, schema.GroupNamespace, principalID, principalType, relationName); err != nil {
if deleteErr := s.policyService.Delete(ctx, createdPolicy.ID); deleteErr != nil {
s.log.WarnContext(ctx, "orphaned policy: relation creation failed and policy cleanup also failed",
"policy_id", createdPolicy.ID,
"group_id", groupID,
"principal_id", principalID,
"policy_delete_error", deleteErr,
)
}
return err
}

s.auditGroupMemberAdded(ctx, grp, principal, fetchedRole.ID)
return nil
}

// SetGroupMemberRole changes an existing member's role in a group.
// Returns ErrNotMember if the principal has no existing policy on the group.
// Enforces the min-owner constraint: demoting the last owner returns ErrLastGroupOwnerRole.
func (s *Service) SetGroupMemberRole(ctx context.Context, groupID, principalID, principalType, roleID string) error {
grp, err := s.groupService.Get(ctx, groupID)
if err != nil {
return err
}

principal, err := s.validateGroupPrincipal(ctx, principalID, principalType)
if err != nil {
return err
}

fetchedRole, err := s.validateGroupRole(ctx, roleID, grp.OrganizationID)
if err != nil {
return err
}
resolvedRoleID := fetchedRole.ID

existing, err := s.policyService.List(ctx, policy.Filter{
GroupID: groupID,
PrincipalID: principalID,
PrincipalType: principalType,
})
if err != nil {
return fmt.Errorf("list existing policies: %w", err)
}
if len(existing) == 0 {
return ErrNotMember
}

// skip if the user already has exactly this role
if len(existing) == 1 && existing[0].RoleID == resolvedRoleID {
return nil
}

if err := s.validateMinGroupOwnerConstraint(ctx, groupID, resolvedRoleID, existing); err != nil {
return err
}

if err := s.replacePolicy(ctx, groupID, schema.GroupNamespace, principalID, principalType, resolvedRoleID, existing); err != nil {
return err
}

newRelation := groupRoleToRelation(fetchedRole)
oldRelations := []string{schema.OwnerRelationName, schema.MemberRelationName}
if err := s.replaceRelation(ctx, groupID, schema.GroupNamespace, principalID, principalType, oldRelations, newRelation); err != nil {
s.log.ErrorContext(ctx, "membership state inconsistent: policy replaced but group relation update failed, needs manual fix",
"group_id", groupID,
"principal_id", principalID,
"principal_type", principalType,
"new_role_id", resolvedRoleID,
"expected_relation", newRelation,
"error", err,
)
return err
}

s.auditGroupMemberRoleChanged(ctx, grp, principal, resolvedRoleID)
return nil
}

// OnGroupCreated wires up SpiceDB relations for a newly-created group:
// links the group to its parent organization (both directions) and adds the
// creator as owner via AddGroupMember.
func (s *Service) OnGroupCreated(ctx context.Context, groupID, orgID, creatorID, creatorType string) error {
Comment thread
whoAbhishekSah marked this conversation as resolved.
if err := s.linkGroupToOrg(ctx, groupID, orgID); err != nil {
return err
}
if err := s.AddGroupMember(ctx, groupID, creatorID, creatorType, schema.GroupOwnerRole); err != nil {
return err
}
return nil
}

// linkGroupToOrg creates the two hierarchy relations between a group and its org:
// - group#org@organization (identity link from group to org)
// - organization#member@group#member (lets org#member traverse to group members)
func (s *Service) linkGroupToOrg(ctx context.Context, groupID, orgID string) error {
if _, err := s.relationService.Create(ctx, relation.Relation{
Object: relation.Object{ID: groupID, Namespace: schema.GroupNamespace},
Subject: relation.Subject{ID: orgID, Namespace: schema.OrganizationNamespace},
RelationName: schema.OrganizationRelationName,
}); err != nil {
return fmt.Errorf("link group to org: %w", err)
}

if _, err := s.relationService.Create(ctx, relation.Relation{
Object: relation.Object{ID: orgID, Namespace: schema.OrganizationNamespace},
Subject: relation.Subject{
ID: groupID,
Namespace: schema.GroupNamespace,
SubRelationName: schema.MemberRelationName,
},
RelationName: schema.MemberRelationName,
}); err != nil {
return fmt.Errorf("add group as org member: %w", err)
}

return nil
}

// validateGroupRole checks that the role is valid for group scope:
// - a platform-wide role scoped to groups, or
// - a custom role created for the group's parent organization.
func (s *Service) validateGroupRole(ctx context.Context, roleID, orgID string) (role.Role, error) {
fetchedRole, err := s.roleService.Get(ctx, roleID)
if err != nil {
return role.Role{}, err
}
if !slices.Contains(fetchedRole.Scopes, schema.GroupNamespace) {
return role.Role{}, ErrInvalidGroupRole
}
if fetchedRole.OrgID == orgID {
return fetchedRole, nil
}
if utils.IsNullUUID(fetchedRole.OrgID) {
return fetchedRole, nil
}
return role.Role{}, ErrInvalidGroupRole
}

// validateGroupPrincipal fetches and validates the principal for group operations.
// Currently only app/user is supported; the switch is structured so future principal
// types (e.g. serviceuser) can be enabled here without touching call sites.
func (s *Service) validateGroupPrincipal(ctx context.Context, principalID, principalType string) (principalInfo, error) {
Comment thread
whoAbhishekSah marked this conversation as resolved.
switch principalType {
case schema.UserPrincipal:
usr, err := s.userService.GetByID(ctx, principalID)
if err != nil {
return principalInfo{}, err
}
if usr.State == user.Disabled {
return principalInfo{}, user.ErrDisabled
}
return principalInfo{
ID: usr.ID,
Type: schema.UserPrincipal,
Name: usr.Title,
Email: usr.Email,
}, nil
default:
return principalInfo{}, ErrInvalidPrincipalType
}
}

// validateMinGroupOwnerConstraint ensures the group keeps at least one owner
// after the role change. Mirrors the org-level constraint.
func (s *Service) validateMinGroupOwnerConstraint(ctx context.Context, groupID, newRoleID string, existing []policy.Policy) error {
ownerRole, err := s.roleService.Get(ctx, schema.GroupOwnerRole)
if err != nil {
return fmt.Errorf("get group owner role: %w", err)
}

if newRoleID == ownerRole.ID {
return nil
}

isCurrentlyOwner := false
for _, p := range existing {
if p.RoleID == ownerRole.ID {
isCurrentlyOwner = true
break
}
}
if !isCurrentlyOwner {
return nil
}

ownerPolicies, err := s.policyService.List(ctx, policy.Filter{
GroupID: groupID,
RoleID: ownerRole.ID,
})
if err != nil {
return fmt.Errorf("list group owner policies: %w", err)
}
if len(ownerPolicies) <= 1 {
return ErrLastGroupOwnerRole
}
return nil
}

// groupRoleToRelation maps a group role to the matching SpiceDB relation name.
func groupRoleToRelation(r role.Role) string {
if r.Name == schema.GroupOwnerRole {
return schema.OwnerRelationName
}
return schema.MemberRelationName
}

func (s *Service) auditGroupMemberAdded(ctx context.Context, grp group.Group, p principalInfo, roleID string) {
targetType, _ := principalTypeToAuditType(p.Type)
meta := map[string]any{"role_id": roleID}
if p.Email != "" {
meta["email"] = p.Email
}

s.auditRecordRepository.Create(ctx, auditrecord.AuditRecord{
Event: pkgAuditRecord.GroupMemberAddedEvent,
Resource: auditrecord.Resource{
ID: grp.ID,
Type: pkgAuditRecord.GroupType,
Name: grp.Title,
},
Target: &auditrecord.Target{
ID: p.ID,
Type: targetType,
Name: p.Name,
Metadata: meta,
},
OrgID: grp.OrganizationID,
OccurredAt: time.Now(),
})

audit.GetAuditor(ctx, grp.OrganizationID).LogWithAttrs(audit.GroupMemberCreatedEvent, audit.Target{
ID: p.ID,
Type: p.Type,
}, map[string]string{
"role_id": roleID,
"group_id": grp.ID,
})
}

func (s *Service) auditGroupMemberRoleChanged(ctx context.Context, grp group.Group, p principalInfo, roleID string) {
targetType, _ := principalTypeToAuditType(p.Type)
meta := map[string]any{"role_id": roleID}
if p.Email != "" {
meta["email"] = p.Email
}

s.auditRecordRepository.Create(ctx, auditrecord.AuditRecord{
Event: pkgAuditRecord.GroupMemberRoleChangedEvent,
Resource: auditrecord.Resource{
ID: grp.ID,
Type: pkgAuditRecord.GroupType,
Name: grp.Title,
},
Target: &auditrecord.Target{
ID: p.ID,
Type: targetType,
Name: p.Name,
Metadata: meta,
},
OrgID: grp.OrganizationID,
OccurredAt: time.Now(),
})

audit.GetAuditor(ctx, grp.OrganizationID).LogWithAttrs(audit.GroupMemberRoleChangedEvent, audit.Target{
ID: p.ID,
Type: p.Type,
}, map[string]string{
"role_id": roleID,
"group_id": grp.ID,
})
}
Loading
Loading