Coverage for integrations / social / consent_api.py: 91.9%
86 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"""
2HevolveSocial - User Consent Blueprint (W0c F3 prereq).
4JWT-authed, append-only consent surface for the UserConsent UI.
6Single HTTP surface for consent writes (orchestrator review acd11f55,
72026-04-25): the legacy ``/api/consent/<user_id>/*`` route family in
8``consent_service.py`` was deprecated in this commit and this module
9is now the only HTTP write path for the ``user_consents`` table.
10``ConsentService`` static methods on ``consent_service.py`` are still
11the in-process write API for internal services and now share this
12module's APPEND-ONLY semantics.
14This is the WRITE path the cloud_capability scope (encounter_icebreaker
15agent + future cloud capabilities) reads at runtime via
16encounter_api._has_cloud_drafting_consent (encounter_api.py:616).
18Endpoints (all mounted at /api/social/consent*, JWT auth required):
20 POST /api/social/consent grant — APPEND a NEW row
21 POST /api/social/consent/revoke revoke — set revoked_at on most-recent
22 active row (granted_at preserved)
23 GET /api/social/consent list — newest-first; supports
24 consent_type + active_only filters
26Append-only auditability:
27 * Each grant creates a NEW UserConsent row. Revoked rows are
28 PRESERVED with their original granted_at intact and revoked_at
29 set; subsequent grants do NOT reuse revoked rows. Two rows with
30 the same (user_id, agent_id=NULL, consent_type, scope) coexist
31 because SQL treats NULL as distinct in the unique constraint.
32 * Revoke flips revoked_at on the most-recent row where
33 granted=True AND revoked_at IS NULL. granted_at is never
34 rewritten — the audit trail of WHEN the consent was granted is
35 immutable.
37Privacy invariants:
38 * All routes scope by g.user_id — user A cannot see, grant, or
39 revoke user B's consents.
40 * 404 responses on revoke-with-no-active-row are neutral (no leak
41 about whether the user has any consent rows at all).
43Relationship to integrations/social/consent_service.py:
44 * ``consent_service.py`` exposes the in-process ``ConsentService``
45 static methods (``grant_consent``, ``revoke_consent``,
46 ``check_consent``, ``list_consents``, ``set_payment_id``) used by
47 internal services (``revenue_tracker``, ``ai_governance``,
48 ``federated_aggregator``, ``lifecycle_hooks``).
49 * Both surfaces share APPEND-ONLY write semantics as of the
50 consolidation: every grant inserts a NEW row; ``granted_at`` is
51 never rewritten on an existing row.
52 * The legacy HTTP route family ``/api/consent/<user_id>/*`` from
53 ``consent_service.py`` was REMOVED in the consolidation; this
54 module is the single JWT-authed HTTP surface for consent writes.
55"""
56from __future__ import annotations
58import logging
59import uuid
60from datetime import datetime
61from typing import Any, Optional
63from flask import Blueprint, g, jsonify, request
65from .auth import require_auth
66from .models import UserConsent
68logger = logging.getLogger('hevolve_social')
70consent_bp = Blueprint('consent', __name__, url_prefix='/api/social')
73# ──────────────────────────────────────────────────────────────────────
74# Tiny helpers — response shape mirrors the rest of /api/social/*.
75# ──────────────────────────────────────────────────────────────────────
77def _ok(data: Any = None, meta: Any = None, status: int = 200):
78 r: dict[str, Any] = {'success': True}
79 if data is not None:
80 r['data'] = data
81 if meta is not None:
82 r['meta'] = meta
83 return jsonify(r), status
86def _err(msg: str, status: int = 400):
87 return jsonify({'success': False, 'error': msg}), status
90def _json() -> dict[str, Any]:
91 return request.get_json(force=True, silent=True) or {}
94def _user_id() -> Optional[str]:
95 """Resolve current user_id as string (matches schema's String(64))."""
96 user = getattr(g, 'user', None)
97 if user is None:
98 return None
99 uid = getattr(user, 'id', None)
100 if uid is None and isinstance(user, dict):
101 uid = user.get('id')
102 return str(uid) if uid is not None else None
105def _row_to_dict(row: UserConsent) -> dict[str, Any]:
106 """Subset projection that mirrors the F3 UI's expected shape.
108 Avoids leaking columns the UI doesn't need (created_at,
109 updated_at, agent_id) while staying compatible with
110 UserConsent.to_dict() callers elsewhere in the codebase.
111 """
112 return {
113 'id': row.id,
114 'consent_type': row.consent_type,
115 'scope': row.scope,
116 'granted': bool(row.granted),
117 'granted_at': row.granted_at.isoformat() if row.granted_at else None,
118 'revoked_at': row.revoked_at.isoformat() if row.revoked_at else None,
119 }
122# ──────────────────────────────────────────────────────────────────────
123# POST /api/social/consent — grant (APPEND a NEW row)
124# ──────────────────────────────────────────────────────────────────────
126@consent_bp.route('/consent', methods=['POST'])
127@require_auth
128def grant_consent():
129 """Grant a consent. ALWAYS appends a new row — never updates an
130 existing one — so the audit log of grant events is immutable.
132 Body: {consent_type: str, scope: str (default '*')}
133 Returns: {id, consent_type, scope, granted_at}
134 """
135 uid = _user_id()
136 if uid is None:
137 return _err('unauthenticated', 401)
139 body = _json()
140 consent_type = str(body.get('consent_type', '')).strip()
141 scope = str(body.get('scope', '*')).strip() or '*'
143 if not consent_type:
144 return _err('consent_type required')
145 # Defensive caps that match the schema column widths so writes
146 # never silently truncate at the DB layer.
147 if len(consent_type) > 30:
148 return _err('consent_type exceeds 30 chars')
149 if len(scope) > 100:
150 return _err('scope exceeds 100 chars')
152 now = datetime.utcnow()
153 row = UserConsent(
154 id=str(uuid.uuid4()),
155 user_id=uid,
156 agent_id=None,
157 consent_type=consent_type,
158 scope=scope,
159 granted=True,
160 granted_at=now,
161 revoked_at=None,
162 )
163 g.db.add(row)
164 g.db.flush() # assign defaults + surface IntegrityError before response
166 logger.info(
167 'consent.grant user=%s type=%s scope=%s id=%s',
168 uid, consent_type, scope, row.id,
169 )
170 return _ok({
171 'id': row.id,
172 'consent_type': row.consent_type,
173 'scope': row.scope,
174 'granted_at': row.granted_at.isoformat(),
175 }, status=201)
178# ──────────────────────────────────────────────────────────────────────
179# POST /api/social/consent/revoke — set revoked_at on the active row
180# ──────────────────────────────────────────────────────────────────────
182@consent_bp.route('/consent/revoke', methods=['POST'])
183@require_auth
184def revoke_consent():
185 """Revoke the most-recent active consent for (user, type, scope).
187 Active = granted=True AND revoked_at IS NULL.
189 Body: {consent_type: str, scope: str (default '*')}
190 Returns: {id, revoked_at}
191 Errors:
192 400 — missing consent_type
193 404 — no active consent for this triple (neutral message; no
194 leak about whether the user has any rows at all)
195 """
196 uid = _user_id()
197 if uid is None:
198 return _err('unauthenticated', 401)
200 body = _json()
201 consent_type = str(body.get('consent_type', '')).strip()
202 scope = str(body.get('scope', '*')).strip() or '*'
204 if not consent_type:
205 return _err('consent_type required')
207 # Most-recent active row. Sort by granted_at desc so a re-grant
208 # made after a previous revoke is the row we touch.
209 row = g.db.query(UserConsent).filter(
210 UserConsent.user_id == uid,
211 UserConsent.consent_type == consent_type,
212 UserConsent.scope == scope,
213 UserConsent.granted == True, # noqa: E712 — SQLAlchemy idiom
214 UserConsent.revoked_at.is_(None),
215 ).order_by(UserConsent.granted_at.desc()).first()
217 if row is None:
218 return _err('no active consent', 404)
220 # Audit-evidence-discipline: NEVER overwrite granted_at. The
221 # event of "this consent was granted at T" is immutable history.
222 now = datetime.utcnow()
223 row.revoked_at = now
224 g.db.flush()
226 logger.info(
227 'consent.revoke user=%s type=%s scope=%s id=%s',
228 uid, consent_type, scope, row.id,
229 )
230 return _ok({
231 'id': row.id,
232 'revoked_at': row.revoked_at.isoformat(),
233 })
236# ──────────────────────────────────────────────────────────────────────
237# GET /api/social/consent — list this user's consents
238# ──────────────────────────────────────────────────────────────────────
240@consent_bp.route('/consent', methods=['GET'])
241@require_auth
242def list_consents():
243 """List the caller's consent rows (newest-first by granted_at).
245 Query params:
246 consent_type — filter by exact match (optional)
247 active_only — if 'true', return only granted=True AND
248 revoked_at IS NULL (default: return all rows)
250 Returns: {consents: [{id, consent_type, scope, granted,
251 granted_at, revoked_at}, ...]}
252 """
253 uid = _user_id()
254 if uid is None:
255 return _err('unauthenticated', 401)
257 consent_type = request.args.get('consent_type')
258 active_only = request.args.get('active_only', '').lower() in {
259 'true', '1', 'yes',
260 }
262 q = g.db.query(UserConsent).filter(UserConsent.user_id == uid)
263 if consent_type:
264 q = q.filter(UserConsent.consent_type == consent_type)
265 if active_only:
266 q = q.filter(
267 UserConsent.granted == True, # noqa: E712
268 UserConsent.revoked_at.is_(None),
269 )
270 # Newest-first by granted_at. Rows that were created via
271 # request_consent (granted=False, granted_at=NULL) sort last
272 # naturally because NULLs are LAST in ASC and FIRST in DESC on
273 # most engines — push them to the bottom by also sorting on
274 # created_at DESC as a stable secondary key.
275 rows = q.order_by(
276 UserConsent.granted_at.desc().nullslast()
277 if hasattr(UserConsent.granted_at.desc(), 'nullslast')
278 else UserConsent.granted_at.desc(),
279 UserConsent.created_at.desc(),
280 ).all()
282 return _ok({
283 'consents': [_row_to_dict(r) for r in rows],
284 'count': len(rows),
285 })