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 @@