// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above // copyright notice and this permission notice appear in all copies. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH // REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, // INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. package iam import ( "context" "errors" "fmt" "io" "time" "go.gearno.de/crypto/uuid" "go.gearno.de/kit/pg" "go.probo.inc/probo/packages/emails" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/filevalidation" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam/scim" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/slug" "go.probo.inc/probo/pkg/statelesstoken" "go.probo.inc/probo/pkg/validator" "go.probo.inc/probo/pkg/webhook" webhooktypes "go.probo.inc/probo/pkg/webhook/types" ) type ( OrganizationService struct { *Service } InvitationTokenData struct { InvitationID gid.GID `json:"invitation_id"` } UploadedFile struct { Content io.Reader Filename string Size int64 ContentType string } CreateOrganizationRequest struct { Name string LogoFile *UploadedFile HorizontalLogoFile *UploadedFile } UpdateOrganizationRequest struct { Name *string LogoFile *UploadedFile HorizontalLogoFile *UploadedFile Description **string WebsiteURL **string Email **string HeadquarterAddress **string } CreateSAMLConfigurationRequest struct { EmailDomain string IdPEntityID string IdPSsoURL string IdPCertificate string AttributeEmail *string AttributeFirstname *string AttributeLastname *string AttributeRole *string AutoSignupEnabled bool } UpdateSAMLConfigurationRequest struct { ID gid.GID EnforcementPolicy *coredata.SAMLEnforcementPolicy IdPEntityID *string IdPSsoURL *string IdPCertificate *string AttributeEmail *string AttributeFirstname *string AttributeLastname *string AttributeRole *string AutoSignupEnabled *bool } CreateInvitationRequest struct { ProfileID gid.GID OrganizationID gid.GID } CreateUserRequest struct { OrganizationID gid.GID EmailAddress mail.Addr Role coredata.MembershipRole FullName string AdditionalEmailAddresses mail.Addrs Kind *string Position *string ContractStartDate **time.Time ContractEndDate **time.Time } UpdateUserRequest struct { ID gid.GID FullName string AdditionalEmailAddresses mail.Addrs Kind *string Position *string ContractStartDate **time.Time ContractEndDate **time.Time } ) var ( proboThirdParty = struct { Name string Description string LegalName string HeadquarterAddress string WebsiteURL string PrivacyPolicyURL string TermsOfServiceURL string SubprocessorsListURL string }{ Name: "Probo", Description: "Probo is an open-source compliance platform that helps startups achieve SOC 2 and ISO 27001 certifications quickly and affordably, with expert guidance and no thirdParty lock-in.", LegalName: "Probo Inc.", HeadquarterAddress: "490 Post St, Suite 640,San Francisco, CA 94102, United States", WebsiteURL: "https://www.probo.com/", PrivacyPolicyURL: "https://www.probo.com/privacy", TermsOfServiceURL: "https://www.probo.com/terms", SubprocessorsListURL: "https://www.probo.com/subprocessors", } ) const ( TokenTypeAPIKey = "api_key" NameMaxLength = 100 TitleMaxLength = 1000 ContentMaxLength = 5000 DefaultAttributeEmail = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" DefaultAttributeFirstname = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname" DefaultAttributeLastname = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname" DefaultAttributeRole = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role" ) func (req CreateOrganizationRequest) Validate() error { v := validator.New() fv := filevalidation.NewValidator(filevalidation.WithCategories(filevalidation.CategoryImage)) if req.LogoFile != nil { err := fv.Validate(req.LogoFile.Filename, req.LogoFile.ContentType, req.LogoFile.Size) if err != nil { return fmt.Errorf("invalid logo file: %w", err) } } if req.HorizontalLogoFile != nil { err := fv.Validate(req.HorizontalLogoFile.Filename, req.HorizontalLogoFile.ContentType, req.HorizontalLogoFile.Size) if err != nil { return fmt.Errorf("invalid horizontal logo file: %w", err) } } v.Check(req.Name, "name", validator.Required(), validator.SafeTextNoNewLine(255)) return v.Error() } func (req UpdateOrganizationRequest) Validate() error { v := validator.New() fv := filevalidation.NewValidator(filevalidation.WithCategories(filevalidation.CategoryImage)) v.Check(req.Name, "name", validator.SafeTextNoNewLine(255)) v.Check(req.Description, "description", validator.SafeText(ContentMaxLength)) v.Check(req.WebsiteURL, "website_url", validator.SafeText(2048)) v.Check(req.Email, "email", validator.SafeText(255)) v.Check(req.HeadquarterAddress, "headquarter_address", validator.SafeText(2048)) v.Check(req.LogoFile, "logo_file", validator.NotEmpty()) if req.LogoFile != nil { if err := fv.Validate(req.LogoFile.Filename, req.LogoFile.ContentType, req.LogoFile.Size); err != nil { return fmt.Errorf("invalid logo file: %w", err) } } v.Check(req.HorizontalLogoFile, "horizontal_logo_file", validator.NotEmpty()) if req.HorizontalLogoFile != nil { if err := fv.Validate(req.HorizontalLogoFile.Filename, req.HorizontalLogoFile.ContentType, req.HorizontalLogoFile.Size); err != nil { return fmt.Errorf("invalid horizontal logo file: %w", err) } } return v.Error() } func (cur *CreateUserRequest) Validate() error { v := validator.New() v.Check(cur.OrganizationID, "id", validator.Required(), validator.GID(coredata.OrganizationEntityType)) v.Check(cur.FullName, "full_name", validator.SafeTextNoNewLine(NameMaxLength)) v.CheckEach(cur.AdditionalEmailAddresses, "additional_email_addresses", func(index int, item any) { v.Check(item, fmt.Sprintf("additional_email_addresses[%d]", index), validator.Required(), validator.NotEmpty()) }) v.Check(cur.Kind, "kind", validator.SafeTextNoNewLine(NameMaxLength)) v.Check(cur.Position, "position", validator.SafeTextNoNewLine(TitleMaxLength)) v.Check(cur.ContractStartDate, "contract_start_date", validator.Before(cur.ContractEndDate)) v.Check(cur.ContractEndDate, "contract_end_date", validator.After(cur.ContractStartDate)) return v.Error() } func (upr *UpdateUserRequest) Validate() error { v := validator.New() v.Check(upr.ID, "id", validator.Required(), validator.GID(coredata.MembershipProfileEntityType)) v.Check(upr.FullName, "full_name", validator.SafeTextNoNewLine(NameMaxLength)) v.CheckEach(upr.AdditionalEmailAddresses, "additional_email_addresses", func(index int, item any) { v.Check(item, fmt.Sprintf("additional_email_addresses[%d]", index), validator.Required(), validator.NotEmpty()) }) v.Check(upr.Kind, "kind", validator.SafeTextNoNewLine(NameMaxLength)) v.Check(upr.Position, "position", validator.SafeTextNoNewLine(TitleMaxLength)) v.Check(upr.ContractStartDate, "contract_start_date", validator.Before(upr.ContractEndDate)) v.Check(upr.ContractEndDate, "contract_end_date", validator.After(upr.ContractStartDate)) return v.Error() } func NewOrganizationService(svc *Service) *OrganizationService { return &OrganizationService{Service: svc} } func (s *OrganizationService) UpdateMembership( ctx context.Context, organizationID gid.GID, membershipID gid.GID, role coredata.MembershipRole, ) (*coredata.Membership, error) { scope := coredata.NewScopeFromObjectID(organizationID) membership := coredata.Membership{} if err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { if err := membership.LoadByID(ctx, tx, scope, membershipID); err != nil { if err == coredata.ErrResourceNotFound { return NewMembershipNotFoundError(membershipID) } return fmt.Errorf("cannot load membership: %w", err) } if membership.OrganizationID != organizationID { return NewMembershipNotFoundError(membership.ID) } profile := &coredata.MembershipProfile{} if err := profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, membership.IdentityID, membership.OrganizationID); err != nil { return fmt.Errorf("cannot load profile: %w", err) } if membership.Role == coredata.MembershipRoleOwner && role != coredata.MembershipRoleOwner && profile.State == coredata.ProfileStateActive { profiles := coredata.MembershipProfiles{} count, err := profiles.CountActiveOwnerByOrganizationID(ctx, tx, scope, organizationID) if err != nil { return fmt.Errorf("cannot count active owners: %w", err) } if count <= 1 { return NewLastActiveOwnerError(membershipID) } } membership.Role = role membership.UpdatedAt = time.Now() if err := membership.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update membership: %w", err) } if err := webhook.InsertData(ctx, tx, scope, organizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(profile, &membership)); err != nil { return fmt.Errorf("cannot insert webhook event: %w", err) } return nil }, ); err != nil { return nil, err } return &membership, nil } func (s *OrganizationService) RemoveUser( ctx context.Context, organizationID gid.GID, profileID gid.GID, ) error { scope := coredata.NewScopeFromObjectID(organizationID) return s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { profile := coredata.MembershipProfile{} if err := profile.LoadByID(ctx, tx, scope, profileID); err != nil { if err == coredata.ErrResourceNotFound { return NewProfileNotFoundError(profileID) } return fmt.Errorf("cannot load profile: %w", err) } if profile.Source == coredata.ProfileSourceSCIM { return NewUserManagedBySCIMError(profileID) } if profile.OrganizationID != organizationID { return NewProfileNotFoundError(profileID) } membership := &coredata.Membership{} if err := membership.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, profile.IdentityID, profile.OrganizationID); err != nil { return fmt.Errorf("cannot load membership: %w", err) } if membership.Role == coredata.MembershipRoleOwner && profile.State == coredata.ProfileStateActive { profiles := coredata.MembershipProfiles{} count, err := profiles.CountActiveOwnerByOrganizationID(ctx, tx, scope, profile.OrganizationID) if err != nil { return fmt.Errorf("cannot count active owners: %w", err) } if count <= 1 { return NewLastActiveOwnerError(profileID) } } if err := profile.Delete(ctx, tx, scope, profileID); err != nil { return fmt.Errorf("cannot delete profile: %w", err) } if err := membership.Delete(ctx, tx, scope, membership.ID); err != nil { return fmt.Errorf("cannot delete membership: %w", err) } if err := webhook.InsertData(ctx, tx, scope, organizationID, coredata.WebhookEventTypeUserDeleted, webhooktypes.NewUser(&profile, membership)); err != nil { return fmt.Errorf("cannot insert webhook event: %w", err) } return nil }, ) } func (s *OrganizationService) ArchiveUser( ctx context.Context, scope coredata.Scoper, organizationID gid.GID, profileID gid.GID, ) error { return s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { profile := coredata.MembershipProfile{} if err := profile.LoadByID(ctx, tx, scope, profileID); err != nil { if err == coredata.ErrResourceNotFound { return NewProfileNotFoundError(profileID) } return fmt.Errorf("cannot load profile: %w", err) } if profile.Source == coredata.ProfileSourceSCIM { return NewUserManagedBySCIMError(profileID) } membership := &coredata.Membership{} if err := membership.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, profile.IdentityID, profile.OrganizationID); err != nil { return fmt.Errorf("cannot load membership: %w", err) } if membership.Role == coredata.MembershipRoleOwner && profile.State == coredata.ProfileStateActive { profiles := coredata.MembershipProfiles{} count, err := profiles.CountActiveOwnerByOrganizationID(ctx, tx, scope, organizationID) if err != nil { return fmt.Errorf("cannot count active owners: %w", err) } if count <= 1 { return NewLastActiveOwnerError(profileID) } } invitations := &coredata.Invitations{} onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) if err := invitations.ExpireByUserID( ctx, tx, scope, profile.ID, onlyPending, ); err != nil { return fmt.Errorf("cannot expire pending invitations: %w", err) } signatures := &coredata.DocumentVersionSignatures{} if err := signatures.DeleteRequestedBySignatory(ctx, tx, scope, profile.ID); err != nil { return fmt.Errorf("cannot delete requested signatures: %w", err) } now := time.Now() if profile.State != coredata.ProfileStateInactive { profile.State = coredata.ProfileStateInactive profile.UpdatedAt = now if err := profile.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update profile state: %w", err) } } membership.UpdatedAt = now if err := membership.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update membership: %w", err) } if err := webhook.InsertData(ctx, tx, scope, profile.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhooktypes.NewUser(&profile, membership)); err != nil { return fmt.Errorf("cannot insert webhook event: %w", err) } return nil }, ) } func (s *OrganizationService) InviteUser( ctx context.Context, req *CreateInvitationRequest, ) (*coredata.Invitation, error) { var ( scope = coredata.NewScopeFromObjectID(req.OrganizationID) now = time.Now() invitation = &coredata.Invitation{ ID: gid.New(req.OrganizationID.TenantID(), coredata.InvitationEntityType), OrganizationID: req.OrganizationID, UserID: req.ProfileID, Status: coredata.InvitationStatusPending, ExpiresAt: now.Add(s.invitationTokenValidity), CreatedAt: now, } ) err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { organization := coredata.Organization{} err := organization.LoadByID(ctx, tx, scope, req.OrganizationID) if err != nil { if err == coredata.ErrResourceNotFound { return NewOrganizationNotFoundError(req.OrganizationID) } return fmt.Errorf("cannot load organization: %w", err) } profile := &coredata.MembershipProfile{} if err := profile.LoadByID(ctx, tx, scope, req.ProfileID); err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return NewProfileNotFoundError(req.ProfileID) } return fmt.Errorf("cannot load profile: %w", err) } if profile.Source == coredata.ProfileSourceSCIM { return NewUserManagedBySCIMError(profile.ID) } err = invitation.Insert(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot insert invitation: %w", err) } invitationToken, err := statelesstoken.NewToken( s.tokenSecret, TokenTypeOrganizationInvitation, s.invitationTokenValidity, InvitationTokenData{InvitationID: invitation.ID}, ) if err != nil { return fmt.Errorf("cannot generate invitation token: %w", err) } emailPresenter := emails.NewPresenter(s.baseURL, profile.FullName) subject, textBody, htmlBody, err := emailPresenter.RenderInvitation( ctx, "/auth/activate-account", invitationToken, organization.Name, ) if err != nil { return fmt.Errorf("cannot render invitation email: %w", err) } email := coredata.NewEmail( profile.FullName, profile.EmailAddress, subject, textBody, htmlBody, nil, ) err = email.Insert(ctx, tx) if err != nil { return fmt.Errorf("cannot insert email: %w", err) } return nil }, ) if err != nil { return nil, err } return invitation, nil } func (s *OrganizationService) CreateOrganization( ctx context.Context, identityID gid.GID, req *CreateOrganizationRequest, ) (*coredata.Organization, *coredata.MembershipProfile, error) { if err := req.Validate(); err != nil { return nil, nil, fmt.Errorf("invalid request: %w", err) } var ( tenantID = gid.NewTenantID() organizationID = gid.New(tenantID, coredata.OrganizationEntityType) now = time.Now() organization = &coredata.Organization{ ID: organizationID, TenantID: tenantID, Name: req.Name, CreatedAt: now, UpdatedAt: now, } profile = &coredata.MembershipProfile{ ID: gid.New(tenantID, coredata.MembershipProfileEntityType), IdentityID: identityID, OrganizationID: organization.ID, Source: coredata.ProfileSourceManual, State: coredata.ProfileStateActive, CreatedAt: now, UpdatedAt: now, } membership = &coredata.Membership{ ID: gid.New(tenantID, coredata.MembershipEntityType), IdentityID: identityID, OrganizationID: organizationID, Role: coredata.MembershipRoleOwner, CreatedAt: now, UpdatedAt: now, } organizationContext = &coredata.OrganizationContext{ OrganizationID: organizationID, CreatedAt: now, UpdatedAt: now, } mailingList = &coredata.MailingList{ ID: gid.New(tenantID, coredata.MailingListEntityType), OrganizationID: organization.ID, CreatedAt: now, UpdatedAt: now, } trustCenter = &coredata.TrustCenter{ ID: gid.New(tenantID, coredata.TrustCenterEntityType), OrganizationID: organization.ID, TenantID: organization.TenantID, Active: false, Slug: slug.Make(organization.Name), SearchEngineIndexing: coredata.SearchEngineIndexingNotIndexable, MailingListID: &mailingList.ID, CreatedAt: now, UpdatedAt: now, } logoFile *coredata.File horizontalLogoFile *coredata.File scope = coredata.NewScope(tenantID) ) if req.LogoFile != nil { var ( fileID = gid.New(tenantID, coredata.FileEntityType) objectKey = uuid.MustNewV7() filename = req.LogoFile.Filename contentType = req.LogoFile.ContentType now = time.Now() ) logoFile = &coredata.File{ ID: fileID, OrganizationID: organization.ID, BucketName: s.bucket, MimeType: contentType, FileName: filename, FileKey: objectKey.String(), FileSize: req.LogoFile.Size, Visibility: coredata.FileVisibilityPublic, CreatedAt: now, UpdatedAt: now, } fileSize, err := s.fm.PutFile( ctx, logoFile, req.LogoFile.Content, map[string]string{ "file-id": fileID.String(), "organization-id": organization.ID.String(), }, ) if err != nil { return nil, nil, fmt.Errorf("cannot upload logo file: %w", err) } logoFile.FileSize = fileSize } if req.HorizontalLogoFile != nil { var ( fileID = gid.New(tenantID, coredata.FileEntityType) objectKey = uuid.MustNewV7() filename = req.HorizontalLogoFile.Filename contentType = req.HorizontalLogoFile.ContentType now = time.Now() ) horizontalLogoFile = &coredata.File{ ID: fileID, OrganizationID: organization.ID, BucketName: s.bucket, MimeType: contentType, FileName: filename, FileKey: objectKey.String(), FileSize: req.HorizontalLogoFile.Size, Visibility: coredata.FileVisibilityPublic, CreatedAt: now, UpdatedAt: now, } fileSize, err := s.fm.PutFile( ctx, horizontalLogoFile, req.HorizontalLogoFile.Content, map[string]string{ "file-id": fileID.String(), "organization-id": organization.ID.String(), }, ) if err != nil { return nil, nil, fmt.Errorf("cannot upload logo file: %w", err) } horizontalLogoFile.FileSize = fileSize } err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { identity := &coredata.Identity{} err := identity.LoadByID(ctx, tx, identityID) if err != nil { return fmt.Errorf("cannot load identity: %w", err) } profile.FullName = identity.FullName err = organization.Insert(ctx, tx) if err != nil { return fmt.Errorf("cannot insert organization: %w", err) } if logoFile != nil { err := logoFile.Insert(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot insert file: %w", err) } organization.LogoFileID = &logoFile.ID trustCenter.LogoFileID = &logoFile.ID } if horizontalLogoFile != nil { err := horizontalLogoFile.Insert(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot insert file: %w", err) } organization.HorizontalLogoFileID = &horizontalLogoFile.ID } err = profile.Insert(ctx, tx) if err != nil { return fmt.Errorf("cannot insert profile: %w", err) } err = membership.Insert(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot insert membership: %w", err) } if err := organizationContext.Insert(ctx, tx, scope); err != nil { return fmt.Errorf("cannot insert organization context: %w", err) } if err := mailingList.Insert(ctx, tx, scope); err != nil { return fmt.Errorf("cannot insert mailing list: %w", err) } if err := trustCenter.Insert(ctx, tx, scope); err != nil { return fmt.Errorf("cannot insert trust center: %w", err) } proboData := &coredata.ThirdParty{ ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType), OrganizationID: organization.ID, Name: proboThirdParty.Name, Description: &proboThirdParty.Description, Category: coredata.ThirdPartyCategorySecurity, HeadquarterAddress: &proboThirdParty.HeadquarterAddress, LegalName: &proboThirdParty.LegalName, WebsiteURL: &proboThirdParty.WebsiteURL, PrivacyPolicyURL: &proboThirdParty.PrivacyPolicyURL, TermsOfServiceURL: &proboThirdParty.TermsOfServiceURL, SubprocessorsListURL: &proboThirdParty.SubprocessorsListURL, ShowOnTrustCenter: false, Level: 1, CreatedAt: now, UpdatedAt: now, } if err := proboData.Insert(ctx, tx, scope); err != nil { return fmt.Errorf("cannot insert thirdParty: %w", err) } return nil }, ) if err != nil { return nil, nil, fmt.Errorf("cannot insert organization: %w", err) } return organization, profile, nil } func (s *OrganizationService) UpdateOrganization(ctx context.Context, organizationID gid.GID, req *UpdateOrganizationRequest) (*coredata.Organization, error) { if err := req.Validate(); err != nil { return nil, fmt.Errorf("invalid request: %w", err) } var ( now = time.Now() logoFile *coredata.File horizontalLogoFile *coredata.File tenantID = organizationID.TenantID() scope = coredata.NewScopeFromObjectID(organizationID) organization = &coredata.Organization{} compliancePage = &coredata.TrustCenter{} ) // TODO: s3 upload happen before we validate the tenantID if req.LogoFile != nil { var ( fileID = gid.New(tenantID, coredata.FileEntityType) objectKey = uuid.MustNewV7() filename = (*req.LogoFile).Filename contentType = (*req.LogoFile).ContentType ) logoFile = &coredata.File{ ID: fileID, OrganizationID: organizationID, BucketName: s.bucket, MimeType: contentType, FileName: filename, FileKey: objectKey.String(), FileSize: (*req.LogoFile).Size, Visibility: coredata.FileVisibilityPublic, CreatedAt: now, UpdatedAt: now, } fileSize, err := s.fm.PutFile( ctx, logoFile, (*req.LogoFile).Content, map[string]string{ "file-id": fileID.String(), "organization-id": organizationID.String(), }, ) if err != nil { return nil, fmt.Errorf("cannot upload logo file: %w", err) } logoFile.FileSize = fileSize } if req.HorizontalLogoFile != nil { var ( fileID = gid.New(tenantID, coredata.FileEntityType) objectKey = uuid.MustNewV7() filename = (*req.HorizontalLogoFile).Filename contentType = (*req.HorizontalLogoFile).ContentType now = time.Now() ) horizontalLogoFile = &coredata.File{ ID: fileID, OrganizationID: organizationID, BucketName: s.bucket, MimeType: contentType, FileName: filename, FileKey: objectKey.String(), FileSize: (*req.HorizontalLogoFile).Size, Visibility: coredata.FileVisibilityPublic, CreatedAt: now, UpdatedAt: now, } fileSize, err := s.fm.PutFile( ctx, horizontalLogoFile, (*req.HorizontalLogoFile).Content, map[string]string{ "file-id": fileID.String(), "organization-id": organizationID.String(), }, ) if err != nil { return nil, fmt.Errorf("cannot upload logo file: %w", err) } horizontalLogoFile.FileSize = fileSize } err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { err := organization.LoadByID(ctx, tx, scope, organizationID) if err != nil { return fmt.Errorf("cannot load organization: %w", err) } organization.UpdatedAt = now if req.Name != nil { organization.Name = *req.Name } if req.Description != nil { organization.Description = *req.Description } if req.WebsiteURL != nil { organization.WebsiteURL = *req.WebsiteURL } if req.Email != nil { if *req.Email != nil { if _, err := mail.ParseAddr(**req.Email); err != nil { return fmt.Errorf("invalid email address: %w", err) } } organization.Email = *req.Email } if req.HeadquarterAddress != nil { organization.HeadquarterAddress = *req.HeadquarterAddress } if logoFile != nil { if err := logoFile.Insert(ctx, tx, scope); err != nil { return fmt.Errorf("cannot insert file: %w", err) } organization.LogoFileID = &logoFile.ID // Auto set the compliance page org logo in case it wasn't already specified if err := compliancePage.LoadByOrganizationID(ctx, tx, scope, organizationID); err != nil { return fmt.Errorf("cannot load compliance page: %w", err) } if compliancePage.LogoFileID == nil { compliancePage.LogoFileID = &logoFile.ID compliancePage.UpdatedAt = now if err := compliancePage.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update compliance page: %w", err) } } } if horizontalLogoFile != nil { err := horizontalLogoFile.Insert(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot insert file: %w", err) } organization.HorizontalLogoFileID = &horizontalLogoFile.ID } err = organization.Update(ctx, scope, tx) if err != nil { return fmt.Errorf("cannot update organization: %w", err) } return nil }, ) if err != nil { return nil, err } return organization, nil } func (s *OrganizationService) DeleteOrganization(ctx context.Context, organizationID gid.GID) error { scope := coredata.NewScopeFromObjectID(organizationID) return s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { organization := &coredata.Organization{} err := organization.LoadByID(ctx, tx, scope, organizationID) if err != nil { return fmt.Errorf("cannot load organization: %w", err) } err = organization.Delete(ctx, tx, organizationID) if err != nil { return fmt.Errorf("cannot delete organization: %w", err) } return nil }, ) } func (s *OrganizationService) CreateUser(ctx context.Context, req *CreateUserRequest) (*coredata.MembershipProfile, error) { if err := req.Validate(); err != nil { return nil, fmt.Errorf("invalid request: %w", err) } var ( scope = coredata.NewScopeFromObjectID(req.OrganizationID) profile *coredata.MembershipProfile now = time.Now() ) err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { identity := &coredata.Identity{} if err := identity.LoadByEmail(ctx, conn, req.EmailAddress); err != nil { if !errors.Is(err, coredata.ErrResourceNotFound) { return fmt.Errorf("cannot load identity: %w", err) } identity = &coredata.Identity{ ID: gid.New(gid.NilTenant, coredata.IdentityEntityType), EmailAddress: req.EmailAddress, FullName: req.FullName, CreatedAt: now, UpdatedAt: now, } if err := identity.Insert(ctx, conn); err != nil { return fmt.Errorf("cannot insert identity: %w", err) } } profile = &coredata.MembershipProfile{ ID: gid.New(req.OrganizationID.TenantID(), coredata.MembershipProfileEntityType), IdentityID: identity.ID, OrganizationID: req.OrganizationID, EmailAddress: req.EmailAddress, Source: coredata.ProfileSourceManual, FullName: req.FullName, Kind: req.Kind, AdditionalEmailAddresses: req.AdditionalEmailAddresses, Position: req.Position, // User is created inactive State: coredata.ProfileStateInactive, CreatedAt: now, UpdatedAt: now, } if req.ContractStartDate != nil { profile.ContractStartDate = *req.ContractStartDate } if req.ContractEndDate != nil { profile.ContractEndDate = *req.ContractEndDate } if err := profile.Insert(ctx, conn); err != nil { if errors.Is(err, coredata.ErrResourceAlreadyExists) { return NewUserAlreadyExistsError(identity.ID, req.OrganizationID) } return fmt.Errorf("cannot insert profile: %w", err) } membership := &coredata.Membership{ ID: gid.New(req.OrganizationID.TenantID(), coredata.MembershipEntityType), IdentityID: identity.ID, OrganizationID: req.OrganizationID, Role: req.Role, CreatedAt: now, UpdatedAt: now, } if err := membership.Insert(ctx, conn, scope); err != nil { return fmt.Errorf("cannot insert membership: %w", err) } if err := webhook.InsertData(ctx, conn, scope, req.OrganizationID, coredata.WebhookEventTypeUserCreated, webhooktypes.NewUser(profile, membership)); err != nil { return fmt.Errorf("cannot insert webhook event: %w", err) } return nil }, ) if err != nil { return nil, err } return profile, nil } func (s *OrganizationService) UpdateUser(ctx context.Context, req *UpdateUserRequest) (*coredata.MembershipProfile, error) { if err := req.Validate(); err != nil { return nil, fmt.Errorf("invalid request: %w", err) } var ( scope = coredata.NewScopeFromObjectID(req.ID) profile = &coredata.MembershipProfile{} ) err := s.pg.WithTx( ctx, func(ctx context.Context, conn pg.Tx) error { if err := profile.LoadByID(ctx, conn, scope, req.ID); err != nil { return fmt.Errorf("cannot load profile: %w", err) } if profile.Source != coredata.ProfileSourceSCIM { profile.FullName = req.FullName profile.Kind = req.Kind profile.AdditionalEmailAddresses = req.AdditionalEmailAddresses profile.Position = req.Position } if req.ContractStartDate != nil { profile.ContractStartDate = *req.ContractStartDate } if req.ContractEndDate != nil { profile.ContractEndDate = *req.ContractEndDate } now := time.Now() profile.UpdatedAt = now if err := profile.Update(ctx, conn, scope); err != nil { return fmt.Errorf("cannot update profile: %w", err) } if profile.ContractEndDate != nil && profile.ContractEndDate.Before(now) { signatures := &coredata.DocumentVersionSignatures{} if err := signatures.DeleteRequestedBySignatory(ctx, conn, scope, profile.ID); err != nil { return fmt.Errorf("cannot delete requested signatures: %w", err) } } membership := &coredata.Membership{} var webhookPayload *webhooktypes.User if err := membership.LoadByIdentityIDAndOrganizationID(ctx, conn, scope, profile.IdentityID, profile.OrganizationID); err != nil { if !errors.Is(err, coredata.ErrResourceNotFound) { return fmt.Errorf("cannot load membership: %w", err) } webhookPayload = webhooktypes.NewUser(profile, nil) } else { webhookPayload = webhooktypes.NewUser(profile, membership) } if err := webhook.InsertData(ctx, conn, scope, profile.OrganizationID, coredata.WebhookEventTypeUserUpdated, webhookPayload, ); err != nil { return fmt.Errorf("cannot insert webhook event: %w", err) } return nil }, ) if err != nil { return nil, err } return profile, nil } func (s *OrganizationService) UpdateUserState( ctx context.Context, userID gid.GID, state coredata.ProfileState, ) (*coredata.MembershipProfile, error) { var ( scope = coredata.NewScopeFromObjectID(userID) profile = &coredata.MembershipProfile{} ) err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { if err := profile.LoadByID(ctx, tx, scope, userID); err != nil { return fmt.Errorf("cannot load profile: %w", err) } profile.State = state profile.UpdatedAt = time.Now() if err := profile.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update profile: %w", err) } if state == coredata.ProfileStateInactive { signatures := &coredata.DocumentVersionSignatures{} if err := signatures.DeleteRequestedBySignatory(ctx, tx, scope, profile.ID); err != nil { return fmt.Errorf("cannot delete requested signatures: %w", err) } } return nil }, ) if err != nil { return nil, err } return profile, nil } func (s *OrganizationService) GetProfile(ctx context.Context, profileID gid.GID) (*coredata.MembershipProfile, error) { profile := &coredata.MembershipProfile{} err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { if err := profile.LoadByID(ctx, conn, coredata.NewScopeFromObjectID(profileID), profileID); err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return NewProfileNotFoundError(profileID) } return fmt.Errorf("cannot load profile: %w", err) } return nil }, ) if err != nil { return nil, err } return profile, nil } func (s *OrganizationService) GetProfilesByIDs( ctx context.Context, scope coredata.Scoper, profileIDs ...gid.GID, ) (coredata.MembershipProfiles, error) { var profiles coredata.MembershipProfiles err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { if err := profiles.LoadByIDs( ctx, conn, scope, profileIDs, ); err != nil { return fmt.Errorf("cannot load profiles by ids: %w", err) } return nil }, ) if err != nil { return nil, err } return profiles, nil } func (s *OrganizationService) GetProfileForIdentityAndOrganization(ctx context.Context, identityID gid.GID, organizationID gid.GID) (*coredata.MembershipProfile, error) { profile := &coredata.MembershipProfile{} err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { if err := profile.LoadByIdentityIDAndOrganizationID( ctx, conn, coredata.NewScopeFromObjectID(organizationID), identityID, organizationID, ); err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return NewProfileNotFoundError(gid.Nil) } return fmt.Errorf("cannot load profile: %w", err) } return nil }, ) if err != nil { return nil, err } return profile, nil } func (s *OrganizationService) ListProfiles( ctx context.Context, organizationID gid.GID, cursor *page.Cursor[coredata.MembershipProfileOrderField], filter *coredata.MembershipProfileFilter, ) (*page.Page[*coredata.MembershipProfile, coredata.MembershipProfileOrderField], error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) profiles = coredata.MembershipProfiles{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { if err := profiles.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil { return fmt.Errorf("cannot load profiles: %w", err) } return nil }, ) if err != nil { return nil, err } return page.NewPage(profiles, cursor), nil } func (s OrganizationService) CountProfiles( ctx context.Context, organizationID gid.GID, filter *coredata.MembershipProfileFilter, ) (int, error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) count int ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) (err error) { profiles := coredata.MembershipProfiles{} count, err = profiles.CountByOrganizationID(ctx, conn, scope, organizationID, filter) if err != nil { return fmt.Errorf("cannot count profiles: %w", err) } return nil }, ) return count, err } func (s *OrganizationService) GetOrganizationForMembership(ctx context.Context, membershipID gid.GID) (*coredata.Organization, error) { var ( scope = coredata.NewScopeFromObjectID(membershipID) organization = &coredata.Organization{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { membership := &coredata.Membership{} err := membership.LoadByID(ctx, conn, scope, membershipID) if err != nil { if err == coredata.ErrResourceNotFound { return NewMembershipNotFoundError(membershipID) } return fmt.Errorf("cannot load membership: %w", err) } err = organization.LoadByID(ctx, conn, scope, membership.OrganizationID) if err != nil { if err == coredata.ErrResourceNotFound { return NewOrganizationNotFoundError(membership.OrganizationID) } return fmt.Errorf("cannot load organization: %w", err) } return nil }, ) if err != nil { return nil, err } return organization, nil } func (s OrganizationService) GenerateLogoURL( ctx context.Context, organizationID gid.GID, expiresIn time.Duration, ) (*string, error) { var ( errNoLogoFile = errors.New("no logo file found") scope = coredata.NewScopeFromObjectID(organizationID) file = &coredata.File{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { organization := &coredata.Organization{} if err := organization.LoadByID(ctx, conn, scope, organizationID); err != nil { return fmt.Errorf("cannot load organization: %w", err) } if organization.LogoFileID == nil { return errNoLogoFile } if err := file.LoadByID(ctx, conn, scope, *organization.LogoFileID); err != nil { return fmt.Errorf("cannot load file: %w", err) } return nil }, ) if err == errNoLogoFile { return nil, nil } if err != nil { return nil, fmt.Errorf("cannot generate logo URL: %w", err) } downloadURL, err := s.fm.BuildDownloadURL(file) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } return &downloadURL, nil } func (s OrganizationService) GenerateHorizontalLogoURL( ctx context.Context, organizationID gid.GID, expiresIn time.Duration, ) (*string, error) { var ( errNoLogoFile = errors.New("no logo file found") scope = coredata.NewScopeFromObjectID(organizationID) file = &coredata.File{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { organization := &coredata.Organization{} if err := organization.LoadByID(ctx, conn, scope, organizationID); err != nil { return fmt.Errorf("cannot load organization: %w", err) } if organization.HorizontalLogoFileID == nil { return errNoLogoFile } if err := file.LoadByID(ctx, conn, scope, *organization.HorizontalLogoFileID); err != nil { return fmt.Errorf("cannot load file: %w", err) } return nil }, ) if err == errNoLogoFile { return nil, nil } if err != nil { return nil, err } downloadURL, err := s.fm.BuildDownloadURL(file) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } return &downloadURL, nil } func (s OrganizationService) DeleteSAMLConfiguration( ctx context.Context, organizationID gid.GID, configID gid.GID, ) error { scope := coredata.NewScopeFromObjectID(organizationID) return s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { var config coredata.SAMLConfiguration if err := config.LoadByID(ctx, tx, scope, configID); err != nil { return fmt.Errorf("cannot load saml configuration: %w", err) } if config.OrganizationID != organizationID { return NewSAMLConfigurationNotFoundError(configID) } if err := config.Delete(ctx, tx, scope); err != nil { return fmt.Errorf("cannot delete saml configuration: %w", err) } return nil }, ) } func (s OrganizationService) ListSAMLConfigurations( ctx context.Context, organizationID gid.GID, cursor *page.Cursor[coredata.SAMLConfigurationOrderField], ) (*page.Page[*coredata.SAMLConfiguration, coredata.SAMLConfigurationOrderField], error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) samlConfigurations = coredata.SAMLConfigurations{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { err := samlConfigurations.LoadByOrganizationID(ctx, conn, scope, organizationID) if err != nil { return fmt.Errorf("cannot load saml configurations: %w", err) } return nil }, ) if err != nil { return nil, err } return page.NewPage(samlConfigurations, cursor), nil } func (s OrganizationService) CountSAMLConfigurations( ctx context.Context, organizationID gid.GID, ) (int, error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) count int ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) (err error) { samlConfigurations := coredata.SAMLConfigurations{} count, err = samlConfigurations.CountByOrganizationID(ctx, conn, scope, organizationID) if err != nil { return fmt.Errorf("cannot count saml configurations: %w", err) } return nil }, ) return count, err } func (s OrganizationService) ListSCIMEvents( ctx context.Context, organizationID gid.GID, cursor *page.Cursor[coredata.SCIMEventOrderField], ) (*page.Page[*coredata.SCIMEvent, coredata.SCIMEventOrderField], error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) scimEvents = coredata.SCIMEvents{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { err := scimEvents.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor) if err != nil { return fmt.Errorf("cannot load scim events: %w", err) } return nil }, ) if err != nil { return nil, err } return page.NewPage(scimEvents, cursor), nil } func (s OrganizationService) CountSCIMEvents( ctx context.Context, organizationID gid.GID, ) (int, error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) count int ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) (err error) { scimEvents := coredata.SCIMEvents{} count, err = scimEvents.CountByOrganizationID(ctx, conn, scope, organizationID) if err != nil { return fmt.Errorf("cannot count scim events: %w", err) } return nil }, ) return count, err } func (s OrganizationService) GetSCIMConfiguration( ctx context.Context, organizationID gid.GID, ) (*coredata.SCIMConfiguration, error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) config = &coredata.SCIMConfiguration{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { err := config.LoadByOrganizationID(ctx, conn, scope, organizationID) if err != nil { if err == coredata.ErrResourceNotFound { return NewNoSCIMConfigurationFoundError(organizationID) } return fmt.Errorf("cannot load SCIM configuration: %w", err) } return nil }, ) if err != nil { return nil, err } return config, nil } func (s OrganizationService) CreateSCIMConfiguration( ctx context.Context, organizationID gid.GID, ) (*coredata.SCIMConfiguration, string, error) { token, err := scim.GenerateToken() if err != nil { return nil, "", err } hashedToken := scim.HashToken(token) now := time.Now() config := &coredata.SCIMConfiguration{ ID: gid.New(organizationID.TenantID(), coredata.SCIMConfigurationEntityType), OrganizationID: organizationID, HashedToken: hashedToken, CreatedAt: now, UpdatedAt: now, } scope := coredata.NewScopeFromObjectID(organizationID) err = s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { err := config.Insert(ctx, tx, scope) if err != nil { if err == coredata.ErrResourceAlreadyExists { return scim.NewSCIMConfigurationAlreadyExistsError(organizationID) } return fmt.Errorf("cannot insert SCIM configuration: %w", err) } return nil }, ) if err != nil { return nil, "", err } return config, token, nil } func (s OrganizationService) DeleteSCIMConfiguration( ctx context.Context, organizationID gid.GID, configID gid.GID, ) error { scope := coredata.NewScopeFromObjectID(configID) return s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { config := &coredata.SCIMConfiguration{} err := config.LoadByID(ctx, tx, scope, configID) if err != nil { if err == coredata.ErrResourceNotFound { return scim.NewSCIMConfigurationNotFoundError(configID) } return fmt.Errorf("cannot load SCIM configuration: %w", err) } if config.OrganizationID != organizationID { return scim.NewSCIMConfigurationNotFoundError(configID) } profiles := &coredata.MembershipProfiles{} err = profiles.ResetSCIMSources(ctx, tx, scope, config.OrganizationID) if err != nil { return fmt.Errorf("cannot reset user sources: %w", err) } // Delete SCIM bridge and its connector if they exist bridge := &coredata.SCIMBridge{} err = bridge.LoadBySCIMConfigurationID(ctx, tx, scope, configID) if err != nil && err != coredata.ErrResourceNotFound { return fmt.Errorf("cannot load SCIM bridge: %w", err) } if err == nil { // Bridge exists. Only delete the underlying connector if nothing // else references it (e.g. access_sources). Otherwise leave it in // place — the bridge's FK is ON DELETE SET NULL, so deleting the // bridge alone is sufficient to unbind SCIM from the connector. if bridge.ConnectorID != nil { accessSources := &coredata.AccessSources{} count, err := accessSources.CountByConnectorID(ctx, tx, scope, *bridge.ConnectorID) if err != nil { return fmt.Errorf("cannot count access sources for connector: %w", err) } if count == 0 { connector := &coredata.Connector{ID: *bridge.ConnectorID} err = connector.Delete(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot delete connector: %w", err) } } } // Delete the bridge err = bridge.Delete(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot delete SCIM bridge: %w", err) } } err = config.Delete(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot delete SCIM configuration: %w", err) } return nil }, ) } func (s OrganizationService) RegenerateSCIMToken( ctx context.Context, organizationID gid.GID, configID gid.GID, ) (*coredata.SCIMConfiguration, string, error) { token, err := scim.GenerateToken() if err != nil { return nil, "", err } hashedToken := scim.HashToken(token) config := &coredata.SCIMConfiguration{} scope := coredata.NewScopeFromObjectID(configID) err = s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { err := config.LoadByID(ctx, tx, scope, configID) if err != nil { if err == coredata.ErrResourceNotFound { return scim.NewSCIMConfigurationNotFoundError(configID) } return fmt.Errorf("cannot load SCIM configuration: %w", err) } if config.OrganizationID != organizationID { return scim.NewSCIMConfigurationNotFoundError(configID) } config.HashedToken = hashedToken config.UpdatedAt = time.Now() err = config.Update(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot update SCIM configuration: %w", err) } return nil }, ) if err != nil { return nil, "", err } return config, token, nil } func (s OrganizationService) UpdateSCIMBridge( ctx context.Context, organizationID gid.GID, bridgeID gid.GID, excludedUserNames []string, ) (*coredata.SCIMBridge, error) { bridge := &coredata.SCIMBridge{} scope := coredata.NewScopeFromObjectID(bridgeID) err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { err := bridge.LoadByID(ctx, tx, scope, bridgeID) if err != nil { if err == coredata.ErrResourceNotFound { return NewSCIMBridgeNotFoundError(bridgeID) } return fmt.Errorf("cannot load SCIM bridge: %w", err) } if bridge.OrganizationID != organizationID { return NewSCIMBridgeNotFoundError(bridgeID) } bridge.ExcludedUserNames = excludedUserNames bridge.UpdatedAt = time.Now() err = bridge.Update(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot update SCIM bridge: %w", err) } return nil }, ) if err != nil { return nil, err } return bridge, nil } func (s OrganizationService) ListSCIMEventsByConfigID( ctx context.Context, scimConfigurationID gid.GID, cursor *page.Cursor[coredata.SCIMEventOrderField], ) (*page.Page[*coredata.SCIMEvent, coredata.SCIMEventOrderField], error) { var ( scope = coredata.NewScopeFromObjectID(scimConfigurationID) scimEvents = coredata.SCIMEvents{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { err := scimEvents.LoadBySCIMConfigurationID(ctx, conn, scope, scimConfigurationID, cursor) if err != nil { return fmt.Errorf("cannot load scim events: %w", err) } return nil }, ) if err != nil { return nil, err } return page.NewPage(scimEvents, cursor), nil } func (s OrganizationService) CountSCIMEventsByConfigID( ctx context.Context, scimConfigurationID gid.GID, ) (int, error) { var ( scope = coredata.NewScopeFromObjectID(scimConfigurationID) count int ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) (err error) { scimEvents := coredata.SCIMEvents{} count, err = scimEvents.CountBySCIMConfigurationID(ctx, conn, scope, scimConfigurationID) if err != nil { return fmt.Errorf("cannot count scim events: %w", err) } return nil }, ) return count, err } func (s OrganizationService) CreateSAMLConfiguration( ctx context.Context, organizationID gid.GID, req *CreateSAMLConfigurationRequest, ) (*coredata.SAMLConfiguration, error) { var ( now = time.Now() scope = coredata.NewScopeFromObjectID(organizationID) domainVerificationToken = uuid.MustNewV4().String() config = &coredata.SAMLConfiguration{ ID: gid.New(scope.GetTenantID(), coredata.SAMLConfigurationEntityType), OrganizationID: organizationID, EnforcementPolicy: coredata.SAMLEnforcementPolicyOff, IdPEntityID: req.IdPEntityID, IdPSsoURL: req.IdPSsoURL, IdPCertificate: req.IdPCertificate, DomainVerificationToken: &domainVerificationToken, EmailDomain: req.EmailDomain, AutoSignupEnabled: req.AutoSignupEnabled, AttributeEmail: DefaultAttributeEmail, AttributeFirstname: DefaultAttributeFirstname, AttributeLastname: DefaultAttributeLastname, AttributeRole: DefaultAttributeRole, CreatedAt: now, UpdatedAt: now, } ) if req.AttributeEmail != nil { config.AttributeEmail = *req.AttributeEmail } if req.AttributeFirstname != nil { config.AttributeFirstname = *req.AttributeFirstname } if req.AttributeLastname != nil { config.AttributeLastname = *req.AttributeLastname } if req.AttributeRole != nil { config.AttributeRole = *req.AttributeRole } err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { organization := &coredata.Organization{} err := organization.LoadByID(ctx, tx, scope, organizationID) if err != nil { return fmt.Errorf("cannot load organization: %w", err) } err = config.Insert(ctx, tx, scope) if err != nil { if errors.Is(err, coredata.ErrResourceAlreadyExists) { return NewSAMLConfigurationEmailDomainAlreadyExistsError(req.EmailDomain) } return fmt.Errorf("cannot insert saml configuration: %w", err) } return nil }, ) if err != nil { return nil, err } return config, nil } func (s OrganizationService) UpdateSAMLConfiguration( ctx context.Context, organizationID gid.GID, configID gid.GID, req *UpdateSAMLConfigurationRequest, ) (*coredata.SAMLConfiguration, error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) config = &coredata.SAMLConfiguration{} ) err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { organization := &coredata.Organization{} err := organization.LoadByID(ctx, tx, scope, organizationID) if err != nil { return fmt.Errorf("cannot load organization: %w", err) } config = &coredata.SAMLConfiguration{} err = config.LoadByID(ctx, tx, scope, configID) if err != nil { return fmt.Errorf("cannot load saml configuration: %w", err) } if req.EnforcementPolicy != nil { if config.DomainVerifiedAt == nil { return NewSAMLConfigurationDomainNotVerifiedError(configID) } config.EnforcementPolicy = *req.EnforcementPolicy } if req.IdPEntityID != nil { config.IdPEntityID = *req.IdPEntityID } if req.IdPSsoURL != nil { config.IdPSsoURL = *req.IdPSsoURL } if req.IdPCertificate != nil { config.IdPCertificate = *req.IdPCertificate } if req.AttributeEmail != nil { config.AttributeEmail = *req.AttributeEmail } if req.AttributeFirstname != nil { config.AttributeFirstname = *req.AttributeFirstname } if req.AttributeLastname != nil { config.AttributeLastname = *req.AttributeLastname } if req.AttributeRole != nil { config.AttributeRole = *req.AttributeRole } if req.AutoSignupEnabled != nil { config.AutoSignupEnabled = *req.AutoSignupEnabled } config.UpdatedAt = time.Now() err = config.Update(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot update saml configuration: %w", err) } return nil }, ) if err != nil { return nil, err } return config, nil } func (s OrganizationService) GetOrganization(ctx context.Context, organizationID gid.GID) (*coredata.Organization, error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) organization = &coredata.Organization{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { err := organization.LoadByID(ctx, conn, scope, organizationID) if err != nil { if err == coredata.ErrResourceNotFound { return NewOrganizationNotFoundError(organizationID) } return fmt.Errorf("cannot load organization: %w", err) } return nil }, ) if err != nil { return nil, err } return organization, nil } func (s OrganizationService) GetSCIMBridgeByID(ctx context.Context, bridgeID gid.GID) (*coredata.SCIMBridge, error) { var ( scope = coredata.NewScopeFromObjectID(bridgeID) bridge = &coredata.SCIMBridge{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { err := bridge.LoadByID(ctx, conn, scope, bridgeID) if err != nil { if err == coredata.ErrResourceNotFound { return NewSCIMBridgeNotFoundError(bridgeID) } return fmt.Errorf("cannot load SCIM bridge: %w", err) } return nil }, ) if err != nil { return nil, err } return bridge, nil } // GetConnectorMetadataByID returns connector metadata without decrypting the connection. // Use this when you only need provider, organization, or other metadata fields. func (s OrganizationService) GetConnectorMetadataByID(ctx context.Context, connectorID gid.GID) (*coredata.Connector, error) { var ( scope = coredata.NewScopeFromObjectID(connectorID) connector = &coredata.Connector{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { err := connector.LoadMetadataByID(ctx, conn, scope, connectorID) if err != nil { if err == coredata.ErrResourceNotFound { return NewConnectorNotFoundError(connectorID) } return fmt.Errorf("cannot load connector: %w", err) } return nil }, ) if err != nil { return nil, err } return connector, nil } func (s OrganizationService) GetSCIMBridgeByOrganizationID(ctx context.Context, organizationID gid.GID) (*coredata.SCIMBridge, error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) bridge = &coredata.SCIMBridge{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { err := bridge.LoadByOrganizationID(ctx, conn, scope, organizationID) if err != nil { if err == coredata.ErrResourceNotFound { return nil // No bridge found, not an error } return fmt.Errorf("cannot load SCIM bridge: %w", err) } return nil }, ) if err != nil { return nil, err } // If bridge ID is empty, no bridge was found if bridge.ID == (gid.GID{}) { return nil, nil } return bridge, nil } func (s OrganizationService) CreateSCIMBridge( ctx context.Context, organizationID gid.GID, scimConfigurationID gid.GID, connectorID gid.GID, ) (*coredata.SCIMBridge, error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) now = time.Now() bridge *coredata.SCIMBridge ) err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { organization := &coredata.Organization{} err := organization.LoadByID(ctx, tx, scope, organizationID) if err != nil { if err == coredata.ErrResourceNotFound { return NewOrganizationNotFoundError(organizationID) } return fmt.Errorf("cannot load organization: %w", err) } config := &coredata.SCIMConfiguration{} err = config.LoadByID(ctx, tx, scope, scimConfigurationID) if err != nil { if err == coredata.ErrResourceNotFound { return scim.NewSCIMConfigurationNotFoundError(scimConfigurationID) } return fmt.Errorf("cannot load SCIM configuration: %w", err) } if config.OrganizationID != organizationID { return scim.NewSCIMConfigurationNotFoundError(scimConfigurationID) } // Load and validate the connector (metadata only, no decryption needed) existingConnector := &coredata.Connector{} err = existingConnector.LoadMetadataByID(ctx, tx, scope, connectorID) if err != nil { if err == coredata.ErrResourceNotFound { return NewConnectorNotFoundError(connectorID) } return fmt.Errorf("cannot load connector: %w", err) } // Verify connector belongs to the same organization if existingConnector.OrganizationID != organizationID { return NewConnectorNotFoundError(connectorID) } // Map connector provider to bridge type var bridgeType coredata.SCIMBridgeType switch existingConnector.Provider { case coredata.ConnectorProviderGoogleWorkspace: bridgeType = coredata.SCIMBridgeTypeGoogleWorkspace case coredata.ConnectorProviderMicrosoft365: bridgeType = coredata.SCIMBridgeTypeMicrosoft365 default: return fmt.Errorf("connector provider %s is not supported for SCIM bridge", existingConnector.Provider) } bridge = &coredata.SCIMBridge{ ID: gid.New(organizationID.TenantID(), coredata.SCIMBridgeEntityType), OrganizationID: organizationID, ScimConfigurationID: scimConfigurationID, ConnectorID: &connectorID, Type: bridgeType, State: coredata.SCIMBridgeStateActive, // Active immediately since connector already exists ExcludedUserNames: []string{}, CreatedAt: now, UpdatedAt: now, } if err := bridge.Insert(ctx, tx, scope); err != nil { return fmt.Errorf("cannot insert SCIM bridge: %w", err) } return nil }, ) if err != nil { return nil, err } return bridge, nil } func (s OrganizationService) DeleteSCIMBridge(ctx context.Context, organizationID gid.GID, bridgeID gid.GID) error { var ( scope = coredata.NewScopeFromObjectID(organizationID) bridge = &coredata.SCIMBridge{} ) err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { organization := &coredata.Organization{} err := organization.LoadByID(ctx, tx, scope, organizationID) if err != nil { return fmt.Errorf("cannot load organization: %w", err) } if err := bridge.LoadByID(ctx, tx, scope, bridgeID); err != nil { return fmt.Errorf("cannot load SCIM bridge: %w", err) } if bridge.OrganizationID != organizationID { return NewSCIMBridgeNotFoundError(bridgeID) } if err := bridge.Delete(ctx, tx, scope); err != nil { return fmt.Errorf("cannot delete SCIM bridge: %w", err) } return nil }, ) if err != nil { return err } return nil } func (s *OrganizationService) GetAuditLogEntry( ctx context.Context, id gid.GID, ) (*coredata.AuditLogEntry, error) { var ( scope = coredata.NewScopeFromObjectID(id) entry = &coredata.AuditLogEntry{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { return entry.LoadByID(ctx, conn, scope, id) }, ) if err != nil { return nil, fmt.Errorf("cannot load audit log entry: %w", err) } return entry, nil } func (s *OrganizationService) ListAuditLogEntries( ctx context.Context, organizationID gid.GID, cursor *page.Cursor[coredata.AuditLogEntryOrderField], filter *coredata.AuditLogEntryFilter, ) (*page.Page[*coredata.AuditLogEntry, coredata.AuditLogEntryOrderField], error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) entries = coredata.AuditLogEntries{} ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { if err := entries.LoadAllByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil { return fmt.Errorf("cannot load audit log entries: %w", err) } return nil }, ) if err != nil { return nil, err } return page.NewPage(entries, cursor), nil } func (s *OrganizationService) CountAuditLogEntries( ctx context.Context, organizationID gid.GID, filter *coredata.AuditLogEntryFilter, ) (int, error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) count int ) err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) (err error) { entries := coredata.AuditLogEntries{} count, err = entries.CountByOrganizationID(ctx, conn, scope, organizationID, filter) if err != nil { return fmt.Errorf("cannot count audit log entries: %w", err) } return nil }, ) return count, err }