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>
1415 lines
33 KiB
Go
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
|
|
}
|