612 lines
21 KiB
Python
612 lines
21 KiB
Python
"""Audited Owner authorization broker for task-scoped writer provisioning.
|
|
|
|
The broker closes the bootstrap gap without minting a new general-purpose
|
|
Owner token and without modifying the bearer-token registry. It accepts an
|
|
already-authenticated, explicitly allow-listed Owner principal, records a
|
|
tamper-evident authorization event, and invokes the existing
|
|
AuthorizedTaskWriterProvisioner with a generated authorization reference.
|
|
|
|
The broker never writes Git refs itself. The governed signer, validator and
|
|
one-shot provisioner remain the only components allowed to create the task
|
|
branch/worktree/writer.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Mapping, Protocol
|
|
|
|
from authorized_task_writer_provisioning import (
|
|
AuthorizedTaskWriterProvisioner,
|
|
BRANCH_RE,
|
|
OID_RE,
|
|
OwnerContext,
|
|
ProvisioningError,
|
|
REQUIRED_ALLOWED_OPERATIONS,
|
|
REQUIRED_DENIED_OPERATIONS,
|
|
TASK_RE,
|
|
)
|
|
|
|
|
|
ACTION = "AUTHORIZE_TASK_WRITER_PROVISIONING"
|
|
SCHEMA = "aios.owner-authorization-event.v1"
|
|
BROKER_REQUEST_FIELDS = frozenset(
|
|
{
|
|
"action",
|
|
"task_id",
|
|
"repository",
|
|
"base_ref",
|
|
"base_oid",
|
|
"target_branch",
|
|
"one_time_use",
|
|
"expires_in_seconds",
|
|
"production_change_authorized",
|
|
"tag_change_authorized",
|
|
"baseline_change_authorized",
|
|
"main_write_authorized",
|
|
"allowed_operations",
|
|
"denied_operations",
|
|
}
|
|
)
|
|
IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@:+/-]{1,254}$")
|
|
SCOPE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
|
CREDENTIAL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@+-]{1,255}$")
|
|
WILDCARD_SCOPE_RE = re.compile(r"(^\*$)|(^.+:\*$)")
|
|
|
|
|
|
class OwnerAuthorizationError(RuntimeError):
|
|
"""Stable fail-closed broker error."""
|
|
|
|
def __init__(
|
|
self,
|
|
code: str,
|
|
status: int = 400,
|
|
*,
|
|
details: Mapping[str, Any] | None = None,
|
|
) -> None:
|
|
super().__init__(code)
|
|
self.code = code
|
|
self.status = status
|
|
self.details = dict(details or {})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OwnerPrincipal:
|
|
"""Non-secret authentication evidence returned by the existing API."""
|
|
|
|
identity: str
|
|
role: str
|
|
authenticated_scope: str
|
|
token_scopes: frozenset[str]
|
|
credential_id: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OwnerAuthorizationBrokerConfig:
|
|
repository_identity: str
|
|
state_root: Path
|
|
allowed_owner_identities: frozenset[str]
|
|
allowed_intake_scopes: frozenset[str]
|
|
allowed_credential_ids: frozenset[str] = frozenset()
|
|
max_expiry_seconds: int = 900
|
|
|
|
@classmethod
|
|
def from_environment(cls) -> "OwnerAuthorizationBrokerConfig":
|
|
identities = _csv_set(
|
|
os.getenv("AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_IDENTITIES", "")
|
|
)
|
|
scopes = _csv_set(
|
|
os.getenv("AIOS_GITOPS_OWNER_AUTHORIZATION_INTAKE_SCOPES", "")
|
|
)
|
|
credential_ids = _csv_set(
|
|
os.getenv("AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_CREDENTIAL_IDS", "")
|
|
)
|
|
if not identities:
|
|
raise OwnerAuthorizationError(
|
|
"OWNER_IDENTITY_ALLOWLIST_REQUIRED", 503
|
|
)
|
|
if not scopes:
|
|
raise OwnerAuthorizationError(
|
|
"OWNER_INTAKE_SCOPE_ALLOWLIST_REQUIRED", 503
|
|
)
|
|
if any(_is_wildcard_scope(item) for item in scopes):
|
|
raise OwnerAuthorizationError(
|
|
"WILDCARD_INTAKE_SCOPE_REJECTED", 503
|
|
)
|
|
if "writer:provision" in scopes:
|
|
raise OwnerAuthorizationError(
|
|
"WRITER_PROVISION_SCOPE_BOOTSTRAP_REJECTED", 503
|
|
)
|
|
try:
|
|
maximum = int(
|
|
os.getenv(
|
|
"AIOS_GITOPS_OWNER_AUTHORIZATION_MAX_EXPIRY_SECONDS", "900"
|
|
)
|
|
)
|
|
except ValueError:
|
|
raise OwnerAuthorizationError(
|
|
"BROKER_MAX_EXPIRY_INVALID", 503
|
|
) from None
|
|
if maximum < 60 or maximum > 1800:
|
|
raise OwnerAuthorizationError("BROKER_MAX_EXPIRY_INVALID", 503)
|
|
return cls(
|
|
repository_identity=os.getenv(
|
|
"AIOS_GITOPS_REPOSITORY_IDENTITY", "aios/aios"
|
|
).strip(),
|
|
state_root=Path(
|
|
os.getenv(
|
|
"AIOS_GITOPS_OWNER_AUTHORIZATION_STATE_ROOT",
|
|
"/var/lib/aios-gitops/owner-authorization-broker",
|
|
)
|
|
),
|
|
allowed_owner_identities=identities,
|
|
allowed_intake_scopes=scopes,
|
|
allowed_credential_ids=credential_ids,
|
|
max_expiry_seconds=maximum,
|
|
)
|
|
|
|
|
|
class TaskWriterProvisioner(Protocol):
|
|
def provision(
|
|
self, raw: Mapping[str, Any], owner: OwnerContext
|
|
) -> dict[str, Any]:
|
|
...
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def iso_utc(value: datetime) -> str:
|
|
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def canonical_json_bytes(value: Any) -> bytes:
|
|
return (
|
|
json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
+ "\n"
|
|
).encode("utf-8")
|
|
|
|
|
|
def sha256_json(value: Any) -> str:
|
|
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
|
|
|
|
|
def _csv_set(value: str) -> frozenset[str]:
|
|
return frozenset(
|
|
item.strip() for item in value.split(",") if item.strip()
|
|
)
|
|
|
|
|
|
def _is_wildcard_scope(scope: str) -> bool:
|
|
return bool(WILDCARD_SCOPE_RE.fullmatch(scope.strip()))
|
|
|
|
|
|
def _atomic_write_json(path: Path, value: Any, mode: int = 0o600) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if path.parent.is_symlink() or path.is_symlink():
|
|
raise OwnerAuthorizationError("BROKER_STATE_SYMLINK_REJECTED", 500)
|
|
fd, temporary = tempfile.mkstemp(prefix=".tmp-", dir=path.parent)
|
|
try:
|
|
os.fchmod(fd, mode)
|
|
with os.fdopen(fd, "wb", closefd=True) as stream:
|
|
stream.write(canonical_json_bytes(value))
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
os.chmod(path, mode)
|
|
parent_fd = os.open(
|
|
path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
)
|
|
try:
|
|
os.fsync(parent_fd)
|
|
finally:
|
|
os.close(parent_fd)
|
|
finally:
|
|
if os.path.exists(temporary):
|
|
os.unlink(temporary)
|
|
|
|
|
|
def _read_json(path: Path) -> dict[str, Any]:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise OwnerAuthorizationError("BROKER_RECEIPT_INVALID", 500)
|
|
if path.stat().st_size <= 0 or path.stat().st_size > 1_048_576:
|
|
raise OwnerAuthorizationError("BROKER_RECEIPT_INVALID", 500)
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
raise OwnerAuthorizationError(
|
|
"BROKER_RECEIPT_JSON_INVALID", 500
|
|
) from None
|
|
if not isinstance(value, dict):
|
|
raise OwnerAuthorizationError("BROKER_RECEIPT_INVALID", 500)
|
|
return value
|
|
|
|
|
|
def _prepare_state_root(root: Path) -> None:
|
|
if root.is_symlink():
|
|
raise OwnerAuthorizationError("BROKER_STATE_SYMLINK_REJECTED", 500)
|
|
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
os.chmod(root, 0o700)
|
|
if root.stat().st_mode & 0o077:
|
|
raise OwnerAuthorizationError(
|
|
"BROKER_STATE_PERMISSIONS_REJECTED", 500
|
|
)
|
|
|
|
|
|
def _event_id(
|
|
principal: OwnerPrincipal, request_digest: str
|
|
) -> str:
|
|
material = (
|
|
f"{principal.identity}\0{principal.authenticated_scope}\0"
|
|
f"{request_digest}"
|
|
).encode("utf-8")
|
|
return "owner-auth-" + hashlib.sha256(material).hexdigest()[:32]
|
|
|
|
|
|
def _authorization_reference(event_id: str, request_digest: str) -> str:
|
|
return f"owner-auth:{event_id}:{request_digest[:16]}"
|
|
|
|
|
|
def _validate_principal(
|
|
principal: OwnerPrincipal,
|
|
config: OwnerAuthorizationBrokerConfig,
|
|
) -> None:
|
|
if principal.role != "owner":
|
|
raise OwnerAuthorizationError("OWNER_ROLE_REQUIRED", 403)
|
|
if (
|
|
not principal.identity
|
|
or not IDENTITY_RE.fullmatch(principal.identity)
|
|
or principal.identity not in config.allowed_owner_identities
|
|
):
|
|
raise OwnerAuthorizationError("OWNER_IDENTITY_NOT_ALLOWED", 403)
|
|
if (
|
|
not principal.authenticated_scope
|
|
or not SCOPE_RE.fullmatch(principal.authenticated_scope)
|
|
or principal.authenticated_scope
|
|
not in config.allowed_intake_scopes
|
|
):
|
|
raise OwnerAuthorizationError("OWNER_INTAKE_SCOPE_REJECTED", 403)
|
|
if principal.authenticated_scope == "writer:provision":
|
|
raise OwnerAuthorizationError(
|
|
"WRITER_PROVISION_SCOPE_BOOTSTRAP_REJECTED", 403
|
|
)
|
|
|
|
scopes = principal.token_scopes
|
|
if any(_is_wildcard_scope(scope) for scope in scopes):
|
|
raise OwnerAuthorizationError(
|
|
"WILDCARD_OWNER_CREDENTIAL_REJECTED", 403
|
|
)
|
|
if scopes:
|
|
if principal.authenticated_scope not in scopes:
|
|
raise OwnerAuthorizationError(
|
|
"OWNER_SCOPE_EVIDENCE_MISMATCH", 403
|
|
)
|
|
elif (
|
|
not principal.credential_id
|
|
or principal.credential_id not in config.allowed_credential_ids
|
|
):
|
|
# handler.need(scope) proves authorization but may hide whether the
|
|
# credential was wildcard. When scope metadata is absent, require an
|
|
# exact non-secret credential ID allowlist as the alternative proof.
|
|
raise OwnerAuthorizationError(
|
|
"OWNER_CREDENTIAL_SCOPE_NOT_PROVABLE", 403
|
|
)
|
|
if principal.credential_id:
|
|
if not CREDENTIAL_ID_RE.fullmatch(principal.credential_id):
|
|
raise OwnerAuthorizationError("OWNER_CREDENTIAL_ID_INVALID", 403)
|
|
if (
|
|
config.allowed_credential_ids
|
|
and principal.credential_id not in config.allowed_credential_ids
|
|
):
|
|
raise OwnerAuthorizationError(
|
|
"OWNER_CREDENTIAL_ID_NOT_ALLOWED", 403
|
|
)
|
|
|
|
|
|
def _validate_request(
|
|
raw: Mapping[str, Any],
|
|
config: OwnerAuthorizationBrokerConfig,
|
|
) -> dict[str, Any]:
|
|
if set(raw) != BROKER_REQUEST_FIELDS:
|
|
raise OwnerAuthorizationError("BROKER_REQUEST_FIELDS_REJECTED")
|
|
if raw["action"] != ACTION:
|
|
raise OwnerAuthorizationError("BROKER_ACTION_REJECTED")
|
|
if raw["repository"] != config.repository_identity:
|
|
raise OwnerAuthorizationError(
|
|
"REPOSITORY_IDENTITY_MISMATCH", 409
|
|
)
|
|
if raw["base_ref"] != "refs/heads/main":
|
|
raise OwnerAuthorizationError("BASE_REF_REJECTED", 409)
|
|
if (
|
|
not isinstance(raw["base_oid"], str)
|
|
or not OID_RE.fullmatch(raw["base_oid"])
|
|
):
|
|
raise OwnerAuthorizationError("BASE_OID_REJECTED")
|
|
if (
|
|
not isinstance(raw["target_branch"], str)
|
|
or not BRANCH_RE.fullmatch(raw["target_branch"])
|
|
or raw["target_branch"] in {"main", "master"}
|
|
):
|
|
raise OwnerAuthorizationError("TARGET_BRANCH_REJECTED")
|
|
if (
|
|
not isinstance(raw["task_id"], str)
|
|
or not TASK_RE.fullmatch(raw["task_id"])
|
|
):
|
|
raise OwnerAuthorizationError("TASK_ID_REJECTED")
|
|
if raw["one_time_use"] is not True:
|
|
raise OwnerAuthorizationError("ONE_TIME_USE_REQUIRED")
|
|
expiry = raw["expires_in_seconds"]
|
|
if (
|
|
not isinstance(expiry, int)
|
|
or isinstance(expiry, bool)
|
|
or not 60 <= expiry <= config.max_expiry_seconds
|
|
):
|
|
raise OwnerAuthorizationError("GRANT_LIFETIME_REJECTED")
|
|
for field in (
|
|
"production_change_authorized",
|
|
"tag_change_authorized",
|
|
"baseline_change_authorized",
|
|
"main_write_authorized",
|
|
):
|
|
if raw[field] is not False:
|
|
raise OwnerAuthorizationError(
|
|
"PRODUCTION_CAPABILITY_REJECTED", 403
|
|
)
|
|
allowed = raw["allowed_operations"]
|
|
denied = raw["denied_operations"]
|
|
if (
|
|
not isinstance(allowed, list)
|
|
or frozenset(allowed) != REQUIRED_ALLOWED_OPERATIONS
|
|
):
|
|
raise OwnerAuthorizationError("ALLOWED_OPERATIONS_MISMATCH", 403)
|
|
if (
|
|
not isinstance(denied, list)
|
|
or frozenset(denied) != REQUIRED_DENIED_OPERATIONS
|
|
):
|
|
raise OwnerAuthorizationError("DENIED_OPERATIONS_MISMATCH", 403)
|
|
return dict(raw)
|
|
|
|
|
|
def _response_is_safe(value: Any) -> bool:
|
|
"""Reject accidental credential material before writing a receipt."""
|
|
|
|
sensitive_markers = (
|
|
"access_token",
|
|
"bearer",
|
|
"client_secret",
|
|
"private_key",
|
|
"raw_token",
|
|
"secret_value",
|
|
)
|
|
if isinstance(value, Mapping):
|
|
for key, inner in value.items():
|
|
lowered = str(key).lower()
|
|
if any(marker in lowered for marker in sensitive_markers):
|
|
return False
|
|
if not _response_is_safe(inner):
|
|
return False
|
|
elif isinstance(value, list):
|
|
return all(_response_is_safe(item) for item in value)
|
|
return True
|
|
|
|
|
|
class OwnerAuthorizationBroker:
|
|
"""Create an audited Owner event and invoke the governed bridge."""
|
|
|
|
def __init__(
|
|
self,
|
|
config: OwnerAuthorizationBrokerConfig | None = None,
|
|
provisioner: TaskWriterProvisioner | None = None,
|
|
) -> None:
|
|
self.config = (
|
|
config or OwnerAuthorizationBrokerConfig.from_environment()
|
|
)
|
|
self.provisioner = provisioner or AuthorizedTaskWriterProvisioner()
|
|
|
|
def authorize_and_provision(
|
|
self,
|
|
raw: Mapping[str, Any],
|
|
principal: OwnerPrincipal,
|
|
) -> dict[str, Any]:
|
|
config = self.config
|
|
_validate_principal(principal, config)
|
|
request = _validate_request(raw, config)
|
|
_prepare_state_root(config.state_root)
|
|
|
|
request_digest = sha256_json(request)
|
|
event_id = _event_id(principal, request_digest)
|
|
reference = _authorization_reference(event_id, request_digest)
|
|
event_dir = config.state_root / "events" / event_id
|
|
event_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
os.chmod(event_dir, 0o700)
|
|
|
|
lock_path = event_dir / "event.lock"
|
|
lock_path.touch(mode=0o600, exist_ok=True)
|
|
os.chmod(lock_path, 0o600)
|
|
with lock_path.open("r+b") as lock:
|
|
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
response_path = event_dir / "response.json"
|
|
event_path = event_dir / "event.json"
|
|
|
|
if response_path.exists():
|
|
response = _read_json(response_path)
|
|
response["owner_authorization_idempotent"] = True
|
|
return response
|
|
|
|
issued_at = utc_now()
|
|
expires_at = issued_at + timedelta(
|
|
seconds=int(request["expires_in_seconds"])
|
|
)
|
|
event: dict[str, Any] = {
|
|
"schema": SCHEMA,
|
|
"event_id": event_id,
|
|
"authorization_reference": reference,
|
|
"status": "AUTHORIZED",
|
|
"owner": {
|
|
"identity": principal.identity,
|
|
"role": principal.role,
|
|
"authenticated_scope": principal.authenticated_scope,
|
|
"credential_id": principal.credential_id or None,
|
|
"token_scope_digest_sha256": sha256_json(
|
|
sorted(principal.token_scopes)
|
|
),
|
|
"wildcard_scope_used": False,
|
|
},
|
|
"request": {
|
|
"task_id": request["task_id"],
|
|
"repository": request["repository"],
|
|
"base_ref": request["base_ref"],
|
|
"base_oid": request["base_oid"],
|
|
"target_branch": request["target_branch"],
|
|
"one_time_use": True,
|
|
"production_change_authorized": False,
|
|
"tag_change_authorized": False,
|
|
"baseline_change_authorized": False,
|
|
"main_write_authorized": False,
|
|
"allowed_operations": sorted(
|
|
REQUIRED_ALLOWED_OPERATIONS
|
|
),
|
|
"denied_operations": sorted(
|
|
REQUIRED_DENIED_OPERATIONS
|
|
),
|
|
},
|
|
"request_digest_sha256": request_digest,
|
|
"issued_at": iso_utc(issued_at),
|
|
"expires_at": iso_utc(expires_at),
|
|
}
|
|
event["event_digest_sha256"] = sha256_json(event)
|
|
_atomic_write_json(event_path, event)
|
|
|
|
bridge_request = dict(request)
|
|
bridge_request.pop("action")
|
|
bridge_request["owner_authorization_reference"] = reference
|
|
try:
|
|
result = self.provisioner.provision(
|
|
bridge_request,
|
|
OwnerContext(
|
|
identity=principal.identity,
|
|
role=principal.role,
|
|
authorization_reference=reference,
|
|
),
|
|
)
|
|
if not isinstance(result, dict):
|
|
raise OwnerAuthorizationError(
|
|
"PROVISIONER_RESPONSE_INVALID", 502
|
|
)
|
|
if result.get("status") != "READY_FOR_CODE_AUTHORING":
|
|
raise OwnerAuthorizationError(
|
|
"PROVISIONER_NOT_READY",
|
|
409,
|
|
details={"status": result.get("status")},
|
|
)
|
|
if not _response_is_safe(result):
|
|
raise OwnerAuthorizationError(
|
|
"PROVISIONER_RESPONSE_CONTAINS_SENSITIVE_DATA", 500
|
|
)
|
|
except ProvisioningError as exc:
|
|
event.update(
|
|
{
|
|
"status": "PROVISIONING_BLOCKED",
|
|
"failed_at": iso_utc(utc_now()),
|
|
"error": exc.code,
|
|
"error_status": exc.status,
|
|
}
|
|
)
|
|
event["event_digest_sha256"] = sha256_json(
|
|
{
|
|
key: value
|
|
for key, value in event.items()
|
|
if key != "event_digest_sha256"
|
|
}
|
|
)
|
|
_atomic_write_json(event_path, event)
|
|
raise
|
|
except OwnerAuthorizationError as exc:
|
|
event.update(
|
|
{
|
|
"status": "PROVISIONING_BLOCKED",
|
|
"failed_at": iso_utc(utc_now()),
|
|
"error": exc.code,
|
|
"error_status": exc.status,
|
|
}
|
|
)
|
|
event["event_digest_sha256"] = sha256_json(
|
|
{
|
|
key: value
|
|
for key, value in event.items()
|
|
if key != "event_digest_sha256"
|
|
}
|
|
)
|
|
_atomic_write_json(event_path, event)
|
|
raise
|
|
except Exception as exc:
|
|
event.update(
|
|
{
|
|
"status": "PROVISIONING_FAILED",
|
|
"failed_at": iso_utc(utc_now()),
|
|
"error": "OWNER_AUTHORIZATION_BROKER_INTERNAL_ERROR",
|
|
}
|
|
)
|
|
event["event_digest_sha256"] = sha256_json(
|
|
{
|
|
key: value
|
|
for key, value in event.items()
|
|
if key != "event_digest_sha256"
|
|
}
|
|
)
|
|
_atomic_write_json(event_path, event)
|
|
raise OwnerAuthorizationError(
|
|
"OWNER_AUTHORIZATION_BROKER_INTERNAL_ERROR", 500
|
|
) from exc
|
|
|
|
completed_at = utc_now()
|
|
event.update(
|
|
{
|
|
"status": "CONSUMED",
|
|
"consumed_at": iso_utc(completed_at),
|
|
"approval_plan_id": result.get("approval_plan_id"),
|
|
"grant_id": result.get("grant_id"),
|
|
"grant_digest_sha256": result.get(
|
|
"grant_digest_sha256"
|
|
),
|
|
"branch_oid": result.get("branch_oid"),
|
|
"grant_consumed": result.get("grant_consumed") is True,
|
|
}
|
|
)
|
|
event["event_digest_sha256"] = sha256_json(
|
|
{
|
|
key: value
|
|
for key, value in event.items()
|
|
if key != "event_digest_sha256"
|
|
}
|
|
)
|
|
_atomic_write_json(event_path, event)
|
|
|
|
response = dict(result)
|
|
response.update(
|
|
{
|
|
"owner_authorization_event_id": event_id,
|
|
"owner_authorization_reference": reference,
|
|
"owner_authorization_digest_sha256": event[
|
|
"event_digest_sha256"
|
|
],
|
|
"owner_authorization_consumed": True,
|
|
"owner_authorization_idempotent": False,
|
|
}
|
|
)
|
|
_atomic_write_json(response_path, response)
|
|
return response
|