forked from baron/baron-sso
사용자 삭제 RP 관계 정리 로그 미표시 수정
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -1768,7 +1769,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), id); err != nil {
|
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, id); err != nil {
|
||||||
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -2227,7 +2228,7 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), userID); err != nil {
|
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, userID); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2264,11 +2265,20 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) enqueueDeletedUserRelyingPartyCleanup(ctx context.Context, userID string) error {
|
func (h *UserHandler) enqueueDeletedUserRelyingPartyCleanup(ctx context.Context, requester *domain.UserProfileResponse, userID string) error {
|
||||||
if h.KetoService == nil || h.KetoOutboxRepo == nil {
|
if h.KetoService == nil || h.KetoOutboxRepo == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actorID := ""
|
||||||
|
tenantID := ""
|
||||||
|
if requester != nil {
|
||||||
|
actorID = strings.TrimSpace(requester.ID)
|
||||||
|
if requester.TenantID != nil {
|
||||||
|
tenantID = strings.TrimSpace(*requester.TenantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
subject := "User:" + strings.TrimSpace(userID)
|
subject := "User:" + strings.TrimSpace(userID)
|
||||||
tuples, err := h.listDeletedUserRelyingPartyRelations(ctx, subject)
|
tuples, err := h.listDeletedUserRelyingPartyRelations(ctx, subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2314,12 +2324,65 @@ func (h *UserHandler) enqueueDeletedUserRelyingPartyCleanup(ctx context.Context,
|
|||||||
Action: domain.KetoOutboxActionDelete,
|
Action: domain.KetoOutboxActionDelete,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Warn("[UserHandler] Failed to enqueue RelyingParty relation cleanup", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
|
slog.Warn("[UserHandler] Failed to enqueue RelyingParty relation cleanup", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.recordDeletedUserRelyingPartyCleanupAudit(actorID, tenantID, userID, tuple, relSubject); err != nil {
|
||||||
|
slog.Warn("[UserHandler] Failed to record RelyingParty cleanup audit", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) recordDeletedUserRelyingPartyCleanupAudit(
|
||||||
|
actorID string,
|
||||||
|
tenantID string,
|
||||||
|
deletedUserID string,
|
||||||
|
tuple service.RelationTuple,
|
||||||
|
relSubject string,
|
||||||
|
) error {
|
||||||
|
if h.AuditRepo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
details := map[string]any{
|
||||||
|
"action": "REMOVE_RELATION",
|
||||||
|
"target_id": strings.TrimSpace(tuple.Object),
|
||||||
|
"source": "user_delete",
|
||||||
|
"deleted_user_id": strings.TrimSpace(deletedUserID),
|
||||||
|
"cascade_cleanup": true,
|
||||||
|
"relation_subject": strings.TrimSpace(relSubject),
|
||||||
|
"before": map[string]any{
|
||||||
|
"relation": strings.TrimSpace(tuple.Relation),
|
||||||
|
"subject": strings.TrimSpace(relSubject),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(tenantID) != "" {
|
||||||
|
details["tenant_id"] = strings.TrimSpace(tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(details)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
eventType := fmt.Sprintf("DELETE /api/v1/dev/clients/%s/relations", strings.TrimSpace(tuple.Object))
|
||||||
|
if strings.TrimSpace(tuple.Relation) != "" {
|
||||||
|
eventType = fmt.Sprintf("%s/%s", eventType, strings.TrimSpace(tuple.Relation))
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.AuditRepo.Create(&domain.AuditLog{
|
||||||
|
EventID: fmt.Sprintf("user-delete-rp-cleanup-%d", time.Now().UnixNano()),
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
UserID: strings.TrimSpace(actorID),
|
||||||
|
TenantID: strings.TrimSpace(tenantID),
|
||||||
|
EventType: eventType,
|
||||||
|
Status: "success",
|
||||||
|
Details: string(raw),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *UserHandler) listDeletedUserRelyingPartyRelations(ctx context.Context, subject string) ([]service.RelationTuple, error) {
|
func (h *UserHandler) listDeletedUserRelyingPartyRelations(ctx context.Context, subject string) ([]service.RelationTuple, error) {
|
||||||
var tuples []service.RelationTuple
|
var tuples []service.RelationTuple
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -1293,6 +1293,66 @@ func TestUserHandler_DeleteUserFallsBackToKetoOutboxWhenLiveRelationsAreEmpty(t
|
|||||||
mockOutbox.AssertExpectations(t)
|
mockOutbox.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_DeleteUserRecordsCascadeRelyingPartyCleanupAudit(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
userRepo := new(MockUserRepoForHandler)
|
||||||
|
mockKeto := new(userHandlerMockKetoService)
|
||||||
|
mockOutbox := new(userHandlerMockKetoOutboxRepository)
|
||||||
|
auditRepo := &mockAuditRepo{}
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
UserRepo: userRepo,
|
||||||
|
KetoService: mockKeto,
|
||||||
|
KetoOutboxRepo: mockOutbox,
|
||||||
|
AuditRepo: auditRepo,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Delete("/users/:id", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||||
|
return h.DeleteUser(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "", "", "User:u-1").Return([]service.RelationTuple{
|
||||||
|
{Namespace: "RelyingParty", Object: "client-1", Relation: "admins", SubjectID: "User:u-1"},
|
||||||
|
}, nil).Once()
|
||||||
|
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Once()
|
||||||
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||||
|
return entry.Namespace == "RelyingParty" && entry.Object == "client-1" && entry.Relation == "admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
|
||||||
|
})).Return(nil).Once()
|
||||||
|
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool {
|
||||||
|
return entry.Namespace == "System" && entry.Object == "global" && entry.Relation == "super_admins" && entry.Subject == "User:u-1" && entry.Action == domain.KetoOutboxActionDelete
|
||||||
|
})).Return(nil).Once()
|
||||||
|
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||||
|
|
||||||
|
require.Len(t, auditRepo.logs, 1)
|
||||||
|
log := auditRepo.logs[0]
|
||||||
|
assert.Equal(t, "admin-1", log.UserID)
|
||||||
|
assert.Equal(t, "DELETE /api/v1/dev/clients/client-1/relations/admins", log.EventType)
|
||||||
|
|
||||||
|
details := map[string]any{}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(log.Details), &details))
|
||||||
|
assert.Equal(t, "REMOVE_RELATION", details["action"])
|
||||||
|
assert.Equal(t, "client-1", details["target_id"])
|
||||||
|
assert.Equal(t, "user_delete", details["source"])
|
||||||
|
assert.Equal(t, "u-1", details["deleted_user_id"])
|
||||||
|
assert.Equal(t, "User:u-1", details["relation_subject"])
|
||||||
|
|
||||||
|
before, ok := details["before"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "admins", before["relation"])
|
||||||
|
assert.Equal(t, "User:u-1", before["subject"])
|
||||||
|
|
||||||
|
mockKratos.AssertExpectations(t)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
mockOutbox.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
|
func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
|||||||
Reference in New Issue
Block a user