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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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,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
|
+
)
|