nat-engine 1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,873 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+
5
+ """Centralised tenant management service.
6
+
7
+ Used by both the ``nat-admin`` CLI and the ``/api/v1/admin/tenants`` API
8
+ endpoints. All operations gracefully raise :class:`RuntimeError` when no
9
+ ``DATABASE_URL`` is configured.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+ from typing import Optional
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Valid plan tiers (drives quota defaults)
22
+ _VALID_PLANS = {"free", "pro", "team", "enterprise"}
23
+
24
+
25
+ class TenantManager:
26
+ """Centralised tenant management — used by both CLI and admin API."""
27
+
28
+ # ------------------------------------------------------------------
29
+ # Internal helpers
30
+ # ------------------------------------------------------------------
31
+
32
+ @staticmethod
33
+ def _require_db():
34
+ """Return (engine, session_factory) or raise RuntimeError."""
35
+ try:
36
+ from mannf.product.database import ( # noqa: PLC0415
37
+ _async_session_factory,
38
+ _build_engine,
39
+ )
40
+ except ImportError as exc:
41
+ raise RuntimeError("Database module not available") from exc
42
+
43
+ if _build_engine() is None or _async_session_factory is None:
44
+ raise RuntimeError(
45
+ "Database not configured — set DATABASE_URL and restart."
46
+ )
47
+ return _async_session_factory
48
+
49
+ @staticmethod
50
+ def _tenant_to_dict(tenant_obj) -> dict:
51
+ return {
52
+ "tenant_id": str(tenant_obj.id),
53
+ "name": tenant_obj.name,
54
+ "email": tenant_obj.email,
55
+ "plan": tenant_obj.plan_tier,
56
+ "monthly_scan_quota": tenant_obj.monthly_scan_quota,
57
+ "monthly_security_scan_quota": tenant_obj.monthly_security_scan_quota,
58
+ "max_concurrent_scans": getattr(tenant_obj, "max_concurrent_scans", 1),
59
+ "is_active": tenant_obj.is_active,
60
+ "created_at": tenant_obj.created_at.isoformat() if tenant_obj.created_at else None,
61
+ "updated_at": tenant_obj.updated_at.isoformat() if tenant_obj.updated_at else None,
62
+ }
63
+
64
+ # ------------------------------------------------------------------
65
+ # CRUD operations
66
+ # ------------------------------------------------------------------
67
+
68
+ async def create_tenant(
69
+ self,
70
+ name: str,
71
+ email: str,
72
+ plan_tier: str = "free",
73
+ ) -> dict:
74
+ """Create a new tenant with a default API key.
75
+
76
+ Returns a dict with: tenant_id, name, email, plan, api_key
77
+ (plaintext — shown once).
78
+
79
+ Raises:
80
+ RuntimeError: if the database is not configured.
81
+ ValueError: if the plan tier is unknown.
82
+ """
83
+ if plan_tier not in _VALID_PLANS:
84
+ raise ValueError(
85
+ f"Unknown plan tier {plan_tier!r}. "
86
+ f"Valid tiers: {sorted(_VALID_PLANS)}"
87
+ )
88
+
89
+ session_factory = self._require_db()
90
+
91
+ from mannf.product.billing.plans import get_plan # noqa: PLC0415
92
+ from mannf.product.billing.stripe_billing import generate_api_key # noqa: PLC0415
93
+ from mannf.product.models import ApiKey, Tenant # noqa: PLC0415
94
+
95
+ plan = get_plan(plan_tier)
96
+ plaintext_key, key_hash, key_prefix = generate_api_key()
97
+
98
+ async with session_factory() as session:
99
+ tenant = Tenant(
100
+ id=uuid.uuid4(),
101
+ name=name,
102
+ email=email,
103
+ plan_tier=plan_tier,
104
+ monthly_scan_quota=plan.monthly_scan_quota,
105
+ monthly_security_scan_quota=plan.monthly_security_scan_quota,
106
+ max_concurrent_scans=plan.max_concurrent_scans,
107
+ is_active=True,
108
+ )
109
+ session.add(tenant)
110
+ await session.flush()
111
+
112
+ api_key_obj = ApiKey(
113
+ id=uuid.uuid4(),
114
+ tenant_id=tenant.id,
115
+ key_hash=key_hash,
116
+ key_prefix=key_prefix,
117
+ label="Default",
118
+ is_active=True,
119
+ )
120
+ session.add(api_key_obj)
121
+ await session.commit()
122
+
123
+ logger.info("Created tenant %s (%s, plan=%s)", tenant.id, email, plan_tier)
124
+ result = self._tenant_to_dict(tenant)
125
+ result["api_key"] = plaintext_key
126
+
127
+ # Audit log
128
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
129
+ import asyncio # noqa: PLC0415
130
+ asyncio.ensure_future(log_billing_event(
131
+ "tenant.created",
132
+ tenant_id=str(tenant.id),
133
+ source="admin",
134
+ details={"name": name, "email": email, "plan_tier": plan_tier},
135
+ ))
136
+
137
+ return result
138
+
139
+ async def list_tenants(
140
+ self,
141
+ plan_filter: Optional[str] = None,
142
+ active_only: bool = True,
143
+ limit: int = 100,
144
+ ) -> list[dict]:
145
+ """List tenants with optional filtering.
146
+
147
+ Args:
148
+ plan_filter: If set, only return tenants on this plan tier.
149
+ active_only: If True (default), exclude deactivated tenants.
150
+ limit: Maximum number of results to return.
151
+
152
+ Returns:
153
+ List of tenant dicts (without api_key — never returned in lists).
154
+ """
155
+ session_factory = self._require_db()
156
+
157
+ from sqlalchemy import select # noqa: PLC0415
158
+ from mannf.product.models import Tenant # noqa: PLC0415
159
+
160
+ async with session_factory() as session:
161
+ stmt = select(Tenant).order_by(Tenant.created_at.desc()).limit(limit)
162
+ if active_only:
163
+ stmt = stmt.where(Tenant.is_active.is_(True))
164
+ if plan_filter is not None:
165
+ stmt = stmt.where(Tenant.plan_tier == plan_filter)
166
+ result = await session.execute(stmt)
167
+ tenants = result.scalars().all()
168
+
169
+ return [self._tenant_to_dict(t) for t in tenants]
170
+
171
+ async def get_tenant(self, tenant_id: str) -> Optional[dict]:
172
+ """Get tenant details by ID.
173
+
174
+ Returns None if not found.
175
+ """
176
+ session_factory = self._require_db()
177
+
178
+ from mannf.product.models import Tenant # noqa: PLC0415
179
+
180
+ try:
181
+ tid = uuid.UUID(tenant_id)
182
+ except ValueError:
183
+ return None
184
+
185
+ async with session_factory() as session:
186
+ tenant = await session.get(Tenant, tid)
187
+
188
+ if tenant is None:
189
+ return None
190
+ return self._tenant_to_dict(tenant)
191
+
192
+ async def update_tenant(
193
+ self,
194
+ tenant_id: str,
195
+ plan_tier: Optional[str] = None,
196
+ monthly_scan_quota: Optional[int] = None,
197
+ monthly_security_scan_quota: Optional[int] = None,
198
+ is_active: Optional[bool] = None,
199
+ ) -> dict:
200
+ """Update tenant settings.
201
+
202
+ Returns the updated tenant dict.
203
+
204
+ Raises:
205
+ KeyError: if the tenant is not found.
206
+ ValueError: if an invalid plan tier is supplied.
207
+ """
208
+ if plan_tier is not None and plan_tier not in _VALID_PLANS:
209
+ raise ValueError(
210
+ f"Unknown plan tier {plan_tier!r}. "
211
+ f"Valid tiers: {sorted(_VALID_PLANS)}"
212
+ )
213
+
214
+ session_factory = self._require_db()
215
+
216
+ from mannf.product.billing.plans import get_plan # noqa: PLC0415
217
+ from mannf.product.models import Tenant # noqa: PLC0415
218
+
219
+ try:
220
+ tid = uuid.UUID(tenant_id)
221
+ except ValueError as exc:
222
+ raise KeyError(f"Tenant not found: {tenant_id!r}") from exc
223
+
224
+ async with session_factory() as session:
225
+ tenant = await session.get(Tenant, tid)
226
+ if tenant is None:
227
+ raise KeyError(f"Tenant not found: {tenant_id!r}")
228
+
229
+ if plan_tier is not None:
230
+ tenant.plan_tier = plan_tier
231
+ plan = get_plan(plan_tier)
232
+ # Only update quotas from plan defaults if not explicitly overridden
233
+ if monthly_scan_quota is None:
234
+ tenant.monthly_scan_quota = plan.monthly_scan_quota
235
+ if monthly_security_scan_quota is None:
236
+ tenant.monthly_security_scan_quota = plan.monthly_security_scan_quota
237
+ tenant.max_concurrent_scans = plan.max_concurrent_scans
238
+ if monthly_scan_quota is not None:
239
+ tenant.monthly_scan_quota = monthly_scan_quota
240
+ if monthly_security_scan_quota is not None:
241
+ tenant.monthly_security_scan_quota = monthly_security_scan_quota
242
+ if is_active is not None:
243
+ tenant.is_active = is_active
244
+
245
+ tenant.updated_at = datetime.now(timezone.utc)
246
+ await session.commit()
247
+ await session.refresh(tenant)
248
+
249
+ logger.info("Updated tenant %s", tenant_id)
250
+ # Audit log
251
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
252
+ import asyncio # noqa: PLC0415
253
+ event_type = "tenant.deactivated" if is_active is False else "tenant.updated"
254
+ asyncio.ensure_future(log_billing_event(
255
+ event_type,
256
+ tenant_id=tenant_id,
257
+ source="admin",
258
+ details={
259
+ "plan_tier": plan_tier,
260
+ "is_active": is_active,
261
+ },
262
+ ))
263
+ return self._tenant_to_dict(tenant)
264
+
265
+ async def rotate_api_key(self, tenant_id: str) -> dict:
266
+ """Revoke the existing API key(s) and generate a fresh one.
267
+
268
+ Returns a dict with: tenant_id, api_key (new plaintext key).
269
+
270
+ Raises:
271
+ KeyError: if the tenant is not found.
272
+ """
273
+ session_factory = self._require_db()
274
+
275
+ from sqlalchemy import select # noqa: PLC0415
276
+ from mannf.product.billing.stripe_billing import generate_api_key # noqa: PLC0415
277
+ from mannf.product.models import ApiKey, Tenant # noqa: PLC0415
278
+
279
+ try:
280
+ tid = uuid.UUID(tenant_id)
281
+ except ValueError as exc:
282
+ raise KeyError(f"Tenant not found: {tenant_id!r}") from exc
283
+
284
+ plaintext_key, key_hash, key_prefix = generate_api_key()
285
+
286
+ async with session_factory() as session:
287
+ tenant = await session.get(Tenant, tid)
288
+ if tenant is None:
289
+ raise KeyError(f"Tenant not found: {tenant_id!r}")
290
+
291
+ # Deactivate all current keys
292
+ stmt = select(ApiKey).where(
293
+ ApiKey.tenant_id == tid,
294
+ ApiKey.is_active.is_(True),
295
+ )
296
+ result = await session.execute(stmt)
297
+ for key_obj in result.scalars().all():
298
+ key_obj.is_active = False
299
+
300
+ # Insert the new key
301
+ new_key = ApiKey(
302
+ id=uuid.uuid4(),
303
+ tenant_id=tid,
304
+ key_hash=key_hash,
305
+ key_prefix=key_prefix,
306
+ label="Rotated",
307
+ is_active=True,
308
+ )
309
+ session.add(new_key)
310
+ await session.commit()
311
+
312
+ logger.info("Rotated API key for tenant %s", tenant_id)
313
+ # Audit log
314
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
315
+ import asyncio # noqa: PLC0415
316
+ asyncio.ensure_future(log_billing_event(
317
+ "api_key.rotated",
318
+ tenant_id=tenant_id,
319
+ source="admin",
320
+ details={"key_prefix": key_prefix},
321
+ ))
322
+ return {"tenant_id": tenant_id, "api_key": plaintext_key}
323
+
324
+ async def deactivate_tenant(self, tenant_id: str) -> dict:
325
+ """Soft-delete: mark tenant as inactive and revoke all API keys.
326
+
327
+ Returns the updated tenant dict.
328
+
329
+ Raises:
330
+ KeyError: if the tenant is not found.
331
+ """
332
+ session_factory = self._require_db()
333
+
334
+ from sqlalchemy import select # noqa: PLC0415
335
+ from mannf.product.models import ApiKey, Tenant # noqa: PLC0415
336
+
337
+ try:
338
+ tid = uuid.UUID(tenant_id)
339
+ except ValueError as exc:
340
+ raise KeyError(f"Tenant not found: {tenant_id!r}") from exc
341
+
342
+ async with session_factory() as session:
343
+ tenant = await session.get(Tenant, tid)
344
+ if tenant is None:
345
+ raise KeyError(f"Tenant not found: {tenant_id!r}")
346
+
347
+ tenant.is_active = False
348
+ tenant.updated_at = datetime.now(timezone.utc)
349
+
350
+ # Revoke all active API keys
351
+ stmt = select(ApiKey).where(
352
+ ApiKey.tenant_id == tid,
353
+ ApiKey.is_active.is_(True),
354
+ )
355
+ result = await session.execute(stmt)
356
+ for key_obj in result.scalars().all():
357
+ key_obj.is_active = False
358
+
359
+ await session.commit()
360
+ await session.refresh(tenant)
361
+
362
+ logger.info("Deactivated tenant %s", tenant_id)
363
+ # Audit log
364
+ from mannf.product.billing.audit import log_billing_event # noqa: PLC0415
365
+ import asyncio # noqa: PLC0415
366
+ asyncio.ensure_future(log_billing_event(
367
+ "tenant.deactivated",
368
+ tenant_id=tenant_id,
369
+ source="admin",
370
+ details={},
371
+ ))
372
+ return self._tenant_to_dict(tenant)
373
+
374
+ async def get_tenant_usage(self, tenant_id: str) -> dict:
375
+ """Get current month's usage statistics for a tenant.
376
+
377
+ Returns a dict with: tenant_id, period, scan_count, security_scan_count,
378
+ monthly_scan_quota, monthly_security_scan_quota.
379
+
380
+ Raises:
381
+ KeyError: if the tenant is not found.
382
+ """
383
+ session_factory = self._require_db()
384
+
385
+ from sqlalchemy import func, select # noqa: PLC0415
386
+ from mannf.product.models import Tenant, UsageRecord # noqa: PLC0415
387
+
388
+ try:
389
+ tid = uuid.UUID(tenant_id)
390
+ except ValueError as exc:
391
+ raise KeyError(f"Tenant not found: {tenant_id!r}") from exc
392
+
393
+ now = datetime.now(timezone.utc)
394
+
395
+ async with session_factory() as session:
396
+ tenant = await session.get(Tenant, tid)
397
+ if tenant is None:
398
+ raise KeyError(f"Tenant not found: {tenant_id!r}")
399
+
400
+ def _count_stmt(scan_type: str):
401
+ return (
402
+ select(func.count())
403
+ .select_from(UsageRecord)
404
+ .where(UsageRecord.tenant_id == tid)
405
+ .where(UsageRecord.scan_type == scan_type)
406
+ .where(UsageRecord.period_year == now.year)
407
+ .where(UsageRecord.period_month == now.month)
408
+ )
409
+
410
+ scan_count = (await session.execute(_count_stmt("scan"))).scalar() or 0
411
+ security_count = (
412
+ await session.execute(_count_stmt("security_scan"))
413
+ ).scalar() or 0
414
+
415
+ return {
416
+ "tenant_id": tenant_id,
417
+ "period": f"{now.year}-{now.month:02d}",
418
+ "scan_count": scan_count,
419
+ "security_scan_count": security_count,
420
+ "monthly_scan_quota": tenant.monthly_scan_quota,
421
+ "monthly_security_scan_quota": tenant.monthly_security_scan_quota,
422
+ }
423
+
424
+ # ------------------------------------------------------------------
425
+ # Static dict helpers for new models
426
+ # ------------------------------------------------------------------
427
+
428
+ @staticmethod
429
+ def _workspace_to_dict(ws) -> dict:
430
+ return {
431
+ "workspace_id": str(ws.id),
432
+ "tenant_id": str(ws.tenant_id),
433
+ "name": ws.name,
434
+ "description": ws.description,
435
+ "is_active": ws.is_active,
436
+ "created_at": ws.created_at.isoformat() if ws.created_at else None,
437
+ "updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
438
+ }
439
+
440
+ @staticmethod
441
+ def _project_to_dict(p) -> dict:
442
+ return {
443
+ "project_id": str(p.id),
444
+ "workspace_id": str(p.workspace_id),
445
+ "tenant_id": str(p.tenant_id),
446
+ "name": p.name,
447
+ "description": p.description,
448
+ "parent_project_id": str(p.parent_project_id) if p.parent_project_id else None,
449
+ "is_active": p.is_active,
450
+ "created_at": p.created_at.isoformat() if p.created_at else None,
451
+ "updated_at": p.updated_at.isoformat() if p.updated_at else None,
452
+ }
453
+
454
+ @staticmethod
455
+ def _user_to_dict(u, role: str | None = None) -> dict:
456
+ return {
457
+ "user_id": str(u.id),
458
+ "tenant_id": str(u.tenant_id),
459
+ "email": u.email,
460
+ "name": u.name,
461
+ "sso_subject": u.sso_subject,
462
+ "is_active": u.is_active,
463
+ "role": role,
464
+ "created_at": u.created_at.isoformat() if u.created_at else None,
465
+ "updated_at": u.updated_at.isoformat() if u.updated_at else None,
466
+ }
467
+
468
+ # ------------------------------------------------------------------
469
+ # Workspace CRUD
470
+ # ------------------------------------------------------------------
471
+
472
+ async def create_workspace(self, tenant_id: str, name: str, description: str | None = None) -> dict:
473
+ """Create a workspace for a tenant."""
474
+ session_factory = self._require_db()
475
+ from mannf.product.models import Tenant, Workspace # noqa: PLC0415
476
+
477
+ try:
478
+ tid = uuid.UUID(tenant_id)
479
+ except ValueError as exc:
480
+ raise KeyError(f"Tenant not found: {tenant_id!r}") from exc
481
+
482
+ async with session_factory() as session:
483
+ tenant = await session.get(Tenant, tid)
484
+ if tenant is None or not tenant.is_active:
485
+ raise KeyError(f"Tenant not found: {tenant_id!r}")
486
+
487
+ ws = Workspace(
488
+ id=uuid.uuid4(),
489
+ tenant_id=tid,
490
+ name=name,
491
+ description=description,
492
+ is_active=True,
493
+ )
494
+ session.add(ws)
495
+ await session.commit()
496
+ await session.refresh(ws)
497
+
498
+ return self._workspace_to_dict(ws)
499
+
500
+ async def list_workspaces(self, tenant_id: str) -> list[dict]:
501
+ """List workspaces for a tenant."""
502
+ session_factory = self._require_db()
503
+ from sqlalchemy import select # noqa: PLC0415
504
+ from mannf.product.models import Workspace # noqa: PLC0415
505
+
506
+ try:
507
+ tid = uuid.UUID(tenant_id)
508
+ except ValueError as exc:
509
+ raise KeyError(f"Tenant not found: {tenant_id!r}") from exc
510
+
511
+ async with session_factory() as session:
512
+ stmt = select(Workspace).where(
513
+ Workspace.tenant_id == tid,
514
+ Workspace.is_active.is_(True),
515
+ )
516
+ result = await session.execute(stmt)
517
+ workspaces = result.scalars().all()
518
+
519
+ return [self._workspace_to_dict(ws) for ws in workspaces]
520
+
521
+ async def get_workspace(self, tenant_id: str, workspace_id: str) -> dict | None:
522
+ """Get a workspace by ID (scoped to tenant)."""
523
+ session_factory = self._require_db()
524
+ from mannf.product.models import Workspace # noqa: PLC0415
525
+
526
+ try:
527
+ tid = uuid.UUID(tenant_id)
528
+ wid = uuid.UUID(workspace_id)
529
+ except ValueError:
530
+ return None
531
+
532
+ async with session_factory() as session:
533
+ ws = await session.get(Workspace, wid)
534
+ if ws is None or ws.tenant_id != tid or not ws.is_active:
535
+ return None
536
+
537
+ return self._workspace_to_dict(ws)
538
+
539
+ async def delete_workspace(self, tenant_id: str, workspace_id: str) -> dict:
540
+ """Delete (deactivate) a workspace."""
541
+ session_factory = self._require_db()
542
+ from mannf.product.models import Workspace # noqa: PLC0415
543
+ from datetime import datetime, timezone # noqa: PLC0415
544
+
545
+ try:
546
+ tid = uuid.UUID(tenant_id)
547
+ wid = uuid.UUID(workspace_id)
548
+ except ValueError as exc:
549
+ raise KeyError(f"Workspace not found: {workspace_id!r}") from exc
550
+
551
+ async with session_factory() as session:
552
+ ws = await session.get(Workspace, wid)
553
+ if ws is None or ws.tenant_id != tid:
554
+ raise KeyError(f"Workspace not found: {workspace_id!r}")
555
+
556
+ ws.is_active = False
557
+ ws.updated_at = datetime.now(timezone.utc)
558
+ await session.commit()
559
+ await session.refresh(ws)
560
+
561
+ return self._workspace_to_dict(ws)
562
+
563
+ # ------------------------------------------------------------------
564
+ # Project CRUD
565
+ # ------------------------------------------------------------------
566
+
567
+ async def create_project(
568
+ self,
569
+ tenant_id: str,
570
+ workspace_id: str,
571
+ name: str,
572
+ description: str | None = None,
573
+ parent_project_id: str | None = None,
574
+ ) -> dict:
575
+ """Create a project in a workspace."""
576
+ session_factory = self._require_db()
577
+ from mannf.product.models import Project, Workspace # noqa: PLC0415
578
+
579
+ try:
580
+ tid = uuid.UUID(tenant_id)
581
+ wid = uuid.UUID(workspace_id)
582
+ except ValueError as exc:
583
+ raise KeyError(f"Workspace not found: {workspace_id!r}") from exc
584
+
585
+ parent_uuid: Optional[uuid.UUID] = None
586
+ if parent_project_id:
587
+ try:
588
+ parent_uuid = uuid.UUID(parent_project_id)
589
+ except ValueError as exc:
590
+ raise ValueError(f"Invalid parent_project_id: {parent_project_id!r}") from exc
591
+
592
+ async with session_factory() as session:
593
+ ws = await session.get(Workspace, wid)
594
+ if ws is None or ws.tenant_id != tid or not ws.is_active:
595
+ raise KeyError(f"Workspace not found: {workspace_id!r}")
596
+
597
+ proj = Project(
598
+ id=uuid.uuid4(),
599
+ workspace_id=wid,
600
+ tenant_id=tid,
601
+ name=name,
602
+ description=description,
603
+ parent_project_id=parent_uuid,
604
+ is_active=True,
605
+ )
606
+ session.add(proj)
607
+ await session.commit()
608
+ await session.refresh(proj)
609
+
610
+ return self._project_to_dict(proj)
611
+
612
+ async def list_projects(self, tenant_id: str, workspace_id: str) -> list[dict]:
613
+ """List projects in a workspace (tenant-scoped)."""
614
+ session_factory = self._require_db()
615
+ from sqlalchemy import select # noqa: PLC0415
616
+ from mannf.product.models import Project # noqa: PLC0415
617
+
618
+ try:
619
+ tid = uuid.UUID(tenant_id)
620
+ wid = uuid.UUID(workspace_id)
621
+ except ValueError as exc:
622
+ raise KeyError(f"Workspace not found: {workspace_id!r}") from exc
623
+
624
+ async with session_factory() as session:
625
+ stmt = select(Project).where(
626
+ Project.workspace_id == wid,
627
+ Project.tenant_id == tid,
628
+ Project.is_active.is_(True),
629
+ )
630
+ result = await session.execute(stmt)
631
+ projects = result.scalars().all()
632
+
633
+ return [self._project_to_dict(p) for p in projects]
634
+
635
+ async def get_project(self, tenant_id: str, project_id: str) -> dict | None:
636
+ """Get a project by ID (scoped to tenant)."""
637
+ session_factory = self._require_db()
638
+ from mannf.product.models import Project # noqa: PLC0415
639
+
640
+ try:
641
+ tid = uuid.UUID(tenant_id)
642
+ pid = uuid.UUID(project_id)
643
+ except ValueError:
644
+ return None
645
+
646
+ async with session_factory() as session:
647
+ proj = await session.get(Project, pid)
648
+ if proj is None or proj.tenant_id != tid or not proj.is_active:
649
+ return None
650
+
651
+ return self._project_to_dict(proj)
652
+
653
+ async def delete_project(self, tenant_id: str, project_id: str) -> dict:
654
+ """Delete (deactivate) a project."""
655
+ session_factory = self._require_db()
656
+ from mannf.product.models import Project # noqa: PLC0415
657
+ from datetime import datetime, timezone # noqa: PLC0415
658
+
659
+ try:
660
+ tid = uuid.UUID(tenant_id)
661
+ pid = uuid.UUID(project_id)
662
+ except ValueError as exc:
663
+ raise KeyError(f"Project not found: {project_id!r}") from exc
664
+
665
+ async with session_factory() as session:
666
+ proj = await session.get(Project, pid)
667
+ if proj is None or proj.tenant_id != tid:
668
+ raise KeyError(f"Project not found: {project_id!r}")
669
+
670
+ proj.is_active = False
671
+ proj.updated_at = datetime.now(timezone.utc)
672
+ await session.commit()
673
+ await session.refresh(proj)
674
+
675
+ return self._project_to_dict(proj)
676
+
677
+ # ------------------------------------------------------------------
678
+ # User management
679
+ # ------------------------------------------------------------------
680
+
681
+ async def invite_user(self, tenant_id: str, email: str, name: str, role: str = "viewer") -> dict:
682
+ """Create a user record (invite). Enforces seat limit."""
683
+ session_factory = self._require_db()
684
+ from sqlalchemy import func, select # noqa: PLC0415
685
+ from mannf.product.models import Tenant, User, UserRole # noqa: PLC0415
686
+ from mannf.product.billing.plans import get_plan # noqa: PLC0415
687
+
688
+ try:
689
+ tid = uuid.UUID(tenant_id)
690
+ except ValueError as exc:
691
+ raise KeyError(f"Tenant not found: {tenant_id!r}") from exc
692
+
693
+ async with session_factory() as session:
694
+ tenant = await session.get(Tenant, tid)
695
+ if tenant is None or not tenant.is_active:
696
+ raise KeyError(f"Tenant not found: {tenant_id!r}")
697
+
698
+ plan = get_plan(tenant.plan_tier)
699
+ max_seats = getattr(plan, "max_seats", 0)
700
+
701
+ if max_seats > 0:
702
+ count_stmt = select(func.count()).select_from(User).where(
703
+ User.tenant_id == tid,
704
+ User.is_active.is_(True),
705
+ )
706
+ current_count = (await session.execute(count_stmt)).scalar() or 0
707
+ if current_count >= max_seats:
708
+ raise ValueError(
709
+ f"SEAT_LIMIT_EXCEEDED: plan {tenant.plan_tier!r} allows {max_seats} seat(s), "
710
+ f"currently {current_count} active user(s)."
711
+ )
712
+
713
+ user_obj = User(
714
+ id=uuid.uuid4(),
715
+ tenant_id=tid,
716
+ email=email,
717
+ name=name,
718
+ sso_subject=None,
719
+ is_active=True,
720
+ )
721
+ session.add(user_obj)
722
+ await session.flush()
723
+
724
+ role_obj = UserRole(
725
+ id=uuid.uuid4(),
726
+ user_id=user_obj.id,
727
+ tenant_id=tid,
728
+ workspace_id=None,
729
+ role=role,
730
+ )
731
+ session.add(role_obj)
732
+ await session.commit()
733
+ await session.refresh(user_obj)
734
+
735
+ return self._user_to_dict(user_obj, role=role)
736
+
737
+ async def list_users(self, tenant_id: str) -> list[dict]:
738
+ """List users in a tenant."""
739
+ session_factory = self._require_db()
740
+ from sqlalchemy import select # noqa: PLC0415
741
+ from mannf.product.models import User, UserRole # noqa: PLC0415
742
+
743
+ try:
744
+ tid = uuid.UUID(tenant_id)
745
+ except ValueError as exc:
746
+ raise KeyError(f"Tenant not found: {tenant_id!r}") from exc
747
+
748
+ async with session_factory() as session:
749
+ stmt = select(User).where(
750
+ User.tenant_id == tid,
751
+ User.is_active.is_(True),
752
+ )
753
+ result = await session.execute(stmt)
754
+ users = result.scalars().all()
755
+
756
+ out = []
757
+ for u in users:
758
+ role_stmt = select(UserRole).where(
759
+ UserRole.user_id == u.id,
760
+ UserRole.tenant_id == tid,
761
+ UserRole.workspace_id.is_(None),
762
+ )
763
+ role_result = await session.execute(role_stmt)
764
+ role_obj = role_result.scalars().first()
765
+ out.append(self._user_to_dict(u, role=role_obj.role if role_obj else None))
766
+
767
+ return out
768
+
769
+ async def get_user(self, tenant_id: str, user_id: str) -> dict | None:
770
+ """Get a user by ID (scoped to tenant)."""
771
+ session_factory = self._require_db()
772
+ from sqlalchemy import select # noqa: PLC0415
773
+ from mannf.product.models import User, UserRole # noqa: PLC0415
774
+
775
+ try:
776
+ tid = uuid.UUID(tenant_id)
777
+ uid = uuid.UUID(user_id)
778
+ except ValueError:
779
+ return None
780
+
781
+ async with session_factory() as session:
782
+ user_obj = await session.get(User, uid)
783
+ if user_obj is None or user_obj.tenant_id != tid or not user_obj.is_active:
784
+ return None
785
+
786
+ role_stmt = select(UserRole).where(
787
+ UserRole.user_id == uid,
788
+ UserRole.tenant_id == tid,
789
+ UserRole.workspace_id.is_(None),
790
+ )
791
+ role_result = await session.execute(role_stmt)
792
+ role_obj = role_result.scalars().first()
793
+
794
+ return self._user_to_dict(user_obj, role=role_obj.role if role_obj else None)
795
+
796
+ async def assign_role(
797
+ self,
798
+ tenant_id: str,
799
+ user_id: str,
800
+ role: str,
801
+ workspace_id: str | None = None,
802
+ ) -> dict:
803
+ """Assign or update a user's role (tenant-wide or workspace-scoped)."""
804
+ session_factory = self._require_db()
805
+ from sqlalchemy import select # noqa: PLC0415
806
+ from mannf.product.models import User, UserRole # noqa: PLC0415
807
+
808
+ try:
809
+ tid = uuid.UUID(tenant_id)
810
+ uid = uuid.UUID(user_id)
811
+ except ValueError as exc:
812
+ raise KeyError(f"User not found: {user_id!r}") from exc
813
+
814
+ wid: Optional[uuid.UUID] = None
815
+ if workspace_id:
816
+ try:
817
+ wid = uuid.UUID(workspace_id)
818
+ except ValueError as exc:
819
+ raise ValueError(f"Invalid workspace_id: {workspace_id!r}") from exc
820
+
821
+ async with session_factory() as session:
822
+ user_obj = await session.get(User, uid)
823
+ if user_obj is None or user_obj.tenant_id != tid:
824
+ raise KeyError(f"User not found: {user_id!r}")
825
+
826
+ role_stmt = select(UserRole).where(
827
+ UserRole.user_id == uid,
828
+ UserRole.tenant_id == tid,
829
+ UserRole.workspace_id == wid if wid else UserRole.workspace_id.is_(None),
830
+ )
831
+ role_result = await session.execute(role_stmt)
832
+ role_obj = role_result.scalars().first()
833
+
834
+ if role_obj is None:
835
+ role_obj = UserRole(
836
+ id=uuid.uuid4(),
837
+ user_id=uid,
838
+ tenant_id=tid,
839
+ workspace_id=wid,
840
+ role=role,
841
+ )
842
+ session.add(role_obj)
843
+ else:
844
+ role_obj.role = role
845
+
846
+ await session.commit()
847
+ await session.refresh(user_obj)
848
+
849
+ return self._user_to_dict(user_obj, role=role)
850
+
851
+ async def remove_user(self, tenant_id: str, user_id: str) -> dict:
852
+ """Deactivate a user."""
853
+ session_factory = self._require_db()
854
+ from mannf.product.models import User # noqa: PLC0415
855
+ from datetime import datetime, timezone # noqa: PLC0415
856
+
857
+ try:
858
+ tid = uuid.UUID(tenant_id)
859
+ uid = uuid.UUID(user_id)
860
+ except ValueError as exc:
861
+ raise KeyError(f"User not found: {user_id!r}") from exc
862
+
863
+ async with session_factory() as session:
864
+ user_obj = await session.get(User, uid)
865
+ if user_obj is None or user_obj.tenant_id != tid:
866
+ raise KeyError(f"User not found: {user_id!r}")
867
+
868
+ user_obj.is_active = False
869
+ user_obj.updated_at = datetime.now(timezone.utc)
870
+ await session.commit()
871
+ await session.refresh(user_obj)
872
+
873
+ return self._user_to_dict(user_obj)