Skip to content

Commit 42a6da4

Browse files
refactor(membership): deduplicate repeated patterns
Behavior unchanged; call sites and public signatures stay the same. - One resourcePolicyFilter builds resource-scoped policy filters; the duplicate policyFilterForResource is gone. removeAllPolicies now goes through it and reuses removePoliciesByFilter, so its filter also pins resource_type (redundant with the per-type ID fields in SQL). - validateOrgRole/validateProjectRole/validateGroupRole are one-line wrappers over a shared validateRoleForScope. - validateMinOwnerConstraint/validateMinGroupOwnerConstraint are wrappers over a shared validateMinRoleConstraint. - The five near-identical org/group audit helpers delegate to one auditMemberChange that writes both audit stores. - hasExactlyRole names the repeated "already has exactly this role" check. - principalKey struct replaces the \x00-joined string map keys, and ListPrincipalsByResource's role enrichment moves into its own function. - listOrgsForPrincipal/listGroupsForPrincipal reuse policyResourceIDs instead of hand-rolled pluck loops. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 98a7873 commit 42a6da4

7 files changed

Lines changed: 219 additions & 384 deletions

File tree

core/membership/audit.go

Lines changed: 57 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -49,82 +49,78 @@ func (s *Service) createAuditRecord(ctx context.Context, record auditrecord.Audi
4949
}
5050
}
5151

52-
func (s *Service) auditOrgMemberRoleChanged(ctx context.Context, org organization.Organization, p principalInfo, roleID string) {
52+
// auditMemberChange writes a membership change to both audit stores: an audit
53+
// record against the resource and a legacy auditor log with the given attrs.
54+
// The role ID and the principal's email go into the record's target metadata
55+
// when present.
56+
func (s *Service) auditMemberChange(ctx context.Context, event pkgAuditRecord.Event, legacyEvent audit.EventName, res auditrecord.Resource, orgID string, p principalInfo, roleID string, legacyAttrs map[string]string) {
5357
targetType, _ := principalTypeToAuditType(p.Type)
54-
meta := map[string]any{"role_id": roleID}
58+
meta := map[string]any{}
59+
if roleID != "" {
60+
meta["role_id"] = roleID
61+
}
5562
if p.Email != "" {
5663
meta["email"] = p.Email
5764
}
5865

5966
s.createAuditRecord(ctx, auditrecord.AuditRecord{
60-
Event: pkgAuditRecord.OrganizationMemberRoleChangedEvent,
61-
Resource: auditrecord.Resource{
62-
ID: org.ID,
63-
Type: pkgAuditRecord.OrganizationType,
64-
Name: org.Title,
65-
},
67+
Event: event,
68+
Resource: res,
6669
Target: &auditrecord.Target{
6770
ID: p.ID,
6871
Type: targetType,
6972
Name: p.Name,
7073
Metadata: meta,
7174
},
72-
OrgID: org.ID,
75+
OrgID: orgID,
7376
OccurredAt: time.Now(),
7477
})
7578

76-
if err := audit.GetAuditor(ctx, org.ID).LogWithAttrs(audit.OrgMemberRoleChangedEvent, audit.Target{
79+
if err := audit.GetAuditor(ctx, orgID).LogWithAttrs(legacyEvent, audit.Target{
7780
ID: p.ID,
7881
Type: p.Type,
79-
}, map[string]string{
80-
"role_id": roleID,
81-
}); err != nil {
82-
s.log.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.OrgMemberRoleChangedEvent)
82+
}, legacyAttrs); err != nil {
83+
s.log.WarnContext(ctx, "failed to write audit log", "error", err, "event", legacyEvent)
8384
}
8485
}
8586

87+
func orgAuditResource(org organization.Organization) auditrecord.Resource {
88+
return auditrecord.Resource{ID: org.ID, Type: pkgAuditRecord.OrganizationType, Name: org.Title}
89+
}
90+
91+
func groupAuditResource(grp group.Group) auditrecord.Resource {
92+
return auditrecord.Resource{ID: grp.ID, Type: pkgAuditRecord.GroupType, Name: grp.Title}
93+
}
94+
8695
func (s *Service) auditOrgMemberAdded(ctx context.Context, org organization.Organization, p principalInfo, roleID string) {
87-
targetType, _ := principalTypeToAuditType(p.Type)
88-
meta := map[string]any{"role_id": roleID}
89-
if p.Email != "" {
90-
meta["email"] = p.Email
91-
}
96+
s.auditMemberChange(ctx, pkgAuditRecord.OrganizationMemberAddedEvent, audit.OrgMemberCreatedEvent,
97+
orgAuditResource(org), org.ID, p, roleID, map[string]string{"role_id": roleID})
98+
}
9299

93-
s.createAuditRecord(ctx, auditrecord.AuditRecord{
94-
Event: pkgAuditRecord.OrganizationMemberAddedEvent,
95-
Resource: auditrecord.Resource{
96-
ID: org.ID,
97-
Type: pkgAuditRecord.OrganizationType,
98-
Name: org.Title,
99-
},
100-
Target: &auditrecord.Target{
101-
ID: p.ID,
102-
Type: targetType,
103-
Name: p.Name,
104-
Metadata: meta,
105-
},
106-
OrgID: org.ID,
107-
OccurredAt: time.Now(),
108-
})
100+
func (s *Service) auditOrgMemberRoleChanged(ctx context.Context, org organization.Organization, p principalInfo, roleID string) {
101+
s.auditMemberChange(ctx, pkgAuditRecord.OrganizationMemberRoleChangedEvent, audit.OrgMemberRoleChangedEvent,
102+
orgAuditResource(org), org.ID, p, roleID, map[string]string{"role_id": roleID})
103+
}
109104

110-
if err := audit.GetAuditor(ctx, org.ID).LogWithAttrs(audit.OrgMemberCreatedEvent, audit.Target{
111-
ID: p.ID,
112-
Type: p.Type,
113-
}, map[string]string{
114-
"role_id": roleID,
115-
}); err != nil {
116-
s.log.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.OrgMemberCreatedEvent)
117-
}
105+
func (s *Service) auditGroupMemberAdded(ctx context.Context, grp group.Group, p principalInfo, roleID string) {
106+
s.auditMemberChange(ctx, pkgAuditRecord.GroupMemberAddedEvent, audit.GroupMemberCreatedEvent,
107+
groupAuditResource(grp), grp.OrganizationID, p, roleID, map[string]string{"role_id": roleID, "group_id": grp.ID})
108+
}
109+
110+
func (s *Service) auditGroupMemberRoleChanged(ctx context.Context, grp group.Group, p principalInfo, roleID string) {
111+
s.auditMemberChange(ctx, pkgAuditRecord.GroupMemberRoleChangedEvent, audit.GroupMemberRoleChangedEvent,
112+
groupAuditResource(grp), grp.OrganizationID, p, roleID, map[string]string{"role_id": roleID, "group_id": grp.ID})
113+
}
114+
115+
func (s *Service) auditGroupMemberRemoved(ctx context.Context, grp group.Group, p principalInfo) {
116+
s.auditMemberChange(ctx, pkgAuditRecord.GroupMemberRemovedEvent, audit.GroupMemberRemovedEvent,
117+
groupAuditResource(grp), grp.OrganizationID, p, "", map[string]string{"group_id": grp.ID})
118118
}
119119

