From 903a72a6fd2f6b4be9a97de2c270c42ea399e3bf Mon Sep 17 00:00:00 2001 From: rock Date: Mon, 26 Oct 2026 10:26:54 -0700 Subject: [PATCH] feat(iam): provision the LLM inference roles and assert project roles The api-gw gates /v1/* on a capability named in the access token. ZITADEL had no such role, so every inference call was 403 once the token came from ZITADEL instead of Authentik. - add llm:inference (Authentik spelling, keeps consumer manifests unchanged) and llm-inference (dash alias) to the project roles - enable projectRoleAssertion on the project, which is what puts urn:zitadel:iam:org:project:roles into the access token; without it the role list never reaches the resource server --- scripts/iam/zitadel-provision.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/iam/zitadel-provision.py b/scripts/iam/zitadel-provision.py index 5c01255..e2d4fb1 100755 --- a/scripts/iam/zitadel-provision.py +++ b/scripts/iam/zitadel-provision.py @@ -80,6 +80,10 @@ ROLES = { "sqs-writers": "SQS/Temporal queue read/write access", "s3-users": "S3 read access", "s3-writers": "S3 read/write access", + # LLM gate for the api-gw. The colon form matches the Authentik-era + # permission string, so consumer manifests did not change. + "llm:inference": "LLM inference access (api-gw)", + "llm-inference": "LLM inference access (dash alias)", } _WEB = {"app_type_id": "OIDC_APP_TYPE_WEB", "auth_method_id": "OIDC_AUTH_METHOD_TYPE_BASIC"} @@ -266,13 +270,26 @@ def _api(method: str, path: str, token: str, body: dict | None = None, def _ensure_project(token: str, name: str) -> str: code, d = _api("POST", "/management/v1/projects/_search", token, {}) + project_id = "" for row in d.get("result") or []: if row.get("name") == name: - return row["id"] - code, d = _api("POST", "/management/v1/projects", token, {"name": name}) - if code not in (200, 201) or not d.get("id"): - die(f"create project {name} failed: {code} {d}") - return d["id"] + project_id = row["id"] + break + if not project_id: + code, d = _api("POST", "/management/v1/projects", token, {"name": name}) + if code not in (200, 201) or not d.get("id"): + die(f"create project {name} failed: {code} {d}") + project_id = d["id"] + + # projectRoleAssertion puts the project roles into the ACCESS token as + # `urn:zitadel:iam:org:project:roles`. Without it the api-gw sees no + # llm:inference role and answers 403 for every inference call, even when + # the caller is correctly authorised in ZITADEL. + code, d = _api("PUT", f"/management/v1/projects/{project_id}", + token, {"name": name, "projectRoleAssertion": True}) + if code not in (200, 201): + die(f"enable projectRoleAssertion on {name} failed: {code} {d}") + return project_id def _ensure_roles(token: str, project_id: str, roles: dict[str, str]) -> None: