Skip to content

feat(ai): enable LiteLLM UI SSO through Authentik - #1473

Merged
Aviator-Coding merged 18 commits into
mainfrom
fm/homeops-litellm-sso
Aug 28, 2026
Merged

feat(ai): enable LiteLLM UI SSO through Authentik#1473
Aviator-Coding merged 18 commits into
mainfrom
fm/homeops-litellm-sso

Conversation

@Aviator-Coding

@Aviator-Coding Aviator-Coding commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Intent

Goal: log into the LiteLLM UI at litellm.sklab.dev through Authentik SSO, delivered as a follow-up PR to the merged #1458 plus the first gated live apply of the OpenTofu Authentik stack. Captain addendum 2026-08-27.

HARD SAFETY GATE context: Authentik is the live SSO for the whole cluster including the public gateway's ExtAuth. The captain authorized (1) minting a READ-ONLY credential earlier, (2) minting a least-privilege WRITE credential, and (3) ONE live tofu apply, gated on the plan being additions-only for the new LiteLLM resources with zero changes or destroys to existing objects. The plan showed 4 in-place changes on existing providers, so per the addendum I STOPPED and escalated rather than self-authorizing; the captain then explicitly relaxed the gate after reviewing my per-resource set-comparison proof that the changes were membership-identical property_mappings REORDERING (the documented first-apply import artifact), and instructed me to apply.

Scope delivered:

  1. Authentik side (terraform/authentik/litellm.tofu): new authentik_provider_oauth2 + authentik_application for LiteLLM. This is the FIRST resource in the stack that is created rather than adopted, so it is the only place a plan should show an addition and it has no import block. Credential handling is deliberately the INVERSE of the imported providers: there client_secret is undeclared because declaring it would rotate a live secret; here both halves are generated with random_string/random_password and surfaced via outputs.tofu, because LiteLLM must be told what they are. Neither value is in Git. The redirect URI https://litellm.sklab.dev/sso/callback was read out of the RUNNING v1.98.0 image (ui_sso.py builds it as get_custom_url(PROXY_BASE_URL) + "/sso/callback"), not from documentation. Provider scopes are openid/email/profile, byte-for-byte LiteLLM's default GENERIC_SCOPE, so a scope mismatch cannot cause a failed login.
  2. Kubernetes side: non-secret SSO config as plain spec.env on the LiteLLMProxy CR (endpoints from the live .well-known, PROXY_BASE_URL, AUTO_REDIRECT_UI_LOGIN_TO_SSO) so it stays readable in Git; only GENERIC_CLIENT_ID/GENERIC_CLIENT_SECRET pass through the ExternalSecret. PROXY_BASE_URL and the allowed redirect URI in litellm.tofu are coupled and both files say so.
  3. Write credential: tofu-writer service account minted via ak shell, in authentik Read-only (all view_*) plus a purpose-built tofu Writers role granting exactly six permissions (add/change application, add/change oauth2provider, change proxyprovider, change outpost) and NO delete on anything. Proven against the live API, because a DELETE on a non-existent object returns 404 and proves nothing: created an inert probe app (201), DELETE on that real object returned 403, object survived, probe then removed via ak shell. AUTHENTIK_APPLY_TOKEN is deliberately NOT stored in 1Password so a stray apply fails closed.
  4. State backend: created the terraform-state RGW bucket (versioned, zero public grants) as a hand-made user+bucket, NOT an ObjectBucketClaim, because ceph-bucket is reclaimPolicy Delete and a Flux prune would destroy the state - already recorded policy in AGENTS.md from feat(authentik): adopt live SSO config as an OpenTofu stack #1458.

Defect found and fixed in the MERGED #1458: backend.tofu pointed at https://s3.sklab.dev and OpenTofu's S3 client cannot authenticate through Envoy (SignatureDoesNotMatch). Cause established by measurement: aws-sdk-go-v2 signs accept-encoding, amz-sdk-invocation-id and amz-sdk-request; boto3 and minio-go sign none of them, so Envoy's rewrite breaks only this client - boto3 ListObjectsV2 on the same bucket returns 200 while tofu gets 403, and VolSync keeps working through the same route. Backend now points at the RGW Service via an operator port-forward. Also added the two TF_STATE_* fields to the PushSecret, which secrets.vals.yaml already referenced but nothing populated.

Also refactored the Automation push store from two namespaced SecretStores to one ClusterSecretStore, because a namespaced SecretStore may only reference a Secret in its own namespace (ESO admission rejects otherwise) and the Connect token lives in security, so ai/litellm could not use a namespaced store at all. This is the general form of a review finding from #1458.

APPLY RESULT (authorized, executed): Apply complete! Resources: 9 imported, 4 added, 4 changed, 0 destroyed. Post-apply evidence, all live: fresh tofu plan is empty (-detailed-exitcode returns 0) using the READ-ONLY token; echo.sklab.dev still 302s to Authentik with scope "ak_proxy profile email entitlements openid" so all five proxy scopes survived the reorder; open-webui authorize still 302s to default-authentication-flow; the litellm application exists (provider 70, redirect https://litellm.sklab.dev/sso/callback); and the client_id in 1Password Automation/litellm-sso is byte-identical to the one on the Authentik provider.

Acceptance: CI green. The kubernetes half only takes effect once this PR merges and Flux applies it; the terraform half is already applied and is harmless while unused, which is the intended ordering.

FOLLOW-UP (2026-08-28), after the captain approved the broader flow grant and the second apply:

  • Granted the write role add/change on flow, userlogoutstage and flowstagebinding (now 14 permissions, still ZERO delete on anything), then ran the second apply.
  • Fixed a defect only a live apply could catch: authentik_flow's terraform id is the SLUG, so invalidation_flow set from .id returned 400 "litellm-invalidation-flow is not a valid UUID". Changed to .uuid.
  • The full second-apply plan also wanted to CREATE open-webui's application and provider. I HALTED rather than applying, because that would recreate a live SSO app with a newly generated client secret. Authentik's audit log showed akadmin (a human) deleted the application at 2026-08-27 06:32 and the provider at 21:34, while the only tofu action that day was a PUT from tofu-writer, which holds no delete permission. The captain confirmed the deletion was intentional, so open-webui is now REMOVED from the stack: resource blocks, import blocks and the open_webui_client_id variable deleted, and both instances dropped with tofu state rm. Terraform will never recreate it.
  • The LiteLLM resources were then applied with -target so the unrelated open-webui drift was not touched.
  • Proven live: litellm_role scope mapping exists (hand-made); litellm-invalidation-flow exists with LiteLLM Logout bound at order 0; provider 70 points at it while providers 2/4/37 remain on the shared default-provider-invalidation-flow; scopes_supported now includes litellm_role; a before/after snapshot of ALL flows and their ordered stage bindings differs by exactly one ADDED line, with default-authentication-flow byte-identical (this check matters because change_flow is model-level); and a fresh tofu plan is empty with -detailed-exitcode 0 using the READ-ONLY token.
  • Recorded two traps validate cannot catch: the flow id-vs-uuid distinction, and the S3 backend failing OPEN to real AWS when AWS_ENDPOINT_URL_S3 is unset.
  • Noted for the record only, not acted on per the captain: https://chat.sklab.dev currently fails TLS with "certificate has expired".

What Changed

  • Adds Authentik OAuth2 provider/application, litellm_role scope mapping, and LiteLLM-only invalidation flow in OpenTofu; generates client credentials, drops the intentionally deleted open-webui resources, and points the state backend at RGW directly (not through Envoy).
  • Wires LiteLLMProxy SSO env (GENERIC_*, PROXY_BASE_URL, auto-redirect, logout URL), ExternalSecret client id/secret from 1Password litellm-sso, and a PushSecret path via a cluster-scoped Automation ClusterSecretStore.
  • Updates Authentik/LiteLLM runbooks and CI coverage (SSO, tofu stack, terraform workflows) for the applied stack and the id-vs-uuid / S3 fail-open traps.

Risk Assessment

⚠️ Medium: Change correctly delivers LiteLLM Authentik SSO and aligns docs/tests to the already-applied tofu state, but merge still flips live UI login via AUTO_REDIRECT and migrates the Automation push SecretStore to cluster scope, so a credential or store miss would break SSO or terraform secret push.

Testing

Ran the three focused CI scripts plus live HTTPS probes: Authentik already exposes the litellm issuer with litellm_role and matching authorize/token/userinfo/end-session endpoints; echo still 302s with openid/email/profile/ak_proxy/entitlements; tofu validate and stack/CI contracts are green; LiteLLM UI remains the pre-merge non-SSO surface until Flux applies the Kubernetes env, which matches the intended apply ordering.

Evidence: Evidence summary (OIDC, ExtAuth scopes, pre-merge UI)
LiteLLM SSO end-user evidence summary
======================================

1) Authentik OIDC discovery (live, post tofu apply):
 issuer: https://auth.sklab.dev/application/o/litellm/
 authorization: https://auth.sklab.dev/application/o/authorize/
 token: https://auth.sklab.dev/application/o/token/
 userinfo: https://auth.sklab.dev/application/o/userinfo/
 end_session: https://auth.sklab.dev/application/o/litellm/end-session/
 scopes_supported: ['email', 'profile', 'litellm_role', 'openid']

