Files
probo/pkg/coredata/membership.go
Sacha Al Himdani f604c48686 Archive SCIM users with in-use profiles instead of 500ing
When a SCIM hard delete targets a profile that is still referenced
(e.g. completed document version signatures, FK RESTRICT), profile.Delete
fails with 23503 and poisons the surrounding transaction. The existing
deactivate fallback then ran on the aborted transaction and failed with
25P02, surfacing to the connector as an opaque 500 and eventually
disabling the bridge.

Wrap profile.Delete in a savepoint so the FK violation only rolls back
the delete attempt, leaving the outer transaction healthy for the
deactivate/archive fallback. Also map FK violations in Membership.Delete
to ErrResourceInUse for consistency with MembershipProfile.Delete.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-15 15:44:29 +02:00

434 lines
9.3 KiB
Go

// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// 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/iam/policy"
"go.probo.inc/probo/pkg/page"
)
type (
Membership struct {
ID gid.GID `db:"id"`
IdentityID gid.GID `db:"identity_id"`
OrganizationID gid.GID `db:"organization_id"`
Role MembershipRole `db:"role"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Memberships []*Membership
)
func (m Membership) CursorKey(orderBy MembershipOrderField) page.CursorKey {
switch orderBy {
case MembershipOrderFieldRole:
return page.NewCursorKey(m.ID, m.Role)
case MembershipOrderFieldCreatedAt:
return page.NewCursorKey(m.ID, m.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (m *Membership) LoadByIdentityIDAndOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
identityID gid.GID,
organizationID gid.GID,
) error {
q := `
SELECT
id,
identity_id,
organization_id,
role,
created_at,
updated_at
FROM
iam_memberships
WHERE
identity_id = @identity_id
AND organization_id = @organization_id
AND %s
`
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 membership: %w", err)
}
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
*m = membership
return nil
}
func (m *Membership) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error {
query := `
INSERT INTO
iam_memberships (
tenant_id,
id,
identity_id,
organization_id,
role,
created_at,
updated_at
)
VALUES (
@tenant_id,
@id,
@identity_id,
@organization_id,
@role,
@created_at,
@updated_at
);
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"id": m.ID,
"identity_id": m.IdentityID,
"organization_id": m.OrganizationID,
"role": m.Role,
"created_at": m.CreatedAt,
"updated_at": m.UpdatedAt,
}
result, err := conn.Exec(ctx, query, args)
if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == "23505" && pgErr.ConstraintName == "authz_memberships_user_id_organization_id_key" {
return ErrResourceAlreadyExists
}
return fmt.Errorf("cannot create membership: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("cannot create membership: organization %s not found", m.OrganizationID)
}
return nil
}
func (m *Membership) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
membershipID gid.GID,
) error {
query := `
SELECT
id,
identity_id,
organization_id,
role,
created_at,
updated_at
FROM
iam_memberships
WHERE
id = @membership_id
AND %s
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"membership_id": membershipID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query membership: %w", err)
}
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
*m = membership
return nil
}
func (m *Membership) AuthorizationAttributes(
ctx context.Context,
conn pg.Querier,
resourceIDs []gid.GID,
) (policy.AttributesByID, error) {
q := `
SELECT
id,
identity_id,
organization_id,
role
FROM
iam_memberships
WHERE
id = ANY(@resource_ids::text[])
`
args := pgx.StrictNamedArgs{
"resource_ids": resourceIDs,
}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query membership authorization attributes: %w", err)
}
defer rows.Close()
attrsByID := make(policy.AttributesByID, len(resourceIDs))
for rows.Next() {
var (
id gid.GID
identityID gid.GID
organizationID gid.GID
role MembershipRole
)
err = rows.Scan(
&id,
&identityID,
&organizationID,
&role,
)
if err != nil {
return nil, fmt.Errorf("cannot scan membership authorization attributes: %w", err)
}
attrsByID[id] = policy.Attributes{
"identity_id": identityID.String(),
"organization_id": organizationID.String(),
"role": role.String(),
}
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("cannot iterate membership authorization attributes: %w", err)
}
return attrsByID, nil
}
func (m *Membership) LoadByIdentityAndOrg(
ctx context.Context,
conn pg.Querier,
scope Scoper,
identityID gid.GID,
organizationID gid.GID,
) error {
q := `
SELECT
id,
identity_id,
organization_id,
role,
created_at,
updated_at
FROM
iam_memberships
WHERE
identity_id = @identity_id
AND organization_id = @organization_id
AND %s
)
`
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 membership: %w", err)
}
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
*m = membership
return nil
}
func (m *Membership) Update(ctx context.Context, conn pg.Tx, scope Scoper) error {
query := `
UPDATE
iam_memberships
SET
identity_id = @identity_id,
role = @role,
updated_at = @updated_at
WHERE
id = @id
AND %s
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": m.ID,
"identity_id": m.IdentityID,
"role": m.Role,
"updated_at": m.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot update membership: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
func (m *Membership) Delete(ctx context.Context, conn pg.Tx, scope Scoper, membershipID gid.GID) error {
query := `
DELETE FROM
iam_memberships
WHERE
%s
AND id = @membership_id
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"membership_id": membershipID,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23503" || pgErr.Code == "23001" {
return ErrResourceInUse
}
}
return fmt.Errorf("cannot delete membership: %w", err)
}
return nil
}
func (m *Membership) LoadActiveByIdentityIDAndOrganizationID(
ctx context.Context,
conn pg.Querier,
identityID gid.GID,
organizationID gid.GID,
) error {
q := `
SELECT
m.id,
m.identity_id,
m.organization_id,
m.role,
m.created_at,
m.updated_at
FROM
iam_memberships m
INNER JOIN iam_membership_profiles p
ON p.identity_id = m.identity_id AND p.organization_id = m.organization_id
WHERE
p.state = @state
AND m.identity_id = @identity_id
AND m.organization_id = @organization_id
LIMIT 1
`
args := pgx.StrictNamedArgs{
"state": ProfileStateActive,
"identity_id": identityID,
"organization_id": organizationID,
}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query memberships: %w", err)
}
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToAddrOfStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
*m = *membership
return nil
}