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,212 @@
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
+ """SAML 2.0 / SSO callback handling for NAT enterprise auth.
6
+
7
+ Integrates with standard SAML 2.0 IdPs (Okta, Azure AD, Google Workspace).
8
+ Maps SAML assertion attributes to NAT tenant + user records.
9
+
10
+ Configuration (env vars):
11
+ NAT_SAML_ENABLED — set to "true" to enable SAML auth
12
+ NAT_SAML_TENANT_ATTRIBUTE — SAML attribute that carries the tenant ID (default: "tenant_id")
13
+ NAT_SAML_ROLE_ATTRIBUTE — SAML attribute that carries the role (default: "nat_role")
14
+ NAT_SAML_EMAIL_ATTRIBUTE — SAML attribute for email (default: "email")
15
+ NAT_SAML_NAME_ATTRIBUTE — SAML attribute for display name (default: "name")
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import logging
22
+ import os
23
+ import uuid
24
+ from typing import Any
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # SAML 2.0 namespaces
29
+ _SAML_NS = {
30
+ "saml": "urn:oasis:names:tc:SAML:2.0:assertion",
31
+ "samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
32
+ }
33
+
34
+ VALID_ROLES = {"admin", "operator", "viewer"}
35
+
36
+
37
+ class SAMLNotConfiguredError(RuntimeError):
38
+ """Raised when SAML auth is requested but not configured."""
39
+
40
+
41
+ class SAMLAssertionError(ValueError):
42
+ """Raised when a SAML assertion is invalid or cannot be processed."""
43
+
44
+
45
+ def is_saml_enabled() -> bool:
46
+ """Return True if SAML auth is enabled via NAT_SAML_ENABLED env var."""
47
+ return os.environ.get("NAT_SAML_ENABLED", "").lower() in ("true", "1", "yes")
48
+
49
+
50
+ def parse_saml_response(saml_response_b64: str) -> dict[str, Any]:
51
+ """Parse a base64-encoded SAML response and extract assertion attributes.
52
+
53
+ Args:
54
+ saml_response_b64: The base64-encoded SAMLResponse form parameter.
55
+
56
+ Returns:
57
+ Dict with keys: name_id, email, name, tenant_id, role, attributes (raw dict).
58
+
59
+ Raises:
60
+ SAMLAssertionError: on parse failure or missing required attributes.
61
+ """
62
+ try:
63
+ xml_bytes = base64.b64decode(saml_response_b64)
64
+ try:
65
+ import defusedxml.ElementTree as ET # noqa: PLC0415
66
+ except ImportError:
67
+ from xml.etree import ElementTree as ET # noqa: PLC0415
68
+ root = ET.fromstring(xml_bytes)
69
+ except Exception as exc:
70
+ raise SAMLAssertionError(f"Failed to parse SAML response: {exc}") from exc
71
+
72
+ # Extract NameID
73
+ name_id_el = root.find(".//{urn:oasis:names:tc:SAML:2.0:assertion}NameID")
74
+ if name_id_el is None or not name_id_el.text:
75
+ raise SAMLAssertionError("SAML assertion missing NameID")
76
+ name_id = name_id_el.text.strip()
77
+
78
+ # Extract all AttributeValue elements keyed by attribute name
79
+ attributes: dict[str, str] = {}
80
+ for attr_el in root.findall(".//{urn:oasis:names:tc:SAML:2.0:assertion}Attribute"):
81
+ attr_name = attr_el.get("Name", "")
82
+ val_el = attr_el.find("{urn:oasis:names:tc:SAML:2.0:assertion}AttributeValue")
83
+ if val_el is not None and val_el.text:
84
+ attributes[attr_name] = val_el.text.strip()
85
+
86
+ email_attr = os.environ.get("NAT_SAML_EMAIL_ATTRIBUTE", "email")
87
+ name_attr = os.environ.get("NAT_SAML_NAME_ATTRIBUTE", "name")
88
+ tenant_attr = os.environ.get("NAT_SAML_TENANT_ATTRIBUTE", "tenant_id")
89
+ role_attr = os.environ.get("NAT_SAML_ROLE_ATTRIBUTE", "nat_role")
90
+
91
+ email = attributes.get(email_attr) or name_id
92
+ name = attributes.get(name_attr) or email
93
+ tenant_id = attributes.get(tenant_attr)
94
+ role = attributes.get(role_attr, "viewer")
95
+
96
+ if role not in VALID_ROLES:
97
+ role = "viewer"
98
+
99
+ return {
100
+ "name_id": name_id,
101
+ "email": email,
102
+ "name": name,
103
+ "tenant_id": tenant_id,
104
+ "role": role,
105
+ "attributes": attributes,
106
+ }
107
+
108
+
109
+ async def process_saml_callback(saml_response_b64: str) -> dict[str, Any]:
110
+ """Process a SAML callback — resolve or create user and return auth context.
111
+
112
+ 1. Parse the SAML assertion.
113
+ 2. Look up the tenant by ID from the assertion.
114
+ 3. Find-or-create the user record (matched on sso_subject = NameID).
115
+ 4. Ensure a UserRole record exists for this user with the asserted role.
116
+ 5. Return an auth context dict suitable for setting on request.state.user.
117
+
118
+ Args:
119
+ saml_response_b64: Base64-encoded SAMLResponse.
120
+
121
+ Returns:
122
+ Auth context dict: user_id, email, name, role, tenant_id, sso_subject.
123
+
124
+ Raises:
125
+ SAMLNotConfiguredError: if SAML is not enabled.
126
+ SAMLAssertionError: on parse/validation failure.
127
+ ValueError: if the asserted tenant_id is unknown.
128
+ """
129
+ if not is_saml_enabled():
130
+ raise SAMLNotConfiguredError("SAML auth is not enabled (set NAT_SAML_ENABLED=true)")
131
+
132
+ attrs = parse_saml_response(saml_response_b64)
133
+ tenant_id_str = attrs.get("tenant_id")
134
+ if not tenant_id_str:
135
+ raise SAMLAssertionError("SAML assertion did not include a tenant_id attribute")
136
+
137
+ try:
138
+ tenant_uuid = uuid.UUID(tenant_id_str)
139
+ except ValueError as exc:
140
+ raise SAMLAssertionError(f"SAML tenant_id is not a valid UUID: {tenant_id_str!r}") from exc
141
+
142
+ try:
143
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
144
+ from mannf.product.models import Tenant, User, UserRole # noqa: PLC0415
145
+ from sqlalchemy import select # noqa: PLC0415
146
+ except ImportError as exc:
147
+ raise RuntimeError("Database models not available") from exc
148
+
149
+ if _build_engine() is None or _async_session_factory is None:
150
+ raise RuntimeError("Database not configured — set DATABASE_URL and restart.")
151
+
152
+ async with _async_session_factory() as session:
153
+ # Verify tenant exists and is active
154
+ tenant = await session.get(Tenant, tenant_uuid)
155
+ if tenant is None or not tenant.is_active:
156
+ raise ValueError(f"Unknown or inactive tenant: {tenant_id_str!r}")
157
+
158
+ # Find or create user by sso_subject (NameID)
159
+ stmt = select(User).where(
160
+ User.sso_subject == attrs["name_id"],
161
+ User.tenant_id == tenant_uuid,
162
+ )
163
+ result = await session.execute(stmt)
164
+ user_obj = result.scalars().first()
165
+
166
+ if user_obj is None:
167
+ user_obj = User(
168
+ id=uuid.uuid4(),
169
+ tenant_id=tenant_uuid,
170
+ email=attrs["email"],
171
+ name=attrs["name"],
172
+ sso_subject=attrs["name_id"],
173
+ is_active=True,
174
+ )
175
+ session.add(user_obj)
176
+ await session.flush()
177
+
178
+ if not user_obj.is_active:
179
+ raise ValueError(f"User {attrs['email']!r} is disabled")
180
+
181
+ # Ensure tenant-level role exists
182
+ role_stmt = select(UserRole).where(
183
+ UserRole.user_id == user_obj.id,
184
+ UserRole.tenant_id == tenant_uuid,
185
+ UserRole.workspace_id.is_(None),
186
+ )
187
+ role_result = await session.execute(role_stmt)
188
+ role_obj = role_result.scalars().first()
189
+
190
+ if role_obj is None:
191
+ role_obj = UserRole(
192
+ id=uuid.uuid4(),
193
+ user_id=user_obj.id,
194
+ tenant_id=tenant_uuid,
195
+ workspace_id=None,
196
+ role=attrs["role"],
197
+ )
198
+ session.add(role_obj)
199
+ else:
200
+ role_obj.role = attrs["role"]
201
+
202
+ await session.commit()
203
+ await session.refresh(user_obj)
204
+
205
+ return {
206
+ "id": str(user_obj.id),
207
+ "email": user_obj.email,
208
+ "name": user_obj.name,
209
+ "role": attrs["role"],
210
+ "tenant_id": tenant_id_str,
211
+ "sso_subject": attrs["name_id"],
212
+ }
@@ -0,0 +1,5 @@
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
+ """NAT SaaS billing package."""
@@ -0,0 +1,160 @@
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
+ """Billing event audit log.
6
+
7
+ Provides :func:`log_billing_event` which persists a :class:`BillingEvent`
8
+ record to the database. All operations degrade gracefully when
9
+ ``DATABASE_URL`` is not configured.
10
+
11
+ Event types (not exhaustive):
12
+ - ``tenant.created``
13
+ - ``tenant.updated``
14
+ - ``tenant.deactivated``
15
+ - ``tenant.synced``
16
+ - ``plan.upgraded``
17
+ - ``plan.changed``
18
+ - ``subscription.cancelled``
19
+ - ``payment.failed``
20
+ - ``payment.succeeded``
21
+ - ``charge.refunded``
22
+ - ``api_key.rotated``
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import uuid
29
+ from typing import Any, Optional
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ async def log_billing_event(
35
+ event_type: str,
36
+ *,
37
+ tenant_id: Optional[str] = None,
38
+ source: str = "system",
39
+ details: Optional[dict[str, Any]] = None,
40
+ ) -> None:
41
+ """Persist a billing lifecycle event to the ``billing_events`` table.
42
+
43
+ Args:
44
+ event_type: Short string identifier, e.g. ``"tenant.created"``.
45
+ tenant_id: UUID string of the affected tenant (may be None for
46
+ system-level events).
47
+ source: Origin of the event — ``"stripe"``, ``"admin"``, or
48
+ ``"system"``.
49
+ details: Arbitrary JSON-serialisable dict with event context.
50
+
51
+ This function never raises — it logs and returns on any error to ensure
52
+ billing operations are not blocked by audit failures.
53
+ """
54
+ try:
55
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
56
+
57
+ if _build_engine() is None or _async_session_factory is None:
58
+ logger.debug(
59
+ "log_billing_event: no database configured — skipping audit for %s",
60
+ event_type,
61
+ )
62
+ return
63
+
64
+ from mannf.product.models import BillingEvent # noqa: PLC0415
65
+
66
+ tid: uuid.UUID | None = None
67
+ if tenant_id:
68
+ try:
69
+ tid = uuid.UUID(tenant_id)
70
+ except ValueError:
71
+ logger.warning(
72
+ "log_billing_event: invalid tenant_id %r for event %s",
73
+ tenant_id,
74
+ event_type,
75
+ )
76
+
77
+ async with _async_session_factory() as session:
78
+ event = BillingEvent(
79
+ id=uuid.uuid4(),
80
+ tenant_id=tid,
81
+ event_type=event_type,
82
+ source=source,
83
+ details=details or {},
84
+ )
85
+ session.add(event)
86
+ await session.commit()
87
+
88
+ logger.debug("Logged billing event: %s (tenant=%s)", event_type, tenant_id)
89
+
90
+ except ImportError:
91
+ logger.debug("log_billing_event: SQLAlchemy not available — skipping audit")
92
+ except Exception as exc: # noqa: BLE001
93
+ logger.error(
94
+ "log_billing_event failed for event %s (tenant=%s): %s",
95
+ event_type,
96
+ tenant_id,
97
+ exc,
98
+ )
99
+
100
+
101
+ async def get_billing_events(
102
+ tenant_id: str,
103
+ *,
104
+ limit: int = 100,
105
+ event_type: Optional[str] = None,
106
+ ) -> list[dict[str, Any]]:
107
+ """Return billing events for a tenant, newest first.
108
+
109
+ Args:
110
+ tenant_id: UUID string of the tenant.
111
+ limit: Maximum number of events to return (capped at 500).
112
+ event_type: Optional filter — return only events of this type.
113
+
114
+ Returns:
115
+ List of event dicts with keys: ``id``, ``tenant_id``, ``event_type``,
116
+ ``source``, ``details``, ``created_at``.
117
+
118
+ Raises:
119
+ RuntimeError: if the database is not configured.
120
+ """
121
+ try:
122
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
123
+
124
+ if _build_engine() is None or _async_session_factory is None:
125
+ raise RuntimeError("Database not configured — set DATABASE_URL and restart.")
126
+
127
+ from sqlalchemy import select # noqa: PLC0415
128
+ from mannf.product.models import BillingEvent # noqa: PLC0415
129
+
130
+ tid = uuid.UUID(tenant_id)
131
+ effective_limit = min(limit, 500)
132
+
133
+ async with _async_session_factory() as session:
134
+ stmt = (
135
+ select(BillingEvent)
136
+ .where(BillingEvent.tenant_id == tid)
137
+ .order_by(BillingEvent.created_at.desc())
138
+ .limit(effective_limit)
139
+ )
140
+ if event_type:
141
+ stmt = stmt.where(BillingEvent.event_type == event_type)
142
+ result = await session.execute(stmt)
143
+ rows = result.scalars().all()
144
+
145
+ return [
146
+ {
147
+ "id": str(row.id),
148
+ "tenant_id": str(row.tenant_id) if row.tenant_id else None,
149
+ "event_type": row.event_type,
150
+ "source": row.source,
151
+ "details": row.details or {},
152
+ "created_at": row.created_at.isoformat() if row.created_at else None,
153
+ }
154
+ for row in rows
155
+ ]
156
+
157
+ except ImportError as exc:
158
+ raise RuntimeError("Database models not available") from exc
159
+ except ValueError as exc:
160
+ raise ValueError(f"Invalid tenant_id: {tenant_id!r}") from exc
@@ -0,0 +1,180 @@
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
+ """Feature-gating configuration for NAT SaaS plan tiers.
6
+
7
+ Some features are only available on higher-tier plans. ``check_feature()``
8
+ is the single public entry point — it returns ``True`` when the tenant's
9
+ plan allows the requested feature.
10
+
11
+ ``api_specs`` is an integer: ``-1`` means unlimited, any positive integer
12
+ is the maximum number of API specs the tenant may upload.
13
+
14
+ ``modules`` lists the scan modules included with the plan. Use
15
+ ``check_module()`` to gate module access by plan.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, TypedDict
21
+
22
+
23
+ class FeatureConfig(TypedDict):
24
+ security_scans: bool
25
+ webhooks: bool
26
+ export_csv: bool
27
+ export_html: bool
28
+ export_pdf: bool # PDF report export; Pro+ only
29
+ pdf_whitelabel: bool # Strip NAT branding from PDF; Enterprise only
30
+ api_specs: int # -1 = unlimited
31
+ modules: list[str] # included scan modules
32
+ scheduled_scans: dict # {"enabled": bool, "max_schedules": int}
33
+ llm_test_plans: int # plans/month; -1 = unlimited, 0 = disabled
34
+ llm_natural_language: int # NL test authoring queries/month; -1 = unlimited, 0 = disabled
35
+ llm_root_cause: int # root cause analyses/scan; -1 = unlimited, 0 = disabled, 5 = top-5
36
+
37
+
38
+ _ALL_MODULES: list[str] = ["functional", "visual", "accessibility", "performance"]
39
+
40
+ PLAN_FEATURES: dict[str, FeatureConfig] = {
41
+ "free": {
42
+ "security_scans": False,
43
+ "webhooks": False,
44
+ "export_csv": True,
45
+ "export_html": True,
46
+ "export_pdf": False,
47
+ "pdf_whitelabel": False,
48
+ "api_specs": 1,
49
+ "modules": ["functional"],
50
+ "scheduled_scans": {"enabled": False, "max_schedules": 0},
51
+ "llm_test_plans": 0,
52
+ "llm_natural_language": 0,
53
+ "llm_root_cause": 0,
54
+ },
55
+ "pro": {
56
+ "security_scans": True,
57
+ "webhooks": False,
58
+ "export_csv": True,
59
+ "export_html": True,
60
+ "export_pdf": True,
61
+ "pdf_whitelabel": False,
62
+ "api_specs": 5,
63
+ "modules": _ALL_MODULES,
64
+ "scheduled_scans": {"enabled": True, "max_schedules": 3},
65
+ "llm_test_plans": 5,
66
+ "llm_natural_language": 10,
67
+ "llm_root_cause": 5,
68
+ },
69
+ "team": {
70
+ "security_scans": True,
71
+ "webhooks": True,
72
+ "export_csv": True,
73
+ "export_html": True,
74
+ "export_pdf": True,
75
+ "pdf_whitelabel": False,
76
+ "api_specs": -1, # unlimited
77
+ "modules": _ALL_MODULES,
78
+ "scheduled_scans": {"enabled": True, "max_schedules": 10},
79
+ "llm_test_plans": 20,
80
+ "llm_natural_language": 50,
81
+ "llm_root_cause": -1, # unlimited
82
+ },
83
+ "enterprise": {
84
+ "security_scans": True,
85
+ "webhooks": True,
86
+ "export_csv": True,
87
+ "export_html": True,
88
+ "export_pdf": True,
89
+ "pdf_whitelabel": True,
90
+ "api_specs": -1, # unlimited
91
+ "modules": _ALL_MODULES,
92
+ "scheduled_scans": {"enabled": True, "max_schedules": 999_999},
93
+ "llm_test_plans": -1, # unlimited
94
+ "llm_natural_language": -1, # unlimited
95
+ "llm_root_cause": -1, # unlimited
96
+ },
97
+ }
98
+
99
+ _DEFAULT_PLAN = "free"
100
+
101
+
102
+ def get_features(plan_tier: str) -> FeatureConfig:
103
+ """Return the feature-gate config for *plan_tier*, defaulting to free."""
104
+ return PLAN_FEATURES.get(plan_tier, PLAN_FEATURES[_DEFAULT_PLAN])
105
+
106
+
107
+ def check_feature(plan_tier: str, feature: str) -> bool:
108
+ """Return ``True`` if *plan_tier* allows *feature*.
109
+
110
+ For boolean features, returns the boolean value directly.
111
+ For integer features (e.g. ``api_specs``), returns ``True`` when the
112
+ value is non-zero (i.e. the feature is available at all).
113
+ """
114
+ config: dict[str, Any] = get_features(plan_tier)
115
+ value = config.get(feature)
116
+ if value is None:
117
+ return False
118
+ if isinstance(value, bool):
119
+ return value
120
+ # Integer feature: -1 = unlimited (always allowed), 0 = blocked
121
+ return int(value) != 0
122
+
123
+
124
+ def check_module(plan: str, module: str) -> bool:
125
+ """Return ``True`` if *plan* includes the scan *module*.
126
+
127
+ Args:
128
+ plan: Plan tier name, e.g. ``"free"``, ``"pro"``, ``"team"``,
129
+ ``"enterprise"``.
130
+ module: Module name, e.g. ``"functional"``, ``"visual"``,
131
+ ``"accessibility"``, ``"performance"``.
132
+
133
+ Returns:
134
+ ``True`` if the plan includes the module, ``False`` otherwise.
135
+ """
136
+ return module in get_features(plan).get("modules", [])
137
+
138
+
139
+ def check_module_entitlement(
140
+ tenant_plan: str,
141
+ module_name: str,
142
+ tenant_addons: list[str] | None = None,
143
+ ) -> tuple[bool, str | None]:
144
+ """Check if a tenant is entitled to use a scan module.
145
+
146
+ First checks whether the module is included in the tenant's base plan via
147
+ ``included_modules`` on :class:`~mannf.product.billing.plans.PlanTier`.
148
+ If not included in the base plan, checks whether the tenant has purchased
149
+ the module as an add-on (present in *tenant_addons*).
150
+
151
+ Args:
152
+ tenant_plan: The tenant's plan tier name (e.g. ``"free"``, ``"pro"``).
153
+ module_name: The scan module to check (e.g. ``"visual"``,
154
+ ``"performance"``).
155
+ tenant_addons: List of module names the tenant has purchased as
156
+ add-ons. ``None`` is treated the same as an empty list.
157
+
158
+ Returns:
159
+ A ``(allowed, denial_reason)`` tuple. If allowed, ``denial_reason``
160
+ is ``None``. If denied, ``denial_reason`` is a human-readable string
161
+ explaining why access was refused.
162
+ """
163
+ from mannf.product.billing.plans import get_plan # noqa: PLC0415
164
+
165
+ plan = get_plan(tenant_plan)
166
+
167
+ # 1. Module is included in the base plan subscription.
168
+ if module_name in plan.included_modules:
169
+ return True, None
170
+
171
+ # 2. Tenant purchased the module as an add-on.
172
+ addons = tenant_addons or []
173
+ if module_name in addons:
174
+ return True, None
175
+
176
+ # 3. Denied — neither plan-included nor add-on purchased.
177
+ return False, (
178
+ f"Module '{module_name}' is not included in the '{tenant_plan}' plan "
179
+ "and has not been purchased as an add-on."
180
+ )