Skip to content

Commit 4a8ea33

Browse files
authored
fix(rbac): org creation authz changes (#2361)
Signed-off-by: Miguel Martinez <[email protected]>
1 parent 57aaae8 commit 4a8ea33

File tree

4 files changed

+61
-53
lines changed

4 files changed

+61
-53
lines changed

app/controlplane/internal/service/organization.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,28 @@ func (s *OrganizationService) Update(ctx context.Context, req *pb.OrganizationSe
9898
}
9999

100100
func (s *OrganizationService) Delete(ctx context.Context, req *pb.OrganizationServiceDeleteRequest) (*pb.OrganizationServiceDeleteResponse, error) {
101-
currentUser, err := requireCurrentUser(ctx)
102-
if err != nil {
101+
if _, err := requireCurrentUser(ctx); err != nil {
103102
return nil, err
104103
}
105104

106-
if err := s.orgUC.DeleteByUser(ctx, req.Name, currentUser.ID); err != nil {
105+
// Find the organization to get its UUID for authorization
106+
org, err := s.orgUC.FindByName(ctx, req.Name)
107+
if err != nil {
108+
return nil, handleUseCaseErr(err, s.log)
109+
}
110+
111+
orgUUID, err := uuid.Parse(org.ID)
112+
if err != nil {
113+
return nil, handleUseCaseErr(biz.NewErrInvalidUUID(err), s.log)
114+
}
115+
116+
// Check if user has permission to delete this specific organization
117+
// Force RBAC to ensure only owners can delete, even if they have admin privileges elsewhere
118+
if err := s.authorizeResource(ctx, authz.PolicyOrganizationDelete, authz.ResourceTypeOrganization, orgUUID, withForceRBAC()); err != nil {
119+
return nil, errors.Forbidden("forbidden", "only organization owners can delete the organization")
120+
}
121+
122+
if err := s.orgUC.Delete(ctx, orgUUID.String()); err != nil {
107123
return nil, handleUseCaseErr(err, s.log)
108124
}
109125

app/controlplane/internal/service/service.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,32 @@ func WithGroupUseCase(groupUseCase *biz.GroupUseCase) NewOpt {
164164
}
165165
}
166166

167+
type authorizeResourceOpts struct {
168+
forceRBAC bool
169+
}
170+
171+
type AuthorizeResourceOpt func(*authorizeResourceOpts)
172+
173+
// withForceRBAC forces RBAC checks even for admin roles that would normally skip RBAC
174+
func withForceRBAC() AuthorizeResourceOpt {
175+
return func(opts *authorizeResourceOpts) {
176+
opts.forceRBAC = true
177+
}
178+
}
179+
167180
// authorizeResource is a helper that checks if the user has a particular `op` permission policy on a particular resource
168181
// For example: `s.authorizeResource(ctx, authz.PolicyAttachedIntegrationDetach, authz.ResourceTypeProject, projectUUID);`
169182
// checks if the user has a role in the project that allows to detach integrations on it.
170183
// This method is available to every service that embeds `service`
171184
// It goes through all the memberships of the user, direct memberships and indirect memberships (Groups)
172185
// and checks if the user has any role that allows the operation on the resourceType and resourceID.
173-
func (s *service) authorizeResource(ctx context.Context, op *authz.Policy, resourceType authz.ResourceType, resourceID uuid.UUID) error {
174-
if !rbacEnabled(ctx) {
186+
func (s *service) authorizeResource(ctx context.Context, op *authz.Policy, resourceType authz.ResourceType, resourceID uuid.UUID, opts ...AuthorizeResourceOpt) error {
187+
options := &authorizeResourceOpts{}
188+
for _, opt := range opts {
189+
opt(options)
190+
}
191+
192+
if !options.forceRBAC && !rbacEnabled(ctx) {
175193
return nil
176194
}
177195

@@ -188,24 +206,28 @@ func (s *service) authorizeResource(ctx context.Context, op *authz.Policy, resou
188206
return errors.Forbidden("forbidden", fmt.Errorf("operation not allowed: This auth token is valid only with the project %q", *token.ProjectName).Error())
189207
}
190208

209+
var defaultMessage = fmt.Sprintf("you do not have permissions to access the %q with id %q", resourceType, resourceID.String())
191210
// 2 - We are a user
192211
// find the resource membership that matches the resource type and ID
193212
// for example admin in project1, then apply RBAC enforcement
194213
m := entities.CurrentMembership(ctx)
195214
var matchingResources []*entities.ResourceMembership
215+
var foundRoles []string
196216
// First, collect all memberships that match the requested resource type and ID
197217
for _, rm := range m.Resources {
198218
if rm.ResourceType == resourceType && rm.ResourceID == resourceID {
199219
matchingResources = append(matchingResources, rm)
220+
foundRoles = append(foundRoles, string(rm.Role))
200221
}
201222
}
202223

203-
var defaultMessage = fmt.Sprintf("you do not have permissions to access to the %s associated with this resource", resourceType)
204224
// If no matching resources were found, return forbidden error
205225
if len(matchingResources) == 0 {
206226
return errors.Forbidden("forbidden", defaultMessage)
207227
}
208228

229+
defaultMessage = fmt.Sprintf("%s, roles=%v", defaultMessage, foundRoles)
230+
209231
// Try to enforce the policy with each matching role
210232
// If any role passes, authorize the request
211233
for _, rm := range matchingResources {

app/controlplane/pkg/authz/authz.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@ var (
167167
// Projects
168168
PolicyProjectCreate = &Policy{ResourceProject, ActionCreate}
169169

170+
// Organization
170171
PolicyOrganizationCreate = &Policy{Organization, ActionCreate}
172+
PolicyOrganizationDelete = &Policy{Organization, ActionDelete}
171173
// User Membership
172174
PolicyOrganizationRead = &Policy{Organization, ActionRead}
173175
PolicyOrganizationListMemberships = &Policy{OrganizationMemberships, ActionList}
@@ -200,6 +202,18 @@ var RolesMap = map[Role][]*Policy{
200202
RoleInstanceAdmin: {
201203
PolicyOrganizationCreate,
202204
},
205+
RoleOwner: {
206+
PolicyOrganizationDelete,
207+
},
208+
// RoleAdmin is an org-scoped role that provides super admin privileges (it's the higher role)
209+
RoleAdmin: {
210+
// We do a manual check in the artifact upload endpoint
211+
// so we need the actual policy in place skipping it is not enough
212+
PolicyArtifactUpload,
213+
// We manually check this policy to be able to know if the user can invite users to the system
214+
PolicyOrganizationInvitationsCreate,
215+
// + all the policies from the viewer role inherited automatically
216+
},
203217
// RoleViewer is an org-scoped role that provides read-only access to all resources
204218
RoleViewer: {
205219
// Referrer
@@ -234,16 +248,6 @@ var RolesMap = map[Role][]*Policy{
234248
// List organization memberships
235249
PolicyOrganizationListMemberships,
236250
},
237-
// RoleAdmin is an org-scoped role that provides super admin privileges (it's the higher role)
238-
RoleAdmin: {
239-
// We do a manual check in the artifact upload endpoint
240-
// so we need the actual policy in place skipping it is not enough
241-
PolicyArtifactUpload,
242-
// We manually check this policy to be able to know if the user can invite users to the system
243-
PolicyOrganizationInvitationsCreate,
244-
// + all the policies from the viewer role inherited automatically
245-
},
246-
247251
// RoleOrgMember is an org-scoped role that enables RBAC in the underlying resources. Users with this role at
248252
// the organization level will need specific project roles to access their contents
249253
RoleOrgContributor: {
@@ -403,6 +407,9 @@ var ServerOperationsMap = map[string][]*Policy{
403407
// since all the permissions here are in the context of an organization
404408
// Create new organization
405409
"/controlplane.v1.OrganizationService/Create": {},
410+
// Delete an organization makes checks at the service level since the
411+
// user can explicitly set the org they want to delete and might not be the current one
412+
"/controlplane.v1.OrganizationService/Delete": {},
406413

407414
// List global memberships
408415
"/controlplane.v1.OrganizationService/ListMemberships": {PolicyOrganizationListMemberships},

app/controlplane/pkg/biz/organization.go

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -290,43 +290,6 @@ func (uc *OrganizationUseCase) Delete(ctx context.Context, id string) error {
290290
return uc.orgRepo.Delete(ctx, orgUUID)
291291
}
292292

293-
// DeleteByUser deletes an organization initiated by a user with owner validation
294-
// Only organization owners can delete an organization
295-
func (uc *OrganizationUseCase) DeleteByUser(ctx context.Context, orgName, userID string) error {
296-
// Find organization by name
297-
org, err := uc.orgRepo.FindByName(ctx, orgName)
298-
if err != nil {
299-
return err
300-
} else if org == nil {
301-
return NewErrNotFound("organization")
302-
}
303-
304-
orgUUID, err := uuid.Parse(org.ID)
305-
if err != nil {
306-
return NewErrInvalidUUID(err)
307-
}
308-
309-
userUUID, err := uuid.Parse(userID)
310-
if err != nil {
311-
return NewErrInvalidUUID(err)
312-
}
313-
314-
// Check if user is an owner of the organization
315-
m, err := uc.membershipRepo.FindByOrgAndUser(ctx, orgUUID, userUUID)
316-
if err != nil {
317-
return fmt.Errorf("failed to find owners: %w", err)
318-
}
319-
320-
if m == nil || m.Role != authz.RoleOwner {
321-
return NewErrValidationStr("only organization owners can delete the organization")
322-
}
323-
324-
uc.logger.Infow("msg", "User deleting organization", "user_id", userID, "organization_id", org.ID)
325-
326-
// Use the existing Delete method to handle the actual deletion
327-
return uc.Delete(ctx, org.ID)
328-
}
329-
330293
// AutoOnboardOrganizations creates the organizations specified in the onboarding config and assigns the user to them
331294
// with the specified role if they are not already a member.
332295
func (uc *OrganizationUseCase) AutoOnboardOrganizations(ctx context.Context, userID string) error {

0 commit comments

Comments
 (0)