Coverage for dispatch/runner.py: 99%

106 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2026-07-24 16:42 +0000

1"""Runner identity: registration, token verification, and lazy GC (spec §7.1, §8). 

2 

3Identity is soft state that can be rebuilt from scratch (ADR-0004): the 

4``runners:registered`` ZSET tracks last-seen, while meta/token_hash carry a 7d 

5TTL. TTL is the *only* thing that invalidates a live identity. GC never kills an 

6identity — it lazily sweeps the corpses (ZSET members have no TTL of their own) 

7left behind once ``token_hash`` has already evaporated, so a heartbeat that 

8renews an identity between GC's scan and sweep can never lose its just-renewed 

9token (the TOCTOU is structurally impossible, no atomicity machinery needed). 

10 

11Security notes: 

12- The runner token is returned exactly once. Only its SHA-256 hex is stored, so 

13 a Redis dump never reveals a usable credential. 

14- All token comparisons are constant-time (``hmac.compare_digest``). 

15- A missing ``token_hash`` key means the identity was revoked (or expired) → 

16 verification fails. This is the single revocation mechanism (ADR-0004). 

17- Registration verification fails closed: if the shared secret is unset/empty in 

18 the deployment settings (startup snapshot, ADR-0005), every candidate is 

19 rejected rather than accepted or crashing. 

20- Verification never raises on hostile input: non-str values and lone UTF-16 

21 surrogate strings (which json.loads accepts but .encode() rejects) fail closed 

22 to False → 401, never a 500. 

23""" 

24 

25import hashlib 

26import hmac 

27import secrets 

28import time 

29from dataclasses import dataclass 

30from typing import Dict, List, Optional 

31 

32from ulid import ULID 

33 

34from config import settings 

35from mongo.utils import RedisCache 

36from . import params 

37from . import redis_keys 

38 

39RUNNER_ID_PREFIX = 'rn_' 

40RUNNER_TOKEN_PREFIX = 'rk_' 

41 

42# One shared RedisCache instance. In production every RedisCache() shares the 

43# pooled real Redis, but under fakeredis each instance gets an isolated dataset; 

44# caching one instance keeps register/verify/list coherent in both worlds. 

45_cache: Optional[RedisCache] = None 

46 

47 

48@dataclass(frozen=True) 

49class Registration: 

50 runner_id: str 

51 token: str 

52 

53 

54def _redis(): 

55 global _cache 

56 if _cache is None: 

57 _cache = RedisCache() 

58 return _cache.client 

59 

60 

61def _now() -> float: 

62 return time.time() 

63 

64 

65def _token_hash(token: str) -> str: 

66 return hashlib.sha256(token.encode()).hexdigest() 

67 

68 

69def _utf8(s: str) -> Optional[bytes]: 

70 """UTF-8 encode ``s``, or None if it cannot be encoded (fail closed). 

71 

72 json.loads happily yields str values holding a lone UTF-16 surrogate (the 

73 JSON literal "\\ud800" decodes to such a str), and .encode() on those raises 

74 UnicodeEncodeError. Callers at the trust boundary use None to fail closed 

75 instead of letting a hostile body turn into a 500. 

76 """ 

77 try: 

78 return s.encode() 

79 except UnicodeEncodeError: 

80 return None 

81 

82 

83def verify_registration_token(candidate: Optional[str]) -> bool: 

84 """Constant-time check of a register request's shared secret. Fails closed. 

85 

86 The secret is a startup-snapshot deployment setting (ADR-0005): rotating 

87 it takes a Back-End restart; unset/empty ⇒ registration is disabled. 

88 """ 

89 expected = settings.RUNNER_REGISTRATION_TOKEN 

90 # Fail closed: no configured secret ⇒ registration is disabled, not open. 

91 # Reject non-str candidates too — a JSON body can carry ints/lists/bytes, 

92 # and .encode() below would otherwise raise instead of returning False. 

93 if not expected or not isinstance(candidate, str) or not candidate: 

94 return False 

95 # Compare UTF-8 bytes: compare_digest accepts str only when both sides are 

96 # ASCII ("str (ASCII only)", hmac docs) and raises TypeError otherwise; 

97 # `candidate` is attacker-controlled, so str comparison could crash (500) 

98 # instead of failing closed (401). _utf8 also fails closed on a lone- 

99 # surrogate candidate whose .encode() would raise UnicodeEncodeError. 

100 expected_bytes = _utf8(expected) 

101 candidate_bytes = _utf8(candidate) 

102 if expected_bytes is None or candidate_bytes is None: 

103 return False 

104 return hmac.compare_digest(expected_bytes, candidate_bytes) 

105 

106 

107def register(name: str, ip: str) -> Registration: 

108 """Mint a fresh runner identity and persist its soft state (spec §7.1). 

109 

110 Returns the runner_id and the plaintext token (shown once). Only the token's 

111 SHA-256 hex is stored. Also sweeps expired identities before registering. 

112 """ 

113 now = _now() 

114 _gc(now) 

115 

116 runner_id = RUNNER_ID_PREFIX + str(ULID()) 

117 token = RUNNER_TOKEN_PREFIX + secrets.token_urlsafe(32) 

118 

119 client = _redis() 

120 ttl = params.IDENTITY_TTL_SEC 

121 meta_key = redis_keys.runner_meta(runner_id) 

122 

123 # Identity creation is deliberately all-or-nothing (MULTI/EXEC), unlike the 

124 # non-transactional pipelines in _gc/list_runners. 

125 pipe = client.pipeline() 

126 pipe.zadd(redis_keys.RUNNERS_REGISTERED, {runner_id: now}) 

