Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 104 additions & 69 deletions packages/dashboard-api/internal/api/api.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

92 changes: 92 additions & 0 deletions packages/dashboard-api/internal/handlers/admin_users_delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package handlers

import (
"context"
"errors"
"fmt"
"net/http"
"time"

"github.com/gin-gonic/gin"
"go.uber.org/zap"

"github.com/e2b-dev/infra/packages/dashboard-api/internal/api"
"github.com/e2b-dev/infra/packages/dashboard-api/internal/userprofile"
"github.com/e2b-dev/infra/packages/db/pkg/dberrors"
"github.com/e2b-dev/infra/packages/shared/pkg/logger"
)

const identityDeleteMaxRetries = 3

func (s *APIStore) DeleteAdminUsersUserId(c *gin.Context, userId api.UserId) {
ctx := c.Request.Context()

// Resolve the external identity references while user_identities still exists.
handle, err := s.userProfiles.PrepareDeleteUser(ctx, userId)
if err != nil {
if errors.Is(err, userprofile.ErrUserNotFound) {
s.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("User %s not found or has no identity provider record", userId))

return
}

logger.L().Error(ctx, "failed to prepare user deletion", zap.String("user_id", userId.String()), zap.Error(err))
s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to resolve identity provider record for user")

return
}

// Delete from public.users (cascades to user_identities via FK).
// Done before the IdP removal so a DB failure does not orphan the identity.
if err := s.authDB.Write.DeletePublicUser(ctx, userId); err != nil {
Comment thread
ben-fornefeld marked this conversation as resolved.
if dberrors.IsNotFoundError(err) {
s.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("User %s not found", userId))

return
}

if dberrors.IsForeignKeyViolation(err) {
logger.L().Warn(ctx, "cannot delete user due to existing references", zap.String("user_id", userId.String()), zap.Error(err))
s.sendAPIStoreError(c, http.StatusConflict, "Cannot delete user: existing references (e.g. addons) must be removed first")

return
}

logger.L().Error(ctx, "failed to delete public user", zap.String("user_id", userId.String()), zap.Error(err))
s.sendAPIStoreError(c, http.StatusInternalServerError, "Failed to delete public user record")

return
}

// Remove the external identity (e.g. Ory) using pre-fetched references.
// Retry since the DB rows are already gone and we must not leave the IdP identity active.
// Use a detached context so a client disconnect does not cancel the cleanup.
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cleanupCancel()

var identityErr error
for attempt := range identityDeleteMaxRetries {
identityErr = handle.Execute(cleanupCtx)
if identityErr == nil {
break
}

logger.L().Warn(ctx, "retrying identity deletion",
zap.String("user_id", userId.String()),
zap.Int("attempt", attempt+1),
zap.Error(identityErr),
)

time.Sleep(time.Duration(attempt+1) * 200 * time.Millisecond)
}

if identityErr != nil {
logger.L().Error(ctx, "failed to delete user identity provider record after retries", zap.String("user_id", userId.String()), zap.Error(identityErr))
s.sendAPIStoreError(c, http.StatusInternalServerError,
"User DB records deleted but identity provider removal failed — the IdP identity may need manual cleanup")

return
}

c.Status(http.StatusNoContent)
}
43 changes: 43 additions & 0 deletions packages/dashboard-api/internal/userprofile/ory.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,49 @@ func (p *oryProvider) GetTeamCreatorContext(ctx context.Context, userID uuid.UUI
return creatorContextFromOryIdentity(identities[0]), nil
}

func (p *oryProvider) PrepareDeleteUser(ctx context.Context, userID uuid.UUID) (DeleteUserHandle, error) {
if userID == uuid.Nil {
return nil, errors.New("user id is required")
}

subjectsByUser, err := p.subjectsForUserIDs(ctx, []uuid.UUID{userID})
if err != nil {
return nil, fmt.Errorf("lookup ory subject for user: %w", err)
}

if len(subjectsByUser) == 0 {
return nil, fmt.Errorf("%w: no identity mapping for user %s", ErrUserNotFound, userID)
}

subjects := make([]string, 0, len(subjectsByUser))
for s := range subjectsByUser {
subjects = append(subjects, s)
}

return &oryDeleteHandle{provider: p, subjects: subjects}, nil
Comment thread
ben-fornefeld marked this conversation as resolved.
}

type oryDeleteHandle struct {
provider *oryProvider
subjects []string
}

func (h *oryDeleteHandle) Execute(ctx context.Context) error {
for _, subject := range h.subjects {
resp, err := h.provider.identities.DeleteIdentityExecute(
h.provider.identities.DeleteIdentity(h.provider.authCtx(ctx), subject),
)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if err != nil {
return fmt.Errorf("delete ory identity %s: %w", subject, err)
}
}

return nil
}

func (p *oryProvider) subjectsForUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[string]uuid.UUID, error) {
rows, err := p.resolver.GetUserIdentitiesByUserIDs(ctx, authqueries.GetUserIdentitiesByUserIDsParams{
OidcIss: p.issuer,
Expand Down
Loading
Loading