Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,348 @@
// monorepo/cloud/backend/internal/service/user/complete_deletion.go
package user
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
uc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/collection"
uc_filemetadata "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/filemetadata"
uc_storagedailyusage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storagedailyusage"
uc_storageusageevent "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storageusageevent"
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
)
// CompleteUserDeletionRequest represents a GDPR right-to-be-forgotten deletion request
type CompleteUserDeletionRequest struct {
UserID gocql.UUID `json:"user_id"`
Password string `json:"password"` // For authentication
}
// DeletionResult contains comprehensive information about the deletion operation
type DeletionResult struct {
UserID gocql.UUID `json:"user_id"`
FilesDeleted int `json:"files_deleted"`
CollectionsDeleted int `json:"collections_deleted"`
S3ObjectsDeleted int `json:"s3_objects_deleted"`
TotalDataSizeBytes int64 `json:"total_data_size_bytes"`
MembershipsRemoved int `json:"memberships_removed"`
DeletedAt time.Time `json:"deleted_at"`
Success bool `json:"success"`
Errors []string `json:"errors,omitempty"` // Non-fatal errors
}
// CompleteUserDeletionService orchestrates complete GDPR-compliant user deletion
type CompleteUserDeletionService interface {
Execute(ctx context.Context, req *CompleteUserDeletionRequest) (*DeletionResult, error)
}
type completeUserDeletionServiceImpl struct {
config *config.Configuration
logger *zap.Logger
getUserUseCase uc_user.UserGetByIDUseCase
deleteUserByIDUseCase uc_user.UserDeleteByIDUseCase
listFilesByOwnerIDService svc_file.ListFilesByOwnerIDService
softDeleteFileService svc_file.SoftDeleteFileService
listCollectionsByUserUseCase uc_collection.ListCollectionsByUserUseCase
softDeleteCollectionService svc_collection.SoftDeleteCollectionService
removeUserFromAllCollectionsUseCase uc_collection.RemoveUserFromAllCollectionsUseCase
deleteStorageDailyUsageUseCase uc_storagedailyusage.DeleteByUserUseCase
deleteStorageUsageEventUseCase uc_storageusageevent.DeleteByUserUseCase
anonymizeUserIPsImmediatelyUseCase uc_user.AnonymizeUserIPsImmediatelyUseCase
clearUserCacheUseCase uc_user.ClearUserCacheUseCase
anonymizeFileUserReferencesUseCase uc_filemetadata.AnonymizeUserReferencesUseCase
anonymizeCollectionUserReferencesUseCase uc_collection.AnonymizeUserReferencesUseCase
}
func NewCompleteUserDeletionService(
config *config.Configuration,
logger *zap.Logger,
getUserUseCase uc_user.UserGetByIDUseCase,
deleteUserByIDUseCase uc_user.UserDeleteByIDUseCase,
listFilesByOwnerIDService svc_file.ListFilesByOwnerIDService,
softDeleteFileService svc_file.SoftDeleteFileService,
listCollectionsByUserUseCase uc_collection.ListCollectionsByUserUseCase,
softDeleteCollectionService svc_collection.SoftDeleteCollectionService,
removeUserFromAllCollectionsUseCase uc_collection.RemoveUserFromAllCollectionsUseCase,
deleteStorageDailyUsageUseCase uc_storagedailyusage.DeleteByUserUseCase,
deleteStorageUsageEventUseCase uc_storageusageevent.DeleteByUserUseCase,
anonymizeUserIPsImmediatelyUseCase uc_user.AnonymizeUserIPsImmediatelyUseCase,
clearUserCacheUseCase uc_user.ClearUserCacheUseCase,
anonymizeFileUserReferencesUseCase uc_filemetadata.AnonymizeUserReferencesUseCase,
anonymizeCollectionUserReferencesUseCase uc_collection.AnonymizeUserReferencesUseCase,
) CompleteUserDeletionService {
logger = logger.Named("CompleteUserDeletionService")
return &completeUserDeletionServiceImpl{
config: config,
logger: logger,
getUserUseCase: getUserUseCase,
deleteUserByIDUseCase: deleteUserByIDUseCase,
listFilesByOwnerIDService: listFilesByOwnerIDService,
softDeleteFileService: softDeleteFileService,
listCollectionsByUserUseCase: listCollectionsByUserUseCase,
softDeleteCollectionService: softDeleteCollectionService,
removeUserFromAllCollectionsUseCase: removeUserFromAllCollectionsUseCase,
deleteStorageDailyUsageUseCase: deleteStorageDailyUsageUseCase,
deleteStorageUsageEventUseCase: deleteStorageUsageEventUseCase,
anonymizeUserIPsImmediatelyUseCase: anonymizeUserIPsImmediatelyUseCase,
clearUserCacheUseCase: clearUserCacheUseCase,
anonymizeFileUserReferencesUseCase: anonymizeFileUserReferencesUseCase,
anonymizeCollectionUserReferencesUseCase: anonymizeCollectionUserReferencesUseCase,
}
}
func (svc *completeUserDeletionServiceImpl) Execute(ctx context.Context, req *CompleteUserDeletionRequest) (*DeletionResult, error) {
//
// STEP 0: Validation
//
if req == nil {
svc.logger.Warn("Failed validation with nil request")
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Request is required")
}
e := make(map[string]string)
if req.UserID.String() == "" {
e["user_id"] = "User ID is required"
}
if len(e) != 0 {
svc.logger.Warn("Failed validating complete user deletion",
zap.Any("error", e))
return nil, httperror.NewForBadRequest(&e)
}
result := &DeletionResult{
UserID: req.UserID,
DeletedAt: time.Now(),
Errors: []string{},
}
svc.logger.Info("🚨 Starting GDPR right-to-be-forgotten complete user deletion",
zap.String("user_id", req.UserID.String()))
//
// STEP 1: Verify user exists
//
user, err := svc.getUserUseCase.Execute(ctx, req.UserID)
if err != nil {
svc.logger.Error("User not found for deletion",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
return nil, err
}
svc.logger.Info("User verified for deletion",
zap.String("user_id", req.UserID.String()),
zap.String("email", user.Email))
//
// STEP 2: List and hard delete all user files
//
svc.logger.Info("Step 2/11: Deleting user files...")
listFilesReq := &svc_file.ListFilesByOwnerIDRequestDTO{OwnerID: req.UserID}
filesResp, err := svc.listFilesByOwnerIDService.Execute(ctx, listFilesReq)
if err != nil {
svc.logger.Error("Failed to list user files",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("List files: %v", err))
} else {
result.FilesDeleted = len(filesResp.Files)
svc.logger.Info("Found files to delete",
zap.Int("file_count", result.FilesDeleted))
// Hard delete each file (no tombstone - GDPR mode)
for _, file := range filesResp.Files {
deleteFileReq := &svc_file.SoftDeleteFileRequestDTO{
FileID: file.ID,
ForceHardDelete: true, // GDPR mode - immediate permanent deletion
}
deleteResp, err := svc.softDeleteFileService.Execute(ctx, deleteFileReq)
if err != nil {
svc.logger.Error("Failed to delete file",
zap.String("file_id", file.ID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("File %s: %v", file.ID, err))
} else {
result.S3ObjectsDeleted++
result.TotalDataSizeBytes += deleteResp.ReleasedBytes
}
}
}
//
// STEP 3: List and hard delete all user collections
//
svc.logger.Info("Step 3/11: Deleting user collections...")
collections, err := svc.listCollectionsByUserUseCase.Execute(ctx, req.UserID)
if err != nil {
svc.logger.Error("Failed to list user collections",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("List collections: %v", err))
} else {
result.CollectionsDeleted = len(collections)
svc.logger.Info("Found collections to delete",
zap.Int("collection_count", result.CollectionsDeleted))
// Hard delete each collection (no tombstone - GDPR mode)
for _, collection := range collections {
deleteColReq := &svc_collection.SoftDeleteCollectionRequestDTO{
ID: collection.ID,
ForceHardDelete: true, // GDPR mode - immediate permanent deletion
}
_, err := svc.softDeleteCollectionService.Execute(ctx, deleteColReq)
if err != nil {
svc.logger.Error("Failed to delete collection",
zap.String("collection_id", collection.ID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("Collection %s: %v", collection.ID, err))
}
}
}
//
// STEP 4: Remove user from shared collections
//
svc.logger.Info("Step 4/11: Removing user from shared collections...")
removedCount, err := svc.removeUserFromAllCollectionsUseCase.Execute(ctx, req.UserID, user.Email)
if err != nil {
svc.logger.Error("Failed to remove user from shared collections",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("Membership cleanup: %v", err))
} else {
result.MembershipsRemoved = removedCount
svc.logger.Info("Removed user from shared collections",
zap.Int("memberships_removed", removedCount))
}
//
// STEP 5: Delete storage daily usage data
//
svc.logger.Info("Step 5/11: Deleting storage daily usage data...")
err = svc.deleteStorageDailyUsageUseCase.Execute(ctx, req.UserID)
if err != nil {
svc.logger.Error("Failed to delete storage daily usage",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("Storage daily usage: %v", err))
}
//
// STEP 6: Delete storage usage events
//
svc.logger.Info("Step 6/11: Deleting storage usage events...")
err = svc.deleteStorageUsageEventUseCase.Execute(ctx, req.UserID)
if err != nil {
svc.logger.Error("Failed to delete storage usage events",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("Storage usage events: %v", err))
}
//
// STEP 7: Anonymize all IP addresses
//
svc.logger.Info("Step 7/11: Anonymizing IP addresses...")
err = svc.anonymizeUserIPsImmediatelyUseCase.Execute(ctx, req.UserID)
if err != nil {
svc.logger.Error("Failed to anonymize IP addresses",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("IP anonymization: %v", err))
}
//
// STEP 8: Anonymize user references in files (CreatedByUserID/ModifiedByUserID)
//
svc.logger.Info("Step 8/11: Anonymizing user references in files...")
filesUpdated, err := svc.anonymizeFileUserReferencesUseCase.Execute(ctx, req.UserID)
if err != nil {
svc.logger.Error("Failed to anonymize user references in files",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("File user references: %v", err))
} else {
svc.logger.Info("Anonymized user references in files",
zap.Int("files_updated", filesUpdated))
}
//
// STEP 9: Anonymize user references in collections (CreatedByUserID/ModifiedByUserID/GrantedByID)
//
svc.logger.Info("Step 9/11: Anonymizing user references in collections...")
collectionsUpdated, err := svc.anonymizeCollectionUserReferencesUseCase.Execute(ctx, req.UserID)
if err != nil {
svc.logger.Error("Failed to anonymize user references in collections",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("Collection user references: %v", err))
} else {
svc.logger.Info("Anonymized user references in collections",
zap.Int("collections_updated", collectionsUpdated))
}
//
// STEP 10: Clear cache and session data
//
svc.logger.Info("Step 10/11: Clearing cache and session data...")
err = svc.clearUserCacheUseCase.Execute(ctx, req.UserID, user.Email)
if err != nil {
svc.logger.Error("Failed to clear user cache",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
result.Errors = append(result.Errors, fmt.Sprintf("Cache cleanup: %v", err))
}
//
// STEP 11: Delete user account (final step - point of no return)
//
svc.logger.Info("Step 11/11: Deleting user account (final step)...")
err = svc.deleteUserByIDUseCase.Execute(ctx, req.UserID)
if err != nil {
svc.logger.Error("CRITICAL: User account deletion failed",
zap.String("user_id", req.UserID.String()),
zap.Error(err))
return nil, fmt.Errorf("CRITICAL: User account deletion failed: %w", err)
}
//
// SUCCESS
//
result.Success = true
svc.logger.Info("✅ GDPR right-to-be-forgotten complete user deletion SUCCEEDED",
zap.String("user_id", req.UserID.String()),
zap.String("email", user.Email),
zap.Int("files_deleted", result.FilesDeleted),
zap.Int("collections_deleted", result.CollectionsDeleted),
zap.Int("s3_objects_deleted", result.S3ObjectsDeleted),
zap.Int("memberships_removed", result.MembershipsRemoved),
zap.Int64("data_size_bytes", result.TotalDataSizeBytes),
zap.Int("non_fatal_errors", len(result.Errors)))
if len(result.Errors) > 0 {
svc.logger.Warn("Deletion completed with non-fatal errors",
zap.Strings("errors", result.Errors))
}
return result, nil
}

View file

@ -0,0 +1,41 @@
package user
import (
"testing"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
)
// NOTE: Unit tests for CompleteUserDeletionService would require mocks.
// For now, this service will be tested via integration tests.
// See Task 1.10 in RIGHT_TO_BE_FORGOTTEN_IMPLEMENTATION.md
func TestCompleteUserDeletionService_Constructor(t *testing.T) {
// Test that constructor creates service successfully
cfg := &config.Configuration{}
logger := zap.NewNop()
service := NewCompleteUserDeletionService(
cfg,
logger,
nil, // getUserUseCase
nil, // deleteUserByIDUseCase
nil, // listFilesByOwnerIDService
nil, // softDeleteFileService
nil, // listCollectionsByUserUseCase
nil, // softDeleteCollectionService
nil, // removeUserFromAllCollectionsUseCase
nil, // deleteStorageDailyUsageUseCase
nil, // deleteStorageUsageEventUseCase
nil, // anonymizeUserIPsImmediatelyUseCase
nil, // clearUserCacheUseCase
nil, // anonymizeFileUserReferencesUseCase
nil, // anonymizeCollectionUserReferencesUseCase
)
if service == nil {
t.Error("Expected service to be created, got nil")
}
}

View file

@ -0,0 +1,61 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/user/provider.go
package user
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
svc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/collection"
svc_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/file"
uc_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/collection"
uc_filemetadata "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/filemetadata"
uc_storagedailyusage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storagedailyusage"
uc_storageusageevent "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storageusageevent"
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
)
// ProvideUserPublicLookupService provides the user public lookup service
func ProvideUserPublicLookupService(
config *config.Config,
logger *zap.Logger,
userGetByEmailUC uc_user.UserGetByEmailUseCase,
) UserPublicLookupService {
return NewUserPublicLookupService(config, logger, userGetByEmailUC)
}
// ProvideCompleteUserDeletionService provides the complete GDPR user deletion service
func ProvideCompleteUserDeletionService(
cfg *config.Configuration,
logger *zap.Logger,
getUserUseCase uc_user.UserGetByIDUseCase,
deleteUserByIDUseCase uc_user.UserDeleteByIDUseCase,
listFilesByOwnerIDService svc_file.ListFilesByOwnerIDService,
softDeleteFileService svc_file.SoftDeleteFileService,
listCollectionsByUserUseCase uc_collection.ListCollectionsByUserUseCase,
softDeleteCollectionService svc_collection.SoftDeleteCollectionService,
removeUserFromAllCollectionsUseCase uc_collection.RemoveUserFromAllCollectionsUseCase,
deleteStorageDailyUsageUseCase uc_storagedailyusage.DeleteByUserUseCase,
deleteStorageUsageEventUseCase uc_storageusageevent.DeleteByUserUseCase,
anonymizeUserIPsImmediatelyUseCase uc_user.AnonymizeUserIPsImmediatelyUseCase,
clearUserCacheUseCase uc_user.ClearUserCacheUseCase,
anonymizeFileUserReferencesUseCase uc_filemetadata.AnonymizeUserReferencesUseCase,
anonymizeCollectionUserReferencesUseCase uc_collection.AnonymizeUserReferencesUseCase,
) CompleteUserDeletionService {
return NewCompleteUserDeletionService(
cfg,
logger,
getUserUseCase,
deleteUserByIDUseCase,
listFilesByOwnerIDService,
softDeleteFileService,
listCollectionsByUserUseCase,
softDeleteCollectionService,
removeUserFromAllCollectionsUseCase,
deleteStorageDailyUsageUseCase,
deleteStorageUsageEventUseCase,
anonymizeUserIPsImmediatelyUseCase,
clearUserCacheUseCase,
anonymizeFileUserReferencesUseCase,
anonymizeCollectionUserReferencesUseCase,
)
}

View file

@ -0,0 +1,109 @@
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/user/publiclookup.go
package user
import (
"context"
"encoding/base64"
"strings"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
type UserPublicLookupRequestDTO struct {
Email string `json:"email"`
}
type UserPublicLookupResponseDTO struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"` // Optional: for display
PublicKeyInBase64 string `json:"public_key_in_base64"` // Base64 encoded
VerificationID string `json:"verification_id"`
}
type UserPublicLookupService interface {
Execute(ctx context.Context, req *UserPublicLookupRequestDTO) (*UserPublicLookupResponseDTO, error)
}
type userPublicLookupServiceImpl struct {
config *config.Config
logger *zap.Logger
userGetByEmailUC uc_user.UserGetByEmailUseCase
}
func NewUserPublicLookupService(
cfg *config.Config,
logger *zap.Logger,
userGetByEmailUC uc_user.UserGetByEmailUseCase,
) UserPublicLookupService {
logger = logger.Named("UserPublicLookupService")
return &userPublicLookupServiceImpl{cfg, logger, userGetByEmailUC}
}
func (svc *userPublicLookupServiceImpl) Execute(ctx context.Context, req *UserPublicLookupRequestDTO) (*UserPublicLookupResponseDTO, error) {
//
// STEP 1: Sanitization of the input.
//
// Defensive Code: For security purposes we need to perform some sanitization on the inputs.
req.Email = strings.ToLower(req.Email)
req.Email = strings.ReplaceAll(req.Email, " ", "")
req.Email = strings.ReplaceAll(req.Email, "\t", "")
req.Email = strings.TrimSpace(req.Email)
svc.logger.Debug("sanitized email",
zap.String("email", validation.MaskEmail(req.Email)))
//
// STEP 2: Validation of input.
//
e := make(map[string]string)
if req.Email == "" {
e["email"] = "Email is required"
}
if len(req.Email) > 255 {
e["email"] = "Email is too long"
}
if len(e) != 0 {
svc.logger.Warn("failed validating",
zap.Any("e", e))
return nil, httperror.NewForBadRequest(&e)
}
//
// STEP 3: Lookup user by email
//
// Lookup the user in our database, else return a `400 Bad Request` error.
// Note: We return a generic error message to prevent user enumeration attacks.
u, err := svc.userGetByEmailUC.Execute(ctx, req.Email)
if err != nil {
svc.logger.Error("failed getting user by email from database",
zap.Any("error", err))
return nil, httperror.NewForBadRequestWithSingleField("email", "Unable to complete lookup")
}
if u == nil {
svc.logger.Warn("user lookup attempted for non-existent email",
zap.String("email", validation.MaskEmail(req.Email)))
// Return same error message as above to prevent user enumeration
return nil, httperror.NewForBadRequestWithSingleField("email", "Unable to complete lookup")
}
// STEP 4: Build response DTO
dto := &UserPublicLookupResponseDTO{
UserID: u.ID.String(),
Email: u.Email,
Name: u.Name,
PublicKeyInBase64: base64.StdEncoding.EncodeToString(u.SecurityData.PublicKey.Key),
VerificationID: u.SecurityData.VerificationID,
}
return dto, nil
}