oc_chat
Loading...
Searching...
No Matches
rotation.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/auth/rotation.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_auth_rotation.py"
6"""
7Actor HMAC credential rotation.
8
9Purpose
10-------
11Implement the two-phase credential rotation used by M11.5 actor auth.
12
13Responsibilities
14----------------
15- Create a rotating-new credential and expire the previous active key.
16- Finalize a rotation by revoking the old key and promoting the new key.
17- Keep the local secret file in sync with generated key material.
18- Emit audited ``auth.rotate_*`` rows through the registry seam.
19
20Diagnostics
21-----------
22Domain: OC_CHAT.MAIN
23Levels:
24 L2 - lifecycle events
25 L3 - semantic details
26 L4 - deep tracing
27
28Contracts
29---------
30- Raw secrets are written only to ``agent_secrets.json``.
31- Credential-table writes happen before their audit operation is emitted.
32- Nested rotations for one slot are rejected.
33
34Dependencies
35------------
36- pathlib (secrets file paths)
37- uuid (key ids for new credentials)
38- oc_chat.registry.auth.secrets_store (generate new secrets and keep the local secrets file in sync)
39- oc_chat.registry.clock (grace-period expiry timestamp)
40- oc_chat.registry.errors (RotationInFlightError for a nested rotation)
41- oc_chat.registry.api (Registry that emits the audited rotation operations)
42- oc_chat.registry.backends.base (OperationLogRow carried in the rotation result)
43- oc_diagnostics (structured debug logging via _dbg)
44"""
45from __future__ import annotations
46
47from dataclasses import dataclass
48from pathlib import Path
49import uuid
50
51from oc_diagnostics import _dbg
52
53from oc_chat.registry.api import Registry
55 SecretBlock,
56 block_from_secret,
57 generate_secret,
58 load_secrets,
59 write_secrets,
60)
61from oc_chat.registry.backends.base import OperationLogRow
62from oc_chat.registry.clock import wall_plus_seconds
63from oc_chat.registry.errors import RotationInFlightError
64
65
66_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
67
68
69@dataclass(frozen=True)
71 """Result for one rotation start/finalize call."""
72
73 slot: str
74 old_key_id: str
75 new_key_id: str
76 operation: OperationLogRow
77
78
80 registry: Registry,
81 *,
82 slot: str,
83 actor_slot: str,
84 actor_type: str = "human_operator",
85 grace_seconds: int | None = None,
86 secrets_path: str | Path | None = None,
87 trigger: str = "manual",
88) -> RotationResult:
89 """Start a two-phase credential rotation for ``slot``."""
90
91 backend = registry.backend
92 connect_func = getattr(backend, "connect", None)
93 transaction_func = getattr(backend, "transaction", None)
94 if connect_func is None or transaction_func is None:
95 raise TypeError("rotation requires a SQLite-compatible registry backend")
96 now = registry.clock.now_wall()
97 effective_grace = _effective_grace_seconds(backend, slot, grace_seconds)
98 expires_at = wall_plus_seconds(now, effective_grace)
99 raw_secret = generate_secret()
100 new_key_id = str(uuid.uuid4())
101 new_block = block_from_secret(
102 slot=slot,
103 phase="rotating_new",
104 hmac_key_id=new_key_id,
105 raw_secret=raw_secret,
106 created_at=now,
107 expires_at=None,
108 )
109 with transaction_func("immediate") as conn:
110 in_flight = conn.execute(
111 # OCT-LINT: disable=no_hardcoded_secrets reason="Credential field names are schema identifiers"
112 "SELECT 1 FROM agent_credentials WHERE slot = ? "
113 "AND rotation_phase IN ('rotating_old', 'rotating_new') "
114 "AND revoked_at IS NULL",
115 (slot,),
116 ).fetchone()
117 if in_flight is not None:
118 raise RotationInFlightError(slot)
119 active = conn.execute(
120 # OCT-LINT: disable=no_hardcoded_secrets reason="Credential field names are schema identifiers"
121 "SELECT hmac_key_id, secret_fingerprint FROM agent_credentials "
122 "WHERE slot = ? AND rotation_phase = 'active' AND revoked_at IS NULL",
123 (slot,),
124 ).fetchone()
125 if active is None:
126 raise ValueError(f"no active credential for slot {slot!r}")
127 old_key_id = str(active["hmac_key_id"])
128 old_fingerprint = str(active["secret_fingerprint"])
129 conn.execute(
130 # OCT-LINT: disable=no_hardcoded_secrets reason="Credential field names are schema identifiers"
131 "UPDATE agent_credentials SET rotation_phase = 'rotating_old', "
132 "expires_at = ? WHERE hmac_key_id = ?",
133 (expires_at, old_key_id),
134 )
135 conn.execute(
136 # OCT-LINT: disable=no_hardcoded_secrets reason="Credential field names are schema identifiers"
137 "INSERT INTO agent_credentials("
138 "hmac_key_id, slot, secret_fingerprint, created_at, expires_at, "
139 "rotation_phase"
140 ") VALUES (?, ?, ?, ?, NULL, 'rotating_new')",
141 (new_key_id, slot, new_block.secret_fingerprint, now),
142 )
144 backend,
145 slot=slot,
146 new_block=new_block,
147 secrets_path=secrets_path,
148 expires_at=expires_at,
149 )
150 operation = registry.execute_domain_command(
151 "auth.rotate_start",
152 actor_slot,
153 {
154 "version": 1,
155 "slot": slot,
156 "old_key_id": old_key_id,
157 "new_key_id": new_key_id,
158 "old_key_fingerprint": old_fingerprint,
159 "new_key_fingerprint": new_block.secret_fingerprint,
160 "effective_grace_seconds": effective_grace,
161 "grace_source": "cli_override"
162 if grace_seconds is not None
163 else "global_default",
164 "expires_at": expires_at,
165 "trigger": trigger,
166 },
167 actor_type=actor_type,
168 )
169 return RotationResult(slot, old_key_id, new_key_id, operation)
170
171
173 registry: Registry,
174 *,
175 slot: str,
176 actor_slot: str,
177 actor_type: str = "human_operator",
178 secrets_path: str | Path | None = None,
179 trigger: str = "manual_finalize",
180) -> RotationResult:
181 """Finalize a pending rotation for ``slot``."""
182
183 backend = registry.backend
184 transaction_func = getattr(backend, "transaction", None)
185 if transaction_func is None:
186 raise TypeError("rotation requires a SQLite-compatible registry backend")
187 now = registry.clock.now_wall()
188 with transaction_func("immediate") as conn:
189 old = conn.execute(
190 # OCT-LINT: disable=no_hardcoded_secrets reason="Credential field names are schema identifiers"
191 "SELECT hmac_key_id, created_at FROM agent_credentials "
192 "WHERE slot = ? AND rotation_phase = 'rotating_old' "
193 "AND revoked_at IS NULL",
194 (slot,),
195 ).fetchone()
196 new = conn.execute(
197 # OCT-LINT: disable=no_hardcoded_secrets reason="Credential field names are schema identifiers"
198 "SELECT hmac_key_id FROM agent_credentials "
199 "WHERE slot = ? AND rotation_phase = 'rotating_new' "
200 "AND revoked_at IS NULL",
201 (slot,),
202 ).fetchone()
203 if old is None or new is None:
204 raise ValueError(f"no rotation in flight for slot {slot!r}")
205 old_key_id = str(old["hmac_key_id"])
206 new_key_id = str(new["hmac_key_id"])
207 conn.execute(
208 # OCT-LINT: disable=no_hardcoded_secrets reason="Credential field names are schema identifiers"
209 "UPDATE agent_credentials SET revoked_at = ?, revoked_by = ? "
210 "WHERE hmac_key_id = ?",
211 (now, actor_slot, old_key_id),
212 )
213 conn.execute(
214 # OCT-LINT: disable=no_hardcoded_secrets reason="Credential field names are schema identifiers"
215 "UPDATE agent_credentials SET rotation_phase = 'active' "
216 "WHERE hmac_key_id = ?",
217 (new_key_id,),
218 )
219 _promote_secret_block(backend, slot=slot, secrets_path=secrets_path)
220 start_row = _latest_rotation_start_id(backend, slot, old_key_id, new_key_id)
221 operation = registry.execute_domain_command(
222 "auth.rotate_finalize",
223 actor_slot,
224 {
225 "version": 1,
226 "slot": slot,
227 "old_key_id": old_key_id,
228 "new_key_id": new_key_id,
229 "trigger": trigger,
230 "started_at": str(old["created_at"]),
231 "completed_at": now,
232 "parent_operation_id": start_row,
233 },
234 actor_type=actor_type,
235 parent_operation_id=start_row,
236 )
237 return RotationResult(slot, old_key_id, new_key_id, operation)
238
239
241 backend: object,
242 slot: str,
243 grace_seconds: int | None,
244) -> int:
245 if grace_seconds is not None:
246 return grace_seconds
247 connect_func = getattr(backend, "connect")
248 with connect_func() as conn:
249 row = conn.execute(
250 "SELECT value FROM settings WHERE key = ?",
251 (f"hmac_rotation_grace_seconds.{slot}",),
252 ).fetchone()
253 if row is None:
254 row = conn.execute(
255 # OCT-LINT: disable=no_hardcoded_secrets reason="Credential field names are schema identifiers"
256 "SELECT value FROM settings WHERE key = 'hmac_rotation_grace_seconds'"
257 ).fetchone()
258 return 86400 if row is None else int(row["value"])
259
260
262 backend: object,
263 *,
264 slot: str,
265 new_block: SecretBlock,
266 secrets_path: str | Path | None,
267 expires_at: str,
268) -> None:
269 path = _secrets_path(backend, secrets_path)
270 blocks = load_secrets(path)
271 slot_blocks = dict(blocks.get(slot, {}))
272 active = slot_blocks.get("active")
273 if active is not None:
274 slot_blocks["rotating_old"] = SecretBlock(
275 slot=active.slot,
276 phase="rotating_old",
277 hmac_key_id=active.hmac_key_id,
278 secret_b64=active.secret_b64,
279 secret_fingerprint=active.secret_fingerprint,
280 created_at=active.created_at,
281 expires_at=expires_at,
282 )
283 slot_blocks.pop("active", None)
284 slot_blocks["rotating_new"] = new_block
285 blocks[slot] = slot_blocks
286 write_secrets(blocks, path)
287
288
290 backend: object,
291 *,
292 slot: str,
293 secrets_path: str | Path | None,
294) -> None:
295 path = _secrets_path(backend, secrets_path)
296 blocks = load_secrets(path)
297 slot_blocks = dict(blocks.get(slot, {}))
298 new = slot_blocks.get("rotating_new")
299 if new is None:
300 return
301 slot_blocks["active"] = SecretBlock(
302 slot=new.slot,
303 phase="active",
304 hmac_key_id=new.hmac_key_id,
305 secret_b64=new.secret_b64,
306 secret_fingerprint=new.secret_fingerprint,
307 created_at=new.created_at,
308 expires_at=None,
309 )
310 slot_blocks.pop("rotating_new", None)
311 slot_blocks.pop("rotating_old", None)
312 blocks[slot] = slot_blocks
313 write_secrets(blocks, path)
314
315
317 backend: object,
318 slot: str,
319 old_key_id: str,
320 new_key_id: str,
321) -> int | None:
322 connect_func = getattr(backend, "connect")
323 with connect_func() as conn:
324 row = conn.execute(
325 # OCT-LINT: disable=no_hardcoded_secrets reason="Credential field names are schema identifiers"
326 "SELECT id FROM operation_log WHERE operation_type = "
327 "'auth.rotate_start' AND args_json LIKE ? AND args_json LIKE ? "
328 "AND args_json LIKE ? ORDER BY id DESC LIMIT 1",
329 (f"%{slot}%", f"%{old_key_id}%", f"%{new_key_id}%"),
330 ).fetchone()
331 return None if row is None else int(row["id"])
332
333
334def _secrets_path(backend: object, override: str | Path | None) -> Path:
335 if override is not None:
336 return Path(override)
337 backend_path = getattr(backend, "path", None)
338 if isinstance(backend_path, Path) and backend_path.name != ":memory:":
339 if (
340 backend_path.parent.name == "orchestration"
341 and backend_path.parent.parent.name == ".option_c"
342 ):
343 return backend_path.parent.parent / "agent_secrets.json"
344 return backend_path.parent / "agent_secrets.json"
345 return Path(".option_c") / "agent_secrets.json"
None _promote_secret_block(object backend, *, str slot, str|Path|None secrets_path)
Definition rotation.py:294
int _effective_grace_seconds(object backend, str slot, int|None grace_seconds)
Definition rotation.py:244
RotationResult finalize_rotation(Registry registry, *, str slot, str actor_slot, str actor_type="human_operator", str|Path|None secrets_path=None, str trigger="manual_finalize")
Definition rotation.py:180
int|None _latest_rotation_start_id(object backend, str slot, str old_key_id, str new_key_id)
Definition rotation.py:321
None _write_rotation_secret(object backend, *, str slot, SecretBlock new_block, str|Path|None secrets_path, str expires_at)
Definition rotation.py:268
RotationResult rotate_secret(Registry registry, *, str slot, str actor_slot, str actor_type="human_operator", int|None grace_seconds=None, str|Path|None secrets_path=None, str trigger="manual")
Definition rotation.py:88
Path _secrets_path(object backend, str|Path|None override)
Definition rotation.py:334