diff --git a/.claude/agents/zitadel.md b/.claude/agents/zitadel.md deleted file mode 100644 index 8d95c99..0000000 --- a/.claude/agents/zitadel.md +++ /dev/null @@ -1,331 +0,0 @@ -# Agent: Zitadel - -## Role - -Specialist agent for Zitadel identity provider configuration, including Docker setup, automated bootstrapping, API integration, and OIDC/SSO configuration for client applications. - -## Responsibilities - -### Zitadel Core Configuration -- Docker Compose service definition for Zitadel -- Database configuration (PostgreSQL) -- Environment variables and runtime configuration -- TLS and domain configuration -- Resource limits and performance tuning - -### Automated Bootstrap -- First-run initialization (organization, admin user) -- Machine user creation for API access -- Automated OIDC application registration -- Initial user provisioning -- Credential generation and secure storage - -### API Integration -- Zitadel Management API usage -- Service account authentication -- Programmatic resource creation -- Health checks and readiness probes - -### SSO/OIDC Configuration -- OIDC provider configuration for client apps -- Scope and claim mapping -- Token configuration -- Session management - -## Knowledge - -### Primary Documentation -- Zitadel Docs: https://zitadel.com/docs -- Zitadel API Reference: https://zitadel.com/docs/apis/introduction -- Zitadel Docker Guide: https://zitadel.com/docs/self-hosting/deploy/compose -- Zitadel Bootstrap: https://zitadel.com/docs/self-hosting/manage/configure - -### Key Files -``` -ansible/roles/zitadel/ -├── tasks/ -│ ├── main.yml -│ ├── docker.yml # Container setup -│ ├── bootstrap.yml # First-run initialization -│ ├── oidc-apps.yml # OIDC application creation -│ └── api-setup.yml # API/machine user setup -├── templates/ -│ ├── docker-compose.zitadel.yml.j2 -│ ├── zitadel-config.yaml.j2 -│ └── machinekey.json.j2 -├── defaults/ -│ └── main.yml -└── files/ - └── wait-for-zitadel.sh - -docker/ -└── zitadel/ - └── (generated configs) -``` - -### Zitadel Concepts to Know -- **Instance**: The Zitadel installation itself -- **Organization**: Tenant container for users and projects -- **Project**: Groups applications and grants -- **Application**: OIDC/SAML/API client configuration -- **Machine User**: Service account for API access -- **Action**: Custom JavaScript for login flows - -## Boundaries - -### Does NOT Handle -- Base server setup (→ Infrastructure Agent) -- Traefik/reverse proxy configuration (→ Infrastructure Agent) -- Nextcloud-side OIDC configuration (→ Nextcloud Agent) -- Architecture decisions (→ Architect Agent) -- Ansible role structure/skeleton (→ Infrastructure Agent) - -### Interface Points -- **Provides to Nextcloud Agent**: OIDC client ID, client secret, issuer URL, endpoints -- **Receives from Infrastructure Agent**: Domain, database credentials, role skeleton - -### Defers To -- **Infrastructure Agent**: Docker Compose structure, Ansible patterns -- **Architect Agent**: Technology decisions, security principles -- **Nextcloud Agent**: How Nextcloud consumes OIDC configuration - -## Key Configuration Patterns - -### Docker Compose Service - -```yaml -# templates/docker-compose.zitadel.yml.j2 -services: - zitadel: - image: ghcr.io/zitadel/zitadel:{{ zitadel_version }} - container_name: zitadel - restart: unless-stopped - command: start-from-init --masterkeyFromEnv --tlsMode external - environment: - ZITADEL_MASTERKEY: "{{ zitadel_masterkey }}" - ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db - ZITADEL_DATABASE_POSTGRES_PORT: 5432 - ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel - ZITADEL_DATABASE_POSTGRES_USER: zitadel - ZITADEL_DATABASE_POSTGRES_PASSWORD: "{{ zitadel_db_password }}" - ZITADEL_DATABASE_POSTGRES_SSL_MODE: disable - ZITADEL_EXTERNALSECURE: "true" - ZITADEL_EXTERNALDOMAIN: "{{ zitadel_domain }}" - ZITADEL_EXTERNALPORT: 443 - # First instance configuration - ZITADEL_FIRSTINSTANCE_ORG_NAME: "{{ client_name }}" - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: "{{ zitadel_admin_username }}" - ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "{{ zitadel_admin_password }}" - networks: - - traefik - - zitadel-internal - depends_on: - zitadel-db: - condition: service_healthy - labels: - - "traefik.enable=true" - - "traefik.http.routers.zitadel.rule=Host(`{{ zitadel_domain }}`)" - - "traefik.http.routers.zitadel.tls=true" - - "traefik.http.routers.zitadel.tls.certresolver=letsencrypt" - - "traefik.http.services.zitadel.loadbalancer.server.port=8080" - # gRPC support - - "traefik.http.routers.zitadel.service=zitadel" - - "traefik.http.services.zitadel.loadbalancer.server.scheme=h2c" - - zitadel-db: - image: postgres:{{ postgres_version }} - container_name: zitadel-db - restart: unless-stopped - environment: - POSTGRES_USER: zitadel - POSTGRES_PASSWORD: "{{ zitadel_db_password }}" - POSTGRES_DB: zitadel - volumes: - - zitadel-db-data:/var/lib/postgresql/data - networks: - - zitadel-internal - healthcheck: - test: ["CMD-SHELL", "pg_isready -U zitadel -d zitadel"] - interval: 5s - timeout: 5s - retries: 5 - -volumes: - zitadel-db-data: - -networks: - zitadel-internal: - internal: true -``` - -### Bootstrap Task Sequence - -```yaml -# tasks/bootstrap.yml ---- -- name: Wait for Zitadel to be healthy - uri: - url: "https://{{ zitadel_domain }}/debug/ready" - method: GET - status_code: 200 - register: zitadel_health - until: zitadel_health.status == 200 - retries: 30 - delay: 10 - -- name: Check if bootstrap already completed - stat: - path: /opt/docker/zitadel/.bootstrap_complete - register: bootstrap_flag - -- name: Create machine user for automation - when: not bootstrap_flag.stat.exists - block: - - name: Authenticate as admin - uri: - url: "https://{{ zitadel_domain }}/oauth/v2/token" - method: POST - body_format: form-urlencoded - body: - grant_type: password - client_id: "{{ zitadel_console_client_id }}" - username: "{{ zitadel_admin_username }}" - password: "{{ zitadel_admin_password }}" - scope: "openid profile urn:zitadel:iam:org:project:id:zitadel:aud" - status_code: 200 - register: admin_token - no_log: true - - - name: Create machine user - uri: - url: "https://{{ zitadel_domain }}/management/v1/users/machine" - method: POST - headers: - Authorization: "Bearer {{ admin_token.json.access_token }}" - Content-Type: application/json - body_format: json - body: - userName: "automation" - name: "Automation Service Account" - description: "Used by Ansible for provisioning" - status_code: [200, 201] - register: machine_user - - # Additional bootstrap tasks... - - - name: Mark bootstrap as complete - file: - path: /opt/docker/zitadel/.bootstrap_complete - state: touch -``` - -### OIDC Application Creation - -```yaml -# tasks/oidc-apps.yml ---- -- name: Create OIDC application for Nextcloud - uri: - url: "https://{{ zitadel_domain }}/management/v1/projects/{{ project_id }}/apps/oidc" - method: POST - headers: - Authorization: "Bearer {{ api_token }}" - Content-Type: application/json - body_format: json - body: - name: "Nextcloud" - redirectUris: - - "https://{{ nextcloud_domain }}/apps/user_oidc/code" - responseTypes: - - "OIDC_RESPONSE_TYPE_CODE" - grantTypes: - - "OIDC_GRANT_TYPE_AUTHORIZATION_CODE" - - "OIDC_GRANT_TYPE_REFRESH_TOKEN" - appType: "OIDC_APP_TYPE_WEB" - authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC" - postLogoutRedirectUris: - - "https://{{ nextcloud_domain }}/" - devMode: false - status_code: [200, 201] - register: nextcloud_oidc_app - -- name: Store OIDC credentials for Nextcloud - set_fact: - nextcloud_oidc_client_id: "{{ nextcloud_oidc_app.json.clientId }}" - nextcloud_oidc_client_secret: "{{ nextcloud_oidc_app.json.clientSecret }}" -``` - -## Default Variables - -```yaml -# defaults/main.yml ---- -# Zitadel version (pin explicitly) -zitadel_version: "v3.0.0" - -# PostgreSQL version -postgres_version: "16" - -# Admin user (username, password from secrets) -zitadel_admin_username: "admin" - -# OIDC configuration -zitadel_oidc_token_lifetime: "12h" -zitadel_oidc_refresh_lifetime: "720h" - -# Resource limits -zitadel_memory_limit: "512M" -zitadel_cpu_limit: "1.0" -``` - -## Security Considerations - -1. **Masterkey**: 32-byte random key, stored in SOPS, never logged -2. **Admin password**: Generated per-client, minimum 24 characters -3. **Database password**: Generated per-client, stored in SOPS -4. **API tokens**: Short-lived, scoped to minimum required permissions -5. **External access**: Always via Traefik with TLS, never direct - -## OIDC Endpoints Reference - -For configuring client applications: - -```yaml -# Variables to provide to other apps -zitadel_issuer: "https://{{ zitadel_domain }}" -zitadel_authorization_endpoint: "https://{{ zitadel_domain }}/oauth/v2/authorize" -zitadel_token_endpoint: "https://{{ zitadel_domain }}/oauth/v2/token" -zitadel_userinfo_endpoint: "https://{{ zitadel_domain }}/oidc/v1/userinfo" -zitadel_jwks_uri: "https://{{ zitadel_domain }}/oauth/v2/keys" -zitadel_logout_endpoint: "https://{{ zitadel_domain }}/oidc/v1/end_session" -``` - -## Example Interactions - -**Good prompt:** "Create the Ansible tasks to bootstrap Zitadel with an admin user and create an OIDC app for Nextcloud" -**Response approach:** Create idempotent tasks using Zitadel API, with proper error handling and credential storage. - -**Good prompt:** "How should we configure Zitadel token lifetimes for security?" -**Response approach:** Recommend secure defaults (short access tokens, longer refresh tokens), explain trade-offs. - -**Redirect prompt:** "How do I configure Nextcloud to use the OIDC credentials?" -**Response:** "Nextcloud OIDC configuration is handled by the Nextcloud Agent. I'll provide the following variables that Nextcloud needs: `zitadel_issuer`, `nextcloud_oidc_client_id`, `nextcloud_oidc_client_secret`. The Nextcloud Agent will configure the `user_oidc` app with these values." - -## Troubleshooting Knowledge - -### Common Issues - -1. **Zitadel won't start**: Check database connectivity, masterkey format -2. **OIDC redirect fails**: Verify redirect URIs match exactly (trailing slashes!) -3. **Token validation fails**: Check clock sync, external domain configuration -4. **gRPC errors**: Ensure Traefik h2c configuration is correct - -### Health Check - -```bash -# Verify Zitadel is healthy -curl -s https://auth.example.com/debug/ready - -# Check OIDC configuration -curl -s https://auth.example.com/.well-known/openid-configuration | jq -``` \ No newline at end of file diff --git a/PROJECT_REFERENCE.md b/PROJECT_REFERENCE.md index d7d8323..045b689 100644 --- a/PROJECT_REFERENCE.md +++ b/PROJECT_REFERENCE.md @@ -11,7 +11,7 @@ infrastructure/ │ ├── playbooks/ # Main playbooks │ │ ├── deploy.yml # Deploy applications to clients │ │ └── setup.yml # Setup base server infrastructure -│ └── roles/ # Ansible roles (traefik, zitadel, nextcloud, etc.) +│ └── roles/ # Ansible roles (traefik, nextcloud, etc.) ├── keys/ │ └── age-key.txt # SOPS encryption key (gitignored) ├── secrets/ @@ -45,7 +45,6 @@ export HCLOUD_TOKEN="MlURmliUzLcGyzCWXWWsZt3DeWxKcQH9ZMGiaaNrFM3VcgnASlEWKhhxLHd ### Client: test - **Hostname**: test (from Hetzner Cloud) -- **Zitadel**: https://zitadel.test.vrije.cloud - **Nextcloud**: https://nextcloud.test.vrije.cloud - **Secrets**: `secrets/clients/test.sops.yaml` @@ -59,10 +58,6 @@ export SOPS_AGE_KEY_FILE="../keys/age-key.txt" # Deploy everything to test client ~/.local/bin/ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit test - -# Force recreate Zitadel (clean database) -~/.local/bin/ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit test \ - --extra-vars "zitadel_force_recreate=true" ``` ### Check Service Status @@ -73,7 +68,7 @@ export HCLOUD_TOKEN="..." # Run ad-hoc commands ~/.local/bin/ansible test -i hcloud.yml -m shell -a "docker ps" -~/.local/bin/ansible test -i hcloud.yml -m shell -a "docker logs zitadel 2>&1 | tail -50" +~/.local/bin/ansible test -i hcloud.yml -m shell -a "docker logs nextcloud 2>&1 | tail -50" ``` ### Edit Secrets @@ -92,50 +87,19 @@ sops --decrypt secrets/clients/test.sops.yaml ### Service Stack - **Traefik**: Reverse proxy with automatic Let's Encrypt certificates -- **Zitadel v2.63.7**: Identity provider (OIDC/OAuth2) -- **PostgreSQL 16**: Database for Zitadel - **Nextcloud 30.0.17**: File sync and collaboration - **Redis**: Caching for Nextcloud +- **MariaDB**: Database for Nextcloud ### Docker Networks - `traefik`: External network for all web-accessible services -- `zitadel-internal`: Internal network for Zitadel ↔ PostgreSQL -- `nextcloud-internal`: Internal network for Nextcloud ↔ Redis +- `nextcloud-internal`: Internal network for Nextcloud ↔ Redis/DB ### Volumes -- `zitadel_zitadel-db-data`: PostgreSQL data -- `zitadel_zitadel-machinekey`: JWT keys for service accounts - `nextcloud_nextcloud-data`: Nextcloud files and database -## Known Issues - -### Zitadel FirstInstance Configuration Bug -**Issue**: ALL `ZITADEL_FIRSTINSTANCE_*` environment variables cause migration errors in v2.63.7: -``` -ERROR: duplicate key value violates unique constraint "unique_constraints_pkey" -Errors.Instance.Domain.AlreadyExists -``` - -**Root Cause**: Bug in Zitadel v2.63.7 FirstInstance migration logic -**Workaround**: Remove all FirstInstance variables; complete initial setup via web UI -**Upstream Issue**: https://github.com/zitadel/zitadel/issues/8791 -**Status**: Waiting for upstream fix - -### OIDC Automation -**Issue**: Automatic OIDC app provisioning requires manual one-time setup -**Workaround**: -1. Complete Zitadel web UI setup wizard (first access) -2. Create service user with JWT key via web UI -3. Store JWT key in secrets for automated provisioning - -**Status**: Manual one-time setup required per Zitadel instance - ## Service Credentials -### Zitadel Admin -- **URL**: https://zitadel.test.vrije.cloud -- **Setup**: Complete wizard on first visit (no predefined credentials) - ### Nextcloud Admin - **URL**: https://nextcloud.test.vrije.cloud - **Username**: admin diff --git a/README.md b/README.md index da35177..e869e5e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Post-X Society Multi-Tenant Infrastructure -Infrastructure as Code for a scalable multi-tenant VPS platform running Zitadel (identity provider) and Nextcloud (file sync/share) on Hetzner Cloud. +Infrastructure as Code for a scalable multi-tenant VPS platform running Nextcloud (file sync/share) on Hetzner Cloud. ## 🏗️ Architecture @@ -8,7 +8,6 @@ Infrastructure as Code for a scalable multi-tenant VPS platform running Zitadel - **Configuration**: Ansible with dynamic inventory - **Secrets**: SOPS + Age encryption - **Hosting**: Hetzner Cloud (EU-based, GDPR-compliant) -- **Identity**: Zitadel (Swiss company, AGPL 3.0) - **Storage**: Nextcloud (German company, AGPL 3.0) ## 📁 Repository Structure @@ -87,7 +86,6 @@ This project uses specialized AI agents for development: - **Architect**: High-level design decisions - **Infrastructure**: OpenTofu + Ansible implementation -- **Zitadel**: Identity provider configuration - **Nextcloud**: File sync/share configuration See individual agent files in `.claude/agents/` for responsibilities. @@ -107,5 +105,4 @@ TBD For issues or questions, please create a GitHub issue with the appropriate label: - `agent:architect` - Architecture/design questions - `agent:infrastructure` - IaC implementation -- `agent:zitadel` - Identity provider - `agent:nextcloud` - File sync/share diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml index 4da5c09..c2e6c24 100644 --- a/ansible/playbooks/deploy.yml +++ b/ansible/playbooks/deploy.yml @@ -1,6 +1,6 @@ --- # Deploy applications to client servers -# This playbook deploys Zitadel, Nextcloud, and other applications +# This playbook deploys Nextcloud and other applications - name: Deploy applications to client servers hosts: all @@ -21,18 +21,12 @@ age_key: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}" no_log: true - - name: Set Zitadel domain from secrets - set_fact: - zitadel_domain: "{{ client_secrets.zitadel_domain }}" - when: client_secrets.zitadel_domain is defined - - name: Set client domain from secrets set_fact: client_domain: "{{ client_secrets.client_domain }}" when: client_secrets.client_domain is defined roles: - - role: zitadel - role: nextcloud post_tasks: @@ -41,12 +35,4 @@ msg: | Deployment complete for client: {{ client_name }} - Zitadel: https://{{ zitadel_domain }} Nextcloud: https://nextcloud.{{ client_domain }} - - Next steps: - 1. Login to Zitadel with the admin credentials - 2. Create OIDC application in Zitadel for Nextcloud - - Client name: Nextcloud - - Redirect URI: https://nextcloud.{{ client_domain }}/apps/user_oidc/code - 3. Configure OIDC in Nextcloud using the client ID and secret from Zitadel diff --git a/ansible/roles/zitadel/defaults/main.yml b/ansible/roles/zitadel/defaults/main.yml deleted file mode 100644 index 00a652f..0000000 --- a/ansible/roles/zitadel/defaults/main.yml +++ /dev/null @@ -1,33 +0,0 @@ ---- -# Zitadel Default Variables - -# Zitadel version (pin explicitly) -zitadel_version: "v2.63.7" - -# PostgreSQL version for Zitadel database -postgres_version: "16-alpine" - -# Admin user (password from secrets) -zitadel_admin_username: "admin" - -# Console client ID (Zitadel's built-in admin console) -zitadel_console_client_id: "251896714278772225@ptt" - -# OIDC configuration -zitadel_oidc_token_lifetime: "12h" -zitadel_oidc_refresh_lifetime: "720h" - -# Resource limits -zitadel_memory_limit: "512M" -zitadel_cpu_limit: "1.0" - -# Database configuration -zitadel_db_user: "zitadel" -zitadel_db_name: "zitadel" - -# Network configuration -zitadel_network: "zitadel-internal" -zitadel_traefik_network: "traefik" - -# Directory for Zitadel configuration -zitadel_config_dir: "/opt/docker/zitadel" diff --git a/ansible/roles/zitadel/files/bootstrap_api_token.py b/ansible/roles/zitadel/files/bootstrap_api_token.py deleted file mode 100644 index 3c11cd7..0000000 --- a/ansible/roles/zitadel/files/bootstrap_api_token.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -""" -Automate creation of Zitadel API service user and Personal Access Token. -This script logs in as admin and creates a machine user with a PAT for API automation. -""" - -import requests -import sys -import time -import re -from urllib.parse import urlparse, parse_qs - -def login_and_get_session(domain, username, password): - """Login to Zitadel and get authenticated session cookies.""" - session = requests.Session() - session.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' - }) - - # Start login flow - login_url = f"https://{domain}/ui/login/loginname" - print(f"📡 Initiating login to {domain}...") - - # Get login page to establish session - resp = session.get(login_url, allow_redirects=True) - - # Submit username - login_data = { - 'loginName': username - } - resp = session.post(login_url, data=login_data, allow_redirects=True) - - # Submit password - password_url = f"https://{domain}/ui/login/password" - password_data = { - 'password': password - } - resp = session.post(password_url, data=password_data, allow_redirects=True) - - if 'set-cookie' in resp.headers or len(session.cookies) > 0: - print("✅ Login successful!") - return session - else: - print(f"❌ Login failed. Status: {resp.status_code}") - print(f"Response: {resp.text[:500]}") - return None - -def create_machine_user(session, domain): - """Create a machine user via Management API.""" - api_url = f"https://{domain}/management/v1/users/machine" - - print("🤖 Creating API automation service user...") - - payload = { - "userName": "api-automation", - "name": "API Automation Service", - "description": "Service account for automated OIDC app provisioning", - "accessTokenType": "ACCESS_TOKEN_TYPE_BEARER" - } - - resp = session.post(api_url, json=payload) - - if resp.status_code in [200, 201]: - data = resp.json() - user_id = data.get('userId') - print(f"✅ Machine user created: {user_id}") - return user_id - elif resp.status_code == 409: - print("ℹ️ Machine user already exists") - # Try to get existing user - list_url = f"https://{domain}/management/v1/users/_search" - search_payload = { - "query": { - "userName": "api-automation" - } - } - resp = session.post(list_url, json=search_payload) - if resp.status_code == 200: - users = resp.json().get('result', []) - if users: - user_id = users[0].get('id') - print(f"✅ Found existing user: {user_id}") - return user_id - return None - else: - print(f"❌ Failed to create machine user. Status: {resp.status_code}") - print(f"Response: {resp.text}") - return None - -def create_pat(session, domain, user_id): - """Create a Personal Access Token for the machine user.""" - pat_url = f"https://{domain}/management/v1/users/{user_id}/pats" - - print("🔑 Creating Personal Access Token...") - - payload = { - "expirationDate": "2099-12-31T23:59:59Z" - } - - resp = session.post(pat_url, json=payload) - - if resp.status_code in [200, 201]: - data = resp.json() - token = data.get('token') - if token: - print("✅ Personal Access Token created successfully!") - return token - else: - print("⚠️ PAT created but token not in response") - print(f"Response: {resp.text}") - return None - else: - print(f"❌ Failed to create PAT. Status: {resp.status_code}") - print(f"Response: {resp.text}") - return None - -def main(): - if len(sys.argv) != 4: - print("Usage: python3 bootstrap_api_token.py ") - print("Example: python3 bootstrap_api_token.py zitadel.test.vrije.cloud 'admin@test.zitadel.test.vrije.cloud' 'password123'") - sys.exit(1) - - domain = sys.argv[1] - username = sys.argv[2] - password = sys.argv[3] - - print(f""" -🚀 Zitadel API Token Bootstrap -================================ -Domain: {domain} -Admin: {username} -""") - - # Step 1: Login - session = login_and_get_session(domain, username, password) - if not session: - print("\n❌ Failed to establish session") - sys.exit(1) - - # Small delay to ensure session is established - time.sleep(2) - - # Step 2: Create machine user - user_id = create_machine_user(session, domain) - if not user_id: - print("\n❌ Failed to create or find machine user") - sys.exit(1) - - # Small delay - time.sleep(1) - - # Step 3: Create PAT - token = create_pat(session, domain, user_id) - if not token: - print("\n❌ Failed to create Personal Access Token") - sys.exit(1) - - print(f""" -✅ SUCCESS! API automation is ready. - -📋 Personal Access Token: -{token} - -🔐 Add this to your secrets file: -zitadel_api_token: {token} - -Then re-run: ansible-playbook -i hcloud.yml playbooks/deploy.yml -""") - -if __name__ == '__main__': - main() diff --git a/ansible/roles/zitadel/files/bootstrap_api_user.sh b/ansible/roles/zitadel/files/bootstrap_api_user.sh deleted file mode 100644 index 17a83c5..0000000 --- a/ansible/roles/zitadel/files/bootstrap_api_user.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash -# Bootstrap Zitadel API service user and generate PAT -# This script must be run once per client after initial Zitadel deployment -# It creates a machine user with a Personal Access Token for API automation - -set -e - -ZITADEL_DOMAIN="$1" -ADMIN_USERNAME="$2" -ADMIN_PASSWORD="$3" - -if [ -z "$ZITADEL_DOMAIN" ] || [ -z "$ADMIN_USERNAME" ] || [ -z "$ADMIN_PASSWORD" ]; then - echo "Usage: $0 " >&2 - echo "Example: $0 zitadel.test.vrije.cloud 'admin@test.zitadel.test.vrije.cloud' 'password123'" >&2 - exit 1 -fi - -echo "🔧 Bootstrapping Zitadel API automation..." -echo "Domain: $ZITADEL_DOMAIN" -echo "Admin: $ADMIN_USERNAME" -echo "" - -# This is a placeholder script that provides instructions for the manual one-time setup -# In a production environment, this would use Puppeteer/Selenium to automate the browser - -echo "⚠️ MANUAL SETUP REQUIRED (one time per client)" -echo "" -echo "Please follow these steps in your browser:" -echo "" -echo "1. Open: https://$ZITADEL_DOMAIN/ui/console" -echo "2. Login with:" -echo " Username: $ADMIN_USERNAME" -echo " Password: $ADMIN_PASSWORD" -echo "" -echo "3. Navigate to: Users → Service Users" -echo "4. Click 'New'" -echo "5. Enter:" -echo " Username: api-automation" -echo " Name: API Automation Service" -echo "6. Click 'Create'" -echo "" -echo "7. Click on the new user 'api-automation'" -echo "8. Go to 'Personal Access Tokens' tab" -echo "9. Click 'New'" -echo "10. Set expiration date: 2099-12-31 (or far future)" -echo "11. Click 'Add'" -echo "12. COPY THE TOKEN (it will only be shown once!)" -echo "" -echo "13. Add the token to your secrets file:" -echo " zitadel_api_token: " -echo "" -echo "14. Re-run the deployment: ansible-playbook -i hcloud.yml playbooks/deploy.yml" -echo "" -echo "After this one-time setup, all OIDC apps will be created automatically!" -echo "" - -# TODO: Implement browser automation using Puppeteer or Selenium -# For now, this provides clear instructions for the manual process diff --git a/ansible/roles/zitadel/files/create_machine_user.py b/ansible/roles/zitadel/files/create_machine_user.py deleted file mode 100644 index 6e9a811..0000000 --- a/ansible/roles/zitadel/files/create_machine_user.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env python3 -""" -Create a machine user in Zitadel using admin credentials. -This script creates a service account with a JWT key for API automation. -""" - -import json -import sys -import requests -from urllib.parse import urlencode - -def get_admin_token(domain, username, password): - """Get access token using admin username/password.""" - token_url = f"https://{domain}/oauth/v2/token" - - data = { - "grant_type": "password", - "username": username, - "password": password, - "scope": "openid profile email urn:zitadel:iam:org:project:id:zitadel:aud", - } - - response = requests.post(token_url, data=data) - if response.status_code == 200: - return response.json().get("access_token") - else: - raise Exception(f"Failed to get admin token: {response.status_code} - {response.text}") - -def create_machine_user(domain, access_token, username, name): - """Create a machine user.""" - url = f"https://{domain}/management/v1/users/machine" - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - } - payload = { - "userName": username, - "name": name, - "description": "Service account for automated API operations", - "accessTokenType": "ACCESS_TOKEN_TYPE_JWT", - } - - response = requests.post(url, headers=headers, json=payload) - - if response.status_code in [200, 201]: - return response.json().get("userId") - elif response.status_code == 409: - # User already exists, get the user ID - return find_machine_user(domain, access_token, username) - else: - raise Exception(f"Failed to create machine user: {response.status_code} - {response.text}") - -def find_machine_user(domain, access_token, username): - """Find existing machine user by username.""" - url = f"https://{domain}/management/v1/users/_search" - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - } - payload = { - "queries": [ - { - "userNameQuery": { - "userName": username, - "method": "TEXT_QUERY_METHOD_EQUALS" - } - } - ] - } - - response = requests.post(url, headers=headers, json=payload) - - if response.status_code == 200: - result = response.json().get("result", []) - if result: - return result[0].get("id") - return None - -def create_machine_key(domain, access_token, user_id): - """Create a JWT key for the machine user.""" - url = f"https://{domain}/management/v1/users/{user_id}/keys" - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - } - payload = { - "type": "KEY_TYPE_JSON", - "expirationDate": "2030-01-01T00:00:00Z", - } - - response = requests.post(url, headers=headers, json=payload) - - if response.status_code in [200, 201]: - return response.json() - else: - raise Exception(f"Failed to create machine key: {response.status_code} - {response.text}") - -def main(): - if len(sys.argv) != 4: - print("Usage: create_machine_user.py ") - sys.exit(1) - - domain = sys.argv[1] - admin_username = sys.argv[2] - admin_password = sys.argv[3] - - try: - # Get admin access token - print(f"Authenticating as admin...", file=sys.stderr) - access_token = get_admin_token(domain, admin_username, admin_password) - - # Create machine user - print(f"Creating machine user 'api-automation'...", file=sys.stderr) - user_id = create_machine_user(domain, access_token, "api-automation", "API Automation Service") - print(f"Machine user ID: {user_id}", file=sys.stderr) - - # Create JWT key - print(f"Creating JWT key...", file=sys.stderr) - key_data = create_machine_key(domain, access_token, user_id) - - # Output the key as JSON - print(json.dumps(key_data, indent=2)) - - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/ansible/roles/zitadel/files/create_oidc_app.py b/ansible/roles/zitadel/files/create_oidc_app.py deleted file mode 100644 index bb390ad..0000000 --- a/ansible/roles/zitadel/files/create_oidc_app.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python3 -""" -Create OIDC application in Zitadel using the Management API. - -This script automates the creation of OIDC applications for services like Nextcloud. -It uses Zitadel's REST API with service account authentication. -""" - -import json -import sys -import requests -from typing import Dict, Optional - - -class ZitadelOIDCManager: - """Manage OIDC applications in Zitadel.""" - - def __init__(self, domain: str, pat_token: str): - """Initialize the OIDC manager.""" - self.domain = domain - self.base_url = f"https://{domain}" - self.headers = { - "Authorization": f"Bearer {pat_token}", - "Content-Type": "application/json", - } - - def create_project(self, project_name: str) -> Optional[str]: - """Create a project.""" - url = f"{self.base_url}/management/v1/projects" - payload = { - "name": project_name - } - response = requests.post(url, headers=self.headers, json=payload) - - if response.status_code in [200, 201]: - return response.json().get("id") - return None - - def get_or_create_project(self, project_name: str = "SSO Applications") -> Optional[str]: - """Get existing project or create new one.""" - # Try to list projects - url = f"{self.base_url}/management/v1/projects/_search" - response = requests.post(url, headers=self.headers, json={}) - - if response.status_code == 200: - projects = response.json().get("result", []) - for project in projects: - if project.get("name") == project_name: - return project["id"] - # If no matching project, use first one if exists - if projects: - return projects[0]["id"] - - # No project found, try to create one - project_id = self.create_project(project_name) - return project_id - - def check_app_exists(self, project_id: str, app_name: str) -> Optional[Dict]: - """Check if an OIDC app already exists.""" - url = f"{self.base_url}/management/v1/projects/{project_id}/apps/_search" - response = requests.post(url, headers=self.headers, json={}) - - if response.status_code == 200: - apps = response.json().get("result", []) - for app in apps: - if app.get("name") == app_name: - return app - return None - - def create_oidc_app( - self, - project_id: str, - app_name: str, - redirect_uris: list, - post_logout_redirect_uris: list = None, - ) -> Dict: - """Create an OIDC application.""" - url = f"{self.base_url}/management/v1/projects/{project_id}/apps/oidc" - - payload = { - "name": app_name, - "redirectUris": redirect_uris, - "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], - "grantTypes": [ - "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", - "OIDC_GRANT_TYPE_REFRESH_TOKEN", - ], - "appType": "OIDC_APP_TYPE_WEB", - "authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC", - "postLogoutRedirectUris": post_logout_redirect_uris or [], - "version": "OIDC_VERSION_1_0", - "devMode": False, - "accessTokenType": "OIDC_TOKEN_TYPE_BEARER", - "accessTokenRoleAssertion": True, - "idTokenRoleAssertion": True, - "idTokenUserinfoAssertion": True, - "clockSkew": "0s", - } - - response = requests.post(url, headers=self.headers, json=payload) - - if response.status_code in [200, 201]: - return response.json() - else: - raise Exception( - f"Failed to create OIDC app: {response.status_code} - {response.text}" - ) - - -def main(): - """Main entry point.""" - if len(sys.argv) < 5: - print("Usage: create_oidc_app.py ") - sys.exit(1) - - domain = sys.argv[1] - pat_token = sys.argv[2] - app_name = sys.argv[3] - redirect_uri = sys.argv[4] - - try: - manager = ZitadelOIDCManager(domain, pat_token) - - # Get or create project - project_id = manager.get_or_create_project("SSO Applications") - if not project_id: - print("Error: Failed to get or create project", file=sys.stderr) - sys.exit(1) - - # Check if app already exists - existing_app = manager.check_app_exists(project_id, app_name) - if existing_app: - print( - json.dumps( - { - "status": "exists", - "app_id": existing_app.get("id"), - "message": f"App '{app_name}' already exists", - } - ) - ) - sys.exit(0) - - # Create new app - result = manager.create_oidc_app( - project_id=project_id, - app_name=app_name, - redirect_uris=[redirect_uri], - post_logout_redirect_uris=[redirect_uri.rsplit("/", 1)[0] + "/"], - ) - - # Extract client credentials - output = { - "status": "created", - "app_id": result.get("appId"), - "client_id": result.get("clientId"), - "client_secret": result.get("clientSecret"), - "redirect_uri": redirect_uri, - } - - print(json.dumps(output)) - - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/ansible/roles/zitadel/files/get_admin_token.sh b/ansible/roles/zitadel/files/get_admin_token.sh deleted file mode 100644 index 54e2d6c..0000000 --- a/ansible/roles/zitadel/files/get_admin_token.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# Get admin access token from Zitadel using username/password authentication -# This is used for initial OIDC app provisioning automation - -set -e - -DOMAIN="$1" -USERNAME="$2" -PASSWORD="$3" - -if [ -z "$DOMAIN" ] || [ -z "$USERNAME" ] || [ -z "$PASSWORD" ]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -# Get OAuth token using Resource Owner Password Credentials flow -# Note: This is only for admin automation, not recommended for production apps -RESPONSE=$(curl -s -X POST "https://${DOMAIN}/oauth/v2/token" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=password" \ - -d "scope=openid profile email urn:zitadel:iam:org:project:id:zitadel:aud" \ - -d "username=${USERNAME}" \ - -d "password=${PASSWORD}") - -# Extract access token -ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('access_token', ''))") - -if [ -z "$ACCESS_TOKEN" ]; then - echo "Error: Failed to get access token" >&2 - echo "$RESPONSE" >&2 - exit 1 -fi - -echo "$ACCESS_TOKEN" diff --git a/ansible/roles/zitadel/files/setup_automation.py b/ansible/roles/zitadel/files/setup_automation.py deleted file mode 100644 index d16922d..0000000 --- a/ansible/roles/zitadel/files/setup_automation.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -""" -One-time setup script to configure Zitadel for full OIDC automation. -This script uses the manually created PAT to set up everything needed. -""" - -import json -import sys -import requests -from typing import Optional - - -class ZitadelSetup: - """Setup Zitadel for OIDC automation.""" - - def __init__(self, domain: str, pat_token: str): - self.domain = domain - self.base_url = f"https://{domain}" - self.headers = { - "Authorization": f"Bearer {pat_token}", - "Content-Type": "application/json", - } - - def create_project(self, name: str) -> Optional[str]: - """Create a project for OIDC applications.""" - url = f"{self.base_url}/management/v1/projects" - payload = {"name": name} - - print(f"📦 Creating project '{name}'...") - response = requests.post(url, headers=self.headers, json=payload) - - if response.status_code in [200, 201]: - project_id = response.json().get("id") - print(f"✅ Project created: {project_id}") - return project_id - elif response.status_code == 409: - print(f"ℹ️ Project already exists, searching...") - return self.find_project(name) - else: - print(f"❌ Failed to create project: {response.status_code}") - print(f"Response: {response.text}") - return None - - def find_project(self, name: str) -> Optional[str]: - """Find existing project by name.""" - url = f"{self.base_url}/management/v1/projects/_search" - response = requests.post(url, headers=self.headers, json={}) - - if response.status_code == 200: - projects = response.json().get("result", []) - for project in projects: - if project.get("name") == name: - print(f"✅ Found existing project: {project['id']}") - return project["id"] - return None - - def get_service_user_id(self, username: str) -> Optional[str]: - """Find the service user ID.""" - url = f"{self.base_url}/management/v1/users/_search" - payload = { - "query": { - "userName": username - } - } - - print(f"🔍 Looking for service user '{username}'...") - response = requests.post(url, headers=self.headers, json=payload) - - if response.status_code == 200: - users = response.json().get("result", []) - if users: - user_id = users[0].get("id") - print(f"✅ Found service user: {user_id}") - return user_id - - print(f"❌ Service user not found") - return None - - def grant_project_permission(self, project_id: str, user_id: str) -> bool: - """Grant project ownership to service user.""" - url = f"{self.base_url}/management/v1/projects/{project_id}/roles/_bulk/set" - - # Grant PROJECT_OWNER role - payload = { - "grants": [ - { - "userId": user_id, - "roleKeys": ["PROJECT_OWNER"] - } - ] - } - - print(f"🔐 Granting PROJECT_OWNER permission...") - response = requests.post(url, headers=self.headers, json=payload) - - if response.status_code in [200, 201]: - print(f"✅ Permission granted") - return True - else: - print(f"⚠️ Permission grant: {response.status_code}") - print(f"Response: {response.text}") - # This might fail if already granted, which is OK - return True - - -def main(): - if len(sys.argv) < 3: - print("Usage: setup_automation.py ") - sys.exit(1) - - domain = sys.argv[1] - pat_token = sys.argv[2] - - print(f""" -🚀 Zitadel OIDC Automation Setup -================================= -Domain: {domain} - -This script will: -1. Create 'SSO Applications' project -2. Grant api-automation user PROJECT_OWNER permission -3. Enable full OIDC automation - -""") - - try: - setup = ZitadelSetup(domain, pat_token) - - # Step 1: Create or find project - project_id = setup.create_project("SSO Applications") - if not project_id: - print("\n❌ Failed to create/find project") - sys.exit(1) - - # Step 2: Find service user - user_id = setup.get_service_user_id("api-automation") - if not user_id: - print("\n❌ Service user 'api-automation' not found") - print("Please create the machine user first via Zitadel console") - sys.exit(1) - - # Step 3: Grant permissions - if not setup.grant_project_permission(project_id, user_id): - print("\n⚠️ Warning: Could not grant permissions (may already exist)") - - print(f""" -✅ SUCCESS! OIDC automation is now fully configured. - -Next steps: -- Run deployment: ansible-playbook -i hcloud.yml playbooks/deploy.yml -- All OIDC apps will be created automatically -- No more manual steps required! - -Project ID: {project_id} -Service User: api-automation ({user_id}) -""") - - except Exception as e: - print(f"\n❌ Error: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/ansible/roles/zitadel/files/zitadel_api.py b/ansible/roles/zitadel/files/zitadel_api.py deleted file mode 100644 index 37e4646..0000000 --- a/ansible/roles/zitadel/files/zitadel_api.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -""" -Zitadel API client using JWT authentication. -Fully automated OIDC app provisioning. -""" - -import json -import sys -import time -import requests -import jwt -from typing import Dict, Optional - - -class ZitadelAPI: - """Zitadel API client with JWT authentication.""" - - def __init__(self, domain: str, jwt_key_path: str): - """Initialize with JWT key file.""" - self.domain = domain - self.base_url = f"https://{domain}" - - # Load JWT key - with open(jwt_key_path, 'r') as f: - self.jwt_key = json.load(f) - - self.user_id = self.jwt_key.get("userId") - self.key_id = self.jwt_key.get("keyId") - self.private_key = self.jwt_key.get("key") - - def get_access_token(self) -> str: - """Get access token using JWT assertion.""" - # Create JWT assertion - now = int(time.time()) - payload = { - "iss": self.user_id, - "sub": self.user_id, - "aud": self.domain, - "iat": now, - "exp": now + 3600, - } - - assertion = jwt.encode( - payload, - self.private_key, - algorithm="RS256", - headers={"kid": self.key_id} - ) - - # Exchange JWT for access token - token_url = f"{self.base_url}/oauth/v2/token" - data = { - "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", - "assertion": assertion, - "scope": "openid profile email urn:zitadel:iam:org:project:id:zitadel:aud", - } - - response = requests.post(token_url, data=data) - if response.status_code == 200: - return response.json().get("access_token") - else: - raise Exception(f"Failed to get access token: {response.status_code} - {response.text}") - - def create_project(self, access_token: str, name: str) -> Optional[str]: - """Create a project.""" - url = f"{self.base_url}/management/v1/projects" - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - } - payload = {"name": name} - - response = requests.post(url, headers=headers, json=payload) - - if response.status_code in [200, 201]: - return response.json().get("id") - elif response.status_code == 409: - # Already exists, find it - return self.find_project(access_token, name) - else: - raise Exception(f"Failed to create project: {response.status_code} - {response.text}") - - def find_project(self, access_token: str, name: str) -> Optional[str]: - """Find existing project by name.""" - url = f"{self.base_url}/management/v1/projects/_search" - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - } - - response = requests.post(url, headers=headers, json={}) - - if response.status_code == 200: - projects = response.json().get("result", []) - for project in projects: - if project.get("name") == name: - return project["id"] - return None - - def create_oidc_app( - self, - access_token: str, - project_id: str, - app_name: str, - redirect_uri: str, - ) -> Dict: - """Create OIDC application.""" - url = f"{self.base_url}/management/v1/projects/{project_id}/apps/oidc" - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - } - - payload = { - "name": app_name, - "redirectUris": [redirect_uri], - "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], - "grantTypes": [ - "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", - "OIDC_GRANT_TYPE_REFRESH_TOKEN", - ], - "appType": "OIDC_APP_TYPE_WEB", - "authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC", - "postLogoutRedirectUris": [redirect_uri.rsplit("/", 1)[0] + "/"], - "version": "OIDC_VERSION_1_0", - "devMode": False, - "accessTokenType": "OIDC_TOKEN_TYPE_BEARER", - "accessTokenRoleAssertion": True, - "idTokenRoleAssertion": True, - "idTokenUserinfoAssertion": True, - "clockSkew": "0s", - } - - response = requests.post(url, headers=headers, json=payload) - - if response.status_code in [200, 201]: - return response.json() - else: - raise Exception(f"Failed to create OIDC app: {response.status_code} - {response.text}") - - -def main(): - """Main entry point.""" - if len(sys.argv) < 5: - print("Usage: zitadel_api.py ") - sys.exit(1) - - domain = sys.argv[1] - jwt_key_path = sys.argv[2] - app_name = sys.argv[3] - redirect_uri = sys.argv[4] - - try: - api = ZitadelAPI(domain, jwt_key_path) - - # Get access token - access_token = api.get_access_token() - - # Get or create project - project_id = api.create_project(access_token, "SSO Applications") - - # Create OIDC app - result = api.create_oidc_app(access_token, project_id, app_name, redirect_uri) - - # Output credentials - output = { - "status": "created", - "app_id": result.get("appId"), - "client_id": result.get("clientId"), - "client_secret": result.get("clientSecret"), - "redirect_uri": redirect_uri, - } - - print(json.dumps(output)) - - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/ansible/roles/zitadel/handlers/main.yml b/ansible/roles/zitadel/handlers/main.yml deleted file mode 100644 index aef7e07..0000000 --- a/ansible/roles/zitadel/handlers/main.yml +++ /dev/null @@ -1,9 +0,0 @@ ---- -# Handlers for Zitadel role - -- name: Restart Zitadel - community.docker.docker_compose_v2: - project_src: "{{ zitadel_config_dir }}" - services: - - zitadel - state: restarted diff --git a/ansible/roles/zitadel/tasks/bootstrap.yml b/ansible/roles/zitadel/tasks/bootstrap.yml deleted file mode 100644 index 6117466..0000000 --- a/ansible/roles/zitadel/tasks/bootstrap.yml +++ /dev/null @@ -1,55 +0,0 @@ ---- -# Bootstrap tasks for initial Zitadel configuration - -- name: Check if bootstrap already completed - stat: - path: "{{ zitadel_config_dir }}/.bootstrap_complete" - register: bootstrap_flag - -- name: Bootstrap Zitadel instance - when: not bootstrap_flag.stat.exists - block: - - name: Wait for Zitadel to be fully ready - uri: - url: "https://{{ zitadel_domain }}/debug/ready" - validate_certs: yes - status_code: 200 - register: zitadel_ready - until: zitadel_ready.status == 200 - retries: 30 - delay: 10 - - - name: Display bootstrap instructions - debug: - msg: | - ======================================== - Zitadel is running! - ======================================== - - URL: https://{{ zitadel_domain }} - - ⚠️ FIRST-TIME SETUP REQUIRED - - Due to migration bugs in Zitadel v2.63.7, FirstInstance environment - variables cannot be used. You must complete initial setup via web UI. - - Visit: https://{{ zitadel_domain }} - - Next steps: - 1. Complete web UI setup wizard (create admin account) - 2. Create a service user for API automation - 3. Generate JWT key for the service user - 4. Store JWT key for automated OIDC app provisioning - - See: https://github.com/zitadel/zitadel/issues/8791 - - - name: Mark bootstrap as complete - file: - path: "{{ zitadel_config_dir }}/.bootstrap_complete" - state: touch - mode: '0600' - -- name: Bootstrap already completed - debug: - msg: "Zitadel bootstrap already completed, skipping initialization" - when: bootstrap_flag.stat.exists diff --git a/ansible/roles/zitadel/tasks/clean.yml b/ansible/roles/zitadel/tasks/clean.yml deleted file mode 100644 index 11522ea..0000000 --- a/ansible/roles/zitadel/tasks/clean.yml +++ /dev/null @@ -1,17 +0,0 @@ ---- -# Clean Zitadel database and volumes - -- name: Stop Zitadel containers with volumes - shell: | - cd {{ zitadel_config_dir }} && docker compose down -v - ignore_errors: yes - changed_when: false - -- name: Remove bootstrap marker - file: - path: "{{ zitadel_config_dir }}/.bootstrap_complete" - state: absent - -- name: Wait for cleanup - pause: - seconds: 5 diff --git a/ansible/roles/zitadel/tasks/docker.yml b/ansible/roles/zitadel/tasks/docker.yml deleted file mode 100644 index c2c435f..0000000 --- a/ansible/roles/zitadel/tasks/docker.yml +++ /dev/null @@ -1,53 +0,0 @@ ---- -# Docker Compose setup for Zitadel - -- name: Include clean tasks when force recreate is requested - include_tasks: clean.yml - when: zitadel_force_recreate | default(false) | bool - -- name: Create Zitadel configuration directory - file: - path: "{{ zitadel_config_dir }}" - state: directory - mode: '0755' - -- name: Create Zitadel internal network - community.docker.docker_network: - name: "{{ zitadel_network }}" - driver: bridge - internal: true - -- name: Deploy Zitadel Docker Compose configuration - template: - src: docker-compose.zitadel.yml.j2 - dest: "{{ zitadel_config_dir }}/docker-compose.yml" - mode: '0600' - notify: Restart Zitadel - -- name: Start Zitadel services - community.docker.docker_compose_v2: - project_src: "{{ zitadel_config_dir }}" - state: present - register: zitadel_deploy - -- name: Wait for Zitadel database to be ready - community.docker.docker_container_exec: - container: zitadel-db - command: pg_isready -U {{ zitadel_db_user }} -d {{ zitadel_db_name }} - register: db_ready - until: db_ready.rc == 0 - retries: 30 - delay: 2 - changed_when: false - -- name: Wait for Zitadel to be healthy - uri: - url: "https://{{ zitadel_domain }}/debug/ready" - method: GET - status_code: 200 - validate_certs: yes - register: zitadel_health - until: zitadel_health.status == 200 - retries: 30 - delay: 10 - changed_when: false diff --git a/ansible/roles/zitadel/tasks/main.yml b/ansible/roles/zitadel/tasks/main.yml deleted file mode 100644 index 8b382da..0000000 --- a/ansible/roles/zitadel/tasks/main.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -# Main tasks file for Zitadel role - -- name: Include Docker Compose setup - include_tasks: docker.yml - -- name: Include bootstrap setup - include_tasks: bootstrap.yml - when: zitadel_bootstrap | default(true) - -- name: Include OIDC applications setup - include_tasks: oidc-apps.yml - when: zitadel_create_oidc_apps | default(true) diff --git a/ansible/roles/zitadel/tasks/oidc-apps.yml b/ansible/roles/zitadel/tasks/oidc-apps.yml deleted file mode 100644 index 7a26fdc..0000000 --- a/ansible/roles/zitadel/tasks/oidc-apps.yml +++ /dev/null @@ -1,107 +0,0 @@ ---- -# OIDC Application creation tasks via Zitadel API -# Fully automated OIDC app provisioning for Nextcloud and other services - -- name: Create Zitadel scripts directory - file: - path: /opt/zitadel - state: directory - mode: '0755' - -- name: Copy OIDC automation scripts to server - copy: - src: "{{ item }}" - dest: "/opt/zitadel/{{ item }}" - mode: '0755' - loop: - - zitadel_api.py - -- name: Install Python libraries for OIDC automation - package: - name: - - python3-requests - - python3-jwt - state: present - become: yes - -- name: Check if JWT key file exists - shell: docker exec zitadel ls /machinekey/api-automation.json - register: jwt_key_check - failed_when: false - changed_when: false - -- name: Set JWT authentication available - set_fact: - jwt_auth_available: "{{ jwt_key_check.rc == 0 }}" - -- name: Copy JWT key from container to host - shell: docker cp zitadel:/machinekey/api-automation.json /tmp/api-automation.json - when: jwt_auth_available - changed_when: false - -- name: Create OIDC application for Nextcloud using JWT auth - shell: | - python3 /opt/zitadel/zitadel_api.py \ - "{{ zitadel_domain }}" \ - "/tmp/api-automation.json" \ - "Nextcloud" \ - "https://nextcloud.{{ client_domain }}/apps/user_oidc/code" - register: oidc_app_result - when: jwt_auth_available - changed_when: "'created' in oidc_app_result.stdout" - failed_when: oidc_app_result.rc != 0 - -- name: Clean up temporary JWT key file - file: - path: /tmp/api-automation.json - state: absent - when: jwt_auth_available - -- name: Parse OIDC app creation result - set_fact: - oidc_app_data: "{{ oidc_app_result.stdout | from_json }}" - when: jwt_auth_available and oidc_app_result is defined - -- name: Display OIDC app status - debug: - msg: | - ✅ Nextcloud OIDC Application: {{ oidc_app_data.status }} - Client ID: {{ oidc_app_data.client_id | default('N/A') }} - Redirect URI: {{ oidc_app_data.redirect_uri | default('N/A') }} - when: jwt_auth_available and oidc_app_data is defined - -- name: Save OIDC credentials for Nextcloud configuration - set_fact: - nextcloud_oidc_client_id: "{{ oidc_app_data.client_id }}" - nextcloud_oidc_client_secret: "{{ oidc_app_data.client_secret }}" - when: jwt_auth_available and oidc_app_data is defined and oidc_app_data.status == 'created' - no_log: true - -- name: Configure OIDC provider in Nextcloud - shell: | - docker exec -u www-data nextcloud php occ user_oidc:provider:add \ - --clientid="{{ nextcloud_oidc_client_id }}" \ - --clientsecret="{{ nextcloud_oidc_client_secret }}" \ - --discoveryuri="https://{{ zitadel_domain }}/.well-known/openid-configuration" \ - "Zitadel" || true - when: jwt_auth_available and nextcloud_oidc_client_id is defined and nextcloud_oidc_client_secret is defined - register: oidc_config_result - changed_when: "'Provider Zitadel has been created' in oidc_config_result.stdout" - -- name: Display OIDC configuration result - debug: - msg: | - ✅ Nextcloud OIDC Provider Configuration: {{ 'Success' if oidc_config_result.changed else 'Already configured' }} - - Users can now login to Nextcloud using Zitadel SSO! - Visit: https://nextcloud.{{ client_domain }} - when: jwt_auth_available and oidc_config_result is defined - -- name: OIDC automation not available - debug: - msg: | - ⚠️ OIDC automation not available - JWT key not found. - - This should not happen if FirstInstance completed successfully. - Check Zitadel logs: docker logs zitadel - when: not jwt_auth_available diff --git a/ansible/roles/zitadel/templates/docker-compose.zitadel.yml.j2 b/ansible/roles/zitadel/templates/docker-compose.zitadel.yml.j2 deleted file mode 100644 index e656cc8..0000000 --- a/ansible/roles/zitadel/templates/docker-compose.zitadel.yml.j2 +++ /dev/null @@ -1,101 +0,0 @@ -services: - zitadel: - image: ghcr.io/zitadel/zitadel:{{ zitadel_version }} - container_name: zitadel - restart: unless-stopped - command: start-from-init --masterkeyFromEnv --tlsMode external - environment: - # Masterkey for encryption - ZITADEL_MASTERKEY: "{{ client_secrets.zitadel_masterkey }}" - - # Database configuration - ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db - ZITADEL_DATABASE_POSTGRES_PORT: 5432 - ZITADEL_DATABASE_POSTGRES_DATABASE: "{{ zitadel_db_name }}" - ZITADEL_DATABASE_POSTGRES_USER_USERNAME: "{{ zitadel_db_user }}" - ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: "{{ client_secrets.zitadel_db_password }}" - ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: "{{ zitadel_db_user }}" - ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: "{{ client_secrets.zitadel_db_password }}" - ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable - - # External domain configuration - ZITADEL_EXTERNALSECURE: "true" - ZITADEL_EXTERNALDOMAIN: "{{ zitadel_domain }}" - ZITADEL_EXTERNALPORT: 443 - - # FirstInstance variables removed due to migration bugs in v2.63.7 - # See: https://github.com/zitadel/zitadel/issues/8791 - # Initial setup will be done via web UI on first access - - volumes: - - zitadel-machinekey:/machinekey - - networks: - - {{ zitadel_traefik_network }} - - {{ zitadel_network }} - - depends_on: - zitadel-db: - condition: service_healthy - - labels: - - "traefik.enable=true" - - "traefik.http.routers.zitadel.rule=Host(`{{ zitadel_domain }}`)" - - "traefik.http.routers.zitadel.tls=true" - - "traefik.http.routers.zitadel.tls.certresolver=letsencrypt" - - "traefik.http.routers.zitadel.entrypoints=websecure" - - "traefik.http.services.zitadel.loadbalancer.server.port=8080" - # gRPC support for API - - "traefik.http.services.zitadel.loadbalancer.server.scheme=h2c" - # Middleware for security headers - - "traefik.http.routers.zitadel.middlewares=zitadel-headers" - - "traefik.http.middlewares.zitadel-headers.headers.stsSeconds=31536000" - - "traefik.http.middlewares.zitadel-headers.headers.stsIncludeSubdomains=true" - - "traefik.http.middlewares.zitadel-headers.headers.stsPreload=true" - - deploy: - resources: - limits: - memory: {{ zitadel_memory_limit }} - cpus: "{{ zitadel_cpu_limit }}" - - zitadel-db: - image: postgres:{{ postgres_version }} - container_name: zitadel-db - restart: unless-stopped - environment: - POSTGRES_USER: "{{ zitadel_db_user }}" - POSTGRES_PASSWORD: "{{ client_secrets.zitadel_db_password }}" - POSTGRES_DB: "{{ zitadel_db_name }}" - - volumes: - - zitadel-db-data:/var/lib/postgresql/data - - networks: - - {{ zitadel_network }} - - healthcheck: - test: ["CMD-SHELL", "pg_isready -U {{ zitadel_db_user }} -d {{ zitadel_db_name }}"] - interval: 5s - timeout: 5s - retries: 5 - - deploy: - resources: - limits: - memory: 256M - cpus: "0.5" - -volumes: - zitadel-db-data: - driver: local - zitadel-machinekey: - driver: local - -networks: - {{ zitadel_traefik_network }}: - external: true - {{ zitadel_network }}: - driver: bridge - internal: true diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 0ec3396..61c06d3 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -80,10 +80,9 @@ ansible/ ├── roles/ │ ├── common/ # Base setup, hardening, Docker │ ├── traefik/ # Reverse proxy, SSL -│ ├── zitadel/ # Identity provider (Swiss, AGPL 3.0) -│ ├── nextcloud/ -│ ├── pretix/ # Future -│ ├── listmonk/ # Future +│ ├── nextcloud/ # File sync and collaboration +│ ├── pretix/ # Future: Event ticketing +│ ├── listmonk/ # Future: Newsletter/mailing │ ├── backup/ # Restic configuration │ └── monitoring/ # Node exporter, promtail ``` @@ -98,13 +97,11 @@ all: client_name: alpha domain: alpha.platform.nl apps: - - zitadel - nextcloud client-beta: client_name: beta domain: beta.platform.nl apps: - - zitadel - nextcloud - pretix ``` @@ -152,51 +149,19 @@ resource "hetznerdns_record" "client_a" { ## 4. Identity Provider -### Decision: Zitadel (replacing Keycloak) +### Decision: Removed (previously Zitadel) -**Choice:** Zitadel as the identity provider for all client installations. +**Status:** Identity provider removed from architecture. -**Why Zitadel over Keycloak:** +**Reason for Removal:** +- Zitadel v2.63.7 has critical bugs with FirstInstance initialization +- ALL `ZITADEL_FIRSTINSTANCE_*` environment variables cause database migration errors +- Requires manual web UI setup for each instance (not scalable for multi-tenant deployment) +- See: https://github.com/zitadel/zitadel/issues/8791 -| Factor | Zitadel | Keycloak | -|--------|---------|----------| -| Company HQ | 🇨🇭 Switzerland | 🇺🇸 USA (IBM/Red Hat) | -| GDPR Jurisdiction | EU-adequate | US jurisdiction | -| License | AGPL 3.0 | Apache 2.0 | -| Multi-tenancy | Native design | Added later (2024) | -| Language | Go (lightweight) | Java (resource-heavy) | -| Architecture | Event-sourced, API-first | Traditional | - -**Licensing Notes:** -- Zitadel v3 (March 2025) changed from Apache 2.0 to AGPL 3.0 -- For our use case (running Zitadel as IdP), this has zero impact -- AGPL only requires source disclosure if you modify Zitadel AND provide it as a service -- SDKs and APIs remain Apache 2.0 - -**Company Background:** -- CAOS Ltd., headquartered in St. Gallen, Switzerland -- Founded 2019, $15.5M funding (Series A) -- Switzerland has EU data protection adequacy status -- Public product roadmap, transparent development - -**Deployment:** -```yaml -# docker-compose.yml snippet -services: - zitadel: - image: ghcr.io/zitadel/zitadel:v3.x.x # Pin version - command: start-from-init - environment: - ZITADEL_DATABASE_POSTGRES_HOST: postgres - ZITADEL_EXTERNALDOMAIN: ${CLIENT_DOMAIN} - depends_on: - - postgres -``` - -**Multi-tenancy Approach:** -- Each client gets isolated Zitadel organization -- Single Zitadel instance can manage multiple organizations -- Or: fully isolated Zitadel per client (current choice for maximum isolation) +**Future Consideration:** +- May revisit with Authentik or other identity providers when needed +- Currently focusing on Nextcloud as standalone solution --- @@ -266,8 +231,6 @@ Storage Box (BX10 or larger) /opt/docker/ ├── nextcloud/ │ └── data/ # ✓ User files -├── zitadel/ -│ └── db-dumps/ # ✓ PostgreSQL dumps (not live DB) ├── pretix/ │ └── data/ # ✓ When applicable └── configs/ # ✓ docker-compose files, env @@ -414,7 +377,6 @@ SOPS_AGE_KEY_FILE=keys/age-key.txt sops --decrypt secrets/clients/alpha.sops.yam **Monitors per Client:** - HTTPS endpoint (Nextcloud) -- HTTPS endpoint (Zitadel) - TCP port checks (database, if exposed) - Docker container health (via API or agent) @@ -628,7 +590,6 @@ Unattended-Upgrade::Automatic-Reboot "false"; # Manual reboot control ### Verification - [ ] HTTPS accessible -- [ ] Zitadel admin login works - [ ] Nextcloud admin login works - [ ] Backup job runs successfully - [ ] Monitoring checks green @@ -658,8 +619,6 @@ Unattended-Upgrade::Automatic-Reboot "false"; # Manual reboot control ### Data Export (if requested) - [ ] Export Nextcloud data -- [ ] Export Zitadel organization/users -- [ ] Provide secure download link - [ ] Confirm receipt ### Infrastructure Removal @@ -732,7 +691,6 @@ infrastructure/ │ │ ├── common/ │ │ ├── docker/ │ │ ├── traefik/ -│ │ ├── zitadel/ │ │ ├── nextcloud/ │ │ ├── backup/ │ │ └── monitoring-agent/ @@ -763,8 +721,7 @@ infrastructure/ ## 13. Open Decisions / Future Considerations ### To Decide Later -- [ ] Shared Zitadel instance vs isolated instances per client -- [ ] Central logging (Loki) - when/if needed +- [ ] Identity provider (Authentik or other) - if SSO needed - [ ] Prometheus metrics - when/if needed - [ ] Custom domain SSL workflow - [ ] Client self-service portal @@ -785,8 +742,7 @@ infrastructure/ |------|--------|---------|--------| | IaC | OpenTofu | Terraform | BSL license concerns, HashiCorp trust issues | | Secrets | SOPS + Age | HashiCorp Vault | Simplicity, no US vendor dependency, truly open source | -| Identity | Zitadel | Keycloak | Swiss company, GDPR-adequate jurisdiction, native multi-tenancy | -| DNS | Hetzner DNS | Cloudflare | EU-based, GDPR-native, single provider | +| Identity | (Removed) | Keycloak/Zitadel | Removed due to complexity; may add Authentik in future | | Hosting | Hetzner | AWS/GCP/Azure | EU-based, cost-effective, GDPR-compliant | | Backup | Restic + Hetzner Storage Box | Cloud backup services | Open source, EU data residency | @@ -848,5 +804,5 @@ pipx inject ansible requests python-dateutil | 2024-12 | Switched from Terraform to OpenTofu (licensing concerns) | Pieter / Claude | | 2024-12 | Switched from HashiCorp Vault to SOPS + Age (simplicity, open source) | Pieter / Claude | | 2024-12 | Switched from Keycloak to Zitadel (Swiss company, GDPR jurisdiction) | Pieter / Claude | -| 2025-12 | Adopted pipx for isolated Python tool environments (Ansible) | Pieter / Claude | +| 2026-01 | Removed Zitadel due to FirstInstance bugs; may add Authentik in future | Pieter / Claude | ``` \ No newline at end of file diff --git a/secrets/clients/test.sops.yaml b/secrets/clients/test.sops.yaml index 2fe9740..e69de29 100644 --- a/secrets/clients/test.sops.yaml +++ b/secrets/clients/test.sops.yaml @@ -1,37 +0,0 @@ -#ENC[AES256_GCM,data:o6Gv7TOJ+8OJu6tM51EknhHBND/1k7pMLQ==,iv:uTX4bvBSArGi6W+JM+wZ269OoS/54GqyMlkUKmYZuiw=,tag:xgOASRfSQY1qsh34qLFERQ==,type:comment] -#ENC[AES256_GCM,data:kkQhR4LpsiUoK9i76ZmKJ3b1rw==,iv:hMZHHZ7RtC6MYFbF24Oo4CN1yaJ1VFECuUL494TFb28=,tag:GYtuciL1d/GWBF9XlVHOFQ==,type:comment] -client_name: ENC[AES256_GCM,data:8+Lr9w==,iv:2wOOQESZWtoEhPNCstNe9V3FMLFCN0cmo5s4HNbe4JI=,tag:jp66n3TBkNvQ4I2OKjvEVg==,type:str] -client_domain: ENC[AES256_GCM,data:PErC6A2eggbDe3kl+SPw3g==,iv:95IhEuxEdseJxxOAXc+enFx3hKIkoUjqHt1jTERSu9k=,tag:vg188upDCa093P2DZRiMiw==,type:str] -#ENC[AES256_GCM,data:7sMxHHHosm9HaqSAaj9c,iv:H+8WcGSj6wZ5TRbVz/w5zKKiLsUMl12XBiekNXtkwf8=,tag:LnvSnDhZZiRTU2VGmv6DuA==,type:comment] -zitadel_domain: ENC[AES256_GCM,data:BGDfLONSUv1pE9MGTD2FqAdnMYv/nHo3,iv:gHetSoS72+gvTyU40HokR7M3NzOF2jXjSM3AJzOJWxk=,tag:+tO7CI3YA6OiTRZDVEo6SQ==,type:str] -#ENC[AES256_GCM,data:RE98atj6ceVeVyCq8F0QDQ==,iv:ZLWn96U3dZTOfaOXMi2cmznOtwQNvVyWxpY8Wrn8cTs=,tag:raQM4w76FYcxihgXR48kXw==,type:comment] -zitadel_db_password: ENC[AES256_GCM,data:ahTJ6nqi8Z6tH4xfdqOf1G414k+Cghqb9jtpeWDnWDc=,iv:W7Q1oGXPuL4nZKHjtP7whZbIypsiWbla4iJLWTVej+s=,tag:vGdAwTZDs+Dx1VOwpqPI3A==,type:str] -zitadel_admin_password: ENC[AES256_GCM,data:aCwqd0zxYx3U7KXMZXlb+aRJg/o4tw==,iv:7NJ8vDvbPaKUVzdA0nAD/aXXKRrjiAh/Pgs8f658Dug=,tag:Shv3q3un0B67RojdwJpw2g==,type:str] -zitadel_masterkey: ENC[AES256_GCM,data:ZejgZehycR7xXaR3YakV7T3JcaXLH6WgdFZXyADaEaM=,iv:ox9ucUjosXL+v8+fhJ01v/gKUero1AHbEQltFbTdGPY=,tag:v/aAuWI1uWMkTKesNFMhJw==,type:str] -#ENC[AES256_GCM,data:uCeazGQMt+XhXfvYWxXc26rH,iv:It663dHXpWK2geH4W+sCnxb3uCmebtvLAq6FZXZ7b88=,tag:tUf4ACtrV01rxK9fX8IzCQ==,type:comment] -nextcloud_db_password: ENC[AES256_GCM,data:KFENTKR2nVMVW+q7PxKGMCCeaWCje8RvIDz9uQVCZNY=,iv:5SBGVIk3ss141uHBVM8ri4R0oPF5WG7Zr+n1If6LTBo=,tag:wcfWbdznvStQzAqaD8ATiQ==,type:str] -nextcloud_admin_password: ENC[AES256_GCM,data:hY/jDhFv/ARQBLP3BMeoXWz/,iv:GQUJ30p53tJx8Ad4WmuYyO094oLmFo2fscyWMFJTI3Q=,tag:JrmBWIs1aGYA+KrJ/NEU7A==,type:str] -#ENC[AES256_GCM,data:Z6n14z9PYeD6ANSBAmwEC/2dd1jcng/CKA==,iv:DjKRNBCKdbigL5BVS8T+n5ErpdCnWl5vife2Dk82kKQ=,tag:tD5y2LGO5uX2FzLg3e2uwQ==,type:comment] -collabora_admin_password: ENC[AES256_GCM,data:3HxOGhRtHdJmpjZCmNFRDUjt+hImQl490ayVM1ZljOM=,iv:Lo39ivBvdK0PMtQESgs2osxll/N4UaKceBEGDKsvU5M=,tag:46ta6FPyM+nN8yOzUTDikQ==,type:str] -#ENC[AES256_GCM,data:Z35TDtwuZFN5K5GuSOR0Lw2AUkD+id2WVafDU9x96fhnXg==,iv:MhJgiCxKc3p1VEPd1m1nNTvYukY7vg9LYXCdLqJd/QA=,tag:BNbFdq4XljgcXBszZxaErA==,type:comment] -restic_repo_password: ENC[AES256_GCM,data:ae3UuZ/+bkk7FqtINNpzyEUw9SNEuA4Zx57SNsBtbDs=,iv:qEZzg8SBgdEfIO3B/Z3OgqBd4/hc/Pc4JW/MbGYSUT4=,tag:0SsSbfdnqXnGpmwJcFLGkg==,type:str] -#ENC[AES256_GCM,data:fz/o+KoG5CP4oWUhi1nv8thE0bxn7ElmYeQgavPle/ST0twxvM0qoz7t61SW5J3XISMdKFvPAqnU9EDZMsQ/Y/y5JdY=,iv:HP//iD4WG4LuMUaLQ01a6N15UD7qBpg/byelK3acq4s=,tag:VY78NrgHsm+3Imy6dwPIPA==,type:comment] -#ENC[AES256_GCM,data:IJpspOGP4TvW9Bk1vd4OV64DvaKCAGtPIoQm2zQ=,iv:AobxL61gfP5S5l2E7vigM/mzMgg32pqBLPu8rwwdeAA=,tag:Z5cB6zpD2mgTjbdVJCQ9QA==,type:comment] -#ENC[AES256_GCM,data:dX/4WdhxBU6RoB+SkOtsWOWPgf+bX0WC+gxJZqLN8yj+,iv:YER4AaTpLKsCch3Xlvbq4ouDFk4cAcwkJkefgzcd8vc=,tag:zpWLsEiCIBkimiPmMqnFMw==,type:comment] -#ENC[AES256_GCM,data:BmpuNaaJc5PnG2j5YfMU5FfAx+0i7EG3ROOj0vtfQCIi,iv:AxVMdkdrC4r9I9eR4hDQM1Ul6WRmMe5kZt4UuoyyA30=,tag:60qHouezyElWIIuTp9hAfw==,type:comment] -zitadel_api_token: ENC[AES256_GCM,data:LoGhHkXy8M1SYooN67lfb23XAd9w78Q7I9dTOuSlqy1qaJ98Kupjr3vO6SxpN0L+d1BSY5o8dbmgYQnXyYzx9MTKA+UsW5w=,iv:dY2PGkQz7BkYSJgxDBgXGlEboMRyvK6dp9vqHmbjus8=,tag:nv4cqczg7JjKeDFipvAIKA==,type:str] -sops: - age: - - recipient: age170jqy5pg6z62kevadqyxxekw8ryf3e394zaquw0nhs9ae3v9wd6qq2hxnk - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFZGhMWFFDY2VNU1FFSlkw - Ukw1cXk4c2R5OHRLOUtVcno4WnBHNXk0QVVZClFTeFZtRmVBV2NzQkxabFZORFhC - eDcxemZtckdwRTVCQXlTalVYNDV5ZTQKLS0tIERTdWNCeE1Rb0JpSFRGVHh1Zml2 - S3NMRXo4MUxNS1p2d3o5QS9VTld5OWMKb02yi2CyGhAb2YsdgRZm6vhuRV0OIt7I - Lc/mkS3fvs4CDsQsngFFh8YJQlhPolewvijJcOjhN3ga91OS6MTwbQ== - -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-01-06T10:48:07Z" - mac: ENC[AES256_GCM,data:T0Dj4JggKXa8wpl4VP4qCVg3D7fETNdTTKcEHYw2D8++eokzfpEcseVpiF+NNMrz4m+wTLgUmPNezXVF69i6qy9H1+isZ86426o/VSOi9NujkThFqd/KI6MpEGl249B/pWQyWAyFq3dpKS+6spKYX6tePPSVAKh0yuSy+kweitg=,iv:g1Q1yO/qql2WkeGRynK44jzLcFxLNIsPR4KocUmWAgY=,tag:jOpQtrr6RQmKhu1uRi7Qcw==,type:str] - unencrypted_suffix: _unencrypted - version: 3.11.0