oc_connect
Loading...
Searching...
No Matches
grants.py
Go to the documentation of this file.
1"""Durable operator-consent and risk-scoped connector grants.
2
3Dependencies
4------------
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)
8"""
9from __future__ import annotations
10
11import json
12import os
13import secrets as _secrets
14from collections.abc import Mapping
15from pathlib import Path
16
17from oc_diagnostics import _dbg
18from option_c_connector_contracts import ConnectorGrant, OperatorConsent
19
20from .workspace import state_dir
21
22GRANT_FILENAME = "grants.json"
23
24
25def default_grant_path(root: str | Path | None = None) -> Path:
26 """Return the workspace-anchored grant store path (CONNECT-02 §7.1)."""
27 return state_dir(root) / GRANT_FILENAME
28
29
30class GrantFileError(ValueError):
31 """The grant store is unreadable or malformed."""
32
33
34class GrantRequiredError(PermissionError):
35 """A named grant is missing, revoked, or outside its exact scope."""
36
37
39 """Atomic JSON store for grants and the consent records that created them."""
40
42 self,
43 path: str | Path | None = None,
44 *,
45 root: str | Path | None = None,
46 ) -> None:
47 self.path = Path(path) if path is not None else default_grant_path(root)
48
49 def _load(self) -> tuple[dict[str, ConnectorGrant], dict[str, OperatorConsent]]:
50 if not self.path.is_file():
51 return {}, {}
52 try:
53 payload = json.loads(self.path.read_text(encoding="utf-8"))
54 if not isinstance(payload, Mapping):
55 raise TypeError("not a mapping")
56 grants = {
57 item.grant_id: item
58 for item in (
59 ConnectorGrant.model_validate_json(json.dumps(raw))
60 for raw in payload.get("grants", [])
61 )
62 }
63 consents = {
64 item.grant_id: item
65 for item in (
66 OperatorConsent.model_validate_json(json.dumps(raw))
67 for raw in payload.get("consents", [])
68 )
69 }
70 except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc:
71 raise GrantFileError(
72 f"could not parse connector grant store at {self.path} "
73 f"({type(exc).__name__})"
74 ) from None
75 return grants, consents
76
77 def _write(
78 self,
79 grants: Mapping[str, ConnectorGrant],
80 consents: Mapping[str, OperatorConsent],
81 ) -> None:
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)}"
85 )
86 payload = {
87 "version": 1,
88 "grants": [
89 grants[key].model_dump(mode="json") for key in sorted(grants)
90 ],
91 "consents": [
92 consents[key].model_dump(mode="json") for key in sorted(consents)
93 ],
94 }
95 try:
96 with temporary.open("w", encoding="utf-8", newline="\n") as handle:
97 json.dump(payload, handle, indent=2, sort_keys=True)
98 handle.write("\n")
99 handle.flush()
100 os.fsync(handle.fileno())
101 os.replace(temporary, self.path)
102 finally:
103 if temporary.exists():
104 try:
105 temporary.unlink()
106 except OSError:
107 pass
108
110 self,
111 grant: ConnectorGrant,
112 consent: OperatorConsent,
113 ) -> ConnectorGrant:
114 """Persist a grant only with explicit, matching operator consent."""
115 if consent.grant_id != grant.grant_id:
116 raise ValueError(
117 f"operator consent for {consent.grant_id!r} does not match "
118 f"grant {grant.grant_id!r}"
119 )
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)
130 _dbg(
131 "OC_CONNECT.GRANTS",
132 "create",
133 f"created grant={grant.grant_id} destination={grant.destination} "
134 f"risk_ceiling={grant.risk_ceiling}",
135 2,
136 )
137 return grant
138
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)
143
144 def list_grants(self) -> tuple[ConnectorGrant, ...]:
145 """Return all grants in stable id order."""
146 grants, _consents = self._load()
147 return tuple(grants[key] for key in sorted(grants))
148
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)
155 if current is None:
156 raise GrantRequiredError(f"cannot revoke missing grant {grant_id!r}")
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)
161 return revoked
162
164 self,
165 grant_id: str,
166 *,
167 default: str = "low",
168 ) -> str:
169 """Return the send risk implied by a live grant (CONNECT-02 §7.2).
170
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.
177 """
178 grant = self.get(grant_id)
179 if grant is None or grant.revoked_at_utc is not None:
180 _dbg(
181 "OC_CONNECT.GRANTS",
182 "resolve_send_risk",
183 f"no live grant={grant_id}; risk falls back to {default}",
184 3,
185 )
186 return default
187 return grant.risk_ceiling
188
190 self,
191 *,
192 grant_id: str,
193 grantee: str,
194 connector: str,
195 destination: str,
196 risk_class: str,
197 ) -> ConnectorGrant:
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(
201 grantee=grantee,
202 connector=connector,
203 destination=destination,
204 capability="send",
205 risk_class=risk_class,
206 ):
207 raise GrantRequiredError(
208 f"send refused: required grant {grant_id!r} is missing, revoked, "
209 "or does not authorize this grantee, connector, destination, and risk"
210 )
211 _dbg(
212 "OC_CONNECT.GRANTS",
213 "require_send_grant",
214 f"authorized grant={grant_id} connector={connector} "
215 f"destination={destination} risk={risk_class}",
216 3,
217 )
218 return grant
219
220
221__all__ = [
222 "GRANT_FILENAME",
223 "GrantFileError",
224 "GrantRequiredError",
225 "GrantStore",
226 "default_grant_path",
227]
ConnectorGrant revoke(self, str grant_id, str revoked_at_utc)
Definition grants.py:149
None __init__(self, str|Path|None path=None, *, str|Path|None root=None)
Definition grants.py:46
ConnectorGrant create(self, ConnectorGrant grant, OperatorConsent consent)
Definition grants.py:113
ConnectorGrant|None get(self, str grant_id)
Definition grants.py:139
ConnectorGrant require_send_grant(self, *, str grant_id, str grantee, str connector, str destination, str risk_class)
Definition grants.py:197
tuple[dict[str, ConnectorGrant], dict[str, OperatorConsent]] _load(self)
Definition grants.py:49
tuple[ConnectorGrant,...] list_grants(self)
Definition grants.py:144
str resolve_send_risk(self, str grant_id, *, str default="low")
Definition grants.py:168
None _write(self, Mapping[str, ConnectorGrant] grants, Mapping[str, OperatorConsent] consents)
Definition grants.py:81
Path default_grant_path(str|Path|None root=None)
Definition grants.py:25