Coverage for integrations / social / consent_service.py: 97.1%

104 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2User Consent Manager — explicit opt-in for data access, revenue sharing, public exposure. 

3 

4Follows the NotificationService pattern (static methods taking db session). 

5 

6HTTP surface deprecation (orchestrator review acd11f55, 2026-04-25): 

7 The legacy ``/api/consent/<user_id>/*`` route family was deleted as 

8 part of the consent-surface consolidation. All HTTP writes to the 

9 ``user_consents`` table now flow through 

10 ``integrations.social.consent_api`` (JWT-authed, append-only, 

11 mounted at ``/api/social/consent``). ``register_consent_routes`` 

12 was removed from this module. 

13 

14Static methods on ``ConsentService`` REMAIN — they are still the 

15direct (in-process) read + write API for internal services 

16(``revenue_tracker``, ``ai_governance``, ``federated_aggregator``, 

17``lifecycle_hooks``, etc). As of the consolidation, ``grant_consent`` 

18is APPEND-ONLY: every grant inserts a NEW row and never rewrites a 

19prior row's ``granted_at``. This aligns the in-process semantics 

20with the JWT HTTP surface so the audit trail of grant events is 

21immutable regardless of which entry point a caller chose. 

22""" 

23import uuid 

24from datetime import datetime 

25 

26from .models import UserConsent 

27 

28CONSENT_TYPES = frozenset({ 

29 'data_access', # Agent needs to access user data 

30 'revenue_share', # User opts into compute-for-revenue (earnings credited) 

31 'public_exposure', # Content made public 

32 'payment_setup', # User provides UPI/payment ID for revenue payouts 

33 'compute_contribute', # User allows their device to process hive tasks 

34 'cloud_egress', # User allows fallback to cloud (vision, social 

35 # sync, third-party APIs) when local resources 

36 # can't satisfy the request. Scope-aware: e.g. 

37 # scope='vision' grants cloud-vision specifically; 

38 # scope='social_sync' grants cloud social fan-out. 

39 # First-time fallback emits a notification + 

40 # request_consent record; user grants once, 

41 # subsequent requests proceed without prompting. 

42}) 

43 

44 

45def _audit(event_type: str, actor_id: str, action: str, detail: dict): 

46 """Best-effort immutable audit log entry. 

47 

48 Uses the audit log's in-memory fallback when the DB session would conflict 

49 (e.g. StaticPool / in-memory SQLite during tests). 

50 """ 

51 try: 

52 from security.immutable_audit_log import get_audit_log 

53 audit = get_audit_log() 

54 # Force in-memory mode to avoid opening a second DB session 

55 # that would conflict with the caller's active session on StaticPool. 

56 saved = audit._use_db 

57 audit._use_db = False 

58 try: 

59 audit.log_event(event_type, actor_id=actor_id, 

60 action=action, detail=detail) 

61 finally: 

62 audit._use_db = saved 

63 except Exception as e: 

64 import logging as _log 

65 _log.getLogger('hevolve.consent').warning( 

66 "Consent audit log failed (event=%s, actor=%s): %s", 

67 event_type, actor_id, e) 

68 

69 

70def _emit(topic: str, data: dict): 

71 """Best-effort EventBus broadcast + push to frontend via WAMP/SSE.""" 

72 try: 

73 from core.platform.events import emit_event 

74 emit_event(topic, data) 

75 except Exception: 

76 pass 

77 # Also push as a notification to the user's frontend (Nunba, Hevolve, Android) 

78 # so consent dialogs can appear on any platform 

79 user_id = data.get('user_id', '') 

80 if user_id: 

81 try: 

82 from integrations.social.realtime import on_notification 

83 on_notification(user_id, { 

84 'type': topic, # consent.granted, consent.revoked, consent.request 

85 'consent_type': data.get('consent_type', ''), 

86 'agent_id': data.get('agent_id'), 

87 'scope': data.get('scope', '*'), 

88 'reason': data.get('reason', ''), 

89 }) 

90 except Exception: 

91 pass 

92 

93 

94def _validate_consent_type(consent_type: str): 

95 if consent_type not in CONSENT_TYPES: 

96 raise ValueError( 

97 f"Invalid consent_type '{consent_type}'. " 

98 f"Must be one of: {', '.join(sorted(CONSENT_TYPES))}") 

99 

100 

101class ConsentService: 

102 """Static-method service for managing user consent records.""" 

103 

104 @staticmethod 

105 def request_consent(db, user_id: str, consent_type: str, 

106 scope: str = '*', agent_id=None): 

107 """Create a pending (not yet granted) consent record. 

108 

109 Returns existing record if one already exists for this combination. 

110 """ 

111 _validate_consent_type(consent_type) 

112 

113 existing = db.query(UserConsent).filter( 

114 UserConsent.user_id == user_id, 

115 UserConsent.consent_type == consent_type, 

116 UserConsent.scope == scope, 

117 UserConsent.agent_id == agent_id, 

118 ).first() 

119 if existing: 

120 return existing 

121 

122 consent = UserConsent( 

123 id=str(uuid.uuid4()), 

124 user_id=user_id, 

125 agent_id=agent_id, 

126 consent_type=consent_type, 

127 scope=scope, 

128 granted=False, 

129 ) 

130 db.add(consent) 

131 db.flush() 

132 return consent 

133 

134 @staticmethod 

135 def grant_consent(db, user_id: str, consent_type: str, 

136 scope: str = '*', agent_id=None): 

137 """Grant consent — APPEND-ONLY. 

138 

139 Always inserts a NEW ``UserConsent`` row. The audit trail of 

140 WHEN a consent was granted is immutable history — never 

141 rewrite a prior row's ``granted_at``. 

142 

143 Mirror of ``integrations.social.consent_api.grant_consent`` 

144 (JWT HTTP surface) so all writers — internal services and the 

145 UI — share the same audit-trail invariant. 

146 

147 Notes on the unique constraint: 

148 ``UserConsent`` has 

149 ``UniqueConstraint(user_id, agent_id, consent_type, scope)`` 

150 (see ``_models_local.py``). SQL treats ``NULL`` as distinct 

151 in unique constraints, so ``agent_id=None`` rows can stack 

152 freely. For non-NULL ``agent_id``, callers that re-grant 

153 while a prior granted-and-not-revoked row exists for the 

154 same triple will hit ``IntegrityError``; the supported 

155 pattern in that case is to ``revoke_consent`` first or use 

156 ``check_consent`` and skip. No production caller in the 

157 tree today re-grants under a non-NULL agent_id with the 

158 same scope, so this is a no-op semantic change for them 

159 (orchestrator review acd11f55). 

160 

161 Deprecated path: prior versions UPSERTed and rewrote 

162 ``granted_at`` on re-grant. That semantic is gone as of the 

163 consolidation commit. 

164 """ 

165 _validate_consent_type(consent_type) 

166 

167 now = datetime.utcnow() 

168 consent = UserConsent( 

169 id=str(uuid.uuid4()), 

170 user_id=user_id, 

171 agent_id=agent_id, 

172 consent_type=consent_type, 

173 scope=scope, 

174 granted=True, 

175 granted_at=now, 

176 ) 

177 db.add(consent) 

178 db.flush() 

179 

180 _audit('consent', actor_id=user_id, 

181 action=f'consent.granted:{consent_type}', 

182 detail={'scope': scope, 'agent_id': agent_id}) 

183 _emit('consent.granted', { 

184 'user_id': user_id, 

185 'consent_type': consent_type, 

186 'scope': scope, 

187 'agent_id': agent_id, 

188 }) 

189 return consent 

190 

191 @staticmethod 

192 def auto_grant_with_notice(db, user_id: str, consent_type: str, 

193 scope: str = '*', agent_id=None, 

194 reason: str = '') -> bool: 

195 """Privacy-aware auto-grant: NEVER blocks the request, but informs 

196 the user the FIRST time the cloud (or any consent-gated path) is 

197 used and gives them a one-tap revoke action. 

198 

199 Pattern: "transparency + easy revoke" — privacy-first should not 

200 cause functional failures. If the user has actively REVOKED 

201 this consent, this method returns False and the caller refuses. 

202 Otherwise (no record OR previously granted) it ensures a granted 

203 record exists, emits a one-time `consent.auto_granted` notice, 

204 and returns True so the caller proceeds immediately. 

205 

206 Use this for cloud egress that's safe-by-default (vision 

207 fallback, social cross-device sync, fleet API tool calls, etc.) 

208 where blocking the request would degrade UX more than the 

209 privacy gain justifies. Use ``check_consent`` + ``request_consent`` 

210 directly when the gated action is high-stakes (payment_setup, 

211 public_exposure of private content) and you genuinely want to 

212 block until the user explicitly grants. 

213 

214 Returns: 

215 True — caller may proceed (consent is now granted, possibly 

216 silently auto-granted with notice). 

217 False — caller MUST refuse: user has explicitly revoked this 

218 consent and re-granting requires a fresh user action. 

219 """ 

220 _validate_consent_type(consent_type) 

221 

222 # 1. Active grant exists? Proceed silently. 

223 if ConsentService.check_consent(db, user_id, consent_type, 

224 scope=scope, agent_id=agent_id): 

225 return True 

226 

227 # 2. Was there a PRIOR explicit revoke? Honor it — refuse. 

228 prior = db.query(UserConsent).filter( 

229 UserConsent.user_id == user_id, 

230 UserConsent.consent_type == consent_type, 

231 UserConsent.scope == scope, 

232 UserConsent.agent_id == agent_id, 

233 UserConsent.revoked_at.isnot(None), 

234 ).order_by(UserConsent.revoked_at.desc()).first() 

235 if prior is not None and prior.revoked_at is not None: 

236 _emit('consent.refused_after_revoke', { 

237 'user_id': user_id, 

238 'consent_type': consent_type, 

239 'scope': scope, 

240 'agent_id': agent_id, 

241 'reason': reason or ( 

242 f"Previously revoked {consent_type}/{scope}; " 

243 f"re-grant requires a fresh user action." 

244 ), 

245 }) 

246 return False 

247 

248 # 3. No record at all → auto-grant + emit one-time notice. 

249 ConsentService.grant_consent(db, user_id, consent_type, 

250 scope=scope, agent_id=agent_id) 

251 _emit('consent.auto_granted', { 

252 'user_id': user_id, 

253 'consent_type': consent_type, 

254 'scope': scope, 

255 'agent_id': agent_id, 

256 'reason': reason or ( 

257 f"Auto-granted {consent_type}/{scope} so your request " 

258 f"could be served. Tap to review or revoke in settings." 

259 ), 

260 'revoke_action': 'consent.revoke', 

261 }) 

262 return True 

263 

264 @staticmethod 

265 def revoke_consent(db, user_id: str, consent_type: str, 

266 scope: str = '*', agent_id=None): 

267 """Revoke previously granted consent. Returns None if not found.""" 

268 _validate_consent_type(consent_type) 

269 

270 consent = db.query(UserConsent).filter( 

271 UserConsent.user_id == user_id, 

272 UserConsent.consent_type == consent_type, 

273 UserConsent.scope == scope, 

274 UserConsent.agent_id == agent_id, 

275 ).first() 

276 

277 if not consent: 

278 return None 

279 

280 consent.granted = False 

281 consent.revoked_at = datetime.utcnow() 

282 db.flush() 

283 

284 _audit('consent', actor_id=user_id, 

285 action=f'consent.revoked:{consent_type}', 

286 detail={'scope': scope, 'agent_id': agent_id}) 

287 _emit('consent.revoked', { 

288 'user_id': user_id, 

289 'consent_type': consent_type, 

290 'scope': scope, 

291 'agent_id': agent_id, 

292 }) 

293 return consent 

294 

295 @staticmethod 

296 def check_consent(db, user_id: str, consent_type: str, 

297 scope: str = '*', agent_id=None) -> bool: 

298 """Check if user has active consent. 

299 

300 Lookup order: 

301 1. Exact match (user_id + agent_id + consent_type + scope) 

302 2. Wildcard scope (scope='*') for same agent 

303 3. Blanket consent (agent_id=None, scope='*') 

304 """ 

305 _validate_consent_type(consent_type) 

306 

307 # 1. Exact match 

308 exact = db.query(UserConsent).filter( 

309 UserConsent.user_id == user_id, 

310 UserConsent.consent_type == consent_type, 

311 UserConsent.scope == scope, 

312 UserConsent.agent_id == agent_id, 

313 UserConsent.granted == True, 

314 ).first() 

315 if exact: 

316 return True 

317 

318 # 2. Wildcard scope for specific agent 

319 if scope != '*' and agent_id is not None: 

320 wildcard = db.query(UserConsent).filter( 

321 UserConsent.user_id == user_id, 

322 UserConsent.consent_type == consent_type, 

323 UserConsent.scope == '*', 

324 UserConsent.agent_id == agent_id, 

325 UserConsent.granted == True, 

326 ).first() 

327 if wildcard: 

328 return True 

329 

330 # 3. Blanket consent (agent_id=None, scope='*') 

331 if agent_id is not None: 

332 blanket = db.query(UserConsent).filter( 

333 UserConsent.user_id == user_id, 

334 UserConsent.consent_type == consent_type, 

335 UserConsent.scope == '*', 

336 UserConsent.agent_id == None, 

337 UserConsent.granted == True, 

338 ).first() 

339 if blanket: 

340 return True 

341 

342 import logging as _log 

343 _log.getLogger('hevolve.consent').warning( 

344 "Consent check denied: user=%s type=%s scope=%s agent=%s", 

345 user_id, consent_type, scope, agent_id) 

346 return False 

347 

348 # Alias for readability 

349 has_consent = check_consent 

350 

351 @staticmethod 

352 def set_payment_id(db, user_id: str, payment_id: str): 

353 """Store user's UPI/payment ID for revenue payouts. 

354 

355 Uses consent_type='payment_setup', scope=payment_id. 

356 Triggers consent.granted event so frontend shows confirmation. 

357 """ 

358 return ConsentService.grant_consent( 

359 db, user_id, 'payment_setup', scope=payment_id) 

360 

361 @staticmethod 

362 def get_payment_id(db, user_id: str) -> str: 

363 """Get user's most-recently granted UPI/payment ID, or empty string. 

364 

365 APPEND-ONLY ``grant_consent`` means a user who saves a NEW 

366 payment_id leaves the prior row in place (the prior scope 

367 becomes audit history). Order by ``granted_at desc`` so the 

368 latest UPI wins; ``revoked_at IS NULL`` so a revoked entry 

369 does not shadow a later valid one. 

370 """ 

371 record = db.query(UserConsent).filter( 

372 UserConsent.user_id == user_id, 

373 UserConsent.consent_type == 'payment_setup', 

374 UserConsent.granted == True, 

375 UserConsent.revoked_at.is_(None), 

376 ).order_by(UserConsent.granted_at.desc()).first() 

377 return record.scope if record else '' 

378 

379 @staticmethod 

380 def list_consents(db, user_id: str, consent_type: str = None, 

381 agent_id=None): 

382 """List consent records for a user, optionally filtered.""" 

383 q = db.query(UserConsent).filter(UserConsent.user_id == user_id) 

384 if consent_type is not None: 

385 _validate_consent_type(consent_type) 

386 q = q.filter(UserConsent.consent_type == consent_type) 

387 if agent_id is not None: 

388 q = q.filter(UserConsent.agent_id == agent_id) 

389 return q.order_by(UserConsent.created_at.desc()).all() 

390 

391 

392# ────────────────────────────────────────────────────────────────────── 

393# Legacy ``register_consent_routes`` was REMOVED in the consent-surface 

394# consolidation (orchestrator review acd11f55, 2026-04-25). The HTTP 

395# write surface for consent now lives at ``integrations.social.consent_api`` 

396# (JWT-authed, append-only, mounted at ``/api/social/consent``). 

397# Internal services that need direct DB access continue to use the 

398# ``ConsentService`` static methods above — those are unchanged in 

399# signature, with grant_consent flipped to APPEND-ONLY. 

400# ──────────────────────────────────────────────────────────────────────