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
This commit is contained in:
2026-10-26 10:26:54 -07:00
parent 81883a8ea1
commit 903a72a6fd
+19 -2
View File
@@ -80,6 +80,10 @@ ROLES = {
"sqs-writers": "SQS/Temporal queue read/write access", "sqs-writers": "SQS/Temporal queue read/write access",
"s3-users": "S3 read access", "s3-users": "S3 read access",
"s3-writers": "S3 read/write 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"} _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: def _ensure_project(token: str, name: str) -> str:
code, d = _api("POST", "/management/v1/projects/_search", token, {}) code, d = _api("POST", "/management/v1/projects/_search", token, {})
project_id = ""
for row in d.get("result") or []: for row in d.get("result") or []:
if row.get("name") == name: if row.get("name") == name:
return row["id"] project_id = row["id"]
break
if not project_id:
code, d = _api("POST", "/management/v1/projects", token, {"name": name}) code, d = _api("POST", "/management/v1/projects", token, {"name": name})
if code not in (200, 201) or not d.get("id"): if code not in (200, 201) or not d.get("id"):
die(f"create project {name} failed: {code} {d}") die(f"create project {name} failed: {code} {d}")
return d["id"] 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: def _ensure_roles(token: str, project_id: str, roles: dict[str, str]) -> None: