Skip to content

Conversation

@shreeharsha-factly
Copy link
Contributor

@shreeharsha-factly shreeharsha-factly commented Oct 15, 2025

Summary by CodeRabbit

  • New Features
    • Admins can now delete a user by ID via a new DELETE endpoint. The operation anonymizes and deactivates the account, removes organization memberships, and clears related permissions and identity records in external systems. Database errors roll back changes; post-delete external cleanup issues are logged. Successful deletion returns HTTP 200.

@coderabbitai
Copy link

coderabbitai bot commented Oct 15, 2025

Walkthrough

Adds an admin DELETE endpoint to soft-delete a user: parses user_id, runs a DB transaction to remove organisation links and anonymize/soft-delete the user, commits, then performs post-commit external cleanups (remove org roles in Keto, delete Keto relations, delete Kratos identity).

Changes

Cohort / File(s) Summary
Admin user delete handler
server/action/admin/user/delete.go
New handler implementing transactional soft-delete: validate user_id, begin TX, remove OrganisationUser links and collect org IDs, anonymize and soft-delete user within TX, commit/rollback, then post-commit cleanup: remove user from organisation roles in Keto, delete Keto relation tuples, and delete Kratos identity if present. Adds helpers deleteUserRelations and deleteKratosIdentity.
Admin user routes
server/action/admin/user/route.go
Registers DELETE route /{user_id} mapped to the new delete handler.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Admin as Admin Client
  participant Router as Admin Router
  participant Handler as DeleteUserHandler
  participant DB as Database (Tx)
  participant Keto as Keto API
  participant Kratos as Kratos Admin API

  Admin->>Router: DELETE /admin/user/{user_id}
  Router->>Handler: route to delete(user_id)
  Handler->>Handler: validate user_id
  Handler->>DB: BEGIN TRANSACTION
  Handler->>DB: fetch user, fetch OrganisationUser records
  loop per organisation
    Handler->>DB: remove OrganisationUser (in-TX) and collect org ID
  end
  Handler->>DB: anonymize user, set is_active=false, soft-delete (in-TX)
  DB-->>Handler: OK
  Handler->>DB: COMMIT
  alt commit success
    par remove org roles
      Handler->>Keto: remove user from org roles (per org)
      Keto-->>Handler: ok / errors (logged)
    and
      Handler->>Keto: delete relation tuples for user
      Keto-->>Handler: ok / errors (logged)
    end
    alt KID exists
      Handler->>Kratos: DELETE /identities/{kid}
      Kratos-->>Handler: 204 / other (logged)
    end
    Handler-->>Router: 200 OK
    Router-->>Admin: 200 OK
  else commit fails
    Handler-->>Router: error (rollback)
    Router-->>Admin: error
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

I nibble at IDs and tidy the trail,
Soft-hide a name, let emails pale.
I hop through tx, then tidy outside,
Links trimmed gently, no footprints to hide.
A rabbit's small cleanup, neat and spry. 🐇✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "server: anonymized the user data before soft deletion" is directly related to the main changes in the changeset. The raw summary confirms that the implementation includes a step to anonymize user data (setting display_name, first_name, last_name, and email to "deleted-"+email, and is_active to false) before performing the soft delete within a transaction. The title accurately captures a key aspect of the new user deletion functionality added in the delete.go file and the corresponding route in route.go. While the changeset also includes transaction management and post-commit external cleanups (Keto and Kratos), the title focuses on the core anonymization step, which is a central part of the user deletion workflow.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch delete/user

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7b101ad and 4f054d1.

📒 Files selected for processing (1)
  • server/action/admin/user/delete.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/action/admin/user/delete.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Oct 15, 2025

Deploying kavach-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4f054d1
Status: ✅  Deploy successful!
Preview URL: https://c66e9a29.kavach-docs.pages.dev
Branch Preview URL: https://delete-user-8yy4.kavach-docs.pages.dev

View logs

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
server/action/admin/user/delete.go (2)

168-168: Consider using a shared, configured HTTP client.

Creating a new http.Client for each request bypasses connection pooling and requires repeated timeout configuration. A package-level or singleton client would improve performance and maintainability.

Example approach:

// At package level or in a shared util
var defaultHTTPClient = &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
    },
}

// In deleteKratosIdentity
resp, err := defaultHTTPClient.Do(req)

97-102: Extract magic strings to package constants.

Hardcoded anonymization strings reduce maintainability and make it harder to ensure consistency if deletion logic is extended.

