Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ Usage of ./observatorium-api:
File containing the TLS client key to authenticate against upstream logs servers. Leave blank to disable mTLS.
-logs.tls.watch-certs
Watch for certificate changes and reload
-logs.user-field string
The name of the structured metadata field that should hold the user ID in logs queries (e.g., 'user_id'). When not set (default), admin query detection is disabled and all queries use resource='logs'. This is opt-in only to ensure non-breaking changes.
-logs.write-timeout duration
The HTTP write timeout for proxied requests to the logs endpoint. (default 10m0s)
-logs.write.endpoint string
Expand Down
98 changes: 98 additions & 0 deletions api/logs/v1/admin_query_detector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package http

import (
"fmt"
"net/http"
"regexp"
"strings"

"github.com/observatorium/api/authentication"
"github.com/observatorium/api/authorization"
logqlv2 "github.com/observatorium/api/logql/v2"
)

// WithAdminQueryDetector returns a middleware that detects if a query is an "admin query".
// An admin query is one that:
// - Does not contain the specified user field filter
// - Contains the user field filter with a value different from the authenticated subject (including empty)
//
// When an admin query is detected, a flag is stored in the request context that can be
// used by downstream middleware (like authorization) to modify the resource desired by subject sent to OPA.
func WithAdminQueryDetector(fieldName string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip detection for /series endpoint as it uses "match" parameter
// which only supports stream labels, not structured metadata fields
if strings.HasSuffix(r.URL.Path, "/series") {
next.ServeHTTP(w, r)
return
}

// No query parameter, treat as regular query (not admin)
queryString := r.URL.Query().Get(queryParam)
if queryString == "" {
next.ServeHTTP(w, r)
return
}

// No subject in context, can't determine admin status
// Let the request continue without setting the flag
subject, ok := authentication.GetSubject(r.Context())
if !ok {
next.ServeHTTP(w, r)
return
}

isAdmin, _ := detectAdminQuery(fieldName, subject, queryString)
// Note: We ignore the error from parsing. If parsing fails, detectAdminQuery
// returns true (admin query) as a fail-safe approach.

// Store the result in context
ctx := authorization.WithIsAdminQuery(r.Context(), isAdmin)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

// detectAdminQuery analyzes a LogQL query to determine if it's an admin query.
// Returns true if:
// - The user field is not present in the query
// - The user field value doesn't match the authenticated subject
// Returns false if the user field is present and matches the subject exactly.
func detectAdminQuery(fieldName, subject, queryString string) (bool, error) {
// If parsing fails, treat as admin query (fail-safe)
expr, err := logqlv2.ParseExpr(queryString)
if err != nil {
return true, err
}

// Walk the AST to find the user field
var foundUserField bool
var userFieldValue string

expr.Walk(func(e interface{}) {
if logQuery, ok := e.(*logqlv2.LogQueryExpr); ok {
queryStr := logQuery.String()
// Use regex to extract user field value
// Pattern: | fieldName = "value"
pattern := fmt.Sprintf(`\|\s*%s\s*=\s*"([^"]*)"`, regexp.QuoteMeta(fieldName))
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(queryStr)
if len(matches) >= 2 {
foundUserField = true
userFieldValue = matches[1]
}
}
})

// No user field → admin query
if !foundUserField {
return true, nil
}
// Different user or empty value → admin quer
if userFieldValue != subject {
return true, nil
}

return false, nil // User querying their own logs → NOT admin query
}
260 changes: 260 additions & 0 deletions api/logs/v1/admin_query_detector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
package http

import (
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/efficientgo/core/testutil"

"github.com/observatorium/api/authentication"
"github.com/observatorium/api/authorization"
)

func TestDetectAdminQuery(t *testing.T) {
tt := []struct {
desc string
fieldName string
subject string
queryString string
expectAdmin bool
expectError bool
}{
{
desc: "query without user field",
fieldName: "user_id",
subject: "john.doe@example.com",
queryString: `{namespace="prod"}`,
expectAdmin: true,
expectError: false,
},
{
desc: "query with user field matching subject",
fieldName: "user_id",
subject: "john.doe@example.com",
queryString: `{namespace="prod"} | user_id = "john.doe@example.com"`,
expectAdmin: false,
expectError: false,
},
{
desc: "query with user field NOT matching subject",
fieldName: "user_id",
subject: "john.doe@example.com",
queryString: `{namespace="prod"} | user_id = "jane.smith@example.com"`,
expectAdmin: true,
expectError: false,
},
{
desc: "query with empty user field value",
fieldName: "user_id",
subject: "john.doe@example.com",
queryString: `{namespace="prod"} | user_id = ""`,
expectAdmin: true,
expectError: false,
},
{
desc: "query with user field and other filters",
fieldName: "user_id",
subject: "admin@example.com",
queryString: `{namespace="prod"} | user_id = "admin@example.com" | json | level = "error"`,
expectAdmin: false,
expectError: false,
},
{
desc: "custom field name",
fieldName: "subject",
subject: "user123",
queryString: `{namespace="prod"} | subject = "user123"`,
expectAdmin: false,
expectError: false,
},
{
desc: "custom field name with mismatch",
fieldName: "subject",
subject: "user123",
queryString: `{namespace="prod"} | subject = "user456"`,
expectAdmin: true,
expectError: false,
},
{
desc: "invalid LogQL query",
fieldName: "user_id",
subject: "john.doe@example.com",
queryString: `{this is not valid logql`,
expectAdmin: true,
expectError: true,
},
{
desc: "metric expression without log query",
fieldName: "user_id",
subject: "john.doe@example.com",
queryString: `100 * 100`,
expectAdmin: true,
expectError: false,
},
{
desc: "query with multiple pipeline stages",
fieldName: "user_id",
subject: "test@example.com",
queryString: `{app="myapp"} | json | line_format "{{.msg}}" | user_id = "test@example.com"`,
expectAdmin: false,
expectError: false,
},
}

for _, tc := range tt {
t.Run(tc.desc, func(t *testing.T) {
isAdmin, err := detectAdminQuery(tc.fieldName, tc.subject, tc.queryString)

if tc.expectError {
testutil.Assert(t, err != nil, "expected error but got none")
} else {
testutil.Ok(t, err)
}

testutil.Equals(t, tc.expectAdmin, isAdmin,
"expected isAdmin=%v, got isAdmin=%v", tc.expectAdmin, isAdmin)
})
}
}

func TestWithAdminQueryDetectorMiddleware(t *testing.T) {
tt := []struct {
desc string
fieldName string
subject string
urlPath string
queryParam string
expectAdminFlag bool
expectFlagPresent bool
}{
{
desc: "query without user field - should set admin flag",
fieldName: "user_id",
subject: "john.doe@example.com",
urlPath: "/loki/api/v1/query",
queryParam: `{namespace="prod"}`,
expectAdminFlag: true,
expectFlagPresent: true,
},
{
desc: "query with matching user field - should not set admin flag",
fieldName: "user_id",
subject: "john.doe@example.com",
urlPath: "/loki/api/v1/query",
queryParam: `{namespace="prod"} | user_id = "john.doe@example.com"`,
expectAdminFlag: false,
expectFlagPresent: true,
},
{
desc: "query with different user field - should set admin flag",
fieldName: "user_id",
subject: "john.doe@example.com",
urlPath: "/loki/api/v1/query",
queryParam: `{namespace="prod"} | user_id = "jane.smith@example.com"`,
expectAdminFlag: true,
expectFlagPresent: true,
},
{
desc: "series endpoint - should skip detection",
fieldName: "user_id",
subject: "john.doe@example.com",
urlPath: "/loki/api/v1/series",
queryParam: `{namespace="prod"}`,
expectAdminFlag: false,
expectFlagPresent: false,
},
{
desc: "empty query parameter - should skip detection",
fieldName: "user_id",
subject: "john.doe@example.com",
urlPath: "/loki/api/v1/labels",
queryParam: "",
expectAdminFlag: false,
expectFlagPresent: false,
},
{
desc: "query_range endpoint with admin query",
fieldName: "user_id",
subject: "admin@example.com",
urlPath: "/loki/api/v1/query_range",
queryParam: `{app="test"}`,
expectAdminFlag: true,
expectFlagPresent: true,
},
{
desc: "values endpoint with user query",
fieldName: "user_id",
subject: "user@example.com",
urlPath: "/loki/api/v1/label/namespace/values",
queryParam: `{namespace="prod"} | user_id = "user@example.com"`,
expectAdminFlag: false,
expectFlagPresent: true,
},
}

for _, tc := range tt {
t.Run(tc.desc, func(t *testing.T) {
var capturedAdminFlag bool
var capturedFlagPresent bool

// Create a next handler that captures the admin flag from context
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAdminFlag, capturedFlagPresent = authorization.GetIsAdminQuery(r.Context())
w.WriteHeader(http.StatusOK)
})

// Create the middleware
middleware := WithAdminQueryDetector(tc.fieldName)
handler := middleware(nextHandler)

// Create request URL with query parameter
reqURL := tc.urlPath
if tc.queryParam != "" {
reqURL += "?query=" + url.QueryEscape(tc.queryParam)
}
req := httptest.NewRequest("GET", reqURL, nil)

// Add subject to context
ctx := authentication.WithSubjectForTesting(req.Context(), tc.subject)
req = req.WithContext(ctx)

// Execute the handler
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

// Verify the flag was set correctly
testutil.Equals(t, tc.expectFlagPresent, capturedFlagPresent,
"expected flag present=%v, got flag present=%v", tc.expectFlagPresent, capturedFlagPresent)

if tc.expectFlagPresent {
testutil.Equals(t, tc.expectAdminFlag, capturedAdminFlag,
"expected admin flag=%v, got admin flag=%v", tc.expectAdminFlag, capturedAdminFlag)
}
})
}
}

func TestWithAdminQueryDetectorMiddleware_NoSubject(t *testing.T) {
// Test that middleware doesn't set flag when subject is missing
var capturedFlagPresent bool

nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, capturedFlagPresent = authorization.GetIsAdminQuery(r.Context())
w.WriteHeader(http.StatusOK)
})

middleware := WithAdminQueryDetector("user_id")
handler := middleware(nextHandler)

reqURL := "/loki/api/v1/query?query=" + url.QueryEscape(`{namespace="prod"}`)
req := httptest.NewRequest("GET", reqURL, nil)
// Don't add subject to context

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

testutil.Equals(t, false, capturedFlagPresent,
"expected no flag when subject is missing")
}
14 changes: 14 additions & 0 deletions authentication/testing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package authentication

import "context"

// WithSubjectForTesting adds a subject to the context for testing purposes only.
// This should only be used in tests outside the authentication package that need to
// simulate an authenticated request (e.g., testing middleware that depends on GetSubject).
//
// Note: This function exists because subjectKey is intentionally private to prevent
// direct manipulation in production code. In real scenarios, subjects are set by
// authentication middleware (OIDC, mTLS, OpenShift, etc.).
func WithSubjectForTesting(ctx context.Context, subject string) context.Context {
return context.WithValue(ctx, subjectKey, subject)
}
Loading