From 9b6b62d001f6d09c54538012b144c53a806241a1 Mon Sep 17 00:00:00 2001 From: Saswata Mukherjee Date: Mon, 14 Sep 2026 10:49:53 +0100 Subject: [PATCH] breaking/refactor: Split read/write tenancy model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This refactor reimagines the gateway's authentication and authorization model, replacing a complex RBAC/OPA system with a simpler, more flexible approach centered on mTLS authentication and tenant-based query filtering. **What changed:** - RBAC/OPA authorization removed entirely - Label enforcers reinstated but driven by tenant headers instead of RBAC roles - Write authentication: mTLS with tenant extracted from certificate OU field - Read authentication: SSO/OIDC or mTLS with tenant specified via HTTP headers - Multi-tenant queries now supported (e.g., query team-a|team-b|team-c data simultaneously) - URL structure simplified (tenant removed from paths) **What it enables:** - Machine writers: Certificate-based authentication with automatic tenant extraction - Human readers: Grafana-driven queries across multiple tenants with single datasource - Simplified operations: No RBAC YAML configuration required - Better security: Fewer moving parts, clearer authentication boundaries - Query-time tenant isolation: Labels automatically injected based on tenant headers ## Motivation The previous RBAC model required: - Complex per-tenant YAML configuration with role bindings and permissions - OPA policy engine for authorization decisions at query time - Tenant name embedded in URL paths (/api/{signal}/v1/{tenant}/...) - Label enforcers that consulted OPA to determine which labels to inject - Separate authorization checks for each request The new model simplifies this to: - Machine writers: mTLS with tenant in certificate OU field - Human readers: SSO/mTLS with tenant(s) in HTTP headers (set by Grafana) - Label enforcers driven directly by tenant headers (no OPA lookup required) - No RBAC or OPA required - Cleaner URLs without tenant in path ## Architecture Changes ### Before (Old RBAC Model) ``` Request with tenant in URL: /api/logs/v1/team-alpha/query ↓ Extract tenant from URL path ↓ Authenticate user (SSO/mTLS) ↓ Load RBAC roles for user + tenant ↓ Call OPA with roles to get label matchers ↓ Label enforcer injects matchers based on OPA response ↓ Query modified: {app="web"} → {app="web",namespace="prod"} ↓ Proxy to upstream ``` ### After (New Tenant-Based Model) ``` Request with tenant in header: X-Scope-OrgID: team-alpha ↓ Extract tenant from header → context ↓ Authenticate user (SSO/mTLS) ↓ Convert tenant to label matcher (no OPA) ↓ Label enforcer injects tenant matcher ↓ Query modified: {app="web"} → {app="web",tenant_id="team-alpha"} ↓ Proxy to upstream ``` ### Multi-Tenant Support (New Feature) ``` Request with multiple tenants: X-Scope-OrgID: team-a|team-b|team-c ↓ Extract tenant string → context ↓ Authenticate user (SSO/mTLS) ↓ Convert to regex matcher: {tenant_id=~"team-a|team-b|team-c"} ↓ Label enforcer injects matcher ↓ Query modified: {app="web"} → {app="web",tenant_id=~"team-a|team-b|team-c"} ↓ Proxy to upstream (returns data for all 3 tenants) ``` Co-Authored-By: Claude Signed-off-by: Saswata Mukherjee --- api/logs/v1/http.go | 7 +- api/traces/v1/http.go | 11 - authentication/mtls_grpc.go | 91 +++ authentication/mtls_grpc_test.go | 311 ++++++++ authentication/mtls_tenant.go | 83 +++ authentication/mtls_tenant_test.go | 180 +++++ authentication/tenant_header.go | 48 ++ authentication/tenant_header_test.go | 191 +++++ authorization/grpc.go | 79 -- authorization/http.go | 175 +---- authorization/meta.go | 33 +- authorization/meta_test.go | 30 - authorization/rules.go | 33 - authorization/rules_test.go | 51 -- authorization/tenant.go | 64 ++ authorization/tenant_test.go | 219 ++++++ examples/tenants/mtls-write.yaml | 36 + main.go | 351 +++------ opa/opa.go | 350 --------- opa/opa_test.go | 84 --- rbac/rbac.go | 204 ----- rbac/rbac_test.go | 1034 -------------------------- test/e2e/configs.go | 4 +- 23 files changed, 1362 insertions(+), 2307 deletions(-) create mode 100644 authentication/mtls_grpc.go create mode 100644 authentication/mtls_grpc_test.go create mode 100644 authentication/mtls_tenant.go create mode 100644 authentication/mtls_tenant_test.go create mode 100644 authentication/tenant_header.go create mode 100644 authentication/tenant_header_test.go delete mode 100644 authorization/grpc.go delete mode 100644 authorization/meta_test.go delete mode 100644 authorization/rules.go delete mode 100644 authorization/rules_test.go create mode 100644 authorization/tenant.go create mode 100644 authorization/tenant_test.go create mode 100644 examples/tenants/mtls-write.yaml delete mode 100644 opa/opa.go delete mode 100644 opa/opa_test.go delete mode 100644 rbac/rbac.go delete mode 100644 rbac/rbac_test.go diff --git a/api/logs/v1/http.go b/api/logs/v1/http.go index 648b3151b..6f6ec2f1f 100644 --- a/api/logs/v1/http.go +++ b/api/logs/v1/http.go @@ -272,10 +272,9 @@ func NewHandler(read, tail, write, rules *url.URL, rulesReadOnly bool, tlsOption transport := otelhttp.NewTransport(t) proxyPrometheusReadRules = &httputil.ReverseProxy{ - Director: middlewares, - ErrorLog: logger, - Transport: transport, - ModifyResponse: newModifyResponseProm(c.logger, c.rulesLabelFilters), + Director: middlewares, + ErrorLog: logger, + Transport: transport, } proxyRules = &httputil.ReverseProxy{ Director: middlewares, diff --git a/api/traces/v1/http.go b/api/traces/v1/http.go index e2a52562a..122534f4c 100644 --- a/api/traces/v1/http.go +++ b/api/traces/v1/http.go @@ -36,7 +36,6 @@ 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 @@ -94,12 +93,6 @@ func WithWriteMiddleware(m func(http.Handler) http.Handler) HandlerOption { } } -// WithTempoEnableResponseQueryRBACFilter enables query RBAC. -func WithTempoEnableResponseQueryRBACFilter(enableQueryRBAC bool) HandlerOption { - return func(h *handlerConfiguration) { - h.enableRBAC = enableQueryRBAC - } -} type handlerInstrumenter interface { NewHandler(labels prometheus.Labels, handler http.Handler) http.HandlerFunc @@ -259,10 +252,6 @@ func NewV2Handler(read *url.URL, readTemplate string, tempo, writeOTLPHttp *url. ErrorLog: proxy.Logger(c.logger), Transport: otelhttp.NewTransport(t), } - if c.enableRBAC { - tempoProxyRead.Transport = decompressingTransport(tempoProxyRead.Transport) - tempoProxyRead.ModifyResponse = responseRBACModifier(c.logger) - } r.Group(func(r chi.Router) { r.Use(c.tempoMiddlewares...) diff --git a/authentication/mtls_grpc.go b/authentication/mtls_grpc.go new file mode 100644 index 000000000..20333aef4 --- /dev/null +++ b/authentication/mtls_grpc.go @@ -0,0 +1,91 @@ +package authentication + +import ( + "context" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +// WithGRPCMTLSTenantExtraction returns a gRPC StreamServerInterceptor that extracts tenant from +// the client certificate's OrganizationalUnit field and adds it to the gRPC metadata. +// This is designed for write-path authentication where machines authenticate via mTLS. +func WithGRPCMTLSTenantExtraction(tenantHeader string, logger log.Logger) grpc.StreamServerInterceptor { + return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + ctx := ss.Context() + + // Extract peer information (TLS state) + p, ok := peer.FromContext(ctx) + if !ok { + level.Debug(logger).Log("msg", "no peer information in gRPC context") + return status.Error(codes.Unauthenticated, "no peer information") + } + + // Check for TLS credentials + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok { + level.Debug(logger).Log("msg", "no TLS credentials in gRPC peer") + return status.Error(codes.Unauthenticated, "TLS connection required") + } + + if len(tlsInfo.State.PeerCertificates) == 0 { + level.Debug(logger).Log("msg", "no client certificate in gRPC TLS connection") + return status.Error(codes.Unauthenticated, "client certificate required") + } + + cert := tlsInfo.State.PeerCertificates[0] + + // Note: Certificate has already been verified by the TLS handshake when + // the server is configured with RequireAndVerifyClientCert. + // We just need to extract the tenant from the OU field. + + // Extract tenant from OrganizationalUnit field + if len(cert.Subject.OrganizationalUnit) == 0 { + level.Debug(logger).Log("msg", "no organizational unit in client certificate") + return status.Error(codes.InvalidArgument, "tenant not found in certificate OU") + } + + // Use the first OU as the tenant identifier + tenant := cert.Subject.OrganizationalUnit[0] + + level.Debug(logger).Log("msg", "extracted tenant from gRPC mTLS certificate", "tenant", tenant) + + // Add tenant to context + ctx = context.WithValue(ctx, tenantKey, tenant) + + // Add tenant to outgoing metadata for upstream forwarding + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + md = metadata.New(nil) + } else { + md = md.Copy() + } + md.Set(tenantHeader, tenant) + ctx = metadata.NewIncomingContext(ctx, md) + + // Create a new server stream with the updated context + wrappedStream := &wrappedServerStream{ + ServerStream: ss, + ctx: ctx, + } + + return handler(srv, wrappedStream) + } +} + +// wrappedServerStream wraps a grpc.ServerStream to override the context. +type wrappedServerStream struct { + grpc.ServerStream + ctx context.Context +} + +// Context returns the wrapped context. +func (w *wrappedServerStream) Context() context.Context { + return w.ctx +} diff --git a/authentication/mtls_grpc_test.go b/authentication/mtls_grpc_test.go new file mode 100644 index 000000000..a558d7328 --- /dev/null +++ b/authentication/mtls_grpc_test.go @@ -0,0 +1,311 @@ +package authentication + +import ( + "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "testing" + + "github.com/go-kit/log" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +// mockServerStream implements grpc.ServerStream for testing +type mockServerStream struct { + grpc.ServerStream + ctx context.Context +} + +func (m *mockServerStream) Context() context.Context { + return m.ctx +} + +func TestWithGRPCMTLSTenantExtraction(t *testing.T) { + logger := log.NewNopLogger() + tenantHeader := "x-tenant" + + t.Run("extracts tenant from gRPC peer certificate", func(t *testing.T) { + interceptor := WithGRPCMTLSTenantExtraction(tenantHeader, logger) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + OrganizationalUnit: []string{"team-alpha"}, + }, + } + + tlsInfo := credentials.TLSInfo{ + State: tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + }, + } + + p := &peer.Peer{ + AuthInfo: tlsInfo, + } + + ctx := peer.NewContext(context.Background(), p) + ss := &mockServerStream{ctx: ctx} + + var capturedTenant string + var capturedMetadata metadata.MD + handler := func(srv interface{}, stream grpc.ServerStream) error { + tenant, ok := GetTenant(stream.Context()) + if ok { + capturedTenant = tenant + } + md, ok := metadata.FromIncomingContext(stream.Context()) + if ok { + capturedMetadata = md + } + return nil + } + + err := interceptor(nil, ss, nil, handler) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedTenant != "team-alpha" { + t.Errorf("expected tenant 'team-alpha', got '%s'", capturedTenant) + } + + tenantValues := capturedMetadata.Get(tenantHeader) + if len(tenantValues) == 0 || tenantValues[0] != "team-alpha" { + t.Errorf("expected metadata tenant 'team-alpha', got %v", tenantValues) + } + }) + + t.Run("returns error when no peer information", func(t *testing.T) { + interceptor := WithGRPCMTLSTenantExtraction(tenantHeader, logger) + + ctx := context.Background() + ss := &mockServerStream{ctx: ctx} + + handler := func(srv interface{}, stream grpc.ServerStream) error { + t.Fatal("handler should not be called") + return nil + } + + err := interceptor(nil, ss, nil, handler) + if err == nil { + t.Fatal("expected error, got nil") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("expected gRPC status error") + } + + if st.Code() != codes.Unauthenticated { + t.Errorf("expected code Unauthenticated, got %v", st.Code()) + } + }) + + t.Run("returns error when no TLS credentials", func(t *testing.T) { + interceptor := WithGRPCMTLSTenantExtraction(tenantHeader, logger) + + // Peer with non-TLS auth info + p := &peer.Peer{ + AuthInfo: nil, + } + + ctx := peer.NewContext(context.Background(), p) + ss := &mockServerStream{ctx: ctx} + + handler := func(srv interface{}, stream grpc.ServerStream) error { + t.Fatal("handler should not be called") + return nil + } + + err := interceptor(nil, ss, nil, handler) + if err == nil { + t.Fatal("expected error, got nil") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("expected gRPC status error") + } + + if st.Code() != codes.Unauthenticated { + t.Errorf("expected code Unauthenticated, got %v", st.Code()) + } + }) + + t.Run("returns error when no client certificate", func(t *testing.T) { + interceptor := WithGRPCMTLSTenantExtraction(tenantHeader, logger) + + tlsInfo := credentials.TLSInfo{ + State: tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{}, + }, + } + + p := &peer.Peer{ + AuthInfo: tlsInfo, + } + + ctx := peer.NewContext(context.Background(), p) + ss := &mockServerStream{ctx: ctx} + + handler := func(srv interface{}, stream grpc.ServerStream) error { + t.Fatal("handler should not be called") + return nil + } + + err := interceptor(nil, ss, nil, handler) + if err == nil { + t.Fatal("expected error, got nil") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("expected gRPC status error") + } + + if st.Code() != codes.Unauthenticated { + t.Errorf("expected code Unauthenticated, got %v", st.Code()) + } + }) + + t.Run("returns error when certificate has no OU", func(t *testing.T) { + interceptor := WithGRPCMTLSTenantExtraction(tenantHeader, logger) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + OrganizationalUnit: []string{}, + }, + } + + tlsInfo := credentials.TLSInfo{ + State: tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + }, + } + + p := &peer.Peer{ + AuthInfo: tlsInfo, + } + + ctx := peer.NewContext(context.Background(), p) + ss := &mockServerStream{ctx: ctx} + + handler := func(srv interface{}, stream grpc.ServerStream) error { + t.Fatal("handler should not be called") + return nil + } + + err := interceptor(nil, ss, nil, handler) + if err == nil { + t.Fatal("expected error, got nil") + } + + st, ok := status.FromError(err) + if !ok { + t.Fatal("expected gRPC status error") + } + + if st.Code() != codes.InvalidArgument { + t.Errorf("expected code InvalidArgument, got %v", st.Code()) + } + }) + + t.Run("uses first OU when multiple present", func(t *testing.T) { + interceptor := WithGRPCMTLSTenantExtraction(tenantHeader, logger) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + OrganizationalUnit: []string{"first-tenant", "second-tenant"}, + }, + } + + tlsInfo := credentials.TLSInfo{ + State: tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + }, + } + + p := &peer.Peer{ + AuthInfo: tlsInfo, + } + + ctx := peer.NewContext(context.Background(), p) + ss := &mockServerStream{ctx: ctx} + + var capturedTenant string + handler := func(srv interface{}, stream grpc.ServerStream) error { + tenant, _ := GetTenant(stream.Context()) + capturedTenant = tenant + return nil + } + + err := interceptor(nil, ss, nil, handler) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedTenant != "first-tenant" { + t.Errorf("expected first OU 'first-tenant', got '%s'", capturedTenant) + } + }) + + t.Run("merges with existing metadata", func(t *testing.T) { + interceptor := WithGRPCMTLSTenantExtraction(tenantHeader, logger) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + OrganizationalUnit: []string{"team-beta"}, + }, + } + + tlsInfo := credentials.TLSInfo{ + State: tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + }, + } + + p := &peer.Peer{ + AuthInfo: tlsInfo, + } + + // Add existing metadata + md := metadata.New(map[string]string{ + "existing-key": "existing-value", + }) + ctx := metadata.NewIncomingContext(context.Background(), md) + ctx = peer.NewContext(ctx, p) + ss := &mockServerStream{ctx: ctx} + + var capturedMetadata metadata.MD + handler := func(srv interface{}, stream grpc.ServerStream) error { + md, ok := metadata.FromIncomingContext(stream.Context()) + if ok { + capturedMetadata = md + } + return nil + } + + err := interceptor(nil, ss, nil, handler) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check existing metadata is preserved + existingValues := capturedMetadata.Get("existing-key") + if len(existingValues) == 0 || existingValues[0] != "existing-value" { + t.Errorf("expected existing metadata to be preserved, got %v", existingValues) + } + + // Check new tenant metadata is added + tenantValues := capturedMetadata.Get(tenantHeader) + if len(tenantValues) == 0 || tenantValues[0] != "team-beta" { + t.Errorf("expected tenant metadata 'team-beta', got %v", tenantValues) + } + }) +} diff --git a/authentication/mtls_tenant.go b/authentication/mtls_tenant.go new file mode 100644 index 000000000..16a203224 --- /dev/null +++ b/authentication/mtls_tenant.go @@ -0,0 +1,83 @@ +package authentication + +import ( + "context" + "net/http" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + + "github.com/observatorium/api/httperr" +) + +// MTLSTenantExtractor is a middleware that extracts the tenant from the mTLS client certificate's +// OrganizationalUnit (OU) field and sets it in the request context and as a header. +// This is designed for write-path authentication where machines authenticate via mTLS. +type MTLSTenantExtractor struct { + logger log.Logger + tenantHeader string +} + +// NewMTLSTenantExtractor creates a new mTLS tenant extractor middleware. +// tenantHeader is the HTTP header name to set the tenant (e.g., "X-Scope-OrgID" for Loki, "THANOS-TENANT" for Thanos). +func NewMTLSTenantExtractor(logger log.Logger, tenantHeader string) *MTLSTenantExtractor { + return &MTLSTenantExtractor{ + logger: logger, + tenantHeader: tenantHeader, + } +} + +// Middleware returns an HTTP middleware that: +// 1. Requires a valid mTLS client certificate +// 2. Extracts the tenant from the certificate's OU field +// 3. Sets the tenant in request context and as a header for upstream forwarding +func (e *MTLSTenantExtractor) Middleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil { + level.Debug(e.logger).Log("msg", "no TLS connection") + httperr.PrometheusAPIError(w, "TLS connection required", http.StatusUnauthorized) + return + } + + if len(r.TLS.PeerCertificates) == 0 { + level.Debug(e.logger).Log("msg", "no client certificate presented") + httperr.PrometheusAPIError(w, "client certificate required", http.StatusUnauthorized) + return + } + + cert := r.TLS.PeerCertificates[0] + + // Note: Certificate has already been verified by the TLS handshake when + // the server is configured with RequireAndVerifyClientCert. + // We just need to extract the tenant from the OU field. + + // Extract tenant from OrganizationalUnit field + if len(cert.Subject.OrganizationalUnit) == 0 { + level.Debug(e.logger).Log("msg", "no organizational unit in client certificate") + httperr.PrometheusAPIError(w, "tenant not found in certificate OU", http.StatusBadRequest) + return + } + + // Use the first OU as the tenant identifier + tenant := cert.Subject.OrganizationalUnit[0] + + level.Debug(e.logger).Log("msg", "extracted tenant from mTLS certificate", "tenant", tenant) + + // Set tenant in request context + ctx := context.WithValue(r.Context(), tenantKey, tenant) + + // Set tenant header for upstream forwarding + r.Header.Set(e.tenantHeader, tenant) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// WithMTLSTenantExtraction returns a middleware function that extracts tenant from mTLS certificates. +// This is a convenience function for use in the main.go middleware chains. +func WithMTLSTenantExtraction(logger log.Logger, tenantHeader string) Middleware { + extractor := NewMTLSTenantExtractor(logger, tenantHeader) + return extractor.Middleware() +} diff --git a/authentication/mtls_tenant_test.go b/authentication/mtls_tenant_test.go new file mode 100644 index 000000000..ad20d596f --- /dev/null +++ b/authentication/mtls_tenant_test.go @@ -0,0 +1,180 @@ +package authentication + +import ( + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-kit/log" +) + +func TestWithMTLSTenantExtraction(t *testing.T) { + logger := log.NewNopLogger() + tenantHeader := "X-Scope-OrgID" + + t.Run("extracts tenant from certificate OU", func(t *testing.T) { + middleware := WithMTLSTenantExtraction(logger, tenantHeader) + + // Create a mock certificate with OU + cert := &x509.Certificate{ + Subject: pkix.Name{ + OrganizationalUnit: []string{"team-alpha", "other-ou"}, + }, + } + + req := httptest.NewRequest(http.MethodPost, "/api/metrics/v1/api/v1/receive", nil) + req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + } + + var capturedTenant string + var capturedHeader string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant, ok := GetTenant(r.Context()) + if ok { + capturedTenant = tenant + } + capturedHeader = r.Header.Get(tenantHeader) + w.WriteHeader(http.StatusOK) + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + + if capturedTenant != "team-alpha" { + t.Errorf("expected tenant 'team-alpha', got '%s'", capturedTenant) + } + + if capturedHeader != "team-alpha" { + t.Errorf("expected header 'team-alpha', got '%s'", capturedHeader) + } + }) + + t.Run("returns 401 when no TLS connection", func(t *testing.T) { + middleware := WithMTLSTenantExtraction(logger, tenantHeader) + + req := httptest.NewRequest(http.MethodPost, "/api/metrics/v1/api/v1/receive", nil) + // No req.TLS set + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be called") + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected status 401, got %d", rr.Code) + } + }) + + t.Run("returns 401 when no client certificate", func(t *testing.T) { + middleware := WithMTLSTenantExtraction(logger, tenantHeader) + + req := httptest.NewRequest(http.MethodPost, "/api/metrics/v1/api/v1/receive", nil) + req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{}, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be called") + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected status 401, got %d", rr.Code) + } + }) + + t.Run("returns 400 when certificate has no OU", func(t *testing.T) { + middleware := WithMTLSTenantExtraction(logger, tenantHeader) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + OrganizationalUnit: []string{}, // Empty OU + }, + } + + req := httptest.NewRequest(http.MethodPost, "/api/metrics/v1/api/v1/receive", nil) + req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be called") + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rr.Code) + } + }) + + t.Run("uses first OU when multiple present", func(t *testing.T) { + middleware := WithMTLSTenantExtraction(logger, tenantHeader) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + OrganizationalUnit: []string{"first-tenant", "second-tenant"}, + }, + } + + req := httptest.NewRequest(http.MethodPost, "/api/metrics/v1/api/v1/receive", nil) + req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + } + + var capturedTenant string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant, _ := GetTenant(r.Context()) + capturedTenant = tenant + w.WriteHeader(http.StatusOK) + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if capturedTenant != "first-tenant" { + t.Errorf("expected first OU 'first-tenant', got '%s'", capturedTenant) + } + }) + + t.Run("forwards tenant header to upstream", func(t *testing.T) { + middleware := WithMTLSTenantExtraction(logger, tenantHeader) + + cert := &x509.Certificate{ + Subject: pkix.Name{ + OrganizationalUnit: []string{"team-beta"}, + }, + } + + req := httptest.NewRequest(http.MethodPost, "/api/logs/v1/loki/api/v1/push", nil) + req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + } + + var capturedHeader string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeader = r.Header.Get(tenantHeader) + w.WriteHeader(http.StatusOK) + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if capturedHeader != "team-beta" { + t.Errorf("expected header 'team-beta', got '%s'", capturedHeader) + } + }) +} diff --git a/authentication/tenant_header.go b/authentication/tenant_header.go new file mode 100644 index 000000000..7a0fbdcf9 --- /dev/null +++ b/authentication/tenant_header.go @@ -0,0 +1,48 @@ +package authentication + +import ( + "context" + "net/http" + + "github.com/observatorium/api/httperr" +) + +// WithTenantFromHeader extracts the tenant from the specified HTTP header and adds it to the request context. +// This is designed for read-path authentication where clients (like Grafana) specify the tenant via headers. +// +// For example: +// - Loki uses "X-Scope-OrgID" +// - Thanos/Prometheus uses "THANOS-TENANT" +// - Jaeger uses "X-Tenant" +func WithTenantFromHeader(tenantHeader string) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant := r.Header.Get(tenantHeader) + if tenant == "" { + httperr.PrometheusAPIError(w, "tenant header required", http.StatusBadRequest) + return + } + + // Set tenant in request context for downstream middleware + ctx := context.WithValue(r.Context(), tenantKey, tenant) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// WithOptionalTenantFromHeader extracts the tenant from the header if present, otherwise continues without error. +// This is useful for endpoints that can work with or without a tenant specified. +func WithOptionalTenantFromHeader(tenantHeader string) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant := r.Header.Get(tenantHeader) + if tenant != "" { + ctx := context.WithValue(r.Context(), tenantKey, tenant) + r = r.WithContext(ctx) + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/authentication/tenant_header_test.go b/authentication/tenant_header_test.go new file mode 100644 index 000000000..dbf779946 --- /dev/null +++ b/authentication/tenant_header_test.go @@ -0,0 +1,191 @@ +package authentication + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestWithTenantFromHeader(t *testing.T) { + tenantHeader := "X-Scope-OrgID" + + t.Run("extracts tenant from header", func(t *testing.T) { + middleware := WithTenantFromHeader(tenantHeader) + + req := httptest.NewRequest(http.MethodGet, "/api/logs/v1/loki/api/v1/query", nil) + req.Header.Set(tenantHeader, "team-alpha") + + var capturedTenant string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant, ok := GetTenant(r.Context()) + if ok { + capturedTenant = tenant + } + w.WriteHeader(http.StatusOK) + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + + if capturedTenant != "team-alpha" { + t.Errorf("expected tenant 'team-alpha', got '%s'", capturedTenant) + } + }) + + t.Run("returns 400 when header missing", func(t *testing.T) { + middleware := WithTenantFromHeader(tenantHeader) + + req := httptest.NewRequest(http.MethodGet, "/api/logs/v1/loki/api/v1/query", nil) + // No header set + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be called") + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rr.Code) + } + }) + + t.Run("returns 400 when header empty", func(t *testing.T) { + middleware := WithTenantFromHeader(tenantHeader) + + req := httptest.NewRequest(http.MethodGet, "/api/logs/v1/loki/api/v1/query", nil) + req.Header.Set(tenantHeader, "") + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be called") + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rr.Code) + } + }) + + t.Run("works with different header names", func(t *testing.T) { + headers := map[string]string{ + "X-Scope-OrgID": "loki-tenant", + "THANOS-TENANT": "thanos-tenant", + "X-Tenant": "jaeger-tenant", + "Custom-Header": "custom-tenant", + } + + for headerName, expectedTenant := range headers { + middleware := WithTenantFromHeader(headerName) + + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + req.Header.Set(headerName, expectedTenant) + + var capturedTenant string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant, _ := GetTenant(r.Context()) + capturedTenant = tenant + w.WriteHeader(http.StatusOK) + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if capturedTenant != expectedTenant { + t.Errorf("header %s: expected tenant '%s', got '%s'", headerName, expectedTenant, capturedTenant) + } + } + }) +} + +func TestWithOptionalTenantFromHeader(t *testing.T) { + tenantHeader := "X-Scope-OrgID" + + t.Run("extracts tenant from header when present", func(t *testing.T) { + middleware := WithOptionalTenantFromHeader(tenantHeader) + + req := httptest.NewRequest(http.MethodGet, "/api/logs/v1/loki/api/v1/query", nil) + req.Header.Set(tenantHeader, "team-alpha") + + var capturedTenant string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant, ok := GetTenant(r.Context()) + if ok { + capturedTenant = tenant + } + w.WriteHeader(http.StatusOK) + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + + if capturedTenant != "team-alpha" { + t.Errorf("expected tenant 'team-alpha', got '%s'", capturedTenant) + } + }) + + t.Run("continues without error when header missing", func(t *testing.T) { + middleware := WithOptionalTenantFromHeader(tenantHeader) + + req := httptest.NewRequest(http.MethodGet, "/api/logs/v1/loki/api/v1/query", nil) + // No header set + + var handlerCalled bool + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + _, ok := GetTenant(r.Context()) + if ok { + t.Error("expected no tenant in context") + } + w.WriteHeader(http.StatusOK) + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if !handlerCalled { + t.Error("handler should have been called") + } + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + }) + + t.Run("continues without error when header empty", func(t *testing.T) { + middleware := WithOptionalTenantFromHeader(tenantHeader) + + req := httptest.NewRequest(http.MethodGet, "/api/logs/v1/loki/api/v1/query", nil) + req.Header.Set(tenantHeader, "") + + var handlerCalled bool + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + _, ok := GetTenant(r.Context()) + if ok { + t.Error("expected no tenant in context") + } + w.WriteHeader(http.StatusOK) + }) + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if !handlerCalled { + t.Error("handler should have been called") + } + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + }) +} diff --git a/authorization/grpc.go b/authorization/grpc.go deleted file mode 100644 index d71849c76..000000000 --- a/authorization/grpc.go +++ /dev/null @@ -1,79 +0,0 @@ -package authorization - -import ( - "context" - - "github.com/go-kit/log" - "github.com/go-kit/log/level" - grpc_middleware_auth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "github.com/observatorium/api/authentication" - "github.com/observatorium/api/rbac" -) - -// AccessRequirement holds a permission for a particular resource type. -type AccessRequirement struct { - Permission rbac.Permission - // Resource is typically "logs", "metrics", or "traces" - Resource string -} - -// GRPCRBac represents the RBAC requirements for a particular fully-qualified gRPC Method. -// For example, "opentelemetry.proto.collector.trace.v1.TraceService/Export" -// requires "write" permission for "traces". -type GRPCRBac map[string]AccessRequirement - -// WithGRPCAuthorizers is the gRPC version of WithAuthorizers. -func WithGRPCAuthorizers(authorizers map[string]rbac.Authorizer, methReq GRPCRBac, logger log.Logger) grpc_middleware_auth.AuthFunc { - return func(ctx context.Context) (context.Context, error) { - fullMethodName, ok := grpc.Method(ctx) - if !ok { - return ctx, status.Error(codes.Internal, "fullMethodName not in context") - } - - accessReq, ok := methReq[fullMethodName] - if !ok { - return ctx, status.Error(codes.PermissionDenied, "method never permitted") - } - - tenant, ok := authentication.GetTenant(ctx) - if !ok { - return ctx, status.Error(codes.Internal, "error finding tenant") - } - - subject, ok := authentication.GetSubject(ctx) - if !ok { - return ctx, status.Error(codes.PermissionDenied, "unknown subject") - } - - groups, ok := authentication.GetGroups(ctx) - if !ok { - groups = []string{} - } - a, ok := authorizers[tenant] - if !ok { - return ctx, status.Error(codes.Unauthenticated, "error finding tenant") - } - - token, ok := authentication.GetAccessToken(ctx) - if !ok { - return ctx, status.Error(codes.Unauthenticated, "error finding access token") - } - - tenantID, ok := authentication.GetTenantID(ctx) - if !ok { - return ctx, status.Error(codes.Unauthenticated, "error finding tenant id") - } - - _, ok, data := a.Authorize(subject, groups, accessReq.Permission, accessReq.Resource, tenant, tenantID, token, nil) - if !ok { - level.Debug(logger).Log("msg", "gRPC Authorizer: insufficient auth", "subject", subject, "tenant", tenant) - return ctx, status.Error(codes.PermissionDenied, "forbidden") - } - - return context.WithValue(ctx, authorizationDataKey, data), nil - } -} diff --git a/authorization/http.go b/authorization/http.go index c59857519..d757a96ba 100644 --- a/authorization/http.go +++ b/authorization/http.go @@ -2,180 +2,27 @@ package authorization import ( "context" - "fmt" - "net/http" - "strings" - - "github.com/go-kit/log" - "github.com/go-kit/log/level" - - "github.com/observatorium/api/authentication" - "github.com/observatorium/api/httperr" - "github.com/observatorium/api/rbac" ) -// contextKey to use when setting context values in the HTTP package. type contextKey string const ( - // authorizationDataKey is the key that holds the authorization response data - // in a request context. - authorizationDataKey contextKey = "authzData" - - // authorizationSelectorsKey is the key that holds the data about selectors present in the query. - authorizationSelectorsKey contextKey = "authzQuerySelectors" - - // errorMessageForbidden is the error message presented to the user if the user doesn't have - // sufficient permissions to access the requested tenant. - errorMessageForbidden string = "You don't have permission to access this tenant" + dataKey contextKey = "data" ) -type SelectorsInfo struct { - Selectors map[string][]string - HasWildcard bool -} - -var emptySelectorsInfo = &SelectorsInfo{ - Selectors: map[string][]string{}, -} - -// GetData extracts the authz response data from provided context. -func GetData(ctx context.Context) (string, bool) { - value := ctx.Value(authorizationDataKey) - data, ok := value.(string) - - return data, ok -} - -// WithData extends the provided context with the authz response data. +// WithData adds authorization data to the request context. +// This data is typically used by label enforcers. func WithData(ctx context.Context, data string) context.Context { - return context.WithValue(ctx, authorizationDataKey, data) -} - -// GetSelectorsInfo extracts the query namespaces from the provided context. -func GetSelectorsInfo(ctx context.Context) (*SelectorsInfo, bool) { - value := ctx.Value(authorizationSelectorsKey) - namespaces, ok := value.(*SelectorsInfo) - - return namespaces, ok + return context.WithValue(ctx, dataKey, data) } -// WithSelectorsInfo extends the provided context with the query namespaces. -func WithSelectorsInfo(ctx context.Context, info *SelectorsInfo) context.Context { - return context.WithValue(ctx, authorizationSelectorsKey, info) -} - -// WithLogsStreamSelectorsExtractor returns a middleware that, when enabled, tries to extract -// stream selectors from queries or rules, so that they can be used in authorizing the request. -func WithLogsStreamSelectorsExtractor(logger log.Logger, selectorNames []string) func(http.Handler) http.Handler { - enabled := len(selectorNames) > 0 - - selectorNameMap := make(map[string]bool, len(selectorNames)) - for _, l := range selectorNames { - selectorNameMap[l] = true - } - - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !enabled { - next.ServeHTTP(w, r) - - return - } - - var ( - selectorsInfo *SelectorsInfo - err error - ) - - switch { - case strings.HasSuffix(r.URL.Path, "/rules"): - selectorsInfo = extractLogRulesSelectors(selectorNameMap, r.URL.Query()) - case strings.HasSuffix(r.URL.Path, "/series"): - selectorsInfo, err = extractLogStreamSelectors(selectorNameMap, r.URL.Query(), "match") - default: - selectorsInfo, err = extractLogStreamSelectors(selectorNameMap, r.URL.Query(), "query") - } - if err != nil { - // Don't error out, just warn about error and continue with empty selectorsInfo - level.Warn(logger).Log("msg", err) - selectorsInfo = emptySelectorsInfo - } - - next.ServeHTTP(w, r.WithContext(WithSelectorsInfo(r.Context(), selectorsInfo))) - }) +// GetData extracts authorization data from the context. +func GetData(ctx context.Context) (string, bool) { + value := ctx.Value(dataKey) + if value == nil { + return "", false } -} - -// WithAuthorizers returns a middleware that authorizes subjects taken from a request context -// for the given permission on the given resource for a tenant taken from a request context. -func WithAuthorizers(authorizers map[string]rbac.Authorizer, permission rbac.Permission, resource string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - tenant, ok := authentication.GetTenant(ctx) - if !ok { - httperr.PrometheusAPIError(w, "error finding tenant", http.StatusInternalServerError) - - return - } - subject, ok := authentication.GetSubject(ctx) - if !ok { - httperr.PrometheusAPIError(w, "unknown subject", http.StatusUnauthorized) - - return - } - groups, ok := authentication.GetGroups(ctx) - if !ok { - groups = []string{} - } - a, ok := authorizers[tenant] - if !ok { - httperr.PrometheusAPIError(w, "error finding tenant", http.StatusUnauthorized) - - return - } - - token, ok := authentication.GetAccessToken(r.Context()) - if !ok { - httperr.PrometheusAPIError(w, "error finding access token", http.StatusUnauthorized) - return - } - - tenantID, ok := authentication.GetTenantID(r.Context()) - if !ok { - httperr.PrometheusAPIError(w, "error finding tenant id", http.StatusUnauthorized) - - return - } - - selectorsInfo, ok := GetSelectorsInfo(r.Context()) - if !ok { - selectorsInfo = emptySelectorsInfo - } - - metadataOnly := isMetadataRequest(r.URL.Path) - - extraAttributes := &rbac.ExtraAttributes{ - Selectors: selectorsInfo.Selectors, - WildcardSelectors: selectorsInfo.HasWildcard, - MetadataOnly: metadataOnly, - } - - statusCode, ok, data := a.Authorize(subject, groups, permission, resource, tenant, tenantID, token, extraAttributes) - if !ok { - switch statusCode { - case http.StatusForbidden: - httperr.PrometheusAPIError(w, errorMessageForbidden, statusCode) - default: - msg := fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)) - httperr.PrometheusAPIError(w, msg, statusCode) - } - - return - } - next.ServeHTTP(w, r.WithContext(WithData(ctx, data))) - }) - } + data, ok := value.(string) + return data, ok } diff --git a/authorization/meta.go b/authorization/meta.go index dd00e992d..8b67e7fcc 100644 --- a/authorization/meta.go +++ b/authorization/meta.go @@ -1,32 +1,7 @@ package authorization -import ( - "strings" -) - -var ( - metaAbsolutePaths = map[string]bool{ - "/loki/api/v1/label": true, - "/loki/api/v1/labels": true, - "/loki/api/v1/series": true, - "/api/prom/label": true, - "/api/prom/series": true, - } - - metaPathLabelValuesNewPrefix = "/loki/api/v1/label/" - metaPathLabelValuesOldPrefix = "/api/prom/label/" - metaPathLabelValuesSuffix = "/values" -) - -func isMetadataRequest(path string) bool { - if absolutePath := metaAbsolutePaths[path]; absolutePath { - return true - } - - if (strings.HasPrefix(path, metaPathLabelValuesOldPrefix) || strings.HasPrefix(path, metaPathLabelValuesNewPrefix)) && - strings.HasSuffix(path, metaPathLabelValuesSuffix) { - return true - } - - return false +// SelectorsInfo contains information about selectors extracted from a query. +type SelectorsInfo struct { + Selectors map[string][]string + HasWildcard bool } diff --git a/authorization/meta_test.go b/authorization/meta_test.go deleted file mode 100644 index c541872a9..000000000 --- a/authorization/meta_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package authorization - -import "testing" - -func TestIsMetaRequest(t *testing.T) { - tests := []struct { - path string - want bool - }{ - { - path: "/loki/api/v1/labels", - want: true, - }, - { - path: "/loki/api/v1/label/kubernetes_namespace_name/values", - want: true, - }, - { - path: "/loki/api/v1/query_range", - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - if got := isMetadataRequest(tt.path); got != tt.want { - t.Errorf("isMetaRequest() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/authorization/rules.go b/authorization/rules.go deleted file mode 100644 index 153fcc564..000000000 --- a/authorization/rules.go +++ /dev/null @@ -1,33 +0,0 @@ -package authorization - -import ( - "net/url" -) - -func extractLogRulesSelectors(selectorNames map[string]bool, values url.Values) *SelectorsInfo { - return &SelectorsInfo{ - Selectors: parseLogRulesSelectors(selectorNames, values), - } -} - -func parseLogRulesSelectors(selectorNames map[string]bool, values url.Values) map[string][]string { - selectors := make(map[string][]string) - appendSelector := func(selector, value string) { - values, ok := selectors[selector] - if !ok { - values = make([]string, 0) - } - - values = append(values, value) - selectors[selector] = values - } - - for selector := range selectorNames { - values := values[selector] - for _, value := range values { - appendSelector(selector, value) - } - } - - return selectors -} diff --git a/authorization/rules_test.go b/authorization/rules_test.go deleted file mode 100644 index f3c636006..000000000 --- a/authorization/rules_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package authorization - -import ( - "net/url" - "reflect" - "testing" - - "github.com/efficientgo/core/testutil" -) - -func Test_parseQueryParametersSelectors(t *testing.T) { - testSelectorLabels := map[string]bool{ - "namespace": true, - "other_namespace_label": true, - } - tests := []struct { - queryParameters string - wantSelectors map[string][]string - }{ - { - queryParameters: `namespace=test`, - wantSelectors: map[string][]string{ - "namespace": {"test"}, - }, - }, - { - queryParameters: `namespace=test&other_namespace_label=test2`, - wantSelectors: map[string][]string{ - "namespace": {"test"}, - "other_namespace_label": {"test2"}, - }, - }, - { - queryParameters: `namespace=test&namespace=test2`, - wantSelectors: map[string][]string{ - "namespace": {"test", "test2"}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.queryParameters, func(t *testing.T) { - queryValues, err := url.ParseQuery(tt.queryParameters) - testutil.Ok(t, err) - - gotNamespaces := parseLogRulesSelectors(testSelectorLabels, queryValues) - if !reflect.DeepEqual(gotNamespaces, tt.wantSelectors) { - t.Errorf("parseLogStreamSelectors() got = %v, want %v", gotNamespaces, tt.wantSelectors) - } - }) - } -} diff --git a/authorization/tenant.go b/authorization/tenant.go new file mode 100644 index 000000000..d3f8c2f62 --- /dev/null +++ b/authorization/tenant.go @@ -0,0 +1,64 @@ +package authorization + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/prometheus/prometheus/model/labels" + + "github.com/observatorium/api/authentication" + "github.com/observatorium/api/httperr" +) + +// WithTenantLabel returns a middleware that converts tenant(s) from the request context +// into label matchers for enforcement by label enforcer middlewares. +// Supports single tenant or multiple tenants separated by |. +func WithTenantLabel(tenantLabelName string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant, ok := authentication.GetTenant(r.Context()) + if !ok { + httperr.PrometheusAPIError(w, "error finding tenant", http.StatusBadRequest) + return + } + + // Support multiple tenants separated by | + // e.g., "tenant-a|tenant-b|tenant-c" + tenants := strings.Split(tenant, "|") + + var matchers []*labels.Matcher + if len(tenants) == 1 { + // Single tenant: exact match + matchers = []*labels.Matcher{ + { + Type: labels.MatchEqual, + Name: tenantLabelName, + Value: tenants[0], + }, + } + } else { + // Multiple tenants: regex match with OR + // Creates: tenant_id=~"tenant-a|tenant-b|tenant-c" + matchers = []*labels.Matcher{ + { + Type: labels.MatchRegexp, + Name: tenantLabelName, + Value: strings.Join(tenants, "|"), + }, + } + } + + // Serialize matchers to JSON for label enforcers + matchersJSON, err := json.Marshal(matchers) + if err != nil { + httperr.PrometheusAPIError(w, "error encoding tenant matchers", http.StatusInternalServerError) + return + } + + // Set in authorization context for label enforcers to use + ctx := WithData(r.Context(), string(matchersJSON)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/authorization/tenant_test.go b/authorization/tenant_test.go new file mode 100644 index 000000000..36c3c512f --- /dev/null +++ b/authorization/tenant_test.go @@ -0,0 +1,219 @@ +package authorization + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/prometheus/prometheus/model/labels" + + "github.com/observatorium/api/authentication" +) + +// mockTenantHandler wraps a test handler with tenant context setup +func mockTenantHandler(t *testing.T, tenant string, testHandler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set tenant as URL param (what authentication.WithTenant expects) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("tenant", tenant) + ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx) + r = r.WithContext(ctx) + + // Use authentication.WithTenant to properly set tenant in context + authentication.WithTenant(testHandler).ServeHTTP(w, r) + }) +} + +func TestWithTenantLabel(t *testing.T) { + tenantLabelName := "tenant_id" + + t.Run("single tenant creates exact match", func(t *testing.T) { + var capturedData string + innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, ok := GetData(r.Context()) + if ok { + capturedData = data + } + w.WriteHeader(http.StatusOK) + }) + + middleware := WithTenantLabel(tenantLabelName) + handler := mockTenantHandler(t, "team-alpha", middleware(innerHandler)) + + req := httptest.NewRequest(http.MethodGet, "/api/logs/v1/query", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + + // Verify matchers were created correctly + var matchers []*labels.Matcher + if err := json.Unmarshal([]byte(capturedData), &matchers); err != nil { + t.Fatalf("failed to unmarshal matchers: %v", err) + } + + if len(matchers) != 1 { + t.Fatalf("expected 1 matcher, got %d", len(matchers)) + } + + m := matchers[0] + if m.Type != labels.MatchEqual { + t.Errorf("expected MatchEqual, got %v", m.Type) + } + if m.Name != tenantLabelName { + t.Errorf("expected name %s, got %s", tenantLabelName, m.Name) + } + if m.Value != "team-alpha" { + t.Errorf("expected value 'team-alpha', got %s", m.Value) + } + }) + + t.Run("multiple tenants creates regex match", func(t *testing.T) { + var capturedData string + innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, ok := GetData(r.Context()) + if ok { + capturedData = data + } + w.WriteHeader(http.StatusOK) + }) + + middleware := WithTenantLabel(tenantLabelName) + handler := mockTenantHandler(t, "team-alpha|team-beta|team-gamma", middleware(innerHandler)) + + req := httptest.NewRequest(http.MethodGet, "/api/logs/v1/query", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + + // Verify matchers were created correctly + var matchers []*labels.Matcher + if err := json.Unmarshal([]byte(capturedData), &matchers); err != nil { + t.Fatalf("failed to unmarshal matchers: %v", err) + } + + if len(matchers) != 1 { + t.Fatalf("expected 1 matcher, got %d", len(matchers)) + } + + m := matchers[0] + if m.Type != labels.MatchRegexp { + t.Errorf("expected MatchRegexp, got %v", m.Type) + } + if m.Name != tenantLabelName { + t.Errorf("expected name %s, got %s", tenantLabelName, m.Name) + } + if m.Value != "team-alpha|team-beta|team-gamma" { + t.Errorf("expected value 'team-alpha|team-beta|team-gamma', got %s", m.Value) + } + }) + + t.Run("returns 400 when no tenant in context", func(t *testing.T) { + middleware := WithTenantLabel(tenantLabelName) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be called") + }) + + req := httptest.NewRequest(http.MethodGet, "/api/logs/v1/query", nil) + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", rr.Code) + } + }) + + t.Run("handles two tenants", func(t *testing.T) { + var capturedData string + innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := GetData(r.Context()) + capturedData = data + w.WriteHeader(http.StatusOK) + }) + + middleware := WithTenantLabel(tenantLabelName) + handler := mockTenantHandler(t, "tenant-a|tenant-b", middleware(innerHandler)) + + req := httptest.NewRequest(http.MethodGet, "/api/logs/v1/query", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + var matchers []*labels.Matcher + json.Unmarshal([]byte(capturedData), &matchers) + + if len(matchers) == 0 { + t.Fatal("expected matchers to be set") + } + + if matchers[0].Type != labels.MatchRegexp { + t.Errorf("expected MatchRegexp for 2 tenants, got %v", matchers[0].Type) + } + if matchers[0].Value != "tenant-a|tenant-b" { + t.Errorf("expected 'tenant-a|tenant-b', got %s", matchers[0].Value) + } + }) + + t.Run("works with different label names", func(t *testing.T) { + customLabelName := "custom_tenant_label" + var capturedData string + innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := GetData(r.Context()) + capturedData = data + w.WriteHeader(http.StatusOK) + }) + + middleware := WithTenantLabel(customLabelName) + handler := mockTenantHandler(t, "my-tenant", middleware(innerHandler)) + + req := httptest.NewRequest(http.MethodGet, "/api/metrics/v1/query", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + var matchers []*labels.Matcher + json.Unmarshal([]byte(capturedData), &matchers) + + if len(matchers) == 0 { + t.Fatal("expected matchers to be set") + } + + if matchers[0].Name != customLabelName { + t.Errorf("expected label name %s, got %s", customLabelName, matchers[0].Name) + } + }) +} + +func TestGetData(t *testing.T) { + t.Run("returns data when present", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := WithData(req.Context(), "test-data") + data, ok := GetData(ctx) + + if !ok { + t.Error("expected ok to be true") + } + if data != "test-data" { + t.Errorf("expected 'test-data', got %s", data) + } + }) + + t.Run("returns false when not present", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + data, ok := GetData(req.Context()) + + if ok { + t.Error("expected ok to be false") + } + if data != "" { + t.Errorf("expected empty string, got %s", data) + } + }) +} diff --git a/examples/tenants/mtls-write.yaml b/examples/tenants/mtls-write.yaml new file mode 100644 index 000000000..73cbf5857 --- /dev/null +++ b/examples/tenants/mtls-write.yaml @@ -0,0 +1,36 @@ +# Example tenant configuration for mTLS-based write authentication +# Write endpoints extract tenant from certificate OU field +# Read endpoints support SSO or mTLS authentication + +tenants: +- name: team-alpha + id: 1A2B3C4D-5E6F-7A8B-9C0D-1E2F3A4B5C6D + # mTLS configuration for machine writers + mTLS: + caPath: /path/to/ca.pem + # Optional: OIDC for human read access via Grafana + oidc: + clientID: team-alpha + clientSecret: secretvalue + issuerURL: https://sso.example.com + redirectURL: https://observatorium.example.com/oidc/team-alpha/callback + usernameClaim: email + # Optional: Rate limiting (note: endpoint patterns without tenant in path) + rateLimits: + - endpoint: "/api/metrics/v1/api/v1/receive" + limit: 100 + window: 1s + - endpoint: "/api/logs/v1/.*" + limit: 100 + window: 1s + +- name: team-beta + id: 2B3C4D5E-6F7A-8B9C-0D1E-2F3A4B5C6D7E + # mTLS only configuration (both read and write via mTLS) + mTLS: + caPath: /path/to/ca.pem + # Optional: Lower rate limits for this tenant + rateLimits: + - endpoint: "/api/metrics/v1/api/v1/receive" + limit: 10 + window: 1s diff --git a/main.go b/main.go index 217ff0c4e..5001cfb52 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,6 @@ import ( "net/url" "os" "os/signal" - "path" "regexp" "runtime" "strings" @@ -28,7 +27,6 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-kit/log" "github.com/go-kit/log/level" - "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" "github.com/metalmatze/signal/healthcheck" "github.com/metalmatze/signal/internalserver" grpcproxy "github.com/mwitkow/grpc-proxy/proxy" @@ -55,12 +53,8 @@ import ( "github.com/observatorium/api/authentication" "github.com/observatorium/api/authorization" "github.com/observatorium/api/client" - "github.com/observatorium/api/httperr" "github.com/observatorium/api/logger" - "github.com/observatorium/api/opa" - "github.com/observatorium/api/proxy" "github.com/observatorium/api/ratelimit" - "github.com/observatorium/api/rbac" "github.com/observatorium/api/server" "github.com/observatorium/api/tls" "github.com/observatorium/api/tracing" @@ -95,7 +89,6 @@ type config struct { logLevel string logFormat string - rbacConfigPath string tenantsConfigPath string debug debugConfig @@ -261,13 +254,6 @@ type tenant struct { cas []*x509.Certificate config map[string]interface{} } `json:"mTLS"` - OPA *struct { - Query string `json:"query"` - Paths []string `json:"paths"` - URL string `json:"url"` - WithAccessToken bool `json:"withAccessToken"` - authorizer rbac.Authorizer - } `json:"opa"` RateLimits []*struct { Endpoint string `json:"endpoint"` Limit int `json:"limit"` @@ -397,45 +383,8 @@ func main() { } } - if t.OPA != nil { - if t.OPA.URL != "" { - u, err := url.Parse(t.OPA.URL) - if err != nil { - skip.Log("tenant", t.Name, "err", fmt.Sprintf("failed to parse OPA URL: %v", err)) - skippedTenants.WithLabelValues(t.Name).Inc() - tenantsCfg.Tenants[i] = nil - continue - } - t.OPA.authorizer = opa.NewRESTAuthorizer(u, - opa.LoggerOption(log.With(logger, "tenant", t.Name)), - opa.AccessTokenOption(t.OPA.WithAccessToken), - ) - } else { - a, err := opa.NewInProcessAuthorizer(t.OPA.Query, t.OPA.Paths, - opa.LoggerOption(log.With(logger, "tenant", t.Name)), - opa.AccessTokenOption(t.OPA.WithAccessToken), - ) - if err != nil { - skip.Log("tenant", t.Name, "err", fmt.Sprintf("failed to create in-process OPA authorizer: %v", err)) - skippedTenants.WithLabelValues(t.Name).Inc() - tenantsCfg.Tenants[i] = nil - continue - } - t.OPA.authorizer = a - } - } - } - } - - var authorizer rbac.Authorizer - { - f, err := os.Open(cfg.rbacConfigPath) - if err != nil { - stdlog.Fatalf("cannot read RBAC configuration file from path %q: %v", cfg.rbacConfigPath, err) - } - defer f.Close() - if authorizer, err = rbac.Parse(f, logger); err != nil { - stdlog.Fatalf("unable to read RBAC YAML: %v", err) + // OPA authorization is no longer used - RBAC removed from write paths, + // and read paths use SSO/mTLS authentication only } } @@ -545,10 +494,6 @@ func main() { } var ( - tenantIDs = map[string]string{} - authorizers = map[string]rbac.Authorizer{} - oidcTenants = map[string]struct{}{} - rateLimits []ratelimit.Config // registrationRetryCount used by authenticator providers to count // registration failures per tenant. @@ -559,12 +504,6 @@ func main() { r.Group(func(r chi.Router) { // Set up common middleware before mounting authN routes. - for _, t := range tenantsCfg.Tenants { - tenantIDs[t.Name] = t.ID - } - - r.Use(authentication.WithTenant) - r.Use(authentication.WithTenantID(tenantIDs)) r.Use(authentication.WithAccessToken()) r.MethodNotAllowed(blockNonDefinedMethods()) @@ -595,9 +534,6 @@ func main() { if err != nil { stdlog.Fatal(err.Error()) } - if authenticatorType == authentication.OIDCAuthenticatorType { - oidcTenants[t.Name] = struct{}{} - } go func(config map[string]interface{}, authType, tenant string) { initializedAuthenticator := <-pm.InitializeProvider(config, tenant, authType, registerTenantsFailingMetric, logger) @@ -611,16 +547,8 @@ func main() { } } }(authenticatorConfig, authenticatorType, t.Name) - - if t.OPA != nil { - authorizers[t.Name] = t.OPA.authorizer - } else { - authorizers[t.Name] = authorizer - } } - writePathRedirectProtection := authentication.EnforceAccessTokenPresentOnSignalWrite(oidcTenants) - // Metrics. if cfg.metrics.enabled { @@ -642,49 +570,57 @@ func main() { stdlog.Fatalf("failed to read upstream logs TLS: %v", err) } - eps := metricsv1.Endpoints{ - ReadEndpoint: cfg.metrics.readEndpoint, - WriteEndpoint: cfg.metrics.writeEndpoint, - RulesEndpoint: cfg.metrics.rulesEndpoint, - AlertmanagerEndpoint: cfg.metrics.alertmanagerEndpoint, - } - rateLimitMiddleware := ratelimit.WithLocalRateLimiter(rateLimits...) if rateLimitClient != nil { rateLimitMiddleware = ratelimit.WithSharedRateLimiter(logger, rateLimitClient, rateLimits...) } - metricsMiddlewares := []func(http.Handler) http.Handler{ - authentication.WithTenantMiddlewares(pm.Middlewares), - authentication.WithTenantHeader(cfg.metrics.tenantHeader, tenantIDs), - rateLimitMiddleware, + // Metrics WRITE endpoints (without tenant in path, mTLS auth only) + if cfg.metrics.writeEndpoint != nil { + writeEps := metricsv1.Endpoints{ + WriteEndpoint: cfg.metrics.writeEndpoint, + } + + r.Group(func(r chi.Router) { + r.Use(middleware.Timeout(cfg.metrics.upstreamWriteTimeout)) + // Extract tenant from mTLS certificate OU + r.Use(authentication.WithMTLSTenantExtraction(logger, cfg.metrics.tenantHeader)) + r.Use(rateLimitMiddleware) + + r.Mount("/api/metrics/v1", metricsv1.NewHandler( + writeEps, + metricsUpstreamClientOptions, + metricsv1.WithLogger(logger), + metricsv1.WithRegistry(reg), + metricsv1.WithHandlerInstrumenter(instrumenter), + metricsv1.WithTenantLabel(cfg.metrics.tenantLabel), + )) + }) } - r.Group(func(r chi.Router) { - r.HandleFunc("/{tenant}", func(w http.ResponseWriter, r *http.Request) { - tenant, ok := authentication.GetTenant(r.Context()) - if !ok { - w.WriteHeader(http.StatusNotFound) - return - } + // Metrics READ endpoints (with tenant in path, SSO or mTLS auth, no RBAC) + readEps := metricsv1.Endpoints{ + ReadEndpoint: cfg.metrics.readEndpoint, + RulesEndpoint: cfg.metrics.rulesEndpoint, + AlertmanagerEndpoint: cfg.metrics.alertmanagerEndpoint, + } - http.Redirect(w, r, path.Join("/api/metrics/v1/", tenant, "graph"), http.StatusMovedPermanently) - }) - }) + metricsReadMiddlewares := []func(http.Handler) http.Handler{ + authentication.WithTenantFromHeader(cfg.metrics.tenantHeader), + authentication.WithTenantMiddlewares(pm.Middlewares), + authorization.WithTenantLabel(cfg.metrics.tenantLabel), + rateLimitMiddleware, + } r.Group(func(r chi.Router) { r.Use(middleware.Timeout(cfg.metrics.upstreamWriteTimeout)) - const queryParamName = "query" - r.Mount("/api/v1/{tenant}", metricslegacy.NewHandler( + r.Mount("/api/v1", metricslegacy.NewHandler( cfg.metrics.readEndpoint, metricsUpstreamClientOptions, metricslegacy.WithLogger(logger), metricslegacy.WithRegistry(reg), metricslegacy.WithHandlerInstrumenter(instrumenter), - metricslegacy.WithGlobalMiddleware(metricsMiddlewares...), - metricslegacy.WithQueryMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "metrics")), - metricslegacy.WithQueryMiddleware(metricsv1.WithEnforceTenancyOnQuery(cfg.metrics.tenantLabel, queryParamName)), - metricslegacy.WithUIMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "metrics")), + metricslegacy.WithGlobalMiddleware(metricsReadMiddlewares...), )) // enable probes if endpoint is provided. @@ -716,56 +652,30 @@ func main() { probesv1.WithDialTimeout(cfg.probes.dialTimeout), probesv1.WithKeepAliveTimeout(cfg.probes.keepAliveTimeout), probesv1.WithTLSHandshakeTimeout(cfg.probes.tlsHandshakeTimeout), + probesv1.WithReadMiddleware(authentication.WithTenantFromHeader(cfg.probes.tenantHeader)), probesv1.WithReadMiddleware(authentication.WithTenantMiddlewares(pm.Middlewares)), probesv1.WithReadMiddleware(rateLimitMiddleware), - probesv1.WithReadMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "probes")), + probesv1.WithWriteMiddleware(authentication.WithTenantFromHeader(cfg.probes.tenantHeader)), probesv1.WithWriteMiddleware(authentication.WithTenantMiddlewares(pm.Middlewares)), probesv1.WithWriteMiddleware(rateLimitMiddleware), - probesv1.WithWriteMiddleware(authorization.WithAuthorizers(authorizers, rbac.Write, "probes")), ) if err != nil { level.Error(logger).Log("msg", "failed to create probes handler", "err", err) } else { - r.Mount("/api/metrics/v1/{tenant}/probes", - stripTenantPrefix("/api/metrics/v1", probesHandler), - ) + r.Mount("/api/metrics/v1/probes", probesHandler) } } const matchParamName = "match[]" - r.Mount("/api/metrics/v1/{tenant}", metricsv1.NewHandler( - eps, + r.Mount("/api/metrics/v1", metricsv1.NewHandler( + readEps, metricsUpstreamClientOptions, metricsv1.WithLogger(logger), metricsv1.WithRegistry(reg), metricsv1.WithHandlerInstrumenter(instrumenter), metricsv1.WithTenantLabel(cfg.metrics.tenantLabel), - metricsv1.WithWriteMiddleware(writePathRedirectProtection), - metricsv1.WithGlobalMiddleware(metricsMiddlewares...), - metricsv1.WithWriteMiddleware(authorization.WithAuthorizers(authorizers, rbac.Write, "metrics")), - metricsv1.WithQueryMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "metrics")), - metricsv1.WithQueryMiddleware(metricsv1.WithEnforceTenancyOnQuery(cfg.metrics.tenantLabel, queryParamName)), - metricsv1.WithReadMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "metrics")), - metricsv1.WithReadMiddleware(metricsv1.WithEnforceTenancyOnQuery(cfg.metrics.tenantLabel, matchParamName)), - metricsv1.WithReadMiddleware(metricsv1.WithEnforceAuthorizationLabels()), - metricsv1.WithUIMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "metrics")), - metricsv1.WithAlertmanagerAlertsReadMiddleware( - authorization.WithAuthorizers(authorizers, rbac.Read, "metrics"), - metricsv1.WithEnforceTenancyOnFilter(cfg.metrics.tenantLabel), - ), - metricsv1.WithAlertmanagerSilenceReadMiddleware( - authorization.WithAuthorizers(authorizers, rbac.Read, "metrics"), - metricsv1.WithEnforceTenancyOnFilter(cfg.metrics.tenantLabel), - ), - metricsv1.WithAlertmanagerSilenceWriteMiddleware( - authorization.WithAuthorizers(authorizers, rbac.Write, "metrics"), - ), - metricsv1.WithAlertmanagerSilenceIDReadMiddleware( - authorization.WithAuthorizers(authorizers, rbac.Read, "metrics"), - ), - metricsv1.WithAlertmanagerSilenceIDWriteMiddleware( - authorization.WithAuthorizers(authorizers, rbac.Write, "metrics"), - ), + metricsv1.WithGlobalMiddleware(metricsReadMiddlewares...), + metricsv1.WithGlobalMiddleware(metricsv1.WithEnforceAuthorizationLabels()), ), ) }) @@ -792,34 +702,46 @@ func main() { stdlog.Fatalf("failed to read upstream logs TLS: %v", err) } + // Logs WRITE endpoints (without tenant in path, mTLS auth only) + if cfg.logs.writeEndpoint != nil { + r.Group(func(r chi.Router) { + r.Use(middleware.Timeout(cfg.logs.upstreamWriteTimeout)) + // Extract tenant from mTLS certificate OU + r.Use(authentication.WithMTLSTenantExtraction(logger, cfg.logs.tenantHeader)) + + r.Mount("/api/logs/v1", logsv1.NewHandler( + nil, // read endpoint + nil, // tail endpoint + cfg.logs.writeEndpoint, + nil, // rules endpoint + cfg.logs.rulesReadOnly, + logsUpstreamClientOptions, + logsv1.Logger(logger), + logsv1.WithRegistry(reg), + logsv1.WithHandlerInstrumenter(instrumenter), + )) + }) + } + + // Logs READ endpoints (no tenant in path, SSO or mTLS auth, no RBAC) r.Group(func(r chi.Router) { r.Use(middleware.Timeout(cfg.logs.upstreamWriteTimeout)) - r.Mount("/api/logs/v1/{tenant}", - stripTenantPrefix("/api/logs/v1", - logsv1.NewHandler( - cfg.logs.readEndpoint, - cfg.logs.tailEndpoint, - cfg.logs.writeEndpoint, - cfg.logs.rulesEndpoint, - cfg.logs.rulesReadOnly, - logsUpstreamClientOptions, - logsv1.Logger(logger), - logsv1.WithRegistry(reg), - logsv1.WithHandlerInstrumenter(instrumenter), - logsv1.WithWriteMiddleware(writePathRedirectProtection), - logsv1.WithGlobalMiddleware(authentication.WithTenantMiddlewares(pm.Middlewares)), - logsv1.WithGlobalMiddleware(authentication.WithTenantHeader(cfg.logs.tenantHeader, tenantIDs)), - logsv1.WithReadMiddleware(authorization.WithLogsStreamSelectorsExtractor(logger, cfg.logs.authExtractSelectors)), - logsv1.WithReadMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "logs")), - logsv1.WithReadMiddleware(logsv1.WithEnforceAuthorizationLabels()), - logsv1.WithWriteMiddleware(authorization.WithAuthorizers(authorizers, rbac.Write, "logs")), - logsv1.WithRulesLabelFilters(cfg.logs.rulesLabelFilters), - logsv1.WithRulesReadMiddleware(logsv1.WithEnforceTenantAsRuleNamespace()), - logsv1.WithRulesReadMiddleware(logsv1.WithEnforceRulesAuthorizationLabels()), - logsv1.WithRulesReadMiddleware(logsv1.WithParametersAsLabelsFilterRules(cfg.logs.rulesLabelFilters)), - logsv1.WithRulesWriteMiddleware(logsv1.WithEnforceTenantAsRuleNamespace()), - logsv1.WithRulesWriteMiddleware(logsv1.WithEnforceRuleLabels(cfg.logs.tenantLabel)), - ), + r.Mount("/api/logs/v1", + logsv1.NewHandler( + cfg.logs.readEndpoint, + cfg.logs.tailEndpoint, + nil, // write endpoint (handled separately above) + cfg.logs.rulesEndpoint, + cfg.logs.rulesReadOnly, + logsUpstreamClientOptions, + logsv1.Logger(logger), + logsv1.WithRegistry(reg), + logsv1.WithHandlerInstrumenter(instrumenter), + logsv1.WithGlobalMiddleware(authentication.WithTenantFromHeader(cfg.logs.tenantHeader)), + logsv1.WithGlobalMiddleware(authentication.WithTenantMiddlewares(pm.Middlewares)), + logsv1.WithGlobalMiddleware(authorization.WithTenantLabel(cfg.logs.tenantLabel)), + logsv1.WithGlobalMiddleware(logsv1.WithEnforceAuthorizationLabels()), + logsv1.WithRulesLabelFilters(cfg.logs.rulesLabelFilters), ), ) }) @@ -843,44 +765,43 @@ func main() { stdlog.Fatalf("failed to read upstream traces TLS: %v", err) } + // Traces WRITE endpoints (without tenant in path, mTLS auth only) + if cfg.traces.writeOTLPHTTPEndpoint != nil { + r.Group(func(r chi.Router) { + r.Use(middleware.Timeout(cfg.traces.upstreamWriteTimeout)) + // Extract tenant from mTLS certificate OU + r.Use(authentication.WithMTLSTenantExtraction(logger, cfg.traces.tenantHeader)) + + r.Mount("/api/traces/v1", tracesv1.NewV2Handler( + nil, // read endpoint + "", // read template endpoint + nil, // tempo endpoint + cfg.traces.writeOTLPHTTPEndpoint, + tracesUpstreamTLSOptions, + tracesv1.Logger(logger), + tracesv1.WithRegistry(reg), + tracesv1.WithHandlerInstrumenter(instrumenter), + )) + }) + } + + // Traces READ endpoints (no tenant in path, SSO or mTLS auth, no RBAC) r.Group(func(r chi.Router) { + r.Use(authentication.WithTenantFromHeader(cfg.traces.tenantHeader)) r.Use(authentication.WithTenantMiddlewares(pm.Middlewares)) - r.Use(authentication.WithTenantHeader(cfg.traces.tenantHeader, tenantIDs)) r.Use(middleware.Timeout(cfg.traces.upstreamWriteTimeout)) - // There can only be one login UI per tenant. Let metrics be the default; fall back to search - if !cfg.metrics.enabled { - r.HandleFunc("/{tenant}", func(w http.ResponseWriter, r *http.Request) { - tenant, ok := authentication.GetTenant(r.Context()) - if !ok { - w.WriteHeader(http.StatusNotFound) - return - } - - http.Redirect(w, r, path.Join("/api/traces/v1/", tenant, "search"), http.StatusMovedPermanently) - }) - } - - r.Mount("/api/traces/v1/{tenant}", - stripTenantPrefix("/api/traces/v1", - tracesv1.NewV2Handler( - cfg.traces.readEndpoint, - cfg.traces.readTemplateEndpoint, - cfg.traces.tempoEndpoint, - cfg.traces.writeOTLPHTTPEndpoint, - tracesUpstreamTLSOptions, - tracesv1.Logger(logger), - tracesv1.WithRegistry(reg), - tracesv1.WithHandlerInstrumenter(instrumenter), - tracesv1.WithSpanRoutePrefix("/api/traces/v1/{tenant}"), - tracesv1.WithReadMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "traces")), - tracesv1.WithReadMiddleware(logsv1.WithEnforceAuthorizationLabels()), - tracesv1.WithTempoMiddleware(tracesv1.WithTraceQLNamespaceSelectAndForbidOtherAPIs(cfg.traces.queryRBAC)), - tracesv1.WithTempoMiddleware(authorization.WithAuthorizers(authorizers, rbac.Read, "traces")), - tracesv1.WithTempoMiddleware(logsv1.WithEnforceAuthorizationLabels()), - tracesv1.WithWriteMiddleware(authorization.WithAuthorizers(authorizers, rbac.Write, "traces")), - tracesv1.WithTempoEnableResponseQueryRBACFilter(cfg.traces.queryRBAC), - ), + r.Mount("/api/traces/v1", + tracesv1.NewV2Handler( + cfg.traces.readEndpoint, + cfg.traces.readTemplateEndpoint, + cfg.traces.tempoEndpoint, + nil, // write endpoint (handled separately above) + tracesUpstreamTLSOptions, + tracesv1.Logger(logger), + tracesv1.WithRegistry(reg), + tracesv1.WithHandlerInstrumenter(instrumenter), + tracesv1.WithSpanRoutePrefix("/api/traces/v1"), ), ) }) @@ -960,9 +881,6 @@ func main() { gs, err := newGRPCServer( &cfg, cfg.traces.tenantHeader, - tenantIDs, - pm.GRPCMiddlewares, - authorizers, logger, tracesUpstreamTLSOptions, ) @@ -1130,10 +1048,8 @@ func parseFlags() (config, error) { ) cfg := config{} - flag.StringVar(&cfg.rbacConfigPath, "rbac.config", "rbac.yaml", - "Path to the RBAC configuration file.") flag.StringVar(&cfg.tenantsConfigPath, "tenants.config", "tenants.yaml", - "Path to the tenants file.") + "Path to the tenants configuration file (for authenticators and rate limits).") flag.StringVar(&cfg.debug.name, "debug.name", "observatorium", "A name to add as a prefix to log lines.") flag.IntVar(&cfg.debug.mutexProfileFraction, "debug.mutex-profile-fraction", 10, @@ -1502,19 +1418,6 @@ func parseFlags() (config, error) { return cfg, nil } -func stripTenantPrefix(prefix string, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tenant, ok := authentication.GetTenant(r.Context()) - if !ok { - httperr.PrometheusAPIError(w, "tenant not found", http.StatusInternalServerError) - return - } - - tenantPrefix := path.Join("/", prefix, tenant) - http.StripPrefix(tenantPrefix, proxy.WithPrefix(tenantPrefix, next)).ServeHTTP(w, r) - }) -} - func unmarshalLegacyAuthenticatorConfig(v interface{}) (map[string]interface{}, error) { jsonBytes, err := json.Marshal(v) if err != nil { @@ -1561,20 +1464,7 @@ func blockNonDefinedMethods() http.HandlerFunc { return http.HandlerFunc(fn) } -// Permissions required for each gRPC method. -var gRPCRBAC = authorization.GRPCRBac{ - // "opentelemetry.proto.collector.trace.v1.TraceService/Export" requires "traces" "write" perm. - tracesv1.TraceRoute: { - Permission: rbac.Write, - Resource: "traces", - }, - // Add trace read permission for Jaeger queries, etc. - // Add Loki gRPC methods, etc. -} - -func newGRPCServer(cfg *config, tenantHeader string, tenantIDs map[string]string, pmis authentication.GRPCMiddlewareFunc, - authorizers map[string]rbac.Authorizer, logger log.Logger, upstreamTLSOptions *tls.UpstreamOptions, -) (*grpc.Server, error) { +func newGRPCServer(cfg *config, tenantHeader string, logger log.Logger, upstreamTLSOptions *tls.UpstreamOptions) (*grpc.Server, error) { connOtel, err := tracesv1.NewOTelConnection( cfg.traces.writeOTLPGRPCEndpoint, tracesv1.WithLogger(logger), @@ -1614,11 +1504,8 @@ func newGRPCServer(cfg *config, tenantHeader string, tenantIDs map[string]string grpc.UnknownServiceHandler(grpcproxy.TransparentHandler(director)), grpc.ChainStreamInterceptor( - authentication.WithGRPCTenantHeader(tenantHeader, tenantIDs, logger), - authentication.WithGRPCAccessToken(), - authentication.WithGRPCTenantInterceptors(logger, pmis), - auth.StreamServerInterceptor( - authorization.WithGRPCAuthorizers(authorizers, gRPCRBAC, logger)), + // Extract tenant from mTLS certificate OU for write operations + authentication.WithGRPCMTLSTenantExtraction(tenantHeader, logger), ), } diff --git a/opa/opa.go b/opa/opa.go deleted file mode 100644 index 4a585c46c..000000000 --- a/opa/opa.go +++ /dev/null @@ -1,350 +0,0 @@ -package opa - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "sync" - - "github.com/go-kit/log" - "github.com/go-kit/log/level" - "github.com/open-policy-agent/opa/v1/rego" - "github.com/open-policy-agent/opa/v1/server/types" - "github.com/prometheus/client_golang/prometheus" - - "github.com/observatorium/api/rbac" -) - -const ( - contentTypeHeader = "Content-Type" - xForwardedAccessTokenHeader = "X-Forwarded-Access-Token" //nolint:gosec -) - -// regoFunctions map is used for the providers' self-registration. -var regoFunctions map[string]func(log.Logger) func(*rego.Rego) - -// regoFunctionsMtx is used to protect the providerFactories. -var regoFunctionsMtx sync.RWMutex - -func init() { - regoFunctions = make(map[string]func(log.Logger) func(*rego.Rego)) -} - -// onboardNewFunction is used by pluggable custom functions to register theirself. -func onboardNewFunction(regoFunctionName string, regoFunction func(log.Logger) func(*rego.Rego)) { - regoFunctionsMtx.Lock() - defer regoFunctionsMtx.Unlock() - - regoFunctions[regoFunctionName] = regoFunction -} - -// Input models the data that is used for OPA input documents. -type Input struct { - Groups []string `json:"groups"` - Permission rbac.Permission `json:"permission"` - Resource string `json:"resource"` - Subject string `json:"subject"` - Tenant string `json:"tenant"` - TenantID string `json:"tenantID"` - Token string `json:"token"` - Extras *rbac.ExtraAttributes `json:"extras,omitempty"` -} - -type config struct { - logger log.Logger - registerer prometheus.Registerer - withAccessToken bool -} - -// Option modifies the configuration of an OPA authorizer. -type Option func(c *config) - -// LoggerOption sets a custom logger for the authorizer. -func LoggerOption(logger log.Logger) Option { - return func(c *config) { - c.logger = logger - } -} - -// AccessTokenOptions sets the flag for the access token requirement. -func AccessTokenOption(f bool) Option { - return func(c *config) { - c.withAccessToken = f - } -} - -// RegistererOption sets a Prometheus registerer for the authorizer. -func RegistererOption(r prometheus.Registerer) Option { - return func(c *config) { - c.registerer = r - } -} - -type restAuthorizer struct { - client *http.Client - url *url.URL - - logger log.Logger - registerer prometheus.Registerer - withAccessToken bool -} - -// Authorize implements the rbac.Authorizer interface. -func (a *restAuthorizer) Authorize( - subject string, - groups []string, - permission rbac.Permission, - resource, tenant, tenantID, token string, - extras *rbac.ExtraAttributes, -) (int, bool, string) { - var i interface{} = Input{ - Groups: groups, - Permission: permission, - Resource: resource, - Subject: subject, - Tenant: tenant, - TenantID: tenantID, - Extras: extras, - } - - dreq := types.DataRequestV1{ - Input: &i, - } - - j, err := json.Marshal(dreq) - if err != nil { - level.Error(a.logger).Log("msg", "failed to marshal OPA input to JSON", "err", err.Error()) - - return http.StatusForbidden, false, "" - } - - req, err := http.NewRequest(http.MethodPost, a.url.String(), bytes.NewBuffer(j)) - if err != nil { - level.Error(a.logger).Log("msg", "failed to build authorization request", "err", err.Error()) - - return http.StatusInternalServerError, false, "" - } - - req.Header.Set(contentTypeHeader, "application/json") - - if a.withAccessToken { - if token == "" { - level.Error(a.logger).Log("msg", "failed to forward access token to authorization request") - - return http.StatusInternalServerError, false, "" - } - - req.Header.Set(xForwardedAccessTokenHeader, token) - } - - res, err := a.client.Do(req) - if err != nil { - level.Error(a.logger).Log("msg", "make request to OPA endpoint", "URL", a.url.String(), "err", err.Error()) - - if res == nil { - return http.StatusInternalServerError, false, "" - } - - return res.StatusCode, false, "" - } - - if res.StatusCode/100 != 2 { - body, _ := io.ReadAll(res.Body) - res.Body.Close() - level.Error(a.logger).Log( - "msg", "received non-200 status code from OPA endpoint", - "URL", a.url.String(), - "body", body, - "status", res.Status, - ) - - return res.StatusCode, false, "" - } - - dres := types.DataResponseV1{} - if err := json.NewDecoder(res.Body).Decode(&dres); err != nil { - level.Error(a.logger).Log("msg", "failed to unmarshal OPA response", "err", err.Error()) - - return http.StatusForbidden, false, "" - } - - if dres.Result == nil { - level.Error(a.logger).Log("msg", "received an empty OPA response") - - return http.StatusForbidden, false, "" - } - - var ( - allowed bool - data string - ) - - switch res := (*dres.Result).(type) { - case bool: - allowed = res - case map[string]interface{}: - allow, ok := res["allowed"] - if !ok { - level.Error(a.logger).Log("msg", "received a malformed OPA response") - - return http.StatusForbidden, false, "" - } - - allowed, err = strconv.ParseBool(allow.(string)) - if err != nil { - level.Error(a.logger).Log("msg", "received a malformed OPA response") - - return http.StatusForbidden, false, "" - } - - data = res["data"].(string) - - default: - level.Error(a.logger).Log("msg", "received a malformed OPA response") - - return http.StatusForbidden, false, "" - } - - if !allowed { - return http.StatusForbidden, allowed, data - } - - return http.StatusOK, allowed, data -} - -// NewRESTAuthorizer creates a new rbac.Authorizer that works against an OPA endpoint. -func NewRESTAuthorizer(u *url.URL, opts ...Option) rbac.Authorizer { - c := &config{ - logger: log.NewNopLogger(), - registerer: prometheus.NewRegistry(), - } - - for _, o := range opts { - o(c) - } - - return &restAuthorizer{ - client: http.DefaultClient, - logger: c.logger, - registerer: c.registerer, - url: u, - withAccessToken: c.withAccessToken, - } -} - -type inProcessAuthorizer struct { - query *rego.PreparedEvalQuery - - logger log.Logger - registerer prometheus.Registerer -} - -// Authorize implements the rbac.Authorizer interface. -func (a *inProcessAuthorizer) Authorize( - subject string, - groups []string, - permission rbac.Permission, - resource, tenant, tenantID, token string, - extras *rbac.ExtraAttributes, -) (int, bool, string) { - var i interface{} = Input{ - Groups: groups, - Permission: permission, - Resource: resource, - Subject: subject, - Tenant: tenant, - TenantID: tenantID, - Token: token, - Extras: extras, - } - - res, err := a.query.Eval(context.Background(), rego.EvalInput(i)) - if err != nil { - level.Error(a.logger).Log("msg", "failed to evaluate OPA query", "err", err.Error()) - - return http.StatusForbidden, false, "" - } - - if len(res) == 0 || len(res[0].Expressions) == 0 || res[0].Expressions[0] == nil { - level.Error(a.logger).Log("msg", "received a empty OPA response") - - return http.StatusForbidden, false, "" - } - - var ( - allowed bool - data string - ) - - switch res := (res[0].Expressions[0].Value).(type) { - case bool: - allowed = res - case map[string]string: - allow, ok := res["allowed"] - if !ok { - level.Error(a.logger).Log("msg", "received a malformed OPA response") - - return http.StatusForbidden, false, "" - } - - allowed, err = strconv.ParseBool(allow) - if err != nil { - level.Error(a.logger).Log("msg", "received a malformed OPA response") - - return http.StatusForbidden, false, "" - } - - data = res["data"] - - default: - level.Error(a.logger).Log("msg", "received a malformed OPA response") - - return http.StatusForbidden, false, "" - } - - if !allowed { - return http.StatusForbidden, allowed, data - } - - return http.StatusOK, allowed, data -} - -// NewInProcessAuthorizer creates a new rbac.Authorizer that works in-process. -func NewInProcessAuthorizer(query string, paths []string, opts ...Option) (rbac.Authorizer, error) { - c := &config{ - logger: log.NewNopLogger(), - registerer: prometheus.NewRegistry(), - } - - for _, o := range opts { - o(c) - } - - var r *rego.Rego - - regoArgs := make([]func(*rego.Rego), 0, len(regoFunctions)+2) - for _, regoFunction := range regoFunctions { - regoArgs = append(regoArgs, regoFunction(c.logger)) - } - - // Register all provided custom built-in functions - regoArgs = append(regoArgs, rego.Query(query), rego.Load(paths, nil)) - - r = rego.New(regoArgs...) - - q, err := r.PrepareForEval(context.Background()) - if err != nil { - return nil, fmt.Errorf("failed to prepare OPA query: %w", err) - } - - return &inProcessAuthorizer{ - logger: c.logger, - query: &q, - registerer: c.registerer, - }, nil -} diff --git a/opa/opa_test.go b/opa/opa_test.go deleted file mode 100644 index a53723020..000000000 --- a/opa/opa_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package opa - -import ( - "os" - "regexp" - "testing" - - "github.com/go-kit/log" - "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/rego" - "github.com/open-policy-agent/opa/v1/types" - - "github.com/observatorium/api/rbac" -) - -func dummyCustomRegoFunction(logger log.Logger) func(*rego.Rego) { - return rego.Function1( - ®o.Function{ - Name: "isEmailAddress", - Decl: types.NewFunction(types.Args(types.A), types.B), - }, - func(_ rego.BuiltinContext, subject *ast.Term) (*ast.Term, error) { - // Dummy check, allow only email-based subjects - validEmail := regexp.MustCompile(`^\S+@\S+\.\S+$`) - return ast.BooleanTerm(validEmail.Match([]byte(subject.Value.String()))), nil - }) -} - -func TestCustomRegoFunctions(t *testing.T) { - onboardNewFunction("dummy-rego-function", dummyCustomRegoFunction) - - dir := t.TempDir() - defer os.RemoveAll(dir) - - regoFile, err := os.CreateTemp(dir, "test.*.rego") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - defer os.Remove(regoFile.Name()) - - regoLogic := ` -package observatorium - -import input - -default allow = false - -allow if { - isEmailAddress(input.subject) -} -` - - _, err = regoFile.Write([]byte(regoLogic)) - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - authorizer, err := NewInProcessAuthorizer("data.observatorium.allow", []string{regoFile.Name()}) - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - t.Run("successful authorize with rego built-in function", func(t *testing.T) { - _, isPermitted, data := authorizer.Authorize("example@example.com", []string{}, rbac.Write, "logs", "dummyTenant", "dummyTenantID", "", nil) - if len(data) != 0 { - t.Fatalf("unexpected data: Got: %s, Wanted: %s", data, "") - } - - if !isPermitted { - t.Fatalf("unexpected permission response: Got: %t, Wanted: %t", isPermitted, true) - } - }) - - t.Run("unsuccessful authorize with rego built-in function", func(t *testing.T) { - _, isPermitted, data := authorizer.Authorize("dummySubject", []string{}, rbac.Write, "logs", "dummyTenant", "dummyTenantID", "", nil) - if len(data) != 0 { - t.Fatalf("unexpected data: Got: %s, Wanted: %s", data, "") - } - if isPermitted { - t.Fatalf("unexpected permission response: Got: %t, Wanted: %t", isPermitted, false) - } - }) -} diff --git a/rbac/rbac.go b/rbac/rbac.go deleted file mode 100644 index 3734e4fdc..000000000 --- a/rbac/rbac.go +++ /dev/null @@ -1,204 +0,0 @@ -package rbac - -import ( - "fmt" - "io" - "net/http" - - "github.com/ghodss/yaml" - "github.com/go-kit/log" - "github.com/go-kit/log/level" -) - -// Permission is an Observatorium RBAC permission. -type Permission string - -// SubjectKind is a kind of Observatorium RBAC subject. -type SubjectKind string - -const ( - // Write gives access to write data to a tenant. - Write Permission = "write" - // Read gives access to read data from a tenant. - Read Permission = "read" - - // User represents a subject that is a user. - User SubjectKind = "user" - // Group represents a subject that is a group. - Group SubjectKind = "group" -) - -// Role describes a set of permissions to interact with a tenant. -type Role struct { - Name string `json:"name"` - Resources []string `json:"resources"` - Tenants []string `json:"tenants"` - Permissions []Permission `json:"permissions"` -} - -// Subject represents a subject that has been bound to a role. -type Subject struct { - Name string `json:"name"` - Kind SubjectKind `json:"kind"` -} - -// RoleBinding binds a set of roles to a set of subjects. -type RoleBinding struct { - Name string `json:"name"` - Subjects []Subject `json:"subjects"` - Roles []string `json:"roles"` -} - -// ExtraAttributes contains extra data about the request that can be used to make a more precise authorization decision. -type ExtraAttributes struct { - Selectors map[string][]string `json:"selectors,omitempty"` - WildcardSelectors bool `json:"wildcardSelectors,omitempty"` - MetadataOnly bool `json:"metadataOnly,omitempty"` -} - -// Authorizer can authorize a subject's permission for a tenant's resource. -type Authorizer interface { - // Authorize answers the question: can subject S in groups G perform permission P on resource R for Tenant T? - Authorize(subject string, groups []string, permission Permission, resource, tenant, tenantID, token string, extras *ExtraAttributes) (int, bool, string) -} - -// tenant represents the read and write permissions of many subjects on a single tenant. -type tenant struct { - read map[Subject]struct{} - write map[Subject]struct{} -} - -// tenants is a map of tenant names to read and write permissions for subjects. -type tenants map[string]tenant - -// resources is a map of resource names to the permissions on tenants. -type resources struct { - tenants map[string]tenants - logger log.Logger -} - -// Authorize implements the Authorizer interface. -func (rs resources) Authorize(subject string, groups []string, permission Permission, resource, tenant, - tenantID, token string, _ *ExtraAttributes, -) (int, bool, string) { - ts, ok := rs.tenants[resource] - if !ok { - level.Debug(rs.logger).Log("msg", - fmt.Sprintf("authorization: resource %q unknown; valid resources are %v", resource, rs)) - return http.StatusForbidden, false, "" - } - - t, ok := ts[tenant] - if !ok { - level.Debug(rs.logger).Log("msg", - fmt.Sprintf("authorization: tenant %q unknown (%d valid tenants for resource %q)", - tenant, len(ts), resource)) - - return http.StatusForbidden, false, "" - } - - var pmap map[Subject]struct{} - - switch permission { - case Read: - pmap = t.read - case Write: - pmap = t.write - } - - // First check the user directly - if _, ok := pmap[Subject{Name: subject, Kind: User}]; ok { - return http.StatusOK, ok, "" - } - - // Now check the user's groups. - for _, group := range groups { - if _, ok := pmap[Subject{Name: group, Kind: Group}]; ok { - return http.StatusOK, ok, "" - } - } - - level.Debug(rs.logger).Log("msg", - fmt.Sprintf("authorization: %q unknown; groups %v unknown", - subject, groups)) - - return http.StatusForbidden, false, "" -} - -// NewAuthorizer creates a new Authorizer. -// -//nolint:gocognit -func NewAuthorizer(roles []Role, roleBindings []RoleBinding, logger log.Logger) Authorizer { - rs := make(map[string]Role) - for _, role := range roles { - rs[role.Name] = role - } - - resources := resources{ - tenants: make(map[string]tenants), - logger: logger, - } - - for _, rb := range roleBindings { - for _, roleName := range rb.Roles { - role, ok := rs[roleName] - if !ok { - level.Warn(logger).Log("msg", fmt.Sprintf("Unexpected role %q", roleName)) - continue - } - - for _, resourceName := range role.Resources { - if _, ok := resources.tenants[resourceName]; !ok { - resources.tenants[resourceName] = make(tenants) - } - - t := resources.tenants[resourceName] - - for _, tenantName := range role.Tenants { - if _, ok := t[tenantName]; !ok { - t[tenantName] = tenant{ - read: make(map[Subject]struct{}), - write: make(map[Subject]struct{}), - } - } - - for _, s := range rb.Subjects { - for _, p := range role.Permissions { - switch p { - case Read: - t[tenantName].read[s] = struct{}{} - case Write: - t[tenantName].write[s] = struct{}{} - default: - level.Warn(logger).Log("msg", - fmt.Sprintf("Ignoring unexpected role permission %q for subject %q in tenant %q in role %q", p, - s, tenantName, roleName)) - } - } - } - } - } - } - } - - return resources -} - -// Parse parses RBAC data from a reader and creates a new Authorizer. -func Parse(r io.Reader, logger log.Logger) (Authorizer, error) { - rbac := struct { - Roles []Role `json:"roles"` - RoleBindings []RoleBinding `json:"roleBindings"` - }{} - - raw, err := io.ReadAll(r) - if err != nil { - return nil, fmt.Errorf("could not read RBAC data: %w", err) - } - - if err := yaml.Unmarshal(raw, &rbac); err != nil { - return nil, fmt.Errorf("could not parse RBAC data: %w", err) - } - - return NewAuthorizer(rbac.Roles, rbac.RoleBindings, logger), nil -} diff --git a/rbac/rbac_test.go b/rbac/rbac_test.go deleted file mode 100644 index 70eca267b..000000000 --- a/rbac/rbac_test.go +++ /dev/null @@ -1,1034 +0,0 @@ -package rbac - -import ( - "net/http" - "testing" - - "github.com/observatorium/api/logger" -) - -// nolint:dupl,funlen,scopelint -func TestNewAuthorizer(t *testing.T) { - type io struct { - subject string - groups []string - permission Permission - resource string - tenant string - tenantID string - output bool - statusCode int - } - - for _, tc := range []struct { - name string - roles []Role - roleBindings []RoleBinding - ios []io - }{ - { - name: "empty", - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "only roles", - roles: []Role{ - { - Name: "a-write", - Resources: []string{"foo"}, - Tenants: []string{"a"}, - Permissions: []Permission{"write"}, - }, - { - Name: "b-write", - Resources: []string{"foo"}, - Tenants: []string{"b"}, - Permissions: []Permission{"write"}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "only merged roles", - roles: []Role{ - { - Name: "a-b-write", - Resources: []string{"foo"}, - Tenants: []string{"a", "b"}, - Permissions: []Permission{"write"}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "only role bindings", - roleBindings: []RoleBinding{ - { - Name: "erika-a", - Roles: []string{"a-write"}, - Subjects: []Subject{{Name: "erika", Kind: User}}, - }, - { - Name: "max-a", - Roles: []string{"b-write"}, - Subjects: []Subject{{Name: "max", Kind: User}}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "only merged role bindings", - roleBindings: []RoleBinding{ - { - Name: "a-b", - Roles: []string{"a-b-write"}, - Subjects: []Subject{{Name: "erika", Kind: User}}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "erika write foo for a", - roles: []Role{ - { - Name: "a-write", - Resources: []string{"foo"}, - Tenants: []string{"a"}, - Permissions: []Permission{"write"}, - }, - { - Name: "b-write", - Resources: []string{"foo"}, - Tenants: []string{"b"}, - Permissions: []Permission{"write"}, - }, - }, - roleBindings: []RoleBinding{ - { - Name: "erika-a", - Roles: []string{"a-write"}, - Subjects: []Subject{{Name: "erika", Kind: User}}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Write, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "erika write foo and bar for a", - roles: []Role{ - { - Name: "a-write", - Resources: []string{"foo", "bar"}, - Tenants: []string{"a"}, - Permissions: []Permission{"write"}, - }, - { - Name: "b-write", - Resources: []string{"foo"}, - Tenants: []string{"b"}, - Permissions: []Permission{"write"}, - }, - }, - roleBindings: []RoleBinding{ - { - Name: "erika-a", - Roles: []string{"a-write"}, - Subjects: []Subject{{Name: "erika", Kind: User}}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Write, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "erika read-write foo and bar for a", - roles: []Role{ - { - Name: "rw", - Resources: []string{"foo", "bar"}, - Tenants: []string{"a"}, - Permissions: []Permission{"read", "write"}, - }, - { - Name: "b-write", - Resources: []string{"foo"}, - Tenants: []string{"b"}, - Permissions: []Permission{"write"}, - }, - }, - roleBindings: []RoleBinding{ - { - Name: "erika-a", - Roles: []string{"rw"}, - Subjects: []Subject{{Name: "erika", Kind: User}}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Write, - resource: "bar", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Write, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "both write foo for a", - roles: []Role{ - { - Name: "writer", - Resources: []string{"foo"}, - Tenants: []string{"a"}, - Permissions: []Permission{"write"}, - }, - { - Name: "reader", - Resources: []string{"foo"}, - Tenants: []string{"a", "b"}, - Permissions: []Permission{"reader"}, - }, - }, - roleBindings: []RoleBinding{ - { - Name: "a", - Roles: []string{"writer"}, - Subjects: []Subject{{Name: "erika", Kind: User}, {Name: "max", Kind: User}}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "both write for a and b", - roles: []Role{ - { - Name: "writer", - Resources: []string{"foo"}, - Tenants: []string{"a", "b"}, - Permissions: []Permission{"write"}, - }, - { - Name: "reader", - Resources: []string{"foo"}, - Tenants: []string{"a", "b"}, - Permissions: []Permission{"reader"}, - }, - }, - roleBindings: []RoleBinding{ - { - Name: "a", - Roles: []string{"writer"}, - Subjects: []Subject{{Name: "erika", Kind: User}, {Name: "max", Kind: User}}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Write, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "bar", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: true, - statusCode: http.StatusOK, - }, - }, - }, - { - name: "both read foo for b", - roles: []Role{ - { - Name: "writer", - Resources: []string{"foo"}, - Tenants: []string{"a", "b"}, - Permissions: []Permission{"write"}, - }, - { - Name: "reader", - Resources: []string{"foo"}, - Tenants: []string{"b"}, - Permissions: []Permission{"read"}, - }, - }, - roleBindings: []RoleBinding{ - { - Name: "b", - Roles: []string{"reader"}, - Subjects: []Subject{{Name: "erika", Kind: User}, {Name: "max", Kind: User}}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "b", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Read, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Read, - resource: "foo", - tenant: "b", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - permission: Read, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "both read-write foo for a and b", - roles: []Role{ - { - Name: "writer", - Resources: []string{"foo"}, - Tenants: []string{"a", "b"}, - Permissions: []Permission{"write"}, - }, - { - Name: "reader", - Resources: []string{"foo"}, - Tenants: []string{"b", "a"}, - Permissions: []Permission{"read"}, - }, - }, - roleBindings: []RoleBinding{ - { - Name: "a-b", - Roles: []string{"reader", "writer"}, - Subjects: []Subject{{Name: "erika", Kind: User}, {Name: "max", Kind: User}}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "b", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Read, - resource: "foo", - tenant: "b", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Write, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "both read-write merged", - roles: []Role{ - { - Name: "rw", - Resources: []string{"foo"}, - Tenants: []string{"a", "b"}, - Permissions: []Permission{"read", "write"}, - }, - }, - roleBindings: []RoleBinding{ - { - Name: "a-b", - Roles: []string{"rw"}, - Subjects: []Subject{{Name: "erika", Kind: User}, {Name: "max", Kind: User}}, - }, - }, - ios: []io{ - { - subject: "erika", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - permission: Read, - resource: "foo", - tenant: "b", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Read, - resource: "foo", - tenant: "b", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Write, - resource: "foo", - tenant: "b", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - permission: Write, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "group mustermann read for a", - roles: []Role{ - { - Name: "a-read", - Resources: []string{"foo"}, - Tenants: []string{"a"}, - Permissions: []Permission{"read"}, - }, - }, - roleBindings: []RoleBinding{ - { - Name: "mustermann-a", - Roles: []string{"a-read"}, - Subjects: []Subject{{Name: "mustermann", Kind: Group}}, - }, - }, - ios: []io{ - { - subject: "erika", - groups: []string{"mustermann"}, - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - groups: []string{"mustermann"}, - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - groups: []string{"mustermann"}, - permission: Read, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - groups: []string{"mustermann", "other"}, - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "max", - groups: []string{"mustermann", "other"}, - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - groups: []string{"mustermann", "other"}, - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - groups: []string{"mustermann", "other"}, - permission: Write, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - { - name: "group erika read for a", - roles: []Role{ - { - Name: "a-read", - Resources: []string{"foo"}, - Tenants: []string{"a"}, - Permissions: []Permission{"read"}, - }, - }, - roleBindings: []RoleBinding{ - { - Name: "erika-a", - Roles: []string{"a-read"}, - Subjects: []Subject{{Name: "erika", Kind: Group}}, - }, - }, - ios: []io{ - { - subject: "erika", - groups: []string{"erika", "mustermann"}, - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: true, - statusCode: http.StatusOK, - }, - { - subject: "erika", - groups: []string{"erika", "mustermann"}, - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "erika", - groups: []string{"erika", "mustermann"}, - permission: Read, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - groups: []string{"mustermann", "other"}, - permission: Read, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - groups: []string{"mustermann", "other"}, - permission: Write, - resource: "foo", - tenant: "a", - tenantID: "1610b0c3-c509-4592-a256-a1871353dbfa", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - groups: []string{"mustermann", "other"}, - permission: Write, - resource: "foo", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - { - subject: "max", - groups: []string{"mustermann", "other"}, - permission: Write, - resource: "bar", - tenant: "b", - output: false, - statusCode: http.StatusForbidden, - }, - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - a := NewAuthorizer(tc.roles, tc.roleBindings, logger.NewLogger("info", logger.LogFormatLogfmt, "observatorium")) - for i := range tc.ios { - sc, out, data := a.Authorize(tc.ios[i].subject, tc.ios[i].groups, tc.ios[i].permission, tc.ios[i].resource, - tc.ios[i].tenant, tc.ios[i].tenantID, "", nil) - if sc != tc.ios[i].statusCode { - t.Errorf("test case %d: expected status code %d; got %d", i, tc.ios[i].statusCode, sc) - } - if out != tc.ios[i].output { - t.Errorf("test case %d: expected return %t; got %t", i, tc.ios[i].output, out) - } - if data != "" { - t.Errorf("test case %d: no custom data supported", i) - } - } - }) - } -} diff --git a/test/e2e/configs.go b/test/e2e/configs.go index b119e2f7b..28adfd9e1 100644 --- a/test/e2e/configs.go +++ b/test/e2e/configs.go @@ -56,7 +56,7 @@ tenants: - %[3]s - %[4]s rateLimits: - - endpoint: "/api/metrics/v1/.+/api/v1/receive" + - endpoint: "/api/metrics/v1/api/v1/receive" limit: 100 window: 1s - endpoint: "/api/logs/v1/.*" @@ -97,7 +97,7 @@ tenants: opa: url: http://%[6]s rateLimits: - - endpoint: "/api/metrics/v1/.+/api/v1/receive" + - endpoint: "/api/metrics/v1/api/v1/receive" limit: 1 window: 1s - endpoint: "/api/logs/v1/.*"