Files
probo/pkg/iam/organization_service.go
Bryan Frimin cfc514c8e0 Add SCIM management
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
2026-01-17 12:20:14 -08:00

1545 lines
38 KiB
Go

// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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/baseurl"
"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/statelesstoken"
"go.probo.inc/probo/pkg/validator"
)
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 {
Email mail.Addr
FullName string
Role coredata.MembershipRole
}
)
const (
TokenTypeAPIKey = "api_key"
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 NewOrganizationService(svc *Service) *OrganizationService {
return &OrganizationService{Service: svc}
}
func (s *OrganizationService) CountMemberships(
ctx context.Context,
organizationID gid.GID,
) (int, error) {
var count int
scope := coredata.NewScopeFromObjectID(organizationID)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
memberships := coredata.Memberships{}
count, err = memberships.CountByOrganizationID(ctx, conn, scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count memberships: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *OrganizationService) UpdateMempership(
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(tx pg.Conn) 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)
}
membership.Role = role
membership.UpdatedAt = time.Now()
if err := membership.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update membership: %w", err)
}
return nil
},
); err != nil {
return nil, err
}
return &membership, nil
}
func (s *OrganizationService) RemoveMember(
ctx context.Context,
organizationID gid.GID,
membershipID gid.GID,
) error {
scope := coredata.NewScopeFromObjectID(organizationID)
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
membership := coredata.Membership{}
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)
}
err := membership.Delete(ctx, tx, scope, membershipID)
if err != nil {
return fmt.Errorf("cannot delete membership: %w", err)
}
return nil
},
)
}
func (s *OrganizationService) DeleteInvitation(
ctx context.Context,
organizationID gid.GID,
invitationID gid.GID,
) error {
scope := coredata.NewScopeFromObjectID(organizationID)
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := coredata.Invitation{}
err := invitation.LoadByID(ctx, tx, scope, invitationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.Status != coredata.InvitationStatusPending {
return NewInvitationNotPendingError(invitationID)
}
err = invitation.Delete(ctx, tx, scope, invitationID)
if err != nil {
return fmt.Errorf("cannot delete invitation: %w", err)
}
return nil
},
)
}
func (s *OrganizationService) ListInvitations(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField],
filter *coredata.InvitationFilter,
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var (
invitations coredata.Invitations
scope = coredata.NewScopeFromObjectID(organizationID)
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := invitations.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter)
if err != nil {
return fmt.Errorf("cannot load invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
func (s *OrganizationService) CountInvitations(
ctx context.Context,
organizationID gid.GID,
filter *coredata.InvitationFilter,
) (int, error) {
var (
count int
scope = coredata.NewScopeFromObjectID(organizationID)
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
invitations := coredata.Invitations{}
count, err = invitations.CountByOrganizationID(ctx, conn, scope, organizationID, filter)
if err != nil {
return fmt.Errorf("cannot count invitations: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *OrganizationService) InviteMember(
ctx context.Context,
organizationID gid.GID,
req *CreateInvitationRequest,
) (*coredata.Invitation, error) {
var (
scope = coredata.NewScopeFromObjectID(organizationID)
now = time.Now()
invitation = &coredata.Invitation{
ID: gid.New(organizationID.TenantID(), coredata.InvitationEntityType),
OrganizationID: organizationID,
Email: req.Email,
FullName: req.FullName,
Role: req.Role,
Status: coredata.InvitationStatusPending,
ExpiresAt: now.Add(s.invitationTokenValidity),
CreatedAt: now,
}
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) 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)
}
identity := &coredata.Identity{}
err = identity.LoadByEmail(ctx, tx, invitation.Email)
if err != nil && err != coredata.ErrResourceNotFound {
return fmt.Errorf("cannot load identity: %w", err)
}
identityExists := identity.ID != gid.Nil
if identityExists {
membership := &coredata.Membership{}
err = membership.LoadByIdentityAndOrg(ctx, tx, scope, identity.ID, organizationID)
if err != nil && err != coredata.ErrResourceNotFound {
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.ID != gid.Nil {
return NewMembershipAlreadyExistsError(identity.ID, organizationID)
}
}
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)
}
baseurl, err := baseurl.Parse(s.baseURL)
if err != nil {
return fmt.Errorf("cannot parse base URL: %w", err)
}
invitationURL := baseurl.WithPath("/auth/signup-from-invitation").
WithQuery("token", invitationToken).
WithQuery("fullName", invitation.FullName).
MustString()
subject, textBody, htmlBody, err := emails.RenderInvitation(
s.baseURL,
invitation.FullName,
organization.Name,
invitationURL,
)
if err != nil {
return fmt.Errorf("cannot render invitation email: %w", err)
}
email := coredata.NewEmail(
invitation.FullName,
invitation.Email,
subject,
textBody,
htmlBody,
)
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, error) {
if err := req.Validate(); err != nil {
return 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,
}
membership = &coredata.Membership{
ID: gid.New(tenantID, coredata.MembershipEntityType),
IdentityID: identityID,
OrganizationID: organizationID,
Role: coredata.MembershipRoleOwner,
Source: coredata.MembershipSourceManual,
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,
BucketName: s.bucket,
MimeType: contentType,
FileName: filename,
FileKey: objectKey.String(),
FileSize: req.LogoFile.Size,
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, 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.LogoFile.Filename
contentType = req.LogoFile.ContentType
now = time.Now()
)
horizontalLogoFile = &coredata.File{
ID: fileID,
BucketName: s.bucket,
MimeType: contentType,
FileName: filename,
FileKey: objectKey.String(),
FileSize: req.LogoFile.Size,
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, fmt.Errorf("cannot upload logo file: %w", err)
}
horizontalLogoFile.FileSize = fileSize
}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
identity := &coredata.Identity{}
err := identity.LoadByID(ctx, tx, identityID)
if err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
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)
}
}
if horizontalLogoFile != nil {
err := horizontalLogoFile.Insert(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot insert file: %w", err)
}
}
err = membership.Insert(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot create membership: %w", err)
}
profile := &coredata.MembershipProfile{
ID: gid.New(tenantID, coredata.MembershipProfileEntityType),
MembershipID: membership.ID,
FullName: identity.FullName,
CreatedAt: now,
UpdatedAt: now,
}
err = profile.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert profile: %w", err)
}
return nil
},
)
if err != nil {
return nil, fmt.Errorf("cannot insert organization: %w", err)
}
return organization, 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{}
)
// 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,
BucketName: s.bucket,
MimeType: contentType,
FileName: filename,
FileKey: objectKey.String(),
FileSize: (*req.LogoFile).Size,
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,
BucketName: s.bucket,
MimeType: contentType,
FileName: filename,
FileKey: objectKey.String(),
FileSize: (*req.HorizontalLogoFile).Size,
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(tx pg.Conn) 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.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 {
err := logoFile.Insert(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot insert file: %w", err)
}
organization.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 = 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(tx pg.Conn) 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) ListMembers(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.MembershipOrderField],
) (*page.Page[*coredata.Membership, coredata.MembershipOrderField], error) {
var (
scope = coredata.NewScopeFromObjectID(organizationID)
memberships = coredata.Memberships{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := memberships.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor)
if err != nil {
return fmt.Errorf("cannot load memberships: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(memberships, cursor), nil
}
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(conn pg.Conn) 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) GetOrganizationForInvitation(ctx context.Context, invitationID gid.GID) (*coredata.Organization, error) {
var (
scope = coredata.NewScopeFromObjectID(invitationID)
organization = &coredata.Organization{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
invitation := &coredata.Invitation{}
err := invitation.LoadByID(ctx, conn, scope, invitationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot load invitation: %w", err)
}
err = organization.LoadByID(ctx, conn, scope, invitation.OrganizationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewOrganizationNotFoundError(invitation.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(conn pg.Conn) 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)
}
presignedURL, err := s.fm.GenerateFileUrl(ctx, file, expiresIn)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &presignedURL, 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(conn pg.Conn) 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
}
presignedURL, err := s.fm.GenerateFileUrl(ctx, file, expiresIn)
if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err)
}
return &presignedURL, 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(tx pg.Conn) 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(conn pg.Conn) 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(conn pg.Conn) (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(conn pg.Conn) 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(conn pg.Conn) (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(conn pg.Conn) 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(tx pg.Conn) 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(tx pg.Conn) 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)
}
memberships := &coredata.Memberships{}
err = memberships.ResetSCIMSources(ctx, tx, scope, config.OrganizationID)
if err != nil {
return fmt.Errorf("cannot reset membership sources: %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(tx pg.Conn) 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) 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(conn pg.Conn) 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(conn pg.Conn) (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(tx pg.Conn) 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(tx pg.Conn) 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(conn pg.Conn) 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
}