Skip to content

Commit 54a7803

Browse files
committed
Add trace query RBAC
Signed-off-by: Pavol Loffay <[email protected]>
1 parent 1bcf722 commit 54a7803

File tree

8 files changed

+1969
-140
lines changed

8 files changed

+1969
-140
lines changed

Diff for: README.md

+2
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ Usage of ./observatorium-api:
183183
File containing the default x509 Certificate for HTTPS. Leave blank to disable TLS.
184184
-tls.server.key-file string
185185
File containing the default x509 private key matching --tls.server.cert-file. Leave blank to disable TLS.
186+
-traces.query-rbac
187+
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.
186188
-traces.read.endpoint string
187189
The endpoint against which to make HTTP read requests for traces.
188190
-traces.tempo.endpoint string

Diff for: api/logs/v1/labels_enforcer.go

+19
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package http
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"net/http"
@@ -23,6 +24,8 @@ const (
2324
queryParam = "query"
2425
)
2526

27+
type matchersContextKey struct{}
28+
2629
// WithEnforceAuthorizationLabels return a middleware that ensures every query
2730
// has a set of labels returned by the OPA authorizer enforced.
2831
func WithEnforceAuthorizationLabels() func(http.Handler) http.Handler {
@@ -49,6 +52,7 @@ func WithEnforceAuthorizationLabels() func(http.Handler) http.Handler {
4952

5053
return
5154
}
55+
r = r.WithContext(context.WithValue(r.Context(), matchersContextKey{}, matchersInfo))
5256

5357
q, err := enforceValues(matchersInfo, r.URL)
5458
if err != nil {
@@ -178,3 +182,18 @@ func initAuthzMatchers(lm []*labels.Matcher) ([]*labels.Matcher, error) {
178182

179183
return lm, nil
180184
}
185+
186+
// AllowedNamespaces returns the list of namespaces that the user is allowed to list.
187+
func AllowedNamespaces(ctx context.Context) []string {
188+
matchers := ctx.Value(matchersContextKey{})
189+
if matchers == nil {
190+
return nil
191+
}
192+
matchersTyped := matchers.(AuthzResponseData)
193+
194+
var namespaces []string
195+
for _, m := range matchersTyped.Matchers {
196+
namespaces = append(namespaces, strings.Split(m.Value, "|")...)
197+
}
198+
return namespaces
199+
}

Diff for: api/traces/v1/http.go

+11
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type handlerConfiguration struct {
3737
registry *prometheus.Registry
3838
instrument handlerInstrumenter
3939
spanRoutePrefix string
40+
enableRBAC bool
4041
readMiddlewares []func(http.Handler) http.Handler
4142
writeMiddlewares []func(http.Handler) http.Handler
4243
tempoMiddlewares []func(http.Handler) http.Handler
@@ -94,6 +95,13 @@ func WithWriteMiddleware(m func(http.Handler) http.Handler) HandlerOption {
9495
}
9596
}
9697

98+
// WithTempoEnableResponseQueryRBACFilter enables query RBAC.
99+
func WithTempoEnableResponseQueryRBACFilter(enableQueryRBAC bool) HandlerOption {
100+
return func(h *handlerConfiguration) {
101+
h.enableRBAC = enableQueryRBAC
102+
}
103+
}
104+
97105
type handlerInstrumenter interface {
98106
NewHandler(labels prometheus.Labels, handler http.Handler) http.HandlerFunc
99107
}
@@ -253,6 +261,9 @@ func NewV2Handler(read *url.URL, readTemplate string, tempo *url.URL, writeOTLPH
253261
ErrorLog: proxy.Logger(c.logger),
254262
Transport: otelhttp.NewTransport(t),
255263
}
264+
if c.enableRBAC {
265+
tempoProxyRead.ModifyResponse = responseRBACModifier(c.logger)
266+
}
256267

257268
r.Group(func(r chi.Router) {
258269
r.Use(c.tempoMiddlewares...)

Diff for: api/traces/v1/trace_rbac.go

+237
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package v1
2+
3+
import (
4+
"bytes"
5+
"compress/flate"
6+
"compress/gzip"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
13+
"github.com/go-kit/log"
14+
"github.com/go-kit/log/level"
15+
16+
// nolint:staticcheck
17+
"github.com/golang/protobuf/jsonpb"
18+
"github.com/grafana/tempo/pkg/tempopb"
19+
commonv1 "github.com/grafana/tempo/pkg/tempopb/common/v1"
20+
tracev1 "github.com/grafana/tempo/pkg/tempopb/trace/v1"
21+
22+
apilogsv1 "github.com/observatorium/api/api/logs/v1"
23+
)
24+
25+
const (
26+
namespaceAttributeKey = "k8s.namespace.name"
27+
serviceAttributeKey = "service.name"
28+
)
29+
30+
func WithTraceQLNamespaceSelect() func(http.Handler) http.Handler {
31+
return func(next http.Handler) http.Handler {
32+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+
// do not run if request is not for Tempo
34+
if !strings.Contains(r.URL.Path, "/tempo") {
35+
next.ServeHTTP(w, r)
36+
return
37+
}
38+
39+
// block other APIs than api/search and api/traces
40+
if !strings.Contains(r.URL.Path, "/api/traces") &&
41+
!strings.Contains(r.URL.Path, "/api/search") {
42+
w.WriteHeader(http.StatusForbidden)
43+
return
44+
}
45+
46+
if strings.Contains(r.URL.Path, "/api/search") {
47+
query := r.URL.Query()
48+
q := query.Get("q")
49+
traceQL, err := url.QueryUnescape(q)
50+
if err != nil {
51+
next.ServeHTTP(w, r)
52+
return
53+
}
54+
if traceQL == "" {
55+
traceQL = "{ }"
56+
}
57+
traceQL = traceQL + " | select(resource.k8s.namespace.name)"
58+
query.Set("q", traceQL)
59+
r.URL.RawQuery = query.Encode()
60+
}
61+
62+
next.ServeHTTP(w, r)
63+
})
64+
}
65+
}
66+
67+
func responseRBACModifier(log log.Logger) func(response *http.Response) error {
68+
return func(response *http.Response) error {
69+
if strings.Contains(response.Request.URL.Path, "/api/traces/") || strings.Contains(response.Request.URL.Path, "/api/search") {
70+
allowedNamespaces := map[string]bool{}
71+
namespaces := apilogsv1.AllowedNamespaces(response.Request.Context())
72+
for _, ns := range namespaces {
73+
allowedNamespaces[ns] = true
74+
}
75+
level.Debug(log).Log("AllowedNamespaces", allowedNamespaces)
76+
77+
if response.StatusCode == http.StatusOK {
78+
// Uncompressed reader
79+
var reader io.ReadCloser
80+
var err error
81+
82+
// Read what Jaeger UI sent back (which might be compressed)
83+
switch response.Header.Get("Content-Encoding") {
84+
case "gzip":
85+
reader, err = gzip.NewReader(response.Body)
86+
if err != nil {
87+
return err
88+
}
89+
defer reader.Close()
90+
case "deflate":
91+
reader = flate.NewReader(response.Body)
92+
defer reader.Close()
93+
default:
94+
reader = response.Body
95+
}
96+
97+
b, err := io.ReadAll(reader)
98+
if err != nil {
99+
return err
100+
}
101+
102+
responseBuffer := &bytes.Buffer{}
103+
if strings.Contains(response.Request.URL.Path, "/api/traces/") {
104+
trace := &tempopb.Trace{}
105+
err = tempopb.UnmarshalFromJSONV1(b, trace)
106+
if err != nil {
107+
return err
108+
}
109+
trace = traceRBAC(allowedNamespaces, trace)
110+
111+
traceResponseBody, err := tempopb.MarshalToJSONV1(trace)
112+
if err != nil {
113+
return err
114+
}
115+
responseBuffer = bytes.NewBuffer(traceResponseBody)
116+
} else {
117+
searchResponse := &tempopb.SearchResponse{}
118+
err = jsonpb.UnmarshalString(string(b), searchResponse)
119+
if err != nil {
120+
return err
121+
}
122+
searchResponse = searchResponseRBAC(allowedNamespaces, searchResponse)
123+
124+
marshaller := jsonpb.Marshaler{}
125+
err = marshaller.Marshal(responseBuffer, searchResponse)
126+
if err != nil {
127+
return err
128+
}
129+
}
130+
response.Body = io.NopCloser(responseBuffer)
131+
response.Header["Content-Length"] = []string{fmt.Sprint(responseBuffer.Len())}
132+
// We could re-encode in gzip/deflate, but there is no need, so send it raw
133+
response.Header["Content-Encoding"] = []string{}
134+
}
135+
136+
return nil
137+
}
138+
139+
return nil
140+
}
141+
}
142+
143+
func traceRBAC(allowedNamespaces map[string]bool, trace *tempopb.Trace) *tempopb.Trace {
144+
for _, rs := range trace.ResourceSpans {
145+
notAllowedNamespace := ""
146+
missingNamespaceAttribute := true
147+
if rs.Resource != nil && rs.Resource.Attributes != nil {
148+
for _, resAttr := range rs.Resource.Attributes {
149+
if resAttr.Key == namespaceAttributeKey {
150+
missingNamespaceAttribute = false
151+
if !allowedNamespaces[resAttr.Value.GetStringValue()] {
152+
notAllowedNamespace = resAttr.Value.GetStringValue()
153+
for _, scopeSpan := range rs.ScopeSpans {
154+
scopeSpan.Scope.Attributes = []*commonv1.KeyValue{}
155+
for _, span := range scopeSpan.Spans {
156+
span.Attributes = []*commonv1.KeyValue{}
157+
span.Events = []*tracev1.Span_Event{}
158+
}
159+
}
160+
}
161+
}
162+
}
163+
// when resource attribute is missing, all attributes are removed
164+
if missingNamespaceAttribute {
165+
serviceAttribute := getAttribute(rs.Resource.Attributes, serviceAttributeKey)
166+
rs.Resource.Attributes = []*commonv1.KeyValue{}
167+
if serviceAttribute != nil {
168+
rs.Resource.Attributes = append(rs.Resource.Attributes, serviceAttribute)
169+
}
170+
for _, scopeSpan := range rs.ScopeSpans {
171+
scopeSpan.Scope.Attributes = []*commonv1.KeyValue{}
172+
for _, span := range scopeSpan.Spans {
173+
span.Attributes = []*commonv1.KeyValue{}
174+
span.Events = []*tracev1.Span_Event{}
175+
}
176+
}
177+
}
178+
// add namespace back if it was not allowed
179+
if notAllowedNamespace != "" {
180+
serviceAttribute := getAttribute(rs.Resource.Attributes, serviceAttributeKey)
181+
rs.Resource.Attributes = []*commonv1.KeyValue{}
182+
rs.Resource.Attributes = append(rs.Resource.Attributes, &commonv1.KeyValue{
183+
Key: namespaceAttributeKey,
184+
Value: &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: notAllowedNamespace}},
185+
})
186+
rs.Resource.Attributes = append(rs.Resource.Attributes, serviceAttribute)
187+
}
188+
}
189+
}
190+
return trace
191+
}
192+
193+
func getAttribute(attributes []*commonv1.KeyValue, key string) *commonv1.KeyValue {
194+
for _, attr := range attributes {
195+
if attr.GetKey() == key {
196+
return attr
197+
}
198+
}
199+
return nil
200+
}
201+
202+
func searchResponseRBAC(allowedNamespaces map[string]bool, searchResponse *tempopb.SearchResponse) *tempopb.SearchResponse {
203+
for _, traceSearchMetadata := range searchResponse.GetTraces() {
204+
for i := range traceSearchMetadata.GetSpanSets() {
205+
traceSearchMetadata.SpanSets[i] = spanSetRBAC(allowedNamespaces, traceSearchMetadata.SpanSets[i])
206+
}
207+
traceSearchMetadata.SpanSet = spanSetRBAC(allowedNamespaces, traceSearchMetadata.GetSpanSet())
208+
}
209+
return searchResponse
210+
}
211+
212+
func spanSetRBAC(allowedNamespaces map[string]bool, spanSet *tempopb.SpanSet) *tempopb.SpanSet {
213+
for _, span := range spanSet.GetSpans() {
214+
notAllowedNamespace := ""
215+
missingNamespaceAttribute := true
216+
for _, attribute := range span.GetAttributes() {
217+
if attribute.GetKey() == namespaceAttributeKey {
218+
missingNamespaceAttribute = false
219+
if !allowedNamespaces[attribute.GetValue().GetStringValue()] {
220+
notAllowedNamespace = attribute.GetValue().GetStringValue()
221+
}
222+
}
223+
}
224+
if missingNamespaceAttribute {
225+
span.Attributes = []*commonv1.KeyValue{}
226+
}
227+
// remove attributes because span is from not allowed namespace
228+
if notAllowedNamespace != "" {
229+
span.Attributes = []*commonv1.KeyValue{}
230+
span.Attributes = append(span.Attributes, &commonv1.KeyValue{
231+
Key: namespaceAttributeKey,
232+
Value: &commonv1.AnyValue{Value: &commonv1.AnyValue_StringValue{StringValue: notAllowedNamespace}},
233+
})
234+
}
235+
}
236+
return spanSet
237+
}

0 commit comments

Comments
 (0)