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.
This commit is contained in:
tegwick 2026-08-09 02:00:12 +02:00
parent 0e973a91aa
commit 44439f8d8d
8 changed files with 273 additions and 20 deletions

View file

@ -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,
},
},
)

View file

@ -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",
)

View file

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

View file

@ -13,6 +13,7 @@
<dt>Subject</dt><dd>{{ principal.subject }}</dd>
<dt>user-engine id</dt><dd>{{ principal.user_engine_user_id }}</dd>
<dt>user-engine source</dt><dd>{{ principal.user_engine_source|default:"—" }}</dd>
<dt>authz</dt><dd>{{ principal.authz_reason }} <span class="muted">({{ principal.authz_decision_id }})</span></dd>
</dl>
</div>
{% endblock %}