1
0
forked from baron/baron-sso

Merge pull request 'feature/backend-rp-tenant' (#995) from feature/backend-rp-tenant into dev

Reviewed-on: baron/baron-sso#995
This commit is contained in:
2026-06-04 10:57:10 +09:00
11 changed files with 453 additions and 40 deletions

View File

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

View File

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

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

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

View File

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

View File

@@ -516,8 +516,13 @@ class AuthProxyService {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ?? tr('err.userfront.auth_proxy.oidc_accept'),
final rawDetails = errorBody['details'];
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,
);
}
}

View File

@@ -1,5 +1,29 @@
import 'dart:convert';
import 'package:userfront/core/i18n/locale_utils.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';
}
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),
},
),
);
}

View File

@@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
@@ -153,19 +151,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
if (!mounted) {
return;
}
final localeCode =
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),
},
),
);
final target = buildTenantAccessErrorPath(e, Uri.base);
context.go(target);
return;
}

View File

@@ -17,6 +17,7 @@ import '../../../core/services/oidc_redirect_guard.dart';
import '../../../core/notifiers/auth_notifier.dart';
import '../domain/login_challenge_resolver.dart';
import '../domain/cookie_session_policy.dart';
import '../domain/consent_error_routing.dart';
import '../domain/login_link_route_policy.dart';
import '../domain/verification_completion_route.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
@@ -1666,6 +1667,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
} else {}
} 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'));
return;
}

View File

@@ -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(
'approveQrLogin은 credential mode와 bearer token payload를 지원한다',
() async {

View File

@@ -20,4 +20,33 @@ void main() {
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='));
});
}