diff --git a/internal/observabilityproxy/route.go b/internal/observabilityproxy/route.go new file mode 100644 index 000000000..7c692c0ae --- /dev/null +++ b/internal/observabilityproxy/route.go @@ -0,0 +1,146 @@ +/* +Copyright 2026 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package observabilityproxy + +import ( + "fmt" + "net/http" + "strings" + + authorizationv1 "k8s.io/api/authorization/v1" + "k8s.io/apimachinery/pkg/util/validation" +) + +// RouteKind is one of the four Kubernetes-shaped roots the proxy serves. +type RouteKind int + +const ( + NamespaceLogs RouteKind = iota + 1 + NamespaceMetrics + ApplicationLog + ApplicationMetrics +) + +func (k RouteKind) String() string { + switch k { + case NamespaceLogs: + return "namespace-logs" + case NamespaceMetrics: + return "namespace-metrics" + case ApplicationLog: + return "application-log" + case ApplicationMetrics: + return "application-metrics" + } + return "unknown" +} + +const ( + coreGroup = "cozystack.io" + appsGroup = "apps.cozystack.io" +) + +// Route is a request path split into the scope it names and the upstream +// endpoint below the root. Plural and Name are set only on application roots. +type Route struct { + Kind RouteKind + Namespace string + Plural string + Name string + Endpoint string +} + +// HTTPError carries the status code the proxy answers with. +type HTTPError struct { + Code int + Message string +} + +func (e *HTTPError) Error() string { return fmt.Sprintf("%d: %s", e.Code, e.Message) } + +func errorf(code int, format string, args ...any) *HTTPError { + return &HTTPError{Code: code, Message: fmt.Sprintf(format, args...)} +} + +// ParseRoute takes the escaped request path. Percent-encoding is refused +// outright: a decoded "%2F" would shift the segments the authorization is +// derived from. +func ParseRoute(escapedPath string) (Route, *HTTPError) { + notFound := errorf(http.StatusNotFound, "not found") + if strings.Contains(escapedPath, "%") { + return Route{}, notFound + } + segs := strings.Split(strings.TrimPrefix(escapedPath, "/"), "/") + if len(segs) > 0 && segs[len(segs)-1] == "" { + segs = segs[:len(segs)-1] + } + for _, s := range segs { + if s == "" || s == "." || s == ".." { + return Route{}, notFound + } + } + if len(segs) < 6 || segs[0] != "apis" || segs[2] != "v1alpha1" || segs[3] != "namespaces" { + return Route{}, notFound + } + + var r Route + var rest []string + switch { + case segs[1] == coreGroup && segs[5] == "logs": + r.Kind, rest = NamespaceLogs, segs[6:] + case segs[1] == coreGroup && segs[5] == "metrics": + r.Kind, rest = NamespaceMetrics, segs[6:] + case segs[1] == appsGroup && len(segs) >= 8 && segs[7] == "log": + r.Kind, rest = ApplicationLog, segs[8:] + case segs[1] == appsGroup && len(segs) >= 8 && segs[7] == "metrics": + r.Kind, rest = ApplicationMetrics, segs[8:] + default: + return Route{}, notFound + } + + r.Namespace = segs[4] + if errs := validation.IsDNS1123Label(r.Namespace); len(errs) > 0 { + return Route{}, errorf(http.StatusBadRequest, "invalid namespace %q: %s", r.Namespace, strings.Join(errs, "; ")) + } + if r.Kind == ApplicationLog || r.Kind == ApplicationMetrics { + r.Plural, r.Name = segs[5], segs[6] + if errs := validation.IsDNS1123Label(r.Plural); len(errs) > 0 { + return Route{}, errorf(http.StatusBadRequest, "invalid resource %q: %s", r.Plural, strings.Join(errs, "; ")) + } + if errs := validation.IsDNS1123Subdomain(r.Name); len(errs) > 0 { + return Route{}, errorf(http.StatusBadRequest, "invalid name %q: %s", r.Name, strings.Join(errs, "; ")) + } + } + r.Endpoint = strings.Join(rest, "/") + return r, nil +} + +// ResourceAttributes is the single SubjectAccessReview a route requires. It +// depends on the path only, so a POST query authorizes exactly like a GET. +func (r Route) ResourceAttributes() authorizationv1.ResourceAttributes { + switch r.Kind { + case NamespaceLogs: + return authorizationv1.ResourceAttributes{Verb: "list", Group: coreGroup, Resource: "logs", Namespace: r.Namespace} + case NamespaceMetrics: + return authorizationv1.ResourceAttributes{Verb: "list", Group: coreGroup, Resource: "metrics", Namespace: r.Namespace} + case ApplicationLog: + return authorizationv1.ResourceAttributes{Verb: "get", Group: appsGroup, Resource: r.Plural, Subresource: "log", Name: r.Name, Namespace: r.Namespace} + case ApplicationMetrics: + return authorizationv1.ResourceAttributes{Verb: "get", Group: appsGroup, Resource: r.Plural, Subresource: "metrics", Name: r.Name, Namespace: r.Namespace} + } + return authorizationv1.ResourceAttributes{} +} diff --git a/internal/observabilityproxy/route_test.go b/internal/observabilityproxy/route_test.go new file mode 100644 index 000000000..73d8e04cd --- /dev/null +++ b/internal/observabilityproxy/route_test.go @@ -0,0 +1,124 @@ +/* +Copyright 2026 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package observabilityproxy + +import ( + "net/http" + "testing" + + authorizationv1 "k8s.io/api/authorization/v1" +) + +func TestParseRoute(t *testing.T) { + tests := []struct { + name string + path string + wantCode int + want Route + }{ + { + name: "namespace logs", + path: "/apis/cozystack.io/v1alpha1/namespaces/tenant-a/logs/select/logsql/query", + want: Route{Kind: NamespaceLogs, Namespace: "tenant-a", Endpoint: "select/logsql/query"}, + }, + { + name: "namespace logs root without endpoint", + path: "/apis/cozystack.io/v1alpha1/namespaces/tenant-a/logs/", + want: Route{Kind: NamespaceLogs, Namespace: "tenant-a"}, + }, + { + name: "namespace metrics", + path: "/apis/cozystack.io/v1alpha1/namespaces/tenant-a/metrics/api/v1/query", + want: Route{Kind: NamespaceMetrics, Namespace: "tenant-a", Endpoint: "api/v1/query"}, + }, + { + name: "application log", + path: "/apis/apps.cozystack.io/v1alpha1/namespaces/tenant-a/postgreses/db.main/log/select/logsql/tail", + want: Route{Kind: ApplicationLog, Namespace: "tenant-a", Plural: "postgreses", Name: "db.main", Endpoint: "select/logsql/tail"}, + }, + { + name: "application metrics", + path: "/apis/apps.cozystack.io/v1alpha1/namespaces/tenant-a/postgreses/db/metrics/api/v1/query_range", + want: Route{Kind: ApplicationMetrics, Namespace: "tenant-a", Plural: "postgreses", Name: "db", Endpoint: "api/v1/query_range"}, + }, + {name: "unknown group", path: "/apis/core.cozystack.io/v1alpha1/namespaces/a/logs/x", wantCode: http.StatusNotFound}, + {name: "unknown version", path: "/apis/cozystack.io/v1/namespaces/a/logs/x", wantCode: http.StatusNotFound}, + {name: "unknown namespace resource", path: "/apis/cozystack.io/v1alpha1/namespaces/a/events/x", wantCode: http.StatusNotFound}, + {name: "unknown app subresource", path: "/apis/apps.cozystack.io/v1alpha1/namespaces/a/postgreses/db/exec/x", wantCode: http.StatusNotFound}, + {name: "core api", path: "/api/v1/namespaces/a/pods", wantCode: http.StatusNotFound}, + {name: "truncated", path: "/apis/cozystack.io/v1alpha1/namespaces/a", wantCode: http.StatusNotFound}, + {name: "empty segment", path: "/apis/cozystack.io/v1alpha1/namespaces/a/logs/select//logsql/query", wantCode: http.StatusNotFound}, + {name: "dot dot segment", path: "/apis/cozystack.io/v1alpha1/namespaces/a/logs/../metrics/x", wantCode: http.StatusNotFound}, + {name: "percent encoded", path: "/apis/cozystack.io/v1alpha1/namespaces/a%2Fb/logs/x", wantCode: http.StatusNotFound}, + {name: "uppercase namespace", path: "/apis/cozystack.io/v1alpha1/namespaces/Tenant/logs/x", wantCode: http.StatusBadRequest}, + {name: "namespace with dot", path: "/apis/cozystack.io/v1alpha1/namespaces/a.b/logs/x", wantCode: http.StatusBadRequest}, + {name: "namespace with quote", path: `/apis/cozystack.io/v1alpha1/namespaces/a"b/logs/x`, wantCode: http.StatusBadRequest}, + {name: "name with logsql metacharacters", path: `/apis/apps.cozystack.io/v1alpha1/namespaces/a/postgreses/db"|union/log/x`, wantCode: http.StatusBadRequest}, + {name: "name with braces", path: "/apis/apps.cozystack.io/v1alpha1/namespaces/a/postgreses/{db}/log/x", wantCode: http.StatusBadRequest}, + {name: "plural uppercase", path: "/apis/apps.cozystack.io/v1alpha1/namespaces/a/Postgreses/db/log/x", wantCode: http.StatusBadRequest}, + {name: "plural with dot", path: "/apis/apps.cozystack.io/v1alpha1/namespaces/a/postgres.es/db/log/x", wantCode: http.StatusBadRequest}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseRoute(tt.path) + if tt.wantCode != 0 { + if err == nil { + t.Fatalf("ParseRoute(%q) = %+v, want error with code %d", tt.path, got, tt.wantCode) + } + if err.Code != tt.wantCode { + t.Fatalf("ParseRoute(%q) code = %d (%s), want %d", tt.path, err.Code, err.Message, tt.wantCode) + } + return + } + if err != nil { + t.Fatalf("ParseRoute(%q) error = %v", tt.path, err) + } + if got != tt.want { + t.Fatalf("ParseRoute(%q) = %+v, want %+v", tt.path, got, tt.want) + } + }) + } +} + +func TestRouteResourceAttributes(t *testing.T) { + tests := []struct { + route Route + want authorizationv1.ResourceAttributes + }{ + { + route: Route{Kind: NamespaceLogs, Namespace: "a"}, + want: authorizationv1.ResourceAttributes{Verb: "list", Group: "cozystack.io", Resource: "logs", Namespace: "a"}, + }, + { + route: Route{Kind: NamespaceMetrics, Namespace: "a"}, + want: authorizationv1.ResourceAttributes{Verb: "list", Group: "cozystack.io", Resource: "metrics", Namespace: "a"}, + }, + { + route: Route{Kind: ApplicationLog, Namespace: "a", Plural: "postgreses", Name: "db"}, + want: authorizationv1.ResourceAttributes{Verb: "get", Group: "apps.cozystack.io", Resource: "postgreses", Subresource: "log", Name: "db", Namespace: "a"}, + }, + { + route: Route{Kind: ApplicationMetrics, Namespace: "a", Plural: "postgreses", Name: "db"}, + want: authorizationv1.ResourceAttributes{Verb: "get", Group: "apps.cozystack.io", Resource: "postgreses", Subresource: "metrics", Name: "db", Namespace: "a"}, + }, + } + for _, tt := range tests { + if got := tt.route.ResourceAttributes(); got != tt.want { + t.Errorf("%v.ResourceAttributes() = %+v, want %+v", tt.route.Kind, got, tt.want) + } + } +}