skillgate 1.1.0__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.
- skillgate/__init__.py +5 -0
- skillgate/__main__.py +5 -0
- skillgate/api/__init__.py +1 -0
- skillgate/api/app.py +249 -0
- skillgate/api/auth_observability.py +141 -0
- skillgate/api/db.py +127 -0
- skillgate/api/device_codes.py +188 -0
- skillgate/api/entitlement.py +76 -0
- skillgate/api/entitlement_teams.py +91 -0
- skillgate/api/errors.py +83 -0
- skillgate/api/middleware/__init__.py +5 -0
- skillgate/api/middleware/bot_mitigation.py +75 -0
- skillgate/api/migrations/0001_initial.sql +96 -0
- skillgate/api/migrations/supabase/001_rpc_contract_v1.sql +96 -0
- skillgate/api/migrations/supabase/002_rls_policies_v1.sql +63 -0
- skillgate/api/migrations/supabase/email_templates/confirm_signup.html +102 -0
- skillgate/api/migrations/supabase/email_templates/magic_link.html +101 -0
- skillgate/api/migrations/supabase/email_templates/password_reset.html +113 -0
- skillgate/api/models.py +272 -0
- skillgate/api/pricing_catalog.py +371 -0
- skillgate/api/rate_limit.py +68 -0
- skillgate/api/redis_circuit_breaker.py +137 -0
- skillgate/api/redis_rate_limit.py +61 -0
- skillgate/api/resilience.py +94 -0
- skillgate/api/roadmap_catalog.py +136 -0
- skillgate/api/routes/__init__.py +1 -0
- skillgate/api/routes/alerts.py +180 -0
- skillgate/api/routes/api_keys.py +267 -0
- skillgate/api/routes/audit.py +100 -0
- skillgate/api/routes/auth.py +1376 -0
- skillgate/api/routes/entitlements.py +381 -0
- skillgate/api/routes/health.py +15 -0
- skillgate/api/routes/hunt.py +117 -0
- skillgate/api/routes/license.py +57 -0
- skillgate/api/routes/payments.py +1213 -0
- skillgate/api/routes/pricing.py +15 -0
- skillgate/api/routes/retroscan.py +179 -0
- skillgate/api/routes/roadmap.py +15 -0
- skillgate/api/routes/scans.py +192 -0
- skillgate/api/routes/teams.py +256 -0
- skillgate/api/routes/usage.py +139 -0
- skillgate/api/routes/verify.py +54 -0
- skillgate/api/security.py +149 -0
- skillgate/api/settings.py +162 -0
- skillgate/api/supabase_auth_provider.py +514 -0
- skillgate/api/supabase_client.py +296 -0
- skillgate/api/supabase_egress.py +74 -0
- skillgate/api/supabase_jwt.py +197 -0
- skillgate/api/telemetry.py +153 -0
- skillgate/api/worker.py +72 -0
- skillgate/assets/logo.ansi +73 -0
- skillgate/assets/logo_compact_16.ansi +21 -0
- skillgate/assets/logo_compact_16_light.ansi +21 -0
- skillgate/assets/logo_compact_20.ansi +26 -0
- skillgate/assets/logo_compact_20_light.ansi +26 -0
- skillgate/assets/logo_compact_24.ansi +31 -0
- skillgate/assets/logo_compact_24_light.ansi +31 -0
- skillgate/assets/logo_compact_28.ansi +36 -0
- skillgate/assets/logo_compact_28_light.ansi +36 -0
- skillgate/assets/logo_compact_32.ansi +41 -0
- skillgate/assets/logo_compact_32_light.ansi +41 -0
- skillgate/assets/logo_small_48.ansi +62 -0
- skillgate/assets/logo_small_48_light.ansi +62 -0
- skillgate/assets/logo_small_48_light_old.ansi +49 -0
- skillgate/assets/logo_small_48_old.ansi +49 -0
- skillgate/ci/__init__.py +1 -0
- skillgate/ci/bitbucket/__init__.py +0 -0
- skillgate/ci/bitbucket/template.yml +75 -0
- skillgate/ci/github/__init__.py +1 -0
- skillgate/ci/github/action.yml +150 -0
- skillgate/ci/github/annotations.py +112 -0
- skillgate/ci/gitlab/__init__.py +1 -0
- skillgate/ci/gitlab/template.yml +68 -0
- skillgate/ci/noise.py +155 -0
- skillgate/cli/__init__.py +1 -0
- skillgate/cli/app.py +158 -0
- skillgate/cli/branding.py +175 -0
- skillgate/cli/commands/__init__.py +1 -0
- skillgate/cli/commands/approval.py +88 -0
- skillgate/cli/commands/auth.py +365 -0
- skillgate/cli/commands/bom.py +106 -0
- skillgate/cli/commands/dag.py +124 -0
- skillgate/cli/commands/doctor.py +146 -0
- skillgate/cli/commands/drift.py +314 -0
- skillgate/cli/commands/gateway.py +381 -0
- skillgate/cli/commands/hooks.py +141 -0
- skillgate/cli/commands/hunt.py +186 -0
- skillgate/cli/commands/init.py +43 -0
- skillgate/cli/commands/keys.py +59 -0
- skillgate/cli/commands/reputation.py +146 -0
- skillgate/cli/commands/retroscan.py +214 -0
- skillgate/cli/commands/rules_cmd.py +81 -0
- skillgate/cli/commands/run.py +415 -0
- skillgate/cli/commands/scan.py +1097 -0
- skillgate/cli/commands/simulate.py +414 -0
- skillgate/cli/commands/submit_scan.py +49 -0
- skillgate/cli/commands/verify.py +55 -0
- skillgate/cli/formatters/__init__.py +7 -0
- skillgate/cli/formatters/human.py +440 -0
- skillgate/cli/formatters/json_fmt.py +19 -0
- skillgate/cli/formatters/sarif.py +173 -0
- skillgate/cli/main.py +6 -0
- skillgate/cli/remote.py +341 -0
- skillgate/cli/scan_submit.py +90 -0
- skillgate/config/__init__.py +1 -0
- skillgate/config/entitlement.py +188 -0
- skillgate/config/license.py +75 -0
- skillgate/config/secrets.py +107 -0
- skillgate/core/__init__.py +0 -0
- skillgate/core/analyzer/__init__.py +33 -0
- skillgate/core/analyzer/correlation.py +248 -0
- skillgate/core/analyzer/engine.py +140 -0
- skillgate/core/analyzer/perf_guard.py +199 -0
- skillgate/core/analyzer/rules/__init__.py +59 -0
- skillgate/core/analyzer/rules/base.py +159 -0
- skillgate/core/analyzer/rules/command.py +305 -0
- skillgate/core/analyzer/rules/config.py +299 -0
- skillgate/core/analyzer/rules/credential.py +185 -0
- skillgate/core/analyzer/rules/eval.py +131 -0
- skillgate/core/analyzer/rules/filesystem.py +167 -0
- skillgate/core/analyzer/rules/go.py +281 -0
- skillgate/core/analyzer/rules/injection.py +119 -0
- skillgate/core/analyzer/rules/js_ast.py +92 -0
- skillgate/core/analyzer/rules/network.py +141 -0
- skillgate/core/analyzer/rules/obfuscation.py +146 -0
- skillgate/core/analyzer/rules/prompt.py +220 -0
- skillgate/core/analyzer/rules/ruby.py +329 -0
- skillgate/core/analyzer/rules/rust.py +278 -0
- skillgate/core/analyzer/rules/shell.py +201 -0
- skillgate/core/analyzer/rules/shell_ast.py +86 -0
- skillgate/core/analyzer/treesitter.py +156 -0
- skillgate/core/analyzer/unicode_normalizer.py +232 -0
- skillgate/core/connectors/__init__.py +35 -0
- skillgate/core/connectors/base.py +57 -0
- skillgate/core/connectors/file_tip.py +111 -0
- skillgate/core/connectors/manager.py +159 -0
- skillgate/core/connectors/models.py +66 -0
- skillgate/core/connectors/registry.py +69 -0
- skillgate/core/enricher/__init__.py +9 -0
- skillgate/core/enricher/catalog.py +944 -0
- skillgate/core/enricher/engine.py +45 -0
- skillgate/core/enricher/models.py +23 -0
- skillgate/core/entitlement/__init__.py +65 -0
- skillgate/core/entitlement/airgap.py +194 -0
- skillgate/core/entitlement/cache.py +63 -0
- skillgate/core/entitlement/enterprise.py +72 -0
- skillgate/core/entitlement/enterprise_adapter.py +137 -0
- skillgate/core/entitlement/gates.py +75 -0
- skillgate/core/entitlement/mode.py +83 -0
- skillgate/core/entitlement/models.py +139 -0
- skillgate/core/entitlement/quota.py +102 -0
- skillgate/core/entitlement/resilience.py +47 -0
- skillgate/core/entitlement/resolver.py +401 -0
- skillgate/core/entitlement/usage_authority.py +228 -0
- skillgate/core/errors.py +40 -0
- skillgate/core/explainer/__init__.py +9 -0
- skillgate/core/explainer/engine.py +458 -0
- skillgate/core/explainer/templates.py +122 -0
- skillgate/core/gateway/__init__.py +94 -0
- skillgate/core/gateway/allowlist.py +96 -0
- skillgate/core/gateway/approval.py +194 -0
- skillgate/core/gateway/bom_gate.py +192 -0
- skillgate/core/gateway/budget.py +363 -0
- skillgate/core/gateway/executor.py +43 -0
- skillgate/core/gateway/lineage.py +246 -0
- skillgate/core/gateway/runtime.py +67 -0
- skillgate/core/gateway/runtime_engine.py +147 -0
- skillgate/core/gateway/sandbox.py +90 -0
- skillgate/core/gateway/scope.py +100 -0
- skillgate/core/gateway/session.py +202 -0
- skillgate/core/gateway/top_guard.py +168 -0
- skillgate/core/hunt/__init__.py +25 -0
- skillgate/core/hunt/engine.py +290 -0
- skillgate/core/hunt/models.py +127 -0
- skillgate/core/hunt/parser.py +150 -0
- skillgate/core/models/__init__.py +34 -0
- skillgate/core/models/artifact.py +96 -0
- skillgate/core/models/bundle.py +48 -0
- skillgate/core/models/enums.py +40 -0
- skillgate/core/models/finding.py +81 -0
- skillgate/core/models/report.py +99 -0
- skillgate/core/orchestrator/__init__.py +59 -0
- skillgate/core/orchestrator/approval.py +80 -0
- skillgate/core/orchestrator/engine.py +166 -0
- skillgate/core/orchestrator/evidence.py +102 -0
- skillgate/core/orchestrator/models.py +78 -0
- skillgate/core/orchestrator/pipeline.py +166 -0
- skillgate/core/orchestrator/triage.py +167 -0
- skillgate/core/orchestrator/write_path.py +92 -0
- skillgate/core/parser/__init__.py +26 -0
- skillgate/core/parser/archive.py +672 -0
- skillgate/core/parser/bundle.py +100 -0
- skillgate/core/parser/document.py +366 -0
- skillgate/core/parser/fleet.py +115 -0
- skillgate/core/parser/manifest.py +188 -0
- skillgate/core/parser/markdown.py +352 -0
- skillgate/core/parser/source.py +90 -0
- skillgate/core/policy/__init__.py +36 -0
- skillgate/core/policy/engine.py +501 -0
- skillgate/core/policy/loader.py +148 -0
- skillgate/core/policy/presets.py +148 -0
- skillgate/core/policy/schema.py +276 -0
- skillgate/core/reputation/__init__.py +17 -0
- skillgate/core/reputation/models.py +49 -0
- skillgate/core/reputation/policy.py +147 -0
- skillgate/core/reputation/redaction.py +13 -0
- skillgate/core/reputation/store.py +116 -0
- skillgate/core/reputation/verifier.py +96 -0
- skillgate/core/retroscan/__init__.py +24 -0
- skillgate/core/retroscan/engine.py +222 -0
- skillgate/core/retroscan/models.py +80 -0
- skillgate/core/retroscan/store.py +138 -0
- skillgate/core/scorer/__init__.py +6 -0
- skillgate/core/scorer/engine.py +85 -0
- skillgate/core/scorer/severity.py +31 -0
- skillgate/core/scorer/weights.py +15 -0
- skillgate/core/signer/__init__.py +22 -0
- skillgate/core/signer/canonical.py +44 -0
- skillgate/core/signer/engine.py +150 -0
- skillgate/core/signer/keys.py +120 -0
- skillgate/py.typed +0 -0
- skillgate/version.py +3 -0
- skillgate-1.1.0.dist-info/METADATA +219 -0
- skillgate-1.1.0.dist-info/RECORD +229 -0
- skillgate-1.1.0.dist-info/WHEEL +5 -0
- skillgate-1.1.0.dist-info/entry_points.txt +2 -0
- skillgate-1.1.0.dist-info/licenses/LICENSE +50 -0
- skillgate-1.1.0.dist-info/top_level.txt +2 -0
- skillgate-docs/node_modules/flatted/python/flatted.py +149 -0
skillgate/__init__.py
ADDED
skillgate/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SkillGate hosted API — optional FastAPI backend."""
|
skillgate/api/app.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""FastAPI application factory and configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.exceptions import RequestValidationError
|
|
15
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
17
|
+
from starlette.responses import Response
|
|
18
|
+
|
|
19
|
+
from skillgate.api.db import init_db, should_auto_init_db, verify_database_connectivity
|
|
20
|
+
from skillgate.api.errors import error_code_for_status, error_response
|
|
21
|
+
from skillgate.api.middleware import BotMitigationMiddleware
|
|
22
|
+
from skillgate.api.redis_circuit_breaker import RedisCircuitBreaker
|
|
23
|
+
from skillgate.api.routes.alerts import router as alerts_router
|
|
24
|
+
from skillgate.api.routes.api_keys import router as api_keys_router
|
|
25
|
+
from skillgate.api.routes.audit import router as audit_router
|
|
26
|
+
from skillgate.api.routes.auth import router as auth_router
|
|
27
|
+
from skillgate.api.routes.entitlements import router as entitlements_router
|
|
28
|
+
from skillgate.api.routes.health import router as health_router
|
|
29
|
+
from skillgate.api.routes.hunt import router as hunt_router
|
|
30
|
+
from skillgate.api.routes.license import router as license_router
|
|
31
|
+
from skillgate.api.routes.payments import router as payments_router
|
|
32
|
+
from skillgate.api.routes.pricing import router as pricing_router
|
|
33
|
+
from skillgate.api.routes.retroscan import router as retroscan_router
|
|
34
|
+
from skillgate.api.routes.roadmap import router as roadmap_router
|
|
35
|
+
from skillgate.api.routes.scans import router as scans_router
|
|
36
|
+
from skillgate.api.routes.teams import router as teams_router
|
|
37
|
+
from skillgate.api.routes.usage import router as usage_router
|
|
38
|
+
from skillgate.api.routes.verify import router as verify_router
|
|
39
|
+
from skillgate.api.settings import get_settings
|
|
40
|
+
from skillgate.api.telemetry import instrument_app
|
|
41
|
+
from skillgate.version import __version__
|
|
42
|
+
|
|
43
|
+
Redis: Any | None = None
|
|
44
|
+
try:
|
|
45
|
+
from redis.asyncio import Redis as _Redis
|
|
46
|
+
except ImportError: # pragma: no cover
|
|
47
|
+
pass
|
|
48
|
+
else:
|
|
49
|
+
Redis = _Redis
|
|
50
|
+
|
|
51
|
+
API_VERSION = "v1"
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
# Global circuit breaker for Stripe API calls (shared across routes)
|
|
55
|
+
stripe_circuit_breaker: RedisCircuitBreaker | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_stripe_circuit_breaker() -> RedisCircuitBreaker | None:
|
|
59
|
+
"""Get global Stripe circuit breaker instance (None if Redis unavailable)."""
|
|
60
|
+
return stripe_circuit_breaker
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@asynccontextmanager
|
|
64
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
65
|
+
"""Application lifespan — startup and shutdown hooks."""
|
|
66
|
+
global stripe_circuit_breaker
|
|
67
|
+
settings = get_settings()
|
|
68
|
+
|
|
69
|
+
# Suppress verbose library logging in production
|
|
70
|
+
if settings.environment in {"production", "staging"}:
|
|
71
|
+
logging.getLogger("sqlalchemy").setLevel(logging.WARNING)
|
|
72
|
+
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
|
73
|
+
logging.getLogger("asyncpg").setLevel(logging.WARNING)
|
|
74
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
75
|
+
|
|
76
|
+
settings.validate_startup()
|
|
77
|
+
await verify_database_connectivity()
|
|
78
|
+
if should_auto_init_db():
|
|
79
|
+
logger.warning(
|
|
80
|
+
"SKILLGATE_AUTO_INIT_DB enabled; using SQLAlchemy create_all for local/dev startup."
|
|
81
|
+
)
|
|
82
|
+
await init_db()
|
|
83
|
+
|
|
84
|
+
# Initialize Redis circuit breaker for Stripe calls
|
|
85
|
+
if Redis is not None:
|
|
86
|
+
try:
|
|
87
|
+
redis_client = Redis.from_url(settings.redis_url, decode_responses=False)
|
|
88
|
+
ping_result = redis_client.ping()
|
|
89
|
+
if inspect.isawaitable(ping_result):
|
|
90
|
+
await ping_result
|
|
91
|
+
stripe_circuit_breaker = RedisCircuitBreaker(
|
|
92
|
+
redis=redis_client,
|
|
93
|
+
name="stripe",
|
|
94
|
+
failure_threshold=5,
|
|
95
|
+
recovery_seconds=45,
|
|
96
|
+
)
|
|
97
|
+
logger.info("Redis circuit breaker initialized for Stripe API calls")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.warning(f"Redis init failed, circuit breaker disabled: {e}")
|
|
100
|
+
stripe_circuit_breaker = None
|
|
101
|
+
|
|
102
|
+
yield
|
|
103
|
+
|
|
104
|
+
# Shutdown: cleanup redis connection
|
|
105
|
+
if stripe_circuit_breaker is not None and hasattr(stripe_circuit_breaker._redis, "close"):
|
|
106
|
+
await stripe_circuit_breaker._redis.close()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def create_app() -> FastAPI:
|
|
110
|
+
"""Create and configure the FastAPI application.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Configured FastAPI instance.
|
|
114
|
+
"""
|
|
115
|
+
app = FastAPI(
|
|
116
|
+
title="SkillGate API",
|
|
117
|
+
description="Hosted API for SkillGate — agent skill security governance.",
|
|
118
|
+
version=__version__,
|
|
119
|
+
docs_url=f"/api/{API_VERSION}/docs",
|
|
120
|
+
openapi_url=f"/api/{API_VERSION}/openapi.json",
|
|
121
|
+
lifespan=lifespan,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
settings = get_settings()
|
|
125
|
+
|
|
126
|
+
# CORS — configured origins only (no permissive wildcard defaults)
|
|
127
|
+
app.add_middleware(
|
|
128
|
+
CORSMiddleware,
|
|
129
|
+
allow_origins=settings.cors_origins,
|
|
130
|
+
allow_credentials=settings.cors_allow_credentials,
|
|
131
|
+
allow_methods=["*"],
|
|
132
|
+
allow_headers=["*"],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Bot mitigation — block suspicious User-Agent patterns
|
|
136
|
+
app.add_middleware(BotMitigationMiddleware)
|
|
137
|
+
|
|
138
|
+
@app.middleware("http")
|
|
139
|
+
async def request_context_middleware(
|
|
140
|
+
request: Request,
|
|
141
|
+
call_next: Callable[[Request], Awaitable[Response]],
|
|
142
|
+
) -> Response:
|
|
143
|
+
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
|
144
|
+
request.state.request_id = request_id
|
|
145
|
+
start = time.perf_counter()
|
|
146
|
+
try:
|
|
147
|
+
response = await call_next(request)
|
|
148
|
+
except Exception: # noqa: BLE001
|
|
149
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
150
|
+
logger.exception(
|
|
151
|
+
"request.failed method=%s path=%s duration_ms=%.2f request_id=%s",
|
|
152
|
+
request.method,
|
|
153
|
+
request.url.path,
|
|
154
|
+
duration_ms,
|
|
155
|
+
request_id,
|
|
156
|
+
)
|
|
157
|
+
error = error_response(
|
|
158
|
+
status_code=500,
|
|
159
|
+
message="Internal server error",
|
|
160
|
+
request_id=request_id,
|
|
161
|
+
code="INTERNAL_ERROR",
|
|
162
|
+
)
|
|
163
|
+
error.headers["X-Request-ID"] = request_id
|
|
164
|
+
return error
|
|
165
|
+
|
|
166
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
167
|
+
response.headers["X-Request-ID"] = request_id
|
|
168
|
+
|
|
169
|
+
# Security headers (OWASP recommended)
|
|
170
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
171
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
172
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
173
|
+
response.headers["Permissions-Policy"] = (
|
|
174
|
+
"camera=(), microphone=(), geolocation=(), payment=()"
|
|
175
|
+
)
|
|
176
|
+
response.headers["X-XSS-Protection"] = "0" # Disabled; CSP preferred
|
|
177
|
+
response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
|
|
178
|
+
if settings.enable_hsts:
|
|
179
|
+
response.headers["Strict-Transport-Security"] = (
|
|
180
|
+
"max-age=63072000; includeSubDomains; preload"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
logger.info(
|
|
184
|
+
"request.completed method=%s path=%s status=%s duration_ms=%.2f request_id=%s",
|
|
185
|
+
request.method,
|
|
186
|
+
request.url.path,
|
|
187
|
+
response.status_code,
|
|
188
|
+
duration_ms,
|
|
189
|
+
request_id,
|
|
190
|
+
)
|
|
191
|
+
return response
|
|
192
|
+
|
|
193
|
+
@app.exception_handler(StarletteHTTPException)
|
|
194
|
+
async def http_exception_handler(
|
|
195
|
+
request: Request,
|
|
196
|
+
exc: StarletteHTTPException,
|
|
197
|
+
) -> Response:
|
|
198
|
+
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
|
199
|
+
message = str(exc.detail) if exc.detail else "Request failed"
|
|
200
|
+
return error_response(
|
|
201
|
+
status_code=exc.status_code,
|
|
202
|
+
message=message,
|
|
203
|
+
request_id=request_id,
|
|
204
|
+
code=error_code_for_status(exc.status_code),
|
|
205
|
+
headers=exc.headers,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@app.exception_handler(RequestValidationError)
|
|
209
|
+
async def validation_exception_handler(
|
|
210
|
+
request: Request,
|
|
211
|
+
exc: RequestValidationError,
|
|
212
|
+
) -> Response:
|
|
213
|
+
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
|
214
|
+
message = "Validation failed"
|
|
215
|
+
logger.warning(
|
|
216
|
+
"request.validation_failed errors=%s request_id=%s",
|
|
217
|
+
exc.errors(),
|
|
218
|
+
request_id,
|
|
219
|
+
)
|
|
220
|
+
return error_response(
|
|
221
|
+
status_code=422,
|
|
222
|
+
message=message,
|
|
223
|
+
request_id=request_id,
|
|
224
|
+
code="VALIDATION_ERROR",
|
|
225
|
+
retryable=False,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Optional OpenTelemetry instrumentation
|
|
229
|
+
instrument_app(app)
|
|
230
|
+
|
|
231
|
+
# Register route groups
|
|
232
|
+
app.include_router(audit_router, prefix=f"/api/{API_VERSION}")
|
|
233
|
+
app.include_router(alerts_router, prefix=f"/api/{API_VERSION}")
|
|
234
|
+
app.include_router(auth_router, prefix=f"/api/{API_VERSION}")
|
|
235
|
+
app.include_router(entitlements_router, prefix=f"/api/{API_VERSION}")
|
|
236
|
+
app.include_router(api_keys_router, prefix=f"/api/{API_VERSION}")
|
|
237
|
+
app.include_router(health_router, prefix=f"/api/{API_VERSION}")
|
|
238
|
+
app.include_router(hunt_router, prefix=f"/api/{API_VERSION}")
|
|
239
|
+
app.include_router(license_router, prefix=f"/api/{API_VERSION}")
|
|
240
|
+
app.include_router(retroscan_router, prefix=f"/api/{API_VERSION}")
|
|
241
|
+
app.include_router(roadmap_router, prefix=f"/api/{API_VERSION}")
|
|
242
|
+
app.include_router(payments_router, prefix=f"/api/{API_VERSION}")
|
|
243
|
+
app.include_router(pricing_router, prefix=f"/api/{API_VERSION}")
|
|
244
|
+
app.include_router(scans_router, prefix=f"/api/{API_VERSION}")
|
|
245
|
+
app.include_router(teams_router, prefix=f"/api/{API_VERSION}")
|
|
246
|
+
app.include_router(usage_router, prefix=f"/api/{API_VERSION}")
|
|
247
|
+
app.include_router(verify_router, prefix=f"/api/{API_VERSION}")
|
|
248
|
+
|
|
249
|
+
return app
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Auth-provider observability helpers for Section 17.187.
|
|
2
|
+
|
|
3
|
+
Provides provider-path metrics and structured failure triage logging while
|
|
4
|
+
ensuring token/secrets are never emitted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from fastapi import HTTPException
|
|
14
|
+
|
|
15
|
+
from skillgate.api.errors import AuthError
|
|
16
|
+
from skillgate.api.telemetry import get_meter
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
meter = get_meter("skillgate.api.auth_observability")
|
|
20
|
+
provider_decisions_counter = meter.create_counter("auth_provider_decisions_total")
|
|
21
|
+
provider_failures_counter = meter.create_counter("auth_provider_failures_total")
|
|
22
|
+
|
|
23
|
+
_SENSITIVE_KEYS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"token",
|
|
26
|
+
"access_token",
|
|
27
|
+
"refresh_token",
|
|
28
|
+
"authorization",
|
|
29
|
+
"password",
|
|
30
|
+
"secret",
|
|
31
|
+
"api_key",
|
|
32
|
+
"service_role_key",
|
|
33
|
+
"anon_key",
|
|
34
|
+
"jwt_secret",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sanitize_context(payload: dict[str, Any] | None) -> dict[str, Any]:
|
|
40
|
+
"""Return log-safe context by redacting sensitive keys recursively."""
|
|
41
|
+
|
|
42
|
+
if payload is None:
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
safe: dict[str, Any] = {}
|
|
46
|
+
for key, value in payload.items():
|
|
47
|
+
key_norm = key.lower()
|
|
48
|
+
if any(secret_key in key_norm for secret_key in _SENSITIVE_KEYS):
|
|
49
|
+
safe[key] = "[REDACTED]"
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if isinstance(value, dict):
|
|
53
|
+
safe[key] = sanitize_context(value)
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
safe[key] = value
|
|
57
|
+
return safe
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def failure_class_from_exception(exc: Exception) -> str:
|
|
61
|
+
"""Map exception classes to stable triage buckets."""
|
|
62
|
+
|
|
63
|
+
if isinstance(exc, HTTPException):
|
|
64
|
+
status_code = exc.status_code
|
|
65
|
+
if status_code == 400:
|
|
66
|
+
return "bad_request"
|
|
67
|
+
if status_code == 401:
|
|
68
|
+
return "unauthorized"
|
|
69
|
+
if status_code == 403:
|
|
70
|
+
return "forbidden"
|
|
71
|
+
if status_code == 404:
|
|
72
|
+
return "not_found"
|
|
73
|
+
if status_code == 409:
|
|
74
|
+
return "conflict"
|
|
75
|
+
if status_code == 423:
|
|
76
|
+
return "locked"
|
|
77
|
+
if status_code == 429:
|
|
78
|
+
return "rate_limited"
|
|
79
|
+
if status_code >= 500:
|
|
80
|
+
return "upstream_or_internal"
|
|
81
|
+
return f"http_{status_code}"
|
|
82
|
+
|
|
83
|
+
if isinstance(exc, AuthError):
|
|
84
|
+
return "auth_error"
|
|
85
|
+
if isinstance(exc, httpx.HTTPError):
|
|
86
|
+
return "http_transport_error"
|
|
87
|
+
if isinstance(exc, ValueError):
|
|
88
|
+
return "validation_error"
|
|
89
|
+
return "internal_error"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def record_auth_decision(
|
|
93
|
+
*,
|
|
94
|
+
endpoint: str,
|
|
95
|
+
provider: str,
|
|
96
|
+
outcome: str,
|
|
97
|
+
failure_class: str | None = None,
|
|
98
|
+
status_code: int | None = None,
|
|
99
|
+
context: dict[str, Any] | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Emit counters and a structured log line for auth provider decisions."""
|
|
102
|
+
|
|
103
|
+
attributes = {
|
|
104
|
+
"endpoint": endpoint,
|
|
105
|
+
"provider": provider,
|
|
106
|
+
"outcome": outcome,
|
|
107
|
+
}
|
|
108
|
+
provider_decisions_counter.add(1, attributes)
|
|
109
|
+
|
|
110
|
+
safe_context = sanitize_context(context)
|
|
111
|
+
if outcome == "success":
|
|
112
|
+
logger.info(
|
|
113
|
+
"auth_provider.decision endpoint=%s provider=%s outcome=%s context=%s",
|
|
114
|
+
endpoint,
|
|
115
|
+
provider,
|
|
116
|
+
outcome,
|
|
117
|
+
safe_context,
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
failure_bucket = failure_class or "unknown_failure"
|
|
122
|
+
provider_failures_counter.add(
|
|
123
|
+
1,
|
|
124
|
+
{
|
|
125
|
+
"endpoint": endpoint,
|
|
126
|
+
"provider": provider,
|
|
127
|
+
"failure_class": failure_bucket,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
logger.warning(
|
|
131
|
+
(
|
|
132
|
+
"auth_provider.failure endpoint=%s provider=%s outcome=%s "
|
|
133
|
+
"failure_class=%s status=%s context=%s"
|
|
134
|
+
),
|
|
135
|
+
endpoint,
|
|
136
|
+
provider,
|
|
137
|
+
outcome,
|
|
138
|
+
failure_bucket,
|
|
139
|
+
status_code,
|
|
140
|
+
safe_context,
|
|
141
|
+
)
|
skillgate/api/db.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Database engine and session management for hosted API routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import AsyncGenerator
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import text
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
10
|
+
from sqlalchemy.pool import NullPool
|
|
11
|
+
|
|
12
|
+
from skillgate.api.models import Base
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _database_url() -> str:
|
|
16
|
+
"""Resolve database URL from environment."""
|
|
17
|
+
value = os.environ.get("SKILLGATE_DATABASE_URL")
|
|
18
|
+
if value is None or not value.strip():
|
|
19
|
+
raise RuntimeError("SKILLGATE_DATABASE_URL must be set.")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read_replica_url() -> str | None:
|
|
24
|
+
"""Optional read-replica URL for SELECT-heavy routes."""
|
|
25
|
+
return os.environ.get("SKILLGATE_READ_REPLICA_URL")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_bool(value: str | None, default: bool) -> bool:
|
|
29
|
+
if value is None:
|
|
30
|
+
return default
|
|
31
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pool_size() -> int:
|
|
35
|
+
return int(os.environ.get("SKILLGATE_DB_POOL_SIZE", "20"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _max_overflow() -> int:
|
|
39
|
+
return int(os.environ.get("SKILLGATE_DB_MAX_OVERFLOW", "10"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_postgres(url: str) -> bool:
|
|
43
|
+
return url.startswith("postgresql")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def database_url() -> str:
|
|
47
|
+
"""Expose the configured runtime database URL."""
|
|
48
|
+
return _database_url()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def alembic_database_url() -> str:
|
|
52
|
+
"""Return sync-driver database URL for Alembic migration engine."""
|
|
53
|
+
url = _database_url()
|
|
54
|
+
if url.startswith("postgresql+asyncpg://"):
|
|
55
|
+
return url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
|
|
56
|
+
if url.startswith("sqlite+aiosqlite:///"):
|
|
57
|
+
return url.replace("sqlite+aiosqlite:///", "sqlite:///", 1)
|
|
58
|
+
return url
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _engine_kwargs(url: str) -> dict[str, object]:
|
|
62
|
+
"""Build engine kwargs with connection pooling for PostgreSQL."""
|
|
63
|
+
kwargs: dict[str, object] = {"future": True, "pool_pre_ping": True}
|
|
64
|
+
if _parse_bool(os.environ.get("SKILLGATE_DISABLE_DB_POOL"), False):
|
|
65
|
+
kwargs["poolclass"] = NullPool
|
|
66
|
+
return kwargs
|
|
67
|
+
if _is_postgres(url):
|
|
68
|
+
kwargs["pool_size"] = _pool_size()
|
|
69
|
+
kwargs["max_overflow"] = _max_overflow()
|
|
70
|
+
kwargs["pool_recycle"] = 300
|
|
71
|
+
kwargs["pool_timeout"] = 30
|
|
72
|
+
return kwargs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_primary_url = _database_url()
|
|
76
|
+
engine = create_async_engine(_primary_url, **_engine_kwargs(_primary_url))
|
|
77
|
+
|
|
78
|
+
# Read-replica engine (falls back to primary if not configured)
|
|
79
|
+
_replica_url = _read_replica_url()
|
|
80
|
+
read_engine = (
|
|
81
|
+
create_async_engine(_replica_url, **_engine_kwargs(_replica_url)) if _replica_url else engine
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
SessionLocal = async_sessionmaker(
|
|
85
|
+
bind=engine,
|
|
86
|
+
class_=AsyncSession,
|
|
87
|
+
expire_on_commit=False,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
ReadOnlySession = async_sessionmaker(
|
|
91
|
+
bind=read_engine,
|
|
92
|
+
class_=AsyncSession,
|
|
93
|
+
expire_on_commit=False,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def init_db() -> None:
|
|
98
|
+
"""Create database tables for local/dev workflows only.
|
|
99
|
+
|
|
100
|
+
Production deployments must use Alembic migrations.
|
|
101
|
+
"""
|
|
102
|
+
async with engine.begin() as conn:
|
|
103
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def should_auto_init_db() -> bool:
|
|
107
|
+
"""Control schema auto-initialize behavior for local SQLite-only startup."""
|
|
108
|
+
default = _database_url().startswith("sqlite+")
|
|
109
|
+
return _parse_bool(os.environ.get("SKILLGATE_AUTO_INIT_DB"), default)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def verify_database_connectivity() -> None:
|
|
113
|
+
"""Fail fast if the configured database is unreachable."""
|
|
114
|
+
async with engine.connect() as conn:
|
|
115
|
+
await conn.execute(text("SELECT 1"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
119
|
+
"""Yield a transaction-capable async database session."""
|
|
120
|
+
async with SessionLocal() as session:
|
|
121
|
+
yield session
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def get_read_session() -> AsyncGenerator[AsyncSession, None]:
|
|
125
|
+
"""Yield a read-only session routed to the replica (or primary fallback)."""
|
|
126
|
+
async with ReadOnlySession() as session:
|
|
127
|
+
yield session
|