Reimplement remove and invite user

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-02-16 13:53:05 +04:00
parent 196738f876
commit f931bda10f
9 changed files with 422 additions and 460 deletions

View File

@@ -253,14 +253,14 @@ func (e ErrPersonalAPIKeyNotFound) Error() string {
return fmt.Sprintf("personal API key %q not found", e.PersonalAPIKeyID) return fmt.Sprintf("personal API key %q not found", e.PersonalAPIKeyID)
} }
type ErrProfileNotFound struct{ MembershipID gid.GID } type ErrProfileNotFound struct{ ProfileID gid.GID }
func NewProfileNotFoundError(membershipID gid.GID) error { func NewProfileNotFoundError(profileID gid.GID) error {
return &ErrProfileNotFound{MembershipID: membershipID} return &ErrProfileNotFound{ProfileID: profileID}
} }
func (e ErrProfileNotFound) Error() string { func (e ErrProfileNotFound) Error() string {
return fmt.Sprintf("profile for membership %q not found", e.MembershipID) return fmt.Sprintf("profile %q not found", e.ProfileID)
} }
type ErrPersonalAPIKeyExpired struct{ PersonalAPIKeyID gid.GID } type ErrPersonalAPIKeyExpired struct{ PersonalAPIKeyID gid.GID }

View File

@@ -56,6 +56,7 @@ const (
ActionMembershipProfileList = "iam:membership-profile:list" ActionMembershipProfileList = "iam:membership-profile:list"
ActionMembershipProfileCreate = "iam:membership-profile:create" ActionMembershipProfileCreate = "iam:membership-profile:create"
ActionMembershipProfileUpdate = "iam:membership-profile:update" ActionMembershipProfileUpdate = "iam:membership-profile:update"
ActionMembershipProfileDelete = "iam:membership-profile:delete"
// Personal API Key actions // Personal API Key actions
ActionPersonalAPIKeyCreate = "iam:personal-api-key:create" ActionPersonalAPIKeyCreate = "iam:personal-api-key:create"

View File

@@ -93,9 +93,8 @@ type (
} }
CreateInvitationRequest struct { CreateInvitationRequest struct {
Email mail.Addr ProfileID gid.GID
FullName string OrganizationID gid.GID
Role coredata.MembershipRole
} }
CreateUserRequest struct { CreateUserRequest struct {
@@ -110,7 +109,7 @@ type (
ContractEndDate **time.Time ContractEndDate **time.Time
} }
UpdateProfileRequest struct { UpdateUserRequest struct {
ID gid.GID ID gid.GID
FullName string FullName string
AdditionalEmailAddresses mail.Addrs AdditionalEmailAddresses mail.Addrs
@@ -220,7 +219,7 @@ func (cur *CreateUserRequest) Validate() error {
return v.Error() return v.Error()
} }
func (upr *UpdateProfileRequest) Validate() error { func (upr *UpdateUserRequest) Validate() error {
v := validator.New() v := validator.New()
v.Check(upr.ID, "id", validator.Required(), validator.GID(coredata.MembershipProfileEntityType)) v.Check(upr.ID, "id", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
@@ -280,7 +279,7 @@ func (s *OrganizationService) UpdateMempership(
return &membership, nil return &membership, nil
} }
func (s *OrganizationService) RemoveMember( func (s *OrganizationService) RemoveUser(
ctx context.Context, ctx context.Context,
organizationID gid.GID, organizationID gid.GID,
profileID gid.GID, profileID gid.GID,
@@ -300,10 +299,6 @@ func (s *OrganizationService) RemoveMember(
return fmt.Errorf("cannot load profile: %w", err) return fmt.Errorf("cannot load profile: %w", err)
} }
if profile.OrganizationID != organizationID {
return NewMembershipNotFoundError(profile.ID)
}
if profile.Source == coredata.ProfileSourceSCIM { if profile.Source == coredata.ProfileSourceSCIM {
return NewUserManagedBySCIMError(profileID) return NewUserManagedBySCIMError(profileID)
} }
@@ -431,20 +426,16 @@ func (s *OrganizationService) CountInvitations(
return count, nil return count, nil
} }
func (s *OrganizationService) InviteMember( func (s *OrganizationService) InviteUser(
ctx context.Context, ctx context.Context,
organizationID gid.GID,
req *CreateInvitationRequest, req *CreateInvitationRequest,
) (*coredata.Invitation, error) { ) (*coredata.Invitation, error) {
var ( var (
scope = coredata.NewScopeFromObjectID(organizationID) scope = coredata.NewScopeFromObjectID(req.OrganizationID)
now = time.Now() now = time.Now()
invitation = &coredata.Invitation{ invitation = &coredata.Invitation{
ID: gid.New(organizationID.TenantID(), coredata.InvitationEntityType), ID: gid.New(req.OrganizationID.TenantID(), coredata.InvitationEntityType),
OrganizationID: organizationID, OrganizationID: req.OrganizationID,
Email: req.Email,
FullName: req.FullName,
Role: req.Role,
Status: coredata.InvitationStatusPending, Status: coredata.InvitationStatusPending,
ExpiresAt: now.Add(s.invitationTokenValidity), ExpiresAt: now.Add(s.invitationTokenValidity),
CreatedAt: now, CreatedAt: now,
@@ -455,32 +446,22 @@ func (s *OrganizationService) InviteMember(
ctx, ctx,
func(tx pg.Conn) error { func(tx pg.Conn) error {
organization := coredata.Organization{} organization := coredata.Organization{}
err := organization.LoadByID(ctx, tx, scope, organizationID) err := organization.LoadByID(ctx, tx, scope, req.OrganizationID)
if err != nil { if err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return NewOrganizationNotFoundError(organizationID) return NewOrganizationNotFoundError(req.OrganizationID)
} }
return fmt.Errorf("cannot load organization: %w", err) return fmt.Errorf("cannot load organization: %w", err)
} }
identity := &coredata.Identity{} profile := &coredata.MembershipProfile{}
err = identity.LoadByEmail(ctx, tx, invitation.Email) if err := profile.LoadByID(ctx, tx, scope, req.ProfileID); err != nil {
if err != nil && err != coredata.ErrResourceNotFound { if errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load identity: %w", err) return NewProfileNotFoundError(req.ProfileID)
}
identityExists := identity.ID != gid.Nil
if identityExists {
profile := &coredata.MembershipProfile{}
err = profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, identity.ID, organizationID)
if err != nil && err != coredata.ErrResourceNotFound {
return fmt.Errorf("cannot load profile: %w", err)
} }
if profile.ID != gid.Nil && profile.State == coredata.ProfileStateActive { return fmt.Errorf("cannot load profile: %w", err)
return NewUserAlreadyExistsError(identity.ID, organizationID)
}
} }
err = invitation.Insert(ctx, tx, scope) err = invitation.Insert(ctx, tx, scope)
@@ -498,7 +479,7 @@ func (s *OrganizationService) InviteMember(
return fmt.Errorf("cannot generate invitation token: %w", err) return fmt.Errorf("cannot generate invitation token: %w", err)
} }
emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, identity.FullName) emailPresenter := emails.NewPresenter(s.fm, s.bucket, s.baseURL, profile.FullName)
subject, textBody, htmlBody, err := emailPresenter.RenderInvitation( subject, textBody, htmlBody, err := emailPresenter.RenderInvitation(
ctx, ctx,
@@ -511,8 +492,8 @@ func (s *OrganizationService) InviteMember(
} }
email := coredata.NewEmail( email := coredata.NewEmail(
invitation.FullName, profile.FullName,
invitation.Email, profile.EmailAddress,
subject, subject,
textBody, textBody,
htmlBody, htmlBody,
@@ -1034,7 +1015,7 @@ func (s *OrganizationService) CreateUser(ctx context.Context, req *CreateUserReq
return profile, nil return profile, nil
} }
func (s *OrganizationService) UpdateProfile(ctx context.Context, req *UpdateProfileRequest) (*coredata.MembershipProfile, error) { func (s *OrganizationService) UpdateUser(ctx context.Context, req *UpdateUserRequest) (*coredata.MembershipProfile, error) {
if err := req.Validate(); err != nil { if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err) return nil, fmt.Errorf("invalid request: %w", err)
} }

View File

@@ -357,7 +357,7 @@ func (s SessionService) OpenPasswordChildSessionForOrganization(
err = profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, rootSession.IdentityID, organizationID) err = profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, rootSession.IdentityID, organizationID)
if err != nil { if err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return NewProfileNotFoundError(organizationID) return NewProfileNotFoundError(gid.Nil)
} }
return fmt.Errorf("cannot load profile: %w", err) return fmt.Errorf("cannot load profile: %w", err)
} }
@@ -459,7 +459,7 @@ func (s SessionService) OpenSAMLChildSessionForOrganization(
err = profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, rootSession.IdentityID, organizationID) err = profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, rootSession.IdentityID, organizationID)
if err != nil { if err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return NewProfileNotFoundError(organizationID) return NewProfileNotFoundError(gid.Nil)
} }
return fmt.Errorf("cannot load profile: %w", err) return fmt.Errorf("cannot load profile: %w", err)
} }
@@ -545,7 +545,7 @@ func (s SessionService) AssumeOrganizationSession(
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, rootSession.IdentityID, organizationID); err != nil { if err := profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, rootSession.IdentityID, organizationID); err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return NewProfileNotFoundError(organizationID) return NewProfileNotFoundError(gid.Nil)
} }
return fmt.Errorf("cannot load profile: %w", err) return fmt.Errorf("cannot load profile: %w", err)
} }