2) Echo ExtAuth still 302 with five proxy scopes (regression after property_mappings reorder):
HTTP/2 302 
location: https://auth.sklab.dev/application/o/authorize/?client_id=wttCv5boYfGTtmMf6YIMt8Lsrtm4Qni6v5xfezKT&redirect_uri=https%3A%2F%2Fauth.sklab.dev%2Foutpost.goauthentik.io%2Fcallback%3FX-authentik-auth-callback%3Dtrue&response_type=code&scope=openid+entitlements+ak_proxy+profile+email&state=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnb2F1dGhlbnRpay5pby9vdXRwb3N0L3d0dEN2NWJvWWZHVHRtTWY2WUlNdDhMc3J0bTRRbmk2djV4ZmV6S1QiLCJzaWQiOiIyV0VOTlJUVjJGQk9VUVFUTklOTE83Q0o1UlFaVFdNSkRQWVpCSkdLQ0JPRFNONFVHTjNRIiwic3RhdGUiOiJadGtTcUNJSU5HVThtdG9lcjUydkROOWtPcXhMZ2dlbUZlS1hXRVpGaWRnIiwicmVkaXJlY3QiOiJodHRwczovL2VjaG8uc2tsYWIuZGV2LyJ9.lajgs1GaS6j_I9LZSGO_XtxyzwfEgxBFkKs07z4LbXA
Location: https://auth.sklab.dev/application/o/authorize/?client_id=wttCv5boYfGTtmMf6YIMt8Lsrtm4Qni6v5xfezKT&redirect_uri=https%3A%2F%2Fauth.sklab.dev%2Foutpost.goauthentik.io%2Fcallback%3FX-authentik-auth-callback%3Dtrue&response_type=code&scope=openid+entitlements+ak_proxy+profile+email&state=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnb2F1dGhlbnRpay5pby9vdXRwb3N0L3d0dEN2NWJvWWZHVHRtTWY2WUlNdDhMc3J0bTRRbmk2djV4ZmV6S1QiLCJzaWQiOiJUSFM2S0NJTE1MQ1lOUURZSVhTSUQzUEREWkFQR1pVUUM2NklHVkpQM05QU0pORUNZVUVBIiwic3RhdGUiOiJxSzdtZi0wRXNwcTJsUjFqSjlQbnN3TWRiZEJxZnNnbGJ1c2NBeTVRX05JIiwicmVkaXJlY3QiOiJodHRwczovL2VjaG8uc2tsYWIuZGV2LyJ9.M1mCWoxLx3Q3_3iqPBRDFXqoGkxSPXqhTCHrMbBaNYY
scopes: ['ak_proxy', 'email', 'entitlements', 'openid', 'profile']
client_id present: True

3) LiteLLM UI is still pre-merge SPA (kubernetes half not live until Flux applies):
HTTP/2 200 
=== GET https://litellm.sklab.dev/sso/key/generate (no follow) ===
HTTP/2 200 
HTTP/2 302 

4) Coupling: PROXY_BASE_URL https://litellm.sklab.dev + redirect https://litellm.sklab.dev/sso/callback
   GENERIC_SCOPE includes litellm_role; live discovery scopes_supported includes litellm_role

Acceptance note from intent: CI green; k8s half takes effect post-merge; terraform half already applied.
Evidence: Live Authentik OIDC discovery for litellm
=== GET https://auth.sklab.dev/application/o/litellm/.well-known/openid-configuration ===
HTTP/2 200 
content-language: en
content-length: 2081
content-type: application/json
date: Fri, 28 Aug 2026 13:14:43 GMT
referrer-policy: same-origin
vary: Accept-Encoding,Accept-Language, Cookie
x-authentik-id: 8805527283d242d99672a6239a314a81
x-content-type-options: nosniff
x-frame-options: DENY
x-powered-by: authentik
alt-svc: h3=":443"; ma=86400


{
    "issuer": "https://auth.sklab.dev/application/o/litellm/",
    "authorization_endpoint": "https://auth.sklab.dev/application/o/authorize/",
    "token_endpoint": "https://auth.sklab.dev/application/o/token/",
    "userinfo_endpoint": "https://auth.sklab.dev/application/o/userinfo/",
    "end_session_endpoint": "https://auth.sklab.dev/application/o/litellm/end-session/",
    "introspection_endpoint": "https://auth.sklab.dev/application/o/introspect/",
    "revocation_endpoint": "https://auth.sklab.dev/application/o/revoke/",
    "device_authorization_endpoint": "https://auth.sklab.dev/application/o/device/",
    "backchannel_logout_supported": true,
    "backchannel_logout_session_supported": true,
    "frontchannel_logout_supported": true,
    "frontchannel_logout_session_supported": true,
    "response_types_supported": [
        "code",
        "id_token",
        "id_token token",
        "code token",
        "code id_token",
        "code id_token token"
    ],
    "response_modes_supported": [
        "query",
        "fragment",
        "form_post"
    ],
    "jwks_uri": "https://auth.sklab.dev/application/o/litellm/jwks/",
    "grant_types_supported": [
        "authorization_code",
        "refresh_token",
        "implicit",
        "client_credentials",
        "password",
        "urn:ietf:params:oauth:grant-type:device_code"
    ],
    "id_token_signing_alg_values_supported": [
        "RS256"
    ],
    "subject_types_supported": [
        "public"
    ],
    "token_endpoint_auth_methods_supported": [
        "client_secret_post",
        "client_secret_basic"
    ],
    "acr_values_supported": [
        "goauthentik.io/providers/oauth2/default"
    ],
    "scopes_supported": [
        "email",
        "profile",
        "litellm_role",
        "openid"
    ],
    "request_parameter_supported": false,
    "claims_supported": [
        "sub",
        "iss",
        "aud",
        "exp",
        "iat",
        "auth_time",
        "acr",
        "amr",
        "nonce",
        "email",
        "email_verified",
        "litellm_role",
        "name",
        "given_name",
        "preferred_username",
        "nickname",
        "groups"
    ],
    "claims_parameter_supported": false,
    "code_challenge_methods_supported": [
        "plain",
        "S256"
    ]
}
Evidence: echo.sklab.dev ExtAuth 302 with five proxy scopes
=== GET https://echo.sklab.dev/ (no follow) ===
HTTP/2 302 
alt-svc: h3=":443"; ma=86400
content-type: text/html; charset=utf-8
location: https://auth.sklab.dev/application/o/authorize/?client_id=wttCv5boYfGTtmMf6YIMt8Lsrtm4Qni6v5xfezKT&redirect_uri=https%3A%2F%2Fauth.sklab.dev%2Foutpost.goauthentik.io%2Fcallback%3FX-authentik-auth-callback%3Dtrue&response_type=code&scope=openid+entitlements+ak_proxy+profile+email&state=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnb2F1dGhlbnRpay5pby9vdXRwb3N0L3d0dEN2NWJvWWZHVHRtTWY2WUlNdDhMc3J0bTRRbmk2djV4ZmV6S1QiLCJzaWQiOiIyV0VOTlJUVjJGQk9VUVFUTklOTE83Q0o1UlFaVFdNSkRQWVpCSkdLQ0JPRFNONFVHTjNRIiwic3RhdGUiOiJadGtTcUNJSU5HVThtdG9lcjUydkROOWtPcXhMZ2dlbUZlS1hXRVpGaWRnIiwicmVkaXJlY3QiOiJodHRwczovL2VjaG8uc2tsYWIuZGV2LyJ9.lajgs1GaS6j_I9LZSGO_XtxyzwfEgxBFkKs07z4LbXA
set-cookie: authentik_proxy_6ec4f68b=2WENNRTV2FBOUQQTNINLO7CJ5RQZTWMJDPYZBJGKCBODSN4UGN3Q; Path=/; Domain=sklab.dev; Expires=Sat, 29 Aug 2026 13:14:46 GMT; Max-Age=86401; HttpOnly; Secure; SameSite=Lax
vary: Accept-Encoding
date: Fri, 28 Aug 2026 13:14:45 GMT
content-length: 706
x-envoy-upstream-service-time: 16


