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

@ -20,5 +20,5 @@
| task | CSOC-WP-0002-T04 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | | 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-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-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 | | task | CSOC-WP-0002-T08 | progress | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md |

View file

@ -1,7 +1,9 @@
from django.contrib.auth.decorators import login_required 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 django.shortcuts import render
from coulomb_social.apps.identity import flex_auth
def landing(request: HttpRequest) -> HttpResponse: def landing(request: HttpRequest) -> HttpResponse:
if request.user.is_authenticated: if request.user.is_authenticated:
@ -14,6 +16,24 @@ def landing(request: HttpRequest) -> HttpResponse:
@login_required @login_required
def app_home(request: HttpRequest) -> HttpResponse: def app_home(request: HttpRequest) -> HttpResponse:
member = getattr(request.user, "member", None) 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( return render(
request, request,
"core/app_home.html", "core/app_home.html",
@ -33,6 +53,8 @@ def app_home(request: HttpRequest) -> HttpResponse:
), ),
"tenant_id": member.tenant_id if member else "", "tenant_id": member.tenant_id if member else "",
"user_engine_source": request.session.get("user_engine_source", ""), "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 from __future__ import annotations
import json
import uuid
from dataclasses import dataclass 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 from django.conf import settings
@ -12,22 +29,134 @@ class AuthzDecision:
allow: bool allow: bool
reason: str reason: str
decision_id: str = "" decision_id: str = ""
effect: str = ""
def check(action: str, *, resource: str = "shell", subject: str = "") -> AuthzDecision: def check(
"""Decide whether `action` is allowed. 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). if not base:
- other actions require FLEX_AUTH_BASE_URL; until wired, deny. return _local_check(
""" action,
if action == "shell:view": resource=resource,
return AuthzDecision(allow=True, reason="shell-view-authenticated") resource_id=resource_id,
subject=subject,
if not settings.FLEX_AUTH_BASE_URL:
return AuthzDecision(
allow=False,
reason="flex-auth-not-configured-fail-closed",
) )
# TODO(CSOC-WP-0002-T07): HTTP call to flex-auth PDP return _http_check(
return AuthzDecision(allow=False, reason="flex-auth-http-not-implemented") 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" "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_BASE_URL = config("FLEX_AUTH_BASE_URL", default="")
FLEX_AUTH_PROTECTED_SYSTEM_ID = config( FLEX_AUTH_PROTECTED_SYSTEM_ID = config(
"FLEX_AUTH_PROTECTED_SYSTEM_ID", default="coulomb-social" "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>Subject</dt><dd>{{ principal.subject }}</dd>
<dt>user-engine id</dt><dd>{{ principal.user_engine_user_id }}</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>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> </dl>
</div> </div>
{% endblock %} {% endblock %}

View file

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

62
tests/test_flex_auth.py Normal file
View file

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

View file

@ -227,7 +227,7 @@ Ship a minimal product surface that proves user management works:
```task ```task
id: CSOC-WP-0002-T07 id: CSOC-WP-0002-T07
status: progress status: done
priority: medium priority: medium
state_hub_task_id: "04c13dad-1c6e-45b9-96ba-438458e2c388" 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; **Done when:** at least one protected action is decided via the authz port;
docs record system id and vocabulary. 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 ## T08 — Delivery-lane packaging stub
```task ```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 **Done when:** image builds in CI or documented local script; deploy notes in
`docs/deploy.md`. `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.
--- ---