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,514 @@
|
|
|
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
|
+
"""Admin API routes for tenant management.
|
|
6
|
+
|
|
7
|
+
All routes are mounted under ``/api/v1/admin`` and protected by the
|
|
8
|
+
``X-Admin-Key`` header (checked against the ``NAT_ADMIN_KEY`` env var).
|
|
9
|
+
|
|
10
|
+
Endpoints:
|
|
11
|
+
POST /api/v1/admin/tenants — Create tenant
|
|
12
|
+
GET /api/v1/admin/tenants — List tenants
|
|
13
|
+
GET /api/v1/admin/tenants/{id} — Get tenant details
|
|
14
|
+
PUT /api/v1/admin/tenants/{id} — Update tenant
|
|
15
|
+
DELETE /api/v1/admin/tenants/{id} — Deactivate tenant
|
|
16
|
+
POST /api/v1/admin/tenants/{id}/rotate-key — Rotate API key
|
|
17
|
+
GET /api/v1/admin/tenants/{id}/usage — Get usage stats
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from fastapi import APIRouter, Header, HTTPException, Query
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
from mannf.product.billing.tenant_manager import TenantManager
|
|
29
|
+
|
|
30
|
+
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
|
31
|
+
|
|
32
|
+
_manager = TenantManager()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Auth helper
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _require_admin_key(x_admin_key: Optional[str]) -> None:
|
|
41
|
+
"""Raise 401 / 403 if the X-Admin-Key header is missing or invalid."""
|
|
42
|
+
admin_key = os.environ.get("NAT_ADMIN_KEY", "")
|
|
43
|
+
if not admin_key:
|
|
44
|
+
raise HTTPException(
|
|
45
|
+
status_code=503,
|
|
46
|
+
detail={
|
|
47
|
+
"error": {
|
|
48
|
+
"code": "ADMIN_NOT_CONFIGURED",
|
|
49
|
+
"message": "Admin API is not configured — set NAT_ADMIN_KEY.",
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
if not x_admin_key:
|
|
54
|
+
raise HTTPException(
|
|
55
|
+
status_code=401,
|
|
56
|
+
detail={
|
|
57
|
+
"error": {
|
|
58
|
+
"code": "MISSING_ADMIN_KEY",
|
|
59
|
+
"message": "X-Admin-Key header is required.",
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
if x_admin_key != admin_key:
|
|
64
|
+
raise HTTPException(
|
|
65
|
+
status_code=403,
|
|
66
|
+
detail={
|
|
67
|
+
"error": {
|
|
68
|
+
"code": "INVALID_ADMIN_KEY",
|
|
69
|
+
"message": "Invalid admin key.",
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Request / response models
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CreateTenantRequest(BaseModel):
|
|
81
|
+
name: str
|
|
82
|
+
email: str
|
|
83
|
+
plan: str = "free"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class UpdateTenantRequest(BaseModel):
|
|
87
|
+
plan: Optional[str] = None
|
|
88
|
+
monthly_scan_quota: Optional[int] = None
|
|
89
|
+
monthly_security_scan_quota: Optional[int] = None
|
|
90
|
+
is_active: Optional[bool] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CreateWorkspaceRequest(BaseModel):
|
|
94
|
+
name: str
|
|
95
|
+
description: Optional[str] = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CreateProjectRequest(BaseModel):
|
|
99
|
+
name: str
|
|
100
|
+
description: Optional[str] = None
|
|
101
|
+
parent_project_id: Optional[str] = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class InviteUserRequest(BaseModel):
|
|
105
|
+
email: str
|
|
106
|
+
name: str
|
|
107
|
+
role: str = "viewer"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AssignRoleRequest(BaseModel):
|
|
111
|
+
role: str
|
|
112
|
+
workspace_id: Optional[str] = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Routes
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@router.post("/tenants", status_code=201)
|
|
121
|
+
async def create_tenant(
|
|
122
|
+
body: CreateTenantRequest,
|
|
123
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
124
|
+
) -> dict:
|
|
125
|
+
"""Create a new tenant and return its API key (shown once)."""
|
|
126
|
+
_require_admin_key(x_admin_key)
|
|
127
|
+
try:
|
|
128
|
+
return await _manager.create_tenant(
|
|
129
|
+
name=body.name,
|
|
130
|
+
email=body.email,
|
|
131
|
+
plan_tier=body.plan,
|
|
132
|
+
)
|
|
133
|
+
except (RuntimeError, ValueError) as exc:
|
|
134
|
+
raise HTTPException(status_code=400, detail={"error": {"code": "BAD_REQUEST", "message": str(exc)}}) from exc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.get("/tenants")
|
|
138
|
+
async def list_tenants(
|
|
139
|
+
plan: Optional[str] = Query(default=None),
|
|
140
|
+
active_only: bool = Query(default=True),
|
|
141
|
+
limit: int = Query(default=100, ge=1, le=1000),
|
|
142
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
143
|
+
) -> list:
|
|
144
|
+
"""List tenants with optional plan/status filtering."""
|
|
145
|
+
_require_admin_key(x_admin_key)
|
|
146
|
+
try:
|
|
147
|
+
return await _manager.list_tenants(
|
|
148
|
+
plan_filter=plan,
|
|
149
|
+
active_only=active_only,
|
|
150
|
+
limit=limit,
|
|
151
|
+
)
|
|
152
|
+
except RuntimeError as exc:
|
|
153
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@router.get("/tenants/{tenant_id}")
|
|
157
|
+
async def get_tenant(
|
|
158
|
+
tenant_id: str,
|
|
159
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
160
|
+
) -> dict:
|
|
161
|
+
"""Get tenant details by ID."""
|
|
162
|
+
_require_admin_key(x_admin_key)
|
|
163
|
+
try:
|
|
164
|
+
result = await _manager.get_tenant(tenant_id)
|
|
165
|
+
except RuntimeError as exc:
|
|
166
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
167
|
+
if result is None:
|
|
168
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": f"Tenant {tenant_id!r} not found."}})
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.put("/tenants/{tenant_id}")
|
|
173
|
+
async def update_tenant(
|
|
174
|
+
tenant_id: str,
|
|
175
|
+
body: UpdateTenantRequest,
|
|
176
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
177
|
+
) -> dict:
|
|
178
|
+
"""Update tenant plan, quotas, or active status."""
|
|
179
|
+
_require_admin_key(x_admin_key)
|
|
180
|
+
try:
|
|
181
|
+
return await _manager.update_tenant(
|
|
182
|
+
tenant_id=tenant_id,
|
|
183
|
+
plan_tier=body.plan,
|
|
184
|
+
monthly_scan_quota=body.monthly_scan_quota,
|
|
185
|
+
monthly_security_scan_quota=body.monthly_security_scan_quota,
|
|
186
|
+
is_active=body.is_active,
|
|
187
|
+
)
|
|
188
|
+
except KeyError as exc:
|
|
189
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
190
|
+
except (RuntimeError, ValueError) as exc:
|
|
191
|
+
raise HTTPException(status_code=400, detail={"error": {"code": "BAD_REQUEST", "message": str(exc)}}) from exc
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@router.delete("/tenants/{tenant_id}")
|
|
195
|
+
async def deactivate_tenant(
|
|
196
|
+
tenant_id: str,
|
|
197
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
198
|
+
) -> dict:
|
|
199
|
+
"""Soft-delete a tenant — mark as inactive and revoke API keys."""
|
|
200
|
+
_require_admin_key(x_admin_key)
|
|
201
|
+
try:
|
|
202
|
+
return await _manager.deactivate_tenant(tenant_id)
|
|
203
|
+
except KeyError as exc:
|
|
204
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
205
|
+
except RuntimeError as exc:
|
|
206
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@router.post("/tenants/{tenant_id}/rotate-key")
|
|
210
|
+
async def rotate_api_key(
|
|
211
|
+
tenant_id: str,
|
|
212
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
213
|
+
) -> dict:
|
|
214
|
+
"""Revoke the current API key and issue a new one (shown once)."""
|
|
215
|
+
_require_admin_key(x_admin_key)
|
|
216
|
+
try:
|
|
217
|
+
return await _manager.rotate_api_key(tenant_id)
|
|
218
|
+
except KeyError as exc:
|
|
219
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
220
|
+
except RuntimeError as exc:
|
|
221
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@router.get("/tenants/{tenant_id}/usage")
|
|
225
|
+
async def get_tenant_usage(
|
|
226
|
+
tenant_id: str,
|
|
227
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
228
|
+
) -> dict:
|
|
229
|
+
"""Get current month's usage statistics for a tenant."""
|
|
230
|
+
_require_admin_key(x_admin_key)
|
|
231
|
+
try:
|
|
232
|
+
return await _manager.get_tenant_usage(tenant_id)
|
|
233
|
+
except KeyError as exc:
|
|
234
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
235
|
+
except RuntimeError as exc:
|
|
236
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@router.get("/tenants/{tenant_id}/events")
|
|
240
|
+
async def get_tenant_billing_events(
|
|
241
|
+
tenant_id: str,
|
|
242
|
+
limit: int = Query(default=100, ge=1, le=500),
|
|
243
|
+
event_type: Optional[str] = Query(default=None),
|
|
244
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
245
|
+
) -> dict:
|
|
246
|
+
"""Get billing event audit log for a specific tenant."""
|
|
247
|
+
_require_admin_key(x_admin_key)
|
|
248
|
+
try:
|
|
249
|
+
from mannf.product.billing.audit import get_billing_events # noqa: PLC0415
|
|
250
|
+
|
|
251
|
+
events = await get_billing_events(tenant_id, limit=limit, event_type=event_type)
|
|
252
|
+
return {"tenant_id": tenant_id, "events": events, "count": len(events)}
|
|
253
|
+
except ValueError as exc:
|
|
254
|
+
raise HTTPException(status_code=400, detail={"error": {"code": "BAD_REQUEST", "message": str(exc)}}) from exc
|
|
255
|
+
except RuntimeError as exc:
|
|
256
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Workspace endpoints
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@router.post("/tenants/{tenant_id}/workspaces", status_code=201)
|
|
265
|
+
async def create_workspace(
|
|
266
|
+
tenant_id: str,
|
|
267
|
+
body: CreateWorkspaceRequest,
|
|
268
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
269
|
+
) -> dict:
|
|
270
|
+
"""Create a workspace for a tenant."""
|
|
271
|
+
_require_admin_key(x_admin_key)
|
|
272
|
+
try:
|
|
273
|
+
return await _manager.create_workspace(
|
|
274
|
+
tenant_id=tenant_id,
|
|
275
|
+
name=body.name,
|
|
276
|
+
description=body.description,
|
|
277
|
+
)
|
|
278
|
+
except KeyError as exc:
|
|
279
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
280
|
+
except RuntimeError as exc:
|
|
281
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@router.get("/tenants/{tenant_id}/workspaces")
|
|
285
|
+
async def list_workspaces(
|
|
286
|
+
tenant_id: str,
|
|
287
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
288
|
+
) -> list:
|
|
289
|
+
"""List workspaces for a tenant."""
|
|
290
|
+
_require_admin_key(x_admin_key)
|
|
291
|
+
try:
|
|
292
|
+
return await _manager.list_workspaces(tenant_id=tenant_id)
|
|
293
|
+
except KeyError as exc:
|
|
294
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
295
|
+
except RuntimeError as exc:
|
|
296
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@router.delete("/tenants/{tenant_id}/workspaces/{workspace_id}")
|
|
300
|
+
async def delete_workspace(
|
|
301
|
+
tenant_id: str,
|
|
302
|
+
workspace_id: str,
|
|
303
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
304
|
+
) -> dict:
|
|
305
|
+
"""Deactivate a workspace."""
|
|
306
|
+
_require_admin_key(x_admin_key)
|
|
307
|
+
try:
|
|
308
|
+
return await _manager.delete_workspace(tenant_id=tenant_id, workspace_id=workspace_id)
|
|
309
|
+
except KeyError as exc:
|
|
310
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
311
|
+
except RuntimeError as exc:
|
|
312
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Project endpoints
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@router.post("/tenants/{tenant_id}/workspaces/{workspace_id}/projects", status_code=201)
|
|
321
|
+
async def create_project(
|
|
322
|
+
tenant_id: str,
|
|
323
|
+
workspace_id: str,
|
|
324
|
+
body: CreateProjectRequest,
|
|
325
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
326
|
+
) -> dict:
|
|
327
|
+
"""Create a project in a workspace."""
|
|
328
|
+
_require_admin_key(x_admin_key)
|
|
329
|
+
try:
|
|
330
|
+
return await _manager.create_project(
|
|
331
|
+
tenant_id=tenant_id,
|
|
332
|
+
workspace_id=workspace_id,
|
|
333
|
+
name=body.name,
|
|
334
|
+
description=body.description,
|
|
335
|
+
parent_project_id=body.parent_project_id,
|
|
336
|
+
)
|
|
337
|
+
except KeyError as exc:
|
|
338
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
339
|
+
except (RuntimeError, ValueError) as exc:
|
|
340
|
+
raise HTTPException(status_code=400, detail={"error": {"code": "BAD_REQUEST", "message": str(exc)}}) from exc
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@router.get("/tenants/{tenant_id}/workspaces/{workspace_id}/projects")
|
|
344
|
+
async def list_projects(
|
|
345
|
+
tenant_id: str,
|
|
346
|
+
workspace_id: str,
|
|
347
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
348
|
+
) -> list:
|
|
349
|
+
"""List projects in a workspace."""
|
|
350
|
+
_require_admin_key(x_admin_key)
|
|
351
|
+
try:
|
|
352
|
+
return await _manager.list_projects(tenant_id=tenant_id, workspace_id=workspace_id)
|
|
353
|
+
except KeyError as exc:
|
|
354
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
355
|
+
except RuntimeError as exc:
|
|
356
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@router.delete("/tenants/{tenant_id}/projects/{project_id}")
|
|
360
|
+
async def delete_project(
|
|
361
|
+
tenant_id: str,
|
|
362
|
+
project_id: str,
|
|
363
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
364
|
+
) -> dict:
|
|
365
|
+
"""Deactivate a project."""
|
|
366
|
+
_require_admin_key(x_admin_key)
|
|
367
|
+
try:
|
|
368
|
+
return await _manager.delete_project(tenant_id=tenant_id, project_id=project_id)
|
|
369
|
+
except KeyError as exc:
|
|
370
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
371
|
+
except RuntimeError as exc:
|
|
372
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# User endpoints
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@router.post("/tenants/{tenant_id}/users", status_code=201)
|
|
381
|
+
async def invite_user(
|
|
382
|
+
tenant_id: str,
|
|
383
|
+
body: InviteUserRequest,
|
|
384
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
385
|
+
) -> dict:
|
|
386
|
+
"""Invite a user to a tenant."""
|
|
387
|
+
_require_admin_key(x_admin_key)
|
|
388
|
+
try:
|
|
389
|
+
return await _manager.invite_user(
|
|
390
|
+
tenant_id=tenant_id,
|
|
391
|
+
email=body.email,
|
|
392
|
+
name=body.name,
|
|
393
|
+
role=body.role,
|
|
394
|
+
)
|
|
395
|
+
except KeyError as exc:
|
|
396
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
397
|
+
except ValueError as exc:
|
|
398
|
+
raise HTTPException(status_code=422, detail={"error": {"code": "SEAT_LIMIT_EXCEEDED" if "SEAT_LIMIT_EXCEEDED" in str(exc) else "BAD_REQUEST", "message": str(exc)}}) from exc
|
|
399
|
+
except RuntimeError as exc:
|
|
400
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@router.get("/tenants/{tenant_id}/users")
|
|
404
|
+
async def list_users(
|
|
405
|
+
tenant_id: str,
|
|
406
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
407
|
+
) -> list:
|
|
408
|
+
"""List users in a tenant."""
|
|
409
|
+
_require_admin_key(x_admin_key)
|
|
410
|
+
try:
|
|
411
|
+
return await _manager.list_users(tenant_id=tenant_id)
|
|
412
|
+
except KeyError as exc:
|
|
413
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
414
|
+
except RuntimeError as exc:
|
|
415
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@router.put("/tenants/{tenant_id}/users/{user_id}/role")
|
|
419
|
+
async def assign_user_role(
|
|
420
|
+
tenant_id: str,
|
|
421
|
+
user_id: str,
|
|
422
|
+
body: AssignRoleRequest,
|
|
423
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
424
|
+
) -> dict:
|
|
425
|
+
"""Assign or update a user's role."""
|
|
426
|
+
_require_admin_key(x_admin_key)
|
|
427
|
+
try:
|
|
428
|
+
return await _manager.assign_role(
|
|
429
|
+
tenant_id=tenant_id,
|
|
430
|
+
user_id=user_id,
|
|
431
|
+
role=body.role,
|
|
432
|
+
workspace_id=body.workspace_id,
|
|
433
|
+
)
|
|
434
|
+
except KeyError as exc:
|
|
435
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
436
|
+
except (RuntimeError, ValueError) as exc:
|
|
437
|
+
raise HTTPException(status_code=400, detail={"error": {"code": "BAD_REQUEST", "message": str(exc)}}) from exc
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@router.delete("/tenants/{tenant_id}/users/{user_id}")
|
|
441
|
+
async def remove_user(
|
|
442
|
+
tenant_id: str,
|
|
443
|
+
user_id: str,
|
|
444
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
445
|
+
) -> dict:
|
|
446
|
+
"""Deactivate a user."""
|
|
447
|
+
_require_admin_key(x_admin_key)
|
|
448
|
+
try:
|
|
449
|
+
return await _manager.remove_user(tenant_id=tenant_id, user_id=user_id)
|
|
450
|
+
except KeyError as exc:
|
|
451
|
+
raise HTTPException(status_code=404, detail={"error": {"code": "NOT_FOUND", "message": str(exc)}}) from exc
|
|
452
|
+
except RuntimeError as exc:
|
|
453
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# Audit log endpoint
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@router.get("/audit-log")
|
|
462
|
+
async def get_audit_log(
|
|
463
|
+
tenant_id: Optional[str] = Query(default=None),
|
|
464
|
+
method: Optional[str] = Query(default=None),
|
|
465
|
+
path_prefix: Optional[str] = Query(default=None),
|
|
466
|
+
limit: int = Query(default=100, ge=1, le=500),
|
|
467
|
+
x_admin_key: Optional[str] = Header(default=None),
|
|
468
|
+
) -> list:
|
|
469
|
+
"""Query the API audit log."""
|
|
470
|
+
_require_admin_key(x_admin_key)
|
|
471
|
+
try:
|
|
472
|
+
from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
|
|
473
|
+
from mannf.product.models import ApiAuditLog # noqa: PLC0415
|
|
474
|
+
from sqlalchemy import select # noqa: PLC0415
|
|
475
|
+
import uuid as _uuid # noqa: PLC0415
|
|
476
|
+
except ImportError as exc:
|
|
477
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": str(exc)}}) from exc
|
|
478
|
+
|
|
479
|
+
if _build_engine() is None or _async_session_factory is None:
|
|
480
|
+
raise HTTPException(status_code=503, detail={"error": {"code": "DB_UNAVAILABLE", "message": "Database not configured"}})
|
|
481
|
+
|
|
482
|
+
async with _async_session_factory() as session:
|
|
483
|
+
stmt = select(ApiAuditLog).order_by(ApiAuditLog.created_at.desc()).limit(limit)
|
|
484
|
+
|
|
485
|
+
if tenant_id:
|
|
486
|
+
try:
|
|
487
|
+
tid = _uuid.UUID(tenant_id)
|
|
488
|
+
stmt = stmt.where(ApiAuditLog.tenant_id == tid)
|
|
489
|
+
except ValueError:
|
|
490
|
+
pass
|
|
491
|
+
|
|
492
|
+
if method:
|
|
493
|
+
stmt = stmt.where(ApiAuditLog.method == method.upper())
|
|
494
|
+
|
|
495
|
+
if path_prefix:
|
|
496
|
+
stmt = stmt.where(ApiAuditLog.path.startswith(path_prefix))
|
|
497
|
+
|
|
498
|
+
result = await session.execute(stmt)
|
|
499
|
+
entries = result.scalars().all()
|
|
500
|
+
|
|
501
|
+
return [
|
|
502
|
+
{
|
|
503
|
+
"id": str(e.id),
|
|
504
|
+
"tenant_id": str(e.tenant_id) if e.tenant_id else None,
|
|
505
|
+
"user_id": str(e.user_id) if e.user_id else None,
|
|
506
|
+
"method": e.method,
|
|
507
|
+
"path": e.path,
|
|
508
|
+
"status_code": e.status_code,
|
|
509
|
+
"request_summary": e.request_summary,
|
|
510
|
+
"source_ip": e.source_ip,
|
|
511
|
+
"created_at": e.created_at.isoformat() if e.created_at else None,
|
|
512
|
+
}
|
|
513
|
+
for e in entries
|
|
514
|
+
]
|