127 pipe.hset( 

128 meta_key, 

129 mapping={ 

130 'name': name, 

131 'registered_at': repr(now), 

132 'registration_ip': ip, 

133 }, 

134 ) 

135 pipe.expire(meta_key, ttl) 

136 pipe.set(redis_keys.runner_token_hash(runner_id), 

137 _token_hash(token), 

138 ex=ttl) 

139 pipe.execute() 

140 

141 return Registration(runner_id=runner_id, token=token) 

142 

143 

144def verify_token(runner_id: Optional[str], token: Optional[str]) -> bool: 

145 """Constant-time check that ``token`` matches the stored hash for ``runner_id``. 

146 

147 A missing token_hash key (revoked or expired) yields ``False`` (→ 401). 

148 """ 

149 # Reject non-str inputs from the trust boundary: runner_id feeds a Redis key 

150 # and token feeds _token_hash().encode() — both would otherwise raise. 

151 if not isinstance(runner_id, str) or not isinstance(token, str): 

152 return False 

153 if not runner_id or not token: 

154 return False 

155 # Fail closed before touching Redis on a lone-surrogate runner_id/token whose 

156 # UTF-8 encode raises (redis-py encodes keys to UTF-8; _token_hash encodes 

157 # the token) — a hostile body must 401, not 500. 

158 if _utf8(runner_id) is None: 

159 return False 

160 token_bytes = _utf8(token) 

161 if token_bytes is None: 

162 return False 

163 stored = _redis().get(redis_keys.runner_token_hash(runner_id)) 

164 if stored is None: 

165 return False 

166 # Compare raw bytes: no assumption the stored value decodes as UTF-8. 

167 return hmac.compare_digest( 

168 stored, 

169 hashlib.sha256(token_bytes).hexdigest().encode()) 

170 

171 

172def list_runners() -> List[Dict]: 

173 """Return identity-layer facts for all registered identities (spec §7.6 subset). 

174 

175 Not a liveness view: a revoked or dead runner stays listed (frozen 

176 last_seen) until identity GC sweeps it — a deliberate observability 

177 window; revocation only guarantees immediate auth failure (ADR-0004). 

178 Sweeps expired identities first. Fields: runner_id, name, last_seen, 

179 registered_at. Liveness/held-jobs are added by the admin-API slice. 

180 """ 

181 now = _now() 

182 _gc(now) 

183 

184 client = _redis() 

185 members = client.zrange(redis_keys.RUNNERS_REGISTERED, 

186 0, 

187 -1, 

188 withscores=True) 

189 

190 runner_ids = [ 

191 member.decode() if isinstance(member, bytes) else member 

192 for member, _ in members 

193 ] 

194 

195 meta_pipe = client.pipeline(transaction=False) 

196 for runner_id in runner_ids: 

197 meta_pipe.hgetall(redis_keys.runner_meta(runner_id)) 

198 raw_metas = meta_pipe.execute() 

199 

200 runners: List[Dict] = [] 

201 for (_, score), runner_id, raw_meta in zip(members, runner_ids, raw_metas): 

202 meta = { 

203 (k.decode() if isinstance(k, bytes) else k): 

204 (v.decode() if isinstance(v, bytes) else v) 

205 for k, v in raw_meta.items() 

206 } 

207 runners.append({ 

208 'runner_id': runner_id, 

209 'name': meta.get('name'), 

210 'last_seen': score, 

211 'registered_at': meta.get('registered_at'), 

212 }) 

213 return runners 

214 

215 

216def _gc(now: Optional[float] = None) -> None: 

217 """Sweep ZSET corpses whose identity keys have already evaporated via TTL. 

218 

219 TTL is the sole invalidator: GC only removes a member once its 

220 ``token_hash`` is already gone. The score prefilter (>7d stale) just narrows 

221 the candidate set; any candidate whose ``token_hash`` still exists is skipped 

222 untouched — its TTL has not fired, so touching it is exactly what caused the 

223 TOCTOU (a heartbeat renewing the key between scan and delete). 

224 

225 Race-freedom without atomicity: once a ``token_hash`` is gone it can never 

226 reappear for the same ``rn_id`` — register always mints a fresh ULID, and 

227 heartbeat (future) requires token auth that fails without the key. So an 

228 ``EXISTS == 0`` observation stays true, making the sweep safe. 

229 """ 

230 if now is None: 

231 now = _now() 

232 cutoff = now - params.IDENTITY_TTL_SEC 

233 

234 client = _redis() 

235 # Strictly older than the cutoff: '(' makes the max bound exclusive. 

236 candidates = client.zrangebyscore( 

237 redis_keys.RUNNERS_REGISTERED, 

238 '-inf', 

239 f'({cutoff!r}', 

240 ) 

241 if not candidates: 

242 return 

243 

244 runner_ids = [ 

245 member.decode() if isinstance(member, bytes) else member 

246 for member in candidates 

247 ] 

248 

249 # Only sweep members whose token_hash has already expired (TTL fired). 

250 exists_pipe = client.pipeline(transaction=False) 

251 for runner_id in runner_ids: 

252 exists_pipe.exists(redis_keys.runner_token_hash(runner_id)) 

253 token_hash_exists = exists_pipe.execute() 

254 

255 sweep = [ 

256 runner_id 

257 for runner_id, has_token in zip(runner_ids, token_hash_exists) 

258 if not has_token 

259 ] 

260 if not sweep: 

261 return 

262 

263 pipe = client.pipeline(transaction=False) 

264 for runner_id in sweep: 

265 pipe.zrem(redis_keys.RUNNERS_REGISTERED, runner_id) 

266 pipe.delete(redis_keys.runner_meta(runner_id)) 

267 pipe.delete(redis_keys.runner_alive(runner_id)) 

268 pipe.execute()