diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 11faa6e6..03a7cb4d 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -155,6 +155,14 @@ func (m *devMockKetoOutboxRepository) FindPending(ctx context.Context, limit int 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 { return m.Called(ctx, id, status, retryCount, lastError).Error(0) } @@ -678,13 +686,13 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) { "scope": "openid profile email", "token_endpoint_auth_method": "client_secret_basic", "metadata": map[string]any{ - "status": "active", - "tenant_id": "tenant-1", - "tenant_access_restricted": false, - "allowed_tenants": []any{}, - "id_token_claims": []any{}, - "headless_login_enabled": false, - "headless_jwks_uri": "", + "status": "active", + "tenant_id": "tenant-1", + "tenant_access_restricted": false, + "allowed_tenants": []any{}, + "id_token_claims": []any{}, + "headless_login_enabled": false, + "headless_jwks_uri": "", "headless_token_endpoint_auth_method": "", }, }), nil @@ -701,13 +709,13 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) { "scope": "openid profile email tenant", "token_endpoint_auth_method": "private_key_jwt", "metadata": map[string]any{ - "status": "active", - "tenant_id": "tenant-1", - "tenant_access_restricted": true, - "allowed_tenants": []any{"tenant-1", "tenant-2"}, - "id_token_claims": []any{map[string]any{"namespace": "top_level", "key": "locale", "valueType": "text", "value": "ko-KR"}}, - "headless_login_enabled": true, - "headless_jwks_uri": "https://rp.example.com/jwks.json", + "status": "active", + "tenant_id": "tenant-1", + "tenant_access_restricted": true, + "allowed_tenants": []any{"tenant-1", "tenant-2"}, + "id_token_claims": []any{map[string]any{"namespace": "top_level", "key": "locale", "valueType": "text", "value": "ko-KR"}}, + "headless_login_enabled": true, + "headless_jwks_uri": "https://rp.example.com/jwks.json", "headless_token_endpoint_auth_method": "private_key_jwt", }, }), nil @@ -738,7 +746,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) { app.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ - "name": "App One Updated", + "name": "App One Updated", "scopes": []string{"openid", "profile", "email", "tenant"}, "metadata": map[string]any{ "tenant_access_restricted": true, @@ -751,15 +759,15 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) { "value": "ko-KR", }, }, - "headless_login_enabled": true, - "headless_jwks_uri": "https://rp.example.com/jwks.json", - "headless_token_endpoint_auth_method": "private_key_jwt", - "backchannel_logout_uri": "https://rp.example.com/logout", - "backchannel_logout_session_required": true, + "headless_login_enabled": true, + "headless_jwks_uri": "https://rp.example.com/jwks.json", + "headless_token_endpoint_auth_method": "private_key_jwt", + "backchannel_logout_uri": "https://rp.example.com/logout", + "backchannel_logout_session_required": true, }, - "tokenEndpointAuthMethod": "private_key_jwt", - "jwksUri": "https://rp.example.com/jwks.json", - "backchannelLogoutUri": "https://rp.example.com/logout", + "tokenEndpointAuthMethod": "private_key_jwt", + "jwksUri": "https://rp.example.com/jwks.json", + "backchannelLogoutUri": "https://rp.example.com/logout", "backchannelLogoutSessionRequired": true, }) req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index a2ebbc9c..6ade571a 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -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) if err != nil { 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 { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } @@ -2255,6 +2264,102 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { 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 { traits := identity.Traits role := roleFromTraits(traits) diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 12a86e51..f1ee6229 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) // --- Mocks --- @@ -98,6 +99,75 @@ func (m *MockOryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { 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 { upserts []domain.User } @@ -1083,13 +1153,35 @@ func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin) 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 { 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"}, + {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() 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, []string{"u-1"}, userRepo.deletedIDs) 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) { diff --git a/backend/internal/repository/keto_outbox_repository.go b/backend/internal/repository/keto_outbox_repository.go index 74c5193e..8c4caca9 100644 --- a/backend/internal/repository/keto_outbox_repository.go +++ b/backend/internal/repository/keto_outbox_repository.go @@ -12,6 +12,7 @@ type KetoOutboxRepository interface { Create(ctx context.Context, entry *domain.KetoOutbox) error CreateWithTx(tx *gorm.DB, entry *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 MarkProcessed(ctx context.Context, id string) error } @@ -42,6 +43,32 @@ func (r *ketoOutboxRepository) FindPending(ctx context.Context, limit int) ([]do 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 { return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{ "status": status, diff --git a/backend/internal/repository/keto_outbox_repository_test.go b/backend/internal/repository/keto_outbox_repository_test.go new file mode 100644 index 00000000..1a085f0f --- /dev/null +++ b/backend/internal/repository/keto_outbox_repository_test.go @@ -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) +} diff --git a/backend/internal/repository/main_test.go b/backend/internal/repository/main_test.go index 4d1aa43e..9786f4c2 100644 --- a/backend/internal/repository/main_test.go +++ b/backend/internal/repository/main_test.go @@ -63,7 +63,7 @@ func TestMain(m *testing.M) { } // 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 { log.Fatalf("failed to migrate database: %s", err) } diff --git a/backend/internal/service/mock_common_test.go b/backend/internal/service/mock_common_test.go index d80e6c70..094e23cf 100644 --- a/backend/internal/service/mock_common_test.go +++ b/backend/internal/service/mock_common_test.go @@ -30,6 +30,14 @@ func (m *MockKetoOutboxRepositoryShared) FindPending(ctx context.Context, limit 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 { return m.Called(ctx, id, status, retryCount, lastError).Error(0) }