Per-team access control for Prometheus, Loki and Tempo

Teams that share one Prometheus, Loki and Tempo can normally read each other's data. Querdian is a Kubernetes proxy in front of them: it adds each team's label filters to every PromQL, LogQL and TraceQL query, so a team only gets its own telemetry. Unknown users, endpoints and queries are refused.

Run the quickstart Read the docs

Pre-alpha, not released. Apache-2.0.

Access pass

Alice

order-team

Namespace order-service

  • Metricsnamespace="order-service"
  • Logsk8s_namespace_name="order-service"
  • Tracesresource.k8s.namespace.name="order-service"

All zones

Denied label customer_id (metrics)

Demo user from the quickstart

The client sends

sum by (route) (rate(http_requests_total{status=~"5.."}[5m]))

The backend receives

sum by (route) (rate(http_requests_total{namespace="order-service",status=~"5.."}[5m]))

Rewritten by the PromQL enforcer; the matcher is added inside the selector.

MetricsPrometheus and Thanos

Matchers are injected into every vector selector, including selectors inside range vectors and subqueries. A matcher the user wrote on the same label can only narrow the result further.

When a user has several grants, selectors are lifted into or expressions. Label and series endpoints are expanded and filtered, and denied labels are hidden from them.

LogsLoki

Matchers go into the stream selector. Several grants fan out into separate requests whose results are merged, and live tailing over WebSocket is covered too.

Templates that could read a denied label, such as {{ . }} in line_format, are refused instead of guessed at.

TracesTempo

Search queries receive the grant as a parenthesised condition. Trace lookups by ID are filtered span by span, and denied attributes are removed from the spans that remain.

A query that would reveal root-span fields the grant does not cover is rejected, because the answer itself would leak.

Refusing is the feature

Anything Querdian cannot rewrite exactly is rejected, never approximated. Every decision carries a reason code you can search for in logs and traces. These are real ones from the troubleshooting guide.

Reason codes and their HTTP status
ReasonStatusWhen
no_grant403No policy grants this user the backend.
endpoint_not_allowed403The path or method is not in the endpoint table. Everything not listed is denied.
denied_label403The query references a label the policy denies, or a template that could read it.
parse_error400The query did not parse. The parsers are stricter than the backends.
unsupported400Valid, but not rewritable exactly for this user's policy.
bad_request400An unknown parameter, such as storeMatch[], or a malformed request.

All reason codes in the troubleshooting guide

Policies are Kubernetes objects

Teams get an AccessPolicy in their own namespace. Platform admins get a ClusterAccessPolicy, the only kind that can be unrestricted. This is the policy file the quickstart applies.

# AccessPolicy: implicitly limited to its own namespace, on every backend
apiVersion: policy.querdian.dev/v1alpha1
kind: AccessPolicy
metadata: {name: order-team, namespace: order-service}
spec:
  subjects: {groups: [order-team]}
  rules:
    - backends: [metrics]
      labels: {deny: [customer_id]}
    - backends: [logs, traces]
---
# ClusterAccessPolicy: may be unrestricted, per tenant
apiVersion: policy.querdian.dev/v1alpha1
kind: ClusterAccessPolicy
metadata: {name: platform-admins}
spec:
  subjects: {groups: [platform-admins]}
  rules:
    - backends: [metrics, logs, traces]
      tenants: [demo]
      unrestricted: true

The group names come from the token your identity provider issues. Querdian verifies the token with OIDC discovery and accepts only RS256.

A policy is ANDed with the namespace label of its own namespace, so the namespace label must be set by your telemetry pipeline, not by the teams.

A policy that fails validation grants nothing, and its status says why.

Policy guide

Try it on a laptop

One script creates a kind cluster with Keycloak, Prometheus, Loki, Tempo, Grafana, two sample teams and 24 hours of demo data. Log in to Grafana as Alice or Bob and see different data.

hack/quickstart/up.sh       # cluster and everything, prints the URLs
hack/quickstart/verify.sh   # 150 automatic checks
hack/quickstart/down.sh

Needs kind, kubectl, helm, make, curl, jq, and Docker or Podman with about 3 GiB of free memory. There is no published image yet, so the script builds Querdian from the repository.

Quickstart guide

Pre-alpha, and what that means

Nothing here is ready for production. There is no release, no published image or chart, and no adopters. The proxy, the policy objects and the three enforcers are implemented and tested:

Known limitations · Threat model

Type to search all documentation pages.