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
- Metrics
namespace="order-service" - Logs
k8s_namespace_name="order-service" - Traces
resource.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 | Status | When |
|---|---|---|
no_grant | 403 | No policy grants this user the backend. |
endpoint_not_allowed | 403 | The path or method is not in the endpoint table. Everything not listed is denied. |
denied_label | 403 | The query references a label the policy denies, or a template that could read it. |
parse_error | 400 | The query did not parse. The parsers are stricter than the backends. |
unsupported | 400 | Valid, but not rewritable exactly for this user's policy. |
bad_request | 400 | An unknown parameter, such as storeMatch[], or a malformed request. |
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: trueThe 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.
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.shNeeds 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.
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:
- Unit, fuzz and envtest suites, plus differential suites that run the same queries against real Prometheus, Loki and Tempo and compare the results.
- A threat model and a security self-assessment, written alongside the code.
- Known limits, written down: metadata endpoints for Loki under tenant-bound policies fail closed, the parsers reject some valid queries, and denied labels hide labels but do not redact query results.