120120
func (s *Service) auditOrgMemberRemoved(ctx context.Context, org organization.Organization, targetID string, targetType pkgAuditRecord.EntityType) {
121121
s.createAuditRecord(ctx, auditrecord.AuditRecord{
122-
Event: pkgAuditRecord.OrganizationMemberRemovedEvent,
123-
Resource: auditrecord.Resource{
124-
ID: org.ID,
125-
Type: pkgAuditRecord.OrganizationType,
126-
Name: org.Title,
127-
},
122+
Event: pkgAuditRecord.OrganizationMemberRemovedEvent,
123+
Resource: orgAuditResource(org),
128124
Target: &auditrecord.Target{
129125
ID: targetID,
130126
Type: targetType,
@@ -134,21 +130,6 @@ func (s *Service) auditOrgMemberRemoved(ctx context.Context, org organization.Or
134130
})
135131
}
136132

137-
func principalTypeToAuditType(principalType string) (pkgAuditRecord.EntityType, error) {
138-
switch principalType {
139-
case schema.ServiceUserPrincipal:
140-
return pkgAuditRecord.ServiceUserType, nil
141-
case schema.UserPrincipal:
142-
return pkgAuditRecord.UserType, nil
143-
case schema.GroupPrincipal:
144-
return pkgAuditRecord.GroupType, nil
145-
case schema.PATPrincipal:
146-
return pkgAuditRecord.PATType, nil
147-
default:
148-
return "", ErrInvalidPrincipalType
149-
}
150-
}
151-
152133
func (s *Service) auditProjectMember(ctx context.Context, event pkgAuditRecord.Event, prj project.Project, principalID, principalType string, meta map[string]any) {
153134
targetType, _ := principalTypeToAuditType(principalType)
154135
if meta == nil {
@@ -172,106 +153,17 @@ func (s *Service) auditProjectMember(ctx context.Context, event pkgAuditRecord.E
172153
})
173154
}
174155

175-
func (s *Service) auditGroupMemberAdded(ctx context.Context, grp group.Group, p principalInfo, roleID string) {
176-
targetType, _ := principalTypeToAuditType(p.Type)
177-
meta := map[string]any{"role_id": roleID}
178-
if p.Email != "" {
179-
meta["email"] = p.Email
180-
}
181-
182-
s.createAuditRecord(ctx, auditrecord.AuditRecord{
183-
Event: pkgAuditRecord.GroupMemberAddedEvent,
184-
Resource: auditrecord.Resource{
185-
ID: grp.ID,
186-
Type: pkgAuditRecord.GroupType,
187-
Name: grp.Title,
188-
},
189-
Target: &auditrecord.Target{
190-
ID: p.ID,
191-
Type: targetType,
192-
Name: p.Name,
193-
Metadata: meta,
194-
},
195-
OrgID: grp.OrganizationID,
196-
OccurredAt: time.Now(),
197-
})
198-
199-
if err := audit.GetAuditor(ctx, grp.OrganizationID).LogWithAttrs(audit.GroupMemberCreatedEvent, audit.Target{
200-
ID: p.ID,
201-
Type: p.Type,
202-
}, map[string]string{
203-
"role_id": roleID,
204-
"group_id": grp.ID,
205-
}); err != nil {
206-
s.log.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.GroupMemberCreatedEvent)
207-
}
208-
}
209-
210-
func (s *Service) auditGroupMemberRoleChanged(ctx context.Context, grp group.Group, p principalInfo, roleID string) {
211-
targetType, _ := principalTypeToAuditType(p.Type)
212-
meta := map[string]any{"role_id": roleID}
213-
if p.Email != "" {
214-
meta["email"] = p.Email
215-
}
216-
217-
s.createAuditRecord(ctx, auditrecord.AuditRecord{
218-
Event: pkgAuditRecord.GroupMemberRoleChangedEvent,
219-
Resource: auditrecord.Resource{
220-
ID: grp.ID,
221-
Type: pkgAuditRecord.GroupType,
222-
Name: grp.Title,
223-
},
224-
Target: &auditrecord.Target{
225-
ID: p.ID,
226-
Type: targetType,
227-
Name: p.Name,
228-
Metadata: meta,
229-
},
230-
OrgID: grp.OrganizationID,
231-
OccurredAt: time.Now(),
232-
})
233-
234-
if err := audit.GetAuditor(ctx, grp.OrganizationID).LogWithAttrs(audit.GroupMemberRoleChangedEvent, audit.Target{
235-
ID: p.ID,
236-
Type: p.Type,
237-
}, map[string]string{
238-
"role_id": roleID,
239-
"group_id": grp.ID,
240-
}); err != nil {
241-
s.log.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.GroupMemberRoleChangedEvent)
242-
}
243-
}
244-
245-
func (s *Service) auditGroupMemberRemoved(ctx context.Context, grp group.Group, p principalInfo) {
246-
targetType, _ := principalTypeToAuditType(p.Type)
247-
meta := map[string]any{}
248-
if p.Email != "" {
249-
meta["email"] = p.Email
250-
}
251-
252-
s.createAuditRecord(ctx, auditrecord.AuditRecord{
253-
Event: pkgAuditRecord.GroupMemberRemovedEvent,
254-
Resource: auditrecord.Resource{
255-
ID: grp.ID,
256-
Type: pkgAuditRecord.GroupType,
257-
Name: grp.Title,
258-
},
259-
Target: &auditrecord.Target{
260-
ID: p.ID,
261-
Type: targetType,
262-
Name: p.Name,
263-
Metadata: meta,
264-
},
265-
OrgID: grp.OrganizationID,
266-
OccurredAt: time.Now(),
267-
})
268-
269-
if err := audit.GetAuditor(ctx, grp.OrganizationID).LogWithAttrs(audit.GroupMemberRemovedEvent, audit.Target{
270-
ID: p.ID,
271-
Type: p.Type,
272-
}, map[string]string{
273-
"group_id": grp.ID,
274-
}); err != nil {
275-
s.log.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.GroupMemberRemovedEvent)
156+
func principalTypeToAuditType(principalType string) (pkgAuditRecord.EntityType, error) {
157+
switch principalType {
158+
case schema.ServiceUserPrincipal:
159+
return pkgAuditRecord.ServiceUserType, nil
160+
case schema.UserPrincipal:
161+
return pkgAuditRecord.UserType, nil
162+
case schema.GroupPrincipal:
163+
return pkgAuditRecord.GroupType, nil
164+
case schema.PATPrincipal:
165+
return pkgAuditRecord.PATType, nil
166+
default:
167+
return "", ErrInvalidPrincipalType
276168
}
277169
}

core/membership/group.go

Lines changed: 6 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,11 @@ import (
44
"context"
55
"errors"
66
"fmt"
7-
"slices"
87

98
"github.com/raystack/frontier/core/policy"
109
"github.com/raystack/frontier/core/relation"
1110
"github.com/raystack/frontier/core/role"
1211
"github.com/raystack/frontier/internal/bootstrap/schema"
13-
"github.com/raystack/frontier/pkg/utils"
1412
)
1513

1614
// removeGroupMemberRelation deletes the member relation for a principal on a group.
@@ -83,7 +81,7 @@ func (s *Service) SetGroupMemberRole(ctx context.Context, groupID, principalID,
8381
}
8482

8583
// change path: skip if the principal already has exactly this role
86-
if len(existing) == 1 && existing[0].RoleID == resolvedRoleID {
84+
if hasExactlyRole(existing, resolvedRoleID) {
8785
return nil
8886
}
8987

@@ -176,11 +174,11 @@ func (s *Service) RemoveAllGroupMembers(ctx context.Context, groupID string) err
176174
// First pass: delete every policy. Track which principals had any
177175
// delete failure so we don't strip their SpiceDB relations while a
178176
// surviving policy still references them.
179-
principals := make(map[string]policy.Policy, len(policies))
180-
failed := make(map[string]struct{}, len(policies))
177+
principals := make(map[principalKey]policy.Policy, len(policies))
178+
failed := make(map[principalKey]struct{}, len(policies))
181179
var errs error
182180
for _, p := range policies {
183-
key := p.PrincipalType + "\x00" + p.PrincipalID
181+
key := policyPrincipalKey(p)
184182
principals[key] = p
185183
if delErr := s.policyService.Delete(ctx, p.ID); delErr != nil {
186184
failed[key] = struct{}{}
@@ -311,56 +309,13 @@ func (s *Service) unlinkGroupFromOrg(ctx context.Context, groupID, orgID string)
311309
// - a platform-wide role scoped to groups, or
312310
// - a custom role created for the group's parent organization.
313311
func (s *Service) validateGroupRole(ctx context.Context, roleID, orgID string) (role.Role, error) {
314-
fetchedRole, err := s.roleService.Get(ctx, roleID)
315-
if err != nil {
316-
return role.Role{}, err
317-
}
318-
if !slices.Contains(fetchedRole.Scopes, schema.GroupNamespace) {
319-
return role.Role{}, ErrInvalidGroupRole
320-
}
321-
if fetchedRole.OrgID == orgID {
322-
return fetchedRole, nil
323-
}
324-
if utils.IsNullUUID(fetchedRole.OrgID) {
325-
return fetchedRole, nil
326-
}
327-
return role.Role{}, ErrInvalidGroupRole
312+
return s.validateRoleForScope(ctx, roleID, orgID, schema.GroupNamespace, ErrInvalidGroupRole)
328313
}
329314

330315
// validateMinGroupOwnerConstraint ensures the group keeps at least one owner
331316
// after the role change. Returns the resolved group owner role ID so the
332317
// caller can hand it to replacePolicy as a min-role guard, closing the TOCTOU
333318
// race between this pre-check and the policy delete.
334319
func (s *Service) validateMinGroupOwnerConstraint(ctx context.Context, groupID, newRoleID string, existing []policy.Policy) (string, error) {
335-
ownerRole, err := s.roleService.Get(ctx, schema.GroupOwnerRole)
336-
if err != nil {
337-
return "", fmt.Errorf("get group owner role: %w", err)
338-
}
339-
340-
if newRoleID == ownerRole.ID {
341-
return ownerRole.ID, nil
342-
}
343-
344-
isCurrentlyOwner := false
345-
for _, p := range existing {
346-
if p.RoleID == ownerRole.ID {
347-
isCurrentlyOwner = true
348-
break
349-
}
350-
}
351-
if !isCurrentlyOwner {
352-
return ownerRole.ID, nil
353-
}
354-
355-
ownerPolicies, err := s.policyService.List(ctx, policy.Filter{
356-
GroupID: groupID,
357-
RoleID: ownerRole.ID,
358-
})
359-
if err != nil {
360-
return "", fmt.Errorf("list group owner policies: %w", err)
361-
}
362-
if len(ownerPolicies) <= 1 {
363-
return "", ErrLastGroupOwnerRole
364-
}
365-
return ownerRole.ID, nil
320+
return s.validateMinRoleConstraint(ctx, schema.GroupOwnerRole, policy.Filter{GroupID: groupID}, newRoleID, existing, ErrLastGroupOwnerRole)
366321
}

0 commit comments

Comments
 (0)