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
|