1"""Durable operator-consent and risk-scoped connector grants.
5- oc_diagnostics (_dbg structured logging of grant creation, revocation and send checks)
6- option_c_connector_contracts (ConnectorGrant, OperatorConsent - the grant and consent records the store persists)
7- oc_connect.workspace (state_dir - the workspace state directory that holds the grant store)
9from __future__
import annotations
13import secrets
as _secrets
14from collections.abc
import Mapping
15from pathlib
import Path
17from oc_diagnostics
import _dbg
18from option_c_connector_contracts
import ConnectorGrant, OperatorConsent
20from .workspace
import state_dir
22GRANT_FILENAME =
"grants.json"
26 """Return the workspace-anchored grant store path (CONNECT-02 §7.1)."""
27 return state_dir(root) / GRANT_FILENAME
31 """The grant store is unreadable or malformed."""
35 """A named grant is missing, revoked, or outside its exact scope."""
39 """Atomic JSON store for grants and the consent records that created them."""
43 path: str | Path |
None =
None,
45 root: str | Path |
None =
None,
49 def _load(self) -> tuple[dict[str, ConnectorGrant], dict[str, OperatorConsent]]:
50 if not self.
path.is_file():
53 payload = json.loads(self.
path.read_text(encoding=
"utf-8"))
54 if not isinstance(payload, Mapping):
55 raise TypeError(
"not a mapping")
59 ConnectorGrant.model_validate_json(json.dumps(raw))
60 for raw
in payload.get(
"grants", [])
66 OperatorConsent.model_validate_json(json.dumps(raw))
67 for raw
in payload.get(
"consents", [])
70 except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError)
as exc:
72 f
"could not parse connector grant store at {self.path} "
73 f
"({type(exc).__name__})"
75 return grants, consents
79 grants: Mapping[str, ConnectorGrant],
80 consents: Mapping[str, OperatorConsent],
82 self.
path.parent.mkdir(parents=
True, exist_ok=
True)
83 temporary = self.
path.with_name(
84 f
".tmp-{self.path.name}-{_secrets.token_hex(8)}"
89 grants[key].model_dump(mode=
"json")
for key
in sorted(grants)
92 consents[key].model_dump(mode=
"json")
for key
in sorted(consents)
96 with temporary.open(
"w", encoding=
"utf-8", newline=
"\n")
as handle:
97 json.dump(payload, handle, indent=2, sort_keys=
True)
100 os.fsync(handle.fileno())
101 os.replace(temporary, self.
path)
103 if temporary.exists():
111 grant: ConnectorGrant,
112 consent: OperatorConsent,
114 """Persist a grant only with explicit, matching operator consent."""
115 if consent.grant_id != grant.grant_id:
117 f
"operator consent for {consent.grant_id!r} does not match "
118 f
"grant {grant.grant_id!r}"
120 grants, consents = self.
_load()
121 existing_grant = grants.get(grant.grant_id)
122 existing_consent = consents.get(grant.grant_id)
123 if existing_grant
is not None:
124 if existing_grant == grant
and existing_consent == consent:
125 return existing_grant
126 raise ValueError(f
"grant {grant.grant_id!r} already exists")
127 grants[grant.grant_id] = grant
128 consents[consent.grant_id] = consent
129 self.
_write(grants, consents)
133 f
"created grant={grant.grant_id} destination={grant.destination} "
134 f
"risk_ceiling={grant.risk_ceiling}",
139 def get(self, grant_id: str) -> ConnectorGrant |
None:
140 """Return one grant without interpreting its authorization scope."""
141 grants, _consents = self.
_load()
142 return grants.get(grant_id)
145 """Return all grants in stable id order."""
146 grants, _consents = self.
_load()
147 return tuple(grants[key]
for key
in sorted(grants))
149 def revoke(self, grant_id: str, revoked_at_utc: str) -> ConnectorGrant:
150 """Persist revocation; missing ids fail loudly."""
151 if not revoked_at_utc.strip():
152 raise ValueError(
"revocation timestamp must not be blank")
153 grants, consents = self.
_load()
154 current = grants.get(grant_id)
157 revoked = current.model_copy(update={
"revoked_at_utc": revoked_at_utc})
158 grants[grant_id] = revoked
159 self.
_write(grants, consents)
160 _dbg(
"OC_CONNECT.GRANTS",
"revoke", f
"revoked grant={grant_id}", 2)
167 default: str =
"low",
169 """Return the send risk implied by a live grant (CONNECT-02 §7.2).
171 The send default now *follows* the operator's recorded authority
172 instead of a hardcoded ``medium`` that sat above the real grant, which
173 is why every plan-219 send needed an explicit ``--risk low``. This
174 resolves downward only: a missing or revoked grant yields *default*
175 rather than an invented ceiling, and the caller still has to pass
176 :meth:`require_send_grant`, which refuses in both cases.
178 grant = self.
get(grant_id)
179 if grant
is None or grant.revoked_at_utc
is not None:
183 f
"no live grant={grant_id}; risk falls back to {default}",
187 return grant.risk_ceiling
198 """Return an exact live send grant or fail closed with its name."""
199 grant = self.
get(grant_id)
200 if grant
is None or not grant.allows(
203 destination=destination,
205 risk_class=risk_class,
208 f
"send refused: required grant {grant_id!r} is missing, revoked, "
209 "or does not authorize this grantee, connector, destination, and risk"
213 "require_send_grant",
214 f
"authorized grant={grant_id} connector={connector} "
215 f
"destination={destination} risk={risk_class}",
224 "GrantRequiredError",
226 "default_grant_path",
ConnectorGrant revoke(self, str grant_id, str revoked_at_utc)
None __init__(self, str|Path|None path=None, *, str|Path|None root=None)
ConnectorGrant create(self, ConnectorGrant grant, OperatorConsent consent)
ConnectorGrant|None get(self, str grant_id)
ConnectorGrant require_send_grant(self, *, str grant_id, str grantee, str connector, str destination, str risk_class)
tuple[dict[str, ConnectorGrant], dict[str, OperatorConsent]] _load(self)
tuple[ConnectorGrant,...] list_grants(self)
str resolve_send_risk(self, str grant_id, *, str default="low")
None _write(self, Mapping[str, ConnectorGrant] grants, Mapping[str, OperatorConsent] consents)
Path default_grant_path(str|Path|None root=None)