Skip to content

Commit df54d19

Browse files
authored
Extract selectors from logs query to enable more fine-grained access control (observatorium#555)
1 parent 8cb4f4d commit df54d19

File tree

12 files changed

+312
-16
lines changed

12 files changed

+312
-16
lines changed

Diff for: README.md

+2
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ Usage of ./observatorium-api:
9191
The log format to use. Options: 'logfmt', 'json'. (default "logfmt")
9292
-log.level string
9393
The log filtering level. Options: 'error', 'warn', 'info', 'debug'. (default "info")
94+
-logs.auth.extract-selectors string
95+
Comma-separated list of stream selectors that should be extracted from queries and sent to OPA during authorization.
9496
-logs.read.endpoint string
9597
The endpoint against which to make read requests for logs.
9698
-logs.rules.endpoint string

Diff for: authorization/grpc.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func WithGRPCAuthorizers(authorizers map[string]rbac.Authorizer, methReq GRPCRBa
6767
return ctx, status.Error(codes.Unauthenticated, "error finding tenant id")
6868
}
6969

70-
_, ok, data := a.Authorize(subject, groups, accessReq.Permission, accessReq.Resource, tenant, tenantID, token)
70+
_, ok, data := a.Authorize(subject, groups, accessReq.Permission, accessReq.Resource, tenant, tenantID, token, nil)
7171
if !ok {
7272
level.Debug(logger).Log("msg", "gRPC Authorizer: insufficient auth", "subject", subject, "tenant", tenant)
7373
return ctx, status.Error(codes.PermissionDenied, "forbidden")

Diff for: authorization/http.go

+72-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package authorization
22

33
import (
44
"context"
5+
"fmt"
56
"net/http"
67

78
"github.com/observatorium/api/authentication"
@@ -16,6 +17,20 @@ const (
1617
// authorizationDataKey is the key that holds the authorization response data
1718
// in a request context.
1819
authorizationDataKey contextKey = "authzData"
20+
21+
// authorizationSelectorsKey is the key that holds the data about selectors present in the query.
22+
authorizationSelectorsKey contextKey = "authzQuerySelectors"
23+
)
24+
25+
type SelectorsInfo struct {
26+
Selectors map[string][]string
27+
HasWildcard bool
28+
}
29+
30+
var (
31+
emptySelectorsInfo = &SelectorsInfo{
32+
Selectors: map[string][]string{},
33+
}
1934
)
2035

2136
// GetData extracts the authz response data from provided context.
@@ -31,6 +46,49 @@ func WithData(ctx context.Context, data string) context.Context {
3146
return context.WithValue(ctx, authorizationDataKey, data)
3247
}
3348

49+
// GetSelectorsInfo extracts the query namespaces from the provided context.
50+
func GetSelectorsInfo(ctx context.Context) (*SelectorsInfo, bool) {
51+
value := ctx.Value(authorizationSelectorsKey)
52+
namespaces, ok := value.(*SelectorsInfo)
53+
54+
return namespaces, ok
55+
}
56+
57+
// WithSelectorsInfo extends the provided context with the query namespaces.
58+
func WithSelectorsInfo(ctx context.Context, info *SelectorsInfo) context.Context {
59+
return context.WithValue(ctx, authorizationSelectorsKey, info)
60+
}
61+
62+
// WithLogsStreamSelectorsExtractor returns a middleware that, when enabled, tries to extract
63+
// stream selectors from queries, so that they can be used in authorizing the request.
64+
func WithLogsStreamSelectorsExtractor(selectorNames []string) func(http.Handler) http.Handler {
65+
enabled := len(selectorNames) > 0
66+
67+
selectorNameMap := make(map[string]bool, len(selectorNames))
68+
for _, l := range selectorNames {
69+
selectorNameMap[l] = true
70+
}
71+
72+
return func(next http.Handler) http.Handler {
73+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+
if !enabled {
75+
next.ServeHTTP(w, r)
76+
77+
return
78+
}
79+
80+
selectorsInfo, err := extractLogStreamSelectors(selectorNameMap, r.URL.Query())
81+
if err != nil {
82+
httperr.PrometheusAPIError(w, fmt.Sprintf("error extracting selectors from query: %s", err), http.StatusInternalServerError)
83+
84+
return
85+
}
86+
87+
next.ServeHTTP(w, r.WithContext(WithSelectorsInfo(r.Context(), selectorsInfo)))
88+
})
89+
}
90+
}
91+
3492
// WithAuthorizers returns a middleware that authorizes subjects taken from a request context
3593
// for the given permission on the given resource for a tenant taken from a request context.
3694
func WithAuthorizers(authorizers map[string]rbac.Authorizer, permission rbac.Permission, resource string) func(http.Handler) http.Handler {
@@ -74,7 +132,20 @@ func WithAuthorizers(authorizers map[string]rbac.Authorizer, permission rbac.Per
74132
return
75133
}
76134

77-
statusCode, ok, data := a.Authorize(subject, groups, permission, resource, tenant, tenantID, token)
135+
selectorsInfo, ok := GetSelectorsInfo(r.Context())
136+
if !ok {
137+
selectorsInfo = emptySelectorsInfo
138+
}
139+
140+
metadataOnly := isMetadataRequest(r.URL.Path)
141+
142+
extraAttributes := &rbac.ExtraAttributes{
143+
Selectors: selectorsInfo.Selectors,
144+
WildcardSelectors: selectorsInfo.HasWildcard,
145+
MetadataOnly: metadataOnly,
146+
}
147+
148+
statusCode, ok, data := a.Authorize(subject, groups, permission, resource, tenant, tenantID, token, extraAttributes)
78149
if !ok {
79150
// Send 403 http.StatusForbidden
80151
w.WriteHeader(statusCode)

Diff for: authorization/meta.go

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package authorization
2+
3+
import (
4+
"strings"
5+
)
6+
7+
var (
8+
metaAbsolutePaths = map[string]bool{
9+
"/loki/api/v1/label": true,
10+
"/loki/api/v1/labels": true,
11+
"/loki/api/v1/series": true,
12+
"/api/prom/label": true,
13+
"/api/prom/series": true,
14+
}
15+
16+
metaPathLabelValuesNewPrefix = "/loki/api/v1/label/"
17+
metaPathLabelValuesOldPrefix = "/api/prom/label/"
18+
metaPathLabelValuesSuffix = "/values"
19+
)
20+
21+
func isMetadataRequest(path string) bool {
22+
if absolutePath := metaAbsolutePaths[path]; absolutePath {
23+
return true
24+
}
25+
26+
if (strings.HasPrefix(path, metaPathLabelValuesOldPrefix) || strings.HasPrefix(path, metaPathLabelValuesNewPrefix)) &&
27+
strings.HasSuffix(path, metaPathLabelValuesSuffix) {
28+
return true
29+
}
30+
31+
return false
32+
}

Diff for: authorization/meta_test.go

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package authorization
2+
3+
import "testing"
4+
5+
func TestIsMetaRequest(t *testing.T) {
6+
tests := []struct {
7+
path string
8+
want bool
9+
}{
10+
{
11+
path: "/loki/api/v1/labels",
12+
want: true,
13+
},
14+
{
15+
path: "/loki/api/v1/label/kubernetes_namespace_name/values",
16+
want: true,
17+
},
18+
{
19+
path: "/loki/api/v1/query_range",
20+
want: false,
21+
},
22+
}
23+
for _, tt := range tests {
24+
t.Run(tt.path, func(t *testing.T) {
25+
if got := isMetadataRequest(tt.path); got != tt.want {
26+
t.Errorf("isMetaRequest() = %v, want %v", got, tt.want)
27+
}
28+
})
29+
}
30+
}

Diff for: authorization/query.go

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package authorization
2+
3+
import (
4+
"net/url"
5+
"strings"
6+
7+
logqlv2 "github.com/observatorium/api/logql/v2"
8+
"github.com/prometheus/prometheus/model/labels"
9+
)
10+
11+
func extractLogStreamSelectors(selectorNames map[string]bool, values url.Values) (*SelectorsInfo, error) {
12+
query := values.Get("query")
13+
if query == "" {
14+
return emptySelectorsInfo, nil
15+
}
16+
17+
selectors, hasWildcard, err := parseLogStreamSelectors(selectorNames, query)
18+
if err != nil {
19+
return nil, err
20+
}
21+
22+
return &SelectorsInfo{
23+
Selectors: selectors,
24+
HasWildcard: hasWildcard,
25+
}, nil
26+
}
27+
28+
func parseLogStreamSelectors(selectorNames map[string]bool, query string) (map[string][]string, bool, error) {
29+
expr, err := logqlv2.ParseExpr(query)
30+
if err != nil {
31+
return nil, false, err
32+
}
33+
34+
selectors := make(map[string][]string)
35+
appendSelector := func(selector, value string) {
36+
values, ok := selectors[selector]
37+
if !ok {
38+
values = make([]string, 0)
39+
}
40+
41+
values = append(values, value)
42+
selectors[selector] = values
43+
}
44+
45+
hasWildcard := false
46+
expr.Walk(func(expr interface{}) {
47+
switch le := expr.(type) {
48+
case *logqlv2.StreamMatcherExpr:
49+
for _, m := range le.Matchers() {
50+
if _, ok := selectorNames[m.Name]; !ok {
51+
continue
52+
}
53+
54+
switch m.Type {
55+
case labels.MatchEqual:
56+
appendSelector(m.Name, m.Value)
57+
case labels.MatchRegexp:
58+
values := strings.Split(m.Value, "|")
59+
for _, v := range values {
60+
if strings.ContainsAny(v, ".+*") {
61+
hasWildcard = true
62+
continue
63+
}
64+
65+
appendSelector(m.Name, v)
66+
}
67+
}
68+
}
69+
default:
70+
// Do nothing
71+
}
72+
})
73+
74+
return selectors, hasWildcard, nil
75+
}

Diff for: authorization/query_test.go

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package authorization
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func Test_parseQuerySelectors(t *testing.T) {
9+
testSelectorLabels := map[string]bool{
10+
"namespace": true,
11+
"other_namespace_label": true,
12+
}
13+
tests := []struct {
14+
query string
15+
wantSelectors map[string][]string
16+
wantHasWildcard bool
17+
}{
18+
{
19+
query: `{namespace="test"}`,
20+
wantSelectors: map[string][]string{
21+
"namespace": {"test"},
22+
},
23+
},
24+
{
25+
query: `{namespace="test",other_namespace_label="test2"}`,
26+
wantSelectors: map[string][]string{
27+
"namespace": {"test"},
28+
"other_namespace_label": {"test2"},
29+
},
30+
},
31+
{
32+
query: `{namespace="test",namespace="test2"}`,
33+
wantSelectors: map[string][]string{
34+
"namespace": {"test", "test2"},
35+
},
36+
},
37+
{
38+
query: `{namespace=~"test|test2"}`,
39+
wantSelectors: map[string][]string{
40+
"namespace": {"test", "test2"},
41+
},
42+
},
43+
{
44+
query: `{namespace=~"test|test2|test3.+"}`,
45+
wantSelectors: map[string][]string{
46+
"namespace": {"test", "test2"},
47+
},
48+
wantHasWildcard: true,
49+
},
50+
}
51+
for _, tt := range tests {
52+
t.Run(tt.query, func(t *testing.T) {
53+
gotNamespaces, gotHasWildcard, err := parseLogStreamSelectors(testSelectorLabels, tt.query)
54+
if err != nil {
55+
t.Errorf("parseLogStreamSelectors() error = %v", err)
56+
}
57+
if !reflect.DeepEqual(gotNamespaces, tt.wantSelectors) {
58+
t.Errorf("parseLogStreamSelectors() got = %v, want %v", gotNamespaces, tt.wantSelectors)
59+
}
60+
if gotHasWildcard != tt.wantHasWildcard {
61+
t.Errorf("parseLogStreamSelectors() got = %v, want %v", gotHasWildcard, tt.wantHasWildcard)
62+
}
63+
})
64+
}
65+
}

Diff for: main.go

+11-2
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,9 @@ type logsConfig struct {
159159
tenantHeader string
160160
tenantLabel string
161161
// Allow only read-only access on rules
162-
rulesReadOnly bool
163-
rulesLabelFilters map[string][]string
162+
rulesReadOnly bool
163+
rulesLabelFilters map[string][]string
164+
authExtractSelectors []string
164165
// enable logs at least one {read,write,tail}Endpoint} is provided.
165166
enabled bool
166167
}
@@ -714,6 +715,7 @@ func main() {
714715
logsv1.WithWriteMiddleware(writePathRedirectProtection),
715716
logsv1.WithGlobalMiddleware(authentication.WithTenantMiddlewares(pm.Middlewares)),
716717
logsv1.WithGlobalMiddleware(authentication.WithTenantHeader(cfg.logs.tenantHeader, tenantIDs)),
718+
logsv1.WithReadMiddleware(authorization.WithLogsStreamSelectorsExtractor(cfg.logs.authExtractSelectors)),
717719
logsv1.WithReadMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "logs")),
718720
logsv1.WithReadMiddleware(logsv1.WithEnforceAuthorizationLabels()),
719721
logsv1.WithWriteMiddleware(authorization.WithAuthorizers(authorizers, rbac.Write, "logs")),
@@ -984,6 +986,7 @@ func parseFlags() (config, error) {
984986
rawLogsTailEndpoint string
985987
rawLogsWriteEndpoint string
986988
rawLogsRuleLabelFilters string
989+
rawLogsAuthExtractSelectors string
987990
rawTracesReadEndpoint string
988991
rawTracesWriteEndpoint string
989992
rawTracingEndpointType string
@@ -1048,6 +1051,8 @@ func parseFlags() (config, error) {
10481051
"The name of the rules label that should hold the tenant ID in logs upstreams.")
10491052
flag.StringVar(&rawLogsWriteEndpoint, "logs.write.endpoint", "",
10501053
"The endpoint against which to make write requests for logs.")
1054+
flag.StringVar(&rawLogsAuthExtractSelectors, "logs.auth.extract-selectors", "",
1055+
"Comma-separated list of stream selectors that should be extracted from queries and sent to OPA during authorization.")
10511056
flag.StringVar(&rawMetricsReadEndpoint, "metrics.read.endpoint", "",
10521057
"The endpoint against which to send read requests for metrics. It used as a fallback to 'query.endpoint' and 'query-range.endpoint'.")
10531058
flag.StringVar(&rawMetricsWriteEndpoint, "metrics.write.endpoint", "",
@@ -1176,6 +1181,10 @@ func parseFlags() (config, error) {
11761181
}
11771182

11781183
cfg.logs.readEndpoint = logsReadEndpoint
1184+
1185+
if rawLogsAuthExtractSelectors != "" {
1186+
cfg.logs.authExtractSelectors = strings.Split(rawLogsAuthExtractSelectors, ",")
1187+
}
11791188
}
11801189

11811190
if rawLogsRulesEndpoint != "" {

0 commit comments

Comments
 (0)