forked from baron/baron-sso
삭제된 사용자 RP 관계 정리
This commit is contained in:
@@ -155,6 +155,14 @@ func (m *devMockKetoOutboxRepository) FindPending(ctx context.Context, limit int
|
|||||||
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *devMockKetoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) {
|
||||||
|
args := m.Called(ctx, namespace, subject)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *devMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
func (m *devMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
||||||
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
|
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
|
||||||
}
|
}
|
||||||
@@ -678,13 +686,13 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
|||||||
"scope": "openid profile email",
|
"scope": "openid profile email",
|
||||||
"token_endpoint_auth_method": "client_secret_basic",
|
"token_endpoint_auth_method": "client_secret_basic",
|
||||||
"metadata": map[string]any{
|
"metadata": map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
"tenant_access_restricted": false,
|
"tenant_access_restricted": false,
|
||||||
"allowed_tenants": []any{},
|
"allowed_tenants": []any{},
|
||||||
"id_token_claims": []any{},
|
"id_token_claims": []any{},
|
||||||
"headless_login_enabled": false,
|
"headless_login_enabled": false,
|
||||||
"headless_jwks_uri": "",
|
"headless_jwks_uri": "",
|
||||||
"headless_token_endpoint_auth_method": "",
|
"headless_token_endpoint_auth_method": "",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
@@ -701,13 +709,13 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
|||||||
"scope": "openid profile email tenant",
|
"scope": "openid profile email tenant",
|
||||||
"token_endpoint_auth_method": "private_key_jwt",
|
"token_endpoint_auth_method": "private_key_jwt",
|
||||||
"metadata": map[string]any{
|
"metadata": map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
"tenant_access_restricted": true,
|
"tenant_access_restricted": true,
|
||||||
"allowed_tenants": []any{"tenant-1", "tenant-2"},
|
"allowed_tenants": []any{"tenant-1", "tenant-2"},
|
||||||
"id_token_claims": []any{map[string]any{"namespace": "top_level", "key": "locale", "valueType": "text", "value": "ko-KR"}},
|
"id_token_claims": []any{map[string]any{"namespace": "top_level", "key": "locale", "valueType": "text", "value": "ko-KR"}},
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_jwks_uri": "https://rp.example.com/jwks.json",
|
"headless_jwks_uri": "https://rp.example.com/jwks.json",
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
@@ -738,7 +746,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
|||||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"name": "App One Updated",
|
"name": "App One Updated",
|
||||||
"scopes": []string{"openid", "profile", "email", "tenant"},
|
"scopes": []string{"openid", "profile", "email", "tenant"},
|
||||||
"metadata": map[string]any{
|
"metadata": map[string]any{
|
||||||
"tenant_access_restricted": true,
|
"tenant_access_restricted": true,
|
||||||
@@ -751,15 +759,15 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
|||||||
"value": "ko-KR",
|
"value": "ko-KR",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_jwks_uri": "https://rp.example.com/jwks.json",
|
"headless_jwks_uri": "https://rp.example.com/jwks.json",
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
"backchannel_logout_uri": "https://rp.example.com/logout",
|
"backchannel_logout_uri": "https://rp.example.com/logout",
|
||||||
"backchannel_logout_session_required": true,
|
"backchannel_logout_session_required": true,
|
||||||
},
|
},
|
||||||
"tokenEndpointAuthMethod": "private_key_jwt",
|
"tokenEndpointAuthMethod": "private_key_jwt",
|
||||||
"jwksUri": "https://rp.example.com/jwks.json",
|
"jwksUri": "https://rp.example.com/jwks.json",
|
||||||
"backchannelLogoutUri": "https://rp.example.com/logout",
|
"backchannelLogoutUri": "https://rp.example.com/logout",
|
||||||
"backchannelLogoutSessionRequired": true,
|
"backchannelLogoutSessionRequired": true,
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body))
|
||||||
|
|||||||
@@ -1768,6 +1768,11 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), id); err != nil {
|
||||||
|
results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
err = h.KratosAdmin.DeleteIdentity(c.Context(), id)
|
err = h.KratosAdmin.DeleteIdentity(c.Context(), id)
|
||||||
if err != nil {
|
if 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()})
|
||||||
@@ -2222,6 +2227,10 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), userID); err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
@@ -2255,6 +2264,102 @@ 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 {
|
||||||
|
if h.KetoService == nil || h.KetoOutboxRepo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := "User:" + strings.TrimSpace(userID)
|
||||||
|
tuples, err := h.listDeletedUserRelyingPartyRelations(ctx, subject)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list relying party relations for user %s: %w", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tuples) == 0 {
|
||||||
|
slog.Info("[UserHandler] No relying party relations found for deleted user cleanup", "userID", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(tuples))
|
||||||
|
for _, tuple := range tuples {
|
||||||
|
if strings.TrimSpace(tuple.Object) == "" || strings.TrimSpace(tuple.Relation) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relSubject := strings.TrimSpace(tuple.SubjectID)
|
||||||
|
if relSubject == "" {
|
||||||
|
relSubject = subject
|
||||||
|
}
|
||||||
|
|
||||||
|
key := tuple.Namespace + "\x00" + tuple.Object + "\x00" + tuple.Relation + "\x00" + relSubject
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
|
||||||
|
namespace := strings.TrimSpace(tuple.Namespace)
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = "RelyingParty"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.KetoService.DeleteRelation(ctx, namespace, tuple.Object, tuple.Relation, relSubject); err != nil {
|
||||||
|
slog.Warn("[UserHandler] Failed to delete RelyingParty relation immediately", "userID", userID, "namespace", namespace, "object", tuple.Object, "relation", tuple.Relation, "subject", relSubject, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: namespace,
|
||||||
|
Object: tuple.Object,
|
||||||
|
Relation: tuple.Relation,
|
||||||
|
Subject: relSubject,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
}); 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) listDeletedUserRelyingPartyRelations(ctx context.Context, subject string) ([]service.RelationTuple, error) {
|
||||||
|
var tuples []service.RelationTuple
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
tuples, err = h.KetoService.ListRelations(ctx, "RelyingParty", "", "", subject)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(tuples) > 0 {
|
||||||
|
return tuples, nil
|
||||||
|
}
|
||||||
|
if attempt == 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbackEntries, err := h.KetoOutboxRepo.ListCurrentBySubject(ctx, "RelyingParty", subject)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(fallbackEntries) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tuples = make([]service.RelationTuple, 0, len(fallbackEntries))
|
||||||
|
for _, entry := range fallbackEntries {
|
||||||
|
tuples = append(tuples, service.RelationTuple{
|
||||||
|
Namespace: entry.Namespace,
|
||||||
|
Object: entry.Object,
|
||||||
|
Relation: entry.Relation,
|
||||||
|
SubjectID: entry.Subject,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Warn("[UserHandler] Falling back to keto_outbox history for deleted user RP cleanup", "subject", subject, "tuples", len(tuples))
|
||||||
|
return tuples, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
|
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
|
||||||
traits := identity.Traits
|
traits := identity.Traits
|
||||||
role := roleFromTraits(traits)
|
role := roleFromTraits(traits)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mocks ---
|
// --- Mocks ---
|
||||||
@@ -98,6 +99,75 @@ func (m *MockOryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) {
|
|||||||
return args.Get(0).(*domain.PasswordPolicy), args.Error(1)
|
return args.Get(0).(*domain.PasswordPolicy), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type userHandlerMockKetoService struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
|
||||||
|
args := m.Called(ctx, subject, namespace, object, relation)
|
||||||
|
return args.Bool(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
|
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
||||||
|
return m.Called(ctx, namespace, object, relation, subject).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
|
||||||
|
args := m.Called(ctx, namespace, object, relation, subject)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).([]service.RelationTuple), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
|
||||||
|
args := m.Called(ctx, namespace, relation, subject)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).([]string), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
type userHandlerMockKetoOutboxRepository struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error {
|
||||||
|
return m.Called(ctx, entry).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error {
|
||||||
|
return m.Called(tx, entry).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) {
|
||||||
|
args := m.Called(ctx, limit)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) {
|
||||||
|
args := m.Called(ctx, namespace, subject)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
||||||
|
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *userHandlerMockKetoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
|
||||||
|
return m.Called(ctx, id).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
type fakeUserHandlerWorksmobileSyncer struct {
|
type fakeUserHandlerWorksmobileSyncer struct {
|
||||||
upserts []domain.User
|
upserts []domain.User
|
||||||
}
|
}
|
||||||
@@ -1083,13 +1153,35 @@ func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) {
|
|||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
mockKratos := new(MockKratosAdmin)
|
||||||
userRepo := new(MockUserRepoForHandler)
|
userRepo := new(MockUserRepoForHandler)
|
||||||
h := &UserHandler{KratosAdmin: mockKratos, UserRepo: userRepo}
|
mockKeto := new(userHandlerMockKetoService)
|
||||||
|
mockOutbox := new(userHandlerMockKetoOutboxRepository)
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
UserRepo: userRepo,
|
||||||
|
KetoService: mockKeto,
|
||||||
|
KetoOutboxRepo: mockOutbox,
|
||||||
|
}
|
||||||
|
|
||||||
app.Delete("/users/:id", func(c *fiber.Ctx) error {
|
app.Delete("/users/:id", func(c *fiber.Ctx) error {
|
||||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||||
return h.DeleteUser(c)
|
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"},
|
||||||
|
{Namespace: "RelyingParty", Object: "client-2", Relation: "audit_viewer", SubjectID: "User:u-1"},
|
||||||
|
}, nil).Once()
|
||||||
|
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Once()
|
||||||
|
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-2", "audit_viewer", "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 == "RelyingParty" && entry.Object == "client-2" && entry.Relation == "audit_viewer" && 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()
|
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
|
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
|
||||||
@@ -1098,6 +1190,107 @@ func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||||
assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs)
|
assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs)
|
||||||
mockKratos.AssertExpectations(t)
|
mockKratos.AssertExpectations(t)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
mockOutbox.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_BulkDeleteUsers_CleansUpRelyingPartyRelations(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockKeto := new(userHandlerMockKetoService)
|
||||||
|
mockOutbox := new(userHandlerMockKetoOutboxRepository)
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
KetoService: mockKeto,
|
||||||
|
KetoOutboxRepo: mockOutbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Delete("/users/bulk", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
|
||||||
|
return h.BulkDeleteUsers(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{ID: "u-1"}, nil).Once()
|
||||||
|
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()
|
||||||
|
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"userIds": []string{"u-1"},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/users/bulk", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
mockKratos.AssertExpectations(t)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
mockOutbox.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_DeleteUserFallsBackToKetoOutboxWhenLiveRelationsAreEmpty(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
userRepo := new(MockUserRepoForHandler)
|
||||||
|
mockKeto := new(userHandlerMockKetoService)
|
||||||
|
mockOutbox := new(userHandlerMockKetoOutboxRepository)
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
UserRepo: userRepo,
|
||||||
|
KetoService: mockKeto,
|
||||||
|
KetoOutboxRepo: mockOutbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
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{}, nil).Times(3)
|
||||||
|
mockOutbox.On("ListCurrentBySubject", mock.Anything, "RelyingParty", "User:u-1").Return([]domain.KetoOutbox{
|
||||||
|
{
|
||||||
|
Namespace: "RelyingParty",
|
||||||
|
Object: "client-1",
|
||||||
|
Relation: "admins",
|
||||||
|
Subject: "User:u-1",
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Namespace: "RelyingParty",
|
||||||
|
Object: "client-2",
|
||||||
|
Relation: "config_editor",
|
||||||
|
Subject: "User:u-1",
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-1", "admins", "User:u-1").Return(nil).Once()
|
||||||
|
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", "client-2", "config_editor", "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 == "RelyingParty" && entry.Object == "client-2" && entry.Relation == "config_editor" && 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)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||||
|
assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs)
|
||||||
|
mockKratos.AssertExpectations(t)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
mockOutbox.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
|
func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type KetoOutboxRepository interface {
|
|||||||
Create(ctx context.Context, entry *domain.KetoOutbox) error
|
Create(ctx context.Context, entry *domain.KetoOutbox) error
|
||||||
CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error
|
CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error
|
||||||
FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error)
|
FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error)
|
||||||
|
ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error)
|
||||||
UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error
|
UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error
|
||||||
MarkProcessed(ctx context.Context, id string) error
|
MarkProcessed(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,32 @@ func (r *ketoOutboxRepository) FindPending(ctx context.Context, limit int) ([]do
|
|||||||
return entries, err
|
return entries, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ketoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) {
|
||||||
|
var entries []domain.KetoOutbox
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Where("namespace = ? AND subject = ? AND status <> ?", namespace, subject, domain.KetoOutboxStatusFailed).
|
||||||
|
Order("created_at desc").
|
||||||
|
Order("updated_at desc").
|
||||||
|
Find(&entries).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
current := make([]domain.KetoOutbox, 0, len(entries))
|
||||||
|
seen := make(map[string]struct{}, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
key := entry.Namespace + "\x00" + entry.Object + "\x00" + entry.Relation + "\x00" + entry.Subject
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
if entry.Action == domain.KetoOutboxActionCreate {
|
||||||
|
current = append(current, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
||||||
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|||||||
68
backend/internal/repository/keto_outbox_repository_test.go
Normal file
68
backend/internal/repository/keto_outbox_repository_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKetoOutboxRepository_ListCurrentBySubject(t *testing.T) {
|
||||||
|
repo := NewKetoOutboxRepository(testDB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
require.NoError(t, testDB.Exec("DELETE FROM keto_outbox").Error)
|
||||||
|
|
||||||
|
entries := []domain.KetoOutbox{
|
||||||
|
{
|
||||||
|
Namespace: "RelyingParty",
|
||||||
|
Object: "client-1",
|
||||||
|
Relation: "admins",
|
||||||
|
Subject: "User:user-1",
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
Status: domain.KetoOutboxStatusProcessed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Namespace: "RelyingParty",
|
||||||
|
Object: "client-1",
|
||||||
|
Relation: "admins",
|
||||||
|
Subject: "User:user-1",
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
Status: domain.KetoOutboxStatusProcessed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Namespace: "RelyingParty",
|
||||||
|
Object: "client-2",
|
||||||
|
Relation: "config_editor",
|
||||||
|
Subject: "User:user-1",
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
Status: domain.KetoOutboxStatusProcessed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Namespace: "RelyingParty",
|
||||||
|
Object: "client-3",
|
||||||
|
Relation: "audit_viewer",
|
||||||
|
Subject: "User:user-1",
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
Status: domain.KetoOutboxStatusFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: "tenant-1",
|
||||||
|
Relation: "members",
|
||||||
|
Subject: "User:user-1",
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
Status: domain.KetoOutboxStatusProcessed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
require.NoError(t, repo.Create(ctx, &entries[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := repo.ListCurrentBySubject(ctx, "RelyingParty", "User:user-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, current, 1)
|
||||||
|
require.Equal(t, "client-2", current[0].Object)
|
||||||
|
require.Equal(t, "config_editor", current[0].Relation)
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate
|
// Auto-migrate
|
||||||
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{})
|
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}, &domain.KetoOutbox{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to migrate database: %s", err)
|
log.Fatalf("failed to migrate database: %s", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ func (m *MockKetoOutboxRepositoryShared) FindPending(ctx context.Context, limit
|
|||||||
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockKetoOutboxRepositoryShared) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) {
|
||||||
|
args := m.Called(ctx, namespace, subject)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockKetoOutboxRepositoryShared) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
func (m *MockKetoOutboxRepositoryShared) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
|
||||||
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
|
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user