From 44439f8d8dea48d134b6282ad1e5ccf60c529a0e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 9 Aug 2026 02:00:12 +0200 Subject: [PATCH] Complete flex-auth PEP and document railiance packaging path Local + HTTP POST /v1/check PEP with fail-closed transport errors; shell:view enforced on /app/. Vocabulary docs for T07. Helm chart lives in railiance-apps; Dockerfile already present for T08. --- WORK-RECORDS.md | 2 +- coulomb_social/apps/core/views.py | 24 ++- coulomb_social/apps/identity/flex_auth.py | 159 ++++++++++++++++-- coulomb_social/settings/base.py | 3 +- coulomb_social/templates/core/app_home.html | 1 + docs/identity/flex-auth-vocabulary.md | 36 ++++ tests/test_flex_auth.py | 62 +++++++ ...-netkingdom-user-management-reestablish.md | 6 +- 8 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 docs/identity/flex-auth-vocabulary.md create mode 100644 tests/test_flex_auth.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index d41844b..de75670 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -20,5 +20,5 @@ | task | CSOC-WP-0002-T04 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | | task | CSOC-WP-0002-T05 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | | task | CSOC-WP-0002-T06 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | -| task | CSOC-WP-0002-T07 | progress | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | +| task | CSOC-WP-0002-T07 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | | task | CSOC-WP-0002-T08 | progress | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | diff --git a/coulomb_social/apps/core/views.py b/coulomb_social/apps/core/views.py index 52cda88..49ee420 100644 --- a/coulomb_social/apps/core/views.py +++ b/coulomb_social/apps/core/views.py @@ -1,7 +1,9 @@ from django.contrib.auth.decorators import login_required -from django.http import HttpRequest, HttpResponse, JsonResponse +from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import render +from coulomb_social.apps.identity import flex_auth + def landing(request: HttpRequest) -> HttpResponse: if request.user.is_authenticated: @@ -14,6 +16,24 @@ def landing(request: HttpRequest) -> HttpResponse: @login_required def app_home(request: HttpRequest) -> HttpResponse: member = getattr(request.user, "member", None) + subject = member.subject if member else request.user.get_username() + tenant = member.tenant_id if member else "" + decision = flex_auth.check( + "shell:view", + resource="shell", + resource_id="app_home", + subject=subject, + tenant=tenant, + attributes={ + "issuer": member.issuer if member else "", + "user_engine_user_id": str(member.user_engine_user_id) if member else "", + }, + ) + if not decision.allow: + return HttpResponseForbidden( + f"Not authorized to view shell ({decision.reason})." + ) + return render( request, "core/app_home.html", @@ -33,6 +53,8 @@ def app_home(request: HttpRequest) -> HttpResponse: ), "tenant_id": member.tenant_id if member else "", "user_engine_source": request.session.get("user_engine_source", ""), + "authz_decision_id": decision.decision_id, + "authz_reason": decision.reason, }, }, ) diff --git a/coulomb_social/apps/identity/flex_auth.py b/coulomb_social/apps/identity/flex_auth.py index 0ccf2d7..ca95291 100644 --- a/coulomb_social/apps/identity/flex_auth.py +++ b/coulomb_social/apps/identity/flex_auth.py @@ -1,8 +1,25 @@ -"""flex-auth PEP port — fail-closed for sensitive actions when PDP missing.""" +"""flex-auth PEP — check actions via POST /v1/check (fail-closed). + +Vocabulary (minimal for shell / CSOC-WP-0002-T07): + +| action | resource.type | When allowed (local mode) | +|-------------------|---------------|--------------------------------| +| shell:view | shell | any authenticated principal | +| member:self:read | member | subject matches resource id | +| member:admin | member | deny until policy package live | + +When FLEX_AUTH_BASE_URL is set, all checks go to the PDP. Transport or +malformed responses → deny. +""" from __future__ import annotations +import json +import uuid from dataclasses import dataclass +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen from django.conf import settings @@ -12,22 +29,134 @@ class AuthzDecision: allow: bool reason: str decision_id: str = "" + effect: str = "" -def check(action: str, *, resource: str = "shell", subject: str = "") -> AuthzDecision: - """Decide whether `action` is allowed. +def check( + action: str, + *, + resource: str = "shell", + resource_id: str = "default", + subject: str = "", + tenant: str = "", + subject_type: str = "human", + attributes: dict[str, Any] | None = None, +) -> AuthzDecision: + """Decide whether `action` is allowed for the subject on the resource.""" + tenant = tenant or settings.DEFAULT_TENANT_ID + system_id = settings.FLEX_AUTH_PROTECTED_SYSTEM_ID + base = (settings.FLEX_AUTH_BASE_URL or "").strip() - - shell:view is allowed for any authenticated principal (shell smoke). - - other actions require FLEX_AUTH_BASE_URL; until wired, deny. - """ - if action == "shell:view": - return AuthzDecision(allow=True, reason="shell-view-authenticated") - - if not settings.FLEX_AUTH_BASE_URL: - return AuthzDecision( - allow=False, - reason="flex-auth-not-configured-fail-closed", + if not base: + return _local_check( + action, + resource=resource, + resource_id=resource_id, + subject=subject, ) - # TODO(CSOC-WP-0002-T07): HTTP call to flex-auth PDP - return AuthzDecision(allow=False, reason="flex-auth-http-not-implemented") + return _http_check( + action, + resource=resource, + resource_id=resource_id, + subject=subject, + tenant=tenant, + subject_type=subject_type, + system_id=system_id, + attributes=attributes or {}, + ) + + +def _local_check( + action: str, + *, + resource: str, + resource_id: str, + subject: str, +) -> AuthzDecision: + if action == "shell:view" and resource == "shell": + return AuthzDecision( + allow=True, + reason="local-shell-view-authenticated", + effect="allow", + decision_id=f"local_{uuid.uuid4().hex[:12]}", + ) + if action == "member:self:read" and resource == "member": + if subject and resource_id and subject == resource_id: + return AuthzDecision( + allow=True, + reason="local-member-self-read", + effect="allow", + decision_id=f"local_{uuid.uuid4().hex[:12]}", + ) + return AuthzDecision( + allow=False, + reason="local-member-self-mismatch", + effect="deny", + ) + return AuthzDecision( + allow=False, + reason="local-fail-closed-unknown-action", + effect="deny", + ) + + +def _http_check( + action: str, + *, + resource: str, + resource_id: str, + subject: str, + tenant: str, + subject_type: str, + system_id: str, + attributes: dict[str, Any], +) -> AuthzDecision: + decision_id = f"req_{uuid.uuid4().hex}" + payload = { + "id": decision_id, + "tenant": tenant, + "subject": { + "id": subject or "anonymous", + "type": subject_type, + "tenant": tenant, + "attributes": attributes, + }, + "action": action, + "resource": { + "id": resource_id, + "type": resource, + "system": system_id, + "tenant": tenant, + "attributes": {}, + }, + "context": {"application_id": settings.USER_ENGINE_APPLICATION_ID}, + } + url = f"{settings.FLEX_AUTH_BASE_URL.rstrip('/')}/v1/check" + timeout = float(getattr(settings, "FLEX_AUTH_TIMEOUT_SECONDS", 3.0)) + try: + with urlopen( + Request( + url, + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", "Accept": "application/json"}, + method="POST", + ), + timeout=timeout, + ) as resp: + body = json.load(resp) + effect = str(body.get("effect") or "deny").lower() + allow = effect == "allow" + return AuthzDecision( + allow=allow, + reason=str(body.get("reason") or "flex-auth"), + decision_id=str(body.get("id") or decision_id), + effect=effect, + ) + except (HTTPError, URLError, TimeoutError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return AuthzDecision( + allow=False, + reason="flex-auth-unavailable-fail-closed", + decision_id=decision_id, + effect="deny", + ) diff --git a/coulomb_social/settings/base.py b/coulomb_social/settings/base.py index 7716f35..b43eb6f 100644 --- a/coulomb_social/settings/base.py +++ b/coulomb_social/settings/base.py @@ -110,8 +110,9 @@ USER_ENGINE_EXPECTED_AUDIENCE = config( "USER_ENGINE_EXPECTED_AUDIENCE", default="user-engine-portal" ) -# flex-auth (empty → local fail-closed stub for sensitive checks; shell view allowed) +# flex-auth (empty → local vocabulary; URL set → POST /v1/check fail-closed) FLEX_AUTH_BASE_URL = config("FLEX_AUTH_BASE_URL", default="") FLEX_AUTH_PROTECTED_SYSTEM_ID = config( "FLEX_AUTH_PROTECTED_SYSTEM_ID", default="coulomb-social" ) +FLEX_AUTH_TIMEOUT_SECONDS = config("FLEX_AUTH_TIMEOUT_SECONDS", default=3.0, cast=float) diff --git a/coulomb_social/templates/core/app_home.html b/coulomb_social/templates/core/app_home.html index f532719..cd8985e 100644 --- a/coulomb_social/templates/core/app_home.html +++ b/coulomb_social/templates/core/app_home.html @@ -13,6 +13,7 @@
Subject
{{ principal.subject }}
user-engine id
{{ principal.user_engine_user_id }}
user-engine source
{{ principal.user_engine_source|default:"—" }}
+
authz
{{ principal.authz_reason }} ({{ principal.authz_decision_id }})
{% endblock %} diff --git a/docs/identity/flex-auth-vocabulary.md b/docs/identity/flex-auth-vocabulary.md new file mode 100644 index 0000000..4637288 --- /dev/null +++ b/docs/identity/flex-auth-vocabulary.md @@ -0,0 +1,36 @@ +# flex-auth vocabulary — coulomb.social + +Protected system id: `coulomb-social` (`FLEX_AUTH_PROTECTED_SYSTEM_ID`). + +## Actions (v0 shell) + +| Action | Resource type | Resource id | Effect (local mode) | +|--------|---------------|-------------|---------------------| +| `shell:view` | `shell` | e.g. `app_home` | allow if authenticated | +| `member:self:read` | `member` | OIDC `sub` | allow if subject == resource id | +| `member:admin` | `member` | any | deny (until policy package) | + +## Runtime modes + +| `FLEX_AUTH_BASE_URL` | Behavior | +|----------------------|----------| +| empty | local vocabulary above | +| set | `POST {base}/v1/check` (schema: flex-auth `check_request.schema.json`); fail-closed on error | + +## Cluster status (2026-08-09) + +Only `flex-auth-tenant-engine` is running in-cluster; a general flex-auth +check Service for app PEPs is **not** yet the S5 default for coulomb.social. +Until that lands, leave `FLEX_AUTH_BASE_URL` empty (local mode) or point at a +dev PDP. + +## Future policy package + +When registering with flex-auth / Topaz: + +- system: `coulomb-social` +- package: allow `shell:view` for principals with a valid platform session +- package: allow `member:self:read` when subject id matches resource id +- deny-by-default otherwise + +See `flex-auth/schemas/protected_system_manifest.schema.json`. diff --git a/tests/test_flex_auth.py b/tests/test_flex_auth.py new file mode 100644 index 0000000..91fade9 --- /dev/null +++ b/tests/test_flex_auth.py @@ -0,0 +1,62 @@ +import json +from unittest.mock import MagicMock, patch + +from coulomb_social.apps.identity import flex_auth + + +def test_local_shell_view_allow(): + d = flex_auth.check("shell:view", resource="shell", subject="s1") + assert d.allow is True + assert d.effect == "allow" + + +def test_local_member_self_read(): + ok = flex_auth.check( + "member:self:read", resource="member", resource_id="sub-1", subject="sub-1" + ) + assert ok.allow is True + bad = flex_auth.check( + "member:self:read", resource="member", resource_id="sub-1", subject="other" + ) + assert bad.allow is False + + +def test_local_unknown_deny(): + d = flex_auth.check("member:admin", resource="member", subject="s1") + assert d.allow is False + + +def test_http_allow(settings): + settings.FLEX_AUTH_BASE_URL = "http://flex.example" + settings.FLEX_AUTH_PROTECTED_SYSTEM_ID = "coulomb-social" + settings.DEFAULT_TENANT_ID = "tenant:coulomb" + settings.USER_ENGINE_APPLICATION_ID = "coulomb-social" + + body = json.dumps({"id": "dec-1", "effect": "allow", "reason": "policy"}).encode() + mock_resp = MagicMock() + mock_resp.__enter__.return_value = mock_resp + mock_resp.__exit__.return_value = False + mock_resp.read.return_value = body + + # json.load on response + import coulomb_social.apps.identity.flex_auth as mod + + with patch.object(mod, "urlopen", return_value=mock_resp): + with patch.object(mod, "json") as j: + j.load.return_value = {"id": "dec-1", "effect": "allow", "reason": "policy"} + j.dumps = json.dumps + d = flex_auth.check("shell:view", subject="s1", tenant="tenant:coulomb") + assert d.allow is True + assert d.decision_id == "dec-1" + + +def test_http_fail_closed(settings): + settings.FLEX_AUTH_BASE_URL = "http://flex.example" + from urllib.error import URLError + + import coulomb_social.apps.identity.flex_auth as mod + + with patch.object(mod, "urlopen", side_effect=URLError("down")): + d = flex_auth.check("shell:view", subject="s1") + assert d.allow is False + assert "fail-closed" in d.reason diff --git a/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md b/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md index d10f895..640d141 100644 --- a/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md +++ b/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md @@ -227,7 +227,7 @@ Ship a minimal product surface that proves user management works: ```task id: CSOC-WP-0002-T07 -status: progress +status: done priority: medium state_hub_task_id: "04c13dad-1c6e-45b9-96ba-438458e2c388" ``` @@ -244,6 +244,8 @@ If cluster flex-auth is not yet available for this app, deliver: **Done when:** at least one protected action is decided via the authz port; docs record system id and vocabulary. +2026-08-09: Local vocabulary + HTTP PEP (`POST /v1/check`) implemented; `shell:view` enforced on `/app/`. General flex-auth check Service still absent in-cluster — leave `FLEX_AUTH_BASE_URL` empty for local mode. + ## T08 — Delivery-lane packaging stub ```task @@ -264,7 +266,7 @@ Align with business-app delivery lane without full production cutover: **Done when:** image builds in CI or documented local script; deploy notes in `docs/deploy.md`. -2026-08-09: `Dockerfile` added (uv sync, gunicorn, non-root, healthcheck). railiance-apps values still open. +2026-08-09: `Dockerfile` added; `railiance-apps` chart + values + ingress stub + Makefile targets. Image publish and env Secret still operator steps. ---