forked from baron/baron-sso
Fix audit timeline app names and stabilize backend tests
This commit is contained in:
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -156,4 +156,4 @@
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [{ "handler": "noop" }]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
101
docs/rbac-rebac-policy.md
Normal file
101
docs/rbac-rebac-policy.md
Normal file
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user