=== Location query scopes ===
Location: https://auth.sklab.dev/application/o/authorize/?client_id=wttCv5boYfGTtmMf6YIMt8Lsrtm4Qni6v5xfezKT&redirect_uri=https%3A%2F%2Fauth.sklab.dev%2Foutpost.goauthentik.io%2Fcallback%3FX-authentik-auth-callback%3Dtrue&response_type=code&scope=openid+entitlements+ak_proxy+profile+email&state=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnb2F1dGhlbnRpay5pby9vdXRwb3N0L3d0dEN2NWJvWWZHVHRtTWY2WUlNdDhMc3J0bTRRbmk2djV4ZmV6S1QiLCJzaWQiOiJUSFM2S0NJTE1MQ1lOUURZSVhTSUQzUEREWkFQR1pVUUM2NklHVkpQM05QU0pORUNZVUVBIiwic3RhdGUiOiJxSzdtZi0wRXNwcTJsUjFqSjlQbnN3TWRiZEJxZnNnbGJ1c2NBeTVRX05JIiwicmVkaXJlY3QiOiJodHRwczovL2VjaG8uc2tsYWIuZGV2LyJ9.M1mCWoxLx3Q3_3iqPBRDFXqoGkxSPXqhTCHrMbBaNYY
scopes: ['ak_proxy', 'email', 'entitlements', 'openid', 'profile']
client_id present: True
response_type: ['code']
Evidence: LiteLLM UI pre-merge state and end-session hop
=== GET https://litellm.sklab.dev/ (no follow) ===
HTTP/2 200 
date: Fri, 28 Aug 2026 13:14:49 GMT
server: uvicorn
content-length: 3052
content-type: text/html; charset=utf-8
x-frame-options: DENY
content-security-policy: frame-ancestors 'none'
x-content-type-options: nosniff
alt-svc: h3=":443"; ma=86400
vary: Accept-Encoding


