| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207 |
- """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
|