Authorization¶
Authentication establishes who is calling. Authorization decides what they may read and write.
Every telemetry row carries an opaque label — its audience — stamped server-side from the credential that wrote it. A separate, editable grant map says which principals may read from, and mint keys into, each audience. Queries are filtered to the caller's own audiences.
Filtering activates whenever authentication does: an authenticated session gets
ReadScope::Audiences, a --disable-auth one keeps ReadScope::All and reads
everything. There is no separate switch — what an identity-less caller sees is
shaped by MICROMEGAS_DEFAULT_AUDIENCE and the grant map.
Configuration¶
| Variable | Default | Description |
|---|---|---|
MICROMEGAS_DEFAULT_AUDIENCE |
public |
Label stamped on rows whose credential carries no bound audience. Set it identically on every role that builds a lakehouse — FlightSQL, maintenance, monolith, and ingestion. |
MICROMEGAS_PUBLIC_VIEW_SETS |
unset | Comma-separated view sets exempt from filtering entirely; an operator-responsibility allowlist. |
MICROMEGAS_SELF_SERVICE_MINT |
false |
Lets a non-admin mint their own ingestion key and manage grants. See Self-service mint. |
MICROMEGAS_SELF_SERVICE_MAX_CLAIMS_PER_CALLER |
25 |
Distinct audiences one non-admin may claim. Best-effort under concurrency. |
MICROMEGAS_SELF_SERVICE_MAX_KEYS_PER_CALLER |
100 |
Live keys one non-admin may hold. list_keys/revoke_key stay admin-only, so freeing a slot needs an admin. |
MICROMEGAS_SELF_SERVICE_MAX_GRANTS_PER_CALLER |
50 |
Rows one non-admin may have created in audience_grants, excluding their own user:<email> rows. Best-effort. |
MICROMEGAS_AUTH_CACHE_TTL_SECONDS |
60 |
Snapshot lifetime for the grant, API-key, and group stores. Flat and unprefixed — no role-scoped form, including on the monolith. |
Audiences and Grants¶
An audience is an opaque label on data — public, team-alpha,
payments-svc — not an encoding of any principal's identity. Who may use it
is separate configuration: rows in the audience_grants
table, each an (audience, axis, selector) triple.
Audience names are [A-Za-z0-9_-]{1,255}, case-sensitive, with no
normalization. The two axes are independent: read grants visibility, mint
grants authority to issue an ingestion key bound to the audience. Neither
implies the other.
| Selector | Matches |
|---|---|
* |
any authenticated principal |
user:<email> |
the caller's email claim |
group:<g> |
members of local group g, transitively (see Groups) |
- No self-audience rule. A caller is never granted an audience for being
named like one — an API key named
team-alphadoes not readteam-alpha. A personal audience is an ordinary audience with an ordinary grant. - Re-sharing is a grants edit, never a restamp. A stamped audience value
never changes; adding a
readselector forteam-alphaapplies to already-ingested data immediately, bounded by the cache TTL. - Users see their own grants, and share/mint, from the Audience Access page
(
/audiences, open to every authenticated user — seeweb-app.md) or vialist_audience_grants().
public¶
public has no built-in read grant. A fresh deployment's DB grant store ships
with ('public', 'read', '*') inserted, which is the whole of why it is
universally readable; delete the row and it stops being.
A caller with no identity of its own — an API key, or an OIDC token with no
email claim — matches that * selector like any other principal.
MICROMEGAS_DEFAULT_AUDIENCE's default of public is the write-side half: it
is what puts such a caller's data under that same label.
Worked profiles¶
Open — everyone reads everything. Nothing to configure: the default
audience is public and its seeded ('public', 'read', '*') row covers it.
Privacy — a team's data stays inside the team. Point the default at a label nobody is granted, so anything that omits an audience is invisible rather than published, and name the audience explicitly on every key you mint:
export MICROMEGAS_DEFAULT_AUDIENCE=unassigned
micromegas-grants --url https://analytics.example.com create team-alpha read group:eng
Personal audience with mint authority (read is a separate grant):
micromegas-grants --url https://analytics.example.com create alice-laptop mint user:alice@example.com
micromegas-grants --url https://analytics.example.com create alice-laptop read user:alice@example.com
Set a custom MICROMEGAS_DEFAULT_AUDIENCE and it needs a grant row of its own
before MICROMEGAS_SELF_SERVICE_MINT goes on — nothing seeds one, and a
non-admin could otherwise claim the name (see
self-service mint).
Audience stamping¶
processes, streams, and blocks each carry their own audience column,
written from the authenticated ingestion credential — never trusted from the
client payload.
- Ingestion strips any client-supplied
micromegas.*property and stamps at insert time. Each row's stamp is the credential that wrote that row, never derived from theprocess_id/stream_idit points at. - A DB-backed
ingestion_api_keysrow's bound audience is stamped as-is. A credential with none — OIDC token or no auth provider — is stamped withMICROMEGAS_DEFAULT_AUDIENCE. - Client self-stamping has no effect. To get its own label a producer needs a DB ingestion key bound to that audience.
log_entries,measures, andlog_statsinherit the owning block's stamp.- A stamped audience is immutable: there is no
UPDATE processespath.
OTLP process_id is audience-scoped, so two audiences posting identical
resource attributes never collapse onto one process. Each audience gets its own
id namespace and the deployment default owns the un-salted one, which has two
consequences:
- A key explicitly bound to a label equal to the default moves into the un-salted namespace, appearing as a new process row; its earlier data keeps the old id. Rotating a key to a different audience splits a producer's history across two ids the same way.
- Flipping the default can leave unaudienced traffic presenting an un-salted
process_idagainst rows stamped under the old label, which registration rejects (below) until those rows age out.
Registration conflicts¶
insert_process/register_otel_process reject a same-process_id,
different-audience re-registration with a 403, and
insert_stream/register_otel_stream do the same per stream_id. Clearing a
wrongly-registered id means deleting the row by hand (DELETE FROM processes
WHERE process_id = ...); delete_empty_processes only reclaims it once it has
no streams and retention has elapsed.
Query-time audience filtering¶
Two layers narrow reads under a ReadScope::Audiences session.
Row-level (OwnershipRewrite) injects an audience predicate into every
MaterializedView-backed plan, so a caller sees only rows whose own audience
column resolves to one of their audiences — processes, streams, blocks,
log_entries, measures, log_stats.
Call-level (AudienceGuard) covers five arg-addressed functions:
view_instance, process_spans, perfetto_trace_chunks, parse_block,
get_payload. A call fails with a not-found-shaped error unless its id argument
names a process or stream in one of the caller's audiences. list_partitions()
silently omits every row that isn't theirs — except 'global' rows, which an audience-scoped
admin holding no grant on anything still sees, via AudienceGuard::global_rows_visible's
lakehouse_admin arm (an accepted carve-out: it rides on the same lakehouse_admin boolean as
the mutating-function registration gate below, so a caller who can already
retire/regenerate a global file can also see it — no new authority, no new knob).
For net_spans, otel_spans, images, async_events, and thread_spans —
no audience column, reachable only through view_instance(...) — call-level
is the only enforcement. Elsewhere row-level already covers the scan.
Two exemptions from view_instance's guard:
'global'instances, which have nothing to materialize and whose rows row-level filtering covers one at a time. This is a different rule fromlist_partitions()'s'global'-row visibility, which gates partition metadata rather than the rows of the file.- View sets on
MICROMEGAS_PUBLIC_VIEW_SETS, every row of which is already readable.
Freshness differs between the layers. Row-level reads the daemon-materialized parquet snapshot, so a process the maintenance role hasn't caught up on is invisible to everyone, its owner included. Call-level reads Postgres, so it is fresher for a just-ingested process but denies once retention has deleted the Postgres row, even if a compacted partition of its data survives. Both resolve a row's stamp identically — the only skew is this timing.
Admin-gated lakehouse functions¶
Nine functions are gated on admin-ness rather than audience:
retire_partitions, materialize_partitions, regenerate_partitions,
retire_partition_by_file, retire_partition_by_metadata, the query deny
list's list_query_denials,
deny_queries, remove_query_denial, and
list_view_set_definitions. A
non-admin does not get them registered at all, so a call reads as "function
not found". CREATE/DROP MATERIALIZED VIEW (see Materialized
Views) is gated by this same admin check, applied
directly rather than through function registration, since view DDL is
intercepted ahead of the normal query path — and it is a read carve-out too, stronger than the
other admin-gated sites; see below.
Admin-ness is transitive membership in the reserved admins local group — see
Groups and Authentication → Admin
Privileges.
Deployment-wide, not per-audience
None of the nine filters by audience: an admin can retire any audience's
partitions and deny every query. A fresh deployment's admins group holds
a wildcard ('admins', '*') member, which makes every authenticated
caller admin until an operator takes over — micromegas-groups add admins
user:<you>, then remove admins '*'.
Two further is_admin-gated sites are accepted, narrower carve-outs on the data plane, not
covered by the narrowing below: bulk_ingest (flight_sql_service_impl.rs) writes the
audience column verbatim under an is_admin gate — its purpose is cross-audience replication
of a lake already stamped at origin, which per-audience grants cannot express, and it grants no
read — and AudienceGuard::global_rows_visible's lakehouse_admin arm, noted above.
CREATE/DROP MATERIALIZED VIEW is a read carve-out, and the strongest of the three admin-gated
DDL/lakehouse sites: the resulting view's own queries are planned and materialized under
CallerContext::maintenance() (ReadScope::All), so its author sees every audience regardless of
their own read scope, not merely the ability to issue the CREATE/DROP itself.
DDL-defined materialized views and audience¶
A DDL-defined materialized view is filtered by the same two
OwnershipRewrite branches as the code-driven views listed above: its extract query must project
either an audience column (preferred, filtered directly) or a process_id column (resolved to
an audience per-process via MAX(audience) — coarser, and wrong for a process whose rows span
two audiences). This is enforced at CREATE time, not left to fail silently at query time.
Self-service ingestion key mint¶
POST {base_path}/api/ingestion-api-keys is not purely admin-gated: a mint
grant is the authorization instead, resolved per request by a point query
against audience_grants, never a cached snapshot. Gated behind
MICROMEGAS_SELF_SERVICE_MINT, off by default, along with three per-caller
caps — see Configuration. The knob also gates GET
.../audience-grants/my-audiences for non-admins, non-admin grant
create/delete, and GET .../audience-grants/visible's non-admin narrowing.
publicships mintable. A fresh deployment holds('public', 'mint', '*'), so with the knob on any authenticated non-admin can mint apublic-bound key with no admin step. The row confers nothing while the knob is off. Remove it (micromegas-grants delete public mint '*', or Audience Access →public→ Mint → Remove) to require a per-caller grant. A deployment with a custom default still gets the literalpublicrow.- Every caller claims. A caller — admin or not — who names a brand-new,
never-granted audience explicitly claims it inside the same transaction
that mints the key, writing
user:<email>rows on both axes. Any existing grant row — admin-created, self-claimed, or another caller's in-flight claim — makes a matching grant required instead;is_adminconfers none. The response'sclaimedfield reports whether this call is what created it. An admin with no email is unaffected. publicand the deployment'sMICROMEGAS_DEFAULT_AUDIENCEcan never be claimed, which is distinct from being mintable. A deployment running a customMICROMEGAS_DEFAULT_AUDIENCEneeds a one-timemintgrant on it —micromegas-grants create <audience> mint '*'(or a narrower selector) — before its first default-audience mint; the service logs a startupwarn!when that grant is missing.
micromegas-setup-telemetry wraps login, mint, and printing the
OTEL_EXPORTER_OTLP_* env vars:
# Existing grant (resolved via GET .../my-audiences if --audience is omitted):
micromegas-setup-telemetry --url https://analytics.example.com --name my-laptop \
--audience alice-laptop
# Fresh claim of an audience of your own. --user-audience composes the name under a
# prefix derived server-side from your email -- the same command works for an admin
# and a non-admin caller alike.
micromegas-setup-telemetry --url https://analytics.example.com --name ci-runner \
--user-audience ci-runner
eval "$(micromegas-setup-telemetry --url https://analytics.example.com --name my-laptop)"
# PowerShell equivalent (--format is never inferred from the OS -- pass it explicitly):
micromegas-setup-telemetry --url https://analytics.example.com --name my-laptop `
--format powershell | Invoke-Expression
See python-api.md
for the CLI reference and api-keys.md for the mint route's
error shapes (FORBIDDEN, UNAVAILABLE, UNAUTHENTICATED,
CLAIM_CONTENDED).
The grant store¶
Grants live in the audience_grants Postgres table, managed over HTTP with no
redeploy:
CREATE TABLE audience_grants (
audience VARCHAR(255) NOT NULL,
axis VARCHAR(4) NOT NULL CHECK (axis IN ('read', 'mint')),
selector VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
created_by VARCHAR(255) NOT NULL,
PRIMARY KEY (audience, axis, selector),
CONSTRAINT audience_grants_audience_name CHECK (audience ~ '^[A-Za-z0-9_-]+$'),
CONSTRAINT audience_grants_selector_shape
CHECK (selector = '*' OR selector ~ '^(user|group):.+$')
);
Each flight-sql process — standalone, or the monolith's role — holds one
whole-table snapshot; it is the sole source AudienceReadPolicy/
AudienceMintPolicy resolve the read/mint axes from at request time.
analytics-web-srv is the write surface only; it caches nothing.
The table's CHECK constraints are re-validated in Rust on every load, so a row
that bypassed them fails the whole snapshot load loudly.
Routes¶
| Route | Body / result |
|---|---|
POST {base_path}/api/audience-grants |
{"audience","axis","selector"} → 201 created or 200 already existed, returning the row |
DELETE {base_path}/api/audience-grants?audience=&axis=&selector= |
204, or 404/403. Query params, not path segments, since a group:<id> selector may contain / |
GET {base_path}/api/audience-grants/visible |
The rows the caller may see — backs the Audience Access page's list |
GET {base_path}/api/audience-grants/my-audiences |
Any authenticated caller. {"is_admin","audiences","mint_prefix","email","held_pairs","groups"} — audiences whose mint selector matches this caller, a namespace prefix a name is minted under via micromegas-setup-telemetry --user-audience, the "{audience}:{axis}" pairs held via an identity selector (populated for an admin the same as anyone else), and the caller's transitive group closure |
There is no paginated GET over the whole collection; arbitrary rows come from
list_audience_grants().
micromegas-grants wraps the two write routes:
micromegas-grants --url https://analytics.example.com create team-alpha read group:eng
micromegas-grants --url https://analytics.example.com delete team-alpha read group:eng
Write gate¶
An admin acts unconditionally here — unchanged by the data-plane narrowing above, because this
is grant administration: creating/deleting a row in the store, not exercising the access a row
grants. A non-admin is admitted only with
MICROMEGAS_SELF_SERVICE_MINT on, then constrained per call:
- Create:
selectormust beuser:/group:, never*— a caller who can read an audience must not be able to open it to every principal. The caller must hold(audience, axis)via an identity selector. Delegation is per axis: areadgrant sharesread, amintgrant sharesmint, neither confers the other. Agroup:Xnaming a nonexistent group is refused with 404. - Delete: the row must be the caller's own
user:<email>row ("remove my access", never offered forgroup:/*), or one they created — except their ownmint/user:<email>row, which is the claim markermax_claims_per_callercounts from and only an admin can remove. A nonexistent row is 404; an existing one matching no condition is 403.
list_audience_grants()¶
A caller-scoped table function over audience_grants, registered for every
authenticated caller — a SQL auditing surface over grant administration, not a data read, and
unchanged by the data-plane narrowing above. No arguments;
filter with WHERE. Columns: audience, axis, selector, created_at,
created_by.
An admin sees every row. A non-admin sees every grant on each (audience, axis)
pair they hold a matching grant on — deliberately wider than "rows whose
selector matches me": if you may read team-alpha, you may see who else may. A
non-admin with an empty selector set sees nothing; maintenance callers and
--disable-auth requests count as admin.
Unlike GET .../visible, this always applies the held-pair rule, knob or no
knob — it runs in flight-sql-srv, which cannot see analytics-web-srv's
config. See Admin Functions
Reference for the full schema.
Caching and outages¶
A snapshot is served for MICROMEGAS_AUTH_CACHE_TTL_SECONDS (default 60).
Revocation is therefore not instant: a DELETE removes the row at once, but
each flight-sql process keeps granting the removed access until its next
refresh.
Outage behavior differs from the DB-backed key store's. Once a process has
loaded the table successfully, later refresh failures keep serving the last good
snapshot indefinitely. A fresh process whose first query hits a down DB has no
snapshot and fails closed, retrying at most once per TTL window. A sustained
outage surfaces on audience_grant_refresh_error_count, not on the request
path.