Skip to content

Commit 79323c0

Browse files
authored
feat: enforce PAT scope intersection on ByCurrentUser queries (#1447)
1 parent 6c2d989 commit 79323c0

17 files changed

Lines changed: 614 additions & 163 deletions

File tree

core/authenticate/authenticate.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/raystack/frontier/core/serviceuser"
99
"github.com/raystack/frontier/core/user"
1010
pat "github.com/raystack/frontier/core/userpat/models"
11+
"github.com/raystack/frontier/internal/bootstrap/schema"
1112

1213
"github.com/raystack/frontier/pkg/metadata"
1314

@@ -142,3 +143,12 @@ type Principal struct {
142143
ServiceUser *serviceuser.ServiceUser
143144
PAT *pat.PAT
144145
}
146+
147+
// ResolveSubject returns the subject ID and type for authorization queries.
148+
// For PAT principals, it resolves to the underlying user.
149+
func (p Principal) ResolveSubject() (id string, subjectType string) {
150+
if p.PAT != nil {
151+
return p.PAT.UserID, schema.UserPrincipal
152+
}
153+
return p.ID, p.Type
154+
}

core/group/service.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,20 +126,20 @@ func (s Service) Update(ctx context.Context, grp Group) (Group, error) {
126126
return Group{}, ErrInvalidID
127127
}
128128

129-
func (s Service) ListByUser(ctx context.Context, principalID, principalType string, flt Filter) ([]Group, error) {
129+
func (s Service) ListByUser(ctx context.Context, principal authenticate.Principal, flt Filter) ([]Group, error) {
130+
subjectID, subjectType := principal.ResolveSubject()
130131
subjectIDs, err := s.relationService.LookupResources(ctx, relation.Relation{
131-
Object: relation.Object{
132-
Namespace: schema.GroupNamespace,
133-
},
134-
Subject: relation.Subject{
135-
Namespace: principalType,
136-
ID: principalID,
137-
},
132+
Object: relation.Object{Namespace: schema.GroupNamespace},
133+
Subject: relation.Subject{Namespace: subjectType, ID: subjectID},
138134
RelationName: schema.MembershipPermission,
139135
})
140136
if err != nil {
141137
return nil, err
142138
}
139+
subjectIDs, err = s.intersectPATScope(ctx, principal, schema.GroupNamespace, subjectIDs)
140+
if err != nil {
141+
return nil, err
142+
}
143143
if len(subjectIDs) == 0 {
144144
// no groups
145145
return nil, nil
@@ -148,6 +148,23 @@ func (s Service) ListByUser(ctx context.Context, principalID, principalType stri
148148
return s.List(ctx, flt)
149149
}
150150

151+
// intersectPATScope narrows resource IDs to only those the PAT is scoped to.
152+
func (s Service) intersectPATScope(ctx context.Context, principal authenticate.Principal,
153+
namespace string, resourceIDs []string) ([]string, error) {
154+
if principal.PAT == nil || len(resourceIDs) == 0 {
155+
return resourceIDs, nil
156+
}
157+
patIDs, err := s.relationService.LookupResources(ctx, relation.Relation{
158+
Object: relation.Object{Namespace: namespace},
159+
Subject: relation.Subject{ID: principal.PAT.ID, Namespace: schema.PATPrincipal},
160+
RelationName: schema.GetPermission,
161+
})
162+
if err != nil {
163+
return nil, err
164+
}
165+
return utils.Intersection(resourceIDs, patIDs), nil
166+
}
167+
151168
// AddMember adds a subject(user) to group as member
152169
func (s Service) AddMember(ctx context.Context, groupID string, principal authenticate.Principal) error {
153170
// first create a policy for the user as member of the group

core/group/service_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/raystack/frontier/core/policy"
1515
"github.com/raystack/frontier/core/relation"
1616
"github.com/raystack/frontier/core/user"
17+
pat "github.com/raystack/frontier/core/userpat/models"
1718
"github.com/raystack/frontier/internal/bootstrap/schema"
1819
"github.com/stretchr/testify/assert"
1920
"github.com/stretchr/testify/mock"
@@ -262,3 +263,107 @@ func TestService_Update(t *testing.T) {
262263
assert.Equal(t, err, group.ErrInvalidID)
263264
})
264265
}
266+
267+
func TestService_ListByUser(t *testing.T) {
268+
ctx := context.Background()
269+
270+
t.Run("should resolve PAT to user and intersect with PAT group scope", func(t *testing.T) {
271+
mockRepo := mocks.NewRepository(t)
272+
mockRelationSvc := mocks.NewRelationService(t)
273+
mockAuthnSvc := mocks.NewAuthnService(t)
274+
mockPolicySvc := mocks.NewPolicyService(t)
275+
276+
svc := group.NewService(mockRepo, mockRelationSvc, mockAuthnSvc, mockPolicySvc)
277+
278+
// LookupResources for user's group memberships
279+
mockRelationSvc.On("LookupResources", ctx, relation.Relation{
280+
Object: relation.Object{Namespace: schema.GroupNamespace},
281+
Subject: relation.Subject{Namespace: schema.UserPrincipal, ID: "user-123"},
282+
RelationName: schema.MembershipPermission,
283+
}).Return([]string{"group-1", "group-2", "group-3"}, nil).Once()
284+
285+
// LookupResources for PAT's group scope
286+
mockRelationSvc.On("LookupResources", ctx, relation.Relation{
287+
Object: relation.Object{Namespace: schema.GroupNamespace},
288+
Subject: relation.Subject{ID: "pat-456", Namespace: schema.PATPrincipal},
289+
RelationName: schema.GetPermission,
290+
}).Return([]string{"group-1", "group-3"}, nil).Once()
291+
292+
// Repo should be called with intersection
293+
mockRepo.On("List", ctx, group.Filter{
294+
GroupIDs: []string{"group-1", "group-3"},
295+
}).Return([]group.Group{
296+
{ID: "group-1", Name: "group-one"},
297+
{ID: "group-3", Name: "group-three"},
298+
}, nil).Once()
299+
300+
result, err := svc.ListByUser(ctx, authenticate.Principal{
301+
ID: "pat-456",
302+
Type: schema.PATPrincipal,
303+
PAT: &pat.PAT{ID: "pat-456", UserID: "user-123", OrgID: "org-1"},
304+
}, group.Filter{})
305+
306+
assert.NoError(t, err)
307+
assert.Len(t, result, 2)
308+
})
309+
310+
t.Run("should return nil when PAT has no group scope overlap", func(t *testing.T) {
311+
mockRepo := mocks.NewRepository(t)
312+
mockRelationSvc := mocks.NewRelationService(t)
313+
mockAuthnSvc := mocks.NewAuthnService(t)
314+
mockPolicySvc := mocks.NewPolicyService(t)
315+
316+
svc := group.NewService(mockRepo, mockRelationSvc, mockAuthnSvc, mockPolicySvc)
317+
318+
mockRelationSvc.On("LookupResources", ctx, relation.Relation{
319+
Object: relation.Object{Namespace: schema.GroupNamespace},
320+
Subject: relation.Subject{Namespace: schema.UserPrincipal, ID: "user-123"},
321+
RelationName: schema.MembershipPermission,
322+
}).Return([]string{"group-1"}, nil).Once()
323+
324+
mockRelationSvc.On("LookupResources", ctx, relation.Relation{
325+
Object: relation.Object{Namespace: schema.GroupNamespace},
326+
Subject: relation.Subject{ID: "pat-456", Namespace: schema.PATPrincipal},
327+
RelationName: schema.GetPermission,
328+
}).Return([]string{"group-2"}, nil).Once()
329+
330+
result, err := svc.ListByUser(ctx, authenticate.Principal{
331+
ID: "pat-456",
332+
Type: schema.PATPrincipal,
333+
PAT: &pat.PAT{ID: "pat-456", UserID: "user-123", OrgID: "org-1"},
334+
}, group.Filter{})
335+
336+
assert.NoError(t, err)
337+
assert.Nil(t, result)
338+
})
339+
340+
t.Run("should pass through for regular user principal", func(t *testing.T) {
341+
mockRepo := mocks.NewRepository(t)
342+
mockRelationSvc := mocks.NewRelationService(t)
343+
mockAuthnSvc := mocks.NewAuthnService(t)
344+
mockPolicySvc := mocks.NewPolicyService(t)
345+
346+
svc := group.NewService(mockRepo, mockRelationSvc, mockAuthnSvc, mockPolicySvc)
347+
348+
mockRelationSvc.On("LookupResources", ctx, relation.Relation{
349+
Object: relation.Object{Namespace: schema.GroupNamespace},
350+
Subject: relation.Subject{Namespace: schema.UserPrincipal, ID: "user-123"},
351+
RelationName: schema.MembershipPermission,
352+
}).Return([]string{"group-1", "group-2"}, nil).Once()
353+
354+
mockRepo.On("List", ctx, group.Filter{
355+
GroupIDs: []string{"group-1", "group-2"},
356+
}).Return([]group.Group{
357+
{ID: "group-1", Name: "group-one"},
358+
{ID: "group-2", Name: "group-two"},
359+
}, nil).Once()
360+
361+
result, err := svc.ListByUser(ctx, authenticate.Principal{
362+
ID: "user-123",
363+
Type: schema.UserPrincipal,
364+
}, group.Filter{})
365+
366+
assert.NoError(t, err)
367+
assert.Len(t, result, 2)
368+
})
369+
}

core/invitation/mocks/group_service.go

Lines changed: 15 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/invitation/service.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ type OrganizationService interface {
5353
type GroupService interface {
5454
Get(ctx context.Context, id string) (group.Group, error)
5555
AddMember(ctx context.Context, groupID string, principal authenticate.Principal) error
56-
ListByUser(ctx context.Context, principalID, principalType string, flt group.Filter) ([]group.Group, error)
56+
ListByUser(ctx context.Context, principal authenticate.Principal, flt group.Filter) ([]group.Group, error)
5757
}
5858

5959
type RelationService interface {
@@ -315,7 +315,9 @@ func (s Service) Accept(ctx context.Context, id uuid.UUID) error {
315315

316316
// check if the invitation has a group membership
317317
if len(invite.GroupIDs) > 0 {
318-
userGroups, err := s.groupSvc.ListByUser(ctx, userOb.ID, schema.UserPrincipal, group.Filter{})
318+
userGroups, err := s.groupSvc.ListByUser(ctx, authenticate.Principal{
319+
ID: userOb.ID, Type: schema.UserPrincipal,
320+
}, group.Filter{})
319321
if err != nil {
320322
return err
321323
}

core/organization/service.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,19 +308,25 @@ func (s Service) ListByUser(ctx context.Context, principal authenticate.Principa
308308
defer promCollect()
309309
}
310310

311+
subjectID, subjectType := principal.ResolveSubject()
311312
subjectIDs, err := s.relationService.LookupResources(ctx, relation.Relation{
312313
Object: relation.Object{
313314
Namespace: schema.OrganizationNamespace,
314315
},
315316
Subject: relation.Subject{
316-
ID: principal.ID,
317-
Namespace: principal.Type,
317+
ID: subjectID,
318+
Namespace: subjectType,
318319
},
319320
RelationName: schema.MembershipPermission,
320321
})
321322
if err != nil {
322323
return nil, err
323324
}
325+
326+
if principal.PAT != nil {
327+
subjectIDs = utils.Intersection(subjectIDs, []string{principal.PAT.OrgID})
328+
}
329+
324330
if len(subjectIDs) == 0 {
325331
// no organizations
326332
return []Organization{}, nil

0 commit comments

Comments
 (0)