diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index 04c2805e..8a300bce 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -1,16 +1,19 @@ package handler import ( + "baron-sso-backend/internal/service" "runtime" "time" "github.com/gofiber/fiber/v2" ) -type AdminHandler struct{} +type AdminHandler struct { + Keto service.KetoService +} -func NewAdminHandler() *AdminHandler { - return &AdminHandler{} +func NewAdminHandler(keto service.KetoService) *AdminHandler { + return &AdminHandler{Keto: keto} } func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 6514d741..48db2ac5 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -24,7 +24,7 @@ type DevHandler struct { ConsentRepo repository.ClientConsentRepository } -func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository) *DevHandler { +func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService) *DevHandler { return &DevHandler{ Hydra: service.NewHydraAdminService(), Redis: redis, diff --git a/backend/internal/handler/relying_party_handler.go b/backend/internal/handler/relying_party_handler.go index 29611b23..342d7f5d 100644 --- a/backend/internal/handler/relying_party_handler.go +++ b/backend/internal/handler/relying_party_handler.go @@ -9,11 +9,12 @@ import ( ) type RelyingPartyHandler struct { - Service service.RelyingPartyService + Service service.RelyingPartyService + KratosAdmin *service.KratosAdminService } -func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler { - return &RelyingPartyHandler{Service: s} +func NewRelyingPartyHandler(s service.RelyingPartyService, kratos *service.KratosAdminService) *RelyingPartyHandler { + return &RelyingPartyHandler{Service: s, KratosAdmin: kratos} } func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error { diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 53d858bb..e911909c 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -12,12 +12,19 @@ import ( ) type TenantHandler struct { - DB *gorm.DB - Service service.TenantService + DB *gorm.DB + Service service.TenantService + Keto service.KetoService + KratosAdmin *service.KratosAdminService } -func NewTenantHandler(db *gorm.DB, svc service.TenantService) *TenantHandler { - return &TenantHandler{DB: db, Service: svc} +func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, kratos *service.KratosAdminService) *TenantHandler { + return &TenantHandler{ + DB: db, + Service: svc, + Keto: keto, + KratosAdmin: kratos, + } } type tenantSummary struct { @@ -301,6 +308,85 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } +func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error { + tenantID := c.Params("id") + if tenantID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"}) + } + + // Fetch admins from Keto + relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admin", "") + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + type adminInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } + admins := []adminInfo{} + + for _, rel := range relations { + if !strings.HasPrefix(rel.SubjectID, "User:") { + continue + } + userID := strings.TrimPrefix(rel.SubjectID, "User:") + + // Fetch user details from Kratos + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err != nil { + admins = append(admins, adminInfo{ID: userID, Name: "Unknown", Email: "Unknown"}) + continue + } + + name := "" + if n, ok := identity.Traits["name"].(string); ok { + name = n + } + email := "" + if e, ok := identity.Traits["email"].(string); ok { + email = e + } + + admins = append(admins, adminInfo{ + ID: userID, + Name: name, + Email: email, + }) + } + + return c.JSON(admins) +} + +func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error { + tenantID := c.Params("id") + userID := c.Params("userId") + if tenantID == "" || userID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"}) + } + + if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusOK) +} + +func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error { + tenantID := c.Params("id") + userID := c.Params("userId") + if tenantID == "" || userID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"}) + } + + if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} + func mapTenantSummary(t domain.Tenant) tenantSummary { domains := make([]string, 0, len(t.Domains)) for _, d := range t.Domains { diff --git a/backend/internal/middleware/rbac_test.go b/backend/internal/middleware/rbac_test.go index b4bd837f..db9ff925 100644 --- a/backend/internal/middleware/rbac_test.go +++ b/backend/internal/middleware/rbac_test.go @@ -54,6 +54,14 @@ func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, return args.Get(0).([]service.RelationTuple), args.Error(1) } +func (m *MockKetoService) 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) +} + // Fixed MockKetoService to match service.KetoService exactly if possible. // Wait, middleware/rbac.go imports baron-sso-backend/internal/service. // So I should use service.RelationTuple. diff --git a/userfront/lib/core/i18n/locale_storage.dart b/userfront/lib/core/i18n/locale_storage.dart index e54b144e..6c757c04 100644 --- a/userfront/lib/core/i18n/locale_storage.dart +++ b/userfront/lib/core/i18n/locale_storage.dart @@ -4,4 +4,8 @@ import 'locale_storage_stub.dart' abstract class LocaleStorage { static String? read() => localeStorage.read(); static void write(String locale) => localeStorage.write(locale); + static void forceMemoryStorageForTests(bool value) => + localeStorage.forceMemoryStorageForTests(value); + static void forceSessionStorageForTests(bool value) => + localeStorage.forceSessionStorageForTests(value); } diff --git a/userfront/lib/core/i18n/locale_storage_stub.dart b/userfront/lib/core/i18n/locale_storage_stub.dart index 520cd6a4..7d69d372 100644 --- a/userfront/lib/core/i18n/locale_storage_stub.dart +++ b/userfront/lib/core/i18n/locale_storage_stub.dart @@ -6,6 +6,14 @@ class LocaleStorageImpl { void write(String locale) { _locale = locale; } + + void forceMemoryStorageForTests(bool value) { + // Stub + } + + void forceSessionStorageForTests(bool value) { + // Stub + } } final localeStorage = LocaleStorageImpl(); diff --git a/userfront/lib/core/i18n/locale_storage_web.dart b/userfront/lib/core/i18n/locale_storage_web.dart index 06c36e99..9d0d82c1 100644 --- a/userfront/lib/core/i18n/locale_storage_web.dart +++ b/userfront/lib/core/i18n/locale_storage_web.dart @@ -11,7 +11,7 @@ class LocaleStorageImpl { static bool _forceSession = false; @visibleForTesting - static void forceMemoryStorageForTests(bool value) { + void forceMemoryStorageForTests(bool value) { _forceMemory = value; if (!value) { _memory.clear(); @@ -19,7 +19,7 @@ class LocaleStorageImpl { } @visibleForTesting - static void forceSessionStorageForTests(bool value) { + void forceSessionStorageForTests(bool value) { _forceSession = value; } diff --git a/userfront/test/locale_storage_web_test.dart b/userfront/test/locale_storage_web_test.dart index a43c8de8..a1156264 100644 --- a/userfront/test/locale_storage_web_test.dart +++ b/userfront/test/locale_storage_web_test.dart @@ -1,13 +1,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:userfront/core/i18n/locale_storage.dart'; -import 'package:userfront/core/i18n/locale_storage_web.dart' as locale_web; import 'helpers/web_storage.dart'; void main() { setUp(() { - locale_web.LocaleStorageImpl.forceMemoryStorageForTests(false); - locale_web.LocaleStorageImpl.forceSessionStorageForTests(false); + LocaleStorage.forceMemoryStorageForTests(false); + LocaleStorage.forceSessionStorageForTests(false); if (webStorage.isWeb) { webStorage.clear(); webStorage.clearSession(); @@ -15,8 +14,8 @@ void main() { }); tearDown(() { - locale_web.LocaleStorageImpl.forceMemoryStorageForTests(false); - locale_web.LocaleStorageImpl.forceSessionStorageForTests(false); + LocaleStorage.forceMemoryStorageForTests(false); + LocaleStorage.forceSessionStorageForTests(false); if (webStorage.isWeb) { webStorage.clear(); webStorage.clearSession(); @@ -59,7 +58,7 @@ void main() { return; } - locale_web.LocaleStorageImpl.forceMemoryStorageForTests(true); + LocaleStorage.forceMemoryStorageForTests(true); LocaleStorage.write('en'); expect(webStorage.get('locale'), isNull); @@ -76,7 +75,7 @@ void main() { return; } - locale_web.LocaleStorageImpl.forceSessionStorageForTests(true); + LocaleStorage.forceSessionStorageForTests(true); LocaleStorage.write('ko'); expect(webStorage.get('locale'), isNull);