Files
probo/pkg/coredata/membership_profile.go
Sacha Al Himdani e5e17d59ac Handle SCIM user email rename via external ID fallback
When a user's email is renamed in the identity provider (e.g. Google
Workspace), the external ID stays the same but the email changes. The
SCIM CreateUser now falls back to external ID lookup when no profile is
found by identity, and reassociates the existing profile to the new
identity instead of failing with a 409 uniqueness error.

Also removes user emails from bridge sync error messages to avoid
logging PII, using external IDs instead.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
2026-04-23 10:45:48 +02:00

1415 lines
33 KiB
Go

// Copyright (c) 2025-2026 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 coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
)
type (
MembershipProfile struct {
ID gid.GID `db:"id"`
IdentityID gid.GID `db:"identity_id"`
OrganizationID gid.GID `db:"organization_id"`
EmailAddress mail.Addr `db:"email_address"`
Source ProfileSource `db:"source"`
State ProfileState `db:"state"`
FullName string `db:"full_name"`
Kind *string `db:"kind"`
AdditionalEmailAddresses mail.Addrs `db:"additional_email_addresses"`
Position *string `db:"position"`
ContractStartDate *time.Time `db:"contract_start_date"`
ContractEndDate *time.Time `db:"contract_end_date"`
OrganizationName string `db:"organization_name"`
UserName *string `db:"user_name"`
ExternalID *string `db:"external_id"`
Nickname *string `db:"nickname"`
Locale *string `db:"locale"`
Timezone *string `db:"timezone"`
ProfileUrl *string `db:"profile_url"`
PreferredLanguage *string `db:"preferred_language"`
GivenName *string `db:"given_name"`
FamilyName *string `db:"family_name"`
FormattedName *string `db:"formatted_name"`
MiddleName *string `db:"middle_name"`
HonorificPrefix *string `db:"honorific_prefix"`
HonorificSuffix *string `db:"honorific_suffix"`
EmployeeNumber *string `db:"employee_number"`
Department *string `db:"department"`
CostCenter *string `db:"cost_center"`
EnterpriseOrganization *string `db:"enterprise_organization"`
Division *string `db:"division"`
ManagerValue *string `db:"manager_value"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
MembershipProfiles []*MembershipProfile
)
func (p MembershipProfile) CursorKey(orderBy MembershipProfileOrderField) page.CursorKey {
switch orderBy {
case MembershipProfileOrderFieldCreatedAt:
return page.NewCursorKey(p.ID, p.CreatedAt)
case MembershipProfileOrderFieldFullName:
return page.NewCursorKey(p.ID, p.FullName)
case MembershipProfileOrderFieldKind:
return page.NewCursorKey(p.ID, p.Kind)
case MembershipProfileOrderFieldOrganizationName:
return page.NewCursorKey(p.ID, p.OrganizationName)
case MembershipProfileOrderFieldState:
return page.NewCursorKey(p.ID, p.State)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (p *MembershipProfile) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
q := `SELECT organization_id, identity_id FROM iam_membership_profiles WHERE id = $1 LIMIT 1;`
var organizationID gid.GID
var identityID gid.GID
if err := conn.QueryRow(ctx, q, p.ID).Scan(&organizationID, &identityID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query profile authorization attributes: %w", err)
}
return map[string]string{
"organization_id": organizationID.String(),
"identity_id": identityID.String(),
}, nil
}
func (p *MembershipProfile) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
profileID gid.GID,
) error {
q := `
SELECT
p.id,
p.identity_id,
p.organization_id,
i.email_address,
p.source,
p.state,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
'' AS organization_name,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM
iam_membership_profiles p
INNER JOIN identities i
ON i.id = p.identity_id
WHERE
p.%s
AND p.id = @profile_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"profile_id": profileID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query profile: %w", err)
}
profile, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[MembershipProfile])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect profile: %w", err)
}
*p = profile
return nil
}
func (p *MembershipProfile) LoadByIdentityIDAndOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
identityID gid.GID,
organizationID gid.GID,
) error {
q := `
SELECT
p.id,
p.identity_id,
p.organization_id,
i.email_address,
p.source,
p.state,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
'' AS organization_name,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM
iam_membership_profiles p
INNER JOIN identities i
ON i.id = p.identity_id
WHERE
p.%s
AND p.identity_id = @identity_id
AND p.organization_id = @organization_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"identity_id": identityID,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query profile: %w", err)
}
profile, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[MembershipProfile])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect profile: %w", err)
}
*p = profile
return nil
}
func (p *MembershipProfile) LoadByExternalIDAndOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
externalID string,
organizationID gid.GID,
) error {
q := `
SELECT
p.id,
p.identity_id,
p.organization_id,
i.email_address,
p.source,
p.state,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
'' AS organization_name,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM
iam_membership_profiles p
INNER JOIN identities i
ON i.id = p.identity_id
WHERE
p.%s
AND p.external_id = @external_id
AND p.organization_id = @organization_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"external_id": externalID,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query profile: %w", err)
}
profile, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[MembershipProfile])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect profile: %w", err)
}
*p = profile
return nil
}
func (p *MembershipProfiles) LoadByIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
profileIDs []gid.GID,
) error {
q := `
SELECT
p.id,
p.identity_id,
p.organization_id,
i.email_address,
p.source,
p.state,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
'' AS organization_name,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM
iam_membership_profiles p
INNER JOIN identities i
ON i.id = p.identity_id
WHERE
p.%s
AND p.id = ANY(@profile_ids)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{"profile_ids": profileIDs}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query profiles: %w", err)
}
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
if err != nil {
return fmt.Errorf("cannot collect profiles: %w", err)
}
*p = profiles
return nil
}
func (p *MembershipProfiles) LoadByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[MembershipProfileOrderField],
filter *MembershipProfileFilter,
) error {
q := `
WITH profiles AS (
SELECT
p.id,
p.identity_id,
p.organization_id,
i.email_address,
p.source,
p.state,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM
iam_membership_profiles p
INNER JOIN identities i ON i.id = p.identity_id
WHERE
p.%s
AND p.organization_id = @organization_id
AND %s
)
SELECT
id,
identity_id,
organization_id,
email_address,
source,
state,
full_name,
kind,
additional_email_addresses,
position,
contract_start_date,
contract_end_date,
'' AS organization_name,
user_name,
external_id,
nickname,
locale,
timezone,
profile_url,
preferred_language,
given_name,
family_name,
formatted_name,
middle_name,
honorific_prefix,
honorific_suffix,
employee_number,
department,
cost_center,
enterprise_organization,
division,
manager_value,
created_at,
updated_at
FROM profiles
WHERE
%s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query profiles: %w", err)
}
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
if err != nil {
return fmt.Errorf("cannot collect profiles: %w", err)
}
*p = profiles
return nil
}
func (p *MembershipProfiles) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
filter *MembershipProfileFilter,
) error {
q := `
WITH profiles AS (
SELECT
p.id,
p.identity_id,
p.organization_id,
i.email_address,
p.source,
p.state,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM
iam_membership_profiles p
INNER JOIN identities i ON i.id = p.identity_id
WHERE
p.%s
AND p.organization_id = @organization_id
AND %s
)
SELECT
id,
identity_id,
organization_id,
email_address,
source,
state,
full_name,
kind,
additional_email_addresses,
position,
contract_start_date,
contract_end_date,
'' AS organization_name,
user_name,
external_id,
nickname,
locale,
timezone,
profile_url,
preferred_language,
given_name,
family_name,
formatted_name,
middle_name,
honorific_prefix,
honorific_suffix,
employee_number,
department,
cost_center,
enterprise_organization,
division,
manager_value,
created_at,
updated_at
FROM profiles
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.NamedArgs{
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query profiles: %w", err)
}
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
if err != nil {
return fmt.Errorf("cannot collect profiles: %w", err)
}
*p = profiles
return nil
}
func (p *MembershipProfiles) LoadByIdentityID(
ctx context.Context,
conn pg.Querier,
identityID gid.GID,
cursor *page.Cursor[MembershipProfileOrderField],
filter *MembershipProfileFilter,
) error {
q := `
WITH profiles AS (
SELECT
p.id,
p.identity_id,
p.organization_id,
i.email_address,
p.source,
p.state,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM
iam_membership_profiles p
INNER JOIN identities i ON i.id = p.identity_id
WHERE
p.identity_id = @identity_id
AND %s
)
SELECT
p.id,
p.identity_id,
p.organization_id,
p.email_address,
p.source,
p.state,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
o.name AS organization_name,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM profiles p
INNER JOIN organizations o ON o.id = p.organization_id
WHERE
%s
`
q = fmt.Sprintf(q, filter.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"identity_id": identityID}
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query profiles: %w", err)
}
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
if err != nil {
return fmt.Errorf("cannot collect profiles: %w", err)
}
*p = profiles
return nil
}
func (p *MembershipProfiles) LoadByDocumentVersionID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentVersionID gid.GID,
cursor *page.Cursor[MembershipProfileOrderField],
) error {
q := `
WITH latest_quorum AS (
SELECT id
FROM document_version_approval_quorums
WHERE version_id = @version_id
ORDER BY created_at DESC
LIMIT 1
),
version_approvers AS (
SELECT d.approver_id
FROM document_version_approval_decisions d
WHERE d.quorum_id = (SELECT id FROM latest_quorum)
),
profiles AS (
SELECT
mp.id,
mp.identity_id,
mp.organization_id,
mp.full_name,
mp.source,
mp.state,
mp.kind,
mp.additional_email_addresses,
mp.position,
mp.contract_start_date,
mp.contract_end_date,
mp.user_name,
mp.external_id,
mp.nickname,
mp.locale,
mp.timezone,
mp.profile_url,
mp.preferred_language,
mp.given_name,
mp.family_name,
mp.formatted_name,
mp.middle_name,
mp.honorific_prefix,
mp.honorific_suffix,
mp.employee_number,
mp.department,
mp.cost_center,
mp.enterprise_organization,
mp.division,
mp.manager_value,
mp.created_at,
mp.updated_at
FROM
iam_membership_profiles mp
INNER JOIN version_approvers va ON va.approver_id = mp.id
WHERE
mp.%s
AND %s
)
SELECT
p.id,
p.identity_id,
p.organization_id,
i.email_address,
p.source,
p.state,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
'' AS organization_name,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM profiles p
INNER JOIN identities i ON i.id = p.identity_id
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"version_id": documentVersionID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document version approver profiles: %w", err)
}
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
if err != nil {
return fmt.Errorf("cannot collect document version approver profiles: %w", err)
}
*p = profiles
return nil
}
func (p *MembershipProfiles) CountByDocumentVersionID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
documentVersionID gid.GID,
) (int, error) {
q := `
WITH latest_quorum AS (
SELECT id
FROM document_version_approval_quorums
WHERE version_id = @version_id
ORDER BY created_at DESC
LIMIT 1
)
SELECT
COUNT(DISTINCT mp.id)
FROM
iam_membership_profiles mp
INNER JOIN document_version_approval_decisions dvad ON mp.id = dvad.approver_id
INNER JOIN latest_quorum lq ON lq.id = dvad.quorum_id
WHERE
mp.%s
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
maps.Copy(args, scope.SQLArguments())
var count int
err := conn.QueryRow(ctx, q, args).Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot query document version approver profiles count: %w", err)
}
return count, nil
}
func (p *MembershipProfiles) LoadAwaitingSigning(
ctx context.Context,
conn pg.Querier,
scope Scoper,
) error {
q := `
WITH signatories AS (
SELECT
signed_by_profile_id
FROM
document_version_signatures
WHERE
%s
AND state = 'REQUESTED'
GROUP BY
signed_by_profile_id
)
SELECT
p.id,
p.identity_id,
p.organization_id,
p.kind,
p.full_name,
i.email_address,
p.source,
p.state,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
'' AS organization_name,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM
iam_membership_profiles p
INNER JOIN identities i
ON i.id = p.identity_id
INNER JOIN signatories ON p.id = signatories.signed_by_profile_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
rows, err := conn.Query(ctx, q, scope.SQLArguments())
if err != nil {
return fmt.Errorf("cannot query profiles: %w", err)
}
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
if err != nil {
return fmt.Errorf("cannot collect profiles: %w", err)
}
*p = profiles
return nil
}
func (p *MembershipProfiles) CountByIdentityID(
ctx context.Context,
conn pg.Querier,
identityID gid.GID,
filter *MembershipProfileFilter,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
iam_membership_profiles p
INNER JOIN identities i ON i.id = p.identity_id
WHERE
%s
AND p.identity_id = @identity_id
`
q = fmt.Sprintf(q, filter.SQLFragment())
args := pgx.StrictNamedArgs{"identity_id": identityID}
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot collect count: %w", err)
}
return count, nil
}
func (p *MembershipProfiles) CountByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
filter *MembershipProfileFilter,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
iam_membership_profiles p
INNER JOIN identities i ON i.id = p.identity_id
WHERE
p.%s
AND %s
AND p.organization_id = @organization_id
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot collect count: %w", err)
}
return count, nil
}
func (p *MembershipProfiles) CountActiveOwnerByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
iam_membership_profiles p
INNER JOIN iam_memberships m ON m.identity_id = p.identity_id AND m.organization_id = p.organization_id
WHERE
p.%s
AND p.organization_id = @organization_id
AND p.state = @state
AND m.role = @role
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"state": ProfileStateActive,
"role": MembershipRoleOwner,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot collect count: %w", err)
}
return count, nil
}
func (p *MembershipProfile) Insert(
ctx context.Context,
conn pg.Tx,
) error {
q := `
INSERT INTO
iam_membership_profiles (
tenant_id,
id,
identity_id,
organization_id,
source,
state,
full_name,
kind,
additional_email_addresses,
position,
contract_start_date,
contract_end_date,
user_name,
external_id,
nickname,
locale,
timezone,
profile_url,
preferred_language,
given_name,
family_name,
formatted_name,
middle_name,
honorific_prefix,
honorific_suffix,
employee_number,
department,
cost_center,
enterprise_organization,
division,
manager_value,
created_at,
updated_at
)
VALUES (
@tenant_id,
@id,
@identity_id,
@organization_id,
@source,
@state,
@full_name,
@kind,
COALESCE(@additional_email_addresses, '{}'::CITEXT[]),
@position,
@contract_start_date,
@contract_end_date,
@user_name,
@external_id,
@nickname,
@locale,
@timezone,
@profile_url,
@preferred_language,
@given_name,
@family_name,
@formatted_name,
@middle_name,
@honorific_prefix,
@honorific_suffix,
@employee_number,
@department,
@cost_center,
@enterprise_organization,
@division,
@manager_value,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"tenant_id": p.ID.TenantID().String(),
"id": p.ID,
"identity_id": p.IdentityID,
"organization_id": p.OrganizationID,
"source": p.Source,
"state": p.State,
"full_name": p.FullName,
"kind": p.Kind,
"additional_email_addresses": p.AdditionalEmailAddresses,
"position": p.Position,
"contract_start_date": p.ContractStartDate,
"contract_end_date": p.ContractEndDate,
"user_name": p.UserName,
"external_id": p.ExternalID,
"nickname": p.Nickname,
"locale": p.Locale,
"timezone": p.Timezone,
"profile_url": p.ProfileUrl,
"preferred_language": p.PreferredLanguage,
"given_name": p.GivenName,
"family_name": p.FamilyName,
"formatted_name": p.FormattedName,
"middle_name": p.MiddleName,
"honorific_prefix": p.HonorificPrefix,
"honorific_suffix": p.HonorificSuffix,
"employee_number": p.EmployeeNumber,
"department": p.Department,
"cost_center": p.CostCenter,
"enterprise_organization": p.EnterpriseOrganization,
"division": p.Division,
"manager_value": p.ManagerValue,
"created_at": p.CreatedAt,
"updated_at": p.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" {
return ErrResourceAlreadyExists
}
return fmt.Errorf("cannot insert profile: %w", err)
}
return nil
}
func (p *MembershipProfile) Update(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
UPDATE
iam_membership_profiles
SET
identity_id = @identity_id,
source = @source,
state = @state,
full_name = @full_name,
kind = @kind,
additional_email_addresses = COALESCE(@additional_email_addresses, '{}'::CITEXT[]),
position = @position,
contract_start_date = @contract_start_date,
contract_end_date = @contract_end_date,
user_name = @user_name,
external_id = @external_id,
nickname = @nickname,
locale = @locale,
timezone = @timezone,
profile_url = @profile_url,
preferred_language = @preferred_language,
given_name = @given_name,
family_name = @family_name,
formatted_name = @formatted_name,
middle_name = @middle_name,
honorific_prefix = @honorific_prefix,
honorific_suffix = @honorific_suffix,
employee_number = @employee_number,
department = @department,
cost_center = @cost_center,
enterprise_organization = @enterprise_organization,
division = @division,
manager_value = @manager_value,
updated_at = @updated_at
WHERE
id = @id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": p.ID,
"identity_id": p.IdentityID,
"source": p.Source,
"state": p.State,
"full_name": p.FullName,
"kind": p.Kind,
"additional_email_addresses": p.AdditionalEmailAddresses,
"position": p.Position,
"contract_start_date": p.ContractStartDate,
"contract_end_date": p.ContractEndDate,
"user_name": p.UserName,
"external_id": p.ExternalID,
"nickname": p.Nickname,
"locale": p.Locale,
"timezone": p.Timezone,
"profile_url": p.ProfileUrl,
"preferred_language": p.PreferredLanguage,
"given_name": p.GivenName,
"family_name": p.FamilyName,
"formatted_name": p.FormattedName,
"middle_name": p.MiddleName,
"honorific_prefix": p.HonorificPrefix,
"honorific_suffix": p.HonorificSuffix,
"employee_number": p.EmployeeNumber,
"department": p.Department,
"cost_center": p.CostCenter,
"enterprise_organization": p.EnterpriseOrganization,
"division": p.Division,
"manager_value": p.ManagerValue,
"updated_at": p.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update profile: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
func (p *MembershipProfiles) ResetSCIMSources(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) error {
q := `
UPDATE iam_membership_profiles
SET
source = 'MANUAL',
external_id = NULL,
user_name = NULL,
updated_at = @updated_at
WHERE
%s
AND organization_id = @organization_id
AND source = 'SCIM'
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{
"organization_id": organizationID,
"updated_at": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot reset SCIM user sources: %w", err)
}
return nil
}
func (p *MembershipProfile) ClearExternalID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
externalID string,
organizationID gid.GID,
) error {
q := `
UPDATE iam_membership_profiles
SET
external_id = NULL,
updated_at = @updated_at
WHERE
%s
AND external_id = @external_id
AND organization_id = @organization_id
AND id != @exclude_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{
"external_id": externalID,
"organization_id": organizationID,
"exclude_id": p.ID,
"updated_at": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot clear external id: %w", err)
}
return nil
}
func (p *MembershipProfile) Delete(
ctx context.Context,
conn pg.Tx,
scope Scoper,
profileID gid.GID,
) error {
q := `
DELETE FROM
iam_membership_profiles
WHERE
id = @profile_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"profile_id": profileID}
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete profile: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}