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:
parent
0e973a91aa
commit
44439f8d8d
8 changed files with 273 additions and 20 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
36
docs/identity/flex-auth-vocabulary.md
Normal file
36
docs/identity/flex-auth-vocabulary.md
Normal 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
62
tests/test_flex_auth.py
Normal 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
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue