mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
The role declared sso.enabled false behind three suppressions, so the checks were switched off rather than satisfied. LiteLLM authenticates its own admin UI instead of sitting behind an oauth2 proxy, so the declaration is flavor oidc and the client id, the three Keycloak endpoints, the proxy base URL and the admin id all render together under one flag: a half-set group would let the gateway fall back to its own form with nothing complaining. The variants pin both states so the matrix runs with and without SSO. The proof has to measure the departure, not the arrival: a gateway ignoring the client id answers /sso/key/generate with its own username and password form and never redirects, so the spec asserts the round trip to Keycloak and back. The guest spec became SSO-aware, since its canonical-domain and password-field assertions only hold where no SSO is configured. Two more gateway defects: the key warm-up ran even where no model was served, falling back to openrouter/auto so the gateway then answered every prompt with 400, and it is now gated on a served-model expression mirroring the model selection itself. A lint requires every declared svc-ai backend to have a model_list branch, because a backend without a route is an alias nobody serves. probe_auth.py proves revocation rather than creation: it mints a throwaway key, revokes it, and fails unless a real consumer key survives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
163 lines
5.6 KiB
Python
163 lines
5.6 KiB
Python
"""Prove the gateway is closed, keyed per consumer, and revocable in isolation.
|
|
|
|
A gateway that answers an unauthenticated caller hands every model on the
|
|
deployment to anything that can reach the overlay, and a revocation that takes
|
|
other consumers down with it is a revocation nobody dares to use. Both failures
|
|
are silent: the deploy is green either way and the first symptom is either a
|
|
stranger's bill or an outage.
|
|
|
|
The probe mints its own throwaway key, revokes that one, and shows a real
|
|
consumer key still working afterwards, so it proves isolation without touching
|
|
any credential a consumer depends on.
|
|
|
|
Environment:
|
|
LITELLM_MK: master key the gateway accepts.
|
|
LITELLM_PORT: port the gateway listens on inside its own container.
|
|
LITELLM_KEY: a live consumer virtual key that must survive the revocation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
TIMEOUT = 30
|
|
PROBE_ALIAS = "infinito-auth-probe"
|
|
REVOCATION_ATTEMPTS = 10
|
|
REVOCATION_DELAY = 3
|
|
UNAUTHORIZED = (401, 403)
|
|
|
|
|
|
class ProbeError(RuntimeError):
|
|
"""The gateway answered in a way that fails the authentication contract."""
|
|
|
|
|
|
def status_of(base, key):
|
|
"""Return the status the gateway answers ``GET /v1/models`` with.
|
|
|
|
Args:
|
|
base: scheme and authority of the gateway, without a trailing slash.
|
|
key: bearer token to send, or None to send no ``Authorization`` header.
|
|
"""
|
|
headers = {"Authorization": f"Bearer {key}"} if key is not None else {}
|
|
request = urllib.request.Request( # noqa: S310 fixed internal http origin
|
|
base + "/v1/models", headers=headers, method="GET"
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310 fixed internal http origin
|
|
return response.status
|
|
except urllib.error.HTTPError as error:
|
|
return error.code
|
|
|
|
|
|
def admin_call(base, master_key):
|
|
"""Return a ``call(method, path, body)`` authenticated as the gateway admin.
|
|
|
|
Args:
|
|
base: scheme and authority of the gateway, without a trailing slash.
|
|
master_key: the gateway's master key.
|
|
"""
|
|
|
|
def call(method, path, body=None):
|
|
request = urllib.request.Request( # noqa: S310 fixed internal http origin
|
|
base + path,
|
|
data=(json.dumps(body).encode() if body is not None else None),
|
|
headers={
|
|
"Authorization": f"Bearer {master_key}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
method=method,
|
|
)
|
|
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310 fixed internal http origin
|
|
return json.loads(response.read() or b"{}")
|
|
|
|
return call
|
|
|
|
|
|
def await_rejection(get_status, key, sleep):
|
|
"""Return the status of a revoked key once the gateway stops honouring it.
|
|
|
|
LiteLLM serves keys from an in-process cache, so a deletion the database
|
|
already accepted keeps answering 200 for a moment.
|
|
|
|
Args:
|
|
get_status: ``(key) -> int`` returning the gateway's status for a key.
|
|
key: the revoked key.
|
|
sleep: ``(seconds) -> None`` used between attempts.
|
|
"""
|
|
status = get_status(key)
|
|
for _ in range(REVOCATION_ATTEMPTS):
|
|
if status in UNAUTHORIZED:
|
|
return status
|
|
sleep(REVOCATION_DELAY)
|
|
status = get_status(key)
|
|
return status
|
|
|
|
|
|
def probe(get_status, call, consumer_key, sleep=time.sleep):
|
|
"""Run the whole authentication contract, raising on the first breach.
|
|
|
|
Args:
|
|
get_status: ``(key) -> int`` returning the gateway's status for a key.
|
|
call: ``(method, path, body=None)`` performing one admin request.
|
|
consumer_key: a live consumer key that must survive the revocation.
|
|
sleep: ``(seconds) -> None`` used while waiting for a revocation.
|
|
"""
|
|
anonymous = get_status(None)
|
|
if anonymous not in UNAUTHORIZED:
|
|
raise ProbeError(
|
|
f"the gateway answered an unauthenticated GET /v1/models with "
|
|
f"{anonymous}; it is reachable without a key on the overlay"
|
|
)
|
|
|
|
forged = get_status("sk-" + "0" * 32)
|
|
if forged not in UNAUTHORIZED:
|
|
raise ProbeError(
|
|
f"the gateway answered a forged bearer token with {forged}; it is "
|
|
f"not validating keys"
|
|
)
|
|
|
|
with contextlib.suppress(urllib.error.HTTPError):
|
|
call("POST", "/key/delete", {"key_aliases": [PROBE_ALIAS]})
|
|
minted = call("POST", "/key/generate", {"key_alias": PROBE_ALIAS})["key"]
|
|
accepted = get_status(minted)
|
|
if accepted != 200:
|
|
raise ProbeError(
|
|
f"the gateway answered its own freshly minted virtual key with "
|
|
f"{accepted}; virtual keys do not grant access"
|
|
)
|
|
|
|
call("POST", "/key/delete", {"keys": [minted]})
|
|
revoked = await_rejection(get_status, minted, sleep)
|
|
if revoked not in UNAUTHORIZED:
|
|
raise ProbeError(
|
|
f"the revoked probe key still answers with {revoked} after "
|
|
f"{REVOCATION_ATTEMPTS * REVOCATION_DELAY}s; revocation does not "
|
|
f"take effect"
|
|
)
|
|
|
|
survivor = get_status(consumer_key)
|
|
if survivor != 200:
|
|
raise ProbeError(
|
|
f"revoking the probe key left a consumer key answering {survivor}; "
|
|
f"revocation is not isolated to one consumer"
|
|
)
|
|
|
|
|
|
def main():
|
|
base = "http://127.0.0.1:" + os.environ["LITELLM_PORT"]
|
|
probe(
|
|
lambda key: status_of(base, key),
|
|
admin_call(base, os.environ["LITELLM_MK"]),
|
|
os.environ["LITELLM_KEY"],
|
|
)
|
|
print("AUTHENTICATED")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|