View File

@@ -96,13 +96,13 @@ type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload createUser(input: CreateUserInput!): CreateUserPayload
@session(required: PRESENT) @session(required: PRESENT)
inviteMember(input: InviteMemberInput!): InviteMemberPayload inviteUser(input: InviteUserInput!): InviteUserPayload
@session(required: PRESENT) @session(required: PRESENT)
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload
@session(required: PRESENT) @session(required: PRESENT)
updateProfile(input: UpdateProfileInput!): UpdateProfilePayload! updateUser(input: UpdateUserInput!): UpdateUserPayload!
updateMembership(input: UpdateMembershipInput!): UpdateMembershipPayload! updateMembership(input: UpdateMembershipInput!): UpdateMembershipPayload!
removeMember(input: RemoveMemberInput!): RemoveMemberPayload removeUser(input: RemoveUserInput!): RemoveUserPayload
@session(required: PRESENT) @session(required: PRESENT)
acceptInvitation(input: AcceptInvitationInput!): AcceptInvitationPayload acceptInvitation(input: AcceptInvitationInput!): AcceptInvitationPayload
@@ -741,14 +741,12 @@ input CreateUserInput {
contractEndDate: Datetime @goField(omittable: true) contractEndDate: Datetime @goField(omittable: true)
} }
input InviteMemberInput { input InviteUserInput {
organizationId: ID! organizationId: ID!
email: EmailAddr! profileId: ID!
fullName: String!
role: MembershipRole!
} }
input UpdateProfileInput { input UpdateUserInput {
id: ID! id: ID!
fullName: String! fullName: String!
additionalEmailAddresses: [EmailAddr!] additionalEmailAddresses: [EmailAddr!]
@@ -764,9 +762,9 @@ input UpdateMembershipInput {
role: MembershipRole! role: MembershipRole!
} }
input RemoveMemberInput { input RemoveUserInput {
organizationId: ID! organizationId: ID!
membershipId: ID! profileId: ID!
} }
input AcceptInvitationInput { input AcceptInvitationInput {
@@ -908,11 +906,11 @@ type CreateUserPayload {
profileEdge: ProfileEdge! profileEdge: ProfileEdge!
} }
type InviteMemberPayload { type InviteUserPayload {
invitationEdge: InvitationEdge! invitationEdge: InvitationEdge!
} }
type UpdateProfilePayload { type UpdateUserPayload {
profile: Profile! profile: Profile!
} }
@@ -920,8 +918,8 @@ type UpdateMembershipPayload {
membership: Membership! membership: Membership!
} }
type RemoveMemberPayload { type RemoveUserPayload {
deletedMembershipId: ID! deletedProfileId: ID!
} }
type AcceptInvitationPayload { type AcceptInvitationPayload {

File diff suppressed because it is too large Load Diff

View File

@@ -224,14 +224,12 @@ type InvitationEdge struct {
Cursor page.CursorKey `json:"cursor"` Cursor page.CursorKey `json:"cursor"`
} }
type InviteMemberInput struct { type InviteUserInput struct {
OrganizationID gid.GID `json:"organizationId"` OrganizationID gid.GID `json:"organizationId"`
Email mail.Addr `json:"email"` ProfileID gid.GID `json:"profileId"`
FullName string `json:"fullName"`
Role coredata.MembershipRole `json:"role"`
} }
type InviteMemberPayload struct { type InviteUserPayload struct {
InvitationEdge *InvitationEdge `json:"invitationEdge"` InvitationEdge *InvitationEdge `json:"invitationEdge"`
} }
@@ -348,13 +346,13 @@ type RegenerateSCIMTokenPayload struct {
Token string `json:"token"` Token string `json:"token"`
} }
type RemoveMemberInput struct { type RemoveUserInput struct {
OrganizationID gid.GID `json:"organizationId"` OrganizationID gid.GID `json:"organizationId"`
MembershipID gid.GID `json:"membershipId"` ProfileID gid.GID `json:"profileId"`
} }
type RemoveMemberPayload struct { type RemoveUserPayload struct {
DeletedMembershipID gid.GID `json:"deletedMembershipId"` DeletedProfileID gid.GID `json:"deletedProfileId"`
} }
type ResetPasswordInput struct { type ResetPasswordInput struct {
@@ -566,20 +564,6 @@ type UpdateOrganizationPayload struct {
Organization *Organization `json:"organization,omitempty"` Organization *Organization `json:"organization,omitempty"`
} }
type UpdateProfileInput struct {
ID gid.GID `json:"id"`
FullName string `json:"fullName"`
AdditionalEmailAddresses []mail.Addr `json:"additionalEmailAddresses,omitempty"`
Kind coredata.MembershipProfileKind `json:"kind"`
Position *string `json:"position,omitempty"`
ContractStartDate graphql.Omittable[*time.Time] `json:"contractStartDate,omitempty"`
ContractEndDate graphql.Omittable[*time.Time] `json:"contractEndDate,omitempty"`
}
type UpdateProfilePayload struct {
Profile *Profile `json:"profile"`
}
type UpdateSAMLConfigurationInput struct { type UpdateSAMLConfigurationInput struct {
OrganizationID gid.GID `json:"organizationId"` OrganizationID gid.GID `json:"organizationId"`
SamlConfigurationID gid.GID `json:"samlConfigurationId"` SamlConfigurationID gid.GID `json:"samlConfigurationId"`
@@ -605,6 +589,20 @@ type UpdateSCIMBridgePayload struct {
ScimBridge *SCIMBridge `json:"scimBridge"` ScimBridge *SCIMBridge `json:"scimBridge"`
} }
type UpdateUserInput struct {
ID gid.GID `json:"id"`
FullName string `json:"fullName"`
AdditionalEmailAddresses []mail.Addr `json:"additionalEmailAddresses,omitempty"`
Kind coredata.MembershipProfileKind `json:"kind"`
Position *string `json:"position,omitempty"`
ContractStartDate graphql.Omittable[*time.Time] `json:"contractStartDate,omitempty"`
ContractEndDate graphql.Omittable[*time.Time] `json:"contractEndDate,omitempty"`
}
type UpdateUserPayload struct {
Profile *Profile `json:"profile"`
}
type VerifyEmailInput struct { type VerifyEmailInput struct {
Token string `json:"token"` Token string `json:"token"`
} }

View File

@@ -907,19 +907,17 @@ func (r *mutationResolver) CreateUser(ctx context.Context, input types.CreateUse
}, nil }, nil
} }
// InviteMember is the resolver for the inviteMember field. // InviteUser is the resolver for the inviteUser field.
func (r *mutationResolver) InviteMember(ctx context.Context, input types.InviteMemberInput) (*types.InviteMemberPayload, error) { func (r *mutationResolver) InviteUser(ctx context.Context, input types.InviteUserInput) (*types.InviteUserPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, iam.ActionInvitationCreate); err != nil { if err := r.authorize(ctx, input.ProfileID, iam.ActionInvitationCreate); err != nil {
return nil, err return nil, err
} }
invitation, err := r.iam.OrganizationService.InviteMember( invitation, err := r.iam.OrganizationService.InviteUser(
ctx, ctx,
input.OrganizationID,
&iam.CreateInvitationRequest{ &iam.CreateInvitationRequest{
Email: input.Email, OrganizationID: input.OrganizationID,
FullName: input.FullName, ProfileID: input.ProfileID,
Role: input.Role,
}, },
) )
if err != nil { if err != nil {
@@ -934,11 +932,11 @@ func (r *mutationResolver) InviteMember(ctx context.Context, input types.InviteM
return nil, gqlutils.Conflict(ctx, err) return nil, gqlutils.Conflict(ctx, err)
} }
r.logger.ErrorCtx(ctx, "cannot add member to organization", log.Error(err)) r.logger.ErrorCtx(ctx, "cannot invite user", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
return &types.InviteMemberPayload{ return &types.InviteUserPayload{
InvitationEdge: types.NewInvitationEdge(invitation, coredata.InvitationOrderFieldCreatedAt), InvitationEdge: types.NewInvitationEdge(invitation, coredata.InvitationOrderFieldCreatedAt),
}, nil }, nil
} }
@@ -969,15 +967,15 @@ func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.Del
return &types.DeleteInvitationPayload{DeletedInvitationID: input.InvitationID}, nil return &types.DeleteInvitationPayload{DeletedInvitationID: input.InvitationID}, nil
} }
// UpdateProfile is the resolver for the updateProfile field. // UpdateUser is the resolver for the updateUser field.
func (r *mutationResolver) UpdateProfile(ctx context.Context, input types.UpdateProfileInput) (*types.UpdateProfilePayload, error) { func (r *mutationResolver) UpdateUser(ctx context.Context, input types.UpdateUserInput) (*types.UpdateUserPayload, error) {
if err := r.authorize(ctx, input.ID, iam.ActionMembershipProfileUpdate); err != nil { if err := r.authorize(ctx, input.ID, iam.ActionMembershipProfileUpdate); err != nil {
return nil, err return nil, err
} }
profile, err := r.iam.OrganizationService.UpdateProfile( profile, err := r.iam.OrganizationService.UpdateUser(
ctx, ctx,
&iam.UpdateProfileRequest{ &iam.UpdateUserRequest{
ID: input.ID, ID: input.ID,
FullName: input.FullName, FullName: input.FullName,
AdditionalEmailAddresses: input.AdditionalEmailAddresses, AdditionalEmailAddresses: input.AdditionalEmailAddresses,
@@ -992,7 +990,7 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input types.Update
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
return &types.UpdateProfilePayload{ return &types.UpdateUserPayload{
Profile: types.NewProfile(profile), Profile: types.NewProfile(profile),
}, nil }, nil
} }
@@ -1020,13 +1018,13 @@ func (r *mutationResolver) UpdateMembership(ctx context.Context, input types.Upd
}, nil }, nil
} }
// RemoveMember is the resolver for the removeMember field. // RemoveUser is the resolver for the removeUser field.
func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveMemberInput) (*types.RemoveMemberPayload, error) { func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error) {
if err := r.authorize(ctx, input.MembershipID, iam.ActionMembershipDelete); err != nil { if err := r.authorize(ctx, input.ProfileID, iam.ActionMembershipProfileDelete); err != nil {
return nil, err return nil, err
} }
err := r.iam.OrganizationService.RemoveMember(ctx, input.OrganizationID, input.MembershipID) err := r.iam.OrganizationService.RemoveUser(ctx, input.OrganizationID, input.ProfileID)
if err != nil { if err != nil {
var errManagedBySCIM *iam.ErrUserManagedBySCIM var errManagedBySCIM *iam.ErrUserManagedBySCIM
var errLastActiveOwner *iam.ErrLastActiveOwner var errLastActiveOwner *iam.ErrLastActiveOwner
@@ -1039,11 +1037,11 @@ func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveM
return nil, gqlutils.Conflict(ctx, err) return nil, gqlutils.Conflict(ctx, err)
} }
r.logger.ErrorCtx(ctx, "cannot remove member from organization", log.Error(err)) r.logger.ErrorCtx(ctx, "cannot remove user from organization", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
return &types.RemoveMemberPayload{DeletedMembershipID: input.MembershipID}, nil return &types.RemoveUserPayload{DeletedProfileID: input.ProfileID}, nil
} }
// AcceptInvitation is the resolver for the acceptInvitation field. // AcceptInvitation is the resolver for the acceptInvitation field.

View File

@@ -7,10 +7,12 @@ package mcp_v1
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/authn"