From 6ae35e1bd731d8746392c8c550e86885b21c4d6c Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 11 Feb 2026 17:31:34 +0900 Subject: [PATCH] =?UTF-8?q?test:=20ReBAC=20=EA=B6=8C=ED=95=9C=20=EC=83=81?= =?UTF-8?q?=EC=86=8D=20=EB=B0=8F=20=EB=AF=B8=EB=93=A4=EC=9B=A8=EC=96=B4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20#244?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/auth_handler_login_test.go | 4 +- backend/internal/handler/dev_handler_test.go | 100 +++++++++++++- .../internal/handler/tenant_handler_test.go | 20 +++ .../handler/tenant_rebac_handler_test.go | 119 +++++++++++++++++ .../service/tenant_rebac_service_test.go | 125 ++++++++++++++++++ 5 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 backend/internal/handler/tenant_rebac_handler_test.go create mode 100644 backend/internal/service/tenant_rebac_service_test.go diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index dd261e17..7fb89bdc 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -288,8 +288,8 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) { } var got map[string]string json.NewDecoder(resp.Body).Decode(&got) - if got["sessionJwt"] != "valid-jwt" { - t.Errorf("expected jwt valid-jwt, got %s", got["sessionJwt"]) + if got["sessionToken"] != "valid-jwt" { + t.Errorf("expected jwt valid-jwt, got %s", got["sessionToken"]) } // No redirectTo if _, ok := got["redirectTo"]; ok { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 4c491c73..caaccb8c 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1,8 +1,10 @@ package handler import ( + "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -10,8 +12,65 @@ import ( "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) +type MockRPService struct { + mock.Mock +} + +func (m *MockRPService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) { + args := m.Called(ctx, tenantID, client) + return args.Get(0).(*domain.RelyingParty), args.Error(1) +} +func (m *MockRPService) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) { + args := m.Called(ctx, clientID) + return args.Get(0).(*domain.RelyingParty), args.Get(1).(*domain.HydraClient), args.Error(2) +} +func (m *MockRPService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { + args := m.Called(ctx, tenantID) + return args.Get(0).([]domain.RelyingParty), args.Error(1) +} +func (m *MockRPService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.RelyingParty), args.Error(1) +} +func (m *MockRPService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) { + args := m.Called(ctx, tenantIDs) + return args.Get(0).([]domain.RelyingParty), args.Error(1) +} +func (m *MockRPService) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) { + args := m.Called(ctx, clientID, client) + return args.Get(0).(*domain.RelyingParty), args.Error(1) +} +func (m *MockRPService) Delete(ctx context.Context, clientID string) error { + args := m.Called(ctx, clientID) + return args.Error(0) +} +func (m *MockRPService) CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) { + args := m.Called(ctx, userID, clientID, relation) + return args.Bool(0), args.Error(1) +} +func (m *MockRPService) AddOwner(ctx context.Context, clientID, subject string) error { + args := m.Called(ctx, clientID, subject) + return args.Error(0) +} +func (m *MockRPService) RemoveOwner(ctx context.Context, clientID, subject string) error { + args := m.Called(ctx, clientID, subject) + return args.Error(0) +} +func (m *MockRPService) ListOwners(ctx context.Context, clientID string) ([]string, error) { + args := m.Called(ctx, clientID) + return args.Get(0).([]string), args.Error(1) +} + +func withMockProfile(profile *domain.UserProfileResponse) fiber.Handler { + return func(c *fiber.Ctx) error { + c.Locals("user_profile", profile) + return c.Next() + } +} + func TestListClients_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { @@ -30,7 +89,11 @@ func TestListClients_Success(t *testing.T) { }, } app := fiber.New() - app.Get("/api/v1/dev/clients", h.ListClients) + adminProfile := &domain.UserProfileResponse{ + ID: "admin-1", + Role: domain.RoleSuperAdmin, + } + app.Get("/api/v1/dev/clients", withMockProfile(adminProfile), h.ListClients) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) resp, _ := app.Test(req, -1) @@ -66,7 +129,11 @@ func TestGetClient_Success(t *testing.T) { }, } app := fiber.New() - app.Get("/api/v1/dev/clients/:id", h.GetClient) + adminProfile := &domain.UserProfileResponse{ + ID: "admin-1", + Role: domain.RoleSuperAdmin, + } + app.Get("/api/v1/dev/clients/:id", withMockProfile(adminProfile), h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil) resp, _ := app.Test(req, -1) @@ -92,7 +159,11 @@ func TestGetClient_NotFound(t *testing.T) { }, } app := fiber.New() - app.Get("/api/v1/dev/clients/:id", h.GetClient) + adminProfile := &domain.UserProfileResponse{ + ID: "admin-1", + Role: domain.RoleSuperAdmin, + } + app.Get("/api/v1/dev/clients/:id", withMockProfile(adminProfile), h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil) resp, _ := app.Test(req, -1) @@ -109,30 +180,49 @@ func TestCreateClient_Success(t *testing.T) { "client_secret": "secret-123", }), nil } - return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil + if r.Method == http.MethodGet && r.URL.Path == "/clients/new-client-123" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "new-client-123", + "client_name": "New App", + "client_secret": "secret-123", + "metadata": map[string]interface{}{"status": "active"}, + }), nil + } + return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error path: " + r.URL.Path}), nil }) secretRepo := &mockSecretRepo{secrets: make(map[string]string)} redisRepo := &mockRedisRepo{data: make(map[string]string)} + mockRP := new(MockRPService) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", + PublicURL: "http://hydra-public.test", HTTPClient: &http.Client{Transport: transport}, }, SecretRepo: secretRepo, Redis: redisRepo, + RPService: mockRP, } app := fiber.New() - app.Post("/api/v1/dev/clients", h.CreateClient) + adminProfile := &domain.UserProfileResponse{ + ID: "admin-1", + Role: domain.RoleSuperAdmin, + } + app.Post("/api/v1/dev/clients", withMockProfile(adminProfile), h.CreateClient) body, _ := json.Marshal(map[string]interface{}{ "client_name": "New App", "type": "confidential", "redirectUris": []string{"http://localhost/cb"}, }) + + mockRP.On("Create", mock.Anything, "t1", mock.Anything).Return(&domain.RelyingParty{ClientID: "new-client-123"}, nil) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Tenant-ID", "t1") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 28bb903c..e1ae7261 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -70,6 +70,26 @@ func (m *MockTenantService) SetKetoService(keto service.KetoService) { m.Called(keto) } +func (m *MockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + args := m.Called(ctx, userID) + return args.Get(0).([]domain.Tenant), args.Error(1) +} + +func (m *MockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error { + args := m.Called(ctx, tenantID, userID) + return args.Error(0) +} + +func (m *MockTenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error { + args := m.Called(ctx, tenantID, userID) + return args.Error(0) +} + +func (m *MockTenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) { + args := m.Called(ctx, tenantID) + return args.Get(0).([]string), args.Error(1) +} + func TestTenantHandler_CreateTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) diff --git a/backend/internal/handler/tenant_rebac_handler_test.go b/backend/internal/handler/tenant_rebac_handler_test.go new file mode 100644 index 00000000..195ecab4 --- /dev/null +++ b/backend/internal/handler/tenant_rebac_handler_test.go @@ -0,0 +1,119 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/middleware" + "baron-sso-backend/internal/service" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// Reusing MockKetoService from previous step or defining here if needed +type MockKetoService struct { + mock.Mock +} + +func (m *MockKetoService) 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 *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + return m.Called(ctx, namespace, object, relation, subject).Error(0) +} +func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + return m.Called(ctx, namespace, object, relation, subject).Error(0) +} +func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) { + args := m.Called(ctx, namespace, object, relation, subject) + 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) + return args.Get(0).([]string), args.Error(1) +} + +// MockAuthHandler implements middleware.AuthProfileProvider +type MockAuthHandler struct { + mock.Mock +} + +func (m *MockAuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { + args := m.Called(c) + return args.Get(0).(*domain.UserProfileResponse), args.Error(1) +} + +func TestRequireKetoPermission_Tenant_AuditContext(t *testing.T) { + app := fiber.New() + mockKeto := new(MockKetoService) + mockAuth := new(MockAuthHandler) + + config := middleware.RBACConfig{ + AuthHandler: mockAuth, + KetoService: mockKeto, + } + + userID := "user-1" + tenantID := "tenant-abc" + + // Mock user profile + mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ + ID: userID, + Role: domain.RoleTenantAdmin, + }, nil) + + // Mock Keto: Allow access + mockKeto.On("CheckPermission", mock.Anything, userID, "Tenant", tenantID, "manage").Return(true, nil) + + // Route with middleware + app.Get("/test/tenants/:id", middleware.RequireKetoPermission(config, "Tenant", "manage"), func(c *fiber.Ctx) error { + // Verify that tenant_id was injected into Locals for audit log + assert.Equal(t, tenantID, c.Locals("tenant_id")) + return c.SendStatus(fiber.StatusOK) + }) + + // Execute + req := httptest.NewRequest("GET", "/test/tenants/"+tenantID, nil) + resp, _ := app.Test(req) + + // Verify + assert.Equal(t, http.StatusOK, resp.StatusCode) + mockKeto.AssertExpectations(t) + mockAuth.AssertExpectations(t) +} + +func TestRequireKetoPermission_Deny(t *testing.T) { + app := fiber.New() + mockKeto := new(MockKetoService) + mockAuth := new(MockAuthHandler) + + config := middleware.RBACConfig{ + AuthHandler: mockAuth, + KetoService: mockKeto, + } + + userID := "user-bad" + tenantID := "tenant-secret" + + mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{ + ID: userID, + Role: domain.RoleUser, + }, nil) + + // Mock Keto: Deny access + mockKeto.On("CheckPermission", mock.Anything, userID, "Tenant", tenantID, "view").Return(false, nil) + + app.Get("/test/tenants/:id", middleware.RequireKetoPermission(config, "Tenant", "view"), func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + req := httptest.NewRequest("GET", "/test/tenants/"+tenantID, nil) + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} diff --git a/backend/internal/service/tenant_rebac_service_test.go b/backend/internal/service/tenant_rebac_service_test.go new file mode 100644 index 00000000..8e95ddc9 --- /dev/null +++ b/backend/internal/service/tenant_rebac_service_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockTenantRepository is a mock implementation of repository.TenantRepository +type MockTenantRepository struct { + mock.Mock +} + +func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error { + return m.Called(ctx, tenant).Error(0) +} +func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error { + return m.Called(ctx, tenant).Error(0) +} +func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} +func (m *MockTenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + args := m.Called(ctx, slug) + return args.Get(0).(*domain.Tenant), args.Error(1) +} +func (m *MockTenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) { + args := m.Called(ctx, name) + return args.Get(0).(*domain.Tenant), args.Error(1) +} +func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + args := m.Called(ctx, domainName) + return args.Get(0).(*domain.Tenant), args.Error(1) +} +func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) { + args := m.Called(ctx, ids) + return args.Get(0).([]domain.Tenant), args.Error(1) +} +func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error { + return m.Called(ctx, tenantID, domainName).Error(0) +} + +// MockKetoService is a mock implementation of KetoService +type MockKetoService struct { + mock.Mock +} + +func (m *MockKetoService) 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 *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + return m.Called(ctx, namespace, object, relation, subject).Error(0) +} +func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + return m.Called(ctx, namespace, object, relation, subject).Error(0) +} +func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Get(0).([]RelationTuple), args.Error(1) +} +func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { + args := m.Called(ctx, namespace, relation, subject) + return args.Get(0).([]string), args.Error(1) +} + +func TestTenantService_ListManageableTenants_Inheritance(t *testing.T) { + mockRepo := new(MockTenantRepository) + mockKeto := new(MockKetoService) + svc := &tenantService{ + repo: mockRepo, + keto: mockKeto, + } + + userID := "user-123" + ctx := context.Background() + + // 1. Mock direct tenant management (admins relation) + mockKeto.On("ListObjects", ctx, "Tenant", "admins", userID).Return([]string{"t-direct-1"}, nil) + + // 2. Mock group management (admins of a group) + mockKeto.On("ListObjects", ctx, "TenantGroup", "admins", userID).Return([]string{"g-1"}, nil) + + // 3. Mock tenants belonging to group g-1 + mockKeto.On("ListRelations", ctx, "Tenant", "", "parent_group", "TenantGroup:g-1").Return([]RelationTuple{ + {Object: "t-inherited-1", Relation: "parent_group", SubjectID: "TenantGroup:g-1"}, + {Object: "t-inherited-2", Relation: "parent_group", SubjectID: "TenantGroup:g-1"}, + }, nil) + + // 4. Expect repository to fetch all unique IDs: t-direct-1, t-inherited-1, t-inherited-2 + expectedIDs := []string{"t-direct-1", "t-inherited-1", "t-inherited-2"} + mockRepo.On("FindByIDs", ctx, mock.MatchedBy(func(ids []string) bool { + // Check if all expected IDs are present (order doesn't matter since we dedup via map) + foundCount := 0 + for _, eid := range expectedIDs { + for _, id := range ids { + if id == eid { + foundCount++ + break + } + } + } + return foundCount == len(expectedIDs) && len(ids) == len(expectedIDs) + })).Return([]domain.Tenant{ + {ID: "t-direct-1", Name: "Direct Tenant"}, + {ID: "t-inherited-1", Name: "Inherited Tenant 1"}, + {ID: "t-inherited-2", Name: "Inherited Tenant 2"}, + }, nil) + + // Execute + tenants, err := svc.ListManageableTenants(ctx, userID) + + // Verify + assert.NoError(t, err) + assert.Len(t, tenants, 3) + mockKeto.AssertExpectations(t) + mockRepo.AssertExpectations(t) +}