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

1""" 

2HevolveSocial - User Consent Blueprint (W0c F3 prereq). 

3 

4JWT-authed, append-only consent surface for the UserConsent UI. 

5 

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. 

13 

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). 

17 

18Endpoints (all mounted at /api/social/consent*, JWT auth required): 

19 

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 

25 

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. 

36 

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). 

42 

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 

57 

58import logging 

59import uuid 

60from datetime import datetime 

61from typing import Any, Optional 

62 

63from flask import Blueprint, g, jsonify, request 

64 

65from .auth import require_auth 

66from .models import UserConsent 

67 

68logger = logging.getLogger('hevolve_social') 

69 

70consent_bp = Blueprint('consent', __name__, url_prefix='/api/social') 

71 

72 

73# ────────────────────────────────────────────────────────────────────── 

74# Tiny helpers — response shape mirrors the rest of /api/social/*. 

75# ────────────────────────────────────────────────────────────────────── 

76 

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 

84 

85 

86def _err(msg: str, status: int = 400): 

87 return jsonify({'success': False, 'error': msg}), status 

88 

89 

90def _json() -> dict[str, Any]: 

91 return request.get_json(force=True, silent=True) or {} 

92 

93 

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 

103 

104 

105def _row_to_dict(row: UserConsent) -> dict[str, Any]: 

106 """Subset projection that mirrors the F3 UI's expected shape. 

107 

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 } 

120 

121 

122# ────────────────────────────────────────────────────────────────────── 

123# POST /api/social/consent — grant (APPEND a NEW row) 

124# ────────────────────────────────────────────────────────────────────── 

125 

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. 

131 

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) 

138 

139 body = _json() 

140 consent_type = str(body.get('consent_type', '')).strip() 

141 scope = str(body.get('scope', '*')).strip() or '*' 

142 

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') 

151 

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 

165 

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) 

176 

177 

178# ────────────────────────────────────────────────────────────────────── 

179# POST /api/social/consent/revoke — set revoked_at on the active row 

180# ────────────────────────────────────────────────────────────────────── 

181 

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). 

186 

187 Active = granted=True AND revoked_at IS NULL. 

188 

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) 

199 

200 body = _json() 

201 consent_type = str(body.get('consent_type', '')).strip() 

202 scope = str(body.get('scope', '*')).strip() or '*' 

203 

204 if not consent_type: 

205 return _err('consent_type required') 

206 

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() 

216 

217 if row is None: 

218 return _err('no active consent', 404) 

219 

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() 

225 

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 }) 

234 

235 

236# ────────────────────────────────────────────────────────────────────── 

237# GET /api/social/consent — list this user's consents 

238# ────────────────────────────────────────────────────────────────────── 

239 

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). 

244 

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) 

249 

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) 

256 

257 consent_type = request.args.get('consent_type') 

258 active_only = request.args.get('active_only', '').lower() in { 

259 'true', '1', 'yes', 

260 } 

261 

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() 

281 

282 return _ok({ 

283 'consents': [_row_to_dict(r) for r in rows], 

284 'count': len(rows), 

285 })