Remove Zitadel from project completely
Removed Zitadel identity provider due to:
- Critical bugs with FirstInstance initialization in v2.63.7
- Requirement for manual setup (not scalable for multi-tenant)
- User preference for Authentik in future
Changes:
- Removed entire Zitadel Ansible role and all tasks
- Removed Zitadel agent configuration (.claude/agents/zitadel.md)
- Updated deploy.yml playbook (removed Zitadel role)
- Updated architecture decisions document
- Updated PROJECT_REFERENCE.md (removed Zitadel sections)
- Updated README.md (removed Zitadel references)
- Cleaned up Zitadel deployment from test server
- Updated secrets file (removed Zitadel credentials)
Architecture now focuses on:
- Nextcloud as standalone file sync/collaboration platform
- May add Authentik or other identity provider in future if needed
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
48ef4da920
commit
b951d9542e
21 changed files with 22 additions and 1784 deletions
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
@ -11,7 +11,7 @@ infrastructure/
|
||||||
│ ├── playbooks/ # Main playbooks
|
│ ├── playbooks/ # Main playbooks
|
||||||
│ │ ├── deploy.yml # Deploy applications to clients
|
│ │ ├── deploy.yml # Deploy applications to clients
|
||||||
│ │ └── setup.yml # Setup base server infrastructure
|
│ │ └── setup.yml # Setup base server infrastructure
|
||||||
│ └── roles/ # Ansible roles (traefik, zitadel, nextcloud, etc.)
|
│ └── roles/ # Ansible roles (traefik, nextcloud, etc.)
|
||||||
├── keys/
|
├── keys/
|
||||||
│ └── age-key.txt # SOPS encryption key (gitignored)
|
│ └── age-key.txt # SOPS encryption key (gitignored)
|
||||||
├── secrets/
|
├── secrets/
|
||||||
|
|
@ -45,7 +45,6 @@ export HCLOUD_TOKEN="MlURmliUzLcGyzCWXWWsZt3DeWxKcQH9ZMGiaaNrFM3VcgnASlEWKhhxLHd
|
||||||
|
|
||||||
### Client: test
|
### Client: test
|
||||||
- **Hostname**: test (from Hetzner Cloud)
|
- **Hostname**: test (from Hetzner Cloud)
|
||||||
- **Zitadel**: https://zitadel.test.vrije.cloud
|
|
||||||
- **Nextcloud**: https://nextcloud.test.vrije.cloud
|
- **Nextcloud**: https://nextcloud.test.vrije.cloud
|
||||||
- **Secrets**: `secrets/clients/test.sops.yaml`
|
- **Secrets**: `secrets/clients/test.sops.yaml`
|
||||||
|
|
||||||
|
|
@ -59,10 +58,6 @@ export SOPS_AGE_KEY_FILE="../keys/age-key.txt"
|
||||||
|
|
||||||
# Deploy everything to test client
|
# Deploy everything to test client
|
||||||
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit test
|
~/.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
|
### Check Service Status
|
||||||
|
|
@ -73,7 +68,7 @@ export HCLOUD_TOKEN="..."
|
||||||
|
|
||||||
# Run ad-hoc commands
|
# 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 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
|
### Edit Secrets
|
||||||
|
|
@ -92,50 +87,19 @@ sops --decrypt secrets/clients/test.sops.yaml
|
||||||
|
|
||||||
### Service Stack
|
### Service Stack
|
||||||
- **Traefik**: Reverse proxy with automatic Let's Encrypt certificates
|
- **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
|
- **Nextcloud 30.0.17**: File sync and collaboration
|
||||||
- **Redis**: Caching for Nextcloud
|
- **Redis**: Caching for Nextcloud
|
||||||
|
- **MariaDB**: Database for Nextcloud
|
||||||
|
|
||||||
### Docker Networks
|
### Docker Networks
|
||||||
- `traefik`: External network for all web-accessible services
|
- `traefik`: External network for all web-accessible services
|
||||||
- `zitadel-internal`: Internal network for Zitadel ↔ PostgreSQL
|
- `nextcloud-internal`: Internal network for Nextcloud ↔ Redis/DB
|
||||||
- `nextcloud-internal`: Internal network for Nextcloud ↔ Redis
|
|
||||||
|
|
||||||
### Volumes
|
### Volumes
|
||||||
- `zitadel_zitadel-db-data`: PostgreSQL data
|
|
||||||
- `zitadel_zitadel-machinekey`: JWT keys for service accounts
|
|
||||||
- `nextcloud_nextcloud-data`: Nextcloud files and database
|
- `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
|
## Service Credentials
|
||||||
|
|
||||||
### Zitadel Admin
|
|
||||||
- **URL**: https://zitadel.test.vrije.cloud
|
|
||||||
- **Setup**: Complete wizard on first visit (no predefined credentials)
|
|
||||||
|
|
||||||
### Nextcloud Admin
|
### Nextcloud Admin
|
||||||
- **URL**: https://nextcloud.test.vrije.cloud
|
- **URL**: https://nextcloud.test.vrije.cloud
|
||||||
- **Username**: admin
|
- **Username**: admin
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Post-X Society Multi-Tenant Infrastructure
|
# 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
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
|
@ -8,7 +8,6 @@ Infrastructure as Code for a scalable multi-tenant VPS platform running Zitadel
|
||||||
- **Configuration**: Ansible with dynamic inventory
|
- **Configuration**: Ansible with dynamic inventory
|
||||||
- **Secrets**: SOPS + Age encryption
|
- **Secrets**: SOPS + Age encryption
|
||||||
- **Hosting**: Hetzner Cloud (EU-based, GDPR-compliant)
|
- **Hosting**: Hetzner Cloud (EU-based, GDPR-compliant)
|
||||||
- **Identity**: Zitadel (Swiss company, AGPL 3.0)
|
|
||||||
- **Storage**: Nextcloud (German company, AGPL 3.0)
|
- **Storage**: Nextcloud (German company, AGPL 3.0)
|
||||||
|
|
||||||
## 📁 Repository Structure
|
## 📁 Repository Structure
|
||||||
|
|
@ -87,7 +86,6 @@ This project uses specialized AI agents for development:
|
||||||
|
|
||||||
- **Architect**: High-level design decisions
|
- **Architect**: High-level design decisions
|
||||||
- **Infrastructure**: OpenTofu + Ansible implementation
|
- **Infrastructure**: OpenTofu + Ansible implementation
|
||||||
- **Zitadel**: Identity provider configuration
|
|
||||||
- **Nextcloud**: File sync/share configuration
|
- **Nextcloud**: File sync/share configuration
|
||||||
|
|
||||||
See individual agent files in `.claude/agents/` for responsibilities.
|
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:
|
For issues or questions, please create a GitHub issue with the appropriate label:
|
||||||
- `agent:architect` - Architecture/design questions
|
- `agent:architect` - Architecture/design questions
|
||||||
- `agent:infrastructure` - IaC implementation
|
- `agent:infrastructure` - IaC implementation
|
||||||
- `agent:zitadel` - Identity provider
|
|
||||||
- `agent:nextcloud` - File sync/share
|
- `agent:nextcloud` - File sync/share
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
# Deploy applications to client servers
|
# 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
|
- name: Deploy applications to client servers
|
||||||
hosts: all
|
hosts: all
|
||||||
|
|
@ -21,18 +21,12 @@
|
||||||
age_key: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
age_key: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
|
||||||
no_log: true
|
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
|
- name: Set client domain from secrets
|
||||||
set_fact:
|
set_fact:
|
||||||
client_domain: "{{ client_secrets.client_domain }}"
|
client_domain: "{{ client_secrets.client_domain }}"
|
||||||
when: client_secrets.client_domain is defined
|
when: client_secrets.client_domain is defined
|
||||||
|
|
||||||
roles:
|
roles:
|
||||||
- role: zitadel
|
|
||||||
- role: nextcloud
|
- role: nextcloud
|
||||||
|
|
||||||
post_tasks:
|
post_tasks:
|
||||||
|
|
@ -41,12 +35,4 @@
|
||||||
msg: |
|
msg: |
|
||||||
Deployment complete for client: {{ client_name }}
|
Deployment complete for client: {{ client_name }}
|
||||||
|
|
||||||
Zitadel: https://{{ zitadel_domain }}
|
|
||||||
Nextcloud: https://nextcloud.{{ client_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
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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 <domain> <admin_username> <admin_password>")
|
|
||||||
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()
|
|
||||||
|
|
@ -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 <zitadel_domain> <admin_username> <admin_password>" >&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: <paste-token-here>"
|
|
||||||
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
|
|
||||||
|
|
@ -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 <domain> <admin_username> <admin_password>")
|
|
||||||
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()
|
|
||||||
|
|
@ -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 <domain> <pat_token> <app_name> <redirect_uri>")
|
|
||||||
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()
|
|
||||||
|
|
@ -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 <domain> <username> <password>" >&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"
|
|
||||||
|
|
@ -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 <domain> <pat_token>")
|
|
||||||
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()
|
|
||||||
|
|
@ -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 <domain> <jwt_key_path> <app_name> <redirect_uri>")
|
|
||||||
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()
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -80,10 +80,9 @@ ansible/
|
||||||
├── roles/
|
├── roles/
|
||||||
│ ├── common/ # Base setup, hardening, Docker
|
│ ├── common/ # Base setup, hardening, Docker
|
||||||
│ ├── traefik/ # Reverse proxy, SSL
|
│ ├── traefik/ # Reverse proxy, SSL
|
||||||
│ ├── zitadel/ # Identity provider (Swiss, AGPL 3.0)
|
│ ├── nextcloud/ # File sync and collaboration
|
||||||
│ ├── nextcloud/
|
│ ├── pretix/ # Future: Event ticketing
|
||||||
│ ├── pretix/ # Future
|
│ ├── listmonk/ # Future: Newsletter/mailing
|
||||||
│ ├── listmonk/ # Future
|
|
||||||
│ ├── backup/ # Restic configuration
|
│ ├── backup/ # Restic configuration
|
||||||
│ └── monitoring/ # Node exporter, promtail
|
│ └── monitoring/ # Node exporter, promtail
|
||||||
```
|
```
|
||||||
|
|
@ -98,13 +97,11 @@ all:
|
||||||
client_name: alpha
|
client_name: alpha
|
||||||
domain: alpha.platform.nl
|
domain: alpha.platform.nl
|
||||||
apps:
|
apps:
|
||||||
- zitadel
|
|
||||||
- nextcloud
|
- nextcloud
|
||||||
client-beta:
|
client-beta:
|
||||||
client_name: beta
|
client_name: beta
|
||||||
domain: beta.platform.nl
|
domain: beta.platform.nl
|
||||||
apps:
|
apps:
|
||||||
- zitadel
|
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- pretix
|
- pretix
|
||||||
```
|
```
|
||||||
|
|
@ -152,51 +149,19 @@ resource "hetznerdns_record" "client_a" {
|
||||||
|
|
||||||
## 4. Identity Provider
|
## 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 |
|
**Future Consideration:**
|
||||||
|--------|---------|----------|
|
- May revisit with Authentik or other identity providers when needed
|
||||||
| Company HQ | 🇨🇭 Switzerland | 🇺🇸 USA (IBM/Red Hat) |
|
- Currently focusing on Nextcloud as standalone solution
|
||||||
| 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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -266,8 +231,6 @@ Storage Box (BX10 or larger)
|
||||||
/opt/docker/
|
/opt/docker/
|
||||||
├── nextcloud/
|
├── nextcloud/
|
||||||
│ └── data/ # ✓ User files
|
│ └── data/ # ✓ User files
|
||||||
├── zitadel/
|
|
||||||
│ └── db-dumps/ # ✓ PostgreSQL dumps (not live DB)
|
|
||||||
├── pretix/
|
├── pretix/
|
||||||
│ └── data/ # ✓ When applicable
|
│ └── data/ # ✓ When applicable
|
||||||
└── configs/ # ✓ docker-compose files, env
|
└── 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:**
|
**Monitors per Client:**
|
||||||
- HTTPS endpoint (Nextcloud)
|
- HTTPS endpoint (Nextcloud)
|
||||||
- HTTPS endpoint (Zitadel)
|
|
||||||
- TCP port checks (database, if exposed)
|
- TCP port checks (database, if exposed)
|
||||||
- Docker container health (via API or agent)
|
- Docker container health (via API or agent)
|
||||||
|
|
||||||
|
|
@ -628,7 +590,6 @@ Unattended-Upgrade::Automatic-Reboot "false"; # Manual reboot control
|
||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
- [ ] HTTPS accessible
|
- [ ] HTTPS accessible
|
||||||
- [ ] Zitadel admin login works
|
|
||||||
- [ ] Nextcloud admin login works
|
- [ ] Nextcloud admin login works
|
||||||
- [ ] Backup job runs successfully
|
- [ ] Backup job runs successfully
|
||||||
- [ ] Monitoring checks green
|
- [ ] Monitoring checks green
|
||||||
|
|
@ -658,8 +619,6 @@ Unattended-Upgrade::Automatic-Reboot "false"; # Manual reboot control
|
||||||
|
|
||||||
### Data Export (if requested)
|
### Data Export (if requested)
|
||||||
- [ ] Export Nextcloud data
|
- [ ] Export Nextcloud data
|
||||||
- [ ] Export Zitadel organization/users
|
|
||||||
- [ ] Provide secure download link
|
|
||||||
- [ ] Confirm receipt
|
- [ ] Confirm receipt
|
||||||
|
|
||||||
### Infrastructure Removal
|
### Infrastructure Removal
|
||||||
|
|
@ -732,7 +691,6 @@ infrastructure/
|
||||||
│ │ ├── common/
|
│ │ ├── common/
|
||||||
│ │ ├── docker/
|
│ │ ├── docker/
|
||||||
│ │ ├── traefik/
|
│ │ ├── traefik/
|
||||||
│ │ ├── zitadel/
|
|
||||||
│ │ ├── nextcloud/
|
│ │ ├── nextcloud/
|
||||||
│ │ ├── backup/
|
│ │ ├── backup/
|
||||||
│ │ └── monitoring-agent/
|
│ │ └── monitoring-agent/
|
||||||
|
|
@ -763,8 +721,7 @@ infrastructure/
|
||||||
## 13. Open Decisions / Future Considerations
|
## 13. Open Decisions / Future Considerations
|
||||||
|
|
||||||
### To Decide Later
|
### To Decide Later
|
||||||
- [ ] Shared Zitadel instance vs isolated instances per client
|
- [ ] Identity provider (Authentik or other) - if SSO needed
|
||||||
- [ ] Central logging (Loki) - when/if needed
|
|
||||||
- [ ] Prometheus metrics - when/if needed
|
- [ ] Prometheus metrics - when/if needed
|
||||||
- [ ] Custom domain SSL workflow
|
- [ ] Custom domain SSL workflow
|
||||||
- [ ] Client self-service portal
|
- [ ] Client self-service portal
|
||||||
|
|
@ -785,8 +742,7 @@ infrastructure/
|
||||||
|------|--------|---------|--------|
|
|------|--------|---------|--------|
|
||||||
| IaC | OpenTofu | Terraform | BSL license concerns, HashiCorp trust issues |
|
| IaC | OpenTofu | Terraform | BSL license concerns, HashiCorp trust issues |
|
||||||
| Secrets | SOPS + Age | HashiCorp Vault | Simplicity, no US vendor dependency, truly open source |
|
| Secrets | SOPS + Age | HashiCorp Vault | Simplicity, no US vendor dependency, truly open source |
|
||||||
| Identity | Zitadel | Keycloak | Swiss company, GDPR-adequate jurisdiction, native multi-tenancy |
|
| Identity | (Removed) | Keycloak/Zitadel | Removed due to complexity; may add Authentik in future |
|
||||||
| DNS | Hetzner DNS | Cloudflare | EU-based, GDPR-native, single provider |
|
|
||||||
| Hosting | Hetzner | AWS/GCP/Azure | EU-based, cost-effective, GDPR-compliant |
|
| Hosting | Hetzner | AWS/GCP/Azure | EU-based, cost-effective, GDPR-compliant |
|
||||||
| Backup | Restic + Hetzner Storage Box | Cloud backup services | Open source, EU data residency |
|
| 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 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 HashiCorp Vault to SOPS + Age (simplicity, open source) | Pieter / Claude |
|
||||||
| 2024-12 | Switched from Keycloak to Zitadel (Swiss company, GDPR jurisdiction) | 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 |
|
||||||
```
|
```
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Add table
Reference in a new issue