"""Authenticated, fail-closed HTTP entry points for the conditional WP10 base.""" from __future__ import annotations import os from flask import g, jsonify, request from app.api.system import bp from app.core.system.permissions import ( IDENTITY_MANAGE, IDENTITY_OPERATE, IDENTITY_READ, require_permissions, ) from app.core.system.tenant_control_repository import SqlAlchemyTenantControlRepository from app.core.system.tenant_control_service import ( TenantControlError, TenantControlService, ) from app.models.result import failed, success _MAX_REQUEST_BYTES = 4096 _QUOTA_FIELDS = {"quota_name", "amount", "idempotency_key"} _LIFECYCLE_FIELDS = {"expected_fence", "idempotency_key", "approval_ref", "backup_digest", "retention_seconds"} _APPROVAL_FIELDS = {"operation", "expected_fence", "idempotency_key", "backup_digest", "retention_seconds"} def _service() -> TenantControlService: return TenantControlService(SqlAlchemyTenantControlRepository()) def _body(allowed: set[str] = _QUOTA_FIELDS) -> dict: if request.content_length is not None and request.content_length > _MAX_REQUEST_BYTES: raise TenantControlError("tenant_payload_closed") if len(request.get_data(cache=True)) > _MAX_REQUEST_BYTES: raise TenantControlError("tenant_payload_closed") value = request.get_json(silent=True) if not isinstance(value, dict) or set(value) != allowed: raise TenantControlError("tenant_payload_closed") return value def _server_host() -> str: # Deliberately never consume a client-supplied tenant field or a forwarded # host header. Trusted proxy normalization belongs in deployment; the # persistent DB membership is the final principal+route authorization. host = os.environ.get("TRUSTED_TENANT_ROUTE_HOST", "").lower() if not host: raise TenantControlError("tenant_route_denied") return host @bp.post("/tenant/quota-claims") @require_permissions(IDENTITY_OPERATE) def issue_tenant_quota_claim(): try: value = _service().issue_quota_claim( principal_id=str(g.current_user["id"]), host=_server_host(), request=_body(), ) response = jsonify(success(value, code=201)) response.headers["Cache-Control"] = "no-store" return response, 201 except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError): response = jsonify(failed("tenant control request rejected", code=400)) response.headers["Cache-Control"] = "no-store" return response, 400 @bp.post("/tenant/provisions") @require_permissions(IDENTITY_MANAGE) def provision_private_tenant(): try: value = _service().provision_private( principal_id=str(g.current_user["id"]), host=_server_host(), request=_body_provision() ) response = jsonify(success(value, code=201)) response.headers["Cache-Control"] = "no-store" return response, 201 except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError): response = jsonify(failed("tenant control request rejected", code=400)) response.headers["Cache-Control"] = "no-store" return response, 400 def _body_provision() -> dict: return _body({"idempotency_key"}) @bp.post("/tenant/approvals") @require_permissions(IDENTITY_MANAGE) def issue_tenant_lifecycle_approval(): try: value = _service().issue_lifecycle_approval( reviewer_id=str(g.current_user["id"]), request=_body_approval() ) response = jsonify(success(value, code=201)) response.headers["Cache-Control"] = "no-store" return response, 201 except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError): response = jsonify(failed("tenant approval request rejected", code=400)) response.headers["Cache-Control"] = "no-store" return response, 400 def _body_approval() -> dict: if request.content_length is not None and request.content_length > _MAX_REQUEST_BYTES: raise TenantControlError("tenant_payload_closed") if len(request.get_data(cache=True)) > _MAX_REQUEST_BYTES: raise TenantControlError("tenant_payload_closed") value = request.get_json(silent=True) if not isinstance(value, dict) or not set(value).issubset(_APPROVAL_FIELDS) or not {"operation", "expected_fence", "idempotency_key"}.issubset(value): raise TenantControlError("tenant_payload_closed") return value def _lifecycle(operation: str): try: value = _service().lifecycle_transition( principal_id=str(g.current_user["id"]), host=_server_host(), operation=operation, request=_body_lifecycle() ) response = jsonify(success(value)) response.headers["Cache-Control"] = "no-store" return response, 200 except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError): response = jsonify(failed("tenant lifecycle request rejected", code=400)) response.headers["Cache-Control"] = "no-store" return response, 400 @bp.post("/tenant/lifecycle/activate") @require_permissions(IDENTITY_MANAGE) def activate_tenant_lifecycle(): return _lifecycle("activate") @bp.post("/tenant/lifecycle/freeze") @require_permissions(IDENTITY_MANAGE) def freeze_tenant_lifecycle(): return _lifecycle("freeze") @bp.post("/tenant/lifecycle/recover") @require_permissions(IDENTITY_MANAGE) def recover_tenant_lifecycle(): return _lifecycle("begin_recovery") @bp.post("/tenant/lifecycle/deletion-candidate") @require_permissions(IDENTITY_MANAGE) def mark_tenant_deletion_candidate(): return _lifecycle("mark_deletion_candidate") @bp.post("/tenant/lifecycle/rollback") @require_permissions(IDENTITY_MANAGE) def rollback_tenant_lifecycle(): return _lifecycle("rollback") @bp.post("/tenant/lifecycle/delete") @require_permissions(IDENTITY_MANAGE) def delete_tenant_lifecycle(): return _lifecycle("delete") @bp.get("/tenant/status") @require_permissions(IDENTITY_READ) def get_tenant_status(): return _read("status") @bp.get("/tenant/audit") @require_permissions(IDENTITY_READ) def get_tenant_audit(): return _read("audit") @bp.get("/tenant/manifests") @require_permissions(IDENTITY_READ) def get_tenant_manifests(): return _read("manifests") def _read(kind: str): try: value = _service().read(principal_id=str(g.current_user["id"]), host=_server_host(), kind=kind) response = jsonify(success(value)) response.headers["Cache-Control"] = "no-store" return response, 200 except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError): response = jsonify(failed("tenant read request rejected", code=400)) response.headers["Cache-Control"] = "no-store" return response, 400 def _body_lifecycle() -> dict: if request.content_length is not None and request.content_length > _MAX_REQUEST_BYTES: raise TenantControlError("tenant_payload_closed") if len(request.get_data(cache=True)) > _MAX_REQUEST_BYTES: raise TenantControlError("tenant_payload_closed") value = request.get_json(silent=True) if not isinstance(value, dict) or not set(value).issubset(_LIFECYCLE_FIELDS) or not {"expected_fence", "idempotency_key"}.issubset(value): raise TenantControlError("tenant_payload_closed") return value