+const (
+    deletedUserDisplayName = "Deleted User"
+    deletedEmailPrefix     = "deleted-"
+)
+
 func delete(w http.ResponseWriter, r *http.Request) {
     // ... existing code ...
     
     // Anonymize user data
-    anonymizedEmail := "deleted-" + userToUpdate.Email
+    anonymizedEmail := deletedEmailPrefix + userToUpdate.Email
     
     // Update user with anonymized data
     updates := map[string]interface{}{
-        "display_name": "Deleted User",
-        "first_name":   "Deleted User",
+        "display_name": deletedUserDisplayName,
+        "first_name":   deletedUserDisplayName,
         "email":        anonymizedEmail,
         "is_active":    false,
     }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 45f4b80 and 71b5c2a.

📒 Files selected for processing (2)
  • server/action/admin/user/delete.go (1 hunks)
  • server/action/admin/user/route.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
server/action/admin/user/delete.go (4)
server/model/setup.go (1)
  • DB (17-17)
server/util/user/organisationUser.go (1)
  • DeleteUserFromOrganisationRoles (15-92)
server/model/keto.go (2)
  • KetoRelationTupleWithSubjectID (50-53)
  • KetoSubjectSet (44-48)
server/util/keto/relationTuple/delete.go (1)
  • DeleteRelationTupleWithSubjectID (14-63)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cloudflare Pages
🔇 Additional comments (4)
server/action/admin/user/route.go (1)

22-22: LGTM!

The DELETE route addition follows the existing routing patterns and correctly binds to the delete handler.

server/action/admin/user/delete.go (3)

29-37: LGTM!

URL parameter extraction and validation follows the standard pattern with appropriate error handling.


107-122: LGTM!

The anonymization-then-soft-delete sequence correctly ensures data is sanitized before the soft delete marker is applied.


44-78: LGTM!

Error handling follows consistent patterns with proper rollbacks, logging, and error responses throughout the deletion workflow.

Comment on lines 40 to 136
tx := model.DB.Begin()

// 1. Delete all user's relation tuples from Keto
err = deleteUserRelations(uint(uID))
if err != nil {
tx.Rollback()
loggerx.Error(err)
errorx.Render(w, errorx.Parser(errorx.InternalServerError()))
return
}

// 2. Remove user from all organizations and roles
// First get all organizations the user is part of
var orgUsers []model.OrganisationUser
err = tx.Model(&model.OrganisationUser{}).Where("user_id = ?", uID).Find(&orgUsers).Error
if err != nil {
tx.Rollback()
loggerx.Error(err)
errorx.Render(w, errorx.Parser(errorx.DBError()))
return
}

// Remove user from each organization
for _, orgUser := range orgUsers {
err = userUtil.DeleteUserFromOrganisationRoles(orgUser.OrganisationID, uint(uID))
if err != nil {
tx.Rollback()
loggerx.Error(err)
errorx.Render(w, errorx.Parser(errorx.InternalServerError()))
return
}

err = tx.Delete(&orgUser).Error
if err != nil {
tx.Rollback()
loggerx.Error(err)
errorx.Render(w, errorx.Parser(errorx.DBError()))
return
}
}

// 3. Soft delete user record and anonymize their data
userToUpdate := &model.User{}

// First get the user details before updating
err = tx.Model(&model.User{}).Where("id = ?", uID).First(userToUpdate).Error
if err != nil {
tx.Rollback()
loggerx.Error(err)
errorx.Render(w, errorx.Parser(errorx.DBError()))
return
}

// Store the KID for later use
kid := userToUpdate.KID

// Anonymize user data
anonymizedEmail := "deleted-" + userToUpdate.Email

// Update user with anonymized data
updates := map[string]interface{}{
"display_name": "Deleted User",
"first_name": "Deleted User",
"email": anonymizedEmail,
"is_active": false,
}

err = tx.Model(&model.User{}).Where("id = ?", uID).Updates(updates).Error
if err != nil {
tx.Rollback()
loggerx.Error(err)
errorx.Render(w, errorx.Parser(errorx.DBError()))
return
}

// Soft delete the user
err = tx.Delete(&model.User{}, uID).Error
if err != nil {
tx.Rollback()
loggerx.Error(err)
errorx.Render(w, errorx.Parser(errorx.DBError()))
return
}

// 4. Delete user's identity from Kratos
if kid != "" {
err = deleteKratosIdentity(kid)
if err != nil {
tx.Rollback()
loggerx.Error(err)
errorx.Render(w, errorx.Parser(errorx.InternalServerError()))
return
}
}

// Commit the transaction
tx.Commit()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Transaction cannot guarantee atomicity across external HTTP calls.

The transaction wraps database operations but external HTTP calls to Keto (lines 43, 64) and Kratos (line 126) are not transactional. This creates race conditions and potential inconsistencies:

Failure scenarios:

  1. Keto relations deleted successfully → later DB error → rollback → Keto state orphaned
  2. Kratos identity deleted → commit fails → identity lost but user record remains
  3. Organization roles removed from Keto → transaction rolled back → roles deleted but user associations intact

Impact: Data corruption across services, difficult-to-recover inconsistent states, potential security issues (orphaned permissions in Keto).

Consider implementing one of these patterns:

  1. Saga pattern with compensating transactions:

    • Track which external operations succeeded
    • On rollback, call compensating APIs to reverse external changes
    • Example: Store deleted relation tuples to re-create them on rollback
  2. Idempotent operations with retry logic:

    • Make external deletions idempotent
    • Use a separate cleanup job to reconcile state post-commit
    • Store deletion intent in DB first, then process asynchronously
  3. Reorder operations (partial mitigation):

    • Perform all DB operations in transaction first
    • Only call external services after successful commit
    • Accept that external call failures after commit require manual intervention or async retry

Example saga-style rollback:

+// Track operations for potential rollback
+type deletionState struct {
+    ketoRelationsDeleted bool
+    orgRolesDeleted []uint
+    kratosDeleted bool
+}

 func delete(w http.ResponseWriter, r *http.Request) {
     // ... validation ...
     tx := model.DB.Begin()
+    state := &deletionState{}
+    defer func() {
+        if r := recover(); r != nil || err != nil {
+            // Compensate external operations
+            compensateDeleteOperation(state, uint(uID))
+            tx.Rollback()
+        }
+    }()
     
-    err = deleteUserRelations(uint(uID))
+    err = deleteUserRelations(uint(uID), state)
     if err != nil {
-        tx.Rollback()
         return
     }
     // ... rest of operations tracking state ...
     tx.Commit()
 }
+
+func compensateDeleteOperation(state *deletionState, userID uint) {
+    if state.ketoRelationsDeleted {
+        // Re-create essential relations from backup
+    }
+    // ... other compensations ...
+}

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines 97 to 105
anonymizedEmail := "deleted-" + userToUpdate.Email

// Update user with anonymized data
updates := map[string]interface{}{
"display_name": "Deleted User",
"first_name": "Deleted User",
"email": anonymizedEmail,
"is_active": false,
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Email uniqueness constraint violation risk.

The anonymization strategy prefixes email with "deleted-", which can cause conflicts:

  1. Deleting the same user twice will attempt to create duplicate "[email protected]" entries
  2. An actual user with email "[email protected]" would conflict

Additionally, last_name is not anonymized despite being present in the user model, potentially leaving sensitive data.

Apply this diff to use a unique anonymized email and include last_name:

-    // Anonymize user data
-    anonymizedEmail := "deleted-" + userToUpdate.Email
+    // Anonymize user data with unique identifier
+    anonymizedEmail := fmt.Sprintf("deleted-%d-%s", uID, userToUpdate.Email)
     
     // Update user with anonymized data
     updates := map[string]interface{}{
         "display_name": "Deleted User",
         "first_name":   "Deleted User",
+        "last_name":    "Deleted User",
         "email":        anonymizedEmail,
         "is_active":    false,
     }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In server/action/admin/user/delete.go around lines 97 to 105, the anonymized
email currently uses a static "deleted-"+email which can collide and the
last_name field is not anonymized; change the anonymization to produce a
guaranteed-unique email (for example include the user ID and a timestamp or
UUID, e.g. fmt.Sprintf("deleted-%s-%d@redacted", user.ID, time.Now().Unix()) or
use a UUID) and add last_name to the updates map (e.g. "last_name": "Deleted
User") so all identifiable name fields are cleared; ensure the generated email
conforms to your email schema/validation and is deterministic enough to avoid
uniqueness constraint violations on repeated deletes.

Comment on lines +168 to +182
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

// Check response status
if resp.StatusCode != http.StatusNoContent {
var respBody map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return fmt.Errorf("failed to decode Kratos error response: %v", err)
}
return fmt.Errorf("failed to delete Kratos identity: %v", respBody)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add timeout to HTTP client and improve error handling.

The HTTP client lacks a timeout, risking indefinite hangs. Additionally, assuming the error response is JSON can mask the actual error if Kratos returns non-JSON.

Apply this diff:

-    // Send request
-    client := &http.Client{}
+    // Send request with timeout
+    client := &http.Client{
+        Timeout: 30 * time.Second,
+    }
     resp, err := client.Do(req)
     if err != nil {
         return err
     }
     defer resp.Body.Close()
 
     // Check response status
     if resp.StatusCode != http.StatusNoContent {
         var respBody map[string]interface{}
-        if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
-            return fmt.Errorf("failed to decode Kratos error response: %v", err)
+        decodeErr := json.NewDecoder(resp.Body).Decode(&respBody)
+        if decodeErr != nil {
+            return fmt.Errorf("failed to delete Kratos identity, status %d: %w", resp.StatusCode, decodeErr)
         }
         return fmt.Errorf("failed to delete Kratos identity: %v", respBody)
     }

Don't forget to add the time import:

 import (
     "encoding/json"
     "fmt"
     "net/http"
     "net/url"
     "strconv"
+    "time"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusNoContent {
var respBody map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return fmt.Errorf("failed to decode Kratos error response: %v", err)
}
return fmt.Errorf("failed to delete Kratos identity: %v", respBody)
}
// in the import block, add:
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
// … later in deleteKratosIdentity …
// Send request with timeout
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusNoContent {
var respBody map[string]interface{}
decodeErr := json.NewDecoder(resp.Body).Decode(&respBody)
if decodeErr != nil {
return fmt.Errorf(
"failed to delete Kratos identity, status %d: %w",
resp.StatusCode,
decodeErr,
)
}
return fmt.Errorf("failed to delete Kratos identity: %v", respBody)
}
🤖 Prompt for AI Agents
In server/action/admin/user/delete.go around lines 168 to 182, the HTTP client
is created without a timeout and the error-path assumes a JSON response which
can fail or mask the real error; fix by creating the client with a reasonable
timeout (e.g., http.Client{Timeout: time.Second * 10}) and on non-204 responses
read the full response body bytes, attempt to json.Decode into a map but if that
fails fall back to using the raw body string, then return an error that includes
the HTTP status code and the decoded or raw body; also add the time import.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (3)
server/action/admin/user/delete.go (3)

81-81: Email uniqueness constraint violation risk persists.

The anonymization strategy still uses a simple "deleted-" prefix, which will cause database uniqueness constraint violations when the same user is deleted multiple times (e.g., if the user is restored and deleted again, or in test scenarios).

Consider using a unique identifier in the anonymized email to guarantee uniqueness:

-	anonymizedEmail := "deleted-" + userToUpdate.Email
+	// Use timestamp or UUID to ensure uniqueness on repeated deletions
+	anonymizedEmail := fmt.Sprintf("deleted-%d-%s", uID, userToUpdate.Email)

This ensures that each deletion creates a unique anonymized email even if the same user is deleted multiple times.


179-183: Add timeout to HTTP client.

The HTTP client lacks a timeout configuration, which can cause the request to hang indefinitely if Kratos is unresponsive or network issues occur. This is especially problematic in a delete operation that should complete quickly.

Apply this diff to add a timeout:

 	// Send request
-	client := &http.Client{}
+	client := &http.Client{
+		Timeout: 30 * time.Second,
+	}
 	resp, err := client.Do(req)

Don't forget to add the time import:

 import (
 	"encoding/json"
 	"fmt"
 	"net/http"
 	"net/url"
 	"strconv"
+	"time"
 
 	"github.com/factly/kavach-server/model"

187-193: Improve error handling for non-JSON responses.

The error handling assumes Kratos returns a JSON response on failure (line 189), which may not always be the case. If the response is not valid JSON, the actual error is masked and you get a generic JSON decode error instead.

Apply this diff to handle both JSON and non-JSON error responses:

 	// Check response status
 	if resp.StatusCode != http.StatusNoContent {
 		var respBody map[string]interface{}
-		if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
-			return fmt.Errorf("failed to decode Kratos error response: %v", err)
+		decodeErr := json.NewDecoder(resp.Body).Decode(&respBody)
+		if decodeErr != nil {
+			return fmt.Errorf("failed to delete Kratos identity, status %d: %w", resp.StatusCode, decodeErr)
 		}
 		return fmt.Errorf("failed to delete Kratos identity: %v", respBody)
 	}
🧹 Nitpick comments (1)
server/action/admin/user/delete.go (1)

118-145: Good improvement: external operations after DB commit.

Moving external system operations (Keto, Kratos) after the database transaction commit is the correct approach and addresses the critical transaction atomicity concern from previous reviews. This ensures the database remains consistent even if external operations fail.

Enhancement opportunity:
The inline comments mention implementing a retry mechanism or queue for failed operations (line 127). Consider implementing an asynchronous job queue or dead-letter queue pattern to handle failed external operations, which would improve reliability and allow automatic retry of transient failures.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 71b5c2a and 94d34c6.

📒 Files selected for processing (1)
  • server/action/admin/user/delete.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
server/action/admin/user/delete.go (4)
server/model/setup.go (1)
  • DB (17-17)
server/util/user/organisationUser.go (1)
  • DeleteUserFromOrganisationRoles (15-92)
server/model/keto.go (2)
  • KetoRelationTupleWithSubjectID (50-53)
  • KetoSubjectSet (44-48)
server/util/keto/relationTuple/delete.go (1)
  • DeleteRelationTupleWithSubjectID (14-63)

@shreeharsha-factly shreeharsha-factly merged commit 60d5318 into develop Oct 15, 2025
1 of 3 checks passed
@shreeharsha-factly shreeharsha-factly deleted the delete/user branch October 15, 2025 23:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants