From 4aeb65d212698044316e0db542656bd6fd479299 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 4 Feb 2026 10:37:00 +0100 Subject: [PATCH 01/14] allow creating orgs to tokens with proper permissions Signed-off-by: Jose I. Paris --- .../internal/service/organization.go | 33 +++++++---------- app/controlplane/internal/service/service.go | 35 +++++++++++++++++++ app/controlplane/pkg/authz/authz.go | 3 +- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/app/controlplane/internal/service/organization.go b/app/controlplane/internal/service/organization.go index 1268c1bfd..fda34b92e 100644 --- a/app/controlplane/internal/service/organization.go +++ b/app/controlplane/internal/service/organization.go @@ -43,9 +43,9 @@ func NewOrganizationService(muc *biz.MembershipUseCase, ouc *biz.OrganizationUse } } -// Create persists an organization with a given name and associate it to the current user. +// Create persists an organization with a given name and associates it with the current user. func (s *OrganizationService) Create(ctx context.Context, req *pb.OrganizationServiceCreateRequest) (*pb.OrganizationServiceCreateResponse, error) { - currentUser, err := requireCurrentUser(ctx) + currentUser, _, err := requireCurrentUserOrAPIToken(ctx) if err != nil { return nil, err } @@ -65,8 +65,11 @@ func (s *OrganizationService) Create(ctx context.Context, req *pb.OrganizationSe return nil, handleUseCaseErr(err, s.log) } - if _, err := s.membershipUC.Create(ctx, org.ID, currentUser.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership()); err != nil { - return nil, handleUseCaseErr(err, s.log) + // Add membership if invoker is a user + if currentUser != nil { + if _, err := s.membershipUC.Create(ctx, org.ID, currentUser.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership()); err != nil { + return nil, handleUseCaseErr(err, s.log) + } } return &pb.OrganizationServiceCreateResponse{Result: bizOrgToPb(org)}, nil @@ -211,25 +214,15 @@ func (s *OrganizationService) UpdateMembership(ctx context.Context, req *pb.Orga } func (s *OrganizationService) canCreateOrganization(ctx context.Context) (bool, error) { - // Restricted org creation is disabled, allow creation - if !s.authz.RestrictOrgCreation { + // Restricted org creation is disabled, allow creation only to users + if !s.authz.RestrictOrgCreation && entities.CurrentMembership(ctx) != nil { return true, nil } - m := entities.CurrentMembership(ctx) - for _, rm := range m.Resources { - if rm.ResourceType != authz.ResourceTypeInstance { - continue - } - - pass, err := s.authz.Enforce(ctx, string(rm.Role), authz.PolicyOrganizationCreate) - if err != nil { - return false, handleUseCaseErr(err, s.log) - } - if pass { - return true, nil - } + // otherwise, check for permissions + if err := s.checkPolicy(ctx, authz.PolicyOrganizationCreate); err != nil { + return false, err } - return false, nil + return true, nil } diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index 44830d9aa..bcdda7ee6 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -329,6 +329,41 @@ func (s *service) visibleProjects(ctx context.Context) []uuid.UUID { return projects } +// checkPolicy Checks a policy against a user or a token +func (s *service) checkPolicy(ctx context.Context, policy *authz.Policy) error { + _, token, err := requireCurrentUserOrAPIToken(ctx) + if err != nil { + return err + } + + // Token case + if token != nil { + for _, p := range token.Policies { + if p.Resource == policy.Resource && p.Action == policy.Action { + return nil + } + } + return errors.Forbidden("forbidden", "not allowed") + } + + // user case + m := entities.CurrentMembership(ctx) + if m == nil { + return errors.Forbidden("forbidden", "not allowed") + } + for _, rm := range m.Resources { + pass, err := s.authz.Enforce(ctx, string(rm.Role), authz.PolicyOrganizationCreate) + if err != nil { + return handleUseCaseErr(err, s.log) + } + if pass { + return nil + } + } + + return errors.Forbidden("forbidden", "not allowed") +} + // isUserOrgAdmin checks if the current user is an org admin or owner func isUserOrgAdmin(ctx context.Context) bool { userRole := usercontext.CurrentAuthzSubject(ctx) diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index bad0a6534..25d84e1fc 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -386,7 +386,8 @@ var ServerOperationsMap = map[string][]*Policy{ "/controlplane.v1.ContextService/Current": {PolicyOrganizationRead}, // Listing, create or selecting an organization does not have any required permissions, // since all the permissions here are in the context of an organization - // Create new organization + // Create new organization. No user required at middleware level. The endpoint will handle + // it based on diverse conditions "/controlplane.v1.OrganizationService/Create": {}, // Delete an organization makes checks at the service level since the // user can explicitly set the org they want to delete and might not be the current one From 838f00cf28a6069771a62b3594de5647b26c78bb Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 4 Feb 2026 11:12:34 +0100 Subject: [PATCH 02/14] fix Signed-off-by: Jose I. Paris --- app/controlplane/internal/service/organization.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/controlplane/internal/service/organization.go b/app/controlplane/internal/service/organization.go index fda34b92e..b4c7219ab 100644 --- a/app/controlplane/internal/service/organization.go +++ b/app/controlplane/internal/service/organization.go @@ -215,8 +215,11 @@ func (s *OrganizationService) UpdateMembership(ctx context.Context, req *pb.Orga func (s *OrganizationService) canCreateOrganization(ctx context.Context) (bool, error) { // Restricted org creation is disabled, allow creation only to users - if !s.authz.RestrictOrgCreation && entities.CurrentMembership(ctx) != nil { - return true, nil + if !s.authz.RestrictOrgCreation { + if entities.CurrentMembership(ctx) != nil { + return true, nil + } + return false, nil } // otherwise, check for permissions From fbee36e85bb37c32c452ed8533377c06f39352fd Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 4 Feb 2026 13:58:14 +0100 Subject: [PATCH 03/14] create invitations Signed-off-by: Jose I. Paris --- app/controlplane/internal/service/context.go | 3 + .../internal/service/orginvitation.go | 16 +++++- .../currentorganization_middleware.go | 1 + app/controlplane/pkg/authz/authz.go | 6 ++ .../pkg/authz/middleware/middleware.go | 1 + app/controlplane/pkg/biz/group.go | 5 +- app/controlplane/pkg/biz/orginvitation.go | 56 ++++++++++--------- app/controlplane/pkg/biz/project.go | 5 +- .../ent/migrate/migrations/20260204113827.sql | 2 + .../pkg/data/ent/migrate/migrations/atlas.sum | 3 +- .../pkg/data/ent/migrate/schema.go | 2 +- app/controlplane/pkg/data/ent/mutation.go | 21 ++++++- .../pkg/data/ent/orginvitation/where.go | 10 ++++ .../pkg/data/ent/orginvitation_create.go | 34 +++++++++-- .../pkg/data/ent/orginvitation_update.go | 18 ++++-- .../pkg/data/ent/schema/orginvitation.go | 4 +- app/controlplane/pkg/data/orginvitation.go | 13 +++-- 17 files changed, 147 insertions(+), 53 deletions(-) create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20260204113827.sql diff --git a/app/controlplane/internal/service/context.go b/app/controlplane/internal/service/context.go index a9dd6aac5..ef8a3c7d8 100644 --- a/app/controlplane/internal/service/context.go +++ b/app/controlplane/internal/service/context.go @@ -127,6 +127,9 @@ func bizOrgToPb(m *biz.Organization) *pb.OrgItem { } func bizUserToPb(u *biz.User) *pb.User { + if u == nil { + return nil + } return &pb.User{Id: u.ID, Email: u.Email, CreatedAt: timestamppb.New(*u.CreatedAt), FirstName: u.FirstName, LastName: u.LastName, UpdatedAt: timestamppb.New(*u.UpdatedAt), diff --git a/app/controlplane/internal/service/orginvitation.go b/app/controlplane/internal/service/orginvitation.go index c05b00829..bbec4a48e 100644 --- a/app/controlplane/internal/service/orginvitation.go +++ b/app/controlplane/internal/service/orginvitation.go @@ -20,6 +20,7 @@ import ( pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" + "github.com/google/uuid" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -38,7 +39,7 @@ func NewOrgInvitationService(uc *biz.OrgInvitationUseCase, opts ...NewOpt) *OrgI } func (s *OrgInvitationService) Create(ctx context.Context, req *pb.OrgInvitationServiceCreateRequest) (*pb.OrgInvitationServiceCreateResponse, error) { - user, err := requireCurrentUser(ctx) + user, _, err := requireCurrentUserOrAPIToken(ctx) if err != nil { return nil, err } @@ -48,8 +49,17 @@ func (s *OrgInvitationService) Create(ctx context.Context, req *pb.OrgInvitation return nil, err } - // Validations and rbac checks are done in the biz layer - i, err := s.useCase.Create(ctx, org.ID, user.ID, req.ReceiverEmail, biz.WithInvitationRole(biz.PbRoleToBiz(req.Role))) + opts := []biz.InvitationCreateOpt{biz.WithInvitationRole(biz.PbRoleToBiz(req.Role))} + if user != nil { + userID, err := uuid.Parse(user.ID) + if err != nil { + return nil, handleUseCaseErr(err, s.log) + } + opts = append(opts, biz.WithSender(userID)) + } + + // Validations are done in the biz layer + i, err := s.useCase.Create(ctx, org.ID, req.ReceiverEmail, opts...) if err != nil { return nil, handleUseCaseErr(err, s.log) } diff --git a/app/controlplane/internal/usercontext/currentorganization_middleware.go b/app/controlplane/internal/usercontext/currentorganization_middleware.go index da1139d4b..624cb5562 100644 --- a/app/controlplane/internal/usercontext/currentorganization_middleware.go +++ b/app/controlplane/internal/usercontext/currentorganization_middleware.go @@ -62,6 +62,7 @@ func WithCurrentOrganizationMiddleware(userUseCase biz.UserOrgFinder, logger *lo // Get the current user and return if not found, meaning we are probably coming from an API Token u := entities.CurrentUser(ctx) if u == nil { + // For API tokens, the organization is already set in WithCurrentAPITokenAndOrgMiddleware return handler(ctx, req) } diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 25d84e1fc..f2c94502f 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -180,7 +180,10 @@ var ( var RolesMap = map[Role][]*Policy{ // Organizations in chainloop might be restricted to instance admins RoleInstanceAdmin: { + // Instance admins can create new organizations PolicyOrganizationCreate, + // Instance admins can invite users to organizations + PolicyOrganizationInvitationsCreate, }, RoleOwner: { PolicyOrganizationDelete, @@ -429,6 +432,9 @@ var ServerOperationsMap = map[string][]*Policy{ "/controlplane.v1.APITokenService/List": {PolicyAPITokenList}, "/controlplane.v1.APITokenService/Create": {PolicyAPITokenCreate}, "/controlplane.v1.APITokenService/Revoke": {PolicyAPITokenRevoke}, + + // Org invitations + "/controlplane.v1.OrgInvitationService/Create": {PolicyOrganizationInvitationsCreate}, } // Implements https://pkg.go.dev/entgo.io/ent/schema/field#EnumValues diff --git a/app/controlplane/pkg/authz/middleware/middleware.go b/app/controlplane/pkg/authz/middleware/middleware.go index f7d7d6d2c..a55afb294 100644 --- a/app/controlplane/pkg/authz/middleware/middleware.go +++ b/app/controlplane/pkg/authz/middleware/middleware.go @@ -81,6 +81,7 @@ func checkPolicies(ctx context.Context, subject, apiOperation string, enforcer E } // For users, use role-based enforcement via Casbin + // For tokens, check for specific policies in the database for _, p := range policies { ok, err := enforcer.Enforce(ctx, subject, p) if err != nil { diff --git a/app/controlplane/pkg/biz/group.go b/app/controlplane/pkg/biz/group.go index 7a2f53c2e..3dcf2aced 100644 --- a/app/controlplane/pkg/biz/group.go +++ b/app/controlplane/pkg/biz/group.go @@ -564,7 +564,10 @@ func (uc *GroupUseCase) handleNonExistingUser(ctx context.Context, orgID, groupI } // Create an invitation for the user to join the organization - if _, err := uc.orgInvitationUC.Create(ctx, orgID.String(), opts.RequesterID.String(), opts.UserEmail, WithInvitationRole(authz.RoleOrgContributor), WithInvitationContext(invitationContext)); err != nil { + if _, err := uc.orgInvitationUC.Create(ctx, orgID.String(), opts.UserEmail, + WithSender(opts.RequesterID), + WithInvitationRole(authz.RoleOrgContributor), + WithInvitationContext(invitationContext)); err != nil { return nil, fmt.Errorf("failed to create invitation: %w", err) } diff --git a/app/controlplane/pkg/biz/orginvitation.go b/app/controlplane/pkg/biz/orginvitation.go index ddab056fa..9c4acac65 100644 --- a/app/controlplane/pkg/biz/orginvitation.go +++ b/app/controlplane/pkg/biz/orginvitation.go @@ -71,7 +71,7 @@ type OrgInvitationContext struct { } type OrgInvitationRepo interface { - Create(ctx context.Context, orgID, senderID uuid.UUID, receiverEmail string, role authz.Role, invCtx *OrgInvitationContext) (*OrgInvitation, error) + Create(ctx context.Context, orgID uuid.UUID, senderID *uuid.UUID, receiverEmail string, role authz.Role, invCtx *OrgInvitationContext) (*OrgInvitation, error) FindByID(ctx context.Context, ID uuid.UUID) (*OrgInvitation, error) PendingInvitation(ctx context.Context, orgID uuid.UUID, receiverEmail string) (*OrgInvitation, error) PendingInvitations(ctx context.Context, receiverEmail string) ([]*OrgInvitation, error) @@ -88,8 +88,9 @@ func NewOrgInvitationUseCase(r OrgInvitationRepo, mRepo MembershipRepo, uRepo Us } type invitationCreateOpts struct { - role authz.Role - ctx *OrgInvitationContext + role authz.Role + ctx *OrgInvitationContext + senderID *uuid.UUID } type InvitationCreateOpt func(*invitationCreateOpts) @@ -108,7 +109,13 @@ func WithInvitationContext(ctx *OrgInvitationContext) InvitationCreateOpt { } } -func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, senderID, receiverEmail string, createOpts ...InvitationCreateOpt) (*OrgInvitation, error) { +func WithSender(senderID uuid.UUID) InvitationCreateOpt { + return func(o *invitationCreateOpts) { + o.senderID = &senderID + } +} + +func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, receiverEmail string, createOpts ...InvitationCreateOpt) (*OrgInvitation, error) { receiverEmail = strings.ToLower(receiverEmail) // 1 - Static Validation @@ -135,29 +142,26 @@ func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, senderID, rec return nil, NewErrInvalidUUID(err) } - senderUUID, err := uuid.Parse(senderID) - if err != nil { - return nil, NewErrInvalidUUID(err) - } - // 2 - the sender exists and it's not the same than the receiver of the invitation - sender, err := uc.userRepo.FindByID(ctx, senderUUID) - if err != nil { - return nil, fmt.Errorf("error finding sender %s: %w", senderUUID.String(), err) - } else if sender == nil { - return nil, NewErrNotFound("sender") - } + if opts.senderID != nil { + sender, err := uc.userRepo.FindByID(ctx, *opts.senderID) + if err != nil { + return nil, fmt.Errorf("error finding sender %s: %w", opts.senderID.String(), err) + } else if sender == nil { + return nil, NewErrNotFound("sender") + } - if sender.Email == receiverEmail { - return nil, NewErrValidationStr("sender and receiver emails cannot be the same") - } + if sender.Email == receiverEmail { + return nil, NewErrValidationStr("sender and receiver emails cannot be the same") + } - // 3 - Check that the user is a member of the given org - // NOTE: this check is not necessary, as the user is already a member of the org - if membership, err := uc.mRepo.FindByOrgAndUser(ctx, orgUUID, senderUUID); err != nil { - return nil, fmt.Errorf("failed to find memberships: %w", err) - } else if membership == nil { - return nil, NewErrNotFound("user does not have permission to invite to this org") + // 3 - Check that the user is a member of the given org + // NOTE: this check is not necessary, as the user is already a member of the org + if membership, err := uc.mRepo.FindByOrgAndUser(ctx, orgUUID, *opts.senderID); err != nil { + return nil, fmt.Errorf("failed to find memberships: %w", err) + } else if membership == nil { + return nil, NewErrNotFound("user does not have permission to invite to this org") + } } // 4 - The receiver does exist in the org already @@ -165,7 +169,7 @@ func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, senderID, rec Email: &receiverEmail, }, pagination.NewDefaultOffsetPaginationOpts()) if err != nil { - return nil, fmt.Errorf("error finding memberships for user %s: %w", senderUUID.String(), err) + return nil, fmt.Errorf("error finding memberships for user %s: %w", receiverEmail, err) } if membershipCount > 0 { @@ -183,7 +187,7 @@ func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, senderID, rec } // 6 - Create the invitation - invitation, err := uc.repo.Create(ctx, orgUUID, senderUUID, receiverEmail, opts.role, opts.ctx) + invitation, err := uc.repo.Create(ctx, orgUUID, opts.senderID, receiverEmail, opts.role, opts.ctx) if err != nil { return nil, fmt.Errorf("error creating invitation: %w", err) } diff --git a/app/controlplane/pkg/biz/project.go b/app/controlplane/pkg/biz/project.go index bc29b2077..b1c29fc09 100644 --- a/app/controlplane/pkg/biz/project.go +++ b/app/controlplane/pkg/biz/project.go @@ -410,7 +410,10 @@ func (uc *ProjectUseCase) handleNonExistingUser(ctx context.Context, orgID, proj } // Create an invitation for the user to join the organization with project context - if _, err := uc.orgInvitationUC.Create(ctx, orgID.String(), opts.RequesterID.String(), opts.UserEmail, WithInvitationRole(authz.RoleOrgMember), WithInvitationContext(invitationContext)); err != nil { + if _, err := uc.orgInvitationUC.Create(ctx, orgID.String(), opts.UserEmail, + WithSender(opts.RequesterID), + WithInvitationRole(authz.RoleOrgMember), + WithInvitationContext(invitationContext)); err != nil { return nil, fmt.Errorf("failed to create invitation: %w", err) } diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260204113827.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260204113827.sql new file mode 100644 index 000000000..e473b37db --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260204113827.sql @@ -0,0 +1,2 @@ +-- Modify "org_invitations" table +ALTER TABLE "org_invitations" ALTER COLUMN "sender_id" DROP NOT NULL; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index 80dc1e7eb..04f33f296 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum +++ b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:Xgi8kkxc2dgEdAYSi1CmSXcczC+eiY2CIURHYEjCH3c= +h1:HfuhzadCjBbwi+tW82Z8bEYHpv2onjcYBfwov7Z/9II= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -123,3 +123,4 @@ h1:Xgi8kkxc2dgEdAYSi1CmSXcczC+eiY2CIURHYEjCH3c= 20251212115308.sql h1:CmwHDA9X91++2dnThzk57++5sBDAGw2IQnHzO3/bRlk= 20251217164302.sql h1:OL3OCqWsMtv06RfIlQNcdLMbt4Tz91Lijpbkxqwt7zM= 20260112115927.sql h1:/RKhzT5dRphgeBitxBfo3a3fqLVgvmVZxxqe9fH8lkg= +20260204113827.sql h1:rlJNf8QRfqOfDHf2GUi+59Rgv2BkSbMTPuMalPsMkZg= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 14fff21d2..bd6167e14 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -399,7 +399,7 @@ var ( {Name: "role", Type: field.TypeEnum, Nullable: true, Enums: []string{"role:instance:admin", "role:org:owner", "role:org:admin", "role:org:viewer", "role:org:member", "role:org:contributor", "role:project:admin", "role:project:viewer", "role:group:maintainer", "role:product:admin", "role:product:viewer"}}, {Name: "context", Type: field.TypeJSON, Nullable: true}, {Name: "organization_id", Type: field.TypeUUID}, - {Name: "sender_id", Type: field.TypeUUID}, + {Name: "sender_id", Type: field.TypeUUID, Nullable: true}, } // OrgInvitationsTable holds the schema information for the "org_invitations" table. OrgInvitationsTable = &schema.Table{ diff --git a/app/controlplane/pkg/data/ent/mutation.go b/app/controlplane/pkg/data/ent/mutation.go index f51b8dc01..aa2ed5599 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -8192,9 +8192,22 @@ func (m *OrgInvitationMutation) OldSenderID(ctx context.Context) (v uuid.UUID, e return oldValue.SenderID, nil } +// ClearSenderID clears the value of the "sender_id" field. +func (m *OrgInvitationMutation) ClearSenderID() { + m.sender = nil + m.clearedFields[orginvitation.FieldSenderID] = struct{}{} +} + +// SenderIDCleared returns if the "sender_id" field was cleared in this mutation. +func (m *OrgInvitationMutation) SenderIDCleared() bool { + _, ok := m.clearedFields[orginvitation.FieldSenderID] + return ok +} + // ResetSenderID resets all changes to the "sender_id" field. func (m *OrgInvitationMutation) ResetSenderID() { m.sender = nil + delete(m.clearedFields, orginvitation.FieldSenderID) } // SetRole sets the "role" field. @@ -8330,7 +8343,7 @@ func (m *OrgInvitationMutation) ClearSender() { // SenderCleared reports if the "sender" edge to the User entity was cleared. func (m *OrgInvitationMutation) SenderCleared() bool { - return m.clearedsender + return m.SenderIDCleared() || m.clearedsender } // SenderIDs returns the "sender" edge IDs in the mutation. @@ -8555,6 +8568,9 @@ func (m *OrgInvitationMutation) ClearedFields() []string { if m.FieldCleared(orginvitation.FieldDeletedAt) { fields = append(fields, orginvitation.FieldDeletedAt) } + if m.FieldCleared(orginvitation.FieldSenderID) { + fields = append(fields, orginvitation.FieldSenderID) + } if m.FieldCleared(orginvitation.FieldRole) { fields = append(fields, orginvitation.FieldRole) } @@ -8578,6 +8594,9 @@ func (m *OrgInvitationMutation) ClearField(name string) error { case orginvitation.FieldDeletedAt: m.ClearDeletedAt() return nil + case orginvitation.FieldSenderID: + m.ClearSenderID() + return nil case orginvitation.FieldRole: m.ClearRole() return nil diff --git a/app/controlplane/pkg/data/ent/orginvitation/where.go b/app/controlplane/pkg/data/ent/orginvitation/where.go index 99da61cda..4a4dcbc2a 100644 --- a/app/controlplane/pkg/data/ent/orginvitation/where.go +++ b/app/controlplane/pkg/data/ent/orginvitation/where.go @@ -308,6 +308,16 @@ func SenderIDNotIn(vs ...uuid.UUID) predicate.OrgInvitation { return predicate.OrgInvitation(sql.FieldNotIn(FieldSenderID, vs...)) } +// SenderIDIsNil applies the IsNil predicate on the "sender_id" field. +func SenderIDIsNil() predicate.OrgInvitation { + return predicate.OrgInvitation(sql.FieldIsNull(FieldSenderID)) +} + +// SenderIDNotNil applies the NotNil predicate on the "sender_id" field. +func SenderIDNotNil() predicate.OrgInvitation { + return predicate.OrgInvitation(sql.FieldNotNull(FieldSenderID)) +} + // RoleEQ applies the EQ predicate on the "role" field. func RoleEQ(v authz.Role) predicate.OrgInvitation { vc := v diff --git a/app/controlplane/pkg/data/ent/orginvitation_create.go b/app/controlplane/pkg/data/ent/orginvitation_create.go index b7fb77850..10d4a93c1 100644 --- a/app/controlplane/pkg/data/ent/orginvitation_create.go +++ b/app/controlplane/pkg/data/ent/orginvitation_create.go @@ -88,6 +88,14 @@ func (_c *OrgInvitationCreate) SetSenderID(v uuid.UUID) *OrgInvitationCreate { return _c } +// SetNillableSenderID sets the "sender_id" field if the given value is not nil. +func (_c *OrgInvitationCreate) SetNillableSenderID(v *uuid.UUID) *OrgInvitationCreate { + if v != nil { + _c.SetSenderID(*v) + } + return _c +} + // SetRole sets the "role" field. func (_c *OrgInvitationCreate) SetRole(v authz.Role) *OrgInvitationCreate { _c.mutation.SetRole(v) @@ -208,9 +216,6 @@ func (_c *OrgInvitationCreate) check() error { if _, ok := _c.mutation.OrganizationID(); !ok { return &ValidationError{Name: "organization_id", err: errors.New(`ent: missing required field "OrgInvitation.organization_id"`)} } - if _, ok := _c.mutation.SenderID(); !ok { - return &ValidationError{Name: "sender_id", err: errors.New(`ent: missing required field "OrgInvitation.sender_id"`)} - } if v, ok := _c.mutation.Role(); ok { if err := orginvitation.RoleValidator(v); err != nil { return &ValidationError{Name: "role", err: fmt.Errorf(`ent: validator failed for field "OrgInvitation.role": %w`, err)} @@ -219,9 +224,6 @@ func (_c *OrgInvitationCreate) check() error { if len(_c.mutation.OrganizationIDs()) == 0 { return &ValidationError{Name: "organization", err: errors.New(`ent: missing required edge "OrgInvitation.organization"`)} } - if len(_c.mutation.SenderIDs()) == 0 { - return &ValidationError{Name: "sender", err: errors.New(`ent: missing required edge "OrgInvitation.sender"`)} - } return nil } @@ -422,6 +424,12 @@ func (u *OrgInvitationUpsert) UpdateSenderID() *OrgInvitationUpsert { return u } +// ClearSenderID clears the value of the "sender_id" field. +func (u *OrgInvitationUpsert) ClearSenderID() *OrgInvitationUpsert { + u.SetNull(orginvitation.FieldSenderID) + return u +} + // SetRole sets the "role" field. func (u *OrgInvitationUpsert) SetRole(v authz.Role) *OrgInvitationUpsert { u.Set(orginvitation.FieldRole, v) @@ -575,6 +583,13 @@ func (u *OrgInvitationUpsertOne) UpdateSenderID() *OrgInvitationUpsertOne { }) } +// ClearSenderID clears the value of the "sender_id" field. +func (u *OrgInvitationUpsertOne) ClearSenderID() *OrgInvitationUpsertOne { + return u.Update(func(s *OrgInvitationUpsert) { + s.ClearSenderID() + }) +} + // SetRole sets the "role" field. func (u *OrgInvitationUpsertOne) SetRole(v authz.Role) *OrgInvitationUpsertOne { return u.Update(func(s *OrgInvitationUpsert) { @@ -901,6 +916,13 @@ func (u *OrgInvitationUpsertBulk) UpdateSenderID() *OrgInvitationUpsertBulk { }) } +// ClearSenderID clears the value of the "sender_id" field. +func (u *OrgInvitationUpsertBulk) ClearSenderID() *OrgInvitationUpsertBulk { + return u.Update(func(s *OrgInvitationUpsert) { + s.ClearSenderID() + }) +} + // SetRole sets the "role" field. func (u *OrgInvitationUpsertBulk) SetRole(v authz.Role) *OrgInvitationUpsertBulk { return u.Update(func(s *OrgInvitationUpsert) { diff --git a/app/controlplane/pkg/data/ent/orginvitation_update.go b/app/controlplane/pkg/data/ent/orginvitation_update.go index 5bee3b482..1d3f3dcbe 100644 --- a/app/controlplane/pkg/data/ent/orginvitation_update.go +++ b/app/controlplane/pkg/data/ent/orginvitation_update.go @@ -96,6 +96,12 @@ func (_u *OrgInvitationUpdate) SetNillableSenderID(v *uuid.UUID) *OrgInvitationU return _u } +// ClearSenderID clears the value of the "sender_id" field. +func (_u *OrgInvitationUpdate) ClearSenderID() *OrgInvitationUpdate { + _u.mutation.ClearSenderID() + return _u +} + // SetRole sets the "role" field. func (_u *OrgInvitationUpdate) SetRole(v authz.Role) *OrgInvitationUpdate { _u.mutation.SetRole(v) @@ -205,9 +211,6 @@ func (_u *OrgInvitationUpdate) check() error { if _u.mutation.OrganizationCleared() && len(_u.mutation.OrganizationIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "OrgInvitation.organization"`) } - if _u.mutation.SenderCleared() && len(_u.mutation.SenderIDs()) > 0 { - return errors.New(`ent: clearing a required unique edge "OrgInvitation.sender"`) - } return nil } @@ -392,6 +395,12 @@ func (_u *OrgInvitationUpdateOne) SetNillableSenderID(v *uuid.UUID) *OrgInvitati return _u } +// ClearSenderID clears the value of the "sender_id" field. +func (_u *OrgInvitationUpdateOne) ClearSenderID() *OrgInvitationUpdateOne { + _u.mutation.ClearSenderID() + return _u +} + // SetRole sets the "role" field. func (_u *OrgInvitationUpdateOne) SetRole(v authz.Role) *OrgInvitationUpdateOne { _u.mutation.SetRole(v) @@ -514,9 +523,6 @@ func (_u *OrgInvitationUpdateOne) check() error { if _u.mutation.OrganizationCleared() && len(_u.mutation.OrganizationIDs()) > 0 { return errors.New(`ent: clearing a required unique edge "OrgInvitation.organization"`) } - if _u.mutation.SenderCleared() && len(_u.mutation.SenderIDs()) > 0 { - return errors.New(`ent: clearing a required unique edge "OrgInvitation.sender"`) - } return nil } diff --git a/app/controlplane/pkg/data/ent/schema/orginvitation.go b/app/controlplane/pkg/data/ent/schema/orginvitation.go index dc24fd329..0b421fa9b 100644 --- a/app/controlplane/pkg/data/ent/schema/orginvitation.go +++ b/app/controlplane/pkg/data/ent/schema/orginvitation.go @@ -46,7 +46,7 @@ func (OrgInvitation) Fields() []ent.Field { // edge fields to be able to access to them directly field.UUID("organization_id", uuid.UUID{}), - field.UUID("sender_id", uuid.UUID{}), + field.UUID("sender_id", uuid.UUID{}).Optional(), // Role that will be assigned to the user when they accept the invitation field.Enum("role").GoType(authz.Role("")).Optional(), // Context is a JSON field that can be used to store additional information @@ -57,6 +57,6 @@ func (OrgInvitation) Fields() []ent.Field { func (OrgInvitation) Edges() []ent.Edge { return []ent.Edge{ edge.To("organization", Organization.Type).Unique().Required().Field("organization_id").Annotations(entsql.Annotation{OnDelete: entsql.Cascade}), - edge.To("sender", User.Type).Unique().Required().Field("sender_id").Annotations(entsql.Annotation{OnDelete: entsql.Cascade}), + edge.To("sender", User.Type).Unique().Field("sender_id").Annotations(entsql.Annotation{OnDelete: entsql.Cascade}), } } diff --git a/app/controlplane/pkg/data/orginvitation.go b/app/controlplane/pkg/data/orginvitation.go index f7823122a..51f221d4c 100644 --- a/app/controlplane/pkg/data/orginvitation.go +++ b/app/controlplane/pkg/data/orginvitation.go @@ -40,15 +40,18 @@ func NewOrgInvitation(data *Data, logger log.Logger) biz.OrgInvitationRepo { } } -func (r *OrgInvitation) Create(ctx context.Context, orgID, senderID uuid.UUID, receiverEmail string, role authz.Role, invCtx *biz.OrgInvitationContext) (*biz.OrgInvitation, error) { - invite, err := r.data.DB.OrgInvitation.Create(). +func (r *OrgInvitation) Create(ctx context.Context, orgID uuid.UUID, senderID *uuid.UUID, receiverEmail string, role authz.Role, invCtx *biz.OrgInvitationContext) (*biz.OrgInvitation, error) { + update := r.data.DB.OrgInvitation.Create(). SetOrganizationID(orgID). - SetSenderID(senderID). SetRole(role). SetReceiverEmail(receiverEmail). - SetNillableContext(invCtx). - Save(ctx) + SetNillableContext(invCtx) + if senderID != nil { + update.SetSenderID(*senderID) + } + + invite, err := update.Save(ctx) if err != nil { return nil, err } From 32add98be1810ffe36d5bd2a0f32489978cb76f8 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 4 Feb 2026 14:17:25 +0100 Subject: [PATCH 04/14] use enforcer Signed-off-by: Jose I. Paris --- app/controlplane/internal/service/service.go | 21 +++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index bcdda7ee6..a035414e2 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -331,22 +331,19 @@ func (s *service) visibleProjects(ctx context.Context) []uuid.UUID { // checkPolicy Checks a policy against a user or a token func (s *service) checkPolicy(ctx context.Context, policy *authz.Policy) error { - _, token, err := requireCurrentUserOrAPIToken(ctx) - if err != nil { - return err - } - // Token case - if token != nil { - for _, p := range token.Policies { - if p.Resource == policy.Resource && p.Action == policy.Action { - return nil - } + sub := usercontext.CurrentAuthzSubject(ctx) + if sub != "" { + ok, err := s.authz.Enforce(ctx, sub, policy) + if err != nil { + return handleUseCaseErr(err, s.log) + } + if ok { + return nil } - return errors.Forbidden("forbidden", "not allowed") } - // user case + // Other cases m := entities.CurrentMembership(ctx) if m == nil { return errors.Forbidden("forbidden", "not allowed") From 0a2f322d3ccd6432494d2049c02bc5b1e89a4bd2 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 4 Feb 2026 14:23:28 +0100 Subject: [PATCH 05/14] check policies in all cases Signed-off-by: Jose I. Paris --- app/controlplane/internal/service/organization.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/app/controlplane/internal/service/organization.go b/app/controlplane/internal/service/organization.go index b4c7219ab..55e0f316f 100644 --- a/app/controlplane/internal/service/organization.go +++ b/app/controlplane/internal/service/organization.go @@ -214,15 +214,12 @@ func (s *OrganizationService) UpdateMembership(ctx context.Context, req *pb.Orga } func (s *OrganizationService) canCreateOrganization(ctx context.Context) (bool, error) { - // Restricted org creation is disabled, allow creation only to users - if !s.authz.RestrictOrgCreation { - if entities.CurrentMembership(ctx) != nil { - return true, nil - } - return false, nil + // if org creation restriction is disabled, allow creation to all users + if !s.authz.RestrictOrgCreation && entities.CurrentMembership(ctx) != nil { + return true, nil } - // otherwise, check for permissions + // otherwise, check for permissions (both users and API tokens) if err := s.checkPolicy(ctx, authz.PolicyOrganizationCreate); err != nil { return false, err } From 4803ca3f6a49a23d46af61bde499edf8a7d5e77f Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 4 Feb 2026 23:57:33 +0100 Subject: [PATCH 06/14] use currentuser Signed-off-by: Jose I. Paris --- app/controlplane/internal/service/organization.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controlplane/internal/service/organization.go b/app/controlplane/internal/service/organization.go index 55e0f316f..2d12a2552 100644 --- a/app/controlplane/internal/service/organization.go +++ b/app/controlplane/internal/service/organization.go @@ -215,7 +215,7 @@ func (s *OrganizationService) UpdateMembership(ctx context.Context, req *pb.Orga func (s *OrganizationService) canCreateOrganization(ctx context.Context) (bool, error) { // if org creation restriction is disabled, allow creation to all users - if !s.authz.RestrictOrgCreation && entities.CurrentMembership(ctx) != nil { + if !s.authz.RestrictOrgCreation && entities.CurrentUser(ctx) != nil { return true, nil } From e3144c3b1a1c7463207a600d0c0e4ede8d32afc0 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 5 Feb 2026 12:11:18 +0100 Subject: [PATCH 07/14] support org invitations for instance admins (users and tokens) Signed-off-by: Jose I. Paris --- app/controlplane/internal/server/grpc.go | 8 ++--- .../currentorganization_middleware.go | 34 +++++++++++++++---- .../currentorganization_middleware_test.go | 2 +- .../usercontext/currentuser_middleware.go | 4 +-- app/controlplane/pkg/biz/orginvitation.go | 10 +++--- 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/app/controlplane/internal/server/grpc.go b/app/controlplane/internal/server/grpc.go index 4fac0922a..49d9a96c9 100644 --- a/app/controlplane/internal/server/grpc.go +++ b/app/controlplane/internal/server/grpc.go @@ -193,14 +193,14 @@ func craftMiddleware(opts *Opts) []middleware.Middleware { usercontext.WithAPITokenUsageUpdater(opts.APITokenUseCase, logHelper), // 2.c - Set its user usercontext.WithCurrentUserMiddleware(opts.UserUseCase, logHelper), + // Store all memberships in the context + usercontext.WithCurrentMembershipsMiddleware(opts.MembershipUseCase), selector.Server( // 2.d- Set its organization - usercontext.WithCurrentOrganizationMiddleware(opts.UserUseCase, logHelper), + usercontext.WithCurrentOrganizationMiddleware(opts.UserUseCase, opts.OrganizationUseCase, logHelper), // 3 - Check user/token authorization authzMiddleware.WithAuthzMiddleware(opts.AuthzUseCase, logHelper), ).Match(requireAllButOrganizationOperationsMatcher()).Build(), - // Store all memberships in the context - usercontext.WithCurrentMembershipsMiddleware(opts.MembershipUseCase), // 4 - Make sure the account is fully functional selector.Server( usercontext.CheckUserHasAccess(opts.AuthConfig.AllowList, opts.UserUseCase), @@ -232,7 +232,7 @@ func craftMiddleware(opts *Opts) []middleware.Middleware { // 2.b - Set its API token and Robot Account as alternative to the user usercontext.WithAttestationContextFromAPIToken(opts.APITokenUseCase, opts.OrganizationUseCase, logHelper), // 2.c - Set Attestation context from user token - usercontext.WithAttestationContextFromUser(opts.UserUseCase, logHelper), + usercontext.WithAttestationContextFromUser(opts.UserUseCase, opts.OrganizationUseCase, logHelper), // 2.d - Set its robot account from federated delegation usercontext.WithAttestationContextFromFederatedInfo(opts.OrganizationUseCase, logHelper), // Store all memberships in the context diff --git a/app/controlplane/internal/usercontext/currentorganization_middleware.go b/app/controlplane/internal/usercontext/currentorganization_middleware.go index 624cb5562..68388dfd8 100644 --- a/app/controlplane/internal/usercontext/currentorganization_middleware.go +++ b/app/controlplane/internal/usercontext/currentorganization_middleware.go @@ -20,10 +20,12 @@ import ( "encoding/json" "errors" "fmt" + "slices" "time" v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/unmarshal" "github.com/go-kratos/kratos/v2/log" @@ -56,7 +58,7 @@ func WithCurrentMembershipsMiddleware(membershipUC biz.MembershipsRBAC) middlewa } } -func WithCurrentOrganizationMiddleware(userUseCase biz.UserOrgFinder, logger *log.Helper) middleware.Middleware { +func WithCurrentOrganizationMiddleware(userUseCase biz.UserOrgFinder, orgUC *biz.OrganizationUseCase, logger *log.Helper) middleware.Middleware { return func(handler middleware.Handler) middleware.Handler { return func(ctx context.Context, req interface{}) (interface{}, error) { // Get the current user and return if not found, meaning we are probably coming from an API Token @@ -79,7 +81,7 @@ func WithCurrentOrganizationMiddleware(userUseCase biz.UserOrgFinder, logger *lo } if orgName != "" { - ctx, err = setCurrentMembershipFromOrgName(ctx, u, orgName, userUseCase) + ctx, err = setCurrentMembershipFromOrgName(ctx, u, orgName, userUseCase, orgUC) if err != nil { return nil, v1.ErrorUserNotMemberOfOrgErrorNotInOrg("user is not a member of organization %s", orgName) } @@ -141,15 +143,35 @@ func ResetMembershipsCache() { membershipsCache.Purge() } -func setCurrentMembershipFromOrgName(ctx context.Context, user *entities.User, orgName string, userUC biz.UserOrgFinder) (context.Context, error) { +func setCurrentMembershipFromOrgName(ctx context.Context, user *entities.User, orgName string, userUC biz.UserOrgFinder, orgUC *biz.OrganizationUseCase) (context.Context, error) { membership, err := userUC.MembershipInOrg(ctx, user.ID, orgName) - if err != nil { + if err != nil && !biz.IsNotFound(err) { return nil, fmt.Errorf("failed to find membership: %w", err) } - ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt}) + var role authz.Role + if membership == nil { + // if not found, check if the user is instance admin + m := entities.CurrentMembership(ctx) + if m != nil { + if slices.ContainsFunc(m.Resources, func(r *entities.ResourceMembership) bool { + return r.Role == authz.RoleInstanceAdmin && r.ResourceType == authz.ResourceTypeInstance + }) { + org, err := orgUC.FindByName(ctx, orgName) + if err != nil { + return nil, fmt.Errorf("failed to find organization: %w", err) + } + role = authz.RoleInstanceAdmin + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) + } + } + } else { + role = membership.Role + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt}) + } + // Set the authorization subject that will be used to check the policies - return WithAuthzSubject(ctx, string(membership.Role)), nil + return WithAuthzSubject(ctx, string(role)), nil } // Find the current membership of the user and sets it on the context diff --git a/app/controlplane/internal/usercontext/currentorganization_middleware_test.go b/app/controlplane/internal/usercontext/currentorganization_middleware_test.go index 6b30be92b..14ee3bc4c 100644 --- a/app/controlplane/internal/usercontext/currentorganization_middleware_test.go +++ b/app/controlplane/internal/usercontext/currentorganization_middleware_test.go @@ -80,7 +80,7 @@ func TestWithCurrentOrganizationMiddleware(t *testing.T) { usecase.On("CurrentMembership", ctx, wantUser.ID).Maybe().Return(nil, nil) } - m := WithCurrentOrganizationMiddleware(usecase, logger) + m := WithCurrentOrganizationMiddleware(usecase, nil, logger) _, err := m( func(ctx context.Context, _ interface{}) (interface{}, error) { if tc.wantErr { diff --git a/app/controlplane/internal/usercontext/currentuser_middleware.go b/app/controlplane/internal/usercontext/currentuser_middleware.go index cf1975268..b52ae27f4 100644 --- a/app/controlplane/internal/usercontext/currentuser_middleware.go +++ b/app/controlplane/internal/usercontext/currentuser_middleware.go @@ -88,7 +88,7 @@ func setCurrentUser(ctx context.Context, userUC biz.UserOrgFinder, userID string // WithAttestationContextFromUser injects the current user + organization to the context during the attestation process // it leverages the existing middlewares to set the current user and organization // but with a skipping behavior since that's the one required by the attMiddleware multi-selector -func WithAttestationContextFromUser(userUC *biz.UserUseCase, logger *log.Helper) middleware.Middleware { +func WithAttestationContextFromUser(userUC *biz.UserUseCase, orgUC *biz.OrganizationUseCase, logger *log.Helper) middleware.Middleware { return func(handler middleware.Handler) middleware.Handler { return func(ctx context.Context, req interface{}) (interface{}, error) { // If the token is not an user token, we don't need to do anything @@ -114,7 +114,7 @@ func WithAttestationContextFromUser(userUC *biz.UserUseCase, logger *log.Helper) // NOTE: we reuse the existing middlewares to set the current user and organization by wrapping the call // Now we can load the organization using the other middleware we have set return WithCurrentUserMiddleware(userUC, logger)(func(ctx context.Context, req any) (any, error) { - return WithCurrentOrganizationMiddleware(userUC, logger)(func(ctx context.Context, req any) (any, error) { + return WithCurrentOrganizationMiddleware(userUC, orgUC, logger)(func(ctx context.Context, req any) (any, error) { org := entities.CurrentOrg(ctx) if org == nil { return nil, errors.New("organization not found") diff --git a/app/controlplane/pkg/biz/orginvitation.go b/app/controlplane/pkg/biz/orginvitation.go index 9c4acac65..999ea5c4a 100644 --- a/app/controlplane/pkg/biz/orginvitation.go +++ b/app/controlplane/pkg/biz/orginvitation.go @@ -157,11 +157,11 @@ func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, receiverEmail // 3 - Check that the user is a member of the given org // NOTE: this check is not necessary, as the user is already a member of the org - if membership, err := uc.mRepo.FindByOrgAndUser(ctx, orgUUID, *opts.senderID); err != nil { - return nil, fmt.Errorf("failed to find memberships: %w", err) - } else if membership == nil { - return nil, NewErrNotFound("user does not have permission to invite to this org") - } + //if membership, err := uc.mRepo.FindByOrgAndUser(ctx, orgUUID, *opts.senderID); err != nil { + // return nil, fmt.Errorf("failed to find memberships: %w", err) + //} else if membership == nil { + // return nil, NewErrNotFound("user does not have permission to invite to this org") + //} } // 4 - The receiver does exist in the org already From 877ceb30038f69a9df1445b4c451c19606c3b328 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 5 Feb 2026 14:43:51 +0100 Subject: [PATCH 08/14] fix tests Signed-off-by: Jose I. Paris --- .../pkg/biz/orginvitation_integration_test.go | 63 +++++++++---------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/app/controlplane/pkg/biz/orginvitation_integration_test.go b/app/controlplane/pkg/biz/orginvitation_integration_test.go index 1d33644b7..685b5f407 100644 --- a/app/controlplane/pkg/biz/orginvitation_integration_test.go +++ b/app/controlplane/pkg/biz/orginvitation_integration_test.go @@ -34,12 +34,12 @@ const receiverEmail = "sarah@cyberdyne.io" func (s *OrgInvitationIntegrationTestSuite) TestList() { ctx := context.Background() - inviteOrg1A, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, receiverEmail) + inviteOrg1A, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) // same org but another user - inviteOrg1B, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user2.ID, "another-email@cyberdyne.io") + inviteOrg1B, err := s.OrgInvitation.Create(ctx, s.org1.ID, "another-email@cyberdyne.io", biz.WithSender(uuid.MustParse(s.user2.ID))) s.NoError(err) - inviteOrg2A, err := s.OrgInvitation.Create(ctx, s.org2.ID, s.user.ID, receiverEmail) + inviteOrg2A, err := s.OrgInvitation.Create(ctx, s.org2.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) testCases := []struct { @@ -76,28 +76,21 @@ func (s *OrgInvitationIntegrationTestSuite) TestList() { func (s *OrgInvitationIntegrationTestSuite) TestCreate() { ctx := context.Background() s.T().Run("invalid org ID", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, "deadbeef", s.user.ID, receiverEmail) - s.Error(err) - s.True(biz.IsErrInvalidUUID(err)) - s.Nil(invite) - }) - - s.T().Run("invalid user ID", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "deadbeef", receiverEmail) + invite, err := s.OrgInvitation.Create(ctx, "deadbeef", receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.True(biz.IsErrInvalidUUID(err)) s.Nil(invite) }) s.T().Run("missing receiver email", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, "") + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "", biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.True(biz.IsErrValidation(err)) s.Nil(invite) }) s.T().Run("receiver email same than sender", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, s.user.Email) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.Email, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "sender and receiver emails cannot be the same") s.True(biz.IsErrValidation(err)) @@ -105,7 +98,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { }) s.T().Run("receiver is already a member", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, s.user2.Email) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user2.Email, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "user already exists in the org") s.True(biz.IsErrValidation(err)) @@ -113,14 +106,14 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { }) s.T().Run("org not found", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, uuid.NewString(), receiverEmail) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.Nil)) s.Error(err) s.True(biz.IsNotFound(err)) s.Nil(invite) }) s.T().Run("sender is not member of that org", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org3.ID, s.user.ID, receiverEmail) + invite, err := s.OrgInvitation.Create(ctx, s.org3.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "user does not have permission to invite to this org") s.True(biz.IsNotFound(err)) @@ -128,7 +121,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { }) s.T().Run("sender is not member of that org but receiver is", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org3.ID, s.user.ID, s.user2.Email) + invite, err := s.OrgInvitation.Create(ctx, s.org3.ID, s.user2.Email, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "user does not have permission to invite to this org") s.True(biz.IsNotFound(err)) @@ -137,7 +130,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { s.T().Run("can create invites for org1 and 2", func(t *testing.T) { for _, org := range []*biz.Organization{s.org1, s.org2} { - invite, err := s.OrgInvitation.Create(ctx, org.ID, s.user.ID, receiverEmail) + invite, err := s.OrgInvitation.Create(ctx, org.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal(org, invite.Org) s.Equal(s.user, invite.Sender) @@ -148,7 +141,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { }) s.T().Run("but can't create if there is one pending", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, receiverEmail) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "already exists") s.True(biz.IsErrValidation(err)) @@ -156,28 +149,28 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { }) s.T().Run("but it can if it's another email", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, "anotheremail@cyberdyne.io") + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "anotheremail@cyberdyne.io", biz.WithSender(uuid.MustParse(s.user.ID))) s.Equal("anotheremail@cyberdyne.io", invite.ReceiverEmail) s.Equal(s.org1, invite.Org) s.NoError(err) }) s.T().Run("the default role is viewer", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, "viewer@cyberdyne.io") + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "viewer@cyberdyne.io", biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal(authz.RoleViewer, invite.Role) }) s.T().Run("but can have other roles", func(t *testing.T) { for _, r := range []authz.Role{authz.RoleOwner, authz.RoleAdmin, authz.RoleViewer} { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, fmt.Sprintf("%s@cyberdyne.io", r), biz.WithInvitationRole(r)) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, fmt.Sprintf("%s@cyberdyne.io", r), biz.WithInvitationRole(r), biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal(r, invite.Role) } }) s.Run("and the email address is downcased", func() { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, "WasCamelCase@cyberdyne.io") + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "WasCamelCase@cyberdyne.io", biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal("wascamelcase@cyberdyne.io", invite.ReceiverEmail) }) @@ -203,7 +196,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { }) s.T().Run("user is invited to org 1 as viewer", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, receiverEmail) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) require.NoError(s.T(), err) err = s.OrgInvitation.AcceptPendingInvitations(ctx, receiverEmail) s.NoError(err) @@ -227,7 +220,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { receiverEmail := fmt.Sprintf("user%d@cyberdyne.io", i) receiver, err := s.User.UpsertByEmail(ctx, receiverEmail, nil) s.NoError(err) - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, receiverEmail, biz.WithInvitationRole(r)) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithInvitationRole(r), biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal(r, invite.Role) // accept the invite and make sure the new membership has the role @@ -257,7 +250,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { }) s.T().Run("invitation in another org", func(t *testing.T) { - _, err := s.OrgInvitation.Create(ctx, s.org2.ID, s.user.ID, receiverEmail) + _, err := s.OrgInvitation.Create(ctx, s.org2.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) err = s.OrgInvitation.Revoke(ctx, s.org1.ID, uuid.NewString()) s.Error(err) @@ -265,7 +258,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { }) s.T().Run("invitation not in pending state", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, receiverEmail) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) require.NoError(s.T(), err) err = s.OrgInvitation.AcceptInvitation(ctx, invite.ID.String()) require.NoError(s.T(), err) @@ -278,7 +271,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { }) s.T().Run("happy path", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.ID, receiverEmail) + invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) require.NoError(s.T(), err) err = s.OrgInvitation.Revoke(ctx, s.org1.ID, invite.ID.String()) s.NoError(err) @@ -322,10 +315,10 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { invite, err := s.OrgInvitation.Create( ctx, s.org1.ID, - s.user.ID, receiverForGroupEmail, biz.WithInvitationRole(authz.RoleViewer), biz.WithInvitationContext(invitationContext), + biz.WithSender(uuid.MustParse(s.user.ID)), ) require.NoError(t, err) require.NotNil(t, invite) @@ -388,10 +381,10 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithGroupContext() { invite, err := s.OrgInvitation.Create( ctx, s.org1.ID, - s.user.ID, anotherReceiverEmail, biz.WithInvitationRole(authz.RoleViewer), biz.WithInvitationContext(invitationContext), + biz.WithSender(uuid.MustParse(s.user.ID)), ) require.NoError(t, err) require.NotNil(t, invite) @@ -455,9 +448,9 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { invite, err := s.OrgInvitation.Create( ctx, s.org1.ID, - s.user.ID, receiverForProjectEmail, biz.WithInvitationRole(authz.RoleViewer), + biz.WithSender(uuid.MustParse(s.user.ID)), biz.WithInvitationContext(invitationContext), ) require.NoError(t, err) @@ -519,8 +512,8 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { invite, err := s.OrgInvitation.Create( ctx, s.org1.ID, - s.user.ID, anotherReceiverEmail, + biz.WithSender(uuid.MustParse(s.user.ID)), biz.WithInvitationRole(authz.RoleViewer), biz.WithInvitationContext(invitationContext), ) @@ -580,8 +573,8 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { invite, err := s.OrgInvitation.Create( ctx, s.org1.ID, - s.user.ID, combinedReceiverEmail, + biz.WithSender(uuid.MustParse(s.user.ID)), biz.WithInvitationRole(authz.RoleViewer), biz.WithInvitationContext(invitationContext), ) @@ -662,7 +655,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { invite, err := s.Repos.OrgInvitationRepo.Create( ctx, uuid.MustParse(s.org1.ID), - uuid.MustParse(s.user.ID), + biz.ToPtr(uuid.MustParse(s.user.ID)), newReceiverEmail, authz.RoleViewer, invitationContext, @@ -691,7 +684,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestInvitationWithProjectContext() { invite, err := s.Repos.OrgInvitationRepo.Create( ctx, uuid.MustParse(s.org1.ID), - uuid.MustParse(s.user.ID), + biz.ToPtr(uuid.MustParse(s.user.ID)), newReceiverEmail, authz.RoleViewer, invitationContext, From e25f7473b0728cc1ef73f3586918b462e328c83c Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 5 Feb 2026 17:20:09 +0100 Subject: [PATCH 09/14] fix test Signed-off-by: Jose I. Paris --- app/controlplane/pkg/biz/project_integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controlplane/pkg/biz/project_integration_test.go b/app/controlplane/pkg/biz/project_integration_test.go index 3503023fa..5d7e28493 100644 --- a/app/controlplane/pkg/biz/project_integration_test.go +++ b/app/controlplane/pkg/biz/project_integration_test.go @@ -1816,7 +1816,7 @@ func (s *projectMembersIntegrationTestSuite) TestAddNonExistingMemberToProject() alreadyInvitedEmail := "already-invited@example.com" // First create an invitation - _, err := s.OrgInvitation.Create(ctx, s.org.ID, s.user.ID, alreadyInvitedEmail) + _, err := s.OrgInvitation.Create(ctx, s.org.ID, alreadyInvitedEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) // Now try to add the same email to a project From d6f7c76d6dc02d951e373d40926d050b8b360011 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 5 Feb 2026 17:29:59 +0100 Subject: [PATCH 10/14] add permission Signed-off-by: Jose I. Paris --- app/controlplane/pkg/authz/authz.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index f2c94502f..10f1e508a 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -63,7 +63,6 @@ const ( ResourceAPIToken = "api_token" ResourceProjectMembership = "project_membership" ResourceOrganizationInvitations = "organization_invitations" - ResourceGroupProjects = "group_projects" // Top level instance admin role // this is used to know if an user is a super admin of the chainloop instance @@ -111,6 +110,7 @@ var ( // CAS backend PolicyCASBackendList = &Policy{ResourceCASBackend, ActionList} PolicyCASBackendUpdate = &Policy{ResourceCASBackend, ActionUpdate} + PolicyCASBackendCreate = &Policy{ResourceCASBackend, ActionCreate} // Available integrations PolicyAvailableIntegrationList = &Policy{ResourceAvailableIntegration, ActionList} PolicyAvailableIntegrationRead = &Policy{ResourceAvailableIntegration, ActionRead} @@ -184,6 +184,8 @@ var RolesMap = map[Role][]*Policy{ PolicyOrganizationCreate, // Instance admins can invite users to organizations PolicyOrganizationInvitationsCreate, + // Instance admins can configure CAS Backends in all organizations + PolicyCASBackendCreate, }, RoleOwner: { PolicyOrganizationDelete, @@ -355,6 +357,7 @@ var ServerOperationsMap = map[string][]*Policy{ // CAS Backend listing "/controlplane.v1.CASBackendService/List": {PolicyCASBackendList}, "/controlplane.v1.CASBackendService/Revalidate": {PolicyCASBackendUpdate}, + "/controlplane.v1.CASBackendService/Create": {PolicyCASBackendCreate}, // Available integrations "/controlplane.v1.IntegrationsService/ListAvailable": {PolicyAvailableIntegrationList, PolicyAvailableIntegrationRead}, // Registered integrations From 8e5f831b2040b1234bc74050e98ef2ab7054ad9a Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 5 Feb 2026 17:30:55 +0100 Subject: [PATCH 11/14] remove comment Signed-off-by: Jose I. Paris --- app/controlplane/pkg/biz/orginvitation.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/controlplane/pkg/biz/orginvitation.go b/app/controlplane/pkg/biz/orginvitation.go index 999ea5c4a..7496aba7a 100644 --- a/app/controlplane/pkg/biz/orginvitation.go +++ b/app/controlplane/pkg/biz/orginvitation.go @@ -154,14 +154,6 @@ func (uc *OrgInvitationUseCase) Create(ctx context.Context, orgID, receiverEmail if sender.Email == receiverEmail { return nil, NewErrValidationStr("sender and receiver emails cannot be the same") } - - // 3 - Check that the user is a member of the given org - // NOTE: this check is not necessary, as the user is already a member of the org - //if membership, err := uc.mRepo.FindByOrgAndUser(ctx, orgUUID, *opts.senderID); err != nil { - // return nil, fmt.Errorf("failed to find memberships: %w", err) - //} else if membership == nil { - // return nil, NewErrNotFound("user does not have permission to invite to this org") - //} } // 4 - The receiver does exist in the org already From 8dd2cc3409cf10c5bf35cac9f750eb91263d913d Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 5 Feb 2026 17:52:47 +0100 Subject: [PATCH 12/14] add casbackend/list, as it's used by the CLI Signed-off-by: Jose I. Paris --- app/controlplane/pkg/authz/authz.go | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 10f1e508a..ab081eaa3 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -185,6 +185,7 @@ var RolesMap = map[Role][]*Policy{ // Instance admins can invite users to organizations PolicyOrganizationInvitationsCreate, // Instance admins can configure CAS Backends in all organizations + PolicyCASBackendList, PolicyCASBackendCreate, }, RoleOwner: { From 69054e0ace968a412c034faea39bd0bb7d9cd7c6 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 5 Feb 2026 18:11:19 +0100 Subject: [PATCH 13/14] fix tests Signed-off-by: Jose I. Paris --- .../pkg/biz/orginvitation_integration_test.go | 54 +++++++------------ 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/app/controlplane/pkg/biz/orginvitation_integration_test.go b/app/controlplane/pkg/biz/orginvitation_integration_test.go index 685b5f407..d372ea9fc 100644 --- a/app/controlplane/pkg/biz/orginvitation_integration_test.go +++ b/app/controlplane/pkg/biz/orginvitation_integration_test.go @@ -75,21 +75,21 @@ func (s *OrgInvitationIntegrationTestSuite) TestList() { func (s *OrgInvitationIntegrationTestSuite) TestCreate() { ctx := context.Background() - s.T().Run("invalid org ID", func(t *testing.T) { + s.T().Run("invalid org ID", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, "deadbeef", receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.True(biz.IsErrInvalidUUID(err)) s.Nil(invite) }) - s.T().Run("missing receiver email", func(t *testing.T) { + s.T().Run("missing receiver email", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "", biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.True(biz.IsErrValidation(err)) s.Nil(invite) }) - s.T().Run("receiver email same than sender", func(t *testing.T) { + s.T().Run("receiver email same than sender", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user.Email, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "sender and receiver emails cannot be the same") @@ -97,7 +97,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { s.Nil(invite) }) - s.T().Run("receiver is already a member", func(t *testing.T) { + s.T().Run("receiver is already a member", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, s.user2.Email, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "user already exists in the org") @@ -105,30 +105,14 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { s.Nil(invite) }) - s.T().Run("org not found", func(t *testing.T) { + s.T().Run("org not found", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.Nil)) s.Error(err) s.True(biz.IsNotFound(err)) s.Nil(invite) }) - s.T().Run("sender is not member of that org", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org3.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) - s.Error(err) - s.ErrorContains(err, "user does not have permission to invite to this org") - s.True(biz.IsNotFound(err)) - s.Nil(invite) - }) - - s.T().Run("sender is not member of that org but receiver is", func(t *testing.T) { - invite, err := s.OrgInvitation.Create(ctx, s.org3.ID, s.user2.Email, biz.WithSender(uuid.MustParse(s.user.ID))) - s.Error(err) - s.ErrorContains(err, "user does not have permission to invite to this org") - s.True(biz.IsNotFound(err)) - s.Nil(invite) - }) - - s.T().Run("can create invites for org1 and 2", func(t *testing.T) { + s.T().Run("can create invites for org1 and 2", func(_ *testing.T) { for _, org := range []*biz.Organization{s.org1, s.org2} { invite, err := s.OrgInvitation.Create(ctx, org.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) @@ -140,7 +124,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { } }) - s.T().Run("but can't create if there is one pending", func(t *testing.T) { + s.T().Run("but can't create if there is one pending", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.Error(err) s.ErrorContains(err, "already exists") @@ -148,20 +132,20 @@ func (s *OrgInvitationIntegrationTestSuite) TestCreate() { s.Nil(invite) }) - s.T().Run("but it can if it's another email", func(t *testing.T) { + s.T().Run("but it can if it's another email", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "anotheremail@cyberdyne.io", biz.WithSender(uuid.MustParse(s.user.ID))) s.Equal("anotheremail@cyberdyne.io", invite.ReceiverEmail) s.Equal(s.org1, invite.Org) s.NoError(err) }) - s.T().Run("the default role is viewer", func(t *testing.T) { + s.T().Run("the default role is viewer", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, "viewer@cyberdyne.io", biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) s.Equal(authz.RoleViewer, invite.Role) }) - s.T().Run("but can have other roles", func(t *testing.T) { + s.T().Run("but can have other roles", func(_ *testing.T) { for _, r := range []authz.Role{authz.RoleOwner, authz.RoleAdmin, authz.RoleViewer} { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, fmt.Sprintf("%s@cyberdyne.io", r), biz.WithInvitationRole(r), biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) @@ -181,12 +165,12 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { receiver, err := s.User.UpsertByEmail(ctx, receiverEmail, nil) require.NoError(s.T(), err) - s.T().Run("user doesn't exist", func(t *testing.T) { + s.T().Run("user doesn't exist", func(_ *testing.T) { err := s.OrgInvitation.AcceptPendingInvitations(ctx, "non-existant@cyberdyne.io") s.ErrorContains(err, "not found") }) - s.T().Run("no invites for user", func(t *testing.T) { + s.T().Run("no invites for user", func(_ *testing.T) { err = s.OrgInvitation.AcceptPendingInvitations(ctx, receiverEmail) s.NoError(err) @@ -195,7 +179,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { s.Len(memberships, 0) }) - s.T().Run("user is invited to org 1 as viewer", func(t *testing.T) { + s.T().Run("user is invited to org 1 as viewer", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) require.NoError(s.T(), err) err = s.OrgInvitation.AcceptPendingInvitations(ctx, receiverEmail) @@ -214,7 +198,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { s.Equal(biz.OrgInvitationStatusAccepted, invite.Status) }) - s.T().Run("or take any other role", func(t *testing.T) { + s.T().Run("or take any other role", func(_ *testing.T) { for i, r := range []authz.Role{authz.RoleOwner, authz.RoleAdmin, authz.RoleViewer} { // Create user and invite it with different roles receiverEmail := fmt.Sprintf("user%d@cyberdyne.io", i) @@ -237,19 +221,19 @@ func (s *OrgInvitationIntegrationTestSuite) TestAcceptPendingInvitations() { func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { ctx := context.Background() - s.T().Run("invalid ID", func(t *testing.T) { + s.T().Run("invalid ID", func(_ *testing.T) { err := s.OrgInvitation.Revoke(ctx, s.org1.ID, "deadbeef") s.Error(err) s.True(biz.IsErrInvalidUUID(err)) }) - s.T().Run("invitation not found", func(t *testing.T) { + s.T().Run("invitation not found", func(_ *testing.T) { err := s.OrgInvitation.Revoke(ctx, s.org1.ID, uuid.NewString()) s.Error(err) s.True(biz.IsNotFound(err)) }) - s.T().Run("invitation in another org", func(t *testing.T) { + s.T().Run("invitation in another org", func(_ *testing.T) { _, err := s.OrgInvitation.Create(ctx, s.org2.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) s.NoError(err) err = s.OrgInvitation.Revoke(ctx, s.org1.ID, uuid.NewString()) @@ -257,7 +241,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { s.True(biz.IsNotFound(err)) }) - s.T().Run("invitation not in pending state", func(t *testing.T) { + s.T().Run("invitation not in pending state", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) require.NoError(s.T(), err) err = s.OrgInvitation.AcceptInvitation(ctx, invite.ID.String()) @@ -270,7 +254,7 @@ func (s *OrgInvitationIntegrationTestSuite) TestRevoke() { s.True(biz.IsErrValidation(err)) }) - s.T().Run("happy path", func(t *testing.T) { + s.T().Run("happy path", func(_ *testing.T) { invite, err := s.OrgInvitation.Create(ctx, s.org1.ID, receiverEmail, biz.WithSender(uuid.MustParse(s.user.ID))) require.NoError(s.T(), err) err = s.OrgInvitation.Revoke(ctx, s.org1.ID, invite.ID.String()) From 0488f3aec9d2bb9958c272bfdaebd1f3bce485db Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 5 Feb 2026 23:33:14 +0100 Subject: [PATCH 14/14] move membership to function Signed-off-by: Jose I. Paris --- .../currentorganization_middleware.go | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/app/controlplane/internal/usercontext/currentorganization_middleware.go b/app/controlplane/internal/usercontext/currentorganization_middleware.go index 68388dfd8..754eb427d 100644 --- a/app/controlplane/internal/usercontext/currentorganization_middleware.go +++ b/app/controlplane/internal/usercontext/currentorganization_middleware.go @@ -152,19 +152,11 @@ func setCurrentMembershipFromOrgName(ctx context.Context, user *entities.User, o var role authz.Role if membership == nil { // if not found, check if the user is instance admin - m := entities.CurrentMembership(ctx) - if m != nil { - if slices.ContainsFunc(m.Resources, func(r *entities.ResourceMembership) bool { - return r.Role == authz.RoleInstanceAdmin && r.ResourceType == authz.ResourceTypeInstance - }) { - org, err := orgUC.FindByName(ctx, orgName) - if err != nil { - return nil, fmt.Errorf("failed to find organization: %w", err) - } - role = authz.RoleInstanceAdmin - ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) - } + ctx, err = setMembershipIfInstanceAdmin(ctx, orgName, orgUC) + if err != nil { + return nil, err } + role = authz.RoleInstanceAdmin } else { role = membership.Role ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt}) @@ -174,6 +166,28 @@ func setCurrentMembershipFromOrgName(ctx context.Context, user *entities.User, o return WithAuthzSubject(ctx, string(role)), nil } +// sets membership to any organization if the user is an instance admin +func setMembershipIfInstanceAdmin(ctx context.Context, orgName string, orgUC *biz.OrganizationUseCase) (context.Context, error) { + // look for user membership with instance admin role + m := entities.CurrentMembership(ctx) + if m != nil { + if slices.ContainsFunc(m.Resources, func(r *entities.ResourceMembership) bool { + return r.Role == authz.RoleInstanceAdmin && r.ResourceType == authz.ResourceTypeInstance + }) { + org, err := orgUC.FindByName(ctx, orgName) + if err != nil { + return nil, fmt.Errorf("failed to find organization: %w", err) + } + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) + } + } else { + // if no membership and no instance admin, return error + return nil, errors.New("user membership not found") + } + + return ctx, nil +} + // Find the current membership of the user and sets it on the context func setCurrentOrganizationFromDB(ctx context.Context, user *entities.User, userUC biz.UserOrgFinder, logger *log.Helper) (context.Context, error) { // We load the current organization