Admission Gates¶
An admission gate makes a coarse allow/deny decision about an already-authenticated request before it reaches any handler — distinct from the per-resource Authorizer. Use one to consult an external control-plane entitlement service, or to suspend a tenant or principal.
Gates run on every authenticated request, in order, before the x-assume-role check and before any handler. The first rejection wins. A gate decides about the user alone: it sees the user's identity provider and subject, never the project, warehouse or token of the request. The roles it grants hold in every project and at server actions, on the user's own requests. A gate returns either a terminal 403 Forbidden or — when it fails closed because an upstream it depends on is unreachable — a 503 with a Retry-After.
The gate seam itself (AdmissionGate) is a Rust trait; see Customize to implement your own. This page documents the external enforce-endpoint gate that ships ready-to-configure with Lakekeeper Plus.
When to use it¶
This gate is for deployments whose IdP issues broad, non-instance-scoped tokens, where a separate service — not the token — is authoritative for whether the caller may use this Lakekeeper instance. After authentication, the gate asks that service, per caller, and either lets the request through or rejects it.
If your tokens already carry the entitlement (claims, roles, audience), rely on authentication and authorization alone.
How it works¶
The gate evaluates one or more named checks against a configured enforce endpoint. Each check carries its own complete request body (any JSON shape), so the gate imposes no schema on the enforce API — you decide what the endpoint sees. Each check is a single POST; the decision is the HTTP status:
| Upstream status | kind = "gating" |
kind = "role_granting" |
|---|---|---|
2xx |
admit + grant role | grant role |
403 (exactly) |
reject request (403) |
withhold role, continue |
| any other status, timeout, network error | 503 + Retry-After (fail closed) |
same — 503 fail closed |
Only an exact 403 is read as an authoritative deny. Every other non-2xx status — including other 4xx (e.g. 400, 401, 404, 429) and any 5xx — is treated as the endpoint being unable to give a verdict, so the gate fails closed with a 503. This is deliberate: a misconfigured or malfunctioning enforce endpoint must never silently admit. If your endpoint signals "denied" with a status other than 403, map it to 403 on its side.
On admit, each passing check contributes its role to the request's admission roles, consumed by authorization downstream. Under Cedar, name an admission role like any group, for example principal.project_roles.contains({provider_id: "control-plane", source_id: "instance-access"}) for the example below.
- Operator-defined body. A check's
bodyis a JSON string of arbitrary shape, parsed and validated once at startup. The only substitutions the gate makes are the user-derived placeholders{{subject}}(the user's subject in its identity provider) and{{idp_id}}inside string values; everything else is sent literally. Invalid JSON or an unknown placeholder is rejected at startup. The gate models no "actions"/"resource" concepts — those are just whatever you write in the body. - IdP-scoped. The gate only governs users of the configured
idp_id. Users of any other identity provider pass through untouched and are reported asskipped, notadmitted, so the requests the gate approved stay distinguishable from the ones it never examined. At startup theidp_idis checked against the providers the server authenticates. An id matching none of them would govern nobody while the gate still looked healthy, so the server refuses to start instead. - Cached. Decisions are cached in memory per
(subject, check)forcache_ttl_secs. Both allow and deny are cached, so a denied-but-authenticated caller triggers at most one upstream call per TTL and cannot amplify load; transient5xx/timeout results are never cached. - Fail closed. Anything other than
2xx/403becomes a503withRetry-After.
Monitoring¶
Every gate evaluation is timed as lakekeeper_admission_gate_duration_seconds{gate, outcome}, each enforce call as lakekeeper_admission_enforce_call_duration_seconds{check, outcome}, and the decision cache reports into the shared lakekeeper_cache_* series under cache_type="admission_enforce". See Monitoring > External Enforce Gate for the queries that matter — rejection rate, fail-closed rate, and the load the gate puts on your enforce endpoint.
Configuration¶
The gate is disabled unless an [admission_enforce] block is present. Like Cedar derivations and role providers, this is nested config, so it is configured via a TOML file with full environment-variable parity — point LAKEKEEPER__ADMISSION_ENFORCE_FILE at a TOML file, and/or set LAKEKEEPER__ADMISSION_ENFORCE__* variables on top.
Unknown keys are refused: a typo in the gate block or in a check fails startup rather than leaving the setting it was meant to be at its default.
[admission_enforce]¶
| Key | Required | Default | Description |
|---|---|---|---|
endpoint |
yes | — | Enforce endpoint URL (POST). Validated at startup. |
idp_id |
yes | — | Only govern users of this IdP; others are admitted untouched. Must name a provider this server authenticates, or startup fails. |
role_provider_id |
yes | — | Provider namespace for the synthesized admission roles. |
cache_ttl_secs |
no | 60 |
TTL for cached allow/deny decisions. |
cache_max_entries |
no | 10000 |
Max cached decisions. |
request_timeout_secs |
no | 5 |
Per-request timeout. |
connect_timeout_secs |
no | 2 |
Connection timeout. |
unavailable_retry_after_secs |
no | 5 |
Retry-After returned on the fail-closed 503. |
headers |
no | {} |
Extra static headers sent on every call (e.g. a service API key). |
checks |
yes | — | Named map of checks (at least one). See below. |
[admission_enforce.checks.<name>]¶
Each check is keyed by a name you choose (used in the cache key and logs). Use lowercase letters, digits, and underscores, so the name round-trips through …__CHECKS__<NAME>__… environment variables.
| Key | Required | Default | Description |
|---|---|---|---|
kind |
yes | — | gating (a 403 rejects the request) or role_granting (403 withholds the role). |
body |
yes | — | This check's complete request body as a JSON string (any shape; parsed at startup). {{subject}}/{{idp_id}} are substituted; everything else is literal. |
role_source_id |
yes | — | Source id of the admission role granted when the check passes. |
role_provider_id |
no | gate-level value | Override the role provider namespace for this check. |
Example¶
[admission_enforce]
endpoint = "https://control-plane.internal/v1/authorize"
idp_id = "oidc"
role_provider_id = "control-plane"
cache_ttl_secs = 60
# A `403` here rejects the request outright.
[admission_enforce.checks.instance_access]
kind = "gating"
role_source_id = "instance-access"
body = '''
{ "subject": "{{subject}}",
"resource": "102befc3-424d-479e-b1f7-bb47c1e1a1a2",
"resourceType": "project",
"actions": ["workflows.instance.read"] }
'''
# A `403` here only withholds the role; the request still proceeds.
[admission_enforce.checks.workflow_editor]
kind = "role_granting"
role_source_id = "workflow-editor"
body = '''
{ "subject": "{{subject}}",
"resource": "102befc3-424d-479e-b1f7-bb47c1e1a1a2",
"resourceType": "project",
"actions": ["workflows.instance.update", "workflows.instance.delete"] }
'''
The body shape is entirely yours — a different enforce API (e.g. OPA-style {"input": {...}}) is just a different body, with no code change:
[admission_enforce.checks.can_read]
kind = "gating"
role_source_id = "reader"
body = '''{ "input": { "user": "{{subject}}", "tenant": "acme", "verb": "read" } }'''
Configuring via environment variables¶
Because the body is a single JSON string, the whole config — including bodies with arrays and mixed-case keys — round-trips through environment variables with full parity. The example above is equivalent to:
LAKEKEEPER__ADMISSION_ENFORCE__ENDPOINT='https://control-plane.internal/v1/authorize'
LAKEKEEPER__ADMISSION_ENFORCE__IDP_ID='oidc'
LAKEKEEPER__ADMISSION_ENFORCE__ROLE_PROVIDER_ID='control-plane'
LAKEKEEPER__ADMISSION_ENFORCE__CHECKS__INSTANCE_ACCESS__KIND='gating'
LAKEKEEPER__ADMISSION_ENFORCE__CHECKS__INSTANCE_ACCESS__ROLE_SOURCE_ID='instance-access'
LAKEKEEPER__ADMISSION_ENFORCE__CHECKS__INSTANCE_ACCESS__BODY='{"subject":"{{subject}}","resource":"102befc3-424d-479e-b1f7-bb47c1e1a1a2","resourceType":"project","actions":["workflows.instance.read"]}'
Body is one variable
The entire body is a single JSON string, so set it as one …__BODY variable rather than per-field keys. This keeps arrays and mixed-case keys (e.g. resourceType) intact — a __-split native map cannot express either.
As with role providers, the two approaches combine: load non-sensitive config from the file and inject secrets (e.g. a service API key in headers) via env vars on top.