diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0913f986..8e5760e8 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -382,7 +382,7 @@ func main() { devHandler.AuditRepo = auditRepo devHandler.RPUserMetadataRepo = rpUserMetadataRepo devHandler.RPUsageQueries = rpUsageQueryRepo - tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService) + tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService, hydraService, consentRepo) userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 98c8c956..2263cef6 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -662,26 +662,7 @@ func tenantAccessPolicyChanged(before, after map[string]any) bool { } func (h *DevHandler) revokeClientConsentsForPolicyChange(ctx context.Context, clientID string) error { - if h.ConsentRepo == nil || h.Hydra == nil { - return nil - } - - subjects, err := h.ConsentRepo.ListSubjectsByClient(ctx, clientID) - if err != nil { - return err - } - - for _, subject := range subjects { - subject = strings.TrimSpace(subject) - if subject == "" { - continue - } - if err := h.Hydra.RevokeConsentSessions(ctx, subject, clientID); err != nil { - return err - } - } - - return h.ConsentRepo.DeleteByClient(ctx, clientID) + return revokeClientConsentsForPolicyChange(ctx, h.Hydra, h.ConsentRepo, clientID) } func isProtectedSystemClient(client domain.HydraClient) bool { diff --git a/backend/internal/handler/tenant_access_cleanup.go b/backend/internal/handler/tenant_access_cleanup.go new file mode 100644 index 00000000..1e9eb1be --- /dev/null +++ b/backend/internal/handler/tenant_access_cleanup.go @@ -0,0 +1,154 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "baron-sso-backend/internal/service" + "context" + "fmt" + "log/slog" + "maps" + "strings" +) + +const tenantAccessCleanupClientPageSize = 500 + +func cleanupDeletedTenantReferences(ctx context.Context, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository, ketoOutbox repository.KetoOutboxRepository, deletedTenantIDs []string) error { + if hydra == nil { + return nil + } + + deletedTenantSet := make(map[string]struct{}, len(deletedTenantIDs)) + for _, tenantID := range deletedTenantIDs { + tenantID = strings.TrimSpace(tenantID) + if tenantID == "" { + continue + } + deletedTenantSet[tenantID] = struct{}{} + } + if len(deletedTenantSet) == 0 { + return nil + } + + for offset := 0; ; offset += tenantAccessCleanupClientPageSize { + clients, err := hydra.ListClients(ctx, tenantAccessCleanupClientPageSize, offset) + if err != nil { + return fmt.Errorf("failed to list hydra clients for tenant cleanup: %w", err) + } + + for _, client := range clients { + beforeMetadata := maps.Clone(client.Metadata) + updatedMetadata, changed, removedOwnerTenantID := pruneDeletedTenantReferences(beforeMetadata, deletedTenantSet) + if !changed { + continue + } + + updatedClient := client + updatedClient.Metadata = updatedMetadata + if _, err := hydra.UpdateClient(ctx, client.ClientID, updatedClient); err != nil { + return fmt.Errorf("failed to update hydra client %s during tenant cleanup: %w", client.ClientID, err) + } + if removedOwnerTenantID != "" { + if err := enqueueDeletedTenantRelyingPartyParentCleanup(ctx, ketoOutbox, client.ClientID, removedOwnerTenantID); err != nil { + return fmt.Errorf("failed to cleanup RP parent relation for client %s during tenant cleanup: %w", client.ClientID, err) + } + } + + if tenantAccessPolicyChanged(beforeMetadata, updatedMetadata) { + if err := revokeClientConsentsForPolicyChange(ctx, hydra, consentRepo, client.ClientID); err != nil { + return fmt.Errorf("failed to revoke consent sessions for client %s during tenant cleanup: %w", client.ClientID, err) + } + } + } + + if len(clients) < tenantAccessCleanupClientPageSize { + return nil + } + } +} + +func pruneDeletedTenantReferences(metadata map[string]any, deletedTenantSet map[string]struct{}) (map[string]any, bool, string) { + if len(deletedTenantSet) == 0 { + return metadata, false, "" + } + + ownerTenantID := normalizeMetadataString(metadata["tenant_id"]) + _, ownerDeleted := deletedTenantSet[ownerTenantID] + + allowedTenants := normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey]) + filtered := make([]string, 0, len(allowedTenants)) + for _, tenantID := range allowedTenants { + if _, ok := deletedTenantSet[tenantID]; ok { + continue + } + filtered = append(filtered, tenantID) + } + allowedChanged := len(filtered) != len(allowedTenants) + + if !ownerDeleted && !allowedChanged { + return metadata, false, "" + } + + updated := maps.Clone(metadata) + if ownerDeleted { + delete(updated, "tenant_id") + } + + if len(filtered) == 0 { + delete(updated, clientAllowedTenantsKey) + updated[clientTenantAccessRestrictedKey] = false + return updated, true, ownerTenantID + } + + updated[clientAllowedTenantsKey] = uniqueSortedStrings(filtered) + updated[clientTenantAccessRestrictedKey] = true + return updated, true, ownerTenantID +} + +func enqueueDeletedTenantRelyingPartyParentCleanup(ctx context.Context, ketoOutbox repository.KetoOutboxRepository, clientID, tenantID string) error { + if ketoOutbox == nil { + return nil + } + clientID = strings.TrimSpace(clientID) + tenantID = strings.TrimSpace(tenantID) + if clientID == "" || tenantID == "" { + return nil + } + return ketoOutbox.Create(ctx, &domain.KetoOutbox{ + Namespace: "RelyingParty", + Object: clientID, + Relation: "parents", + Subject: "Tenant:" + tenantID, + Action: domain.KetoOutboxActionDelete, + }) +} + +func revokeClientConsentsForPolicyChange(ctx context.Context, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository, clientID string) error { + if consentRepo == nil || hydra == nil { + return nil + } + + subjects, err := consentRepo.ListSubjectsByClient(ctx, clientID) + if err != nil { + return err + } + + for _, subject := range subjects { + subject = strings.TrimSpace(subject) + if subject == "" { + continue + } + if err := hydra.RevokeConsentSessions(ctx, subject, clientID); err != nil { + return err + } + } + + return consentRepo.DeleteByClient(ctx, clientID) +} + +func logTenantCleanupFailure(err error, deletedTenantIDs []string) { + if err == nil { + return + } + slog.Error("Failed to cleanup RP tenant restrictions after tenant deletion", "tenant_ids", deletedTenantIDs, "error", err) +} diff --git a/backend/internal/handler/tenant_access_cleanup_test.go b/backend/internal/handler/tenant_access_cleanup_test.go new file mode 100644 index 00000000..a7ce1879 --- /dev/null +++ b/backend/internal/handler/tenant_access_cleanup_test.go @@ -0,0 +1,178 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "baron-sso-backend/internal/service" + "context" + "encoding/json" + "net/http" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestPruneDeletedTenantReferences_PreservesOtherAllowedTenants(t *testing.T) { + metadata := map[string]any{ + clientTenantAccessRestrictedKey: true, + clientAllowedTenantsKey: []string{"keep-tenant", "deleted-tenant"}, + "tenant_id": "deleted-tenant", + } + + updated, changed, removedOwnerTenantID := pruneDeletedTenantReferences(metadata, map[string]struct{}{ + "deleted-tenant": {}, + }) + + require.True(t, changed) + assert.Equal(t, "deleted-tenant", removedOwnerTenantID) + assert.Equal(t, true, updated[clientTenantAccessRestrictedKey]) + assert.Equal(t, []string{"keep-tenant"}, updated[clientAllowedTenantsKey]) + _, exists := updated["tenant_id"] + assert.False(t, exists) +} + +func TestPruneDeletedTenantReferences_DisablesRestrictionWhenLastTenantRemoved(t *testing.T) { + metadata := map[string]any{ + clientTenantAccessRestrictedKey: true, + clientAllowedTenantsKey: []string{"deleted-tenant"}, + "tenant_id": "deleted-tenant", + } + + updated, changed, removedOwnerTenantID := pruneDeletedTenantReferences(metadata, map[string]struct{}{ + "deleted-tenant": {}, + }) + + require.True(t, changed) + assert.Equal(t, "deleted-tenant", removedOwnerTenantID) + assert.Equal(t, false, updated[clientTenantAccessRestrictedKey]) + _, exists := updated[clientAllowedTenantsKey] + assert.False(t, exists) + _, exists = updated["tenant_id"] + assert.False(t, exists) +} + +func TestCleanupDeletedTenantReferences_PrunesClientsAndRevokesConsents(t *testing.T) { + var ( + mu sync.Mutex + page0Called bool + updated = map[string]map[string]any{} + revokes []string + ) + + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + mu.Lock() + defer mu.Unlock() + + switch { + case req.Method == http.MethodGet && req.URL.Path == "/clients": + switch req.URL.Query().Get("offset") { + case "": + page0Called = true + return httpJSONAny(req, http.StatusOK, []domain.HydraClient{ + { + ClientID: "client-keep", + Metadata: map[string]any{ + clientTenantAccessRestrictedKey: true, + clientAllowedTenantsKey: []string{"keep-tenant", "deleted-tenant"}, + "tenant_id": "deleted-tenant", + }, + }, + { + ClientID: "client-drop", + Metadata: map[string]any{ + clientTenantAccessRestrictedKey: true, + clientAllowedTenantsKey: []string{"deleted-tenant"}, + "tenant_id": "deleted-tenant", + }, + }, + }), nil + default: + return httpResponse(req, http.StatusBadRequest, "unexpected offset"), nil + } + + case req.Method == http.MethodPut && strings.HasPrefix(req.URL.Path, "/clients/"): + var client domain.HydraClient + require.NoError(t, json.NewDecoder(req.Body).Decode(&client)) + updated[client.ClientID] = client.Metadata + return httpJSONAny(req, http.StatusOK, client), nil + + case req.Method == http.MethodDelete && req.URL.Path == "/oauth2/auth/sessions/consent": + revokes = append(revokes, req.URL.Query().Get("subject")+"|"+req.URL.Query().Get("client")) + return httpResponse(req, http.StatusNoContent, ""), nil + + default: + return httpResponse(req, http.StatusNotFound, "unexpected request"), nil + } + }) + + hydra := &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + } + consentRepo := &mockConsentRepo{ + consents: []domain.ClientConsent{ + {ClientID: "client-keep", Subject: "user-a"}, + {ClientID: "client-drop", Subject: "user-b"}, + }, + } + outbox := &tenantCleanupMockKetoOutboxRepository{} + + err := cleanupDeletedTenantReferences(context.Background(), hydra, consentRepo, outbox, []string{"deleted-tenant"}) + require.NoError(t, err) + assert.True(t, page0Called) + assert.Equal(t, map[string]any{ + clientTenantAccessRestrictedKey: true, + clientAllowedTenantsKey: []any{"keep-tenant"}, + }, updated["client-keep"]) + assert.Equal(t, map[string]any{ + clientTenantAccessRestrictedKey: false, + }, updated["client-drop"]) + assert.ElementsMatch(t, []string{"user-a|client-keep", "user-b|client-drop"}, revokes) + assert.Empty(t, consentRepo.consents) + require.Len(t, outbox.entries, 2) + assert.ElementsMatch(t, []string{"client-keep", "client-drop"}, []string{outbox.entries[0].Object, outbox.entries[1].Object}) + for _, entry := range outbox.entries { + assert.Equal(t, "RelyingParty", entry.Namespace) + assert.Equal(t, "parents", entry.Relation) + assert.Equal(t, "Tenant:deleted-tenant", entry.Subject) + assert.Equal(t, domain.KetoOutboxActionDelete, entry.Action) + } +} + +type tenantCleanupMockKetoOutboxRepository struct { + entries []domain.KetoOutbox +} + +var _ repository.KetoOutboxRepository = (*tenantCleanupMockKetoOutboxRepository)(nil) + +func (m *tenantCleanupMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error { + if entry == nil { + return nil + } + m.entries = append(m.entries, *entry) + return nil +} + +func (m *tenantCleanupMockKetoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error { + return m.Create(context.Background(), entry) +} + +func (m *tenantCleanupMockKetoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) { + return nil, nil +} + +func (m *tenantCleanupMockKetoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespace, subject string) ([]domain.KetoOutbox, error) { + return nil, nil +} + +func (m *tenantCleanupMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error { + return nil +} + +func (m *tenantCleanupMockKetoOutboxRepository) MarkProcessed(ctx context.Context, id string) error { + return nil +} diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 3082ba5b..9bb66e66 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -32,6 +32,8 @@ type TenantHandler struct { KratosAdmin service.KratosAdminService SharedLink service.SharedLinkService Worksmobile service.WorksmobileSyncer + Hydra *service.HydraAdminService + ConsentRepo repository.ClientConsentRepository } func seedTenantDeleteError(c *fiber.Ctx) error { @@ -51,7 +53,7 @@ func seedTenantSlugsForDeleteGuard() []string { return result } -func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, userProjectionRepo repository.UserProjectionRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService) *TenantHandler { +func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, userProjectionRepo repository.UserProjectionRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler { return &TenantHandler{ DB: db, Service: svc, @@ -61,6 +63,8 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repositor KetoOutbox: outbox, KratosAdmin: kratos, SharedLink: sharedLink, + Hydra: hydra, + ConsentRepo: consentRepo, } } @@ -1855,6 +1859,11 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { return seedTenantDeleteError(c) } + if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, []string{tenantID}); err != nil { + logTenantCleanupFailure(err, []string{tenantID}) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + // Rename slug to release it for reuse before soft delete deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405") if err := h.DB.Model(&tenant).Update("slug", deletedSlug).Error; err != nil { @@ -2183,6 +2192,11 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error { } } + if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, req.IDs); err != nil { + logTenantCleanupFailure(err, req.IDs) + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) }