Coverage for integrations / social / tenant_acl.py: 0.0%

42 statements  

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

1""" 

2HevolveSocial — Phase 8.B WAMP per-tenant subscribe ACL. 

3 

4Plan reference: sunny-gliding-eich.md, Part E.13 + Part 8. 

5 

6This module is the SUBSCRIBE-side counterpart to the publish-side 

7authorizer at integrations/social/realtime._authorize_topic_for_user_id. 

8The publish-side gate fires when our own code tries to emit; the 

9subscribe-side gate fires when an external client (mobile, web, 

10desktop) tries to register interest in a topic. 

11 

12Crossbar.io supports "dynamic authorizers" — an HTTP callback the 

13router invokes for each subscribe/publish request. This module 

14provides the function that callback wraps: 

15 

16 authorize_subscribe(topic, jwt_payload) -> bool 

17 

18Plus a REST endpoint (`GET /api/social/tenants/by-slug/<slug>`) 

19that maps a human-readable tenant slug (e.g., "acme-corp") to the 

20internal `tid` UUID. Nunba web's signup flow calls this to get the 

21JWT 'tid' claim it needs to bind to. 

22 

23Same SQL `tenants` table lookup pattern as Plan E.1 — stays a 

24no-op for flat / regional / Nunba bundled deploys (no tenants 

25exist in those modes). 

26 

27Transport: this module ONLY makes authorization decisions; no 

28publish, no fan-out. Crossbar router is the publish gate; this 

29module is the subscribe gate. 

30""" 

31 

32from __future__ import annotations 

33 

34import logging 

35from typing import Any, Dict, Optional 

36 

37from sqlalchemy import text 

38 

39from .realtime_acl import parse_topic 

40 

41logger = logging.getLogger('hevolve_social') 

42 

43 

44def authorize_subscribe(topic: str, 

45 jwt_payload: Optional[Dict[str, Any]]) -> bool: 

46 """Phase 8.B subscribe-side gate. 

47 

48 Decision tree: 

49 1. No topic → refuse. 

50 2. Public topic (`community.feed`, `chat.social`, etc.) → 

51 allow for any authenticated user. 

52 3. Tenant-scoped topic (`tenant.<tid>.*`) → allow iff JWT 

53 `tid` claim matches. 

54 4. Per-user topic ending in `.<user_id>` → allow iff JWT 

55 `user_id` claim matches. 

56 5. Anything else → refuse (unknown shape). 

57 

58 `jwt_payload` is the decoded JWT body the router parsed from the 

59 subscriber's `WAMP-Auth` header. Caller (the crossbar dynamic 

60 authorizer endpoint) is responsible for verifying signature 

61 BEFORE calling this — we trust the payload here. 

62 

63 Returns True (allow) or False (refuse). Never raises — a 

64 malformed input is treated as refuse so a deploy bug fails 

65 closed. 

66 """ 

67 if not topic: 

68 return False 

69 payload = jwt_payload or {} 

70 

71 # Public topics — any authenticated user may subscribe. 

72 PUBLIC_PREFIXES = ( 

73 'community.feed', 'chat.social', 

74 'social.post.', 'social.comment.', 'social.vote.', 

75 ) 

76 if any(topic == p or topic.startswith(p) for p in PUBLIC_PREFIXES): 

77 return bool(payload.get('user_id')) 

78 

79 # Tenant-scoped: `tenant.<tid>.<scope>.<id>.<event>`. 

80 # Review M2 fix: parse via the shared `parse_topic` helper so 

81 # publish-side and subscribe-side gates can never drift on 

82 # topic-shape semantics (Pass-2 N-NEW-4 substring-vs-segment 

83 # bug surfaced on the publish side; this prevents the same bug 

84 # ever existing on the subscribe side independently). 

85 parsed = parse_topic(topic) 

86 if parsed.is_tenant_scoped: 

87 # tid must match the JWT claim. 

88 if payload.get('tid') != parsed.tid: 

89 logger.info( 

90 "WAMP subscribe refused: cross-tenant — topic tid=%s, " 

91 "JWT tid=%s, user=%s", 

92 parsed.tid, payload.get('tid'), payload.get('user_id')) 

93 return False 

94 scope = parsed.scope 

95 if scope == 'conv': 

96 # Service-layer membership is the gate; subscribe-side 

97 # accepts at the tenant boundary. A user could 

98 # technically subscribe to a conv they're not a member 

99 # of, but messages are only published to members via 

100 # ConversationService. Phase 9 hardening can tighten 

101 # this with a membership lookup here. 

102 return True 

103 if scope == 'user': 

104 # User-scope: the topic-end user_id must match the 

105 # JWT user_id. 

106 user_id = payload.get('user_id') 

107 if not user_id: 

108 return False 

109 return parsed.id == user_id 

110 if scope in ('community', 'call'): 

111 # Community / call topics: any tenant member can listen 

112 # in (community privacy is handled at the post level by 

113 # the privacy gate; calls have their own membership 

114 # gate at join time). 

115 return True 

116 # Unknown scope — refuse. 

117 return False 

118 

119 # Per-user topic without `tenant.` prefix (legacy): 

120 # `com.hertzai.hevolve.social.<user_id>` 

121 user_id = payload.get('user_id') 

122 if user_id and ( 

123 topic.endswith(f'.{user_id}') or topic.endswith(f'/{user_id}')): 

124 return True 

125 

126 # Anything else: refuse. 

127 return False 

128 

129 

130def resolve_tenant_slug(db, slug: str) -> Optional[Dict[str, Any]]: 

131 """Map a human tenant slug (e.g. 'acme-corp') to the internal 

132 tenant row. Returns dict with id + name + slug, or None when 

133 the slug is unknown (or the `tenants` table doesn't exist yet — 

134 flat / regional / Nunba bundled deploys). 

135 

136 Used by Nunba web's signup form: user enters slug → this maps 

137 to `tid`, which the JWT issuer puts in the `tid` claim. 

138 """ 

139 if not slug: 

140 return None 

141 try: 

142 row = db.execute(text( 

143 "SELECT id, name, slug, plan, is_suspended " 

144 "FROM tenants WHERE slug = :slug LIMIT 1"), 

145 {'slug': slug} 

146 ).fetchone() 

147 except Exception as e: 

148 # Table missing (pre-Phase-8 deploys) — graceful degrade. 

149 logger.debug("resolve_tenant_slug: tenants table absent: %s", e) 

150 return None 

151 if row is None: 

152 return None 

153 return { 

154 'id': row[0], 

155 'name': row[1], 

156 'slug': row[2], 

157 'plan': row[3], 

158 'is_suspended': bool(row[4]) if row[4] is not None else False, 

159 } 

160 

161 

162__all__ = ['authorize_subscribe', 'resolve_tenant_slug']