diff --git a/homelab-rbac.md b/homelab-rbac.md new file mode 100644 index 0000000..608b6f9 --- /dev/null +++ b/homelab-rbac.md @@ -0,0 +1,409 @@ +# Homelab RBAC Design + +**Authentik as Universal Identity Provider** — Single source of truth for all identity, groups, and permissions. Services validate JWTs and trust claims; no separate auth systems. + +## Design Principles + +1. **Authentik = Policy Decision Point** — All access decisions computed in Authentik, embedded in JWT claims +2. **Services = Policy Enforcement Points** — Validate JWT signature via JWKS, trust claims blindly +3. **Zero Service-Side RBAC** — No local user/role tables, no permission logic in services +4. **Claims Are The API** — Each service defines what claims it needs; Authentik provides them + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Clients │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Browser │ │ CLI │ │ Mobile │ │ Agent │ │ CI/CD │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +└────────┼─────────────┼─────────────┼─────────────┼─────────────┼────────────┘ + │ │ │ │ │ + │ auth_code │ device │ device │ client │ client + │ + PKCE │ code │ code │ credentials│ credentials + │ │ │ │ │ + └─────────────┴─────────────┴─────────────┴─────────────┴─────────────┐ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ AUTHENTIK (Policy Decision Point) │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────────────────┐ │ +│ │ Users │ │ Groups │ │ User Attributes │ │ +│ │ │ │ │ │ │ │ +│ │ rock (human) │ │ homelab-admins │ │ memory_projects: ["*"] │ │ +│ │ portfolio-sa │ │ llm-admins │ │ memory_visibility: private │ │ +│ │ (svc account) │ │ memory-admins │ │ minio_policy: consoleAdmin │ │ +│ └─────────────────┘ └─────────────────┘ └───────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ Property Mappings (Scope → Claim) │ │ +│ │ │ │ +│ │ scope: groups → claim: groups (group names list) │ │ +│ │ scope: permissions → claim: permissions (computed from groups) │ │ +│ │ scope: memory → claims: memory_projects, memory_visibility, │ │ +│ │ memory_role (from user attrs + groups) │ │ +│ │ scope: minio → claim: policy (MinIO policy name) │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ JWT Token (signed) │ │ +│ │ { │ │ +│ │ "sub": "abc123", │ │ +│ │ "groups": ["homelab-admins", "memory-admins"], │ │ +│ │ "permissions": ["*"], │ │ +│ │ "memory_projects": ["*"], │ │ +│ │ "memory_visibility": "private", │ │ +│ │ "memory_role": "admin", │ │ +│ │ "policy": "consoleAdmin" │ │ +│ │ } │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────┬────────────────────────────────────────┘ + │ + JWT (Authorization: Bearer) + │ + ┌──────────────────────────────┼──────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ +│ API Gateway │ │ Memory Service │ │ MinIO │ +│ │ │ (Poimen) │ │ │ +│ 1. Validate JWT │ │ 1. Validate JWT │ │ 1. Validate JWT │ +│ 2. Read claims: │ │ 2. Read claims: │ │ 2. Read claims: │ +│ - permissions │ │ - memory_* │ │ - policy │ +│ 3. Allow/deny │ │ 3. Filter rows │ │ 3. Map to IAM │ +│ │ │ │ │ │ +│ NO local RBAC │ │ NO local RBAC │ │ NO local RBAC │ +└───────────────────┘ └───────────────────┘ └───────────────────┘ +``` + +--- + +## Authentik Configuration + +### Groups (in Authentik) + +| Group | Purpose | `is_superuser` | +|-------|---------|----------------| +| `homelab-admins` | Universal admin, full access to everything | `true` | +| `llm-admins` | LLM service management | `false` | +| `memory-admins` | Memory service management | `false` | +| `minio-admins` | MinIO storage management | `false` | +| `llm-users` | Can call LLM inference (no admin) | `false` | +| `memory-users` | Can query memory (no write) | `false` | + +### User Attributes (stored on user object) + +Fine-grained scopes stored as user attributes in Authentik: + +| Attribute | Type | Example | Purpose | +|-----------|------|---------|---------| +| `memory_projects` | `string[]` | `["homelab", "portfolio"]` or `["*"]` | Which projects user can access | +| `memory_visibility` | `string` | `"public"` or `"private"` | Max visibility level | +| `minio_policy` | `string` | `"portfolio-rw"` | Override MinIO policy (optional) | + +### Property Mappings (Scopes → Claims) + +#### 1. `permissions` scope (existing) + +```python +# Already in authentik-provision.py +GROUP_PERMISSIONS = { + "homelab-admins": ["*"], + "llm-admins": ["llm:read", "llm:write", "llm:inference"], + "llm-users": ["llm:inference"], + "memory-admins": ["memory:read", "memory:write", "memory:admin"], + "memory-users": ["memory:read"], + "minio-admins": ["minio:read", "minio:write"], + # ... +} +perms = set() +for group in request.user.groups.all(): + perms.update(GROUP_PERMISSIONS.get(group.name, [])) +return {"permissions": sorted(perms)} +``` + +#### 2. `memory` scope (NEW - to add) + +```python +# Memory service claims - reads from user attributes + group membership +_MEMORY_EXPR = """ +# Default scopes +projects = request.user.attributes.get("memory_projects", []) +visibility = request.user.attributes.get("memory_visibility", "public") +role = "user" + +# Admin overrides +if request.user.ak_groups.filter(name="homelab-admins").exists(): + projects = ["*"] + visibility = "private" + role = "admin" +elif request.user.ak_groups.filter(name="memory-admins").exists(): + visibility = "private" + role = "admin" + +# Service account specific configs +SA_CONFIGS = { + "portfolio-agent": { + "projects": ["homelab", "portfolio"], + "visibility": "public", + "role": "portfolio-agent" + }, + "memory-agent": { + "projects": ["*"], + "visibility": "private", + "role": "authenticated-user" + } +} +if request.user.username in SA_CONFIGS: + cfg = SA_CONFIGS[request.user.username] + projects = cfg["projects"] + visibility = cfg["visibility"] + role = cfg["role"] + +return { + "memory_projects": projects, + "memory_visibility": visibility, + "memory_role": role +} +""" +``` + +#### 3. `minio` scope (existing) + +```python +# Already in authentik-provision.py +_POLICY_EXPR = """ +# Check for explicit policy override in user attributes +explicit = request.user.attributes.get("minio_policy") +if explicit: + return {"policy": explicit} + +# Group-based defaults +if request.user.ak_groups.filter(name="homelab-admins").exists(): + return {"policy": "consoleAdmin"} +elif request.user.ak_groups.filter(name="minio-admins").exists(): + return {"policy": "readwrite"} +else: + return {"policy": "readonly"} +""" +``` + +--- + +## Service Claim Contracts + +Each service documents what JWT claims it expects. Authentik must provide them. + +### API Gateway (`api.riotpiao.com`) + +**Required Claims:** +```json +{ + "permissions": ["llm:inference", "..."] // Check against required permission +} +``` + +**Enforcement:** +```go +// Gateway just checks if required permission is in claims +if !slices.Contains(claims.Permissions, "llm:inference") { + return 403 +} +``` + +### Memory Service (`memory.riotpiao.com`) + +**Required Claims:** +```json +{ + "sub": "user-id", // Owner for conversation filtering + "permissions": ["memory:read", "..."], // Capability check + "memory_projects": ["homelab", "..."], // Project filter (or ["*"]) + "memory_visibility": "public|private", // Max visibility level + "memory_role": "admin|user|..." // Built-in role (optional) +} +``` + +**Enforcement:** +```rust +// Memory service filters results based on claims +fn filter_results(results: Vec, claims: &Claims) -> Vec { + results.into_iter().filter(|doc| { + // Project check + let project_ok = claims.memory_projects.contains("*") + || claims.memory_projects.contains(&doc.project); + + // Visibility check + let visibility_ok = match claims.memory_visibility.as_str() { + "private" => true, // Can see everything + "public" => doc.visibility == "public", + _ => false, + }; + + // Owner check for conversations + let owner_ok = doc.resource_type != "conversation" + || doc.owner == claims.sub; + + project_ok && visibility_ok && owner_ok + }).collect() +} +``` + +### MinIO (`minio.riotpiao.com`) + +**Required Claims:** +```json +{ + "policy": "consoleAdmin|readwrite|readonly|" +} +``` + +**Enforcement:** +- MinIO natively reads `policy` claim via `MINIO_IDENTITY_OPENID_CLAIM_NAME=policy` +- Looks up policy by name in MinIO's IAM policy store +- Built-in: `consoleAdmin`, `readwrite`, `readonly`, `writeonly`, `diagnostics` +- Custom policies created via `mc admin policy create` + +--- + +## Service Accounts + +Service accounts are Authentik users with `type: service_account` and client credentials grant. + +### Creating a Service Account + +```python +# In authentik-provision.py, add to SERVICES dict: + +"portfolio-agent": { + "type": "service_account", # No redirect URIs + "grant_types": ["client_credentials"], + "client_secret_source": ("portfolio", "portfolio-oidc", "CLIENT_SECRET"), + "generate_if_missing": True, + "display_name": "Portfolio Agent", + "groups": ["llm-users", "memory-users"], # Group membership + "attributes": { # User attributes + "memory_projects": ["homelab", "portfolio"], + "memory_visibility": "public" + } +} +``` + +### Service Account Token Flow + +```bash +# 1. Service requests token with client credentials +curl -X POST https://authentik.riotpiao.com/application/o/token/ \ + -d "grant_type=client_credentials" \ + -d "client_id=portfolio-agent" \ + -d "client_secret=$SECRET" \ + -d "scope=openid permissions memory" + +# 2. Authentik returns JWT with claims +{ + "access_token": "eyJ...", + "token_type": "Bearer", + "expires_in": 300 +} + +# 3. Service uses token to call API +curl https://api.riotpiao.com/v1/chat/completions \ + -H "Authorization: Bearer eyJ..." +``` + +--- + +## User Onboarding + +### Admin Creates User + +```python +# 1. Create user via Authentik API +POST /api/v3/core/users/ +{ + "username": "alice", + "email": "alice@example.com", + "groups": ["llm-users", "memory-users"], + "attributes": { + "memory_projects": ["homelab"], + "memory_visibility": "public" + } +} + +# 2. Set password or send invite +POST /api/v3/core/users/{pk}/set_password/ + or +POST /api/v3/stages/invitation/invitations/ +``` + +### Grant Additional Access + +```python +# Add to group (grants permissions via GROUP_PERMISSIONS mapping) +PATCH /api/v3/core/users/{pk}/ +{ + "groups": ["llm-users", "memory-users", "memory-admins"] # Added memory-admins +} + +# Or update attributes for fine-grained scopes +PATCH /api/v3/core/users/{pk}/ +{ + "attributes": { + "memory_projects": ["homelab", "new-project"], # Added project + "memory_visibility": "private" # Upgraded visibility + } +} +``` + +### Offboard User + +```python +# Deactivate (preserves audit trail) +PATCH /api/v3/core/users/{pk}/ +{"is_active": false} + +# Revoke all tokens immediately +POST /api/v3/core/users/{pk}/revoke_tokens/ +``` + +--- + +## Implementation Checklist + +### Phase 1: Add Memory Claims ⬜ + +1. Add `memory` scope property mapping to `authentik-provision.py` +2. Add scope to `poimen-memory` OAuth provider +3. Update Memory service to read claims instead of local RBAC + +### Phase 2: Service Accounts ⬜ + +1. Add service account creation to `authentik-provision.py` +2. Create `portfolio-agent` service account +3. Test client credentials flow + +### Phase 3: MinIO Fine-Grained ⬜ + +1. Create custom MinIO policies (`portfolio-rw`, `memory-rw`) +2. Update `minio` scope mapping for service accounts +3. Test bucket-level access + +--- + +## Appendix: Claim Reference + +| Claim | Source | Services | +|-------|--------|----------| +| `sub` | User ID (hashed) | All (owner checks) | +| `groups` | Group membership | ArgoCD, Grafana | +| `permissions` | Computed from groups | API Gateway, Memory | +| `memory_projects` | User attr + group | Memory | +| `memory_visibility` | User attr + group | Memory | +| `memory_role` | User attr + group | Memory | +| `policy` | User attr + group | MinIO | +| `immich_role` | Group membership | Immich | diff --git a/k8s/infra/iam/scripts/authentik-provision.py b/k8s/infra/iam/scripts/authentik-provision.py index 04fa26f..9a4dc02 100644 --- a/k8s/infra/iam/scripts/authentik-provision.py +++ b/k8s/infra/iam/scripts/authentik-provision.py @@ -163,7 +163,10 @@ GROUPS_MAPPING_PK = groups_mapping["pk"] # foundation for a future short-lived federated-operator credential. _PERMISSIONS_EXPR = """ GROUP_PERMISSIONS = { + # Universal admin "homelab-admins": ["*"], + + # Service admin groups (full control) "grafana-admins": ["grafana:read", "grafana:write"], "minio-admins": ["minio:read", "minio:write"], "forgejo-admins": ["forgejo:read", "forgejo:write"], @@ -174,9 +177,14 @@ GROUP_PERMISSIONS = { "llm-admins": ["llm:read", "llm:write", "llm:inference"], "paperless-admins": ["paperless:read", "paperless:write"], "immich-admins": ["immich:read", "immich:write"], - "poimen-memory-admins": ["poimen-memory:read", "poimen-memory:write"], + "poimen-memory-admins": ["memory:read", "memory:write", "memory:admin"], "k8s-devops-admin": ["k8s:devops"], "vault-service-api": ["vault:read", "vault:write"], + + # Capability groups (non-admin users) + "llm-users": ["llm:inference"], + "memory-users": ["memory:read"], + "memory-writers": ["memory:read", "memory:write"], } perms = set() for group in request.user.groups.all(): @@ -240,6 +248,69 @@ policy_mapping = get_or_create( ) POLICY_MAPPING_PK = policy_mapping["pk"] +# Memory service (Poimen) claims - fine-grained access control. +# Returns memory_projects, memory_visibility, memory_role based on: +# 1. User attributes (memory_projects, memory_visibility) +# 2. Group membership (homelab-admins, poimen-memory-admins) +# 3. Service account configs (portfolio-agent, etc.) +_MEMORY_EXPR = """ +# Service account specific configs (checked first) +SA_CONFIGS = { + "portfolio-agent": { + "projects": ["homelab", "portfolio"], + "visibility": "public", + "role": "portfolio-agent" + }, + "memory-agent": { + "projects": ["*"], + "visibility": "private", + "role": "authenticated-user" + } +} + +username = request.user.username +if username in SA_CONFIGS: + cfg = SA_CONFIGS[username] + return { + "memory_projects": cfg["projects"], + "memory_visibility": cfg["visibility"], + "memory_role": cfg["role"] + } + +# Default from user attributes +projects = request.user.attributes.get("memory_projects", []) +visibility = request.user.attributes.get("memory_visibility", "public") +role = "user" + +# Admin group overrides +if request.user.ak_groups.filter(name="homelab-admins").exists(): + projects = ["*"] + visibility = "private" + role = "admin" +elif request.user.ak_groups.filter(name="poimen-memory-admins").exists(): + # memory-admins get full visibility but respect project restrictions + visibility = "private" + role = "admin" + +return { + "memory_projects": projects if projects else [], + "memory_visibility": visibility, + "memory_role": role +} +""".strip() +memory_mapping = get_or_create( + "/api/v3/propertymappings/provider/scope/", + "/api/v3/propertymappings/provider/scope/", + "scope_name=memory", + { + "name": "homelab: memory service claims", + "scope_name": "memory", + "expression": _MEMORY_EXPR, + }, + patch_existing={"expression": _MEMORY_EXPR}, +) +MEMORY_MAPPING_PK = memory_mapping["pk"] + # Fetch the standard openid/email/profile mapping pks (shipped by default). status, res = api("GET", "/api/v3/propertymappings/provider/scope/") by_scope = {m["scope_name"]: m["pk"] for m in res["results"]} @@ -265,14 +336,17 @@ homelab_admins = get_or_create( # k8s-devops-admin is declared with no target yet - foundation for a future # short-lived federated-operator credential. SERVICE_ADMIN_GROUP_NAMES = [ + # Service admin groups (full control of their service) "grafana-admins", "minio-admins", "forgejo-admins", "homarr-admins", "portainer-admins", "kmsvc-admins", "temporal-admins", "llm-admins", "paperless-admins", "immich-admins", "poimen-memory-admins", "k8s-devops-admin", - # Not a human-admin group like the others - Vault Identity Group aliasing - # target for service/API (non-browser) access to Vault, kept separate from - # homelab-admins' blanket "*" grant. See k8s/infra/iam/scripts/vault-provision.sh. + # Vault Identity Group aliasing target for service/API access "vault-service-api", + # Capability groups (non-admin users with specific permissions) + "llm-users", # Can call LLM inference, no admin + "memory-users", # Can query memory, no write + "memory-writers", # Can query and write to memory ] service_admin_groups = {} for group_name in SERVICE_ADMIN_GROUP_NAMES: @@ -439,11 +513,17 @@ SERVICES = { app_pks_for_binding = [] for name, cfg in SERVICES.items(): - # MinIO also needs the "policy" claim (via the minio scope mapping) so its - # MINIO_IDENTITY_OPENID_CLAIM_NAME=policy maps homelab-admins -> consoleAdmin. - # Immich needs "immich_role" so its OAuth roleClaim can grant admin. - provider_mappings = SCOPE_PKS + ([POLICY_MAPPING_PK] if name == "minio" else []) \ - + ([IMMICH_ROLE_MAPPING_PK] if name == "immich" else []) + # Service-specific scope mappings: + # - MinIO: "policy" claim for MINIO_IDENTITY_OPENID_CLAIM_NAME + # - Immich: "immich_role" for OAuth roleClaim + # - poimen-memory, local-llm: "memory" scope for fine-grained access + provider_mappings = SCOPE_PKS[:] + if name == "minio": + provider_mappings.append(POLICY_MAPPING_PK) + if name == "immich": + provider_mappings.append(IMMICH_ROLE_MAPPING_PK) + if name in ("poimen-memory", "local-llm", "portfolio-agent"): + provider_mappings.append(MEMORY_MAPPING_PK) ns, secret_name, key = cfg["client_secret_source"] client_secret = kubectl_get_secret_key(ns, secret_name, key) if client_secret is None: @@ -640,7 +720,123 @@ app_pks_for_binding.append(("kubernetes", k8s_application["pk"])) print(f" kubernetes: provider pk={k8s_provider['pk']} application pk={k8s_application['pk']}") # ----------------------------------------------------------------------------- -print("[5/5] Binding homelab-admins to every application (guaranteed access for rock)...") +# Service accounts for programmatic API access (client_credentials flow). +# These are Authentik users with type=service_account, not OAuth applications. +# They authenticate via client_credentials grant to get JWTs with claims. +print("\n[SERVICE ACCOUNTS] Creating service accounts for API access...") + +SERVICE_ACCOUNTS = { + "portfolio-agent": { + "groups": [], # Permissions come from SA_CONFIGS in memory scope mapping + "attributes": { + "memory_projects": ["homelab", "portfolio"], + "memory_visibility": "public", + }, + "secret_ns": "portfolio", + "secret_name": "portfolio-agent-oidc", + }, + "memory-agent": { + "groups": [], # Permissions come from SA_CONFIGS in memory scope mapping + "attributes": { + "memory_projects": ["*"], + "memory_visibility": "private", + }, + "secret_ns": "poimen", + "secret_name": "memory-agent-oidc", + }, +} + +for sa_name, sa_cfg in SERVICE_ACCOUNTS.items(): + # Check if secret already exists (don't regenerate credentials) + existing_secret = kubectl_get_secret_key(sa_cfg["secret_ns"], sa_cfg["secret_name"], "CLIENT_SECRET") + + # Get or create the service account user + status, res = api("GET", f"/api/v3/core/users/?username={sa_name}") + if status != 200: + die(f"GET users for {sa_name} -> {status} {res}") + + if res.get("results"): + sa_user = res["results"][0] + # Update attributes and groups + group_pks = [service_admin_groups[g]["pk"] for g in sa_cfg["groups"] if g in service_admin_groups] + status, sa_user = api("PATCH", f"/api/v3/core/users/{sa_user['pk']}/", { + "attributes": sa_cfg["attributes"], + "groups": group_pks, + "is_active": True, + }) + if status not in (200, 201): + die(f"PATCH service account {sa_name} -> {status} {sa_user}") + print(f" {sa_name}: updated existing service account") + else: + # Create new service account user + group_pks = [service_admin_groups[g]["pk"] for g in sa_cfg["groups"] if g in service_admin_groups] + status, sa_user = api("POST", "/api/v3/core/users/", { + "username": sa_name, + "name": f"Service Account: {sa_name}", + "type": "service_account", + "path": "service-accounts", + "is_active": True, + "attributes": sa_cfg["attributes"], + "groups": group_pks, + }) + if status not in (200, 201): + die(f"POST service account {sa_name} -> {status} {sa_user}") + print(f" {sa_name}: created new service account") + + # Create OAuth provider for this service account (client_credentials flow) + sa_client_secret = existing_secret or gen_secret(40) + sa_provider = get_or_create( + "/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/", + f"name={sa_name}", + { + "name": sa_name, + "client_id": sa_name, + "client_secret": sa_client_secret, + "client_type": "confidential", + "authorization_flow": AUTHORIZATION_FLOW_PK, + "invalidation_flow": INVALIDATION_FLOW_PK, + "signing_key": SIGNING_KEY_PK, + "property_mappings": SCOPE_PKS + [MEMORY_MAPPING_PK], + "sub_mode": "hashed_user_id", + "include_claims_in_id_token": True, + "grant_types": ["client_credentials"], + "redirect_uris": [], # No redirects for client_credentials + }, + patch_existing={ + "property_mappings": SCOPE_PKS + [MEMORY_MAPPING_PK], + "grant_types": ["client_credentials"], + }, + ) + + # Create application for the service account + sa_application = get_or_create( + "/api/v3/core/applications/", "/api/v3/core/applications/", + f"slug={sa_name}&superuser_full_list=true", + { + "name": f"Service Account: {sa_name}", + "slug": sa_name, + "provider": sa_provider["pk"], + "meta_launch_url": "", + }, + ) + app_pks_for_binding.append((sa_name, sa_application["pk"])) + + # Store credentials in k8s Secret + if not existing_secret: + kubectl_create_secret(sa_cfg["secret_ns"], sa_cfg["secret_name"], { + "CLIENT_ID": sa_name, + "CLIENT_SECRET": sa_client_secret, + "TOKEN_URL": "https://authentik.riotpiao.com/application/o/token/", + "ISSUER": f"https://authentik.riotpiao.com/application/o/{sa_name}/", + }) + print(f" {sa_name}: generated credentials -> {sa_cfg['secret_ns']}/{sa_cfg['secret_name']}") + else: + print(f" {sa_name}: reusing existing credentials from {sa_cfg['secret_ns']}/{sa_cfg['secret_name']}") + + print(f" {sa_name}: provider pk={sa_provider['pk']} application pk={sa_application['pk']}") + +# ----------------------------------------------------------------------------- +print("\n[5/5] Binding homelab-admins to every application (guaranteed access for rock)...") for name, app_pk in app_pks_for_binding: get_or_create( "/api/v3/policies/bindings/", "/api/v3/policies/bindings/", @@ -667,6 +863,10 @@ SERVICE_GROUP_TO_APP_SLUG = { "paperless-admins": "paperless", "immich-admins": "immich", "llm-admins": "local-llm", + "llm-users": "local-llm", + "poimen-memory-admins": "poimen-memory", + "memory-users": "poimen-memory", + "memory-writers": "poimen-memory", } for group_name, app_slug in SERVICE_GROUP_TO_APP_SLUG.items(): app_pk = next((pk for n, pk in app_pks_for_binding if n == app_slug), None)