From 29a9ff735e62f92326bee87c482b01ed77b3b78a Mon Sep 17 00:00:00 2001
From: tegwick
Sign in + {% if registration_enabled %} + Create account + {% endif %}
Tenant: {{ default_tenant_id }} · OIDC: diff --git a/docs/deploy.md b/docs/deploy.md index 5834103..96c6a83 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,11 +1,30 @@ -# Deploy notes (stub — CSOC-WP-0002-T08) +# Deploy notes ## Shape Standalone service: commit-SHA images → registry -`gitea.coulomb.social/coulomb/coulomb-social` → `railiance-apps` values → +`forgejo.coulomb.social/coulomb/coulomb-social` → `railiance-apps` Helm values → railiance01 (same lane as `vergabe-teilnahme`). +Chart/values/ingress live in **`railiance-apps`** +(`helm/coulomb-social-values.yaml`, `docs/coulomb-social.md`). + +## Current cluster status (2026-08-09) + +| Item | State | +|------|--------| +| Namespace | `coulomb-social` Active | +| Deployment | 1/1 Ready, image `:7067145` | +| Service | ClusterIP :80 | +| Ingress | `coulomb.social` → Traefik, cert-manager annotation | +| Env secret | `coulomb-social-env` (`SECRET_KEY`, `DATABASE_URL`, `USER_ENGINE_PROXY_SECRET`) | +| OIDC | enabled; issuer `https://kc.coulomb.social`; public client | +| Public DNS | **still Cloudflare / Bubble** | +| TLS secret | **pending** HTTP-01 until DNS points at the cluster | + +In-cluster smoke (with `Host: coulomb.social`): `/healthz` ok, landing 200, +`/auth/login/` → KeyCape authorize. Full browser session needs cutover. + ## Runtime secrets (names only) K8s Secret `coulomb-social-env` in namespace `coulomb-social` (chart `envFrom`): @@ -26,24 +45,82 @@ make coulomb-social-env-secret ./scripts/create-env-secret.sh ``` -Script: `railiance-apps/tools/create-coulomb-social-env-secret.sh` OIDC is a **public** client — no client secret. + +Non-secret env (OIDC, ALLOWED_HOSTS, user-engine URL) lives in Helm values. + ## Health -- `GET /healthz` → `{"status":"ok"}` +- `GET /healthz` → `{"status":"ok","service":"coulomb-social"}` +- Probes use `Host: coulomb.social` (`probes.hostHeader`) -## Build +## Build / deploy ```bash SHA=$(git rev-parse --short HEAD) docker build -t forgejo.coulomb.social/coulomb/coulomb-social:$SHA . +# push, then: +# COULOMB_SOCIAL_IMAGE_TAG=$SHA make coulomb-social-deploy # in railiance-apps ``` -Runtime env (no secrets in image): `SECRET_KEY`, `DATABASE_URL`, -`OIDC_*`, `USER_ENGINE_*`, `DEFAULT_TENANT_ID`, `ALLOWED_HOSTS`. +## Cutover checklist (DNS → live Railiance) -## Status +**Goal:** `https://coulomb.social` serves this app (identity shell), not Bubble. -- Dockerfile present (gunicorn, non-root, `/healthz` check). -- railiance-apps Helm values / cluster Service **not** yet landed. -- Local `make run` + `make test` remain the default verification path. +### Preconditions + +1. [x] Image + Helm release healthy +2. [x] KeyCape client `coulomb-social` with prod redirect `https://coulomb.social/auth/callback/` +3. [x] In-cluster OIDC start redirect works +4. [ ] Local browser OIDC + MFA completed once (proves IdP + user-engine path) +5. [ ] Operator accepts brief public outage / Bubble freeze during DNS switch +6. [ ] Optional: export Bubble data if still needed (CSOC-WP-0001) — not required for identity-only cutover + +### DNS switch + +1. In Cloudflare (or DNS host): lower TTL on `coulomb.social` if possible (e.g. 300s) ahead of time. +2. Point apex (and `www` if used) **A** to **`92.205.62.239`** (railiance01 ingress). + - Prefer DNS-only (grey cloud) first so LE HTTP-01 and Traefik see real traffic; re-enable proxy only if you understand TLS termination path. +3. Wait for propagation: `dig +short coulomb.social A` → `92.205.62.239`. +4. cert-manager should finish HTTP-01; confirm: + + ```bash + kubectl -n coulomb-social get certificate coulomb-social-tls + # READY=True + ``` + +5. Smoke public HTTPS: + + ```bash + curl -fsS https://coulomb.social/healthz + curl -sI https://coulomb.social/auth/login/ | grep -i location + # Location: https://kc.coulomb.social/authorize?... + ``` + +6. **Browser:** Sign in → Authelia MFA → land on `/app/` with principal. +7. Sign out; confirm `/app/` requires login. +8. Second login: same member row / user-engine user_id. + +### Rollback + +- Point DNS A (or Cloudflare origin) back to Bubble/Cloudflare target. +- Cluster release can stay; it only receives traffic when DNS aims at the node. + +### After cutover residuals + +| Item | Note | +|------|------| +| Bubble freeze | Stop editing live Bubble as source of truth | +| Content/UI | CSOC-WP-0001 + design extract — not required for identity shell | +| flex-auth Service | leave `FLEX_AUTH_BASE_URL` unset (local vocabulary) until PDP exists | +| apps-pg backup/HA | business-app contract | +| OpenBao CCR | replace kubectl-sourced env secret when ready | +| Image CI | Forgejo/Gitea pipeline for SHA tags | + +## Local verification (no cutover) + +```bash +make test +make run # offline identity +# or OIDC vars from docs/identity/oidc-client.md +``` diff --git a/docs/identity/smoke.md b/docs/identity/smoke.md index b0f6079..0d1cca2 100644 --- a/docs/identity/smoke.md +++ b/docs/identity/smoke.md @@ -1,23 +1,101 @@ # Identity smoke checklist -## Offline (dev claims) +Evidence updated: **2026-08-09**. -1. `uv sync && uv run manage.py migrate && uv run manage.py runserver 8008` -2. Open `/` → **Sign in** -3. Dev form → submit subject `smoke-1` -4. Land on `/app/` with display name and subject shown -5. **Sign out** → back to landing; `/app/` redirects to login -6. Sign in again with same subject → single `Member` row (idempotent) +## Offline (dev claims) — **passed** -## With platform OIDC +```bash +uv sync && uv run manage.py migrate && make run +# OIDC_ENABLED=false (default), DEBUG=true +``` -1. Set `OIDC_ENABLED=true` and issuer/client/redirect env vars -2. Register redirect URI at the issuer (no wildcards) -3. `/auth/login/` redirects to IdP; callback creates/links Member -4. Logout clears app session +| Step | Result | +|------|--------| +| Open `/` → **Sign in** | → `/auth/dev-login/` | +| Dev form subject `smoke-1` | 302 → `/app/` | +| Shell shows display name + subject | OK | +| **Sign out** | session cleared | +| `/app/` after logout | 302 → login | +| Second login same subject | single `Member` row (idempotent) | +| `make test` | **15 passed** | + +Automated POST probe (2026-08-09): + +```text +dev_login_post → /app/ 200 with subject smoke-1 +logout → app 302 to /auth/login/?next=/app/ +``` + +## Cluster in-cluster (port-forward) — **passed (start of OIDC)** + +DNS for `coulomb.social` still points at Cloudflare/Bubble; TLS ACME is +blocked until cutover. Smoke via: + +```bash +kubectl -n coulomb-social port-forward svc/coulomb-social 18088:80 +curl -H 'Host: coulomb.social' http://127.0.0.1:18088/healthz +# {"status": "ok", "service": "coulomb-social"} +``` + +| Check | Result | +|-------|--------| +| Image | `forgejo.coulomb.social/coulomb/coulomb-social:7067145` | +| `OIDC_ENABLED` | `true` (values) | +| `GET /healthz` + Host | 200 JSON ok | +| `GET /` + Host | 200 landing shell | +| `GET /auth/login/` + Host | **302** → `https://kc.coulomb.social/authorize?...` with `client_id=coulomb-social`, `redirect_uri=https://coulomb.social/auth/callback/`, PKCE S256 | +| Session cookie | `HttpOnly; Secure; SameSite=Lax` (prod settings) | + +Full browser login against the **cluster** redirect URI requires public HTTPS +on `coulomb.social` (Secure cookie + callback host). Use **local OIDC** below +before DNS cutover, or complete browser MFA after cutover. + +## Platform OIDC (local redirect) — **ready for human MFA** + +Client registration and authorize handoff verified; **human Authelia + MFA** +is the remaining interactive step. + +```bash +export OIDC_ENABLED=true +export OIDC_ISSUER=https://kc.coulomb.social +export OIDC_CLIENT_ID=coulomb-social +export OIDC_REDIRECT_URI=http://127.0.0.1:8008/auth/callback/ +export OIDC_SCOPES="openid profile email groups" +# optional live user-engine (else stub): +# export USER_ENGINE_BASE_URL=https://users.92-205-62-239.nip.io +# export USER_ENGINE_PROXY_SECRET="$(kubectl -n user-engine get secret user-engine-runtime \ +# -o jsonpath='{.data.proxy-secret}' | base64 -d)" +make run +``` + +| Step | Expected | +|------|----------| +| Open http://127.0.0.1:8008/ → Sign in | redirect KeyCape → Authelia | +| Complete MFA | callback → `/app/` with subject / display name | +| Sign out | landing; `/app/` requires login | +| Second login | same Member / user_engine user_id | + +Authorize probe (no browser) 2026-08-09: + +| redirect_uri | KeyCape | +|--------------|---------| +| `http://127.0.0.1:8008/auth/callback/` | **302** → Authelia OIDC | +| `https://coulomb.social/auth/callback/` | **302** → Authelia OIDC | + +Unregistered redirects still fail with `invalid_profile_usage` (T03). ## Automated ```bash make test ``` + +## Blockers for production hostname smoke + +| Blocker | Detail | +|---------|--------| +| Public DNS | `coulomb.social` → Cloudflare `104.*` (Bubble), not `92.205.62.239` | +| TLS cert | `certificate/coulomb-social-tls` **not Ready**; HTTP-01 challenge gets **404** from public edge (LE never reaches cluster solver) | +| Secure cookies | prod `SESSION_COOKIE_SECURE=True` — need HTTPS after cutover | + +See cutover steps in `docs/deploy.md`. diff --git a/tests/test_oidc_claims.py b/tests/test_oidc_claims.py new file mode 100644 index 0000000..d7ce741 --- /dev/null +++ b/tests/test_oidc_claims.py @@ -0,0 +1,110 @@ +"""Unit tests for OIDC claim assembly (no live issuer).""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from django.test import override_settings + +from coulomb_social.apps.identity import oidc + + +@override_settings( + OIDC_ENABLED=True, + OIDC_ISSUER="https://kc.example.test", + OIDC_CLIENT_ID="coulomb-social", + OIDC_REDIRECT_URI="http://127.0.0.1:8008/auth/callback/", +) +def test_claims_prefer_id_token_when_userinfo_empty(): + token = {"id_token": "header.payload.sig"} + fake_claims = { + "iss": "https://kc.example.test", + "sub": "platform-root", + "aud": "coulomb-social", + "name": "Platform Root", + "preferred_username": "platform-root", + "tenant": "tenant:coulomb", + } + with patch.object(oidc, "decode_id_token", return_value=fake_claims) as dec: + out = oidc.claims_from_token_response(token, {}) + dec.assert_called_once_with("header.payload.sig") + assert out["sub"] == "platform-root" + assert out["name"] == "Platform Root" + + +@override_settings( + OIDC_ENABLED=True, + OIDC_ISSUER="https://kc.example.test", + OIDC_CLIENT_ID="coulomb-social", + OIDC_REDIRECT_URI="http://127.0.0.1:8008/auth/callback/", +) +def test_userinfo_overlays_id_token(): + token = {"id_token": "h.p.s"} + with patch.object( + oidc, + "decode_id_token", + return_value={"sub": "u1", "name": "From Token", "email": ""}, + ): + out = oidc.claims_from_token_response( + token, {"name": "From Userinfo", "email": "a@b.c"} + ) + assert out["sub"] == "u1" + assert out["name"] == "From Userinfo" + assert out["email"] == "a@b.c" + + +@override_settings( + OIDC_ENABLED=True, + OIDC_ISSUER="https://kc.example.test", + OIDC_CLIENT_ID="coulomb-social", + OIDC_REDIRECT_URI="http://127.0.0.1:8008/auth/callback/", +) +def test_missing_sub_raises(): + with patch.object(oidc, "decode_id_token", return_value={}): + with pytest.raises(oidc.OIDCConfigurationError, match="no subject"): + oidc.claims_from_token_response({"id_token": "h.p.s"}, {}) + + +@override_settings( + OIDC_ENABLED=True, + OIDC_ISSUER="https://kc.example.test", + OIDC_CLIENT_ID="coulomb-social", + OIDC_REDIRECT_URI="http://127.0.0.1:8008/auth/callback/", +) +def test_fetch_userinfo_soft_fails_on_401(httpx_mock=None): + import httpx + + class FakeResp: + status_code = 401 + + def json(self): + return {"error": "invalid_token"} + + with ( + patch.object( + oidc, + "discovery_document", + return_value={"userinfo_endpoint": "https://kc.example.test/userinfo"}, + ), + patch.object(httpx, "get", return_value=FakeResp()), + ): + assert oidc.fetch_userinfo("opaque-or-jwt") == {} + + +@override_settings( + OIDC_ENABLED=True, + OIDC_ISSUER="https://kc.example.test", + OIDC_CLIENT_ID="coulomb-social", + OIDC_REDIRECT_URI="https://coulomb.example.test/auth/callback/", +) +def test_authorization_url_can_request_aal2(): + with patch.object( + oidc, + "discovery_document", + return_value={"authorization_endpoint": "https://kc.example.test/authorize"}, + ): + url = oidc.build_authorization_url( + state="state", code_verifier="verifier", acr_values="aal2" + ) + assert "acr_values=aal2" in url diff --git a/tests/test_shell.py b/tests/test_shell.py index 85c60ee..6dd05d0 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -20,6 +20,16 @@ def test_landing_public(client): assert b"Sign in" in r.content +@pytest.mark.django_db +def test_registration_link_uses_only_configured_destination(client, settings): + settings.NETKINGDOM_REGISTRATION_URL = ( + "https://users.coulomb.social/register?client_id=coulomb-social" + ) + r = client.get(reverse("identity:register") + "?next=https://evil.example") + assert r.status_code == 302 + assert r["Location"] == settings.NETKINGDOM_REGISTRATION_URL + + @pytest.mark.django_db def test_app_home_requires_login(client): r = client.get(reverse("core:app_home")) diff --git a/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md b/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md index 8b6304d..288a717 100644 --- a/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md +++ b/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md @@ -268,6 +268,8 @@ Align with business-app delivery lane without full production cutover: 2026-08-09: `Dockerfile` added; `railiance-apps` chart + values + ingress stub + Makefile targets. Image `7067145` published and Helm release deployed; migrations applied; in-cluster /healthz+landing OK. Public DNS still Cloudflare/Bubble; TLS cert pending DNS cutover to 92.205.62.239. +2026-08-09 (smoke continuation): Offline checklist + `make test` (15) passed. Port-forward with `Host: coulomb.social`: healthz/landing OK; `/auth/login/` 302 to KeyCape with prod redirect + PKCE. KeyCape authorize accepts local and prod redirect URIs (→ Authelia). Full browser MFA login still human step (`docs/identity/smoke.md`); cutover steps in `docs/deploy.md`. + --- ## Sequencing diff --git a/workplans/CSOC-WP-0003-self-registration-and-assurance.md b/workplans/CSOC-WP-0003-self-registration-and-assurance.md new file mode 100644 index 0000000..9d8f15d --- /dev/null +++ b/workplans/CSOC-WP-0003-self-registration-and-assurance.md @@ -0,0 +1,96 @@ +--- +id: CSOC-WP-0003 +type: workplan +title: "Add NetKingdom self-registration and profile-aware assurance" +domain: communication +repo: coulomb-social +status: active +owner: codex +topic_slug: coulomb-social +created: "2026-08-09" +updated: "2026-08-09" +depends_on: + - CSOC-WP-0002 + - NK-WP-0025 + - USER-WP-0022 + - KEY-WP-0008 +state_hub_workstream_id: "7cd7d6b8-e01d-4b34-8680-3c0cac68d80e" +--- + +# CSOC-WP-0003 - self-registration and assurance + +Extend the working CSOC-WP-0002 OIDC/JIT shell with a NetKingdom account +creation entry point and optional profile/action step-up. + +## T01 - Preserve and prove first-login JIT profile creation + +```task +id: CSOC-WP-0003-T01 +status: done +priority: high +state_hub_task_id: "dde13170-7203-4fcd-b0ce-5874fccc4632" +``` + +Harden the existing issuer/subject keyed Member creation, concurrent callback +behavior, verified ID-token processing, and user-engine link. Preserve the +current uncommitted CSOC-WP-0002 claim-verification work. + +Done when an existing LLDAP identity gets exactly one ordinary Member and +repeat login updates safe display fields without changing identity ownership. + +Covered by the issuer/subject uniqueness constraint, unusable local passwords, +idempotent session establishment tests, and verified ID-token claim handling. + +## T02 - Add Create NetKingdom account + +```task +id: CSOC-WP-0003-T02 +status: progress +priority: high +state_hub_task_id: "aaf2d2cb-6ba9-42cb-9271-aacc414e947a" +``` + +Add a landing-page registration choice using the configured NetKingdom public +registration URL. The configured URL owns any signed return context. Completion must +start a fresh OIDC flow before creating an application session. + +Done when a new user can leave coulomb.social, register, and return through +the same callback/JIT path without open redirects. + +The application entry point is implemented and ignores browser-supplied +redirect parameters. It remains disabled until the NetKingdom public +registration URL and verified-mail flow are deployed. + +## T03 - Support profile/action step-up + +```task +id: CSOC-WP-0003-T03 +status: done +priority: high +state_hub_task_id: "6636a746-02ca-4a70-ac3c-0219c89cd6a7" +``` + +Use AAL1 for ordinary member sessions. When profile policy or a protected +action requires MFA, restart authorization with AAL2 acr_values and verify the +returned assurance claim before completing the action. + +Done when tegwick can use ordinary login without MFA and opt into or encounter +MFA step-up without affecting another member. + +Implemented explicit `?assurance=aal2`, OIDC `acr_values`, and callback-side +assurance validation. Ordinary login sends no ACR request. + +## T04 - Deploy and run Case A / Case B matrix + +```task +id: CSOC-WP-0003-T04 +status: todo +priority: high +state_hub_task_id: "57bac5f4-fd5d-46ba-92a3-a7bbeb15aa08" +``` + +Test known LLDAP user, new registration, repeated/concurrent callback, +email collision, state replay, disabled identity, local-account coexistence, +password-only login, AAL2 step-up, logout, and rollback on railiance01. + +Done when both requested cases pass with non-secret evidence.