diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 9668ac02..7359f48f 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3079,11 +3079,25 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { path := strings.ToLower(extractAuditPath(log)) if strings.Contains(path, "/api/v1/auth/oidc/login/accept") { appName = "OIDC 로그인" - loginChallenge := extractLoginChallengeFromAuditDetails(log.Details) - if loginChallenge != "" { - if info, ok := resolveLoginClient(loginChallenge); ok { - appName = info.Name - clientID = info.ClientID + // 우선 audit details의 client 정보를 사용하고, 없으면 Hydra 조회로 보강 + if details, err := parseAuditDetails(log.Details); err == nil && details != nil { + if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" { + appName = strings.TrimSpace(name) + } + if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" { + clientID = strings.TrimSpace(cid) + if appName == "OIDC 로그인" { + appName = clientID + } + } + } + if appName == "OIDC 로그인" { + loginChallenge := extractLoginChallengeFromAuditDetails(log.Details) + if loginChallenge != "" { + if info, ok := resolveLoginClient(loginChallenge); ok { + appName = info.Name + clientID = info.ClientID + } } } } @@ -3586,11 +3600,26 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { // Check if the client is active loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge) - if err == nil && loginReq != nil && loginReq.Client.Metadata != nil { - if status, ok := loginReq.Client.Metadata["status"].(string); ok { - if strings.ToLower(status) == "inactive" { - slog.Warn("Login rejected for inactive client in AcceptOidcLoginRequest", "client_id", loginReq.Client.ClientID) - return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") + if err == nil && loginReq != nil { + // Audit 상세 정보 보강: OIDC 로그인 시점에 client 정보를 저장 + clientID := strings.TrimSpace(loginReq.Client.ClientID) + if clientID != "" { + clientName := strings.TrimSpace(loginReq.Client.ClientName) + if clientName == "" { + clientName = clientID + } + c.Locals("audit_details_extra", map[string]any{ + "client_id": clientID, + "client_name": clientName, + }) + } + + if loginReq.Client.Metadata != nil { + if status, ok := loginReq.Client.Metadata["status"].(string); ok { + if strings.ToLower(status) == "inactive" { + slog.Warn("Login rejected for inactive client in AcceptOidcLoginRequest", "client_id", loginReq.Client.ClientID) + return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") + } } } } diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go index 17aed830..76275234 100644 --- a/backend/internal/middleware/audit_middleware.go +++ b/backend/internal/middleware/audit_middleware.go @@ -144,6 +144,19 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { "tenant_id": tenantID, "request_body": maskedBody, } + // 핸들러에서 추가한 상세 정보를 병합합니다. + if extra := c.Locals("audit_details_extra"); extra != nil { + switch v := extra.(type) { + case map[string]string: + for key, value := range v { + details[key] = value + } + case map[string]interface{}: + for key, value := range v { + details[key] = value + } + } + } if skipTimeline, ok := c.Locals("auth_timeline_skip").(bool); ok && skipTimeline { details["auth_timeline_skip"] = true } diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go index 5d8bd0d6..c1346034 100644 --- a/backend/internal/middleware/rbac.go +++ b/backend/internal/middleware/rbac.go @@ -2,7 +2,6 @@ package middleware import ( "baron-sso-backend/internal/domain" - "baron-sso-backend/internal/handler" "baron-sso-backend/internal/service" "github.com/gofiber/fiber/v2" "log/slog" @@ -11,10 +10,15 @@ import ( // RBACConfig defines the configuration for RBAC middleware type RBACConfig struct { AllowedRoles []string - AuthHandler *handler.AuthHandler + AuthHandler AuthProfileProvider KetoService service.KetoService } +// AuthProfileProvider는 미들웨어에서 사용자 정보를 조회하기 위한 최소 인터페이스입니다. +type AuthProfileProvider interface { + GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) +} + // RequireKetoPermission enforces permissions using Ory Keto (ReBAC) func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.Handler { return func(c *fiber.Ctx) error { diff --git a/backend/internal/service/hydra_admin_service_test.go b/backend/internal/service/hydra_admin_service_test.go index d2f48d8d..f8a761df 100644 --- a/backend/internal/service/hydra_admin_service_test.go +++ b/backend/internal/service/hydra_admin_service_test.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -17,19 +16,19 @@ func TestHydraAdminService_ListClients(t *testing.T) { {ClientID: "client2", ClientName: "Client 2"}, } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/clients", r.URL.Path) assert.Equal(t, "GET", r.Method) assert.Equal(t, "10", r.URL.Query().Get("limit")) assert.Equal(t, "5", r.URL.Query().Get("offset")) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(clients) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(clients) + }) s := &HydraAdminService{ - AdminURL: server.URL, + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), } result, err := s.ListClients(context.Background(), 10, 5) @@ -40,17 +39,17 @@ func TestHydraAdminService_ListClients(t *testing.T) { func TestHydraAdminService_GetClient(t *testing.T) { client := domain.HydraClient{ClientID: "test-client", ClientName: "Test Client"} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/clients/test-client", r.URL.Path) assert.Equal(t, "GET", r.Method) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(client) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(client) + }) s := &HydraAdminService{ - AdminURL: server.URL, + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), } result, err := s.GetClient(context.Background(), "test-client") @@ -62,21 +61,21 @@ func TestHydraAdminService_CreateClient(t *testing.T) { client := domain.HydraClient{ClientName: "New Client"} created := domain.HydraClient{ClientID: "new-id", ClientName: "New Client"} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/clients", r.URL.Path) assert.Equal(t, "POST", r.Method) var received domain.HydraClient - json.NewDecoder(r.Body).Decode(&received) + _ = json.NewDecoder(r.Body).Decode(&received) assert.Equal(t, client.ClientName, received.ClientName) w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(created) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(created) + }) s := &HydraAdminService{ - AdminURL: server.URL, + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), } result, err := s.CreateClient(context.Background(), client) @@ -85,15 +84,15 @@ func TestHydraAdminService_CreateClient(t *testing.T) { } func TestHydraAdminService_DeleteClient(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/clients/to-delete", r.URL.Path) assert.Equal(t, "DELETE", r.Method) w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() + }) s := &HydraAdminService{ - AdminURL: server.URL, + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), } err := s.DeleteClient(context.Background(), "to-delete") @@ -104,17 +103,17 @@ func TestHydraAdminService_GetConsentRequest(t *testing.T) { challenge := "challenge123" consentReq := domain.HydraConsentRequest{Challenge: challenge, Subject: "user1"} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/oauth2/auth/requests/consent", r.URL.Path) assert.Equal(t, challenge, r.URL.Query().Get("consent_challenge")) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(consentReq) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(consentReq) + }) s := &HydraAdminService{ - AdminURL: server.URL, + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), } result, err := s.GetConsentRequest(context.Background(), challenge) @@ -123,96 +122,108 @@ func TestHydraAdminService_GetConsentRequest(t *testing.T) { } func TestHydraAdminService_PatchClientStatus(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/clients/test-client", r.URL.Path) assert.Equal(t, "PATCH", r.Method) assert.Equal(t, "application/json-patch+json", r.Header.Get("Content-Type")) var payload []map[string]interface{} - json.NewDecoder(r.Body).Decode(&payload) + _ = json.NewDecoder(r.Body).Decode(&payload) assert.Equal(t, "replace", payload[0]["op"]) assert.Equal(t, "/metadata/status", payload[0]["path"]) assert.Equal(t, "inactive", payload[0]["value"]) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(domain.HydraClient{ClientID: "test-client"}) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(domain.HydraClient{ClientID: "test-client"}) + }) - s := &HydraAdminService{AdminURL: server.URL} + s := &HydraAdminService{ + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), + } _, err := s.PatchClientStatus(context.Background(), "test-client", "inactive") assert.NoError(t, err) } func TestHydraAdminService_UpdateClient(t *testing.T) { client := domain.HydraClient{ClientName: "Updated Name"} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/clients/test-client", r.URL.Path) assert.Equal(t, "PUT", r.Method) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(client) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(client) + }) - s := &HydraAdminService{AdminURL: server.URL} + s := &HydraAdminService{ + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), + } _, err := s.UpdateClient(context.Background(), "test-client", client) assert.NoError(t, err) } func TestHydraAdminService_ListConsentSessions(t *testing.T) { sessions := []domain.HydraConsentSession{{Subject: "user1"}} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/oauth2/auth/sessions/consent", r.URL.Path) assert.Equal(t, "user1", r.URL.Query().Get("subject")) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(sessions) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(sessions) + }) - s := &HydraAdminService{AdminURL: server.URL} + s := &HydraAdminService{ + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), + } result, err := s.ListConsentSessions(context.Background(), "user1", "") assert.NoError(t, err) assert.Equal(t, sessions, result) } func TestHydraAdminService_RevokeConsentSessions(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/oauth2/auth/sessions/consent", r.URL.Path) assert.Equal(t, "DELETE", r.Method) w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() + }) - s := &HydraAdminService{AdminURL: server.URL} + s := &HydraAdminService{ + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), + } err := s.RevokeConsentSessions(context.Background(), "user1", "") assert.NoError(t, err) } func TestHydraAdminService_RejectConsentRequest(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/oauth2/auth/requests/consent/reject", r.URL.Path) assert.Equal(t, "PUT", r.Method) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://reject"}) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://reject"}) + }) - s := &HydraAdminService{AdminURL: server.URL} + s := &HydraAdminService{ + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), + } resp, err := s.RejectConsentRequest(context.Background(), "challenge") assert.NoError(t, err) assert.Equal(t, "http://reject", resp.RedirectTo) } func TestHydraAdminService_RejectLoginRequest(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/oauth2/auth/requests/login/reject", r.URL.Path) assert.Equal(t, "PUT", r.Method) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://reject-login"}) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://reject-login"}) + }) - s := &HydraAdminService{AdminURL: server.URL} + s := &HydraAdminService{ + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), + } resp, err := s.RejectLoginRequest(context.Background(), "challenge", "error", "desc") assert.NoError(t, err) assert.Equal(t, "http://reject-login", resp.RedirectTo) @@ -220,14 +231,16 @@ func TestHydraAdminService_RejectLoginRequest(t *testing.T) { func TestHydraAdminService_GetLoginRequest(t *testing.T) { loginReq := domain.HydraLoginRequest{Challenge: "challenge"} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/oauth2/auth/requests/login", r.URL.Path) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(loginReq) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(loginReq) + }) - s := &HydraAdminService{AdminURL: server.URL} + s := &HydraAdminService{ + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), + } result, err := s.GetLoginRequest(context.Background(), "challenge") assert.NoError(t, err) assert.Equal(t, &loginReq, result) @@ -235,15 +248,17 @@ func TestHydraAdminService_GetLoginRequest(t *testing.T) { func TestHydraAdminService_AcceptConsentRequest(t *testing.T) { grant := &domain.HydraConsentRequest{RequestedScope: []string{"openid"}} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/oauth2/auth/requests/consent/accept", r.URL.Path) assert.Equal(t, "PUT", r.Method) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://accept"}) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://accept"}) + }) - s := &HydraAdminService{AdminURL: server.URL} + s := &HydraAdminService{ + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), + } resp, err := s.AcceptConsentRequest(context.Background(), "challenge", grant, nil) assert.NoError(t, err) assert.Equal(t, "http://accept", resp.RedirectTo) @@ -254,21 +269,21 @@ func TestHydraAdminService_AcceptLoginRequest(t *testing.T) { subject := "user@example.com" redirectTo := "http://hydra/auth/confirm" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/oauth2/auth/requests/login/accept", r.URL.Path) assert.Equal(t, challenge, r.URL.Query().Get("login_challenge")) var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) + _ = json.NewDecoder(r.Body).Decode(&body) assert.Equal(t, subject, body["subject"]) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"redirect_to": redirectTo}) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": redirectTo}) + }) s := &HydraAdminService{ - AdminURL: server.URL, + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), } result, err := s.AcceptLoginRequest(context.Background(), challenge, subject) @@ -277,13 +292,15 @@ func TestHydraAdminService_AcceptLoginRequest(t *testing.T) { } func TestHydraAdminService_ErrorHandling(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("bad request")) - })) - defer server.Close() + _, _ = w.Write([]byte("bad request")) + }) - s := &HydraAdminService{AdminURL: server.URL} + s := &HydraAdminService{ + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), + } _, err := s.GetClient(context.Background(), "invalid") assert.Error(t, err) @@ -300,12 +317,14 @@ func TestHydraAdminService_ErrorHandling(t *testing.T) { } func TestHydraAdminService_NotFound(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) - })) - defer server.Close() + }) - s := &HydraAdminService{AdminURL: server.URL} + s := &HydraAdminService{ + AdminURL: "http://hydra-admin.local", + HTTPClient: clientForHandler(handler), + } _, err := s.GetClient(context.Background(), "none") assert.Equal(t, ErrHydraNotFound, err) diff --git a/backend/internal/service/keto_service_test.go b/backend/internal/service/keto_service_test.go index c4cb9dad..18ef210d 100644 --- a/backend/internal/service/keto_service_test.go +++ b/backend/internal/service/keto_service_test.go @@ -4,14 +4,13 @@ import ( "context" "encoding/json" "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/assert" ) func TestKetoService_CheckPermission(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/relation-tuples/check", r.URL.Path) assert.Equal(t, "user1", r.URL.Query().Get("subject_id")) assert.Equal(t, "tenants", r.URL.Query().Get("namespace")) @@ -19,13 +18,12 @@ func TestKetoService_CheckPermission(t *testing.T) { assert.Equal(t, "admin", r.URL.Query().Get("relation")) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(checkResponse{Allowed: true}) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(checkResponse{Allowed: true}) + }) s := &ketoService{ - readURL: server.URL, - client: &http.Client{}, + readURL: "http://keto-read.local", + client: clientForHandler(handler), } allowed, err := s.CheckPermission(context.Background(), "user1", "tenants", "tenant1", "admin") @@ -34,24 +32,23 @@ func TestKetoService_CheckPermission(t *testing.T) { } func TestKetoService_CreateRelation(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/admin/relation-tuples", r.URL.Path) assert.Equal(t, "PUT", r.Method) var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) + _ = json.NewDecoder(r.Body).Decode(&body) assert.Equal(t, "tenants", body["namespace"]) assert.Equal(t, "tenant1", body["object"]) assert.Equal(t, "admin", body["relation"]) assert.Equal(t, "user1", body["subject_id"]) w.WriteHeader(http.StatusCreated) - })) - defer server.Close() + }) s := &ketoService{ - writeURL: server.URL, - client: &http.Client{}, + writeURL: "http://keto-write.local", + client: clientForHandler(handler), } err := s.CreateRelation(context.Background(), "tenants", "tenant1", "admin", "user1") @@ -59,18 +56,17 @@ func TestKetoService_CreateRelation(t *testing.T) { } func TestKetoService_DeleteRelation(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/relation-tuples", r.URL.Path) assert.Equal(t, "DELETE", r.Method) assert.Equal(t, "user1", r.URL.Query().Get("subject_id")) w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() + }) s := &ketoService{ - writeURL: server.URL, - client: &http.Client{}, + writeURL: "http://keto-write.local", + client: clientForHandler(handler), } err := s.DeleteRelation(context.Background(), "tenants", "tenant1", "admin", "user1") @@ -82,17 +78,16 @@ func TestKetoService_ListRelations(t *testing.T) { {Namespace: "tenants", Object: "tenant1", Relation: "admin", SubjectID: "user1"}, } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/relation-tuples", r.URL.Path) - + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(relationTuplesResponse{RelationTuples: tuples}) - })) - defer server.Close() + _ = json.NewEncoder(w).Encode(relationTuplesResponse{RelationTuples: tuples}) + }) s := &ketoService{ - readURL: server.URL, - client: &http.Client{}, + readURL: "http://keto-read.local", + client: clientForHandler(handler), } result, err := s.ListRelations(context.Background(), "tenants", "tenant1", "admin", "user1") @@ -101,21 +96,20 @@ func TestKetoService_ListRelations(t *testing.T) { } func TestKetoService_ErrorHandling(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("internal error")) - })) - defer server.Close() + _, _ = w.Write([]byte("internal error")) + }) s := &ketoService{ - readURL: server.URL, - writeURL: server.URL, - client: &http.Client{}, + readURL: "http://keto-read.local", + writeURL: "http://keto-write.local", + client: clientForHandler(handler), } _, err := s.CheckPermission(context.Background(), "u", "n", "o", "r") assert.Error(t, err) - + err = s.DeleteRelation(context.Background(), "n", "o", "r", "s") assert.Error(t, err) @@ -124,12 +118,14 @@ func TestKetoService_ErrorHandling(t *testing.T) { } func TestKetoService_CheckPermission_Forbidden(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) - })) - defer server.Close() + }) - s := &ketoService{readURL: server.URL, client: &http.Client{}} + s := &ketoService{ + readURL: "http://keto-read.local", + client: clientForHandler(handler), + } allowed, err := s.CheckPermission(context.Background(), "u", "n", "o", "r") assert.NoError(t, err) assert.False(t, allowed) @@ -137,19 +133,18 @@ func TestKetoService_CheckPermission_Forbidden(t *testing.T) { func TestKetoService_CreateRelation_Retry(t *testing.T) { attempts := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts < 2 { w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) - })) - defer server.Close() + }) s := &ketoService{ - writeURL: server.URL, - client: &http.Client{}, + writeURL: "http://keto-write.local", + client: clientForHandler(handler), } err := s.CreateRelation(context.Background(), "n", "o", "r", "s") diff --git a/backend/internal/service/relying_party_service_test.go b/backend/internal/service/relying_party_service_test.go index e5c5fe3d..f1dd705a 100644 --- a/backend/internal/service/relying_party_service_test.go +++ b/backend/internal/service/relying_party_service_test.go @@ -1,13 +1,13 @@ /* 이 테스트 파일은 RelyingPartyService의 기능을 검증하기 위한 유닛 테스트입니다. -RelyingPartyService는 HydraAdminService, KetoService, RelyingPartyRepository와 협력하므로 +RelyingPartyService는 HydraAdminService, KetoService와 협력하므로 각 의존성을 모킹(Mocking)하여 통합 로직을 검증합니다. 주요 테스트 항목: -1. Create: Hydra 클라이언트 생성 -> DB 저장 -> Keto 권한 설정 (성공 및 롤백 시나리오) -2. Get: DB 및 Hydra에서 정보 조회 -3. Update: Hydra 및 DB 업데이트 -4. Delete: DB 및 Hydra 삭제 +1. Create: Hydra 클라이언트 생성 -> Keto 권한 설정 +2. Get: Hydra에서 정보 조회 +3. Update: Hydra 업데이트 +4. Delete: Hydra 삭제 + Keto 권한 정리 */ package service @@ -18,6 +18,7 @@ import ( "encoding/json" "errors" "net/http" + "net/http/httptest" "strings" "testing" @@ -26,43 +27,6 @@ import ( // --- Mocks --- -type MockRelyingPartyRepository struct { - mock.Mock -} - -func (m *MockRelyingPartyRepository) Create(ctx context.Context, rp *domain.RelyingParty) error { - args := m.Called(ctx, rp) - return args.Error(0) -} - -func (m *MockRelyingPartyRepository) Update(ctx context.Context, rp *domain.RelyingParty) error { - args := m.Called(ctx, rp) - return args.Error(0) -} - -func (m *MockRelyingPartyRepository) Delete(ctx context.Context, clientID string) error { - args := m.Called(ctx, clientID) - return args.Error(0) -} - -func (m *MockRelyingPartyRepository) FindByID(ctx context.Context, clientID string) (*domain.RelyingParty, error) { - args := m.Called(ctx, clientID) - if rp, ok := args.Get(0).(*domain.RelyingParty); ok { - return rp, args.Error(1) - } - return nil, args.Error(1) -} - -func (m *MockRelyingPartyRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { - args := m.Called(ctx, tenantID) - return args.Get(0).([]domain.RelyingParty), args.Error(1) -} - -func (m *MockRelyingPartyRepository) ListAll(ctx context.Context) ([]domain.RelyingParty, error) { - args := m.Called(ctx) - return args.Get(0).([]domain.RelyingParty), args.Error(1) -} - type MockKetoService struct { mock.Mock } @@ -82,11 +46,35 @@ func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, return args.Error(0) } +func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) { + args := m.Called(ctx, namespace, object, relation, subject) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]RelationTuple), args.Error(1) +} + +// --- Test Helpers --- + +type hydraRoundTripperFunc func(*http.Request) (*http.Response, error) + +func (f hydraRoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func mockHydraClient(handler http.Handler) *http.Client { + return &http.Client{ + Transport: hydraRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Result(), nil + }), + } +} + // --- Tests --- func TestRelyingPartyService_Create_Success(t *testing.T) { - // Setup - mockRepo := new(MockRelyingPartyRepository) mockKeto := new(MockKetoService) tenantID := "tenant-1" @@ -98,16 +86,16 @@ func TestRelyingPartyService_Create_Success(t *testing.T) { hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/clients") { var req domain.HydraClient - json.NewDecoder(r.Body).Decode(&req) - - // Verify metadata injection + _ = json.NewDecoder(r.Body).Decode(&req) + + // 메타데이터 tenant_id 주입 확인 if req.Metadata["tenant_id"] != tenantID { t.Errorf("expected tenant_id in metadata") } req.ClientID = "generated-client-id" w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(req) + _ = json.NewEncoder(w).Encode(req) return } http.NotFound(w, r) @@ -117,31 +105,25 @@ func TestRelyingPartyService_Create_Success(t *testing.T) { HTTPClient: mockHydraClient(hydraHandler), } - // Expectations - mockRepo.On("Create", mock.Anything, mock.MatchedBy(func(rp *domain.RelyingParty) bool { - return rp.ClientID == "generated-client-id" && rp.TenantID == tenantID - })).Return(nil) - mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "generated-client-id", "parent_tenant", "Tenant:"+tenantID).Return(nil) - // Execute - svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + svc := NewRelyingPartyService(hydraSvc, mockKeto) rp, err := svc.Create(context.Background(), tenantID, inputClient) - // Verify if err != nil { t.Fatalf("Create failed: %v", err) } if rp.ClientID != "generated-client-id" { t.Errorf("expected client id generated-client-id, got %s", rp.ClientID) } + if rp.TenantID != tenantID { + t.Errorf("expected tenant id %s, got %s", tenantID, rp.TenantID) + } - mockRepo.AssertExpectations(t) mockKeto.AssertExpectations(t) } func TestRelyingPartyService_Create_HydraFail(t *testing.T) { - mockRepo := new(MockRelyingPartyRepository) mockKeto := new(MockKetoService) hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -152,7 +134,7 @@ func TestRelyingPartyService_Create_HydraFail(t *testing.T) { HTTPClient: mockHydraClient(hydraHandler), } - svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + svc := NewRelyingPartyService(hydraSvc, mockKeto) _, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{}) if err == nil { @@ -160,16 +142,119 @@ func TestRelyingPartyService_Create_HydraFail(t *testing.T) { } } -func TestRelyingPartyService_Create_DBFail_Rollback(t *testing.T) { - mockRepo := new(MockRelyingPartyRepository) +func TestRelyingPartyService_Create_KetoFail_Rollback(t *testing.T) { mockKeto := new(MockKetoService) clientID := "rollback-client-id" + deleteCalled := false - // Hydra Mock: Create Succeeds, Delete Called hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { - json.NewEncoder(w).Encode(domain.HydraClient{ClientID: clientID}) + _ = json.NewEncoder(w).Encode(domain.HydraClient{ClientID: clientID}) + return + } + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, clientID) { + deleteCalled = true + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", clientID, "parent_tenant", "Tenant:tenant-1").Return(errors.New("keto error")) + + svc := NewRelyingPartyService(hydraSvc, mockKeto) + _, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{}) + + if err == nil { + t.Error("expected error from keto") + } + if !deleteCalled { + t.Error("expected hydra client cleanup on keto failure") + } + + mockKeto.AssertExpectations(t) +} + +func TestRelyingPartyService_Get_Success(t *testing.T) { + mockKeto := new(MockKetoService) + clientID := "client-123" + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(domain.HydraClient{ + ClientID: clientID, + ClientName: "Hydra Name", + Metadata: map[string]interface{}{ + "tenant_id": "tenant-1", + }, + }) + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + svc := NewRelyingPartyService(hydraSvc, mockKeto) + rp, hc, err := svc.Get(context.Background(), clientID) + + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if rp.Name != "Hydra Name" { + t.Errorf("expected Hydra Name, got %s", rp.Name) + } + if hc.ClientName != "Hydra Name" { + t.Errorf("expected Hydra Name, got %s", hc.ClientName) + } +} + +func TestRelyingPartyService_Update_Success(t *testing.T) { + mockKeto := new(MockKetoService) + clientID := "client-123" + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + var req domain.HydraClient + _ = json.NewDecoder(r.Body).Decode(&req) + _ = json.NewEncoder(w).Encode(req) + return + } + }) + hydraSvc := &HydraAdminService{ + AdminURL: "http://hydra:4445", + HTTPClient: mockHydraClient(hydraHandler), + } + + svc := NewRelyingPartyService(hydraSvc, mockKeto) + + updateReq := domain.HydraClient{ClientName: "New Name"} + rp, err := svc.Update(context.Background(), clientID, updateReq) + + if err != nil { + t.Fatalf("Update failed: %v", err) + } + if rp.Name != "New Name" { + t.Errorf("expected New Name, got %s", rp.Name) + } +} + +func TestRelyingPartyService_Delete_Success(t *testing.T) { + mockKeto := new(MockKetoService) + clientID := "client-123" + tenantID := "tenant-1" + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, clientID) { + _ = json.NewEncoder(w).Encode(domain.HydraClient{ + ClientID: clientID, + Metadata: map[string]interface{}{ + "tenant_id": tenantID, + }, + }) return } if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, clientID) { @@ -183,113 +268,14 @@ func TestRelyingPartyService_Create_DBFail_Rollback(t *testing.T) { HTTPClient: mockHydraClient(hydraHandler), } - // DB Fails - mockRepo.On("Create", mock.Anything, mock.Anything).Return(errors.New("db error")) + mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", clientID, "parent_tenant", "Tenant:"+tenantID).Return(nil) - svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) - _, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{}) - - if err == nil { - t.Error("expected error from db") - } - - mockRepo.AssertExpectations(t) - // Keto should NOT be called - mockKeto.AssertNotCalled(t, "CreateRelation") -} - -func TestRelyingPartyService_Get_Success(t *testing.T) { - mockRepo := new(MockRelyingPartyRepository) - mockKeto := new(MockKetoService) - clientID := "client-123" - - mockRepo.On("FindByID", mock.Anything, clientID).Return(&domain.RelyingParty{ClientID: clientID, Name: "DB Name"}, nil) - - hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(domain.HydraClient{ClientID: clientID, ClientName: "Hydra Name"}) - }) - hydraSvc := &HydraAdminService{ - AdminURL: "http://hydra:4445", - HTTPClient: mockHydraClient(hydraHandler), - } - - svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) - rp, hc, err := svc.Get(context.Background(), clientID) - - if err != nil { - t.Fatalf("Get failed: %v", err) - } - if rp.Name != "DB Name" { - t.Errorf("expected DB Name, got %s", rp.Name) - } - if hc.ClientName != "Hydra Name" { - t.Errorf("expected Hydra Name, got %s", hc.ClientName) - } -} - -func TestRelyingPartyService_Update_Success(t *testing.T) { - mockRepo := new(MockRelyingPartyRepository) - mockKeto := new(MockKetoService) - clientID := "client-123" - - // Hydra Update - hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPut { - var req domain.HydraClient - json.NewDecoder(r.Body).Decode(&req) - json.NewEncoder(w).Encode(req) - return - } - }) - hydraSvc := &HydraAdminService{ - AdminURL: "http://hydra:4445", - HTTPClient: mockHydraClient(hydraHandler), - } - - // DB Update - mockRepo.On("FindByID", mock.Anything, clientID).Return(&domain.RelyingParty{ClientID: clientID, Name: "Old Name"}, nil) - mockRepo.On("Update", mock.Anything, mock.MatchedBy(func(rp *domain.RelyingParty) bool { - return rp.Name == "New Name" - })).Return(nil) - - svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) - - updateReq := domain.HydraClient{ClientName: "New Name"} - rp, err := svc.Update(context.Background(), clientID, updateReq) - - if err != nil { - t.Fatalf("Update failed: %v", err) - } - if rp.Name != "New Name" { - t.Errorf("expected New Name, got %s", rp.Name) - } - - mockRepo.AssertExpectations(t) -} - -func TestRelyingPartyService_Delete_Success(t *testing.T) { - mockRepo := new(MockRelyingPartyRepository) - mockKeto := new(MockKetoService) - clientID := "client-123" - - mockRepo.On("Delete", mock.Anything, clientID).Return(nil) - - hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodDelete { - w.WriteHeader(http.StatusNoContent) - } - }) - hydraSvc := &HydraAdminService{ - AdminURL: "http://hydra:4445", - HTTPClient: mockHydraClient(hydraHandler), - } - - svc := NewRelyingPartyService(mockRepo, hydraSvc, mockKeto) + svc := NewRelyingPartyService(hydraSvc, mockKeto) err := svc.Delete(context.Background(), clientID) if err != nil { t.Fatalf("Delete failed: %v", err) } - mockRepo.AssertExpectations(t) + mockKeto.AssertExpectations(t) } diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json index 4a0735da..fd6bfb2d 100755 --- a/docker/ory/oathkeeper/rules.active.json +++ b/docker/ory/oathkeeper/rules.active.json @@ -156,4 +156,4 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] } -] \ No newline at end of file +] diff --git a/docs/rbac-rebac-policy.md b/docs/rbac-rebac-policy.md new file mode 100644 index 00000000..4079d9e2 --- /dev/null +++ b/docs/rbac-rebac-policy.md @@ -0,0 +1,101 @@ +# RBAC / ReBAC 미들웨어 정책 정리 + +## 1. 목적 +- `backend/internal/middleware/rbac.go`는 **역할 기반(RBAC)**과 **관계 기반(ReBAC, Keto)**을 조합해 접근 제어를 일관되게 적용합니다. +- 핵심 목표는 **운영 단순성 + 권한 정밀도**의 균형입니다. + +## 2. 구성 요소와 역할 + +### 2.1 RequireRole +- 역할(Role) 기반 접근 제어를 담당합니다. +- Super Admin은 즉시 통과합니다. +- 허용된 역할 목록에 포함되지 않으면 차단합니다. +- API Key 인증은 우회합니다(시스템/운영 경로). + +### 2.2 RequireKetoPermission +- Ory Keto(ReBAC) 권한 체크를 수행합니다. +- Super Admin은 즉시 통과합니다. +- Keto의 관계 튜플에 기반해 `CheckPermission`을 수행합니다. + +### 2.3 RequireTenantMatch +- 테넌트 관리자 권한을 가진 사용자가 **자신의 테넌트**에만 접근하도록 보장합니다. +- Super Admin은 즉시 통과합니다. +- API Key 인증은 우회합니다. + +## 3. ReBAC 기반인데도 RBAC가 필요한 이유 + +1) **정책 단순화** +- Super Admin 같은 전역 정책은 ReBAC로 표현할 수도 있지만, RBAC가 더 빠르고 명확합니다. + +2) **운영 경로 단축** +- API Key, 배치성 요청 등은 일반 사용자 흐름과 분리해 처리합니다. +- 불필요한 ReBAC 호출을 줄여 장애 전파를 줄입니다. + +3) **테넌트 범위 제어의 명확성** +- "Tenant Admin은 자기 테넌트만"은 자주 쓰는 규칙으로, 미들웨어 단에서 즉시 판단이 효율적입니다. + +4) **성능 및 안정성** +- Keto는 외부 서비스 호출이므로 지연/실패 가능성이 있습니다. +- RBAC로 1차 필터링을 하여 호출 수를 줄입니다. + +## 4. SoT(단일 진실 공급원) 충돌 시 우선순위 정책 + +### 4.1 사용자/인증 SoT +- **1순위: Kratos Identity / Session** + - 사용자 식별과 세션 유효성의 최종 판단 기준 +- **2순위: Backend 프로필 DB / 캐시** + - Kratos와 동기화가 보장되는 범위에서만 보조 사용 + +### 4.2 권한/정책 SoT +- **1순위: Keto(ReBAC) 관계 튜플** + - 리소스 접근 권한의 최종 판단 기준 +- **2순위: RBAC(Role)** + - 전역/상위 정책의 단축 규칙 + - ReBAC와 충돌 시, ReBAC 결과가 항상 우선 + +### 4.3 테넌트 컨텍스트 SoT +- **1순위: 서버 측 프로필(예: UserProfile.tenantId)** +- **2순위: 요청 헤더(X-Tenant-ID)** + - 헤더는 "요청 의도"를 나타내지만, 항상 서버 프로필과 일치해야 함 + - 불일치 시 차단 + +### 4.4 OIDC/RP 정보 SoT +- **1순위: Hydra Client/Consent 데이터** +- **2순위: Backend audit details** + - 과거 데이터 재현을 위해 audit details에 client_id/client_name을 기록 + +## 5. 충돌 시 처리 원칙 (확정) + +1) **RBAC는 필터이고, 허용의 최종 판단은 ReBAC** +- RBAC 통과는 ReBAC 호출의 전제일 뿐, 허용 조건이 아니다. +- ReBAC 결과가 "허용"이어야만 최종 통과한다. + +2) **RBAC 통과 + ReBAC 실패 → 차단** +- ReBAC가 최종 권한을 가진다. + +3) **RBAC 실패 + ReBAC 통과 → 차단** +- 역할 기반 정책 위반은 즉시 차단한다. + +4) **Super Admin 예외** +- Super Admin이라도 기본 흐름에서는 ReBAC 판단을 거친다. +- 예외가 필요한 API는 별도로 명시하고, 감사 로그에 명확히 남긴다. + +5) **API Key 우회 범위** +- API Key 우회는 최소 범위로 제한한다. +- 우회 대상 API와 사유를 별도 문서로 관리한다. + +## 6. 정책 보완 필요 지점 (결정 필요) + +1) **Tenant 헤더 불일치 정책** +- `X-Tenant-ID`가 프로필 테넌트와 불일치할 때 차단은 확정 +- 테넌트 전환 UI/흐름에 따라 정책 확정 필요 + +2) **API Key 우회 범위 문서화** +- 현재는 `RequireRole`/`RequireTenantMatch`에서 우회 처리 +- 우회 허용 API 목록과 사유를 문서로 고정 필요 + +## 7. 관련 코드 +- `backend/internal/middleware/rbac.go` +- `backend/internal/handler/auth_handler.go` +- `backend/internal/service/keto_service.go` +