forked from baron/baron-sso
Merge branch 'dev' into feature/rbac-simplification-and-remove-dev-switcher
This commit is contained in:
@@ -382,7 +382,7 @@ func main() {
|
|||||||
devHandler.AuditRepo = auditRepo
|
devHandler.AuditRepo = auditRepo
|
||||||
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||||
devHandler.RPUsageQueries = rpUsageQueryRepo
|
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)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
|
||||||
|
|||||||
@@ -662,26 +662,7 @@ func tenantAccessPolicyChanged(before, after map[string]any) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DevHandler) revokeClientConsentsForPolicyChange(ctx context.Context, clientID string) error {
|
func (h *DevHandler) revokeClientConsentsForPolicyChange(ctx context.Context, clientID string) error {
|
||||||
if h.ConsentRepo == nil || h.Hydra == nil {
|
return revokeClientConsentsForPolicyChange(ctx, h.Hydra, h.ConsentRepo, clientID)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isProtectedSystemClient(client domain.HydraClient) bool {
|
func isProtectedSystemClient(client domain.HydraClient) bool {
|
||||||
|
|||||||
154
backend/internal/handler/tenant_access_cleanup.go
Normal file
154
backend/internal/handler/tenant_access_cleanup.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
178
backend/internal/handler/tenant_access_cleanup_test.go
Normal file
178
backend/internal/handler/tenant_access_cleanup_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ type TenantHandler struct {
|
|||||||
KratosAdmin service.KratosAdminService
|
KratosAdmin service.KratosAdminService
|
||||||
SharedLink service.SharedLinkService
|
SharedLink service.SharedLinkService
|
||||||
Worksmobile service.WorksmobileSyncer
|
Worksmobile service.WorksmobileSyncer
|
||||||
|
Hydra *service.HydraAdminService
|
||||||
|
ConsentRepo repository.ClientConsentRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedTenantDeleteError(c *fiber.Ctx) error {
|
func seedTenantDeleteError(c *fiber.Ctx) error {
|
||||||
@@ -52,7 +54,7 @@ func seedTenantSlugsForDeleteGuard() []string {
|
|||||||
return result
|
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{
|
return &TenantHandler{
|
||||||
DB: db,
|
DB: db,
|
||||||
Service: svc,
|
Service: svc,
|
||||||
@@ -62,6 +64,8 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repositor
|
|||||||
KetoOutbox: outbox,
|
KetoOutbox: outbox,
|
||||||
KratosAdmin: kratos,
|
KratosAdmin: kratos,
|
||||||
SharedLink: sharedLink,
|
SharedLink: sharedLink,
|
||||||
|
Hydra: hydra,
|
||||||
|
ConsentRepo: consentRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1955,6 +1959,11 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
|
|||||||
return seedTenantDeleteError(c)
|
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
|
// Rename slug to release it for reuse before soft delete
|
||||||
deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405")
|
deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405")
|
||||||
if err := h.DB.Model(&tenant).Update("slug", deletedSlug).Error; err != nil {
|
if err := h.DB.Model(&tenant).Update("slug", deletedSlug).Error; err != nil {
|
||||||
@@ -2283,6 +2292,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 {
|
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -516,8 +516,13 @@ class AuthProxyService {
|
|||||||
return jsonDecode(response.body);
|
return jsonDecode(response.body);
|
||||||
} else {
|
} else {
|
||||||
final errorBody = jsonDecode(response.body);
|
final errorBody = jsonDecode(response.body);
|
||||||
throw Exception(
|
final rawDetails = errorBody['details'];
|
||||||
errorBody['error'] ?? tr('err.userfront.auth_proxy.oidc_accept'),
|
throw AuthProxyException(
|
||||||
|
errorCode: (errorBody['code'] ?? '').toString(),
|
||||||
|
message:
|
||||||
|
(errorBody['error'] ?? tr('err.userfront.auth_proxy.oidc_accept'))
|
||||||
|
.toString(),
|
||||||
|
details: rawDetails is Map<String, dynamic> ? rawDetails : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||||
|
|
||||||
bool shouldRouteConsentErrorToErrorScreen(Object error) {
|
bool shouldRouteTenantAccessErrorToErrorScreen(Object error) {
|
||||||
return error is AuthProxyException && error.errorCode == 'tenant_not_allowed';
|
return error is AuthProxyException && error.errorCode == 'tenant_not_allowed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool shouldRouteConsentErrorToErrorScreen(Object error) {
|
||||||
|
return shouldRouteTenantAccessErrorToErrorScreen(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
String buildTenantAccessErrorPath(Object error, Uri baseUri) {
|
||||||
|
final authError = error as AuthProxyException;
|
||||||
|
final localeCode =
|
||||||
|
extractLocaleFromPath(baseUri) ?? resolvePreferredLocaleCode();
|
||||||
|
return buildLocalizedPath(
|
||||||
|
localeCode,
|
||||||
|
Uri(
|
||||||
|
path: '/error',
|
||||||
|
queryParameters: {
|
||||||
|
'error': authError.errorCode,
|
||||||
|
'error_description': authError.message,
|
||||||
|
if (authError.details != null) 'details': jsonEncode(authError.details),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
@@ -153,19 +151,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final localeCode =
|
final target = buildTenantAccessErrorPath(e, Uri.base);
|
||||||
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
|
|
||||||
final target = buildLocalizedPath(
|
|
||||||
localeCode,
|
|
||||||
Uri(
|
|
||||||
path: '/error',
|
|
||||||
queryParameters: {
|
|
||||||
'error': e.errorCode,
|
|
||||||
'error_description': e.message,
|
|
||||||
if (e.details != null) 'details': jsonEncode(e.details),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
context.go(target);
|
context.go(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../../../core/services/oidc_redirect_guard.dart';
|
|||||||
import '../../../core/notifiers/auth_notifier.dart';
|
import '../../../core/notifiers/auth_notifier.dart';
|
||||||
import '../domain/login_challenge_resolver.dart';
|
import '../domain/login_challenge_resolver.dart';
|
||||||
import '../domain/cookie_session_policy.dart';
|
import '../domain/cookie_session_policy.dart';
|
||||||
|
import '../domain/consent_error_routing.dart';
|
||||||
import '../domain/login_link_route_policy.dart';
|
import '../domain/login_link_route_policy.dart';
|
||||||
import '../domain/verification_completion_route.dart';
|
import '../domain/verification_completion_route.dart';
|
||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
@@ -1666,6 +1667,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return;
|
return;
|
||||||
} else {}
|
} else {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e is AuthProxyException &&
|
||||||
|
shouldRouteTenantAccessErrorToErrorScreen(e)) {
|
||||||
|
final target = buildTenantAccessErrorPath(e, Uri.base);
|
||||||
|
if (mounted) {
|
||||||
|
context.go(target);
|
||||||
|
} else {
|
||||||
|
webWindow.redirectTo(target);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
_showError(tr('msg.userfront.login.oidc_failed'));
|
_showError(tr('msg.userfront.login.oidc_failed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,37 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'acceptOidcLogin error는 code/message/details를 AuthProxyException으로 보존한다',
|
||||||
|
() async {
|
||||||
|
client.enqueueJson({
|
||||||
|
'code': 'tenant_not_allowed',
|
||||||
|
'error': 'tenant blocked',
|
||||||
|
'details': {
|
||||||
|
'allowed_tenants': ['gp'],
|
||||||
|
},
|
||||||
|
}, statusCode: 403);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
AuthProxyService.acceptOidcLogin('login-challenge', token: 'jwt'),
|
||||||
|
throwsA(
|
||||||
|
isA<AuthProxyException>()
|
||||||
|
.having(
|
||||||
|
(error) => error.errorCode,
|
||||||
|
'code',
|
||||||
|
'tenant_not_allowed',
|
||||||
|
)
|
||||||
|
.having((error) => error.message, 'message', 'tenant blocked')
|
||||||
|
.having(
|
||||||
|
(error) => error.details?['allowed_tenants'],
|
||||||
|
'details',
|
||||||
|
['gp'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'approveQrLogin은 credential mode와 bearer token payload를 지원한다',
|
'approveQrLogin은 credential mode와 bearer token payload를 지원한다',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -20,4 +20,33 @@ void main() {
|
|||||||
|
|
||||||
expect(shouldRouteConsentErrorToErrorScreen(error), isFalse);
|
expect(shouldRouteConsentErrorToErrorScreen(error), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tenant_not_allowed auth error also routes to error screen', () {
|
||||||
|
const error = AuthProxyException(
|
||||||
|
errorCode: 'tenant_not_allowed',
|
||||||
|
message: '허용되지 않은 테넌트입니다.',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shouldRouteTenantAccessErrorToErrorScreen(error), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildTenantAccessErrorPath builds userfront error route', () {
|
||||||
|
const error = AuthProxyException(
|
||||||
|
errorCode: 'tenant_not_allowed',
|
||||||
|
message: '허용되지 않은 테넌트입니다.',
|
||||||
|
details: {
|
||||||
|
'allowed_tenants': ['tenant-a'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final target = buildTenantAccessErrorPath(
|
||||||
|
error,
|
||||||
|
Uri.parse('https://sso-test.hmac.kr/ko?login_challenge=abc'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target, contains('/error?'));
|
||||||
|
expect(target, contains('error=tenant_not_allowed'));
|
||||||
|
expect(target, contains('error_description='));
|
||||||
|
expect(target, contains('details='));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user