From 70dec6cc7dcc5ca76809a10095fca9d4c61af7de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 3 Feb 2026 17:44:04 +0400 Subject: [PATCH] Migrate people to identities / memberships / profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- pkg/coredata/membership_profile.go | 73 ++- pkg/coredata/migrations/20260203T132700Z.sql | 498 +++++++++++++++++++ pkg/iam/account_service.go | 2 +- pkg/server/api/console/v1/v1_resolver.go | 2 +- 4 files changed, 557 insertions(+), 18 deletions(-) create mode 100644 pkg/coredata/migrations/20260203T132700Z.sql diff --git a/pkg/coredata/membership_profile.go b/pkg/coredata/membership_profile.go index 2a0a5c271..4a8629a23 100644 --- a/pkg/coredata/membership_profile.go +++ b/pkg/coredata/membership_profile.go @@ -24,15 +24,21 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/mail" ) type ( MembershipProfile struct { - ID gid.GID `db:"id"` - MembershipID gid.GID `db:"membership_id"` - FullName string `db:"full_name"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID gid.GID `db:"id"` + MembershipID gid.GID `db:"membership_id"` + FullName string `db:"full_name"` + Kind PeopleKind `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"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } ) @@ -61,12 +67,17 @@ SELECT id, membership_id, full_name, + kind, + additionalEmailAddresses, + position, + contract_start_date, + contract_end_date, created_at, updated_at FROM iam_membership_profiles WHERE - %s + %s AND membership_id = @membership_id LIMIT 1; ` @@ -106,12 +117,17 @@ SELECT id, membership_id, full_name, + kind, + additionalEmailAddresses, + position, + contract_start_date, + contract_end_date, created_at, updated_at FROM iam_membership_profiles WHERE - %s + %s AND id = @profile_id LIMIT 1; ` @@ -151,6 +167,11 @@ INSERT INTO id, membership_id, full_name, + kind, + additionalEmailAddresses, + position, + contract_start_date, + contract_end_date, created_at, updated_at ) @@ -159,18 +180,28 @@ VALUES ( @id, @membership_id, @full_name, + @kind, + @additionalEmailAddresses, + @position, + @contract_start_date, + @contract_end_date, @created_at, @updated_at ) ` args := pgx.StrictNamedArgs{ - "tenant_id": p.ID.TenantID().String(), - "id": p.ID, - "membership_id": p.MembershipID, - "full_name": p.FullName, - "created_at": p.CreatedAt, - "updated_at": p.UpdatedAt, + "tenant_id": p.ID.TenantID().String(), + "id": p.ID, + "membership_id": p.MembershipID, + "full_name": p.FullName, + "kind": p.Kind, + "additionalEmailAddresses": p.AdditionalEmailAddresses, + "position": p.Position, + "contract_start_date": p.ContractStartDate, + "contract_end_date": p.ContractEndDate, + "created_at": p.CreatedAt, + "updated_at": p.UpdatedAt, } _, err := conn.Exec(ctx, q, args) @@ -191,6 +222,11 @@ UPDATE iam_membership_profiles SET full_name = @full_name, + kind = @kind, + additionalEmailAddresses = @additionalEmailAddresses, + position = @position, + contract_start_date = @contract_start_date, + contract_end_date = @contract_end_date, updated_at = @updated_at WHERE id = @id @@ -200,9 +236,14 @@ WHERE q = fmt.Sprintf(q, scope.SQLFragment()) args := pgx.StrictNamedArgs{ - "id": p.ID, - "full_name": p.FullName, - "updated_at": p.UpdatedAt, + "id": p.ID, + "full_name": p.FullName, + "kind": p.Kind, + "additionalEmailAddresses": p.AdditionalEmailAddresses, + "position": p.Position, + "contract_start_date": p.ContractStartDate, + "contract_end_date": p.ContractEndDate, + "updated_at": p.UpdatedAt, } maps.Copy(args, scope.SQLArguments()) diff --git a/pkg/coredata/migrations/20260203T132700Z.sql b/pkg/coredata/migrations/20260203T132700Z.sql new file mode 100644 index 000000000..4007ff5e2 --- /dev/null +++ b/pkg/coredata/migrations/20260203T132700Z.sql @@ -0,0 +1,498 @@ +-- 1. Add peoples column onto profiles +ALTER TABLE + iam_membership_profiles +ADD + COLUMN additional_email_addresses CITEXT [] NOT NULL, +ADD + COLUMN kind PEOPLE_KIND NOT NULL, +ADD + COLUMN contract_start_date DATE, +ADD + COLUMN contract_end_date DATE, +ADD + COLUMN position TEXT; + +-- 2. Add profile references to all table referencing peoples +-- was owner_id, NOT NULL +ALTER TABLE + assets +ADD + COLUMN owner_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT; + +-- was owner_id, NOT NULL +ALTER TABLE + continual_improvements +ADD + COLUMN owner_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT; + +-- was owner_id, NOT NULL +ALTER TABLE + data +ADD + COLUMN owner_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT; + +-- was owner_id, NOT NULL +ALTER TABLE + document_versions +ADD + COLUMN owner_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT; + +-- was attendee_id, NOT NULL +ALTER TABLE + meeting_attendees +ADD + COLUMN attendee_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE CASCADE; + +-- was owner_id, NOT NULL +ALTER TABLE + nonconformities +ADD + COLUMN owner_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT; + +-- was owner_id, NOT NULL +ALTER TABLE + obligations +ADD + COLUMN owner_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT; + +-- was owner_id, can be null +ALTER TABLE + documents +ADD + COLUMN owner_profile_id REFERENCES iam_membership_profiles(id); + +-- was signed_by, NOT NULL +ALTER TABLE + document_version_signatures +ADD + COLUMN signed_by_profile_id REFERENCES iam_membership_profiles(id); + +-- was data_protection_officer_id, can be null +ALTER TABLE + processing_activities +ADD + COLUMN dpo_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT; + +-- was owner_id, can be null +ALTER TABLE + risks +ADD + COLUMN owner_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE +SET + NULL; + +-- was owner_id, NOT NULL +ALTER TABLE + states_of_applicability +ADD + COLUMN owner_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT; + +-- was assigned_to, can be null +ALTER TABLE + tasks +ADD + COLUMN assigned_to_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE +SET + NULL; + +-- was business_owner_id, can be null +ALTER TABLE + vendors +ADD + COLUMN business_owner_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE +SET + NULL; + +-- was security_owner_id, can be null +ALTER TABLE + vendors +ADD + COLUMN security_owner_profile_id REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE +SET + NULL; + +-- 3. Create missing identities +INSERT INTO + identities ( + id, + created_at, + updated_at, + email_address, + email_address_verified, + full_name + ) +SELECT + generate_gid('\x0000000000000000' :: bytea, 11), + NOW(), + NOW(), + p.primary_email_address, + FALSE, + p.full_name +FROM + peoples p ON CONFLICT DO NOTHING; + +-- 4. Create missing memberships & profiles +WITH people_identities AS ( + SELECT + i.id AS identity_id, + p.tenant_id AS tenant_id, + p.organization_id AS organization_id, + p.kind AS kind, + p.full_name AS full_name, + p.additional_email_addresses AS additional_email_addresses, + p.position AS position, + p.contract_start_date AS contract_start_date, + p.contract_end_date AS contract_end_date, + sc.id IS NOT NULL AS scim + FROM + peoples + JOIN identities i ON i.primary_email_address = p.primary_email_address + JOIN organizations o ON o.id = p.organization_id + LEFT JOIN iam_scim_configurations sc ON sc.organization_id = o.id +), +people_memberships AS ( + INSERT INTO + iam_memberships ( + id, + tenant_id, + identity_id, + organization_id, + role, + source, + state, + created_at, + updated_at + ) + SELECT + generate_gid(decode_base64_unpadded(pi.tenant_id), 39), + pi.tenant_id, + pi.identity_id, + pi.organization_id, + 'EMPLOYEE' :: authz_role, + 'MANUAL', + CASE + WHEN pi.scim THEN 'INACTIVE' :: membership_state + ELSE 'ACTIVE' :: membership_state + END, + NOW(), + NOW() + FROM + people_identities pi ON CONFLICT DO NOTHING RETURNING id AS membership_id, + pi.identity_id AS identity_id, + pi.tenant_id AS tenant_id, + pi.organization_id AS organization_id, + pi.kind AS kind, + pi.full_name AS full_name, + pi.additional_email_addresses AS additional_email_addresses, + pi.position AS position, + pi.contract_start_date AS contract_start_date, + pi.contract_end_date AS contract_start_date, +) -- Create missing profiles +-- Note: we always have a profile for an existing membership (they are created together in the same TX), +-- So it is safe to create the missing profiles from the newly created memberships +INSERT INTO + iam_membership_profiles ( + id, + tenant_id, + membership_id, + full_name, + kind, + additional_email_addresses, + position, + contract_start_date, + contract_end_date, + created_at, + updated_at + ) +SELECT + generate_gid(decode_base64_unpadded(pm.tenant_id), 51), + pm.tenant_id, + pm.membership_id, + pm.full_name, + pm.kind, + pm.additional_email_addresses, + pm.position, + pm.contract_start_date, + pm.contract_end_date, + NOW(), + NOW() +FROM + people_memberships pm ON CONFLICT DO NOTHING; + +-- 5. Update all profiles from peoples +WITH people_memberships AS ( + SELECT + m.id AS membership_id, + COALESCE(p.additional_email_addresses, [] :: CITEXT), + COALESCE(p.kind, 'EMPLOYEE' :: PEOPLE_KIND), + p.contract_start_date, + p.contract_end_date, + p.position, + FROM + iam_memberships m + JOIN identities i ON i.id = m.identity_id + LEFT JOIN peoples p ON p.primary_email_address = i.email_address + AND p.organization_id = m.organization_id +) +UPDATE + iam_membership_profiles +SET + additional_email_addresses = pm.additional_email_addresses, + kind = pm.kind, + contract_start_date = pm.contract_start_date, + contract_end_date = pm.contract_end_date, + position = pm.position +FROM + people_memberships pm +WHERE + membership_id = pm.membership_id; + +-- 6. Fill new references to profiles (e.g. previous references to peoples) +WITH people_profiles AS ( + SELECT + mp.id AS membership_profile_id, + p.id AS people_id + FROM + iam_membership_profiles mp + JOIN iam_memberships m ON m.id = mp.membership_id + JOIN identities i ON i.id = m.identity_id + LEFT JOIN peoples p ON p.primary_email_address = i.email_address +), +updated_assets AS ( + UPDATE + assets a + SET + owner_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + a.owner_id = pp.people_id +), +updated_ci AS ( + UPDATE + continual_improvements ci + SET + owner_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + ci.owner_id = pp.people_id +), +updated_data AS ( + UPDATE + data d + SET + owner_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + d.owner_id = pp.people_id +), +updated_dv AS ( + UPDATE + document_versions dv + SET + owner_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + dv.owner_id = pp.people_id +), +updated_ma AS ( + UPDATE + meeting_attendees ma + SET + attendee_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + ma.attendee_id = pp.people_id +), +updated_nc AS ( + UPDATE + nonconformities nc + SET + owner_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + nc.owner_id = pp.people_id +), +updated_obligations AS ( + UPDATE + obligations o + SET + owner_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + o.owner_id = pp.people_id +), +updated_documents AS ( + UPDATE + documents d + SET + owner_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + d.owner_id = pp.people_id +), +updated_dvs AS ( + UPDATE + document_version_signatures dvs + SET + signed_by_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + dvs.signed_by = pp.people_id +), +updated_pa AS ( + UPDATE + processing_activities pa + SET + dpo_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + pa.data_protection_officer_id = pp.people_id +), +updated_risks AS ( + UPDATE + risks r + SET + owner_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + r.owner_id = pp.people_id +), +updated_soa AS ( + UPDATE + states_of_applicability soa + SET + owner_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + soa.owner_id = pp.people_id +), +updated_tasks AS ( + UPDATE + tasks t + SET + assigned_to_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + t.assigned_to = pp.people_id +), +updated_vendors AS ( + UPDATE + vendors v + SET + business_owner_profile_id = pp.membership_profile_id + FROM + people_profiles pp + WHERE + v.business_owner_id = pp.people_id +) +UPDATE + vendors v +SET + security_owner_profile_id = pp.membership_profile_id +FROM + people_profiles pp +WHERE + v.security_owner_id = pp.people_id; + +-- 7. Now that references are filled, add the NOT NULL constraints to those who need it +ALTER TABLE + assets +ALTER COLUMN + owner_profile_id +SET + NOT NULL; + +ALTER TABLE + continual_improvements +ALTER COLUMN + owner_profile_id +SET + NOT NULL; + +ALTER TABLE + data +ALTER COLUMN + owner_profile_id +SET + NOT NULL; + +ALTER TABLE + document_versions +ALTER COLUMN + owner_profile_id +SET + NOT NULL; + +ALTER TABLE + meeting_attendees +ALTER COLUMN + attendee_profile_id +SET + NOT NULL; + +ALTER TABLE + nonconformities +ALTER COLUMN + owner_profile_id +SET + NOT NULL; + +ALTER TABLE + obligations +ALTER COLUMN + owner_profile_id +SET + NOT NULL; + +ALTER TABLE + document_version_signatures +ALTER COLUMN + signed_by_profile_id +SET + NOT NULL; + +ALTER TABLE + states_of_applicability +ALTER COLUMN + owner_profile_id +SET + NOT NULL; + +-- 8. Expire any potential invitation that is now obsolete since we created memberships +ALTER TABLE + iam_invitations +ALTER COLUMN + email TYPE CITEXT []; + +WITH people_identities AS ( + SELECT + m.organization_id AS organization_id, + i.email_address AS email_address + FROM + iam_memberships m + JOIN identities i ON i.id = m.identity_id + JOIN peoples p ON p.primary_email_address = i.email_address + AND p.organization_id = m.organization_id +) +UPDATE + iam_invitations i +SET + expires_at = NOW() +FROM + people_identities pi +WHERE + pi.organization_id = i.organization_id + AND i.email = pi.email_address; diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index 1e47e3de1..0ec90dbb8 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -287,7 +287,7 @@ func (s *AccountService) AcceptInvitation( return fmt.Errorf("cannot update invitation: %w", err) } - // Accept other pending invitations for email in organization + // Expire other pending invitations for email in organization invitations := &coredata.Invitations{} onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending}) if err := invitations.ExpireByEmailAndOrganization( diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 6fe8c4b70..f4fd7fe82 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -1852,7 +1852,7 @@ func (r *mutationResolver) CreateTrustCenterAccess(ctx context.Context, input ty prb := r.ProboService(ctx, input.TrustCenterID.TenantID()) - // TODO: should not create an access nor identity, but send an invitation + // TODO: should not create an access nor identity, but send a compliance page invitation identity, err := r.iam.AuthService.LoadOrCreateIdentity( ctx, &iam.LoadOrCreateIdentityRequest{