Skip to content

Commit

Permalink
Add trace RBAC
Browse files Browse the repository at this point in the history
Signed-off-by: Pavol Loffay <[email protected]>
  • Loading branch information
pavolloffay committed Jan 22, 2025
1 parent e273f28 commit 84b9bcd
Show file tree
Hide file tree
Showing 8 changed files with 1,668 additions and 161 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ Usage of ./observatorium-api:
File containing the default x509 Certificate for HTTPS. Leave blank to disable TLS.
-tls.server.key-file string
File containing the default x509 private key matching --tls.server.cert-file. Leave blank to disable TLS.
-traces.query-rbac
Enables query RBAC. A user will be able to see attributes only from namespaces it has access to. Only the spans with allowed k8s.namespace.name attribute are fully visible.
-traces.read.endpoint string
The endpoint against which to make HTTP read requests for traces.
-traces.tempo.endpoint string
Expand Down
21 changes: 21 additions & 0 deletions api/logs/v1/labels_enforcer.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package http

import (
"context"
"encoding/json"
"fmt"
"net/http"
Expand All @@ -21,6 +22,8 @@ type AuthzResponseData struct {
const (
logicalOr = "or"
queryParam = "query"

matchersContextKey = "matchers"
)

// WithEnforceAuthorizationLabels return a middleware that ensures every query
Expand Down Expand Up @@ -49,6 +52,7 @@ func WithEnforceAuthorizationLabels() func(http.Handler) http.Handler {

return
}
r = r.WithContext(context.WithValue(r.Context(), matchersContextKey, matchersInfo))

q, err := enforceValues(matchersInfo, r.URL)
if err != nil {
Expand Down Expand Up @@ -178,3 +182,20 @@ func initAuthzMatchers(lm []*labels.Matcher) ([]*labels.Matcher, error) {

return lm, nil
}

// AllowedNamespaces returns the list of namespaces that the user is allowed to list.
func AllowedNamespaces(ctx context.Context) []string {
matchers := ctx.Value(matchersContextKey)
if matchers == nil {
return nil
}
matchersTyped := matchers.(AuthzResponseData)

var namespaces []string
for _, m := range matchersTyped.Matchers {
for _, ns := range strings.Split(m.Value, "|") {
namespaces = append(namespaces, ns)
}
}
return namespaces
}
11 changes: 11 additions & 0 deletions api/traces/v1/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type handlerConfiguration struct {
registry *prometheus.Registry
instrument handlerInstrumenter
spanRoutePrefix string
enableRBAC bool
readMiddlewares []func(http.Handler) http.Handler
writeMiddlewares []func(http.Handler) http.Handler
tempoMiddlewares []func(http.Handler) http.Handler
Expand Down Expand Up @@ -94,6 +95,13 @@ func WithWriteMiddleware(m func(http.Handler) http.Handler) HandlerOption {
}
}

// WithTempoEnableQueryRBAC enables query RBAC.
func WithTempoEnableQueryRBAC() HandlerOption {
return func(h *handlerConfiguration) {
h.enableRBAC = true
}
}

type handlerInstrumenter interface {
NewHandler(labels prometheus.Labels, handler http.Handler) http.HandlerFunc
}
Expand Down Expand Up @@ -253,6 +261,9 @@ func NewV2Handler(read *url.URL, readTemplate string, tempo *url.URL, writeOTLPH
ErrorLog: proxy.Logger(c.logger),
Transport: otelhttp.NewTransport(t),
}
if c.enableRBAC {
tempoProxyRead.ModifyResponse = responseRBACModifier(c.logger)
}

r.Group(func(r chi.Router) {
r.Use(c.tempoMiddlewares...)
Expand Down
182 changes: 182 additions & 0 deletions api/traces/v1/trace_rbac.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package v1

import (
"bytes"
"compress/flate"
"compress/gzip"
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/go-kit/log"
"github.com/go-kit/log/level"

"github.com/golang/protobuf/jsonpb"
"github.com/grafana/tempo/pkg/tempopb"
commonv1 "github.com/grafana/tempo/pkg/tempopb/common/v1"
tracev1 "github.com/grafana/tempo/pkg/tempopb/trace/v1"

apilogsv1 "github.com/observatorium/api/api/logs/v1"
)

func WithTraceQLNamespaceSelect() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
q := query.Get("q")
traceQL, err := url.QueryUnescape(q)
if err != nil {
next.ServeHTTP(w, r)
return
}
if traceQL == "" {
traceQL = "{ }"
}
traceQL = traceQL + " | select(resource.k8s.namespace.name)"
query.Set("q", traceQL)
r.URL.RawQuery = query.Encode()
next.ServeHTTP(w, r)
})
}
}

func responseRBACModifier(log log.Logger) func(response *http.Response) error {
return func(response *http.Response) error {
allowedNamespaces := map[string]bool{}
namespaces := apilogsv1.AllowedNamespaces(response.Request.Context())
for _, ns := range namespaces {
allowedNamespaces[ns] = true
}
level.Debug(log).Log("AllowedNamespaces", allowedNamespaces)

if strings.Contains(response.Request.URL.Path, "/api/traces/") || strings.Contains(response.Request.URL.Path, "/api/search") {
if response.StatusCode == http.StatusOK {
// Uncompressed reader
var reader io.ReadCloser
var err error

// Read what Jaeger UI sent back (which might be compressed)
switch response.Header.Get("Content-Encoding") {
case "gzip":
reader, err = gzip.NewReader(response.Body)
if err != nil {
return err
}
defer reader.Close()
case "deflate":
reader = flate.NewReader(response.Body)
defer reader.Close()
default:
reader = response.Body
}

b, err := io.ReadAll(reader)
if err != nil {
return err
}
strResponse := string(b)
buf := bytes.NewBufferString(strResponse)
response.Header["Content-Length"] = []string{fmt.Sprint(buf.Len())}
response.Body = io.NopCloser(buf)

if strings.Contains(response.Request.URL.Path, "/api/traces/") {
trace := &tempopb.Trace{}
err = tempopb.UnmarshalFromJSONV1(b, trace)
if err != nil {
return err
}
trace = traceRBAC(allowedNamespaces, trace)

traceResponseBody, err := tempopb.MarshalToJSONV1(trace)
if err != nil {
return err
}
response.Body = io.NopCloser(bytes.NewBuffer(traceResponseBody))
} else {
searchResponse := &tempopb.SearchResponse{}
err = jsonpb.UnmarshalString(string(b), searchResponse)
if err != nil {
return err
}
searchResponse = searchResponseRBAC(allowedNamespaces, searchResponse)

marshaller := jsonpb.Marshaler{}
responseBuffer := &bytes.Buffer{}
err = marshaller.Marshal(responseBuffer, searchResponse)
if err != nil {
return err
}
response.Body = io.NopCloser(responseBuffer)
}
// We could re-encode in gzip/deflate, but there is no need, so send it raw
response.Header["Content-Encoding"] = []string{}
}

return nil
}

return nil
}
}

func traceRBAC(allowedNamespaces map[string]bool, trace *tempopb.Trace) *tempopb.Trace {
for _, rs := range trace.ResourceSpans {
notAllowedNamespace := ""
if rs.Resource != nil && rs.Resource.Attributes != nil {
for _, resAttr := range rs.Resource.Attributes {
if resAttr.Key == "k8s.namespace.name" && !allowedNamespaces[resAttr.Value.GetStringValue()] {
notAllowedNamespace = resAttr.Value.GetStringValue()
for _, scopeSpan := range rs.ScopeSpans {
for _, span := range scopeSpan.Spans {
// clear all data
span.Attributes = []*commonv1.KeyValue{}
span.Events = []*tracev1.Span_Event{}
}
scopeSpan.Scope.Attributes = []*commonv1.KeyValue{}
}
}
}
// add namespace back if it was not allowed
if notAllowedNamespace != "" {
rs.Resource.Attributes = []*commonv1.KeyValue{}
rs.Resource.Attributes = append(rs.Resource.Attributes, &commonv1.KeyValue{
Key: "k8s.namespace.name",
Value: &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: notAllowedNamespace}},
})
}
}
}
return trace
}

