mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-13 08:05:19 +09:00
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:
1 parent
4087218a02
commit
8e35ff521c
2 files changed
+270
No files matched your search
@@ -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{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user