From 1951336307a72a783b48b0f45b47220e82de60b9 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 24 Mar 2026 13:43:20 +0900 Subject: [PATCH] =?UTF-8?q?oathkeeper-introspect=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=95=B1=20=EB=AA=A9=EB=A1=9D=20=EB=85=B8=EC=B6=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/dev_handler.go | 43 ++++++ backend/internal/handler/dev_handler_test.go | 132 +++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index ec3cb21a..61d08585 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -142,6 +142,10 @@ type clientUpsertRequest struct { Metadata *map[string]interface{} `json:"metadata"` } +var protectedSystemClientIDs = map[string]struct{}{ + "oathkeeper-introspect": {}, +} + func normalizeUserRole(role string) string { return domain.NormalizeRole(role) } @@ -263,6 +267,15 @@ func profileRole(profile *domain.UserProfileResponse) string { return strings.TrimSpace(profile.Role) } +func isProtectedSystemClientID(clientID string) bool { + _, ok := protectedSystemClientIDs[strings.TrimSpace(clientID)] + return ok +} + +func isProtectedSystemClient(client domain.HydraClient) bool { + return isProtectedSystemClientID(client.ClientID) +} + func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) if (!ok || profile == nil) && h.Auth != nil { @@ -557,6 +570,10 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { items := make([]clientSummary, 0, len(clients)) for _, client := range clients { + if isProtectedSystemClient(client) { + continue + } + summary := h.mapClientSummary(client) // 1. [Security] Filter out 'private' clients if user is not an AppManager @@ -604,6 +621,10 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if isProtectedSystemClient(*client) { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + summary := h.mapClientSummary(*client) profile := h.getCurrentProfile(c) if profile == nil { @@ -678,6 +699,10 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if isProtectedSystemClient(*current) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") + } + summary := h.mapClientSummary(*current) profile := h.getCurrentProfile(c) if profile == nil { @@ -759,6 +784,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if clientID == "" { clientID = uuid.NewString() } + if isProtectedSystemClientID(clientID) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id") + } name := strings.TrimSpace(valueOr(req.Name, "")) if name == "" { @@ -899,6 +927,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if isProtectedSystemClient(*current) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") + } + currentSummary := h.mapClientSummary(*current) profile := h.getCurrentProfile(c) if profile == nil { @@ -1030,6 +1062,10 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if isProtectedSystemClient(*current) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") + } + summary := h.mapClientSummary(*current) profile := h.getCurrentProfile(c) if profile == nil { @@ -1265,6 +1301,10 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + if isProtectedSystemClient(*current) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") + } + summary := h.mapClientSummary(*current) profile := h.getCurrentProfile(c) if profile == nil { @@ -1462,6 +1502,9 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error { var totalClients int64 if err == nil { for _, client := range clients { + if isProtectedSystemClient(client) { + continue + } if isSuperAdmin { totalClients++ continue diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index d42b99f5..a7c03619 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -124,6 +124,44 @@ func TestListClients_Success(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } +func TestListClients_ProtectedSystemClientHidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/clients" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + {"client_id": "oathkeeper-introspect", "client_name": "Internal Client"}, + {"client_id": "client-1", "client_name": "App One", "metadata": map[string]interface{}{"status": "active"}}, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + } + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Get("/api/v1/dev/clients", h.ListClients) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var res clientListResponse + _ = json.NewDecoder(resp.Body).Decode(&res) + assert.Len(t, res.Items, 1) + assert.Equal(t, "client-1", res.Items[0].ID) +} + func TestUpdateClientStatus_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { @@ -164,6 +202,38 @@ func TestUpdateClientStatus_Success(t *testing.T) { assert.Equal(t, "inactive", res.Client.Status) } +func TestUpdateClientStatus_ProtectedSystemClientForbidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "oathkeeper-introspect", + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: new(devMockKetoService), + } + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) + + body, _ := json.Marshal(map[string]interface{}{"status": "inactive"}) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/oathkeeper-introspect/status", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + func TestDeleteClient_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { @@ -204,6 +274,67 @@ func TestDeleteClient_Success(t *testing.T) { assert.Error(t, err) } +func TestDeleteClient_ProtectedSystemClientForbidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{"client_id": "oathkeeper-introspect"}), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + SecretRepo: &mockSecretRepo{secrets: map[string]string{"oathkeeper-introspect": "secret"}}, + Redis: &devMockRedisRepo{data: map[string]string{"client_secret:oathkeeper-introspect": "secret"}}, + Keto: new(devMockKetoService), + } + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Delete("/api/v1/dev/clients/:id", h.DeleteClient) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/oathkeeper-introspect", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestGetClient_ProtectedSystemClientHidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "oathkeeper-introspect", + "client_name": "Internal Client", + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: new(devMockKetoService), + } + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-1", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Get("/api/v1/dev/clients/:id", h.GetClient) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/oathkeeper-introspect", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + func TestRotateClientSecret_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { @@ -254,6 +385,7 @@ func TestGetStats_Success(t *testing.T) { return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ {"client_id": "c1", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "c2", "metadata": map[string]interface{}{"tenant_id": "t1"}}, + {"client_id": "oathkeeper-introspect", "metadata": map[string]interface{}{"tenant_id": "t1"}}, {"client_id": "c3", "metadata": map[string]interface{}{"tenant_id": "t2"}}, }), nil }