diff --git a/cmd/server/pactasrv/BUILD.bazel b/cmd/server/pactasrv/BUILD.bazel index e287aa1..bcbb45f 100644 --- a/cmd/server/pactasrv/BUILD.bazel +++ b/cmd/server/pactasrv/BUILD.bazel @@ -3,6 +3,7 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") go_library( name = "pactasrv", srcs = [ + "admin.go", "analysis.go", "audit_logs.go", "blobs.go", diff --git a/cmd/server/pactasrv/admin.go b/cmd/server/pactasrv/admin.go new file mode 100644 index 0000000..dc65dda --- /dev/null +++ b/cmd/server/pactasrv/admin.go @@ -0,0 +1,159 @@ +package pactasrv + +import ( + "context" + "fmt" + + "github.com/RMI/pacta/db" + "github.com/RMI/pacta/oapierr" + api "github.com/RMI/pacta/openapi/pacta" + "github.com/RMI/pacta/pacta" + "go.uber.org/zap" +) + +// Merges two users together +// (POST /admin/merge-users) +func (s *Server) MergeUsers(ctx context.Context, request api.MergeUsersRequestObject) (api.MergeUsersResponseObject, error) { + req := request.Body + actorUserInfo, err := s.getActorInfoOrFail(ctx) + if err != nil { + return nil, err + } + if !actorUserInfo.IsAdmin && !actorUserInfo.IsSuperAdmin { + return nil, oapierr.Forbidden("only admins can merge users", + zap.String("actor_user_id", string(actorUserInfo.UserID)), + zap.String("from_user_id", req.FromUserId), + zap.String("to_user_id", req.ToUserId)) + } + + sourceUID := pacta.UserID(req.FromUserId) + destUID := pacta.UserID(req.ToUserId) + + var ( + numIncompleteUploads, numAnalyses, numPortfolios, numPortfolioGroups, numAuditLogs, numAuditLogsCreated int + buris []pacta.BlobURI + ) + + err = s.DB.Transactional(ctx, func(tx db.Tx) error { + sourceOwner, err := s.DB.GetOwnerForUser(tx, sourceUID) + if err != nil { + return fmt.Errorf("failed to get owner for source user: %w", err) + } + destOwner, err := s.DB.GetOwnerForUser(tx, destUID) + if err != nil { + return fmt.Errorf("failed to get owner for destination user: %w", err) + } + + // Note we do an audit log transfer FIRST so that we don't transfer the audit logs generated from the transfer itself. + nal, err := s.DB.TransferAuditLogOwnership(tx, sourceUID, destUID, sourceOwner, destOwner) + if err != nil { + return fmt.Errorf("failed to transfer audit log ownership: %w", err) + } + numAuditLogs = nal + + auditLogsToCreate := []pacta.AuditLog{} + addAuditLog := func(t pacta.AuditLogTargetType, id string) { + auditLogsToCreate = append(auditLogsToCreate, pacta.AuditLog{ + Action: pacta.AuditLogAction_TransferOwnership, + ActorType: pacta.AuditLogActorType_Admin, + ActorID: string(actorUserInfo.UserID), + ActorOwner: &pacta.Owner{ID: actorUserInfo.OwnerID}, + PrimaryTargetType: t, + PrimaryTargetID: id, + PrimaryTargetOwner: &pacta.Owner{ID: destOwner}, + SecondaryTargetType: pacta.AuditLogTargetType_User, + SecondaryTargetID: string(sourceUID), + SecondaryTargetOwner: &pacta.Owner{ID: sourceOwner}, + }) + } + + incompleteUploads, err := s.DB.IncompleteUploadsByOwner(tx, sourceOwner) + if err != nil { + return fmt.Errorf("failed to get incomplete uploads for source owner: %w", err) + } + for i, upload := range incompleteUploads { + err := s.DB.UpdateIncompleteUpload(tx, upload.ID, db.SetIncompleteUploadOwner(destOwner)) + if err != nil { + return fmt.Errorf("failed to update upload owner %d/%d: %w", i, len(incompleteUploads), err) + } + addAuditLog(pacta.AuditLogTargetType_IncompleteUpload, string(upload.ID)) + } + numIncompleteUploads = len(incompleteUploads) + + analyses, err := s.DB.AnalysesByOwner(tx, sourceOwner) + if err != nil { + return fmt.Errorf("failed to get analyses for source owner: %w", err) + } + for i, analysis := range analyses { + err := s.DB.UpdateAnalysis(tx, analysis.ID, db.SetAnalysisOwner(destOwner)) + if err != nil { + return fmt.Errorf("failed to update analysis owner %d/%d: %w", i, len(analyses), err) + } + addAuditLog(pacta.AuditLogTargetType_Analysis, string(analysis.ID)) + } + numAnalyses = len(analyses) + + portfolios, err := s.DB.PortfoliosByOwner(tx, sourceOwner) + if err != nil { + return fmt.Errorf("failed to get portfolios for source owner: %w", err) + } + for i, portfolio := range portfolios { + err := s.DB.UpdatePortfolio(tx, portfolio.ID, db.SetPortfolioOwner(destOwner)) + if err != nil { + return fmt.Errorf("failed to update portfolio owner %d/%d: %w", i, len(portfolios), err) + } + addAuditLog(pacta.AuditLogTargetType_Portfolio, string(portfolio.ID)) + } + numPortfolios = len(portfolios) + + portfolioGroups, err := s.DB.PortfolioGroupsByOwner(tx, sourceOwner) + if err != nil { + return fmt.Errorf("failed to get portfolio groups for source owner: %w", err) + } + for i, portfolioGroup := range portfolioGroups { + err := s.DB.UpdatePortfolioGroup(tx, portfolioGroup.ID, db.SetPortfolioGroupOwner(destOwner)) + if err != nil { + return fmt.Errorf("failed to update portfolio group owner %d/%d: %w", i, len(portfolioGroups), err) + } + addAuditLog(pacta.AuditLogTargetType_PortfolioGroup, string(portfolioGroup.ID)) + } + numPortfolioGroups = len(portfolioGroups) + + for _, auditLog := range auditLogsToCreate { + _, err := s.DB.CreateAuditLog(tx, &auditLog) + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } + } + numAuditLogsCreated = len(auditLogsToCreate) + + // Now that we've transferred all the audit logs, we can delete the user. + newBuris, err := s.DB.DeleteUser(tx, sourceUID) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + buris = append(buris, newBuris...) + + return nil + }) + if err != nil { + return nil, oapierr.Internal("failed to merge users", zap.Error(err), zap.String("actor_user_id", string(actorUserInfo.UserID)), zap.String("from_user_id", req.FromUserId), zap.String("to_user_id", req.ToUserId)) + } + + if err := s.deleteBlobs(ctx, buris...); err != nil { + return nil, err + } + + s.Logger.Info("user merge completed successfully", + zap.String("actor_user_id", string(actorUserInfo.UserID)), + zap.String("from_user_id", req.FromUserId), + zap.String("to_user_id", req.ToUserId), + zap.Int("num_audit_logs_transferred", numAuditLogs), + zap.Int("num_incomplete_uploads", numIncompleteUploads), + zap.Int("num_analyses", numAnalyses), + zap.Int("num_portfolios", numPortfolios), + zap.Int("num_portfolio_groups", numPortfolioGroups), + zap.Int("num_audit_logs_created", numAuditLogsCreated), + ) + return api.MergeUsers204Response{}, nil +} diff --git a/cmd/server/pactasrv/conv/oapi_to_pacta.go b/cmd/server/pactasrv/conv/oapi_to_pacta.go index 6c6885c..eb207cb 100644 --- a/cmd/server/pactasrv/conv/oapi_to_pacta.go +++ b/cmd/server/pactasrv/conv/oapi_to_pacta.go @@ -144,6 +144,8 @@ func auditLogActionFromOAPI(i api.AuditLogAction) (pacta.AuditLogAction, error) return pacta.AuditLogAction_EnableSharing, nil case api.AuditLogActionDisableSharing: return pacta.AuditLogAction_DisableSharing, nil + case api.AuditLogActionTransferOwnership: + return pacta.AuditLogAction_TransferOwnership, nil } return "", oapierr.BadRequest("unknown audit log action", zap.String("audit_log_action", string(i))) } diff --git a/cmd/server/pactasrv/conv/pacta_to_oapi.go b/cmd/server/pactasrv/conv/pacta_to_oapi.go index c547959..2e41a7b 100644 --- a/cmd/server/pactasrv/conv/pacta_to_oapi.go +++ b/cmd/server/pactasrv/conv/pacta_to_oapi.go @@ -452,6 +452,8 @@ func auditLogActionToOAPI(i pacta.AuditLogAction) (api.AuditLogAction, error) { return api.AuditLogActionEnableSharing, nil case pacta.AuditLogAction_DisableSharing: return api.AuditLogActionDisableSharing, nil + case pacta.AuditLogAction_TransferOwnership: + return api.AuditLogActionTransferOwnership, nil } return "", oapierr.Internal(fmt.Sprintf("auditLogActionToOAPI: unknown action: %q", i)) } diff --git a/cmd/server/pactasrv/pactasrv.go b/cmd/server/pactasrv/pactasrv.go index ad150eb..644ff1b 100644 --- a/cmd/server/pactasrv/pactasrv.go +++ b/cmd/server/pactasrv/pactasrv.go @@ -114,10 +114,11 @@ type DB interface { User(tx db.Tx, id pacta.UserID) (*pacta.User, error) Users(tx db.Tx, ids []pacta.UserID) (map[pacta.UserID]*pacta.User, error) UpdateUser(tx db.Tx, id pacta.UserID, mutations ...db.UpdateUserFn) error - DeleteUser(tx db.Tx, id pacta.UserID) error + DeleteUser(tx db.Tx, id pacta.UserID) ([]pacta.BlobURI, error) CreateAuditLog(tx db.Tx, a *pacta.AuditLog) (pacta.AuditLogID, error) AuditLogs(tx db.Tx, q *db.AuditLogQuery) ([]*pacta.AuditLog, *db.PageInfo, error) + TransferAuditLogOwnership(tx db.Tx, sourceUserID, destUserID pacta.UserID, sourceOwnerID, destOwnerID pacta.OwnerID) (int, error) } type Blob interface { diff --git a/cmd/server/pactasrv/user.go b/cmd/server/pactasrv/user.go index 04ab164..0ee13a3 100644 --- a/cmd/server/pactasrv/user.go +++ b/cmd/server/pactasrv/user.go @@ -60,10 +60,13 @@ func (s *Server) UpdateUser(ctx context.Context, request api.UpdateUserRequestOb // (DELETE /user/{id}) func (s *Server) DeleteUser(ctx context.Context, request api.DeleteUserRequestObject) (api.DeleteUserResponseObject, error) { // TODO(#12) Implement Authorization - err := s.DB.DeleteUser(s.DB.NoTxn(ctx), pacta.UserID(request.Id)) + blobURIs, err := s.DB.DeleteUser(s.DB.NoTxn(ctx), pacta.UserID(request.Id)) if err != nil { return nil, oapierr.Internal("failed to delete user", zap.Error(err)) } + if err := s.deleteBlobs(ctx, blobURIs...); err != nil { + return nil, err + } return api.DeleteUser204Response{}, nil } diff --git a/db/sqldb/audit_log.go b/db/sqldb/audit_log.go index be5966c..1342c9b 100644 --- a/db/sqldb/audit_log.go +++ b/db/sqldb/audit_log.go @@ -88,6 +88,70 @@ func (d *DB) CreateAuditLog(tx db.Tx, a *pacta.AuditLog) (pacta.AuditLogID, erro return id, nil } +func (d *DB) TransferAuditLogOwnership(tx db.Tx, sourceUserID, destUserID pacta.UserID, sourceOwnerID, destOwnerID pacta.OwnerID) (int, error) { + auditLogsUpdated := -1 + err := d.RunOrContinueTransaction(tx, func(tx db.Tx) error { + countLogsReferencingSource := func() (int, error) { + row := d.queryRow(tx, ` + SELECT + COUNT(*) + FROM audit_log + WHERE + actor_id = $1 OR + primary_target_id = $1 OR + secondary_target_id = $1 OR + actor_owner_id = $2 OR + primary_target_owner_id = $2 OR + secondary_target_owner_id = $2;`, sourceUserID, sourceOwnerID) + n := -1 + if err := row.Scan(&n); err != nil { + return -1, fmt.Errorf("scanning audit_log count: %w", err) + } + return n, nil + } + alu, err := countLogsReferencingSource() + if err != nil { + return fmt.Errorf("counting audit_logs referencing source user: %w", err) + } + auditLogsUpdated = alu + + userIDStatements := []string{ + `UPDATE audit_log SET actor_id = $2 WHERE actor_id = $1;`, + `UPDATE audit_log SET primary_target_id = $2 WHERE primary_target_id = $1;`, + `UPDATE audit_log SET secondary_target_id = $2 WHERE secondary_target_id = $1;`, + } + for i, userIDStatement := range userIDStatements { + if err := d.exec(tx, userIDStatement, sourceUserID, destUserID); err != nil { + return fmt.Errorf("user id statement %d/%d: %w", i, len(userIDStatements), err) + } + } + ownerIDStatements := []string{ + `UPDATE audit_log SET actor_owner_id = $2 WHERE actor_owner_id = $1;`, + `UPDATE audit_log SET primary_target_owner_id = $2 WHERE primary_target_owner_id = $1;`, + `UPDATE audit_log SET secondary_target_owner_id = $2 WHERE secondary_target_owner_id = $1;`, + } + for i, ownerIDStatement := range ownerIDStatements { + if err := d.exec(tx, ownerIDStatement, sourceOwnerID, destOwnerID); err != nil { + return fmt.Errorf("owner id statement %d/%d: %w", i, len(ownerIDStatements), err) + } + } + // We do this defensive coding WITHIN the transaction, because we don't want to leave dangling audit-logs. + // If something is being returned here we should abort the proposed merge/transfer of accounts, and fix the logic first. + stillReferenced, err := countLogsReferencingSource() + if err != nil { + return fmt.Errorf("counting audit_logs referencing source user: %w", err) + } + if stillReferenced != 0 { + return fmt.Errorf("%d audit_logs still reference source user after transfer", stillReferenced) + } + return nil + }) + if err != nil { + return -1, fmt.Errorf("transferring audit_log ownership: %w", err) + } + return auditLogsUpdated, nil +} + func rowsToAuditLogs(rows pgx.Rows) ([]*pacta.AuditLog, error) { return mapRows("auditLog", rows, rowToAuditLog) } diff --git a/db/sqldb/audit_log_test.go b/db/sqldb/audit_log_test.go index 0349d52..56979fc 100644 --- a/db/sqldb/audit_log_test.go +++ b/db/sqldb/audit_log_test.go @@ -135,7 +135,7 @@ func TestAuditSearch(t *testing.T) { actorOwner2 := &pacta.Owner{ID: "owner2"} targetType1 := pacta.AuditLogTargetType_Portfolio targetType2 := pacta.AuditLogTargetType_IncompleteUpload - targetID1 := "portfolio-1" + targetID1 := actorID1 targetID2 := "incomplete-upload-2" targetOwner1 := &pacta.Owner{ID: "owner3"} targetOwner2 := &pacta.Owner{ID: "owner4"} @@ -222,7 +222,7 @@ func TestAuditSearch(t *testing.T) { for i, a := range auditLogs { actual[i] = a.ID } - if diff := cmp.Diff(c.expected, actual, sortAuditLogIDs()); diff != "" { + if diff := cmp.Diff(c.expected, actual, auditLogIDCmpOpts()); diff != "" { t.Errorf("unexpected diff:\n%s", diff) } }) @@ -279,12 +279,94 @@ func TestAuditSearch(t *testing.T) { for i, a := range auditLogs { actual[i] = a.ID } - if diff := cmp.Diff(c.expected, actual, sortAuditLogIDs()); diff != "" { + if diff := cmp.Diff(c.expected, actual, auditLogIDCmpOpts()); diff != "" { t.Errorf("unexpected diff:\n%s", diff) } }) } }) + + t.Run("Audit Log Transfers", func(t *testing.T) { + assertWithWhere := func(where *db.AuditLogQueryWhere, expected ...pacta.AuditLogID) { + t.Helper() + auditLogs, _, err := tdb.AuditLogs(tx, &db.AuditLogQuery{ + Limit: 10, + Wheres: []*db.AuditLogQueryWhere{where}, + }) + if err != nil { + t.Fatalf("getting audit logs: %v", err) + } + actual := make([]pacta.AuditLogID, len(auditLogs)) + for i, a := range auditLogs { + actual[i] = a.ID + } + if diff := cmp.Diff(expected, actual, auditLogIDCmpOpts()); diff != "" { + t.Errorf("unexpected diff:\n%s", diff) + } + } + assertActorOwner := func(actorOwner pacta.OwnerID, expected ...pacta.AuditLogID) { + t.Helper() + assertWithWhere(&db.AuditLogQueryWhere{InActorOwnerID: []pacta.OwnerID{actorOwner}}, expected...) + } + assertTargetOwner := func(targetOwner pacta.OwnerID, expected ...pacta.AuditLogID) { + t.Helper() + assertWithWhere(&db.AuditLogQueryWhere{InTargetOwnerID: []pacta.OwnerID{targetOwner}}, expected...) + } + assertActorUser := func(user string, expected ...pacta.AuditLogID) { + t.Helper() + assertWithWhere(&db.AuditLogQueryWhere{InActorID: []string{user}}, expected...) + } + assertTarget := func(targetID string, expected ...pacta.AuditLogID) { + t.Helper() + assertWithWhere(&db.AuditLogQueryWhere{InTargetID: []string{targetID}}, expected...) + } + + // Check initial state + assertActorOwner(actorOwner1.ID, alID1) + assertActorOwner(actorOwner2.ID, alID2, alID3) + assertTargetOwner(targetOwner1.ID, alID1, alID3) + assertTargetOwner(targetOwner2.ID, alID2, alID3) + assertActorUser(actorID1, alID1) + assertActorUser(actorID2, alID2, alID3) + assertTarget(actorID1, alID1) + assertTarget(actorID2) + + // Transferring audit logs from Actor1 => Actor2, and ActorOwner1 => ActorOwner2 + numTransferred, err := tdb.TransferAuditLogOwnership(tx, pacta.UserID(actorID1), pacta.UserID(actorID2), actorOwner1.ID, actorOwner2.ID) + if err != nil { + t.Fatalf("transferring audit log ownership: %v", err) + } + if numTransferred != 1 { + t.Fatalf("expected 1 audit logs to be transferred, got %d", numTransferred) + } + assertActorOwner(actorOwner1.ID) + assertActorOwner(actorOwner2.ID, alID1, alID2, alID3) + assertTargetOwner(targetOwner1.ID, alID1, alID3) + assertTargetOwner(targetOwner2.ID, alID2, alID3) + assertActorUser(actorID1) + assertActorUser(actorID2, alID1, alID2, alID3) + assertTarget(actorID1) + assertTarget(actorID2, alID1) + + // Transferring when empty should be fine. + numTransferred, err = tdb.TransferAuditLogOwnership(tx, pacta.UserID(actorID1), pacta.UserID(actorID2), actorOwner1.ID, actorOwner2.ID) + if err != nil { + t.Fatalf("transferring audit log ownership: %v", err) + } + if numTransferred != 0 { + t.Fatalf("expected 0 audit logs to be transferred, got %d", numTransferred) + } + + numTransferred, err = tdb.TransferAuditLogOwnership(tx, pacta.UserID("a random user id"), pacta.UserID(actorID2), targetOwner1.ID, targetOwner2.ID) + if err != nil { + t.Fatalf("transferring audit log ownership: %v", err) + } + if numTransferred != 2 { + t.Fatalf("expected 1 audit logs to be transferred, got %d", numTransferred) + } + assertTargetOwner(targetOwner1.ID) + assertTargetOwner(targetOwner2.ID, alID1, alID2, alID3) + }) } func auditLogCmpOpts() cmp.Option { @@ -294,8 +376,11 @@ func auditLogCmpOpts() cmp.Option { } } -func sortAuditLogIDs() cmp.Option { - return cmpopts.SortSlices(func(a, b pacta.AuditLogID) bool { - return string(a) < string(b) - }) +func auditLogIDCmpOpts() cmp.Option { + return cmp.Options{ + cmpopts.SortSlices(func(a, b pacta.AuditLogID) bool { + return string(a) < string(b) + }), + cmpopts.EquateEmpty(), + } } diff --git a/db/sqldb/golden/human_readable_schema.sql b/db/sqldb/golden/human_readable_schema.sql index 7c53299..39f74a1 100644 --- a/db/sqldb/golden/human_readable_schema.sql +++ b/db/sqldb/golden/human_readable_schema.sql @@ -18,7 +18,8 @@ CREATE TYPE audit_log_action AS ENUM ( 'DISABLE_ADMIN_DEBUG', 'DOWNLOAD', 'ENABLE_SHARING', - 'DISABLE_SHARING'); + 'DISABLE_SHARING', + 'TRANSFER_OWNERSHIP'); CREATE TYPE audit_log_actor_type AS ENUM ( 'USER', 'ADMIN', diff --git a/db/sqldb/golden/schema_dump.sql b/db/sqldb/golden/schema_dump.sql index 80a8354..081c899 100644 --- a/db/sqldb/golden/schema_dump.sql +++ b/db/sqldb/golden/schema_dump.sql @@ -42,7 +42,8 @@ CREATE TYPE public.audit_log_action AS ENUM ( 'DISABLE_ADMIN_DEBUG', 'DOWNLOAD', 'ENABLE_SHARING', - 'DISABLE_SHARING' + 'DISABLE_SHARING', + 'TRANSFER_OWNERSHIP' ); diff --git a/db/sqldb/migrations/0009_add_transfer_audit_log_action.down.sql b/db/sqldb/migrations/0009_add_transfer_audit_log_action.down.sql new file mode 100644 index 0000000..61be9cf --- /dev/null +++ b/db/sqldb/migrations/0009_add_transfer_audit_log_action.down.sql @@ -0,0 +1,25 @@ +BEGIN; + +-- There isn't a way to delete a value from an enum, so this is the workaround +-- https://stackoverflow.com/a/56777227/17909149 + +ALTER TABLE audit_log ALTER action TYPE TEXT; + +DROP TYPE audit_log_action; +CREATE TYPE audit_log_action AS ENUM ( + 'CREATE', + 'UPDATE', + 'DELETE', + 'ADD_TO', + 'REMOVE_FROM', + 'ENABLE_ADMIN_DEBUG', + 'DISABLE_ADMIN_DEBUG', + 'DOWNLOAD', + 'ENABLE_SHARING', + 'DISABLE_SHARING'); + +ALTER TABLE audit_log + ALTER action TYPE audit_log_action + USING audit_log_action::audit_log_action; + +COMMIT; diff --git a/db/sqldb/migrations/0009_add_transfer_audit_log_action.up.sql b/db/sqldb/migrations/0009_add_transfer_audit_log_action.up.sql new file mode 100644 index 0000000..68d6d47 --- /dev/null +++ b/db/sqldb/migrations/0009_add_transfer_audit_log_action.up.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TYPE audit_log_action ADD VALUE 'TRANSFER_OWNERSHIP'; + +COMMIT; \ No newline at end of file diff --git a/db/sqldb/owner.go b/db/sqldb/owner.go index a781c57..4b044d9 100644 --- a/db/sqldb/owner.go +++ b/db/sqldb/owner.go @@ -112,6 +112,64 @@ func (d *DB) GetOrCreateOwnerForInitiative(tx db.Tx, iID pacta.InitiativeID) (pa return ownerID, nil } +func (d *DB) DeleteOwner(tx db.Tx, oID pacta.OwnerID) ([]pacta.BlobURI, error) { + var buris []pacta.BlobURI + err := d.RunOrContinueTransaction(tx, func(tx db.Tx) error { + portfolios, err := d.PortfoliosByOwner(tx, oID) + if err != nil { + return fmt.Errorf("getting portfolios for owner: %w", err) + } + for _, portfolio := range portfolios { + newBuris, err := d.DeletePortfolio(tx, portfolio.ID) + if err != nil { + return fmt.Errorf("deleting portfolio: %w", err) + } + buris = append(buris, newBuris...) + } + analyses, err := d.AnalysesByOwner(tx, oID) + if err != nil { + return fmt.Errorf("getting analyses for owner: %w", err) + } + for _, analysis := range analyses { + newBuris, err := d.DeleteAnalysis(tx, analysis.ID) + if err != nil { + return fmt.Errorf("deleting analysis: %w", err) + } + buris = append(buris, newBuris...) + } + pgroups, err := d.PortfolioGroupsByOwner(tx, oID) + if err != nil { + return fmt.Errorf("getting portfolio groups for owner: %w", err) + } + for _, pgroup := range pgroups { + err := d.DeletePortfolioGroup(tx, pgroup.ID) + if err != nil { + return fmt.Errorf("deleting portfolio group: %w", err) + } + } + incompleteUploads, err := d.IncompleteUploadsByOwner(tx, oID) + if err != nil { + return fmt.Errorf("getting incomplete uploads for owner: %w", err) + } + for _, iu := range incompleteUploads { + newBuri, err := d.DeleteIncompleteUpload(tx, iu.ID) + if err != nil { + return fmt.Errorf("deleting incomplete upload: %w", err) + } + buris = append(buris, newBuri) + } + err = d.exec(tx, `DELETE FROM owner WHERE id = $1;`, oID) + if err != nil { + return fmt.Errorf("deleting actual owner: %w", err) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("deleting owner: %w", err) + } + return buris, nil +} + func (d *DB) createOwner(tx db.Tx, o *pacta.Owner) (pacta.OwnerID, error) { if err := validateOwnerForCreation(o); err != nil { return "", fmt.Errorf("validating owner for creation: %w", err) @@ -183,5 +241,3 @@ func validateOwnerForCreation(o *pacta.Owner) error { } return nil } - -// TODO(grady) take on owner deletion diff --git a/db/sqldb/sqldb_test.go b/db/sqldb/sqldb_test.go index a62f975..5cdd125 100644 --- a/db/sqldb/sqldb_test.go +++ b/db/sqldb/sqldb_test.go @@ -90,6 +90,7 @@ func TestSchemaHistory(t *testing.T) { {ID: 6, Version: 6}, // 0006_initiative_primary_key {ID: 7, Version: 7}, // 0007_audit_log_actor_type {ID: 8, Version: 8}, // 0008_indexes_on_blob_ids + {ID: 9, Version: 9}, // 0009_add_transfer_audit_log_action } if diff := cmp.Diff(want, got); diff != "" { diff --git a/db/sqldb/user.go b/db/sqldb/user.go index 91948a2..c4dac04 100644 --- a/db/sqldb/user.go +++ b/db/sqldb/user.go @@ -155,19 +155,36 @@ func (d *DB) UpdateUser(tx db.Tx, id pacta.UserID, mutations ...db.UpdateUserFn) return nil } -func (d *DB) DeleteUser(tx db.Tx, id pacta.UserID) error { +func (d *DB) DeleteUser(tx db.Tx, id pacta.UserID) ([]pacta.BlobURI, error) { + buris := []pacta.BlobURI{} err := d.RunOrContinueTransaction(tx, func(db.Tx) error { - // TODO(grady) add entity deletions here - err := d.exec(tx, `DELETE FROM pacta_user WHERE id = $1;`, id) + userOwnerID, err := d.GetOwnerForUser(tx, id) if err != nil { - return fmt.Errorf("deleting user: %w", err) + return fmt.Errorf("getting owner for user: %w", err) + } + newBuris, err := d.DeleteOwner(tx, userOwnerID) + if err != nil { + return fmt.Errorf("deleting owner: %w", err) + } + buris = append(buris, newBuris...) + err = d.exec(tx, `DELETE FROM initiative_invitation WHERE used_by_user_id = $1;`, id) + if err != nil { + return fmt.Errorf("deleting initiative_invitation rows: %w", err) + } + err = d.exec(tx, `UPDATE porftolio_initiative_membership SET added_by_user_id = NULL WHERE added_by_user_id = $1;`, id) + if err != nil { + return fmt.Errorf("clearing portfolio_initiative_membership.added_by_user_id: %w", err) + } + err = d.exec(tx, `DELETE FROM pacta_user WHERE id = $1;`, id) + if err != nil { + return fmt.Errorf("deleting actual user: %w", err) } return nil }) if err != nil { - return fmt.Errorf("performing initiative deletion: %w", err) + return nil, fmt.Errorf("performing user deletion: %w", err) } - return nil + return buris, nil } func (d *DB) putUser(tx db.Tx, u *pacta.User) error { diff --git a/db/sqldb/user_test.go b/db/sqldb/user_test.go index 3d7d557..abb18dd 100644 --- a/db/sqldb/user_test.go +++ b/db/sqldb/user_test.go @@ -218,7 +218,7 @@ func TestDeleteUser(t *testing.T) { userID, err0 := tdb.createUser(tx, u) noErrDuringSetup(t, err0) - err := tdb.DeleteUser(tx, userID) + _, err := tdb.DeleteUser(tx, userID) if err != nil { t.Fatalf("deleting user: %v", err) } diff --git a/openapi/pacta.yaml b/openapi/pacta.yaml index e278719..08d2d7f 100644 --- a/openapi/pacta.yaml +++ b/openapi/pacta.yaml @@ -136,6 +136,21 @@ paths: responses: '204': description: pacta version created successfully + /admin/merge-users: + post: + summary: Merges two users together + description: Merges two users together + operationId: mergeUsers + requestBody: + description: a request describing the two users to merge + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MergeUsersRequest' + responses: + '204': + description: the user changes were applied successfully /initiative/{id}: get: summary: Returns an initiative by ID @@ -1879,6 +1894,7 @@ components: - AuditLogActionDownload - AuditLogActionEnableSharing - AuditLogActionDisableSharing + - AuditLogActionTransferOwnership AuditLogActorType: type: string enum: @@ -2058,6 +2074,18 @@ components: secondaryTargetOwner: type: string description: the id of the owner of the secondary object this action was performed on + MergeUsersRequest: + type: object + required: + - fromUserId + - toUserId + properties: + fromUserId: + type: string + description: the user id of the user to merge records from, and to be deleted after the merge + toUserId: + type: string + description: the user id of the user to recieve merged records and to exist after the merge Error: type: object required: diff --git a/pacta/pacta.go b/pacta/pacta.go index 3d2836a..71fa86d 100644 --- a/pacta/pacta.go +++ b/pacta/pacta.go @@ -579,6 +579,7 @@ const ( AuditLogAction_Download AuditLogAction = "DOWNLOAD" AuditLogAction_EnableSharing AuditLogAction = "ENABLE_SHARING" AuditLogAction_DisableSharing AuditLogAction = "DISABLE_SHARING" + AuditLogAction_TransferOwnership AuditLogAction = "TRANSFER_OWNERSHIP" ) var AuditLogActionValues = []AuditLogAction{ @@ -592,6 +593,7 @@ var AuditLogActionValues = []AuditLogAction{ AuditLogAction_Download, AuditLogAction_EnableSharing, AuditLogAction_DisableSharing, + AuditLogAction_TransferOwnership, } func ParseAuditLogAction(s string) (AuditLogAction, error) { @@ -616,6 +618,8 @@ func ParseAuditLogAction(s string) (AuditLogAction, error) { return AuditLogAction_EnableSharing, nil case "DISABLE_SHARING": return AuditLogAction_DisableSharing, nil + case "TRANSFER_OWNERSHIP": + return AuditLogAction_TransferOwnership, nil } return "", fmt.Errorf("unknown AuditLogAction: %q", s) }