Skip to content

Commit 49f28f8

Browse files
authored
fix(controlplane): restrict organization updates to admins of the target org (CP-N1) (#3321)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 7ee465c commit 49f28f8

5 files changed

Lines changed: 186 additions & 4 deletions

File tree

‎app/controlplane/internal/service/organization.go‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,19 @@ func (s *OrganizationService) Update(ctx context.Context, req *pb.OrganizationSe
8282
return nil, err
8383
}
8484

85+
currentOrg, err := requireCurrentOrg(ctx)
86+
if err != nil {
87+
return nil, err
88+
}
89+
90+
// The authorization middleware evaluates the caller's role against the organization
91+
// selected in the request headers, so the update has to target that same organization.
92+
// Honoring an arbitrary name here would let an admin of one organization change the
93+
// settings of another one they merely belong to.
94+
if req.Name != currentOrg.Name {
95+
return nil, errors.Forbidden("forbidden", "the organization to update must be the currently selected one")
96+
}
97+
8598
// we want to differentiate between setting the value to empty or not setting it at all
8699
// to do that we will use a nil slice to represent not setting it at all
87100
var policiesAllowedHostnames []string
@@ -102,7 +115,7 @@ func (s *OrganizationService) Update(ctx context.Context, req *pb.OrganizationSe
102115
apiTokenMaxDaysInactive = &days
103116
}
104117

105-
org, err := s.orgUC.Update(ctx, currentUser.ID, req.Name, &biz.OrganizationUpdateOpts{
118+
org, err := s.orgUC.Update(ctx, currentUser.ID, currentOrg.Name, &biz.OrganizationUpdateOpts{
106119
BlockOnPolicyViolation: req.BlockOnPolicyViolation,
107120
PoliciesAllowedHostnames: policiesAllowedHostnames,
108121
PreventImplicitWorkflowCreation: req.PreventImplicitWorkflowCreation,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package service
17+
18+
import (
19+
"context"
20+
"testing"
21+
22+
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
23+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
24+
"github.com/go-kratos/kratos/v2/errors"
25+
"github.com/google/uuid"
26+
"github.com/stretchr/testify/assert"
27+
"github.com/stretchr/testify/require"
28+
)
29+
30+
// TestUpdateIsPinnedToCurrentOrg is a regression test for CP-N1. The authz
31+
// middleware evaluates the caller's role against the organization selected in
32+
// the request headers, so Update must refuse to operate on any other
33+
// organization. Otherwise an admin of org A could target org B by naming it in
34+
// the request body.
35+
func TestUpdateIsPinnedToCurrentOrg(t *testing.T) {
36+
// A nil use case is deliberate: a request that reaches the biz layer means
37+
// the guard did not run, and the test fails loudly instead of silently
38+
// passing.
39+
svc := NewOrganizationService(nil, nil)
40+
41+
ctxWithOrg := func(orgName string) context.Context {
42+
ctx := entities.WithCurrentUser(context.Background(), &entities.User{ID: uuid.NewString(), Email: "user@test.com"})
43+
return entities.WithCurrentOrg(ctx, &entities.Org{ID: uuid.NewString(), Name: orgName})
44+
}
45+
46+
testCases := []struct {
47+
name string
48+
currentOrg string
49+
reqName string
50+
}{
51+
{name: "different organization", currentOrg: "my-org", reqName: "victim-org"},
52+
{name: "empty name", currentOrg: "my-org", reqName: ""},
53+
{name: "case variation", currentOrg: "my-org", reqName: "My-Org"},
54+
}
55+
56+
for _, tc := range testCases {
57+
t.Run(tc.name, func(t *testing.T) {
58+
got, err := svc.Update(ctxWithOrg(tc.currentOrg), &pb.OrganizationServiceUpdateRequest{
59+
Name: tc.reqName,
60+
BlockOnPolicyViolation: toPtrBool(false),
61+
})
62+
63+
require.Error(t, err)
64+
assert.Nil(t, got)
65+
assert.True(t, errors.IsForbidden(err), "want forbidden, got %v", err)
66+
})
67+
}
68+
}
69+
70+
func toPtrBool(b bool) *bool {
71+
return &b
72+
}

‎app/controlplane/pkg/biz/apitoken_stale_revoker_integration_test.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"testing"
2323
"time"
2424

25+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
2526
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
2627
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers"
2728
"github.com/google/uuid"
@@ -195,8 +196,8 @@ func (s *staleRevokerTestSuite) createOrgWithThreshold(ctx context.Context, days
195196
org, err := s.Organization.CreateWithRandomName(ctx)
196197
require.NoError(s.T(), err)
197198

198-
// Need a membership so Update works
199-
_, err = s.Membership.Create(ctx, org.ID, s.user.ID, biz.WithCurrentMembership())
199+
// Need an admin membership so Update works
200+
_, err = s.Membership.Create(ctx, org.ID, s.user.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership())
200201
require.NoError(s.T(), err)
201202

202203
org, err = s.Organization.Update(ctx, s.user.ID, org.Name, &biz.OrganizationUpdateOpts{

‎app/controlplane/pkg/biz/organization.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,14 @@ func (uc *OrganizationUseCase) Update(ctx context.Context, userID, orgName strin
245245
return nil, NewErrNotFound("membership")
246246
}
247247

248+
// These settings are organization-wide security controls, so they require an
249+
// admin membership in the organization being updated. Authorizing against this
250+
// membership, and not against the caller's current role, is what keeps a user
251+
// from tampering with another organization they happen to belong to.
252+
if !membership.Role.IsAdmin() {
253+
return nil, NewErrUnauthorizedStr("only organization admins can update the organization settings")
254+
}
255+
248256
orgUUID, err := uuid.Parse(membership.Org.ID)
249257
if err != nil {
250258
return nil, NewErrInvalidUUID(err)

‎app/controlplane/pkg/biz/organization_integration_test.go‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package biz_test
1717

1818
import (
1919
"context"
20+
"fmt"
2021
"testing"
2122

2223
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
@@ -196,6 +197,93 @@ func (s *OrgIntegrationTestSuite) TestUpdate() {
196197
})
197198
}
198199

200+
// TestUpdateRequiresAdminMembership verifies that changing organization-wide
201+
// security settings requires an admin/owner membership in the organization
202+
// being updated. Holding any membership is not enough: the settings gated here
203+
// (policy enforcement, allowed policy hostnames, runner env-var capture) are
204+
// security controls for the whole org.
205+
func (s *OrgIntegrationTestSuite) TestUpdateRequiresAdminMembership() {
206+
ctx := context.Background()
207+
208+
testCases := []struct {
209+
name string
210+
role authz.Role
211+
allowed bool
212+
}{
213+
{name: "owner can update", role: authz.RoleOwner, allowed: true},
214+
{name: "admin can update", role: authz.RoleAdmin, allowed: true},
215+
{name: "viewer cannot update", role: authz.RoleViewer},
216+
{name: "member cannot update", role: authz.RoleOrgMember},
217+
{name: "contributor cannot update", role: authz.RoleOrgContributor},
218+
}
219+
220+
for _, tc := range testCases {
221+
s.Run(tc.name, func() {
222+
org, err := s.Organization.CreateWithRandomName(ctx)
223+
require.NoError(s.T(), err)
224+
225+
user, err := s.User.UpsertByEmail(ctx, fmt.Sprintf("%s@test.com", uuid.NewString()), nil)
226+
require.NoError(s.T(), err)
227+
228+
_, err = s.Membership.Create(ctx, org.ID, user.ID, biz.WithMembershipRole(tc.role))
229+
require.NoError(s.T(), err)
230+
231+
got, err := s.Organization.Update(ctx, user.ID, org.Name, &biz.OrganizationUpdateOpts{
232+
BlockOnPolicyViolation: toPtrBool(false),
233+
})
234+
235+
if tc.allowed {
236+
s.NoError(err)
237+
s.False(got.BlockOnPolicyViolation)
238+
return
239+
}
240+
241+
s.Error(err)
242+
s.True(biz.IsErrUnauthorized(err), "want unauthorized, got %v", err)
243+
s.Nil(got)
244+
})
245+
}
246+
}
247+
248+
// TestUpdateCrossOrgTampering is a regression test for CP-N1: a user who is an
249+
// owner of their own organization but only a viewer of a victim organization
250+
// must not be able to change the victim's security settings. The authz
251+
// middleware evaluates the caller's role against the organization in the
252+
// request header, so the biz layer has to authorize against the organization
253+
// actually being updated.
254+
func (s *OrgIntegrationTestSuite) TestUpdateCrossOrgTampering() {
255+
ctx := context.Background()
256+
257+
victimOrg, err := s.Organization.CreateWithRandomName(ctx)
258+
require.NoError(s.T(), err)
259+
attackerOrg, err := s.Organization.CreateWithRandomName(ctx)
260+
require.NoError(s.T(), err)
261+
262+
attacker, err := s.User.UpsertByEmail(ctx, "attacker@test.com", nil)
263+
require.NoError(s.T(), err)
264+
265+
// Owner of their own org, which is what gets them past the authz middleware
266+
_, err = s.Membership.Create(ctx, attackerOrg.ID, attacker.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership())
267+
require.NoError(s.T(), err)
268+
// ...but only a viewer of the victim org
269+
_, err = s.Membership.Create(ctx, victimOrg.ID, attacker.ID, biz.WithMembershipRole(authz.RoleViewer))
270+
require.NoError(s.T(), err)
271+
272+
got, err := s.Organization.Update(ctx, attacker.ID, victimOrg.Name, &biz.OrganizationUpdateOpts{
273+
BlockOnPolicyViolation: toPtrBool(false),
274+
PoliciesAllowedHostnames: []string{"evil.example.com"},
275+
SkipRunnerEnvVars: toPtrBool(false),
276+
})
277+
s.Error(err)
278+
s.True(biz.IsErrUnauthorized(err), "want unauthorized, got %v", err)
279+
s.Nil(got)
280+
281+
// The victim org keeps its settings
282+
victim, err := s.Organization.FindByName(ctx, victimOrg.Name)
283+
s.NoError(err)
284+
s.Empty(victim.PoliciesAllowedHostnames)
285+
}
286+
199287
// We are doing an integration test here because there are some database constraints
200288
// and delete cascades that we want to validate that they work too
201289
func (s *OrgIntegrationTestSuite) TestDeleteOrg() {
@@ -288,7 +376,7 @@ func (s *OrgIntegrationTestSuite) SetupTest() {
288376

289377
s.user, err = s.User.UpsertByEmail(ctx, "foo@test.com", nil)
290378
assert.NoError(err)
291-
_, err = s.Membership.Create(ctx, s.org.ID, s.user.ID, biz.WithCurrentMembership())
379+
_, err = s.Membership.Create(ctx, s.org.ID, s.user.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership())
292380
assert.NoError(err)
293381

294382
// Integration

0 commit comments

Comments
 (0)