func searchResponseRBAC(allowedNamespaces map[string]bool, searchResponse *tempopb.SearchResponse) *tempopb.SearchResponse {
for _, traceSearchMetadata := range searchResponse.GetTraces() {
for i := range traceSearchMetadata.GetSpanSets() {
traceSearchMetadata.SpanSets[i] = spanSetRBAC(allowedNamespaces, traceSearchMetadata.SpanSets[i])
}
traceSearchMetadata.SpanSet = spanSetRBAC(allowedNamespaces, traceSearchMetadata.GetSpanSet())
}
return searchResponse
}

func spanSetRBAC(allowedNamespaces map[string]bool, spanSet *tempopb.SpanSet) *tempopb.SpanSet {
for _, span := range spanSet.GetSpans() {
notAllowedNamespace := ""
for _, attribute := range span.GetAttributes() {
if attribute.GetKey() == "k8s.namespace.name" && !allowedNamespaces[attribute.GetValue().GetStringValue()] {
notAllowedNamespace = attribute.GetValue().GetStringValue()
}
}
// remove attributes because span is from not allowed namespace
if notAllowedNamespace != "" {
span.Attributes = []*commonv1.KeyValue{}
span.Attributes = append(span.Attributes, &commonv1.KeyValue{
Key: "k8s.namespace.name",
Value: &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: notAllowedNamespace}},
})
}
}
return spanSet
}
Loading

0 comments on commit 84b9bcd

Please sign in to comment.