diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 61d08585..792f1ff2 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -146,6 +146,11 @@ var protectedSystemClientIDs = map[string]struct{}{ "oathkeeper-introspect": {}, } +var reservedSystemClientNames = map[string]string{ + "adminfront": "adminfront", + "devfront": "devfront", +} + func normalizeUserRole(role string) string { return domain.NormalizeRole(role) } @@ -276,6 +281,34 @@ func isProtectedSystemClient(client domain.HydraClient) bool { return isProtectedSystemClientID(client.ClientID) } +func isReservedSystemClientAlias(client domain.HydraClient) bool { + ownerID, reserved := reservedSystemClientOwnerID(client.ClientName) + if !reserved { + return false + } + return !strings.EqualFold(strings.TrimSpace(client.ClientID), ownerID) +} + +func isHiddenSystemClient(client domain.HydraClient) bool { + return isProtectedSystemClient(client) || isReservedSystemClientAlias(client) +} + +func reservedSystemClientOwnerID(name string) (string, bool) { + ownerID, ok := reservedSystemClientNames[strings.ToLower(strings.TrimSpace(name))] + return ownerID, ok +} + +func validateReservedSystemClientName(clientID, name string) error { + ownerID, reserved := reservedSystemClientOwnerID(name) + if !reserved { + return nil + } + if strings.EqualFold(strings.TrimSpace(clientID), ownerID) { + return nil + } + return fmt.Errorf("forbidden: reserved system client name") +} + func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) if (!ok || profile == nil) && h.Auth != nil { @@ -570,7 +603,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { items := make([]clientSummary, 0, len(clients)) for _, client := range clients { - if isProtectedSystemClient(client) { + if isHiddenSystemClient(client) { continue } @@ -621,7 +654,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - if isProtectedSystemClient(*client) { + if isHiddenSystemClient(*client) { return errorJSON(c, fiber.StatusNotFound, "client not found") } @@ -699,7 +732,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - if isProtectedSystemClient(*current) { + if isHiddenSystemClient(*current) { return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") } @@ -792,6 +825,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if name == "" { name = clientID } + if err := validateReservedSystemClientName(clientID, name); err != nil { + return errorJSON(c, fiber.StatusForbidden, err.Error()) + } redirectURIs := derefSlice(req.RedirectURIs, nil) if len(redirectURIs) == 0 { @@ -927,7 +963,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - if isProtectedSystemClient(*current) { + if isHiddenSystemClient(*current) { return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") } @@ -1012,6 +1048,9 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { TokenEndpointAuthMethod: resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod), Metadata: metadata, } + if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { + return errorJSON(c, fiber.StatusForbidden, err.Error()) + } h.setAuditDetailsExtra(c, map[string]any{ "action": "UPDATE_CLIENT", @@ -1062,7 +1101,7 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - if isProtectedSystemClient(*current) { + if isHiddenSystemClient(*current) { return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") } @@ -1301,7 +1340,7 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - if isProtectedSystemClient(*current) { + if isHiddenSystemClient(*current) { return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") } @@ -1502,7 +1541,7 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error { var totalClients int64 if err == nil { for _, client := range clients { - if isProtectedSystemClient(client) { + if isHiddenSystemClient(client) { continue } if isSuperAdmin { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index a7c03619..37a55a09 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -124,6 +124,86 @@ func TestListClients_Success(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } +func TestCreateClient_ReservedSystemNameForbidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + t.Fatalf("hydra should not be called when reserved system name is rejected") + return 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: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Post("/api/v1/dev/clients", h.CreateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "AdminFront", + "type": "pkce", + "redirectUris": []string{"http://localhost/cb"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestUpdateClient_ReservedSystemNameForbidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "client-1", + "client_name": "App One", + "redirect_uris": []string{ + "http://localhost/cb", + }, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "scope": "openid profile email offline_access", + "token_endpoint_auth_method": "none", + "metadata": map[string]any{"status": "active"}, + }), nil + } + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { + t.Fatalf("hydra update should not be called when reserved system name is rejected") + } + 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: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Put("/api/v1/dev/clients/:id", h.UpdateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "DevFront", + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + func TestListClients_ProtectedSystemClientHidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { @@ -162,6 +242,82 @@ func TestListClients_ProtectedSystemClientHidden(t *testing.T) { assert.Equal(t, "client-1", res.Items[0].ID) } +func TestListClients_ReservedSystemNameAliasHidden(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": "adminfront", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}}, + {"client_id": "4f2c9fd6-1111-2222-3333-444444444444", "client_name": "AdminFront", "metadata": map[string]interface{}{"status": "active"}}, + {"client_id": "devfront", "client_name": "DevFront", "metadata": map[string]interface{}{"status": "active"}}, + {"client_id": "7d2c9fd6-1111-2222-3333-444444444444", "client_name": "DevFront", "metadata": map[string]interface{}{"status": "active"}}, + {"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 result clientListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Len(t, result.Items, 3) + assert.Equal(t, "adminfront", result.Items[0].ID) + assert.Equal(t, "devfront", result.Items[1].ID) + assert.Equal(t, "client-1", result.Items[2].ID) +} + +func TestGetClient_ReservedSystemNameAliasHidden(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/clients/4f2c9fd6-1111-2222-3333-444444444444" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "4f2c9fd6-1111-2222-3333-444444444444", + "client_name": "AdminFront", + "metadata": map[string]interface{}{"status": "active"}, + }), 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: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Get("/api/v1/dev/clients/:id", h.GetClient) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/4f2c9fd6-1111-2222-3333-444444444444", nil) + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + 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" {