Skip to content

Commit 73e36fe

Browse files
committed
sec(auth): bound AllowedAccounts on group writes, the fifth write path
An allowed_accounts-only PUT never reached the ceiling at all. checkGrantCeiling opens with `if len(requested) == 0 { return nil }`, and APIUpdateGroupRequest's "empty means not sent" contract makes an accounts-only request the natural shape to send. Verified by execution with an actor holding only update:groups in one group scoped to one account: widening to more accounts, to [] and to ["*"] were all ACCEPTED, while the control -- widening Permissions[].Constraints.AccountIDs on the same call -- was correctly refused. One account dimension was bounded carefully and its sibling left open on the same request. checkAccountCeiling is deliberately a separate call rather than a branch inside checkGrantCeiling, so it cannot inherit that early return. A write is in ceiling if it does not widen the group's existing scope, or if it stays inside the acting principal's own. Two traps are handled explicitly. Empty and "*" both mean UNRESTRICTED, so this cannot be a subset test: the empty set is a subset of everything and means the opposite of narrow. And on create there is no prior scope, where a nil existing would read as unrestricted and swallow every check -- so CreateGroupAPI calls checkAccountGrant directly, and an omitted allowed_accounts is checked too, because it produces an unrestricted group. Every refusal test sends allowed_accounts with no permissions key. A test that included permissions would pass with the bug present, since the permission ceiling would then run and refuse for an unrelated reason. Also strengthens three mutation kills that rested on a missing stub rather than on an assertion. M5, M7 and TestUpdateUser_SelfEscalationDenied panicked on an unstubbed READ downstream of the guard, a kill that disappears the moment someone adds a permissive stub while tidying fixtures. Those now register the downstream reads with .Maybe() so removing the guard cannot panic, leaving the test's own assertions as the only thing that can fail it; M7 now dies on require.Error. FailsClosedOnGroupLoadError asserts errors.Is(err, loadErr) rather than message text. Splits group_ceiling_test.go, which exceeded the repo's 500-line guideline, by concern rather than by line count: fixtures, the permission ceiling, blank-field validation, and system_managed. Verified no test was lost -- 707 passing test names before and after, sorted and identical. Closes #1738. Refs #1550, #1629, #1730.
1 parent 2080867 commit 73e36fe

9 files changed