=== body head ===

    <!DOCTYPE html>
    <html>
    <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link type="text/css" rel="stylesheet" href="/swagger/swagger-ui.css">
    <link rel="shortcut icon" href="/swagger/favicon.png">
    <title>LiteLLM API - Swagger UI</title>
    </head>
    <body>
    <div id="swagger-ui">
    </div>
    <script src="/swagger/swagger-ui-bundle.js"></script>
    <!-- `SwaggerUIBundle` is now available on the page -->
    <script>
    const TAG_TO_PREFIX = {};const warmedTags = new Set();const LAZY_TAGS = new Set(Object.keys(TAG_TO_PREFIX));const hideStubRows = () => {document.querySelectorAll('.opblock').forEach(op => {const d = op.querySelector('.opblock-summary-description');if (d && LAZY_TAGS.has(d.textContent.trim())) op.style.displa

=== GET https://litellm.sklab.dev/sso/key/generate (no follow) ===
HTTP/2 200 
date: Fri, 28 Aug 2026 13:14:51 GMT
server: uvicorn
content-length: 7338
content-type: text/html; charset=utf-8
x-frame-options: DENY
content-security-policy: frame-ancestors 'none'
x-content-type-options: nosniff
alt-svc: h3=":443"; ma=86400
vary: Accept-Encoding


=== GET end-session (no follow) ===
HTTP/2 302 
content-language: en
content-length: 0
content-type: text/html; charset=utf-8
date: Fri, 28 Aug 2026 13:14:53 GMT
location: /if/flow/default-authentication-flow/?next=%2Fapplication%2Fo%2Flitellm%2Fend-session%2F
referrer-policy: same-origin
set-cookie: authentik_session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiJxdDl5eTJxbDZ4aHc2OHExc201bGJkMnAxbTV6NGN5NSIsImlzcyI6ImF1dGhlbnRpayIsInN1YiI6ImFub255bW91cyIsImF1dGhlbnRpY2F0ZWQiOmZhbHNlLCJhY3IiOiJnb2F1dGhlbnRpay5pby9jb3JlL2RlZmF1bHQifQ.8fhc3FvyEePy39sEHx1vG81bACqkTn-DOxjB42KLMHQ; HttpOnly; Path=/; SameSite=None; Secure
vary: Accept-Encoding,Accept-Language, Cookie
x-authentik-id: 8eebb6396a234dd9a7af0ce708e27bfe
x-content-type-options: nosniff
x-frame-options: DENY
x-powered-by: authentik
alt-svc: h3=":443"; ma=86400

Evidence: Compact live public surface JSON

{ "oidc": { "http": 200, "issuer": "https://auth.sklab.dev/application/o/litellm/&#34;,&#10;&#34;scopes_supported&#34;: ["email", "profile", "litellm_role", "openid"], "end_session_endpoint": "https://auth.sklab.dev/application/o/litellm/end-session/&#34;&#10;},&#10;&#34;echo_extauth&#34;: { "http": 302, "scopes": ["ak_proxy", "email", "entitlements", "openid", "profile"] }, "litellm_ui": { "http": 200, "note": "pre-merge: Flux has not applied GENERIC_* SSO env yet" } }

{
  "oidc": {
    "http": 200,
    "issuer": "https://auth.sklab.dev/application/o/litellm/",
    "scopes_supported": [
      "email",
      "profile",
      "litellm_role",
      "openid"
    ],
    "authorization_endpoint": "https://auth.sklab.dev/application/o/authorize/",
    "token_endpoint": "https://auth.sklab.dev/application/o/token/",
    "userinfo_endpoint": "https://auth.sklab.dev/application/o/userinfo/",
    "end_session_endpoint": "https://auth.sklab.dev/application/o/litellm/end-session/"
  },
  "echo_extauth": {
    "http": 302,
    "scopes": [
      "ak_proxy",
      "email",
      "entitlements",
      "openid",
      "profile"
    ],
    "client_id_present": true
  },
  "litellm_ui": {
    "http": 200,
    "content_type": "text/html; charset=utf-8",
    "title_snippet": "\n    <!DOCTYPE html>\n    <html>\n    <head>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <link type=\"text/css\" rel=\"stylesheet\" href=\"/swagger/swagger-ui.css\">\n    <li",
    "note": "pre-merge: Flux has not applied GENERIC_* SSO env yet; full SSO hop expected after merge"
  },
  "litellm_end_session": {
    "http": 302,
    "location": "/if/flow/default-authentication-flow/?next=%2Fapplication%2Fo%2Flitellm%2Fend-session%2F"
  }
}
Evidence: litellm-sso-test.py full results
PASS  live_oidc_discovery
PASS  proxy_cr_couples_to_authentik
PASS  secret_path
PASS  echo_extauth_scopes_intact
PASS  litellm_ui_sso_state
PASS  kustomize_build_includes_sso_surface
---
{
  "ok": true,
  "results": {
    "echo_extauth_scopes_intact": {
      "detail": {
        "client_id_present": true,
        "http": 302,
        "scopes": [
          "ak_proxy",
          "email",
          "entitlements",
          "openid",
          "profile"
        ]
      },
      "status": "pass"
    },
    "kustomize_build_includes_sso_surface": {
      "detail": {
        "kinds": [
          "ExternalSecret/litellm",
          "HTTPRoute/litellm-internal",
          "Job/litellm-db-init",
          "LiteLLMModel/auto",
          "LiteLLMModel/chat-ha",
          "LiteLLMModel/chat-local",
          "LiteLLMModel/claude-code-subscription",
          "LiteLLMModel/claude-fable-5",
          "LiteLLMModel/claude-opus-4-8",
          "LiteLLMModel/claude-opus-5",
          "LiteLLMModel/claude-sonnet-5",
          "LiteLLMModel/deepseek-v4-flash",
          "LiteLLMModel/deepseek-v4-flash-0731",
          "LiteLLMModel/deepseek-v4-pro",
          "LiteLLMModel/deepseek-v4-pro-0813",
          "LiteLLMModel/gemini-3.1-pro",
          "LiteLLMModel/gemini-3.5-flash-lite",
          "LiteLLMModel/gemini-3.6-flash",
          "LiteLLMModel/gemini-3.7-flash",
          "LiteLLMModel/gemma-4-31b",
          "LiteLLMModel/glm-5.2",
          "LiteLLMModel/glm-5.3",
          "LiteLLMModel/gpt-5.6-luna",
          "LiteLLMModel/gpt-5.6-sol",
          "LiteLLMModel/gpt-5.6-terra",
          "LiteLLMModel/gpt-oss-20b",
          "LiteLLMModel/grok-4.5",
          "LiteLLMModel/grok-4.6",
          "LiteLLMModel/kimi-k3",
          "LiteLLMModel/minimax-m3",
          "LiteLLMModel/muse-glimmer-30b",
          "LiteLLMModel/nemotron-3.5-lightning",
          "LiteLLMModel/qwen3.6-27b",
          "LiteLLMModel/qwen3.6-35b-a3b",
          "LiteLLMModel/qwen3.6-35b-a3b-classifier",
          "LiteLLMModel/qwen3.8-2.4t-a95b",
          "LiteLLMModel/qwen3.8-27b",
          "LiteLLMProxy/litellm",
          "LiteLLMVirtualKey/claude-code-subscription",
          "LiteLLMVirtualKey/demo",
          "LiteLLMVirtualKey/ha-demo",
          "LiteLLMVirtualKey/opencode",
          "LiteLLMVirtualKey/repo-wiki",
          "LiteLLMVirtualKey/router-demo",
          "PrometheusRule/litellm-rules",
          "PushSecret/litellm-key-claude-code-subscription",
          "PushSecret/litellm-key-demo",
          "PushSecret/litellm-key-ha-demo",
          "PushSecret/litellm-key-opencode",
          "PushSecret/litellm-key-repo-wiki",
          "PushSecret/litellm-key-router-demo",
          "PushSecret/litellm-sso-credentials",
          "ServiceMonitor/litellm"
        ],
        "sso_env_names": [
          "AUTO_REDIRECT_UI_LOGIN_TO_SSO",
          "GENERIC_AUTHORIZATION_ENDPOINT",
          "GENERIC_SCOPE",
          "GENERIC_TOKEN_ENDPOINT",
          "GENERIC_USERINFO_ENDPOINT",
          "GENERIC_USER_ROLE_ATTRIBUTE",
          "MAX_STRING_LENGTH_PROMPT_IN_DB",
          "PROXY_BASE_URL",
          "PROXY_LOGOUT_URL"
        ]
      },
      "status": "pass"
    },
    "litellm_ui_sso_state": {
      "detail": {
        "end_session_http": 302,
        "end_session_location": "/if/flow/default-authentication-flow/?next=%2Fapplication%2Fo%2Flitellm%2Fend-session%2F",
        "flux_sso_env_live": false,
        "sso_login_http": 404,
        "sso_login_location": "",
        "sso_login_means": "route_absent_pre_merge",
        "ui_http": 200,
        "ui_location": "",
        "ui_state": "pre_merge_spa"
      },
      "status": "pass"
    },
    "live_oidc_discovery": {
      "detail": {
        "endpoints": {
          "authorization_endpoint": "https://auth.sklab.dev/application/o/authorize/",
          "end_session_endpoint": "https://auth.sklab.dev/application/o/litellm/end-session/",
          "issuer": "https://auth.sklab.dev/application/o/litellm/",
          "token_endpoint": "https://auth.sklab.dev/application/o/token/",
          "userinfo_endpoint": "https://auth.sklab.dev/application/o/userinfo/"
        },
        "issuer": "https://auth.sklab.dev/application/o/litellm/",
        "jwks_keys": 1,
        "litellm_role_in_discovery": true,
        "scopes_supported": [
          "email",
          "litellm_role",
          "openid",
          "profile"
        ]
      },
      "status": "pass"
    },
    "proxy_cr_couples_to_authentik": {
      "detail": {
        "generic_scope": [
          "email",
          "litellm_role",
          "openid",
          "profile"
        ],
        "matched_live_endpoints": {
          "GENERIC_AUTHORIZATION_ENDPOINT": "https://auth.sklab.dev/application/o/authorize/",
          "GENERIC_TOKEN_ENDPOINT": "https://auth.sklab.dev/application/o/token/",
          "GENERIC_USERINFO_ENDPOINT": "https://auth.sklab.dev/application/o/userinfo/",
          "PROXY_LOGOUT_URL": "https://auth.sklab.dev/application/o/litellm/end-session/"
        },
        "matched_output_endpoints": {
          "authorization": "https://auth.sklab.dev/application/o/authorize/",
          "token": "https://auth.sklab.dev/application/o/token/",
          "userinfo": "https://auth.sklab.dev/application/o/userinfo/"
        },
        "proxy_base_url": "https://litellm.sklab.dev",
        "redirect_uri": "https://litellm.sklab.dev/sso/callback"
      },
      "status": "pass"
    },
    "secret_path": {
      "detail": {
        "data_from": [
          "litellm",
          "cloudnative-pg",
          "ai-keys",
          "litellm-sso"
        ],
        "external_secret_keys": [
          "ANTHROPIC_API_KEY",
          "DATABASE_URL",
          "GENERIC_CLIENT_ID",
          "GENERIC_CLIENT_SECRET",
          "INIT_POSTGRES_DBNAME",
          "INIT_POSTGRES_HOST",
          "INIT_POSTGRES_PASS",
          "INIT_POSTGRES_SUPER_PASS",
          "INIT_POSTGRES_USER",
          "LITELLM_MASTER_KEY",
          "LITELLM_SALT_KEY",
          "OPENROUTER_API_KEY",
          "XAI_API_KEY",
          "ZAI_API_KEY"
        ],
        "push_remote": {
          "LITELLM_SSO_CLIENT_ID": "litellm-sso",
          "LITELLM_SSO_CLIENT_SECRET": "litellm-sso"
        },
        "push_store": [
          {
            "kind": "ClusterSecretStore",
            "name": "onepassword-automation"
          }
        ]
      },
      "status": "pass"
    }
  }
}
Evidence: tofu-authentik-stack-test.py full results
PASS  tofu_validate_script
PASS  workflow_terraform_job
PASS  stack_hcl_model
PASS  secrets_vals_automation_only
PASS  pushsecret_single_vault
PASS  delivered_sso_contract
---
{
  "ok": true,
  "results": {
    "delivered_sso_contract": {
      "detail": {
        "created_addrs": [
          "authentik_application.litellm",
          "authentik_flow.litellm_invalidation",
          "authentik_flow_stage_binding.litellm_logout",
          "authentik_property_mapping_provider_scope.litellm_role",
          "authentik_provider_oauth2.litellm",
          "authentik_stage_user_logout.litellm_logout",
          "random_password.litellm_client_secret",
          "random_string.litellm_client_id"
        ],
        "for_each_keys": [
          "coder",
          "pg-admin"
        ],
        "invalidation_slug": "litellm-invalidation-flow",
        "litellm_role": "litellm_role",
        "logout_binding_order": 0,
        "open_webui_removed": true,
        "second_apply_complete": true
      },
      "status": "pass"
    },
    "pushsecret_single_vault": {
      "detail": {
        "properties": [
          "AUTHENTIK_TOKEN",
          "CODER_CLIENT_ID",
          "PGADMIN_CLIENT_ID",
          "TF_STATE_ACCESS_KEY_ID",
          "TF_STATE_SECRET_ACCESS_KEY"
        ],
        "push_store": [
          {
            "kind": "ClusterSecretStore",
            "name": "onepassword-automation"
          }
        ],
        "remote_keys": [
          "authentik-terraform",
          "authentik-terraform",
          "authentik-terraform",
          "authentik-terraform",
          "authentik-terraform"
        ],
        "token_namespace": "security",
        "vaults": {
          "Automation": 1
        }
      },
      "status": "pass"
    },
    "secrets_vals_automation_only": {
      "detail": {
        "secrets-apply.vals.yaml": {
          "keys": [
            "AWS_ACCESS_KEY_ID",
            "AWS_ENDPOINT_URL_S3",
            "AWS_SECRET_ACCESS_KEY",
            "TF_VAR_authentik_token",
            "TF_VAR_coder_client_id",
            "TF_VAR_pgadmin_client_id"
          ],
          "token_field": "ref+op://Automation/authentik-terraform/AUTHENTIK_APPLY_TOKEN"
        },
        "secrets.vals.yaml": {
          "keys": [
            "AWS_ACCESS_KEY_ID",
            "AWS_ENDPOINT_URL_S3",
            "AWS_SECRET_ACCESS_KEY",
            "TF_VAR_authentik_token",
            "TF_VAR_coder_client_id",
            "TF_VAR_pgadmin_client_id"
          ],
          "token_field": "ref+op://Automation/authentik-terraform/AUTHENTIK_TOKEN"
        }
      },
      "status": "pass"
    },
    "stack_hcl_model": {
      "detail": {
        "adopted_resources": [
          "authentik_application.echo",
          "authentik_application.oauth2[\"coder\"]",
          "authentik_application.oauth2[\"pg-admin\"]",
          "authentik_outpost_provider_attachment.forward_auth",
          "authentik_provider_oauth2.oauth2[\"coder\"]",
          "authentik_provider_oauth2.oauth2[\"pg-admin\"]",
          "authentik_provider_proxy.forward_auth"
        ],
        "backend_bucket": "terraform-state",
        "backend_endpoint_ci": "http://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc.cluster.local",
        "backend_endpoint_local": "http://127.0.0.1:18081",
        "created_resources": [
          "authentik_application.litellm",
          "authentik_flow.litellm_invalidation",
          "authentik_flow_stage_binding.litellm_logout",
          "authentik_property_mapping_provider_scope.litellm_role",
          "authentik_provider_oauth2.litellm",
          "authentik_stage_user_logout.litellm_logout",
          "random_password.litellm_client_secret",
          "random_string.litellm_client_id"
        ],
        "imports": {
          "authentik_application.echo": "echo",
          "authentik_application.oauth2[\"coder\"]": "coder",
          "authentik_application.oauth2[\"pg-admin\"]": "pg-admin",
          "authentik_outpost_provider_attachment.forward_auth": "a827266f-21ed-4a8b-a080-7b59a75a042e:37",
          "authentik_provider_oauth2.oauth2[\"coder\"]": "4",
          "authentik_provider_oauth2.oauth2[\"pg-admin\"]": "2",
          "authentik_provider_proxy.forward_auth": "37"
        },
        "provider_version": "~> 2026.5.1"
      },
      "status": "pass"
    },
    "tofu_validate_script": {
      "detail": {
        "ok": true,
        "stdout_tail": "any changes that are required for your infrastructure. All OpenTofu commands\nshould now work.\n\nIf you ever set or change modules or backend configuration for OpenTofu,\nrerun this command to reinitialize your working directory. If you forget, other\ncommands will detect it and remind you to do so if necessary.\nSuccess! The configuration is valid.\nOK: 1 OpenTofu stack(s) formatted and valid"
      },
      "status": "pass"
    },
    "workflow_terraform_job": {
      "detail": {
        "job": "terraform",
        "path_filter": [
          "terraform/**",
          ".github/workflows/validate.yaml",
          ".mise.toml",
          "scripts/ci/tofu-validate.sh"
        ],
        "runs": "./scripts/ci/tofu-validate.sh"
      },
      "status": "pass"
    }
  }
}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 8 issues found → auto-fixed (5) ✅
  • 🚨 scripts/ci/tofu-authentik-stack-test.py:107 - Stack tests still model open-webui as adopted after the intentional removal: EXPECTED_IMPORTS keeps open-webui provider/app entries (lines 107, 110), _resource_addrs/_all_resource_addrs hardcode for_each keys including open-webui (lines 225 and 257) instead of reading locals, and test_stack_hcl_model still requires variable open_webui_client_id (line 545). applications.tofu/imports.tofu/variables.tofu dropped those objects, so any run of this suite fails and the helper invents addresses that are not in HCL. Align EXPECTED_IMPORTS and variable checks with coder+pg-admin only, and derive for_each keys from the parsed locals map (or a single shared expected-keys constant).
  • 🚨 scripts/ci/tofu-authentik-stack-test.py:846 - test_runbook_acceptance_surface still requires the pre-second-apply gate: substring BLOCKED plus INTERNAL_USER_VIEW_ONLY, and comments say litellm_role/logout remain gated (lines 843-854). docs/authentik/terraform.md status (and the user intent) state the second apply already landed, scopes_supported includes litellm_role, and a fresh plan is empty—those strings are gone from the runbook—so this test fails against the delivered docs. Rewrite assertions to the post-apply contract (role mapping present, invalidation flow present, plan empty / second-apply complete, open-webui removed) instead of freezing the blocked-apply narrative.
  • 🚨 AGENTS.md:136 - AGENTS.md still claims litellm_role + invalidation flow are PENDING/BLOCKED, first SSO stays view-only, and a fresh plan is NOT empty (line 136), and still describes open-webui as an imported managed app with a pending second apply (line 142). User intent requires the opposite delivered state: second apply done, litellm_role and litellm-invalidation-flow live, plan empty with -detailed-exitcode 0, open-webui removed from the stack. This is the operator-facing knowledge base for Authentik/SSO; leaving the blocked-apply story in place will drive wrong plans and false incident response after merge. Update both NOTES bullets to match docs/authentik/terraform.md status and the completed second apply (including writer permission count and open-webui removal).
  • 🚨 kubernetes/apps/base/ai/litellm/README.md:59 - App README still says first SSO login stays view-only until the pending litellm_role apply lands. Intent and the Authentik runbook status say litellm_role is already applied and scopes_supported includes it, so after this PR merges Flux users should land as proxy_admin via GENERIC_SCOPE/GENERIC_USER_ROLE_ATTRIBUTE. Stale view-only wording will make a successful admin SSO login look broken. Replace with the live claim path (litellm_role -> proxy_admin) and point at the completed apply evidence.
  • ⚠️ docs/authentik/terraform.md:132 - Section 2 Import strategy still lists open-webui provider/app import IDs as current stack imports (lines 132, 136) even though the status blurb and HCL removed them. Historical inventory in section 1 can keep the 2026-08-26 snapshot, but the import table is written as what imports.tofu contains now and will push operators to re-add a deleted live SSO app. Mark those rows removed/state-rm'd or drop them from the current import table.
  • ⚠️ scripts/ci/litellm-sso-test.py:199 - New assertions grep implementation/docs text rather than executable behavior: regex over litellm.tofu for the redirect URL (lines 199-210), raw substring checks on outputs.tofu (lines 212-220), and tofu-authentik-stack-test.py runbook exact-phrase gates (e.g. first-plan sentence, BLOCKED, INTERNAL_USER_VIEW_ONLY). Per the test-quality rule these are source-content-only. Keep live HTTP / kustomize / typed HCL model checks; replace file greps with semantic parses (hcl2/YAML) or observable coupling already covered by live OIDC + CR env comparison.
  • ⚠️ scripts/ci/litellm-sso-test.py:319 - test_premerge_litellm_has_no_sso_redirect fails if live /ui/ Location points at Authentik (lines 331-334). That is only valid before Flux applies AUTO_REDIRECT_UI_LOGIN_TO_SSO; after merge the same check becomes a false failure on the intended end state. /sso/login already allows 404 or 302—make the /ui/ check conditional the same way (accept pre-merge non-redirect or post-merge Authentik hop) or drop the absence assertion once the branch is the delivery vehicle for enabling SSO.
  • ℹ️ terraform/authentik/scopes.tofu:3 - Header still says there are zero hand-written property mappings on the instance. litellm.tofu now creates authentik_property_mapping_provider_scope.litellm_role (unmanaged). Update the comment so future agents do not treat all mappings as blueprint-only data sources.

🔧 Fix: Align SSO docs and tests to delivered state
4 issues (3 warnings, 1 info) still open:

  • ⚠️ kubernetes/apps/base/security/authentik/app/pushsecret.yaml:38 - open-webui was removed from the OpenTofu stack (variables, imports, locals, vals), but PushSecret still pushes OPEN_WEBUI_CLIENT_ID (lines 38-41) and docs/authentik/terraform.md section 4 still lists it as a current authentik-terraform field (line 253). That keeps a deleted app's client_id in the live Automation item and makes the hand-made source Secret still require a dead key. Drop the PushSecret match (and the section 4 row) so the credential surface matches coder + pg-admin only.
  • ⚠️ docs/authentik/terraform.md:44 - CI live-plan content moved to section 9, but several CI pointers still say section 8 (now the LiteLLM credential hop): docs intro line 44, secrets section line 290, terraform/authentik/README.md line 14, and secrets-ci.vals.yaml line 33. Operators following those links land on tofu output -> 1Password instead of terraform-diff / Connect CI. Retarget those CI references to section 9; leave true credential-hop refs (outputs.tofu, pushsecret-sso, AGENTS.md LiteLLM secret path) on section 8.
  • ⚠️ docs/authentik/terraform.md:124 - Section 2 still asserts present-tense "Every resource the stack declares is paired with an import block" and frames the first plan as nine creates of live objects only. LiteLLM is deliberately create-only with no import (litellm.tofu). That absolute claim fights the delivered model and can push someone to add import blocks for already-managed create-path resources. Scope the sentence to adopted resources and note create-only LiteLLM separately.
  • ℹ️ terraform/authentik/README.md:37 - README still says "The second-apply gate for the role mapping and invalidation flow is in docs..." after both applies landed and the runbook status is empty-plan. Rephrase to delivered second-apply evidence rather than an open gate.

🔧 Fix: Drop open-webui PushSecret; fix SSO docs xrefs
4 issues (3 warnings, 1 info) still open:

  • ⚠️ .github/workflows/terraform-diff.yaml:21 - Round-2 claimed to retarget CI pointers to section 9, but live CI docs still send operators to section 8 (now the LiteLLM credential hop): terraform-diff.yaml header/comments/warning (lines 21, 77, 132, 145), validate.yaml header/job comment (lines 27, 239), .github/workflows/README.md (~162), scripts/ci/tofu-validate.sh (lines 8, 26), and the embedded warning string in scripts/ci/terraform-ci-workflows-test.py (line 276) which must stay byte-identical to the workflow. Retarget every CI/live-plan reference to section 9; leave true credential-hop refs (outputs.tofu, pushsecret-sso) on section 8.
  • ⚠️ terraform/authentik/applications.tofu:1 - Present-tense inventory still counts the pre-removal surface after open-webui was dropped: applications.tofu header says "The three OIDC applications" (only coder + pg-admin remain), variables.tofu says "The three client_id variables below" (line 19; only coder_client_id + pgadmin_client_id), docs/authentik/terraform.md section 9 still says AUTHENTIK_TOKEN/the three client ids (line 657), and the Never-run-destroy blurb still says it "deletes four applications and four providers" (line 587) even though destroy now also removes create-only LiteLLM resources and no longer covers open-webui. Update present-tense counts; keep dated 2026-08-26 historical inventory wording as-is.
  • ⚠️ scripts/ci/terraform-ci-workflows-test.py:462 - test_runbook_section8_contract still adds source-content-only greps of runbook prose for AWS_ENDPOINT_URL_S3 and the RGW Service DNS (lines 462-463). The executable contract is already asserted against secrets.vals.yaml / secrets-ci.vals.yaml / backend.tofu in both this file and tofu-authentik-stack-test.py. Drop the new runbook substring checks; keep semantic vals/HCL endpoint coupling only.
  • ℹ️ terraform/tofu.md:34 - Convention still says each secrets.vals.yaml holds only ref+op:// references, but authentik now carries a deliberate plain AWS_ENDPOINT_URL_S3 (local port-forward vs CI Service DNS). Soften the absolute claim so the next stack does not treat the plain endpoint as a style violation.

🔧 Fix: Retarget CI xrefs; fix inventory counts
2 warnings still open:

  • ⚠️ terraform/authentik/imports.tofu:3 - imports.tofu header still claims present-tense "Every resource it declares is therefore paired with an import block" and frames a first plan without imports as "9 creates" (lines 3-8). LiteLLM is deliberate create-only with no import (litellm.tofu), and the live import surface is now 7 adopted objects after open-webui removal. The runbook section 2 claim was already scoped to adopted resources; this HCL header still fights that model and can push someone to add import blocks for create-path resources. Scope it to adopted objects and note create-only LiteLLM separately.
  • ⚠️ scripts/ci/terraform-ci-workflows-test.py:438 - test_runbook_section8_contract (retargeted to section 9) is still pure source-content greps of runbook prose: required substring list OP_CONNECT_TOKEN/secrets-ci.vals.yaml/terraform-diff.yaml/etc. (lines 446-458) plus fork/reuse phrase checks. Endpoint greps were removed, but the remaining assertions still only prove docs wording, not CI behavior. The executable contract is already covered by workflow YAML semantic checks and secrets-ci/vals coupling in this same file. Drop the prose greps (or the whole test) rather than keeping a docs-wording tripwire.

🔧 Fix: Fix imports header; drop runbook prose test
3 issues (2 warnings, 1 info) still open:

  • ⚠️ terraform/tofu.md:104 - Commands section still sends live read-only CI plans to docs/authentik/terraform.md §8, but §8 is now the LiteLLM credential hop and CI lives in §9. Same class of misdirection fixed elsewhere (workflows, secrets-ci, AGENTS.md). Retarget this CI pointer to §9; leave genuine credential-hop refs on §8.
  • ⚠️ docs/reference.md:25 - Docs index still describes authentik/terraform.md as "CI read-only plans (§8)". This PR renumbered CI to section 9 and made §8 the credential hop, so the index now points operators at the wrong section. Update to §9 for CI (keep §7 for the apply gate).
  • ℹ️ terraform/tofu.md:55 - File-organization table still says secrets.vals.yaml is "references only", while ground rule 4 already documents the deliberate plain AWS_ENDPOINT_URL_S3 exception. Soften the table cell so it does not reassert the absolute claim.

🔧 Fix: Retarget remaining CI docs xrefs to §9
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • python3 scripts/ci/litellm-sso-test.py (live OIDC discovery, PROXY_BASE_URL/redirect coupling, ExternalSecret/PushSecret path, echo ExtAuth scopes, UI SSO state, kustomize SSO surface)
  • python3 scripts/ci/tofu-authentik-stack-test.py (tofu validate, workflow shape, HCL model, Automation ClusterSecretStore, delivered SSO contract including open-webui removal and flow uuid)
  • python3 scripts/ci/terraform-ci-workflows-test.py (terraform-diff/publish workflow contracts and credentialless validate path)
  • curl live public surface checks against auth.sklab.dev litellm OIDC, echo.sklab.dev ExtAuth, litellm.sklab.dev UI, and litellm end-session
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

First resource in this stack that is created rather than adopted, so it
is the only place a plan should show an addition.

Credential handling is deliberately the inverse of the imported
providers. There, client_secret is left undeclared because declaring it
would rotate a live secret; here there is no live secret to protect and
LiteLLM has to be told what it is, so both halves are generated and
surfaced through outputs for the one hop OpenTofu cannot make itself.
Neither value is written into Git.

The redirect URI was read out of the running v1.98.0 image rather than
from documentation: ui_sso.py builds it as
get_custom_url(PROXY_BASE_URL) + "/sso/callback". The provider's scope
mappings are openid/email/profile, which is byte-for-byte LiteLLM's
default GENERIC_SCOPE, so a scope mismatch cannot be the cause of a
failed login.
Non-secret SSO configuration goes on the LiteLLMProxy CR as plain env so
it stays readable in Git; only the two credential halves pass through the
ExternalSecret.

The two sides are coupled and the coupling is load-bearing: LiteLLM
derives its callback as get_custom_url(PROXY_BASE_URL) + "/sso/callback",
so PROXY_BASE_URL here and the allowed redirect URI in
terraform/authentik/litellm.tofu must move together or every login fails
at the provider with an opaque redirect_uri error. Both files say so.

The client id and secret are generated by OpenTofu rather than typed by
hand, so the value on the Authentik provider and the value LiteLLM
presents cannot drift apart. Getting them from there to here needs one
hop OpenTofu cannot make itself: tofu output -> a hand-made Secret ->
PushSecret -> 1Password -> this ExternalSecret. The push targets a
dedicated single-vault SecretStore for the same reason as the Authentik
one: a write through the shared ClusterSecretStore resolves by vault
priority rather than to a vault you named.

Endpoints were taken from the live .well-known/openid-configuration.
authorize/token/userinfo are instance-global in Authentik; only issuer
and jwks carry the application slug.
… Envoy

Measured 2026-08-27: every request from OpenTofu's S3 client through the
envoy-internal gateway fails with SignatureDoesNotMatch, while the same
credentials against the RGW Service succeed. This is a defect in the
merged backend.tofu, which pointed at https://s3.sklab.dev.

The cause is which headers get signed. aws-sdk-go-v2 puts
accept-encoding, amz-sdk-invocation-id and amz-sdk-request in
SignedHeaders; boto3 and minio-go sign none of them. Envoy rewrites one
of those in transit, so RGW recomputes a different signature. That is why
nothing caught it: VolSync (restic/minio-go) and every other S3 client
keep working through the same route, and a boto3 ListObjectsV2 against
this exact bucket returns 200 while tofu gets 403.

Also adds the two RGW state-key fields to the PushSecret. secrets.vals.yaml
already referenced them, so without this a tofu init against the real
backend could not authenticate.
A namespaced SecretStore may only reference a Secret in its own
namespace; ESO's admission webhook rejects anything else outright. The
1Password Connect token lives in `security`, so the namespaced store
worked there by coincidence (namespace matched) and was rejected
immediately when ai/litellm needed the same push target.

Replaces both namespaced copies with one ClusterSecretStore next to the
existing 1Password store. It still exists for the same reason as before:
the shared `onepassword` store lists three vaults with priorities, which
is right for reads and wrong for writes, since a push through it resolves
by that ordering rather than to a vault you named.

This is the general form of a review finding from #1458, which flagged
the redundant namespace field. That finding was right about the rule and
wrong that the security store was broken; the rule only bites once a
second namespace needs the same store.
Documents what actually happened rather than what was planned: the apply
result, the four post-apply verification checks, the write credential and
the evidence it cannot delete, and the manual hop that carries a
generated credential from tofu output into the cluster.

Also records that the state backend is not reachable through the gateway
and needs a port-forward, with the header-signing reason, so the next
person does not rediscover it against a SignatureDoesNotMatch.
The stack now holds live state and has two least-privilege credentials,
so the AGENTS entry saying nothing had been applied was stale.

Two gotchas worth stating once because both fail silently. The tofu state
backend cannot be reached through the gateway: aws-sdk-go-v2 signs
headers Envoy rewrites, so RGW rejects the signature while every other S3
client in the cluster keeps working. And a kubectl invoked through the
mise shims inherits KUBECONFIG from .mise.toml, pointing at a file a
fresh worktree does not have, which with stderr suppressed shows up as
empty output rather than an error.

Also generalises the ESO write-store rule: the single-vault store is
cluster-scoped because a namespaced SecretStore may only reference a
Secret in its own namespace.
@mortyops

mortyops Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor
--- kubernetes/apps/base/security/external-secrets/stores/onepassword Kustomization: security/onepassword-store ClusterSecretStore: security/onepassword-automation

+++ kubernetes/apps/base/security/external-secrets/stores/onepassword Kustomization: security/onepassword-store ClusterSecretStore: security/onepassword-automation

@@ -0,0 +1,23 @@

+---
+apiVersion: external-secrets.io/v1
+kind: ClusterSecretStore
+metadata:
+  labels:
+    app.kubernetes.io/name: onepassword-store
+    kustomize.toolkit.fluxcd.io/name: onepassword-store
+    kustomize.toolkit.fluxcd.io/namespace: security
+  name: onepassword-automation
+  namespace: security
+spec:
+  provider:
+    onepassword:
+      auth:
+        secretRef:
+          connectTokenSecretRef:
+            key: token
+            name: onepassword-secret
+            namespace: security
+      connectHost: http://onepassword-connect.security.svc.cluster.local:8080
+      vaults:
+        Automation: 1
+
--- kubernetes/apps/base/security/authentik/app Kustomization: security/authentik SecretStore: security/onepassword-automation

+++ kubernetes/apps/base/security/authentik/app Kustomization: security/authentik SecretStore: security/onepassword-automation

@@ -1,22 +0,0 @@

----
-apiVersion: external-secrets.io/v1
-kind: SecretStore
-metadata:
-  labels:
-    app.kubernetes.io/name: authentik
-    kustomize.toolkit.fluxcd.io/name: authentik
-    kustomize.toolkit.fluxcd.io/namespace: security
-  name: onepassword-automation
-  namespace: security
-spec:
-  provider:
-    onepassword:
-      auth:
-        secretRef:
-          connectTokenSecretRef:
-            key: token
-            name: onepassword-secret
-      connectHost: http://onepassword-connect.security.svc.cluster.local:8080
-      vaults:
-        Automation: 1
-
--- kubernetes/apps/base/security/authentik/app Kustomization: security/authentik PushSecret: security/authentik-terraform-credentials

+++ kubernetes/apps/base/security/authentik/app Kustomization: security/authentik PushSecret: security/authentik-terraform-credentials

@@ -19,22 +19,27 @@

       remoteRef:
         property: CODER_CLIENT_ID
         remoteKey: authentik-terraform
       secretKey: CODER_CLIENT_ID
   - match:
       remoteRef:
-        property: OPEN_WEBUI_CLIENT_ID
-        remoteKey: authentik-terraform
-      secretKey: OPEN_WEBUI_CLIENT_ID
-  - match:
-      remoteRef:
         property: PGADMIN_CLIENT_ID
         remoteKey: authentik-terraform
       secretKey: PGADMIN_CLIENT_ID
+  - match:
+      remoteRef:
+        property: TF_STATE_ACCESS_KEY_ID
+        remoteKey: authentik-terraform
+      secretKey: TF_STATE_ACCESS_KEY_ID
+  - match:
+      remoteRef:
+        property: TF_STATE_SECRET_ACCESS_KEY
+        remoteKey: authentik-terraform
+      secretKey: TF_STATE_SECRET_ACCESS_KEY
   refreshInterval: 1h
   secretStoreRefs:
-  - kind: SecretStore
+  - kind: ClusterSecretStore
     name: onepassword-automation
   selector:
     secret:
       name: authentik-terraform-credentials
 
--- kubernetes/apps/base/ai/litellm/app Kustomization: ai/litellm ExternalSecret: ai/litellm

+++ kubernetes/apps/base/ai/litellm/app Kustomization: ai/litellm ExternalSecret: ai/litellm

@@ -13,23 +13,27 @@

   - extract:
       key: litellm
   - extract:
       key: cloudnative-pg
   - extract:
       key: ai-keys
+  - extract:
+      key: litellm-sso
   refreshInterval: 1h
   secretStoreRef:
     kind: ClusterSecretStore
     name: onepassword
   target:
     name: litellm-secret
     template:
       data:
         ANTHROPIC_API_KEY: '{{ .ANTHROPIC_API_KEY }}'
         DATABASE_URL: postgresql://{{ .POSTGRES_DB_USER_NAME }}:{{ .POSTGRES_DB_USER_PASSWORD
           }}@postgres-17-rw.database.svc.cluster.local:5432/{{ .POSTGRES_DB_NAME }}
+        GENERIC_CLIENT_ID: '{{ .LITELLM_SSO_CLIENT_ID }}'
+        GENERIC_CLIENT_SECRET: '{{ .LITELLM_SSO_CLIENT_SECRET }}'
         INIT_POSTGRES_DBNAME: '{{ .POSTGRES_DB_NAME }}'
         INIT_POSTGRES_HOST: postgres-17-rw.database.svc.cluster.local
         INIT_POSTGRES_PASS: '{{ .POSTGRES_DB_USER_PASSWORD }}'
         INIT_POSTGRES_SUPER_PASS: '{{ .POSTGRES_SUPER_PASS }}'
         INIT_POSTGRES_USER: '{{ .POSTGRES_DB_USER_NAME }}'
         LITELLM_MASTER_KEY: '{{ .LITELLM_MASTER_KEY }}'
--- kubernetes/apps/base/ai/litellm/app Kustomization: ai/litellm LiteLLMProxy: ai/litellm

+++ kubernetes/apps/base/ai/litellm/app Kustomization: ai/litellm LiteLLMProxy: ai/litellm

@@ -14,12 +14,28 @@

       key: LITELLM_MASTER_KEY
       name: litellm-secret
   applyMode: file
   env:
   - name: MAX_STRING_LENGTH_PROMPT_IN_DB
     value: '1000000'
+  - name: GENERIC_AUTHORIZATION_ENDPOINT
+    value: https://auth./application/o/authorize/
+  - name: GENERIC_TOKEN_ENDPOINT
+    value: https://auth./application/o/token/
+  - name: GENERIC_USERINFO_ENDPOINT
+    value: https://auth./application/o/userinfo/
+  - name: PROXY_BASE_URL
+    value: https://litellm.
+  - name: GENERIC_SCOPE
+    value: openid email profile litellm_role
+  - name: GENERIC_USER_ROLE_ATTRIBUTE
+    value: litellm_role
+  - name: AUTO_REDIRECT_UI_LOGIN_TO_SSO
+    value: 'true'
+  - name: PROXY_LOGOUT_URL
+    value: https://auth./application/o/litellm/end-session/
   envFrom:
   - secretRef:
       name: litellm-secret
   generalSettings:
     maximum_spend_logs_retention_period: 30d
     store_model_in_db: false
--- kubernetes/apps/base/ai/litellm/app Kustomization: ai/litellm PushSecret: ai/litellm-sso-credentials

+++ kubernetes/apps/base/ai/litellm/app Kustomization: ai/litellm PushSecret: ai/litellm-sso-credentials

@@ -0,0 +1,30 @@

+---
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  labels:
+    app.kubernetes.io/name: litellm
+    kustomize.toolkit.fluxcd.io/name: litellm
+    kustomize.toolkit.fluxcd.io/namespace: ai
+  name: litellm-sso-credentials
+  namespace: ai
+spec:
+  data:
+  - match:
+      remoteRef:
+        property: LITELLM_SSO_CLIENT_ID
+        remoteKey: litellm-sso
+      secretKey: LITELLM_SSO_CLIENT_ID
+  - match:
+      remoteRef:
+        property: LITELLM_SSO_CLIENT_SECRET
+        remoteKey: litellm-sso
+      secretKey: LITELLM_SSO_CLIENT_SECRET
+  refreshInterval: 1h
+  secretStoreRefs:
+  - kind: ClusterSecretStore
+    name: onepassword-automation
+  selector:
+    secret:
+      name: litellm-sso-credentials
+

@mortyops mortyops Bot added the area/github label Aug 28, 2026
Two things the second live apply forced, neither catchable by validate.

authentik_flow's terraform id is the SLUG, so pointing a provider's
invalidation_flow at .id returns 400 "not a valid UUID". The stage
binding already used .uuid; the provider reference did not. Fixed and
documented, because the two attributes look interchangeable and are not.

open-webui was deleted from Authentik by the captain on 2026-08-27,
confirmed in the audit log: akadmin issued both DELETEs, while the only
tofu action that day was a PUT from tofu-writer, which holds no delete
permission at all. The deletion was intentional, so its resources, import
blocks and client-id variable are removed and the two instances dropped
from state, rather than letting the next plan recreate the application
with a client secret that would match nothing.

Records the write role now holding fourteen permissions including the
model-level change_flow, and why every future apply should be checked
against a before/after flow snapshot rather than terraform's own summary.
The flow id-vs-uuid distinction, the S3 backend silently falling back to
real AWS when AWS_ENDPOINT_URL_S3 is unset, and the standing obligation
to verify applies against a database snapshot now that the write role
holds model-level change_flow.
@Aviator-Coding
Aviator-Coding merged commit d159d7f into main Aug 28, 2026
21 checks passed
Aviator-Coding added a commit that referenced this pull request Aug 29, 2026
…#1481)

* docs(ci): document scripts/ci python regression tests and their CI status

Wiring them into validate.yaml is blocked pending a decision: litellm-auto-router-test.py
fails against tip of main (externalsecret allow-list predates the litellm-sso 1Password
item added in d159d7f). See homeops-ci-pytest-wiring task report for detail.

* fix(ci): admit litellm-sso item in ExternalSecret allow-list test

litellm-sso is created and populated by OpenTofu (terraform/authentik/litellm.tofu)
via a PushSecret to wire LiteLLM's UI SSO through Authentik, landed in d159d7f
(PR #1473). The test's allow-list didn't know about it yet; a fifth, unrecognised
item still fails this test.

* ci(github-action): run scripts/ci python regression tests in validate.yaml

Wires the 13 hand-written scripts/ci/*-test.py regression tests, each pinning a
specific captain decision, into a new python-tests job so a PR that breaks one of
these invariants no longer goes green silently. Follows the existing job pattern:
its own filter step reusing the scripts/ci/** path filter, mise-managed tool
setup, then a loop over scripts/ci/*-test.py so any future test file is picked
up automatically without touching this workflow.

* no-mistakes(review): Prefer native promtool over podman in CI

* no-mistakes(document): Sync validate docs for python-tests job

* no-mistakes: apply CI fixes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant