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
« 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.
4Follows the NotificationService pattern (static methods taking db session).
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.
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
26from .models import UserConsent
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})
45def _audit(event_type: str, actor_id: str, action: str, detail: dict):
46 """Best-effort immutable audit log entry.
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)
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
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))}")
101class ConsentService:
102 """Static-method service for managing user consent records."""
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.
109 Returns existing record if one already exists for this combination.
110 """
111 _validate_consent_type(consent_type)
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
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
134 @staticmethod
135 def grant_consent(db, user_id: str, consent_type: str,
136 scope: str = '*', agent_id=None):
137 """Grant consent — APPEND-ONLY.
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``.
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.
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).
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)
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()
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
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.
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.
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.
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)
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
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
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
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)
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()
277 if not consent:
278 return None
280 consent.granted = False
281 consent.revoked_at = datetime.utcnow()
282 db.flush()
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
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.
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)
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
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
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
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
348 # Alias for readability
349 has_consent = check_consent
351 @staticmethod
352 def set_payment_id(db, user_id: str, payment_id: str):
353 """Store user's UPI/payment ID for revenue payouts.
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)
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.
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 ''
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()
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# ──────────────────────────────────────────────────────────────────────