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
« 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.
4Plan reference: sunny-gliding-eich.md, Part E.13 + Part 8.
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.
12Crossbar.io supports "dynamic authorizers" — an HTTP callback the
13router invokes for each subscribe/publish request. This module
14provides the function that callback wraps:
16 authorize_subscribe(topic, jwt_payload) -> bool
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.
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).
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"""
32from __future__ import annotations
34import logging
35from typing import Any, Dict, Optional
37from sqlalchemy import text
39from .realtime_acl import parse_topic
41logger = logging.getLogger('hevolve_social')
44def authorize_subscribe(topic: str,
45 jwt_payload: Optional[Dict[str, Any]]) -> bool:
46 """Phase 8.B subscribe-side gate.
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).
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.
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 {}
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'))
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
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
126 # Anything else: refuse.
127 return False
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).
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 }
162__all__ = ['authorize_subscribe', 'resolve_tenant_slug']