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,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)
|