tenant_routes.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. """Authenticated, fail-closed HTTP entry points for the conditional WP10 base."""
  2. from __future__ import annotations
  3. import os
  4. from flask import g, jsonify, request
  5. from app.api.system import bp
  6. from app.core.system.permissions import (
  7. IDENTITY_MANAGE,
  8. IDENTITY_OPERATE,
  9. IDENTITY_READ,
  10. require_permissions,
  11. )
  12. from app.core.system.tenant_control_repository import SqlAlchemyTenantControlRepository
  13. from app.core.system.tenant_control_service import (
  14. TenantControlError,
  15. TenantControlService,
  16. )
  17. from app.models.result import failed, success
  18. _MAX_REQUEST_BYTES = 4096
  19. _QUOTA_FIELDS = {"quota_name", "amount", "idempotency_key"}
  20. _LIFECYCLE_FIELDS = {"expected_fence", "idempotency_key", "approval_ref", "backup_digest", "retention_seconds"}
  21. _APPROVAL_FIELDS = {"operation", "expected_fence", "idempotency_key", "backup_digest", "retention_seconds"}
  22. def _service() -> TenantControlService:
  23. return TenantControlService(SqlAlchemyTenantControlRepository())
  24. def _body(allowed: set[str] = _QUOTA_FIELDS) -> dict:
  25. if request.content_length is not None and request.content_length > _MAX_REQUEST_BYTES:
  26. raise TenantControlError("tenant_payload_closed")
  27. if len(request.get_data(cache=True)) > _MAX_REQUEST_BYTES:
  28. raise TenantControlError("tenant_payload_closed")
  29. value = request.get_json(silent=True)
  30. if not isinstance(value, dict) or set(value) != allowed:
  31. raise TenantControlError("tenant_payload_closed")
  32. return value
  33. def _server_host() -> str:
  34. # Deliberately never consume a client-supplied tenant field or a forwarded
  35. # host header. Trusted proxy normalization belongs in deployment; the
  36. # persistent DB membership is the final principal+route authorization.
  37. host = os.environ.get("TRUSTED_TENANT_ROUTE_HOST", "").lower()
  38. if not host:
  39. raise TenantControlError("tenant_route_denied")
  40. return host
  41. @bp.post("/tenant/quota-claims")
  42. @require_permissions(IDENTITY_OPERATE)
  43. def issue_tenant_quota_claim():
  44. try:
  45. value = _service().issue_quota_claim(
  46. principal_id=str(g.current_user["id"]),
  47. host=_server_host(),
  48. request=_body(),
  49. )
  50. response = jsonify(success(value, code=201))
  51. response.headers["Cache-Control"] = "no-store"
  52. return response, 201
  53. except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError):
  54. response = jsonify(failed("tenant control request rejected", code=400))
  55. response.headers["Cache-Control"] = "no-store"
  56. return response, 400
  57. @bp.post("/tenant/provisions")
  58. @require_permissions(IDENTITY_MANAGE)
  59. def provision_private_tenant():
  60. try:
  61. value = _service().provision_private(
  62. principal_id=str(g.current_user["id"]), host=_server_host(), request=_body_provision()
  63. )
  64. response = jsonify(success(value, code=201))
  65. response.headers["Cache-Control"] = "no-store"
  66. return response, 201
  67. except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError):
  68. response = jsonify(failed("tenant control request rejected", code=400))
  69. response.headers["Cache-Control"] = "no-store"
  70. return response, 400
  71. def _body_provision() -> dict:
  72. return _body({"idempotency_key"})
  73. @bp.post("/tenant/approvals")
  74. @require_permissions(IDENTITY_MANAGE)
  75. def issue_tenant_lifecycle_approval():
  76. try:
  77. value = _service().issue_lifecycle_approval(
  78. reviewer_id=str(g.current_user["id"]), request=_body_approval()
  79. )
  80. response = jsonify(success(value, code=201))
  81. response.headers["Cache-Control"] = "no-store"
  82. return response, 201
  83. except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError):
  84. response = jsonify(failed("tenant approval request rejected", code=400))
  85. response.headers["Cache-Control"] = "no-store"
  86. return response, 400
  87. def _body_approval() -> dict:
  88. if request.content_length is not None and request.content_length > _MAX_REQUEST_BYTES:
  89. raise TenantControlError("tenant_payload_closed")
  90. if len(request.get_data(cache=True)) > _MAX_REQUEST_BYTES:
  91. raise TenantControlError("tenant_payload_closed")
  92. value = request.get_json(silent=True)
  93. if not isinstance(value, dict) or not set(value).issubset(_APPROVAL_FIELDS) or not {"operation", "expected_fence", "idempotency_key"}.issubset(value):
  94. raise TenantControlError("tenant_payload_closed")
  95. return value
  96. def _lifecycle(operation: str):
  97. try:
  98. value = _service().lifecycle_transition(
  99. principal_id=str(g.current_user["id"]), host=_server_host(), operation=operation, request=_body_lifecycle()
  100. )
  101. response = jsonify(success(value))
  102. response.headers["Cache-Control"] = "no-store"
  103. return response, 200
  104. except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError):
  105. response = jsonify(failed("tenant lifecycle request rejected", code=400))
  106. response.headers["Cache-Control"] = "no-store"
  107. return response, 400
  108. @bp.post("/tenant/lifecycle/activate")
  109. @require_permissions(IDENTITY_MANAGE)
  110. def activate_tenant_lifecycle():
  111. return _lifecycle("activate")
  112. @bp.post("/tenant/lifecycle/freeze")
  113. @require_permissions(IDENTITY_MANAGE)
  114. def freeze_tenant_lifecycle():
  115. return _lifecycle("freeze")
  116. @bp.post("/tenant/lifecycle/recover")
  117. @require_permissions(IDENTITY_MANAGE)
  118. def recover_tenant_lifecycle():
  119. return _lifecycle("begin_recovery")
  120. @bp.post("/tenant/lifecycle/deletion-candidate")
  121. @require_permissions(IDENTITY_MANAGE)
  122. def mark_tenant_deletion_candidate():
  123. return _lifecycle("mark_deletion_candidate")
  124. @bp.post("/tenant/lifecycle/rollback")
  125. @require_permissions(IDENTITY_MANAGE)
  126. def rollback_tenant_lifecycle():
  127. return _lifecycle("rollback")
  128. @bp.post("/tenant/lifecycle/delete")
  129. @require_permissions(IDENTITY_MANAGE)
  130. def delete_tenant_lifecycle():
  131. return _lifecycle("delete")
  132. @bp.get("/tenant/status")
  133. @require_permissions(IDENTITY_READ)
  134. def get_tenant_status():
  135. return _read("status")
  136. @bp.get("/tenant/audit")
  137. @require_permissions(IDENTITY_READ)
  138. def get_tenant_audit():
  139. return _read("audit")
  140. @bp.get("/tenant/manifests")
  141. @require_permissions(IDENTITY_READ)
  142. def get_tenant_manifests():
  143. return _read("manifests")
  144. def _read(kind: str):
  145. try:
  146. value = _service().read(principal_id=str(g.current_user["id"]), host=_server_host(), kind=kind)
  147. response = jsonify(success(value))
  148. response.headers["Cache-Control"] = "no-store"
  149. return response, 200
  150. except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError):
  151. response = jsonify(failed("tenant read request rejected", code=400))
  152. response.headers["Cache-Control"] = "no-store"
  153. return response, 400
  154. def _body_lifecycle() -> dict:
  155. if request.content_length is not None and request.content_length > _MAX_REQUEST_BYTES:
  156. raise TenantControlError("tenant_payload_closed")
  157. if len(request.get_data(cache=True)) > _MAX_REQUEST_BYTES:
  158. raise TenantControlError("tenant_payload_closed")
  159. value = request.get_json(silent=True)
  160. if not isinstance(value, dict) or not set(value).issubset(_LIFECYCLE_FIELDS) or not {"expected_fence", "idempotency_key"}.issubset(value):
  161. raise TenantControlError("tenant_payload_closed")
  162. return value