Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions app/controlplane/internal/server/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/controlplane/internal/service/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
16 changes: 13 additions & 3 deletions app/controlplane/internal/service/orginvitation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
}
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -56,12 +58,13 @@ 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
u := entities.CurrentUser(ctx)
if u == nil {
// For API tokens, the organization is already set in WithCurrentAPITokenAndOrgMiddleware
return handler(ctx, req)
}

Expand All @@ -78,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)
}
Expand Down Expand Up @@ -140,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions app/controlplane/pkg/authz/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/controlplane/pkg/authz/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion app/controlplane/pkg/biz/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
56 changes: 30 additions & 26 deletions app/controlplane/pkg/biz/orginvitation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -135,37 +142,34 @@ 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
_, membershipCount, err := uc.mRepo.FindByOrg(ctx, orgUUID, &ListByOrgOpts{
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 {
Expand All @@ -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)
}
Expand Down
5 changes: 4 additions & 1 deletion app/controlplane/pkg/biz/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Modify "org_invitations" table
ALTER TABLE "org_invitations" ALTER COLUMN "sender_id" DROP NOT NULL;
3 changes: 2 additions & 1 deletion app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
2 changes: 1 addition & 1 deletion app/controlplane/pkg/data/ent/migrate/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading
Loading