|
| 1 | +"""Account-mode primitives for sandbox-authority enforcement of |
| 2 | +``comply_test_controller`` and other test-only surfaces. |
| 3 | +
|
| 4 | +The hard rule: under no circumstances should the comply test controller |
| 5 | +(or any test-only surface) operate on a ``live``-mode account. The flag |
| 6 | +must live on the resolved account, not on a process-level env var — |
| 7 | +env vars are operator-error-prone proxies for what is fundamentally an |
| 8 | +authority decision per principal. |
| 9 | +
|
| 10 | +Mirrors the JS-side ``src/lib/server/account-mode.ts`` for cross-language |
| 11 | +parity. Phase 1 of the lifecycle-state-and-sandbox-authority proposal — |
| 12 | +ships the field, helpers, and dispatch gate. Mock-mode routing |
| 13 | +(Phase 2) is deferred. |
| 14 | +
|
| 15 | +See ``docs/proposals/lifecycle-state-and-sandbox-authority.md`` for the |
| 16 | +full three-mode design. |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +from typing import Any, Literal, cast |
| 22 | + |
| 23 | +from adcp.decisioning.types import AdcpError |
| 24 | + |
| 25 | +#: Three operationally distinct account modes: |
| 26 | +#: |
| 27 | +#: - ``live``: production traffic. Adopter's upstream is truth. |
| 28 | +#: Test-only surfaces (comply controller, force_*, simulate_*) are denied. |
| 29 | +#: - ``sandbox``: adopter's own test account. Their code path runs against |
| 30 | +#: their test infrastructure. Test-only surfaces are allowed. |
| 31 | +#: - ``mock``: SDK-routed-to-mock-server. Adopter's code is bypassed; the |
| 32 | +#: SDK forwards to the mock upstream backend (Phase 2). Test-only |
| 33 | +#: surfaces are allowed. |
| 34 | +#: |
| 35 | +#: Default when unspecified: ``live``. A missing or unknown ``mode`` reads |
| 36 | +#: as production, fail-closed for any test-only dispatch. |
| 37 | +AccountMode = Literal["live", "sandbox", "mock"] |
| 38 | + |
| 39 | + |
| 40 | +def get_account_mode(account: Any) -> AccountMode: |
| 41 | + """Read ``mode`` off any account-shaped value, with back-compat for |
| 42 | + the legacy ``sandbox: bool`` field. |
| 43 | +
|
| 44 | + Returns the explicit mode if present; otherwise infers ``'sandbox'`` |
| 45 | + from ``sandbox is True``; otherwise ``'live'``. |
| 46 | +
|
| 47 | + Adopters that have not yet migrated to the ``mode`` field continue to |
| 48 | + work — ``account.sandbox is True`` reads as sandbox mode through this |
| 49 | + helper. New code should prefer ``mode`` directly. |
| 50 | +
|
| 51 | + Unknown / unrecognized ``mode`` values fall through to ``'live'`` — |
| 52 | + we never silently admit on a misspelled mode string. |
| 53 | + """ |
| 54 | + if account is None: |
| 55 | + return "live" |
| 56 | + mode = _attr(account, "mode") |
| 57 | + if mode == "live" or mode == "sandbox" or mode == "mock": |
| 58 | + return cast(AccountMode, mode) |
| 59 | + # Back-compat: legacy `sandbox: True` flag reads as `sandbox` mode. |
| 60 | + if _attr(account, "sandbox") is True: |
| 61 | + return "sandbox" |
| 62 | + return "live" |
| 63 | + |
| 64 | + |
| 65 | +def is_sandbox_or_mock_account(account: Any) -> bool: |
| 66 | + """Predicate: is the account in a non-production mode that admits |
| 67 | + test-only surfaces (comply controller, force_*, simulate_*)? |
| 68 | +
|
| 69 | + Returns ``True`` for ``mode in {'sandbox', 'mock'}`` (or legacy |
| 70 | + ``sandbox is True``); ``False`` for ``mode == 'live'`` or any account |
| 71 | + shape that doesn't carry the field. |
| 72 | + """ |
| 73 | + mode = get_account_mode(account) |
| 74 | + return mode == "sandbox" or mode == "mock" |
| 75 | + |
| 76 | + |
| 77 | +def assert_sandbox_account( |
| 78 | + account: Any, |
| 79 | + *, |
| 80 | + tool: str | None = None, |
| 81 | + message: str | None = None, |
| 82 | +) -> None: |
| 83 | + """Raise ``AdcpError('PERMISSION_DENIED')`` if the account is not in |
| 84 | + a non-production mode. Use to gate dispatch of test-only surfaces. |
| 85 | +
|
| 86 | + Fail-closed semantics: |
| 87 | +
|
| 88 | + - ``account is None`` (no resolved account): raises. |
| 89 | + - ``mode == 'live'`` or unspecified + no ``sandbox is True``: raises. |
| 90 | + - ``mode in {'sandbox', 'mock'}`` (or legacy ``sandbox is True``): |
| 91 | + no-op, dispatch proceeds. |
| 92 | +
|
| 93 | + The ``details`` payload carries ``{scope: 'sandbox-gate', tool?}`` |
| 94 | + so dashboards can distinguish gate-rejections from other permission |
| 95 | + denials. |
| 96 | +
|
| 97 | + **Resolver discipline.** The strength of this gate depends entirely |
| 98 | + on how the adopter's :meth:`AccountStore.resolve` constructs its |
| 99 | + return value. Resolvers MUST NOT spread untrusted input (request |
| 100 | + body, headers, ``ctx_metadata``, query params) into the resolved |
| 101 | + account — doing so lets a buyer self-promote to ``mode='sandbox'`` |
| 102 | + and unlock test-only surfaces on a live principal. Source ``mode`` |
| 103 | + (and ``sandbox``) from a trusted store keyed by the authenticated |
| 104 | + principal; never from request data. |
| 105 | +
|
| 106 | + **opts.message must be a static string literal.** The message is |
| 107 | + echoed on the wire inside the error envelope. Interpolating |
| 108 | + user-controlled values into it creates a reflection sink (PII |
| 109 | + leakage, log injection, downstream HTML rendering). Pick from a |
| 110 | + fixed set of messages keyed by ``tool`` if you need variants. |
| 111 | + """ |
| 112 | + if is_sandbox_or_mock_account(account): |
| 113 | + return |
| 114 | + details: dict[str, Any] = { |
| 115 | + "scope": "sandbox-gate", |
| 116 | + "reason": "sandbox-or-mock-required", |
| 117 | + } |
| 118 | + if tool is not None: |
| 119 | + details["tool"] = tool |
| 120 | + raise AdcpError( |
| 121 | + "PERMISSION_DENIED", |
| 122 | + message=message |
| 123 | + or ( |
| 124 | + "Test-only surface requires a sandbox or mock account; " |
| 125 | + "resolved account is in live mode." |
| 126 | + ), |
| 127 | + recovery="terminal", |
| 128 | + details=details, |
| 129 | + ) |
| 130 | + |
| 131 | + |
| 132 | +def _attr(obj: Any, name: str) -> Any: |
| 133 | + """Own-attribute read mirroring the JS-side ``Object.hasOwn`` posture. |
| 134 | +
|
| 135 | + Python doesn't have prototype-pollution the same way JS does, but |
| 136 | + the equivalent risk is class-level attribute inheritance: an |
| 137 | + adopter who defines ``Account`` subclasses and accidentally sets a |
| 138 | + class-level ``mode = 'sandbox'`` would have every instance read as |
| 139 | + sandbox. We only consult instance state (``__dict__``) for known |
| 140 | + attribute carriers; for dataclass instances the field lives there. |
| 141 | + Falls back to ``getattr`` for dict-shaped accounts. |
| 142 | +
|
| 143 | + Returns ``None`` when the attribute is absent — the caller treats |
| 144 | + that as "field not present" and falls through to the next check. |
| 145 | + """ |
| 146 | + if isinstance(obj, dict): |
| 147 | + return obj.get(name) |
| 148 | + inst = getattr(obj, "__dict__", None) |
| 149 | + if isinstance(inst, dict) and name in inst: |
| 150 | + return inst[name] |
| 151 | + # Fallback: bare attribute read for objects without a writable |
| 152 | + # ``__dict__`` (slots, namedtuple, custom descriptors). |
| 153 | + return getattr(obj, name, None) |
0 commit comments