Lines changed: 709 additions & 233 deletions
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/mock"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// Account-scope ceiling on group writes (issue #1738, folded into #1737).
13+
//
14+
// EVERY refusal case here sends allowed_accounts with NO "permissions" key.
15+
// That shape is the whole finding: checkGrantCeiling returns early when no
16+
// permissions are sent, so a test that includes permissions alongside would
17+
// PASS WITH THE BUG PRESENT, because the permission ceiling then runs and
18+
// refuses for an unrelated reason.
19+
20+
const scopedAccountA = "acct-A"
21+
22+
// scopedActorService wires an actor holding only update:groups, scoped to
23+
// exactly one cloud account, editing a group scoped to that same account.
24+
func scopedActorService(t *testing.T, ctx context.Context, mockStore *MockStore) *Service {
25+
t.Helper()
26+
mockStore.On("GetUserByID", ctx, ceilingActorID).
27+
Return(&User{ID: ceilingActorID, GroupIDs: []string{ceilingActorGroupID}}, nil)
28+
mockStore.On("GetGroup", ctx, ceilingActorGroupID).Return(&Group{
29+
ID: ceilingActorGroupID,
30+
Name: "Scoped Group Managers",
31+
Permissions: []Permission{{Action: ActionUpdate, Resource: ResourceGroups}},
32+
AllowedAccounts: []string{scopedAccountA},
33+
}, nil)
34+
mockStore.On("GetGroup", ctx, ceilingTargetID).Return(&Group{
35+
ID: ceilingTargetID,
36+
Name: "Team",
37+
AllowedAccounts: []string{scopedAccountA},
38+
}, nil)
39+
return createTestService(mockStore, new(MockEmailSender))
40+
}
41+
42+
func TestAccountCeiling_RefusesWideningWithNoPermissionsSent(t *testing.T) {
43+
ctx := context.Background()
44+
45+
widenings := []struct {
46+
name string
47+
accounts []string
48+
wantIn string
49+
}{
50+
{"to additional accounts", []string{scopedAccountA, "acct-B"}, `"acct-B"`},
51+
// The two unrestricted spellings. An empty list is NOT a narrowing:
52+
// IsUnrestrictedAccess reads it as "all accounts".
53+
{"to the empty list (unrestricted)", []string{}, "unrestricted"},
54+
{"to the wildcard", []string{"*"}, "unrestricted"},
55+
}
56+
57+
for _, w := range widenings {
58+
t.Run(w.name, func(t *testing.T) {
59+
mockStore := new(MockStore)
60+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
61+
svc := scopedActorService(t, ctx, mockStore)
62+
63+
// NOTE: no Permissions field. This is the request shape the bug
64+
// lives in; adding permissions here would mask it entirely.
65+
result, err := svc.UpdateGroupAPI(ctx, ceilingActorID, ceilingTargetID,
66+
APIUpdateGroupRequest{AllowedAccounts: w.accounts})
67+
68+
require.Error(t, err)
69+
assert.Nil(t, result)
70+
assert.ErrorIs(t, err, ErrPermissionCeiling)
71+
assert.Contains(t, err.Error(), w.wantIn)
72+
mockStore.AssertNotCalled(t, "UpdateGroup", mock.Anything, mock.Anything)
73+
})
74+
}
75+
}
76+
77+
// Negative control 1: a write that WIDENS the group but stays inside the
78+
// actor's own scope succeeds.
79+
//
80+
// This deliberately widens rather than repeating the group's current scope. A
81+
// same-value write returns on the not-a-widening branch without ever
82+
// resolving the actor, so it would prove only that the early return works --
83+
// not that the actor-scope branch actually grants anything.
84+
func TestAccountCeiling_AllowsInScopeWidening(t *testing.T) {
85+
ctx := context.Background()
86+
mockStore := new(MockStore)
87+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
88+
svc := createTestService(mockStore, new(MockEmailSender))
89+
90+
// Actor may reach A and B; the group currently reaches only A.
91+
mockStore.On("GetUserByID", ctx, ceilingActorID).
92+
Return(&User{ID: ceilingActorID, GroupIDs: []string{ceilingActorGroupID}}, nil)
93+
mockStore.On("GetGroup", ctx, ceilingActorGroupID).Return(&Group{
94+
ID: ceilingActorGroupID,
95+
Permissions: []Permission{{Action: ActionUpdate, Resource: ResourceGroups}},
96+
AllowedAccounts: []string{scopedAccountA, "acct-B"},
97+
}, nil)
98+
mockStore.On("GetGroup", ctx, ceilingTargetID).Return(&Group{
99+
ID: ceilingTargetID, Name: "Team", AllowedAccounts: []string{scopedAccountA},
100+
}, nil)
101+
102+
var saved *Group
103+
mockStore.On("UpdateGroup", ctx, mock.AnythingOfType("*auth.Group")).
104+
Run(func(a mock.Arguments) {
105+
g, ok := a.Get(1).(*Group)
106+
require.True(t, ok)
107+
saved = g
108+
}).Return(nil).Once()
109+
110+
_, err := svc.UpdateGroupAPI(ctx, ceilingActorID, ceilingTargetID,
111+
APIUpdateGroupRequest{AllowedAccounts: []string{scopedAccountA, "acct-B"}})
112+
113+
require.NoError(t, err)
114+
require.NotNil(t, saved)
115+
assert.Equal(t, []string{scopedAccountA, "acct-B"}, saved.AllowedAccounts)
116+
}
117+
118+
// Negative control 1b: repeating the group's current scope is not a widening
119+
// and must not require an authorization round-trip at all.
120+
func TestAccountCeiling_UnchangedScopeSkipsActorLookup(t *testing.T) {
121+
ctx := context.Background()
122+
mockStore := new(MockStore)
123+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
124+
svc := createTestService(mockStore, new(MockEmailSender))
125+
126+
mockStore.On("GetGroup", ctx, ceilingTargetID).Return(&Group{
127+
ID: ceilingTargetID, Name: "Team", AllowedAccounts: []string{scopedAccountA},
128+
}, nil)
129+
mockStore.On("UpdateGroup", ctx, mock.AnythingOfType("*auth.Group")).Return(nil).Once()
130+
131+
_, err := svc.UpdateGroupAPI(ctx, ceilingActorID, ceilingTargetID,
132+
APIUpdateGroupRequest{AllowedAccounts: []string{scopedAccountA}})
133+
134+
require.NoError(t, err)
135+
mockStore.AssertNotCalled(t, "GetUserByID", mock.Anything, mock.Anything)
136+
}
137+
138+
// Negative control 2: an UNRESTRICTED actor may still widen a group, so the
139+
// refusals above come from the actor's scope and not from the check refusing
140+
// all widenings outright.
141+
func TestAccountCeiling_UnrestrictedActorMayWiden(t *testing.T) {
142+
ctx := context.Background()
143+
mockStore := new(MockStore)
144+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
145+
svc := createTestService(mockStore, new(MockEmailSender))
146+
147+
stubActorPermissions(ctx, mockStore, adminOnly) // no AllowedAccounts -> unrestricted
148+
stubTargetGroup(ctx, mockStore, &Group{
149+
ID: ceilingTargetID, Name: "Team", AllowedAccounts: []string{scopedAccountA},
150+
})
151+
mockStore.On("UpdateGroup", ctx, mock.AnythingOfType("*auth.Group")).Return(nil).Once()
152+
153+
_, err := svc.UpdateGroupAPI(ctx, ceilingActorID, ceilingTargetID,
154+
APIUpdateGroupRequest{AllowedAccounts: []string{"*"}})
155+
require.NoError(t, err)
156+
}
157+
158+
// Negative control 3: omitting allowed_accounts entirely (nil, "not sent")
159+
// must not be treated as a write, or every name-only edit would be refused.
160+
func TestAccountCeiling_NotSentIsNotAWrite(t *testing.T) {
161+
ctx := context.Background()
162+
mockStore := new(MockStore)
163+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
164+
svc := createTestService(mockStore, new(MockEmailSender))
165+
166+
// Only the target group is stubbed. Neither ceiling resolves the actor
167+
// for a request that sends nothing they gate, and asserting that here
168+
// (via AssertExpectations on an actor-free mock) is the point: a
169+
// name-only edit must not become an authorization round-trip.
170+
mockStore.On("GetGroup", ctx, ceilingTargetID).Return(&Group{
171+
ID: ceilingTargetID, Name: "Team", AllowedAccounts: []string{scopedAccountA},
172+
}, nil)
173+
174+
var saved *Group
175+
mockStore.On("UpdateGroup", ctx, mock.AnythingOfType("*auth.Group")).
176+
Run(func(a mock.Arguments) {
177+
g, ok := a.Get(1).(*Group)
178+
require.True(t, ok)
179+
saved = g
180+
}).Return(nil).Once()
181+
182+
_, err := svc.UpdateGroupAPI(ctx, ceilingActorID, ceilingTargetID,
183+
APIUpdateGroupRequest{Name: "Renamed"})
184+
185+
require.NoError(t, err)
186+
require.NotNil(t, saved)
187+
assert.Equal(t, "Renamed", saved.Name)
188+
assert.Equal(t, []string{scopedAccountA}, saved.AllowedAccounts, "scope must be untouched")
189+
}
190+
191+
// Narrowing is always allowed, whoever the actor is: it cannot widen anything.
192+
func TestAccountCeiling_NarrowingIsAllowed(t *testing.T) {
193+
ctx := context.Background()
194+
mockStore := new(MockStore)
195+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
196+
svc := createTestService(mockStore, new(MockEmailSender))
197+
198+
mockStore.On("GetUserByID", ctx, ceilingActorID).
199+
Return(&User{ID: ceilingActorID, GroupIDs: []string{ceilingActorGroupID}}, nil).Maybe()
200+
mockStore.On("GetGroup", ctx, ceilingActorGroupID).Return(&Group{
201+
ID: ceilingActorGroupID,
202+
Permissions: []Permission{{Action: ActionUpdate, Resource: ResourceGroups}},
203+
AllowedAccounts: []string{"acct-Z"},
204+
}, nil).Maybe()
205+
mockStore.On("GetGroup", ctx, ceilingTargetID).Return(&Group{
206+
ID: ceilingTargetID, Name: "Team", AllowedAccounts: []string{scopedAccountA, "acct-B"},
207+
}, nil)
208+
mockStore.On("UpdateGroup", ctx, mock.AnythingOfType("*auth.Group")).Return(nil).Once()
209+
210+
_, err := svc.UpdateGroupAPI(ctx, ceilingActorID, ceilingTargetID,
211+
APIUpdateGroupRequest{AllowedAccounts: []string{scopedAccountA}})
212+
require.NoError(t, err, "narrowing the group's own scope is never a widening")
213+
}
214+
215+
// CreateGroupAPI has no existing scope to compare against, so any
216+
// allowed_accounts value must sit inside the creator's own.
217+
func TestAccountCeiling_CreateIsBounded(t *testing.T) {
218+
ctx := context.Background()
219+
220+
t.Run("out of scope is refused", func(t *testing.T) {
221+
mockStore := new(MockStore)
222+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
223+
svc := createTestService(mockStore, new(MockEmailSender))
224+
225+
mockStore.On("GetUserByID", ctx, ceilingActorID).
226+
Return(&User{ID: ceilingActorID, GroupIDs: []string{ceilingActorGroupID}}, nil)
227+
mockStore.On("GetGroup", ctx, ceilingActorGroupID).Return(&Group{
228+
ID: ceilingActorGroupID,
229+
Permissions: []Permission{{Action: ActionCreate, Resource: ResourceGroups}},
230+
AllowedAccounts: []string{scopedAccountA},
231+
}, nil)
232+
233+
_, err := svc.CreateGroupAPI(ctx, ceilingActorID, APICreateGroupRequest{
234+
Name: "Wider",
235+
AllowedAccounts: []string{"acct-B"},
236+
})
237+
238+
require.Error(t, err)
239+
assert.ErrorIs(t, err, ErrPermissionCeiling)
240+
mockStore.AssertNotCalled(t, "CreateGroup", mock.Anything, mock.Anything)
241+
})
242+
243+
t.Run("in scope is allowed", func(t *testing.T) {
244+
mockStore := new(MockStore)
245+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
246+
svc := createTestService(mockStore, new(MockEmailSender))
247+
248+
mockStore.On("GetUserByID", ctx, ceilingActorID).
249+
Return(&User{ID: ceilingActorID, GroupIDs: []string{ceilingActorGroupID}}, nil)
250+
mockStore.On("GetGroup", ctx, ceilingActorGroupID).Return(&Group{
251+
ID: ceilingActorGroupID,
252+
Permissions: []Permission{{Action: ActionCreate, Resource: ResourceGroups}},
253+
AllowedAccounts: []string{scopedAccountA},
254+
}, nil)
255+
mockStore.On("CreateGroup", ctx, mock.AnythingOfType("*auth.Group")).Return(nil).Once()
256+
257+
_, err := svc.CreateGroupAPI(ctx, ceilingActorID, APICreateGroupRequest{
258+
Name: "Narrower",
259+
AllowedAccounts: []string{scopedAccountA},
260+
})
261+
require.NoError(t, err)
262+
})
263+
}
264+
265+
// Fail closed: an actor whose scope cannot be resolved cannot widen.
266+
func TestAccountCeiling_FailsClosedOnUnidentifiedActor(t *testing.T) {
267+
ctx := context.Background()
268+
mockStore := new(MockStore)
269+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
270+
svc := createTestService(mockStore, new(MockEmailSender))
271+
272+
stubTargetGroup(ctx, mockStore, &Group{
273+
ID: ceilingTargetID, Name: "Team", AllowedAccounts: []string{scopedAccountA},
274+
})
275+
276+
_, err := svc.UpdateGroupAPI(ctx, "", ceilingTargetID,
277+
APIUpdateGroupRequest{AllowedAccounts: []string{"*"}})
278+
279+
require.Error(t, err)
280+
assert.ErrorIs(t, err, ErrPermissionCeiling)
281+
mockStore.AssertNotCalled(t, "UpdateGroup", mock.Anything, mock.Anything)
282+
}

