АРХ - NODEDC PLATFORM: bootstrap Authentik applications
This commit is contained in:
parent
afa53d59c1
commit
4a10726b2e
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
# dependencies and build output
|
# dependencies and build output
|
||||||
node_modules/
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.turbo/
|
.turbo/
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,14 @@ AUTHENTIK_ERROR_REPORTING__ENABLED=false
|
||||||
|
|
||||||
# launcher oidc
|
# launcher oidc
|
||||||
LAUNCHER_OIDC_ISSUER=http://auth.local.nodedc/application/o/launcher/
|
LAUNCHER_OIDC_ISSUER=http://auth.local.nodedc/application/o/launcher/
|
||||||
LAUNCHER_OIDC_CLIENT_ID=
|
LAUNCHER_OIDC_CLIENT_ID=nodedc-launcher
|
||||||
LAUNCHER_OIDC_CLIENT_SECRET=
|
LAUNCHER_OIDC_CLIENT_SECRET=change-me-generate-with-bootstrap-authentik-dev
|
||||||
LAUNCHER_OIDC_REDIRECT_URI=http://launcher.local.nodedc/auth/callback
|
LAUNCHER_OIDC_REDIRECT_URI=http://launcher.local.nodedc/auth/callback
|
||||||
|
|
||||||
# plane oidc
|
# plane oidc
|
||||||
PLANE_OIDC_ISSUER=http://auth.local.nodedc/application/o/task-manager/
|
PLANE_OIDC_ISSUER=http://auth.local.nodedc/application/o/task-manager/
|
||||||
PLANE_OIDC_CLIENT_ID=
|
PLANE_OIDC_CLIENT_ID=nodedc-task-manager
|
||||||
PLANE_OIDC_CLIENT_SECRET=
|
PLANE_OIDC_CLIENT_SECRET=change-me-generate-with-bootstrap-authentik-dev
|
||||||
PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
|
PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
|
||||||
|
|
||||||
# security
|
# security
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,20 @@ docker compose --env-file infra/.env -f infra/docker-compose.dev.yml up -d
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file infra/.env -f infra/docker-compose.dev.yml ps
|
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: 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`.
|
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
|
## Current local status
|
||||||
|
|
||||||
This stack was verified locally with `PLATFORM_PROXY_IMAGE=nodedc/plane-proxy:ru`:
|
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;
|
- `launcher.local.nodedc` returns `200` from the current Vite launcher through Caddy;
|
||||||
- `task.local.nodedc` returns `200` from the current Plane runtime 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 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.
|
Browser testing still requires `/etc/hosts` entries on the host machine.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -44,14 +44,14 @@ AUTHENTIK_BOOTSTRAP_TOKEN=$(rand 36)
|
||||||
|
|
||||||
# launcher oidc
|
# launcher oidc
|
||||||
LAUNCHER_OIDC_ISSUER=http://auth.local.nodedc/application/o/launcher/
|
LAUNCHER_OIDC_ISSUER=http://auth.local.nodedc/application/o/launcher/
|
||||||
LAUNCHER_OIDC_CLIENT_ID=
|
LAUNCHER_OIDC_CLIENT_ID=nodedc-launcher
|
||||||
LAUNCHER_OIDC_CLIENT_SECRET=
|
LAUNCHER_OIDC_CLIENT_SECRET=$(openssl rand -hex 48 | tr -d '\n')
|
||||||
LAUNCHER_OIDC_REDIRECT_URI=http://launcher.local.nodedc/auth/callback
|
LAUNCHER_OIDC_REDIRECT_URI=http://launcher.local.nodedc/auth/callback
|
||||||
|
|
||||||
# plane oidc
|
# plane oidc
|
||||||
PLANE_OIDC_ISSUER=http://auth.local.nodedc/application/o/task-manager/
|
PLANE_OIDC_ISSUER=http://auth.local.nodedc/application/o/task-manager/
|
||||||
PLANE_OIDC_CLIENT_ID=
|
PLANE_OIDC_CLIENT_ID=nodedc-task-manager
|
||||||
PLANE_OIDC_CLIENT_SECRET=
|
PLANE_OIDC_CLIENT_SECRET=$(openssl rand -hex 48 | tr -d '\n')
|
||||||
PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
|
PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback
|
||||||
|
|
||||||
# security
|
# security
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue