Skip to content
Merged
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),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just moved this earlier in the chain, so that membership is available to the middlewares in the next selector.

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,49 @@ 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
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})
}

// 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
}

// 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
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
12 changes: 11 additions & 1 deletion app/controlplane/pkg/authz/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -180,7 +180,13 @@ 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,
// Instance admins can configure CAS Backends in all organizations
PolicyCASBackendList,
PolicyCASBackendCreate,
},
RoleOwner: {
PolicyOrganizationDelete,
Expand Down Expand Up @@ -352,6 +358,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
Expand Down Expand Up @@ -429,6 +436,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
50 changes: 23 additions & 27 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,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 sender.Email == receiverEmail {
return nil, NewErrValidationStr("sender and receiver emails cannot be the same")
}
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")
}

// 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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the comment states, this is not needed since validation is done in the middleware layer already.

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")
if sender.Email == receiverEmail {
return nil, NewErrValidationStr("sender and receiver emails cannot be the same")
}
}

// 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 +179,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
Loading
Loading