681 lines
23 KiB
Python
681 lines
23 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import copy
|
|||
|
|
import hashlib
|
|||
|
|
import json
|
|||
|
|
import math
|
|||
|
|
import os
|
|||
|
|
import tempfile
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
import uuid
|
|||
|
|
import warnings
|
|||
|
|
from collections.abc import Callable, Iterator
|
|||
|
|
from contextlib import contextmanager
|
|||
|
|
from datetime import UTC, datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any, Protocol
|
|||
|
|
|
|||
|
|
_SCHEMA_VERSION = 1
|
|||
|
|
_MAX_HISTORY = 2_000
|
|||
|
|
_DATABASE_BACKEND = "database"
|
|||
|
|
_FILE_BACKEND = "file"
|
|||
|
|
|
|||
|
|
RecordPredicate = Callable[[dict[str, Any]], bool]
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ApprovalBackend(Protocol):
|
|||
|
|
"""Atomic approval operations shared by file and database backends."""
|
|||
|
|
|
|||
|
|
backend: str
|
|||
|
|
|
|||
|
|
def stage(self, record: dict[str, Any], *, now_epoch: float) -> bool: ...
|
|||
|
|
|
|||
|
|
def decide(
|
|||
|
|
self,
|
|||
|
|
confirm_id: str,
|
|||
|
|
*,
|
|||
|
|
approve: bool,
|
|||
|
|
approver: dict[str, Any],
|
|||
|
|
allowed: RecordPredicate,
|
|||
|
|
now_epoch: float,
|
|||
|
|
grant_ttl_seconds: int,
|
|||
|
|
note: str | None = None,
|
|||
|
|
) -> dict[str, Any] | None: ...
|
|||
|
|
|
|||
|
|
def pending_record(
|
|||
|
|
self, confirm_id: str, *, allowed: RecordPredicate, now_epoch: float
|
|||
|
|
) -> dict[str, Any] | None: ...
|
|||
|
|
|
|||
|
|
def transfer(
|
|||
|
|
self,
|
|||
|
|
confirm_id: str,
|
|||
|
|
*,
|
|||
|
|
to_user_id: int,
|
|||
|
|
allowed: RecordPredicate,
|
|||
|
|
now_epoch: float,
|
|||
|
|
actor: dict[str, Any],
|
|||
|
|
) -> dict[str, Any] | None: ...
|
|||
|
|
|
|||
|
|
def consume_grant(
|
|||
|
|
self,
|
|||
|
|
grant: str,
|
|||
|
|
*,
|
|||
|
|
confirm_id: str,
|
|||
|
|
action: str,
|
|||
|
|
params_hash: str,
|
|||
|
|
allowed: RecordPredicate,
|
|||
|
|
decided_by: dict[str, Any],
|
|||
|
|
now_epoch: float,
|
|||
|
|
) -> bool: ...
|
|||
|
|
|
|||
|
|
def pending_items(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
tenant_uuid: str,
|
|||
|
|
project_id: str,
|
|||
|
|
allowed: RecordPredicate,
|
|||
|
|
now_epoch: float,
|
|||
|
|
) -> list[dict[str, Any]]: ...
|
|||
|
|
|
|||
|
|
def history_items(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
tenant_uuid: str,
|
|||
|
|
project_id: str,
|
|||
|
|
allowed: RecordPredicate,
|
|||
|
|
limit: int,
|
|||
|
|
now_epoch: float,
|
|||
|
|
) -> list[dict[str, Any]]: ...
|
|||
|
|
|
|||
|
|
def expire(self, *, now_epoch: float | None = None) -> bool: ...
|
|||
|
|
|
|||
|
|
def clear(self) -> None: ...
|
|||
|
|
|
|||
|
|
|
|||
|
|
def utc_now(epoch: float | None = None) -> str:
|
|||
|
|
value = time.time() if epoch is None else epoch
|
|||
|
|
return datetime.fromtimestamp(value, tz=UTC).isoformat()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def token_digest(token: str) -> str:
|
|||
|
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def history_event(
|
|||
|
|
record: dict[str, Any],
|
|||
|
|
*,
|
|||
|
|
status: str,
|
|||
|
|
decided_by: dict[str, Any] | None = None,
|
|||
|
|
at_epoch: float | None = None,
|
|||
|
|
note: str | None = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
decided_epoch = time.time() if at_epoch is None else at_epoch
|
|||
|
|
created_epoch = record.get("createdAtEpoch")
|
|||
|
|
dwell_ms = None
|
|||
|
|
if isinstance(created_epoch, (int, float)):
|
|||
|
|
dwell_ms = max(0, round((decided_epoch - float(created_epoch)) * 1000))
|
|||
|
|
event = {
|
|||
|
|
"confirmId": record.get("confirmId"),
|
|||
|
|
"sessionId": record.get("sessionId"),
|
|||
|
|
"action": record.get("action"),
|
|||
|
|
"power": record.get("power"),
|
|||
|
|
"status": status,
|
|||
|
|
"tenantUuid": record.get("tenantUuid"),
|
|||
|
|
"projectId": record.get("projectId"),
|
|||
|
|
"worldKey": record.get("worldKey"),
|
|||
|
|
"ownerUserId": record.get("ownerUserId"),
|
|||
|
|
"requester": record.get("requester"),
|
|||
|
|
"paramsHash": record.get("paramsHash"),
|
|||
|
|
"approvals": list(record.get("approvals") or []),
|
|||
|
|
"createdAt": record.get("createdAt"),
|
|||
|
|
"expiresAt": record.get("expiresAt"),
|
|||
|
|
"decidedAt": utc_now(decided_epoch),
|
|||
|
|
"dwellMs": dwell_ms,
|
|||
|
|
}
|
|||
|
|
if decided_by is not None:
|
|||
|
|
event["decidedBy"] = decided_by
|
|||
|
|
if note:
|
|||
|
|
event["note"] = note
|
|||
|
|
return event
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _approver_key(approver: dict[str, Any]) -> str:
|
|||
|
|
return str(approver.get("userId") or approver.get("username") or "")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _grant_record(
|
|||
|
|
request: dict[str, Any],
|
|||
|
|
*,
|
|||
|
|
approvals: list[dict[str, Any]],
|
|||
|
|
now_epoch: float,
|
|||
|
|
grant_ttl_seconds: int,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
return {
|
|||
|
|
"confirmId": request.get("confirmId"),
|
|||
|
|
"sessionId": request.get("sessionId"),
|
|||
|
|
"action": request.get("action"),
|
|||
|
|
"power": request.get("power"),
|
|||
|
|
"tenantUuid": request.get("tenantUuid"),
|
|||
|
|
"ownerUserId": request.get("ownerUserId"),
|
|||
|
|
"projectId": request.get("projectId"),
|
|||
|
|
"worldKey": request.get("worldKey"),
|
|||
|
|
"requester": request.get("requester"),
|
|||
|
|
"approvals": approvals,
|
|||
|
|
"paramsHash": request.get("paramsHash"),
|
|||
|
|
"createdAt": utc_now(now_epoch),
|
|||
|
|
"createdAtEpoch": now_epoch,
|
|||
|
|
"expiresAt": utc_now(now_epoch + grant_ttl_seconds),
|
|||
|
|
"expiresAtEpoch": now_epoch + grant_ttl_seconds,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def default_approval_path() -> str:
|
|||
|
|
configured = os.environ.get("APS_APPROVAL_PATH")
|
|||
|
|
if configured:
|
|||
|
|
return str(Path(configured).expanduser().resolve())
|
|||
|
|
from server.aps_home import path_under_data
|
|||
|
|
|
|||
|
|
return str(path_under_data("approvals.json"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ApprovalStore:
|
|||
|
|
"""Persistent approval queue and one-time execution grants."""
|
|||
|
|
|
|||
|
|
backend = _FILE_BACKEND
|
|||
|
|
|
|||
|
|
def __init__(self, path: str | None = None) -> None:
|
|||
|
|
self.path = path or default_approval_path()
|
|||
|
|
self.lock_path = f"{self.path}.lock"
|
|||
|
|
self.lock = threading.RLock()
|
|||
|
|
self._transaction_state = threading.local()
|
|||
|
|
state = self._load()
|
|||
|
|
self.pending: dict[str, dict[str, Any]] = state["pending"]
|
|||
|
|
self.grants: dict[str, dict[str, Any]] = state["grants"]
|
|||
|
|
self.history: list[dict[str, Any]] = state["history"]
|
|||
|
|
|
|||
|
|
def _empty(self) -> dict[str, Any]:
|
|||
|
|
return {
|
|||
|
|
"schemaVersion": _SCHEMA_VERSION,
|
|||
|
|
"pending": {},
|
|||
|
|
"grants": {},
|
|||
|
|
"history": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _quarantine(self, reason: str) -> None:
|
|||
|
|
if not os.path.exists(self.path):
|
|||
|
|
return
|
|||
|
|
quarantined = f"{self.path}.corrupt-{int(time.time() * 1000)}-{os.getpid()}"
|
|||
|
|
try:
|
|||
|
|
os.replace(self.path, quarantined)
|
|||
|
|
except OSError as exc:
|
|||
|
|
warnings.warn(
|
|||
|
|
f"approval store is invalid ({reason}) and could not be quarantined: {exc}",
|
|||
|
|
RuntimeWarning,
|
|||
|
|
stacklevel=2,
|
|||
|
|
)
|
|||
|
|
return
|
|||
|
|
warnings.warn(
|
|||
|
|
f"approval store is invalid ({reason}); moved to {quarantined}",
|
|||
|
|
RuntimeWarning,
|
|||
|
|
stacklevel=2,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _records_valid(records: dict[str, Any]) -> bool:
|
|||
|
|
for key, record in records.items():
|
|||
|
|
if not isinstance(key, str) or not isinstance(record, dict):
|
|||
|
|
return False
|
|||
|
|
try:
|
|||
|
|
expires_at = float(record.get("expiresAtEpoch"))
|
|||
|
|
except (TypeError, ValueError):
|
|||
|
|
return False
|
|||
|
|
if (
|
|||
|
|
not math.isfinite(expires_at)
|
|||
|
|
or expires_at <= 0
|
|||
|
|
or not isinstance(record.get("action"), str)
|
|||
|
|
):
|
|||
|
|
return False
|
|||
|
|
if not isinstance(record.get("tenantUuid"), str):
|
|||
|
|
return False
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def _load(self) -> dict[str, Any]:
|
|||
|
|
try:
|
|||
|
|
with open(self.path, "r", encoding="utf-8") as handle:
|
|||
|
|
raw = json.load(handle)
|
|||
|
|
except FileNotFoundError:
|
|||
|
|
return self._empty()
|
|||
|
|
except (json.JSONDecodeError, OSError, TypeError) as exc:
|
|||
|
|
self._quarantine(type(exc).__name__)
|
|||
|
|
return self._empty()
|
|||
|
|
if not isinstance(raw, dict) or raw.get("schemaVersion") != _SCHEMA_VERSION:
|
|||
|
|
self._quarantine("schema")
|
|||
|
|
return self._empty()
|
|||
|
|
pending = raw.get("pending")
|
|||
|
|
grants = raw.get("grants")
|
|||
|
|
history = raw.get("history")
|
|||
|
|
if not isinstance(pending, dict) or not isinstance(grants, dict) or not isinstance(history, list):
|
|||
|
|
self._quarantine("shape")
|
|||
|
|
return self._empty()
|
|||
|
|
if not self._records_valid(pending) or not self._records_valid(grants):
|
|||
|
|
self._quarantine("record")
|
|||
|
|
return self._empty()
|
|||
|
|
if any(not isinstance(record, dict) for record in history):
|
|||
|
|
self._quarantine("history")
|
|||
|
|
return self._empty()
|
|||
|
|
return {
|
|||
|
|
"schemaVersion": _SCHEMA_VERSION,
|
|||
|
|
"pending": pending,
|
|||
|
|
"grants": grants,
|
|||
|
|
"history": history[-_MAX_HISTORY:],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _refresh(self) -> None:
|
|||
|
|
state = self._load()
|
|||
|
|
self.pending.clear()
|
|||
|
|
self.pending.update(state["pending"])
|
|||
|
|
self.grants.clear()
|
|||
|
|
self.grants.update(state["grants"])
|
|||
|
|
self.history[:] = state["history"]
|
|||
|
|
|
|||
|
|
@contextmanager
|
|||
|
|
def _process_lock(self) -> Iterator[None]:
|
|||
|
|
parent = os.path.dirname(self.lock_path) or "."
|
|||
|
|
os.makedirs(parent, exist_ok=True)
|
|||
|
|
with open(self.lock_path, "a+b") as handle:
|
|||
|
|
handle.seek(0, os.SEEK_END)
|
|||
|
|
if handle.tell() == 0:
|
|||
|
|
handle.write(b"\0")
|
|||
|
|
handle.flush()
|
|||
|
|
handle.seek(0)
|
|||
|
|
if os.name == "nt":
|
|||
|
|
import msvcrt
|
|||
|
|
|
|||
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|||
|
|
try:
|
|||
|
|
yield
|
|||
|
|
finally:
|
|||
|
|
handle.seek(0)
|
|||
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|||
|
|
else:
|
|||
|
|
import fcntl
|
|||
|
|
|
|||
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|||
|
|
try:
|
|||
|
|
yield
|
|||
|
|
finally:
|
|||
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|||
|
|
|
|||
|
|
@contextmanager
|
|||
|
|
def transaction(self, *, refresh: bool = True) -> Iterator[None]:
|
|||
|
|
"""Serialize a read-modify-write operation across threads and processes."""
|
|||
|
|
with self.lock:
|
|||
|
|
depth = int(getattr(self._transaction_state, "depth", 0))
|
|||
|
|
if depth:
|
|||
|
|
self._transaction_state.depth = depth + 1
|
|||
|
|
try:
|
|||
|
|
yield
|
|||
|
|
finally:
|
|||
|
|
self._transaction_state.depth = depth
|
|||
|
|
return
|
|||
|
|
with self._process_lock():
|
|||
|
|
if refresh:
|
|||
|
|
self._refresh()
|
|||
|
|
self._transaction_state.depth = 1
|
|||
|
|
try:
|
|||
|
|
yield
|
|||
|
|
finally:
|
|||
|
|
self._transaction_state.depth = 0
|
|||
|
|
|
|||
|
|
def _write(self) -> None:
|
|||
|
|
parent = os.path.dirname(self.path) or "."
|
|||
|
|
os.makedirs(parent, exist_ok=True)
|
|||
|
|
fd, temporary = tempfile.mkstemp(dir=parent, suffix=".tmp")
|
|||
|
|
try:
|
|||
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|||
|
|
json.dump(
|
|||
|
|
{
|
|||
|
|
"schemaVersion": _SCHEMA_VERSION,
|
|||
|
|
"pending": self.pending,
|
|||
|
|
"grants": self.grants,
|
|||
|
|
"history": self.history[-_MAX_HISTORY:],
|
|||
|
|
},
|
|||
|
|
handle,
|
|||
|
|
ensure_ascii=False,
|
|||
|
|
separators=(",", ":"),
|
|||
|
|
)
|
|||
|
|
handle.flush()
|
|||
|
|
os.fsync(handle.fileno())
|
|||
|
|
os.replace(temporary, self.path)
|
|||
|
|
except BaseException:
|
|||
|
|
if os.path.exists(temporary):
|
|||
|
|
os.unlink(temporary)
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
def save(self) -> None:
|
|||
|
|
if int(getattr(self._transaction_state, "depth", 0)):
|
|||
|
|
self._write()
|
|||
|
|
return
|
|||
|
|
with self.transaction(refresh=False):
|
|||
|
|
self._write()
|
|||
|
|
|
|||
|
|
def append_history(
|
|||
|
|
self,
|
|||
|
|
record: dict[str, Any],
|
|||
|
|
*,
|
|||
|
|
status: str,
|
|||
|
|
decided_by: dict[str, Any] | None = None,
|
|||
|
|
at_epoch: float | None = None,
|
|||
|
|
note: str | None = None,
|
|||
|
|
) -> None:
|
|||
|
|
self.history.append(history_event(
|
|||
|
|
record,
|
|||
|
|
status=status,
|
|||
|
|
decided_by=decided_by,
|
|||
|
|
at_epoch=at_epoch,
|
|||
|
|
note=note,
|
|||
|
|
))
|
|||
|
|
self.history = self.history[-_MAX_HISTORY:]
|
|||
|
|
|
|||
|
|
def stage(self, record: dict[str, Any], *, now_epoch: float) -> bool:
|
|||
|
|
with self.transaction():
|
|||
|
|
self.expire(now_epoch=now_epoch)
|
|||
|
|
confirm_id = str(record.get("confirmId") or "")
|
|||
|
|
if not confirm_id or confirm_id in self.pending:
|
|||
|
|
return False
|
|||
|
|
self.pending[confirm_id] = copy.deepcopy(record)
|
|||
|
|
self.save()
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def decide(
|
|||
|
|
self,
|
|||
|
|
confirm_id: str,
|
|||
|
|
*,
|
|||
|
|
approve: bool,
|
|||
|
|
approver: dict[str, Any],
|
|||
|
|
allowed: RecordPredicate,
|
|||
|
|
now_epoch: float,
|
|||
|
|
grant_ttl_seconds: int,
|
|||
|
|
note: str | None = None,
|
|||
|
|
) -> dict[str, Any] | None:
|
|||
|
|
with self.transaction():
|
|||
|
|
self.expire(now_epoch=now_epoch)
|
|||
|
|
pending = self.pending.get(confirm_id)
|
|||
|
|
if pending is None or not allowed(pending):
|
|||
|
|
return None
|
|||
|
|
if not approve:
|
|||
|
|
result = self.pending.pop(confirm_id)
|
|||
|
|
result["needsSecondConfirm"] = False
|
|||
|
|
self.append_history(
|
|||
|
|
result, status="REJECTED", decided_by=approver, at_epoch=now_epoch,
|
|||
|
|
note=note,
|
|||
|
|
)
|
|||
|
|
self.save()
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
required = int(pending.get("requiredApprovals") or 1)
|
|||
|
|
approvals = list(pending.get("approvals") or [])
|
|||
|
|
if required > 1 and approvals and _approver_key(approvals[-1]) == _approver_key(approver):
|
|||
|
|
result = copy.deepcopy(pending)
|
|||
|
|
result["needsSecondConfirm"] = True
|
|||
|
|
result["approvalDenied"] = True
|
|||
|
|
result["separationRequired"] = True
|
|||
|
|
self.append_history(
|
|||
|
|
result,
|
|||
|
|
status="SOD_DENIED",
|
|||
|
|
decided_by=approver,
|
|||
|
|
at_epoch=now_epoch,
|
|||
|
|
note="P3 second approval must be performed by a different user",
|
|||
|
|
)
|
|||
|
|
self.save()
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
approval_entry = {**approver, "approvedAt": utc_now(now_epoch)}
|
|||
|
|
if note:
|
|||
|
|
approval_entry["note"] = note
|
|||
|
|
approvals.append(approval_entry)
|
|||
|
|
current = len(approvals)
|
|||
|
|
pending["approvals"] = approvals
|
|||
|
|
pending["approvalStep"] = current
|
|||
|
|
if current < required:
|
|||
|
|
result = copy.deepcopy(pending)
|
|||
|
|
result["needsSecondConfirm"] = True
|
|||
|
|
self.append_history(
|
|||
|
|
result,
|
|||
|
|
status="PARTIALLY_APPROVED",
|
|||
|
|
decided_by=approver,
|
|||
|
|
at_epoch=now_epoch,
|
|||
|
|
)
|
|||
|
|
self.save()
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
result = self.pending.pop(confirm_id)
|
|||
|
|
result["approvalStep"] = required
|
|||
|
|
result["needsSecondConfirm"] = False
|
|||
|
|
if required > 1:
|
|||
|
|
grant = uuid.uuid4().hex
|
|||
|
|
self.grants[grant] = _grant_record(
|
|||
|
|
result,
|
|||
|
|
approvals=approvals,
|
|||
|
|
now_epoch=now_epoch,
|
|||
|
|
grant_ttl_seconds=grant_ttl_seconds,
|
|||
|
|
)
|
|||
|
|
result["executionGrant"] = grant
|
|||
|
|
self.append_history(
|
|||
|
|
result, status="APPROVED", decided_by=approver, at_epoch=now_epoch,
|
|||
|
|
note=note,
|
|||
|
|
)
|
|||
|
|
self.save()
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
def pending_record(
|
|||
|
|
self, confirm_id: str, *, allowed: RecordPredicate, now_epoch: float
|
|||
|
|
) -> dict[str, Any] | None:
|
|||
|
|
with self.transaction():
|
|||
|
|
self.expire(now_epoch=now_epoch)
|
|||
|
|
pending = self.pending.get(confirm_id)
|
|||
|
|
if pending is None or not allowed(pending):
|
|||
|
|
return None
|
|||
|
|
return copy.deepcopy(pending)
|
|||
|
|
|
|||
|
|
def transfer(
|
|||
|
|
self,
|
|||
|
|
confirm_id: str,
|
|||
|
|
*,
|
|||
|
|
to_user_id: int,
|
|||
|
|
allowed: RecordPredicate,
|
|||
|
|
now_epoch: float,
|
|||
|
|
actor: dict[str, Any],
|
|||
|
|
) -> dict[str, Any] | None:
|
|||
|
|
"""把待确认项转派给指定用户(P2 单重审批;转派后 owner=目标用户)。
|
|||
|
|
|
|||
|
|
仅当前可审批者(owner/被委托人)可转派;P3 因需双人职责分离拒绝转派。
|
|||
|
|
"""
|
|||
|
|
with self.transaction():
|
|||
|
|
self.expire(now_epoch=now_epoch)
|
|||
|
|
pending = self.pending.get(confirm_id)
|
|||
|
|
if pending is None or not allowed(pending):
|
|||
|
|
return None
|
|||
|
|
if str(pending.get("power") or "") == "P3":
|
|||
|
|
return None # P3 不转派(保 SOD)
|
|||
|
|
if to_user_id == int(pending.get("ownerUserId") or 0):
|
|||
|
|
return copy.deepcopy(pending) # 转给自己 = no-op
|
|||
|
|
before_owner = pending.get("ownerUserId")
|
|||
|
|
before_delegate = pending.get("delegateUserId")
|
|||
|
|
pending["ownerUserId"] = to_user_id
|
|||
|
|
pending["delegateUserId"] = None
|
|||
|
|
pending["transferredAt"] = utc_now(now_epoch)
|
|||
|
|
result = copy.deepcopy(pending)
|
|||
|
|
self.append_history(
|
|||
|
|
result, status="TRANSFERRED", decided_by=actor, at_epoch=now_epoch,
|
|||
|
|
note=f"from_owner={before_owner};delegate={before_delegate};to={to_user_id}",
|
|||
|
|
)
|
|||
|
|
self.save()
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
def consume_grant(
|
|||
|
|
self,
|
|||
|
|
grant: str,
|
|||
|
|
*,
|
|||
|
|
confirm_id: str,
|
|||
|
|
action: str,
|
|||
|
|
params_hash: str,
|
|||
|
|
allowed: RecordPredicate,
|
|||
|
|
decided_by: dict[str, Any],
|
|||
|
|
now_epoch: float,
|
|||
|
|
) -> bool:
|
|||
|
|
with self.transaction():
|
|||
|
|
self.expire(now_epoch=now_epoch)
|
|||
|
|
record = self.grants.get(grant)
|
|||
|
|
if (
|
|||
|
|
record is None
|
|||
|
|
or record.get("confirmId") != confirm_id
|
|||
|
|
or record.get("action") != action
|
|||
|
|
or record.get("paramsHash") != params_hash
|
|||
|
|
or not allowed(record)
|
|||
|
|
):
|
|||
|
|
return False
|
|||
|
|
self.grants.pop(grant, None)
|
|||
|
|
self.append_history(
|
|||
|
|
record, status="EXECUTED", decided_by=decided_by, at_epoch=now_epoch
|
|||
|
|
)
|
|||
|
|
self.save()
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
def pending_items(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
tenant_uuid: str,
|
|||
|
|
project_id: str,
|
|||
|
|
allowed: RecordPredicate,
|
|||
|
|
now_epoch: float,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
del tenant_uuid, project_id
|
|||
|
|
with self.transaction():
|
|||
|
|
self.expire(now_epoch=now_epoch)
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
"confirmId": confirm_id,
|
|||
|
|
**copy.deepcopy(info),
|
|||
|
|
"waitingMs": max(
|
|||
|
|
0,
|
|||
|
|
round((now_epoch - float(info.get("createdAtEpoch") or now_epoch)) * 1000),
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
for confirm_id, info in self.pending.items()
|
|||
|
|
if allowed(info)
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
def history_items(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
tenant_uuid: str,
|
|||
|
|
project_id: str,
|
|||
|
|
allowed: RecordPredicate,
|
|||
|
|
limit: int,
|
|||
|
|
now_epoch: float,
|
|||
|
|
) -> list[dict[str, Any]]:
|
|||
|
|
del tenant_uuid, project_id
|
|||
|
|
with self.transaction():
|
|||
|
|
self.expire(now_epoch=now_epoch)
|
|||
|
|
visible = [copy.deepcopy(record) for record in self.history if allowed(record)]
|
|||
|
|
return visible[-limit:]
|
|||
|
|
|
|||
|
|
def expire(self, *, now_epoch: float | None = None) -> bool:
|
|||
|
|
now = time.time() if now_epoch is None else now_epoch
|
|||
|
|
with self.transaction():
|
|||
|
|
changed = False
|
|||
|
|
for confirm_id, record in list(self.pending.items()):
|
|||
|
|
if float(record.get("expiresAtEpoch") or 0) <= now:
|
|||
|
|
expired = self.pending.pop(confirm_id)
|
|||
|
|
expired["confirmId"] = confirm_id
|
|||
|
|
self.append_history(expired, status="EXPIRED", at_epoch=now)
|
|||
|
|
changed = True
|
|||
|
|
for grant, record in list(self.grants.items()):
|
|||
|
|
if float(record.get("expiresAtEpoch") or 0) <= now:
|
|||
|
|
expired = self.grants.pop(grant)
|
|||
|
|
self.append_history(expired, status="GRANT_EXPIRED", at_epoch=now)
|
|||
|
|
changed = True
|
|||
|
|
if changed:
|
|||
|
|
self.save()
|
|||
|
|
return changed
|
|||
|
|
|
|||
|
|
def clear(self) -> None:
|
|||
|
|
with self.transaction():
|
|||
|
|
self.pending.clear()
|
|||
|
|
self.grants.clear()
|
|||
|
|
self.history.clear()
|
|||
|
|
self.save()
|
|||
|
|
|
|||
|
|
|
|||
|
|
_store_lock = threading.Lock()
|
|||
|
|
_store: ApprovalBackend | None = None
|
|||
|
|
_store_key: tuple[str, ...] | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _backend_configuration() -> tuple[str, ...]:
|
|||
|
|
backend = (os.environ.get("APS_APPROVAL_BACKEND") or _FILE_BACKEND).strip().lower()
|
|||
|
|
if backend == _FILE_BACKEND:
|
|||
|
|
return (_FILE_BACKEND, default_approval_path())
|
|||
|
|
if backend != _DATABASE_BACKEND:
|
|||
|
|
raise RuntimeError("APS_APPROVAL_BACKEND must be 'file' or 'database'")
|
|||
|
|
if (os.environ.get("APS_APPROVAL_PATH") or "").strip():
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"APS_APPROVAL_PATH cannot be combined with APS_APPROVAL_BACKEND=database"
|
|||
|
|
)
|
|||
|
|
database_url = (os.environ.get("APS_DATABASE_URL") or "").strip()
|
|||
|
|
if not database_url:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"APS_APPROVAL_BACKEND=database requires an explicit APS_DATABASE_URL"
|
|||
|
|
)
|
|||
|
|
from sqlalchemy.engine import make_url
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
dialect = make_url(database_url).get_backend_name()
|
|||
|
|
except Exception as exc:
|
|||
|
|
raise RuntimeError("APS_DATABASE_URL is invalid") from exc
|
|||
|
|
allow_sqlite = os.environ.get("APS_APPROVAL_DATABASE_ALLOW_SQLITE") == "1"
|
|||
|
|
if dialect == "sqlite" and not allow_sqlite:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"SQLite approval storage is test-only; set "
|
|||
|
|
"APS_APPROVAL_DATABASE_ALLOW_SQLITE=1 explicitly"
|
|||
|
|
)
|
|||
|
|
if dialect not in {"mysql", "sqlite"}:
|
|||
|
|
raise RuntimeError("shared approval storage requires MySQL")
|
|||
|
|
return (_DATABASE_BACKEND, database_url, "sqlite-ok" if allow_sqlite else "mysql")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _create_configured_store(configuration: tuple[str, ...]) -> ApprovalBackend:
|
|||
|
|
if configuration[0] == _FILE_BACKEND:
|
|||
|
|
return ApprovalStore(configuration[1])
|
|||
|
|
from server.agent_core.approval_db_store import DatabaseApprovalStore
|
|||
|
|
|
|||
|
|
return DatabaseApprovalStore()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_approval_store() -> ApprovalBackend:
|
|||
|
|
global _store, _store_key
|
|||
|
|
configuration = _backend_configuration()
|
|||
|
|
with _store_lock:
|
|||
|
|
if _store is None or _store_key != configuration:
|
|||
|
|
_store = _create_configured_store(configuration)
|
|||
|
|
_store_key = configuration
|
|||
|
|
return _store
|
|||
|
|
|
|||
|
|
|
|||
|
|
def set_approval_store(store: ApprovalBackend | None) -> ApprovalBackend:
|
|||
|
|
global _store, _store_key
|
|||
|
|
with _store_lock:
|
|||
|
|
if store is None:
|
|||
|
|
configuration = _backend_configuration()
|
|||
|
|
_store = _create_configured_store(configuration)
|
|||
|
|
_store_key = configuration
|
|||
|
|
else:
|
|||
|
|
_store = store
|
|||
|
|
if isinstance(store, ApprovalStore):
|
|||
|
|
_store_key = (_FILE_BACKEND, str(Path(store.path).resolve()))
|
|||
|
|
else:
|
|||
|
|
_store_key = (getattr(store, "backend", "explicit"), "explicit")
|
|||
|
|
return _store
|