Skip to content

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, suspend a tenant or principal, or reject revoked tokens.

Gates run on every authenticated request, in order, after the actor and instance-admin status are resolved; the first rejection wins. 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), you don't need this — use authentication and authorization.

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.

  • Operator-defined body. A check's body is a JSON string of arbitrary shape, parsed and validated once at startup. The only substitutions the gate makes are the request-derived placeholders {{subject}} (the token sub) 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 tokens from the configured idp_id; tokens from any other identity provider are admitted untouched.
  • Cached. Decisions are cached in memory per (subject, check) for cache_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; transient 5xx/timeout results are never cached.
  • Fail closed. Anything other than 2xx/403 becomes a 503 with Retry-After.
  • Token relay is opt-in. The caller's bearer token is forwarded only when auth is forward_caller_token; otherwise the endpoint is reached with the static headers only. A forwarded token goes over the configured URL (use TLS) and is never logged.

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.

[admission_enforce]

Key Required Default Description
endpoint yes Enforce endpoint URL (POST). Validated at startup.
idp_id yes Only govern tokens from this IdP; others are admitted untouched.
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).
auth no none How to authenticate (see below). Omit to send no Authorization.
checks yes Named map of checks (at least one). See below.

[admission_enforce.auth]

Omit to forward no token. Currently one scheme — relay the caller's bearer token:

[admission_enforce.auth]
type = "forward_caller_token"   # sent as `Authorization: Bearer <caller token>`

When set, the request must carry a bearer token; the gate fails closed if it is absent.

[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

[admission_enforce.auth]
type = "forward_caller_token"

# 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__AUTH__TYPE='forward_caller_token'
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.