‎internal/auth/group_ceiling.go‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,104 @@ func validateRequestedPermissions(requested []Permission) error {
138138
return nil
139139
}
140140

141+
// grantCeilingAccounts returns the account scope a group write is measured
142+
// against: the union of the acting principal's groups' AllowedAccounts.
143+
// Fails closed on an unidentified actor or a resolution error.
144+
//
145+
// The stateless admin API key has no user row and is unrestricted everywhere
146+
// else (see getAllowedAccounts in internal/api), so it is unrestricted here.
147+
func (s *Service) grantCeilingAccounts(ctx context.Context, actorUserID string) ([]string, error) {
148+
if actorUserID == "" {
149+
return nil, fmt.Errorf("%w: the acting user could not be identified", ErrPermissionCeiling)
150+
}
151+
if actorUserID == AdminAPIKeyActorID {
152+
return nil, nil
153+
}
154+
authCtx, err := s.BuildAuthContext(ctx, actorUserID)
155+
if err != nil {
156+
return nil, fmt.Errorf("%w: could not resolve the acting user's account scope: %w", ErrPermissionCeiling, err)
157+
}
158+
return authCtx.AllowedAccounts, nil
159+
}
160+
161+
// checkAccountCeiling bounds the OTHER account dimension of a group write.
162+
//
163+
// This is deliberately a separate call from checkGrantCeiling rather than a
164+
// branch inside it. checkGrantCeiling returns early when no permissions are
165+
// sent, and APIUpdateGroupRequest's "empty means not sent" contract makes an
166+
// allowed_accounts-only PUT the natural shape -- so folding this in would
167+
// leave exactly the request that widens account scope unchecked. That was the
168+
// live gap: widening AllowedAccounts to more accounts, to [] or to ["*"] was
169+
// accepted, while widening Permissions[].Constraints.AccountIDs on the SAME
170+
// call was correctly refused.
171+
//
172+
// requested == nil means "not sent" and is left alone. A write is in ceiling
173+
// if it does not widen the group's existing scope, or if it stays within the
174+
// acting principal's own scope.
175+
func (s *Service) checkAccountCeiling(ctx context.Context, actorUserID string, requested, existing []string) error {
176+
if requested == nil {
177+
return nil
178+
}
179+
// Not a widening of what the group already had -- safe whoever the actor
180+
// is. Only reachable on update: a new group has no prior scope, and nil
181+
// existing would read as UNRESTRICTED here and swallow every check, which
182+
// is why CreateGroupAPI calls checkAccountGrant directly instead.
183+
if accountScopeGap(existing, requested) == "" {
184+
return nil
185+
}
186+
return s.checkAccountGrant(ctx, actorUserID, requested)
187+
}
188+
189+
// checkAccountGrant requires requested to sit inside the acting principal's
190+
// own account scope. This is the create-path entry point, where there is no
191+
// prior scope to compare against, so every value is a grant.
192+
//
193+
// A nil requested is checked too, and deliberately: on create, omitting
194+
// allowed_accounts produces an UNRESTRICTED group (IsUnrestrictedAccess reads
195+
// empty as "all accounts"), so for a scoped actor that is the widest possible
196+
// grant rather than a no-op.
197+
func (s *Service) checkAccountGrant(ctx context.Context, actorUserID string, requested []string) error {
198+
actorAccounts, err := s.grantCeilingAccounts(ctx, actorUserID)
199+
if err != nil {
200+
return err
201+
}
202+
if gap := accountScopeGap(actorAccounts, requested); gap != "" {
203+
return fmt.Errorf(
204+
"%w: cannot grant %s because your own account scope does not include it",
205+
ErrPermissionCeiling, gap)
206+
}
207+
return nil
208+
}
209+
210+
// accountScopeGap returns a description of the first way requested reaches
211+
// beyond outer, or "" when outer covers it entirely.
212+
//
213+
// Empty and "*" both mean UNRESTRICTED on either side (IsUnrestrictedAccess),
214+
// which is why this cannot be a plain subset test: the empty set is a subset
215+
// of everything but means the opposite of narrow. An unrestricted request
216+
// against a restricted holder is the widening this exists to catch.
217+
//
218+
// Comparison is exact, matching MatchesAccount. Case folding would only make
219+
// the check more permissive, which is the wrong direction for a ceiling.
220+
func accountScopeGap(outer, requested []string) string {
221+
if IsUnrestrictedAccess(outer) {
222+
return ""
223+
}
224+
if IsUnrestrictedAccess(requested) {
225+
return "unrestricted access to all cloud accounts"
226+
}
227+
permitted := make(map[string]bool, len(outer))
228+
for _, a := range outer {
229+
permitted[strings.TrimSpace(a)] = true
230+
}
231+
for _, a := range requested {
232+
if !permitted[strings.TrimSpace(a)] {
233+
return fmt.Sprintf("access to cloud account %q", a)
234+
}
235+
}
236+
return ""
237+
}
238+
141239
// grantCeilingAllows reports whether actorPerms holds req in full. Action and
142240
// resource matching mirrors permissionsAllow exactly, including the admin:*
143241
// carve-out, so the ceiling can never be looser than enforcement. It adds one

0 commit comments

Comments
 (0)