iam: add memory scope, service accounts, manual provisioning
- Add 'memory' scope property mapping (memory_projects, memory_visibility, memory_role) - Add capability groups: llm-users, memory-users, memory-writers - Add service account provisioning for portfolio-agent, memory-agent - Fix sops-secrets kustomization (generatorOptions) - Add RoleBindings for portfolio, poimen, dashboard namespaces - Remove PostSync hook - IAM provisioning is now manual-only
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
secretGenerator:
|
||||
- name: portfolio-secrets
|
||||
namespace: portfolio
|
||||
disableNameSuffixHash: true
|
||||
envs:
|
||||
- portfolio-secrets.enc.env
|
||||
# Disable hash suffix for all generated secrets (stable names)
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
# SOPS-encrypted secrets via ksops generator
|
||||
generators:
|
||||
- secret-generator.yaml
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
# Authentik OAuth provisioning — PostSync hook, reruns on every ArgoCD sync
|
||||
# (hook-delete-policy: BeforeHookCreation deletes the previous run's Job before
|
||||
# creating a new one, so this stays reconciled the same way the rest of the
|
||||
# cluster does — no separate manual bootstrap step like setup_talos_iam.sh /
|
||||
# provision_oidc.py, which never got migrated off the old helmfile workflow).
|
||||
# Authentik OAuth provisioning — MANUAL operation, NOT auto-run.
|
||||
#
|
||||
# What it does (see scripts/authentik-provision.py docstring): creates the
|
||||
# "groups" scope mapping, homelab-admins / grafana-admins groups, the "rock"
|
||||
# admin user, OAuth2 providers + Applications for grafana/minio/forgejo/argocd,
|
||||
# and binds homelab-admins to all of them. The script is generated into the
|
||||
# authentik-provision-script ConfigMap by kustomize configMapGenerator (see
|
||||
# kustomization.yaml), not embedded here.
|
||||
# Security-sensitive IAM changes should be reviewed and run locally:
|
||||
# export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \
|
||||
# -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d)
|
||||
# sed 's|http://authentik-server.iam.svc.cluster.local|https://authentik.riotpiao.com|g' \
|
||||
# k8s/infra/iam/scripts/authentik-provision.py | python3
|
||||
#
|
||||
# What it does (see scripts/authentik-provision.py docstring): creates scope
|
||||
# mappings (groups, permissions, memory, policy, immich_role), admin groups,
|
||||
# the "rock" admin user, OAuth2 providers + Applications, service accounts
|
||||
# (portfolio-agent, memory-agent), and binds groups to applications.
|
||||
#
|
||||
# This file provides the RBAC (ServiceAccount + RoleBindings) needed if you
|
||||
# ever want to run the Job in-cluster manually (kubectl create -f), but the
|
||||
# PostSync hook is deliberately removed — IAM is not GitOps-auto-deployed.
|
||||
#
|
||||
# RBAC: this Job only touches Secrets (get existing client secrets, create new
|
||||
# ones for forgejo/argocd/rock) across the namespaces those services live in.
|
||||
# It never touches any other resource type.
|
||||
# ones) across the namespaces those services live in.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
@@ -143,14 +146,54 @@ roleRef:
|
||||
name: authentik-provisioner
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: authentik-provisioner
|
||||
namespace: portfolio
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: authentik-provisioner
|
||||
namespace: iam
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: authentik-provisioner
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: authentik-provisioner
|
||||
namespace: poimen
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: authentik-provisioner
|
||||
namespace: iam
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: authentik-provisioner
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: authentik-provisioner
|
||||
namespace: dashboard
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: authentik-provisioner
|
||||
namespace: iam
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: authentik-provisioner
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: authentik-provision
|
||||
namespace: iam
|
||||
annotations:
|
||||
argocd.argoproj.io/hook: PostSync
|
||||
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
|
||||
# No ArgoCD hook - run manually when IAM changes are needed
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 600
|
||||
backoffLimit: 3
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user