oc_connect
Loading...
Searching...
No Matches
credentials.py
Go to the documentation of this file.
1"""Sidecar-backed credential broker with a runtime gitignore canary.
2
3The implementation follows the plan-216 provider sidecar pattern. Raw values are
4accepted by the backend and returned only to the caller that asks for a named
5credential. Diagnostics, printed output, logging, and exception messages carry
6names and paths only.
7
8Dependencies
9------------
10- oc_connect.workspace (credential_path - the default workspace-anchored sidecar path)
11- oc_diagnostics (_dbg structured logger)
12- git (not imported; run as git check-ignore, the canary that proves the sidecar is gitignored)
13"""
14from __future__ import annotations
15
16from contextlib import contextmanager
17import json
18import os
19import re
20import secrets as _secrets
21import stat
22import subprocess
23from collections.abc import Callable, Iterator, Mapping
24from pathlib import Path
25from typing import Protocol
26
27from oc_diagnostics import _dbg
28
29from .workspace import credential_path
30
31SECRET_FILE_MODE = 0o600
32_NAME_RE = re.compile(r"^[a-z][a-z0-9_.-]*$")
33_GIT_TIMEOUT_SECONDS = 5.0
34
35
36class CredentialPathError(ValueError):
37 """The selected credential file cannot be proven gitignored."""
38
39
40class CredentialFileError(ValueError):
41 """The credential file is unreadable or malformed."""
42
43
44class CredentialNotFoundError(LookupError):
45 """A requested credential name has no stored value."""
46
47
48class CredentialBackend(Protocol):
49 """Storage boundary hidden behind :class:`CredentialBroker`."""
50
51 def get(self, name: str) -> str | None: ...
52
53 def set(self, name: str, value: str) -> None: ...
54
55 def delete(self, name: str) -> None: ...
56
57 def snapshot(self) -> bytes | None: ...
58
59 def restore(self, snapshot: bytes | None) -> None: ...
60
61
62def _existing_ancestor(path: Path) -> Path:
63 candidate = path if path.is_dir() else path.parent
64 while not candidate.is_dir() and candidate != candidate.parent:
65 candidate = candidate.parent
66 return candidate
67
68
69def is_gitignored(path: str | Path) -> bool:
70 """Ask git whether *path* is ignored; every probe error fails closed."""
71 target = Path(path).expanduser()
72 try:
73 target = target.resolve()
74 except OSError:
75 target = target.absolute()
76 try:
77 result = subprocess.run(
78 ["git", "check-ignore", "--quiet", "--", str(target)],
79 cwd=str(_existing_ancestor(target)),
80 capture_output=True,
81 text=True,
82 timeout=_GIT_TIMEOUT_SECONDS,
83 check=False,
84 )
85 except (OSError, subprocess.SubprocessError) as exc:
86 _dbg(
87 "OC_CONNECT.CREDENTIALS",
88 "is_gitignored",
89 f"SECURITY_ERROR: gitignore probe failed for {target}: {type(exc).__name__}",
90 1,
91 override=True,
92 )
93 return False
94 covered = result.returncode == 0
95 _dbg(
96 "OC_CONNECT.CREDENTIALS",
97 "is_gitignored",
98 f"gitignore canary path={target} covered={covered} rc={result.returncode}",
99 3,
100 )
101 return covered
102
103
104def _chmod_private(path: Path) -> None:
105 try:
106 path.chmod(stat.S_IRUSR | stat.S_IWUSR)
107 except OSError:
108 pass
109
110
112 """Atomic JSON sidecar backend guarded by a live git check-ignore probe."""
113
115 self,
116 path: str | Path | None = None,
117 *,
118 gitignore_check: Callable[[str | Path], bool] = is_gitignored,
119 root: str | Path | None = None,
120 ) -> None:
121 self.path = Path(path) if path is not None else credential_path(root)
122 self._gitignore_check = gitignore_check
123
124 def _assert_safe_path(self) -> None:
125 if not self._gitignore_check(self.path):
127 f"refusing to use connector credentials at {self.path}: the path is not "
128 "covered by gitignore (checked with 'git check-ignore')"
129 )
130
131 def _load(self) -> dict[str, str]:
132 if not self.path.is_file():
133 return {}
134 self._assert_safe_path()
135 try:
136 value = json.loads(self.path.read_text(encoding="utf-8"))
137 except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
139 f"could not parse connector credential sidecar at {self.path} "
140 f"({type(exc).__name__})"
141 ) from None
142 if not isinstance(value, Mapping) or not all(
143 isinstance(name, str) and isinstance(item, str)
144 for name, item in value.items()
145 ):
147 f"connector credential sidecar at {self.path} must be a string map"
148 )
149 return dict(value)
150
151 def _write(self, values: Mapping[str, str]) -> None:
152 self._assert_safe_path()
153 self.path.parent.mkdir(parents=True, exist_ok=True)
154 temporary = self.path.with_name(
155 f".tmp-{self.path.name}-{_secrets.token_hex(8)}"
156 )
157 try:
158 with temporary.open("w", encoding="utf-8", newline="\n") as handle:
159 json.dump(dict(sorted(values.items())), handle, indent=2, sort_keys=True)
160 handle.write("\n")
161 handle.flush()
162 os.fsync(handle.fileno())
163 _chmod_private(temporary)
164 os.replace(temporary, self.path)
165 _chmod_private(self.path)
166 finally:
167 if temporary.exists():
168 try:
169 temporary.unlink()
170 except OSError:
171 pass
172
173 def get(self, name: str) -> str | None:
174 """Return the raw value for *name*, or ``None`` when absent."""
175 return self._load().get(name)
176
177 def set(self, name: str, value: str) -> None:
178 """Store one value without exposing it to diagnostics."""
179 values = self._load()
180 values[name] = value
181 self._write(values)
182
183 def delete(self, name: str) -> None:
184 """Delete one value; absent names are idempotent."""
185 values = self._load()
186 if name not in values:
187 return
188 del values[name]
189 self._write(values)
190
191 def snapshot(self) -> bytes | None:
192 """Return the exact sidecar bytes for an internal transaction rollback."""
193 if not self.path.is_file():
194 return None
195 self._assert_safe_path()
196 try:
197 return self.path.read_bytes()
198 except OSError as exc:
200 f"could not snapshot connector credential sidecar at {self.path} "
201 f"({type(exc).__name__})"
202 ) from None
203
204 def restore(self, snapshot: bytes | None) -> None:
205 """Restore an internal snapshot without exposing its bytes."""
206 self._assert_safe_path()
207 if snapshot is None:
208 try:
209 self.path.unlink(missing_ok=True)
210 except OSError as exc:
212 f"could not restore connector credential sidecar at {self.path} "
213 f"({type(exc).__name__})"
214 ) from None
215 return
216 self.path.parent.mkdir(parents=True, exist_ok=True)
217 temporary = self.path.with_name(
218 f".tmp-{self.path.name}-{_secrets.token_hex(8)}"
219 )
220 try:
221 with temporary.open("wb") as handle:
222 handle.write(snapshot)
223 handle.flush()
224 os.fsync(handle.fileno())
225 _chmod_private(temporary)
226 os.replace(temporary, self.path)
227 _chmod_private(self.path)
228 finally:
229 if temporary.exists():
230 try:
231 temporary.unlink()
232 except OSError:
233 pass
234
235
237 """Name-only application boundary around a credential backend."""
238
240 self,
241 backend: CredentialBackend | None = None,
242 *,
243 root: str | Path | None = None,
244 ) -> None:
245 self.backend = backend or SidecarCredentialBackend(root=root)
246
247 @contextmanager
248 def transaction(self) -> Iterator[None]:
249 """Roll the backend back byte-for-byte if the guarded flow fails."""
250 snapshot = self.backend.snapshot()
251 try:
252 yield
253 except BaseException:
254 self.backend.restore(snapshot)
255 _dbg(
256 "OC_CONNECT.CREDENTIALS",
257 "transaction",
258 "credential transaction rolled back",
259 2,
260 )
261 raise
262
263 @staticmethod
264 def _validate_name(name: str) -> str:
265 if not isinstance(name, str) or _NAME_RE.fullmatch(name) is None:
266 raise ValueError(
267 "credential name must start with a lowercase letter and contain only "
268 "lowercase letters, digits, dots, dashes, or underscores"
269 )
270 return name
271
272 def store(self, name: str, value: str) -> None:
273 """Store one named value; diagnostics record only the name."""
274 checked_name = self._validate_name(name)
275 if not isinstance(value, str) or not value.strip():
276 raise ValueError(f"refusing to store an empty value for {checked_name}")
277 self.backend.set(checked_name, value.strip())
278 _dbg(
279 "OC_CONNECT.CREDENTIALS",
280 "store",
281 f"stored connector credential name={checked_name}",
282 2,
283 )
284
285 def retrieve(self, name: str) -> str:
286 """Return one raw value to its transport caller and nowhere else."""
287 checked_name = self._validate_name(name)
288 value = self.backend.get(checked_name)
289 if value is None:
291 f"connector credential {checked_name!r} is not configured"
292 )
293 _dbg(
294 "OC_CONNECT.CREDENTIALS",
295 "retrieve",
296 f"retrieved connector credential name={checked_name}",
297 3,
298 )
299 return value
300
301 def delete(self, name: str) -> None:
302 """Delete one named value without exposing its former content."""
303 checked_name = self._validate_name(name)
304 self.backend.delete(checked_name)
305 _dbg(
306 "OC_CONNECT.CREDENTIALS",
307 "delete",
308 f"deleted connector credential name={checked_name}",
309 2,
310 )
311
312
313__all__ = [
314 "CredentialBackend",
315 "CredentialBroker",
316 "CredentialFileError",
317 "CredentialNotFoundError",
318 "CredentialPathError",
319 "SidecarCredentialBackend",
320 "is_gitignored",
321]
None restore(self, bytes|None snapshot)
None set(self, str name, str value)
None store(self, str name, str value)
None __init__(self, CredentialBackend|None backend=None, *, str|Path|None root=None)
None _write(self, Mapping[str, str] values)
None restore(self, bytes|None snapshot)
None set(self, str name, str value)
None __init__(self, str|Path|None path=None, *, Callable[[str|Path], bool] gitignore_check=is_gitignored, str|Path|None root=None)
Path _existing_ancestor(Path path)
bool is_gitignored(str|Path path)
None _chmod_private(Path path)