1
0
forked from baron/baron-sso

삭제된 사용자 RP 관계 정리

This commit is contained in:
2026-05-28 09:50:15 +09:00
parent f8d0cf411a
commit 041b0724be
7 changed files with 434 additions and 25 deletions

View File

@@ -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))

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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,

View 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)
}

View File

@@ -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)
} }

View File

@@ -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)
} }