# Admin Control Plane · Policy Engine Service · Phase 2a **Owner:** Session A · A-owned implementation layer **Baseline:** `A-runtime-admin-phase-2a` **Preview URL:** `/runtime/admin-control-plane-service/service.html` **Phase 1 source:** `/runtime/admin-control-plane/` (5 JSON models · unchanged) ## What this phase delivers - **HTTP contract** (`policy_contract.json`) — response envelope, 7 endpoint specs, error codes, dev-mode auth, observability plan, non-goals enumerated. - **Python policy engine** (`policy_engine.py`) — consumes the 5 Phase 1 JSON models (role_registry · access_policy · assist_model · approval_queue_model · audit_event_model) and produces deterministic decisions for access · mask · assist · view-as · sensitive. Pure stdlib. No network. No state mutation. - **FastAPI scaffold** (`app.py`) — 7 endpoints wiring the engine, X-PTT-* header context, common envelope `{ok, data, error, service}`, request_id correlation, stdout JSON logs. - **Canonical examples** (`policy_examples.json`) — 24 decision vectors covering every path: 10 access_check · 3 mask_resolve · 5 assist_validate · 3 viewas_validate · 3 sensitive_check. Every vector states expected outcome. - **Regression gate** (`verify_examples.py`) — **24/24 pass** before `run.sh` binds the port. - **Debug surface** (`service.html`) — 6 tabs (Run · Endpoints · Examples · Decision Matrix · Curl samples · Contract), live composer + JSON posters for assist/view-as, per-example runner, role × category matrix generated from access_policy. - **Single-command starter** (`run.sh`). ## What this phase does NOT deliver - No JWT / IdP integration. Dev-mode only; Phase 2b. - No WORM audit sink. Decisions are logged to stdout; Kafka `ptt.audit.trail` binding is Phase 2b+. - No approval queue CRUD. Decisions check `approval_refs` presence; actual store-backed TTL enforcement deferred. - No admin Kanban flip UI. Phase 3. - No cross-tenant write workflows. - No rate limiting. ## File layout ``` docs/runtime/admin-control-plane-service/ ├── README.md ← this file ├── policy_contract.json ← HTTP envelope · 7 endpoints · errors · auth ├── policy_engine.py ← Python evaluator · 5 decision functions ├── app.py ← FastAPI scaffold ├── policy_examples.json ← 24 canonical vectors ├── verify_examples.py ← regression gate · must be 24/24 ├── requirements.txt ← fastapi · uvicorn ├── run.sh ← one-shot dev starter ├── service.html ← local debug page └── .gitignore ← .venv / __pycache__ ``` ## Quick start ```bash bash docs/runtime/admin-control-plane-service/run.sh ``` The script creates `.venv/`, installs dependencies, runs `verify_examples.py` (aborts on any failure), then starts uvicorn on `http://127.0.0.1:8090`. Then in another terminal: ```bash # Health curl -s http://127.0.0.1:8090/api/policy/health | jq . # Access check · tenant_admin own-tenant PII (allow/unmasked) curl -s 'http://127.0.0.1:8090/api/policy/access/check?actor_role=tenant_admin&actor_user_id=U-TA-1&actor_tenant_id=pty-zeroth&target_tenant_id=pty-zeroth&target_user_id=U-42&field_category=email' | jq .data # Sensitive gate · health without approval → deny curl -s -X POST 'http://127.0.0.1:8090/api/policy/sensitive/check' \ -H 'Content-Type: application/json' \ -d '{"context":{"actor_role":"tenant_admin","actor_user_id":"U-TA","actor_tenant_id":"pty-zeroth","target_tenant_id":"pty-zeroth"},"field_category":"health","requested_action":"read"}' | jq .data ``` Or open `docs/runtime/admin-control-plane-service/service.html` for a GUI tester with composer, example runner, and decision matrix. ## Endpoints at a glance | Method | Path | Purpose | Required | |---|---|---|---| | `GET` | `/api/policy/access/check` | Single access decision | `actor_role`, `actor_user_id` | | `POST` | `/api/policy/access/check/batch` | Multi-decision shared context | body `{context, items[]}` | | `POST` | `/api/policy/mask/resolve` | Resolve mask level for a field | body `{context, field_category}` | | `POST` | `/api/policy/assist/validate` | Validate assist session | body `{context, proposed_action?}` · context must include `assist_ctx` | | `POST` | `/api/policy/view-as/validate` | Validate view-as session · read-only | body · context must include `view_as_ctx` | | `POST` | `/api/policy/sensitive/check` | Dual-signer gate for sensitive fields | body `{context, field_category, requested_action}` | | `GET` | `/api/policy/health` | Readiness + counts | — | All responses share the envelope: ```json { "ok": true, "data": { "...decision or list...": "..." }, "error": null, "service": { "service_version": "2a-py-scaffold-0.1", "engine_version": "2a-py-policy-engine", "request_id": "uuid-v4" } } ``` Errors carry `ok: false` and `{code, message, hint?}`. HTTP status: `400 invalid_request`, `503 models_unavailable`, `500 internal_error`. Unknown roles return `HTTP 200` with `data.decision=deny` and `reason=unknown_role` — the request itself is valid. ## Dev-mode auth | Header | Body / query fallback | Meaning | |---|---|---| | `X-PTT-Actor-Role` | `context.actor_role` / `?actor_role=` | Required · one of `role_registry.roles[].key` | | `X-PTT-Actor-User-Id` | `context.actor_user_id` / `?actor_user_id=` | Required · stable id | | `X-PTT-Actor-Tenant-Id` | `context.actor_tenant_id` | Platform roles = null | | `X-PTT-Target-Tenant-Id` | `context.target_tenant_id` | Tenant whose data is accessed | | `X-PTT-Target-User-Id` | `context.target_user_id` | Enables self-view shortcut | | `X-PTT-Approval-Refs` | `context.approval_refs` / `?approval_refs=` | Comma-separated approval ids | | `X-Request-Id` | — | Correlation · echoed back | **JWT comes in Phase 2b** and will replace this shim. Claims per `role_registry.jwt_claim_shape`. ## Decision semantics (preserved from Phase 1) - **Masked by default.** PII/sensitive fields return masked unless a precedence rule explicitly allows unmasked. - **Deny on unknown role.** `decision=deny`, `reason=unknown_role`. - **Self-view exception.** `actor_user_id == target_user_id` → unmasked for own data (precedence `prec-3`). - **Tenant sovereignty.** `tenant_admin` / `tenant_dpo` on own tenant → unmasked PII (`prec-4`). - **Cross-tenant without context.** Platform operator + no `assist_ctx` + no `view_as_ctx` → deny (`prec-5`). Platform roles (null actor_tenant_id) against any target tenant count as cross. - **Sensitive-surface requires dual approvers** per `approval_matrix.row-sensitive-override` (`prec-6`). - **View-as is read-only.** Any `proposed_action != read` with `view_as_ctx` → deny. - **Assist requires explicit consent_record_id.** Missing consent → invalid. Max TTL 120 min. - **Producer-irreversible policies** (GPS fuzz at emit) cannot be bypassed. GPS unmask always denied. - **Forbidden patterns** from `assist_model` enforced: silent impersonation, delegated approval, permanent TTL, cross-tenant during session, banner hiding, audit disable, tenant overriding admin log. ## Regression gate ```bash python3 docs/runtime/admin-control-plane-service/verify_examples.py ``` Expected tail: **`24 pass · 0 fail`**. `run.sh` refuses to start the service if this fails. ## Environment variables | Var | Default | Purpose | |---|---|---| | `POLICY_PORT` | `8090` | Bind port | | `POLICY_HOST` | `127.0.0.1` | Bind host | | `POLICY_VENV` | `.venv` | Virtualenv path | | `ADMIN_MODELS_DIR` | `../admin-control-plane` (resolved) | Where the 5 Phase 1 JSON models live | | `POLICY_LOG_LEVEL` | `INFO` | Python logger level | ## Ownership boundary - **A owns:** this folder and everything in it. - **Phase 1 models (also A-owned · read-only in this phase):** `../admin-control-plane/*.json`. - **B contracts consumed (read-only):** `docs/kb/data/approval_matrix.json#sensitive_surface_markers` and `#dual_approval_rules`. - **B files touched:** none. ## Honest summary A real HTTP-facing policy decision layer is now concrete. Any runtime can ask the service `is this access allowed? · what mask level? · is this assist session valid? · does this sensitive action have the right approvers?` and get a deterministic, traced answer backed by the Phase 1 contracts. The service is runnable on a laptop in one command. It is **not** production-ready — no JWT, no WORM audit sink, no approval store. Those are Phase 2b / 2c / 3. The value of this phase is unblocking the downstream runtimes: they can now call a boundary instead of embedding policy logic. The decision service never mutates state and never executes actions — it only answers questions. --- ## Phase 2b additions (additive · 2a untouched) The Phase 2a surface above is unchanged. Phase 2b adds **four capabilities** on top without breaking the 24/24 example gate: ### New endpoints - `GET /api/policy/approvals/{approval_id}` — read-only single approval inspection with computed TTL - `POST /api/policy/approvals/validate` — batch-validate approval_ids against the store - `POST /api/policy/sensitive.check.2b` — store-aware sensitive check (NOT a replacement for 2a) - `POST /api/policy/audit/preview` — render audit envelope · sink_status='deferred' - `GET /phase-2b/health` — extended health ### New files | Path | Purpose | |---|---| | `phase_2b_contract.json` | Phase 2b HTTP contract · auth resolution · approval store · TTL · audit sink · deferred | | `approval_store_schema.json` | JSON-Schema draft-07 for approval rows (mirrors `queue_shape.item_fields`) | | `approval_examples.json` | 8 seed rows (valid · signed_partial dual · expired · pending · withdrawn · rejected · sensitive-approved) | | `audit_sink_contract.json` | Audit sink boundary contract | | `audit_event_examples.json` | 5 canonical envelope examples across 5 categories | | `phase-2b.html` | Runtime debug surface with JWT / store / TTL / audit sections + 5 use cases | ### New env vars | Var | Default | Purpose | |---|---|---| | `ADMIN_APPROVAL_STORE_PATH` | `./approval_examples.json` | File-backed approval store path | | `POLICY_JWT_VERIFY` | unset (false) | Set to `true` to enable RS256/HS256 verification | | `POLICY_JWT_PUBLIC_KEY` | unset | PEM key for verification | | `POLICY_JWKS_URL` | unset | JWKS URL (rotation deferred) | | `POLICY_JWT_AUDIENCE` | `pty-admin-policy` | Required aud for verified tokens | ### Auth context resolution (Phase 2b) `Authorization: Bearer ` > `X-PTT-*` headers > body > query > defaults. JWT is **decode-only** (with `warnings: ['auth_not_verified']` + `auth_source='jwt_unverified'`) unless all three preconditions hold: PyJWT installed + `POLICY_JWT_VERIFY=true` + key/JWKS env set. ### Approval validation rules - `state in (withdrawn, rejected, sent_back)` → invalid - `sla_due_at < now` OR `state == expired` → expired=true · invalid (prec-8) - `state == signed_full` within TTL → valid - `state == signed_partial` with `required_signer_count == 1` → valid (single path) - `state == signed_partial` with `required_signer_count == 2` → **invalid** (`dual_signers_required`) - `state in (pending, in_review)` → invalid - Unknown approval_id → 404 on `GET /approvals/{id}`; per_ref entry with `approval_not_found` on batch ### Audit sink boundary `POST /api/policy/audit/preview` renders envelopes matching `audit_event_model.base_shape` for any `(actor, event_type, subject, action)` tuple. **Never delivers** — `sink_status: 'deferred'` on every response. Kafka producer + WORM object-lock + hash-chain signing are Phase 2b+ infra. ### What Phase 2b does NOT change - Phase 2a endpoints — byte-identical behaviour; parity gate still 24/24 - Policy engine existing functions (`sensitive_check`, `_active_approval`, etc.) — unchanged - Write path — service remains read-only; approval CRUD deferred to Phase 2c - Admin mutation UI — none; Phase 3 ### Phase 2b planning document Full rationale + DoD + deferred matrix: [/planning/admin-control-plane-phase-2b.html](../../planning/admin-control-plane-phase-2b.html)