feat(observability-proxy): parse the four tenant observability roots

The proxy derives its single SubjectAccessReview from the request path,
so the path has to be split and validated before anything else runs.
Names are DNS-1123 checked because they end up quoted inside LogsQL and
PromQL scopes, and percent-encoding is refused so a decoded slash
cannot shift the segments authorization is built from.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
This commit is contained in:
ZverGuy committed 2026-11-09 15:48:57 +07:00
1 parent 4087218a02
commit 8e35ff521c
2 files changed
+270

No files matched your search

+146
View File
@@ -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{}
}
+124
View File
@@ -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)
}
}
}