From 4a10726b2eefbf93cca790477c238276e5f7b621 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 4 May 2026 11:07:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=A0=D0=A5=20-=20NODEDC=20PLATFORM:=20b?= =?UTF-8?q?ootstrap=20Authentik=20applications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + docs/DEPLOYMENT_LOCAL.md | 21 +++ infra/.env.example | 8 +- infra/README.md | 11 ++ infra/authentik/bootstrap-dev.py | 210 +++++++++++++++++++++++ infra/scripts/bootstrap-authentik-dev.sh | 78 +++++++++ infra/scripts/init-dev-env.sh | 8 +- 7 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 infra/authentik/bootstrap-dev.py create mode 100755 infra/scripts/bootstrap-authentik-dev.sh diff --git a/.gitignore b/.gitignore index 3e640d2..6d140e2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # dependencies and build output node_modules/ +__pycache__/ +*.pyc dist/ build/ .turbo/ diff --git a/docs/DEPLOYMENT_LOCAL.md b/docs/DEPLOYMENT_LOCAL.md index 726f7a6..f97cc88 100644 --- a/docs/DEPLOYMENT_LOCAL.md +++ b/docs/DEPLOYMENT_LOCAL.md @@ -97,6 +97,27 @@ cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform docker compose --env-file infra/.env -f infra/docker-compose.dev.yml up -d ``` +## Authentik bootstrap + +After Authentik is healthy, bootstrap local NODE.DC identity objects: + +```bash +NODEDC_BOOTSTRAP_ADMIN_EMAIL=dcctouch@gmail.com infra/scripts/bootstrap-authentik-dev.sh +``` + +This creates: + +- `nodedc:superadmin`; +- `nodedc:launcher:admin`; +- `nodedc:launcher:user`; +- `nodedc:taskmanager:admin`; +- `nodedc:taskmanager:user`; +- `NODE.DC Launcher` application with OAuth2/OIDC provider; +- `NODE.DC Task Manager` application with OAuth2/OIDC provider; +- group bindings for application access. + +The script fills missing `LAUNCHER_OIDC_CLIENT_SECRET` and `PLANE_OIDC_CLIENT_SECRET` values in ignored `infra/.env`. Secrets are not committed. + Проверка: ```bash diff --git a/infra/.env.example b/infra/.env.example index 17128d3..b38a00f 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -29,14 +29,14 @@ AUTHENTIK_ERROR_REPORTING__ENABLED=false # launcher oidc LAUNCHER_OIDC_ISSUER=http://auth.local.nodedc/application/o/launcher/ -LAUNCHER_OIDC_CLIENT_ID= -LAUNCHER_OIDC_CLIENT_SECRET= +LAUNCHER_OIDC_CLIENT_ID=nodedc-launcher +LAUNCHER_OIDC_CLIENT_SECRET=change-me-generate-with-bootstrap-authentik-dev LAUNCHER_OIDC_REDIRECT_URI=http://launcher.local.nodedc/auth/callback # plane oidc PLANE_OIDC_ISSUER=http://auth.local.nodedc/application/o/task-manager/ -PLANE_OIDC_CLIENT_ID= -PLANE_OIDC_CLIENT_SECRET= +PLANE_OIDC_CLIENT_ID=nodedc-task-manager +PLANE_OIDC_CLIENT_SECRET=change-me-generate-with-bootstrap-authentik-dev PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback # security diff --git a/infra/README.md b/infra/README.md index cdbc77b..dba3c75 100644 --- a/infra/README.md +++ b/infra/README.md @@ -59,10 +59,20 @@ docker compose --env-file infra/.env -f infra/docker-compose.dev.yml up -d ```bash docker compose --env-file infra/.env -f infra/docker-compose.dev.yml ps curl -I -H 'Host: auth.local.nodedc' http://127.0.0.1/ +curl -I -H 'Host: launcher.local.nodedc' http://127.0.0.1/ +curl -I -H 'Host: task.local.nodedc' http://127.0.0.1/ ``` Generated Authentik bootstrap credentials are stored only in `infra/.env`. +5. Bootstrap local Authentik groups and OIDC applications: + +```bash +NODEDC_BOOTSTRAP_ADMIN_EMAIL=dcctouch@gmail.com infra/scripts/bootstrap-authentik-dev.sh +``` + +The script is idempotent. It creates NODE.DC groups, Launcher and Task Manager OAuth2 providers, application tiles, group access bindings and local OIDC client secrets in `infra/.env`. + ## Current local status This stack was verified locally with `PLATFORM_PROXY_IMAGE=nodedc/plane-proxy:ru`: @@ -71,6 +81,7 @@ This stack was verified locally with `PLATFORM_PROXY_IMAGE=nodedc/plane-proxy:ru - `launcher.local.nodedc` returns `200` from the current Vite launcher through Caddy; - `task.local.nodedc` returns `200` from the current Plane runtime through Caddy; - Authentik server, Authentik worker and PostgreSQL report healthy in Docker Compose. +- Authentik login via `auth.local.nodedc` has been verified manually with the local admin user. Browser testing still requires `/etc/hosts` entries on the host machine. diff --git a/infra/authentik/bootstrap-dev.py b/infra/authentik/bootstrap-dev.py new file mode 100644 index 0000000..a213d55 --- /dev/null +++ b/infra/authentik/bootstrap-dev.py @@ -0,0 +1,210 @@ +from os import environ + +from django.db import transaction + +from authentik.common.oauth.constants import SubModes +from authentik.core.models import Application, Group, User +from authentik.flows.models import Flow +from authentik.policies.models import PolicyBinding +from authentik.providers.oauth2.models import ( + ClientTypes, + IssuerMode, + OAuth2LogoutMethod, + OAuth2Provider, + RedirectURI, + RedirectURIMatchingMode, + ScopeMapping, +) + +GROUP_SPECS = [ + ("nodedc:superadmin", False), + ("nodedc:launcher:admin", False), + ("nodedc:launcher:user", False), + ("nodedc:taskmanager:admin", False), + ("nodedc:taskmanager:user", False), +] + +APP_SPECS = [ + { + "slug": "launcher", + "name": "NODE.DC Launcher", + "provider_name": "NODE.DC Launcher OIDC", + "client_id_env": "LAUNCHER_OIDC_CLIENT_ID", + "client_secret_env": "LAUNCHER_OIDC_CLIENT_SECRET", + "redirect_uri_env": "LAUNCHER_OIDC_REDIRECT_URI", + "launch_url": "http://launcher.local.nodedc", + "logout_uri": "http://launcher.local.nodedc/logout", + "groups": ["nodedc:superadmin", "nodedc:launcher:admin", "nodedc:launcher:user"], + "description": "NODE.DC control plane launcher.", + }, + { + "slug": "task-manager", + "name": "NODE.DC Task Manager", + "provider_name": "NODE.DC Task Manager OIDC", + "client_id_env": "PLANE_OIDC_CLIENT_ID", + "client_secret_env": "PLANE_OIDC_CLIENT_SECRET", + "redirect_uri_env": "PLANE_OIDC_REDIRECT_URI", + "launch_url": "http://task.local.nodedc", + "logout_uri": "http://task.local.nodedc/logout", + "groups": ["nodedc:superadmin", "nodedc:taskmanager:admin", "nodedc:taskmanager:user"], + "description": "NODE.DC Plane-based task manager.", + }, +] + + +def required_env(name): + value = environ.get(name, "").strip() + if not value: + raise RuntimeError(f"{name} is required") + return value + + +def ensure_group(name, is_superuser=False): + group, _ = Group.objects.get_or_create(name=name) + group.is_superuser = is_superuser + group.save() + return group + + +def ensure_groups(): + groups = {} + for name, is_superuser in GROUP_SPECS: + groups[name] = ensure_group(name, is_superuser) + return groups + + +def ensure_user_groups(groups): + admin_email = environ.get("NODEDC_BOOTSTRAP_ADMIN_EMAIL", "").strip() + if not admin_email: + return None + + user = User.objects.filter(email__iexact=admin_email).first() or User.objects.filter( + username=admin_email + ).first() + if user is None: + user = User(username=admin_email, email=admin_email, name=admin_email, type="internal") + + user.username = admin_email + user.email = admin_email + user.is_active = True + user.type = "internal" + if environ.get("NODEDC_BOOTSTRAP_ADMIN_PASSWORD"): + user.set_password(environ["NODEDC_BOOTSTRAP_ADMIN_PASSWORD"]) + user.save() + + authentik_admins = Group.objects.filter(name="authentik Admins").first() + if authentik_admins: + user.groups.add(authentik_admins) + + for name in groups: + user.groups.add(groups[name]) + return user + + +def ensure_groups_scope_mapping(): + mapping, _ = ScopeMapping.objects.get_or_create( + name="NODE.DC OAuth Mapping: groups", + defaults={ + "scope_name": "groups", + "description": "Adds Authentik group names to NODE.DC OIDC tokens.", + "expression": 'return {"groups": [group.name for group in request.user.groups.all()]}', + }, + ) + mapping.scope_name = "groups" + mapping.description = "Adds Authentik group names to NODE.DC OIDC tokens." + mapping.expression = 'return {"groups": [group.name for group in request.user.groups.all()]}' + mapping.save() + return mapping + + +def default_scope_mappings(): + scope_names = ["openid", "email", "profile", "offline_access"] + mappings = list(ScopeMapping.objects.filter(scope_name__in=scope_names)) + mappings.append(ensure_groups_scope_mapping()) + return mappings + + +def ensure_provider(spec, mappings): + authorization_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent") + invalidation_flow = Flow.objects.get(slug="default-provider-invalidation-flow") + + provider = OAuth2Provider.objects.filter(name=spec["provider_name"]).first() + if provider is None: + provider = OAuth2Provider(name=spec["provider_name"]) + + provider.name = spec["provider_name"] + provider.client_type = ClientTypes.CONFIDENTIAL + provider.client_id = required_env(spec["client_id_env"]) + provider.client_secret = required_env(spec["client_secret_env"]) + provider.redirect_uris = [ + RedirectURI(RedirectURIMatchingMode.STRICT, required_env(spec["redirect_uri_env"])) + ] + provider.logout_uri = spec["logout_uri"] + provider.logout_method = OAuth2LogoutMethod.FRONTCHANNEL + provider.include_claims_in_id_token = True + provider.sub_mode = SubModes.USER_UUID + provider.issuer_mode = IssuerMode.PER_PROVIDER + provider.authorization_flow = authorization_flow + provider.invalidation_flow = invalidation_flow + provider.save() + provider.property_mappings.set(mappings) + return provider + + +def ensure_application(spec, provider, groups): + application = Application.objects.filter(slug=spec["slug"]).first() + if application is None: + application = Application(slug=spec["slug"], name=spec["name"]) + + application.name = spec["name"] + application.slug = spec["slug"] + application.group = "NODE.DC" + application.provider = provider + application.meta_launch_url = spec["launch_url"] + application.meta_description = spec["description"] + application.meta_publisher = "NODE.DC" + application.open_in_new_tab = False + application.policy_engine_mode = "any" + application.save() + + PolicyBinding.objects.filter(target=application).exclude( + group__name__in=spec["groups"] + ).delete() + for order, group_name in enumerate(spec["groups"]): + binding = PolicyBinding.objects.filter(target=application, group=groups[group_name]).first() + if binding is None: + binding = PolicyBinding(target=application, group=groups[group_name]) + binding.enabled = True + binding.negate = False + binding.timeout = 30 + binding.failure_result = False + binding.order = order + binding.save() + + return application + + +@transaction.atomic +def main(): + groups = ensure_groups() + user = ensure_user_groups(groups) + mappings = default_scope_mappings() + applications = [] + providers = [] + + for spec in APP_SPECS: + provider = ensure_provider(spec, mappings) + application = ensure_application(spec, provider, groups) + providers.append(provider) + applications.append(application) + + summary = { + "groups": list(groups), + "admin_user": user.email if user else None, + "applications": [application.slug for application in applications], + "providers": [provider.name for provider in providers], + } + print(summary) + + +main() diff --git a/infra/scripts/bootstrap-authentik-dev.sh b/infra/scripts/bootstrap-authentik-dev.sh new file mode 100755 index 0000000..0c0ebfc --- /dev/null +++ b/infra/scripts/bootstrap-authentik-dev.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +INFRA_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +ROOT_DIR=$(CDPATH= cd -- "$INFRA_DIR/.." && pwd) +ENV_FILE="$INFRA_DIR/.env" +COMPOSE_FILE="$INFRA_DIR/docker-compose.dev.yml" +BOOTSTRAP_SCRIPT="$INFRA_DIR/authentik/bootstrap-dev.py" + +if [ ! -f "$ENV_FILE" ]; then + echo "Missing $ENV_FILE. Run infra/scripts/init-dev-env.sh first." >&2 + exit 1 +fi + +rand_hex() { + openssl rand -hex "$1" | tr -d '\n' +} + +upsert_env() { + key="$1" + value="$2" + python3 - "$ENV_FILE" "$key" "$value" <<'PY' +from pathlib import Path +from sys import argv + +path = Path(argv[1]) +key = argv[2] +value = argv[3] +lines = path.read_text().splitlines() +prefix = f"{key}=" +for index, line in enumerate(lines): + if line.startswith(prefix): + lines[index] = f"{key}={value}" + break +else: + lines.append(f"{key}={value}") +path.write_text("\n".join(lines) + "\n") +PY +} + +get_env() { + key="$1" + awk -F= -v key="$key" '$1 == key {print substr($0, length(key) + 2)}' "$ENV_FILE" | tail -n 1 +} + +ensure_env_value() { + key="$1" + fallback="$2" + value=$(get_env "$key") + if [ -z "$value" ]; then + value="$fallback" + upsert_env "$key" "$value" + fi +} + +ensure_env_value LAUNCHER_OIDC_CLIENT_ID nodedc-launcher +ensure_env_value PLANE_OIDC_CLIENT_ID nodedc-task-manager +ensure_env_value LAUNCHER_OIDC_CLIENT_SECRET "$(rand_hex 48)" +ensure_env_value PLANE_OIDC_CLIENT_SECRET "$(rand_hex 48)" +ensure_env_value LAUNCHER_OIDC_REDIRECT_URI http://launcher.local.nodedc/auth/callback +ensure_env_value PLANE_OIDC_REDIRECT_URI http://task.local.nodedc/auth/oidc/callback + +set -a +. "$ENV_FILE" +set +a + +cd "$ROOT_DIR" +docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \ + -e NODEDC_BOOTSTRAP_ADMIN_EMAIL="${NODEDC_BOOTSTRAP_ADMIN_EMAIL:-}" \ + -e NODEDC_BOOTSTRAP_ADMIN_PASSWORD="${NODEDC_BOOTSTRAP_ADMIN_PASSWORD:-}" \ + -e LAUNCHER_OIDC_CLIENT_ID="$LAUNCHER_OIDC_CLIENT_ID" \ + -e LAUNCHER_OIDC_CLIENT_SECRET="$LAUNCHER_OIDC_CLIENT_SECRET" \ + -e LAUNCHER_OIDC_REDIRECT_URI="$LAUNCHER_OIDC_REDIRECT_URI" \ + -e PLANE_OIDC_CLIENT_ID="$PLANE_OIDC_CLIENT_ID" \ + -e PLANE_OIDC_CLIENT_SECRET="$PLANE_OIDC_CLIENT_SECRET" \ + -e PLANE_OIDC_REDIRECT_URI="$PLANE_OIDC_REDIRECT_URI" \ + authentik-server ak shell < "$BOOTSTRAP_SCRIPT" diff --git a/infra/scripts/init-dev-env.sh b/infra/scripts/init-dev-env.sh index 686d09a..b2a073b 100755 --- a/infra/scripts/init-dev-env.sh +++ b/infra/scripts/init-dev-env.sh @@ -44,14 +44,14 @@ AUTHENTIK_BOOTSTRAP_TOKEN=$(rand 36) # launcher oidc LAUNCHER_OIDC_ISSUER=http://auth.local.nodedc/application/o/launcher/ -LAUNCHER_OIDC_CLIENT_ID= -LAUNCHER_OIDC_CLIENT_SECRET= +LAUNCHER_OIDC_CLIENT_ID=nodedc-launcher +LAUNCHER_OIDC_CLIENT_SECRET=$(openssl rand -hex 48 | tr -d '\n') LAUNCHER_OIDC_REDIRECT_URI=http://launcher.local.nodedc/auth/callback # plane oidc PLANE_OIDC_ISSUER=http://auth.local.nodedc/application/o/task-manager/ -PLANE_OIDC_CLIENT_ID= -PLANE_OIDC_CLIENT_SECRET= +PLANE_OIDC_CLIENT_ID=nodedc-task-manager +PLANE_OIDC_CLIENT_SECRET=$(openssl rand -hex 48 | tr -d '\n') PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback # security