Coverage for src / lexigram / contracts / tenancy / protocols.py: 0%

33 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-19 05:41 +0800

1"""Tenancy protocol definitions.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable 

6 

7if TYPE_CHECKING: 

8 from lexigram.contracts.core.result import Result 

9 from lexigram.contracts.tenancy.commands import ( 

10 CreateTenantCommand, 

11 UpdateTenantCommand, 

12 ) 

13 from lexigram.contracts.tenancy.errors import TenantError 

14 from lexigram.contracts.tenancy.types import TenantInfo, TenantResolutionContext 

15 

16 

17@runtime_checkable 

18class TenantResolverProtocol(Protocol): 

19 """Resolves tenant identity from request context. 

20 

21 Implementations are tried in priority order by 

22 :class:`~lexigram.tenancy.resolution.chain.CompositeResolver`. 

23 Lower ``priority`` value = tried first = higher trust level. 

24 

25 Attributes: 

26 name: Unique resolver name (e.g. ``"header"``, ``"jwt_claim"``). 

27 priority: Ordering weight; lower = tried first. 

28 """ 

29 

30 name: str 

31 priority: int 

32 

33 async def resolve(self, context: TenantResolutionContext) -> str | None: 

34 """Attempt to resolve the tenant identifier from the given context. 

35 

36 Args: 

37 context: Immutable snapshot of request data. 

38 

39 Returns: 

40 The resolved ``tenant_id`` string, or ``None`` if this resolver 

41 cannot determine the tenant from the provided context. 

42 """ 

43 ... 

44 

45 

46@runtime_checkable 

47class TenantProviderProtocol(Protocol): 

48 """Storage-agnostic tenant CRUD operations. 

49 

50 Applications may supply their own implementation by binding a class to 

51 this protocol in the DI container. The default implementations are 

52 :class:`~lexigram.tenancy.stores.memory.InMemoryTenantProvider` (for 

53 testing/dev) and ``SQLTenantProvider`` (when 

54 ``lexigram-tenancy[sql]`` is installed). 

55 """ 

56 

57 async def get_tenant(self, tenant_id: str) -> TenantInfo | None: 

58 """Retrieve a tenant by its unique identifier. 

59 

60 Args: 

61 tenant_id: The unique tenant identifier. 

62 

63 Returns: 

64 The :class:`~lexigram.contracts.tenancy.types.TenantInfo` record, 

65 or ``None`` if no tenant with that ID exists. 

66 """ 

67 ... 

68 

69 async def get_tenant_by_slug(self, slug: str) -> TenantInfo | None: 

70 """Retrieve a tenant by its URL-safe slug. 

71 

72 Args: 

73 slug: The tenant slug (e.g. ``acme-corp``). 

74 

75 Returns: 

76 The matching :class:`~lexigram.contracts.tenancy.types.TenantInfo`, 

77 or ``None`` if not found. 

78 """ 

79 ... 

80 

81 async def list_tenants(self, *, active_only: bool = True) -> list[TenantInfo]: 

82 """List tenants, optionally filtering to active ones only. 

83 

84 Args: 

85 active_only: When ``True`` (default), return only tenants with 

86 ``status == ACTIVE``. 

87 

88 Returns: 

89 List of matching :class:`~lexigram.contracts.tenancy.types.TenantInfo` 

90 records. 

91 """ 

92 ... 

93 

94 async def create_tenant( 

95 self, command: CreateTenantCommand 

96 ) -> Result[TenantInfo, TenantError]: 

97 """Persist a new tenant record. 

98 

99 Args: 

100 command: :class:`~lexigram.contracts.tenancy.commands.CreateTenantCommand` 

101 with the new tenant's attributes. 

102 

103 Returns: 

104 ``Ok(TenantInfo)`` on success, ``Err(TenantError)`` on failure. 

105 """ 

106 ... 

107 

108 async def update_tenant( 

109 self, 

110 tenant_id: str, 

111 command: UpdateTenantCommand, 

112 ) -> Result[TenantInfo, TenantError]: 

113 """Update mutable fields on an existing tenant record. 

114 

115 Args: 

116 tenant_id: Identifier of the tenant to update. 

117 command: :class:`~lexigram.contracts.tenancy.commands.UpdateTenantCommand` 

118 with the fields to apply. 

119 

120 Returns: 

121 ``Ok(TenantInfo)`` with the updated record, or ``Err(TenantError)``. 

122 """ 

123 ... 

124 

125 async def deactivate_tenant(self, tenant_id: str) -> Result[None, TenantError]: 

126 """Mark a tenant as inactive. 

127 

128 Args: 

129 tenant_id: Identifier of the tenant to deactivate. 

130 

131 Returns: 

132 ``Ok(None)`` on success, ``Err(TenantError)`` on failure. 

133 """ 

134 ... 

135 

136 async def activate_tenant(self, tenant_id: str) -> Result[None, TenantError]: 

137 """Mark a tenant as active. 

138 

139 Args: 

140 tenant_id: Identifier of the tenant to activate. 

141 

142 Returns: 

143 ``Ok(None)`` on success, ``Err(TenantError)`` on failure. 

144 """ 

145 ... 

146 

147 async def suspend_tenant( 

148 self, 

149 tenant_id: str, 

150 reason: str | None = None, 

151 ) -> Result[None, TenantError]: 

152 """Mark a tenant as suspended. 

153 

154 Args: 

155 tenant_id: Identifier of the tenant to suspend. 

156 reason: Optional human-readable reason for the suspension. 

157 

158 Returns: 

159 ``Ok(None)`` on success, ``Err(TenantError)`` on failure. 

160 """ 

161 ... 

162 

163 

164@runtime_checkable 

165class TenantMembershipProtocol(Protocol): 

166 """Verifies whether an authenticated caller belongs to a tenant. 

167 

168 Implemented by the application (e.g. over a ``tenant_memberships`` 

169 table, a ``users.tenant_id`` column, or an external identity service). 

170 The framework does not ship an implementation; the app binds one in 

171 the DI container. Membership caching is delegated to the implementer. 

172 

173 See Also: 

174 ``docs/superpowers/specs/2026-08-16-security-tenancy-design.md`` §3.1 

175 for the required sign-off and the deferred schema-provisioning option 

176 (framework-managed ``tenant_memberships`` table). 

177 """ 

178 

179 async def user_belongs_to_tenant(self, user_id: str, tenant_id: str) -> bool: 

180 """Return ``True`` when *user_id* may act under *tenant_id*.""" 

181 ... 

182 

183 

184@runtime_checkable 

185class TenantConfigProviderProtocol(Protocol): 

186 """Per-tenant configuration key-value store. 

187 

188 Provides a low-level get/set interface. The higher-level 

189 :class:`~lexigram.tenancy.config_overrides.service.TenantConfigService` 

190 adds default fallback and event emission on top of this protocol. 

191 """ 

192 

193 async def get_config(self, tenant_id: str, key: str) -> Any | None: 

194 """Retrieve a single configuration value for a tenant. 

195 

196 Args: 

197 tenant_id: The tenant whose configuration is queried. 

198 key: The configuration key. 

199 

200 Returns: 

201 The stored value, or ``None`` if the key is not set for this tenant. 

202 """ 

203 ... 

204 

205 async def get_all_config(self, tenant_id: str) -> dict[str, Any]: 

206 """Retrieve all configuration entries for a tenant. 

207 

208 Args: 

209 tenant_id: The tenant whose configuration is retrieved. 

210 

211 Returns: 

212 A dictionary of all key-value pairs for the tenant. 

213 Returns an empty dict if no overrides are set. 

214 """ 

215 ... 

216 

217 async def set_config(self, tenant_id: str, key: str, value: Any) -> None: 

218 """Set a configuration value for a tenant. 

219 

220 Args: 

221 tenant_id: The tenant whose configuration is updated. 

222 key: The configuration key. 

223 value: The new value (must be JSON-serialisable). 

224 """ 

225 ... 

226 

227 

228@runtime_checkable 

229class TenantIsolationStrategyProtocol(Protocol): 

230 """Pluggable data isolation strategy. 

231 

232 Implementations provide the mechanics of isolating tenant data at the 

233 database layer (row-level, schema-per-tenant, or database-per-tenant). 

234 

235 Attributes: 

236 name: Strategy identifier used by the registry 

237 (``"row_level"``, ``"schema"``, ``"database"``). 

238 """ 

239 

240 name: str 

241 

242 async def apply_isolation(self, tenant_id: str, context: dict[str, Any]) -> None: 

243 """Apply tenant isolation to the given execution context. 

244 

245 For row-level isolation this is a no-op; for schema isolation this sets 

246 the ``search_path`` in *context*. 

247 

248 Args: 

249 tenant_id: The active tenant. 

250 context: Mutable execution context dict to annotate. 

251 """ 

252 ... 

253 

254 async def remove_isolation(self, tenant_id: str) -> None: 

255 """Remove any active isolation for the tenant. 

256 

257 Args: 

258 tenant_id: The tenant whose isolation context to tear down. 

259 """ 

260 ... 

261 

262 async def provision_isolation(self, tenant_id: str) -> Result[None, TenantError]: 

263 """Provision isolation resources for a newly created tenant. 

264 

265 For row-level isolation this is a no-op. For schema isolation this 

266 creates the schema. 

267 

268 Args: 

269 tenant_id: The newly created tenant. 

270 

271 Returns: 

272 ``Ok(None)`` on success, ``Err(TenantError)`` on failure. 

273 """ 

274 ... 

275 

276 async def deprovision_isolation(self, tenant_id: str) -> Result[None, TenantError]: 

277 """Tear down isolation resources for a deactivated tenant. 

278 

279 Args: 

280 tenant_id: The tenant being deactivated. 

281 

282 Returns: 

283 ``Ok(None)`` on success, ``Err(TenantError)`` on failure. 

284 """ 

285 ... 

286 

287 

288__all__ = [ 

289 "TenantConfigProviderProtocol", 

290 "TenantIsolationStrategyProtocol", 

291 "TenantMembershipProtocol", 

292 "TenantProviderProtocol", 

293 "TenantResolverProtocol", 

294]