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.
Files changed (229) hide show
  1. skillgate/__init__.py +5 -0
  2. skillgate/__main__.py +5 -0
  3. skillgate/api/__init__.py +1 -0
  4. skillgate/api/app.py +249 -0
  5. skillgate/api/auth_observability.py +141 -0
  6. skillgate/api/db.py +127 -0
  7. skillgate/api/device_codes.py +188 -0
  8. skillgate/api/entitlement.py +76 -0
  9. skillgate/api/entitlement_teams.py +91 -0
  10. skillgate/api/errors.py +83 -0
  11. skillgate/api/middleware/__init__.py +5 -0
  12. skillgate/api/middleware/bot_mitigation.py +75 -0
  13. skillgate/api/migrations/0001_initial.sql +96 -0
  14. skillgate/api/migrations/supabase/001_rpc_contract_v1.sql +96 -0
  15. skillgate/api/migrations/supabase/002_rls_policies_v1.sql +63 -0
  16. skillgate/api/migrations/supabase/email_templates/confirm_signup.html +102 -0
  17. skillgate/api/migrations/supabase/email_templates/magic_link.html +101 -0
  18. skillgate/api/migrations/supabase/email_templates/password_reset.html +113 -0
  19. skillgate/api/models.py +272 -0
  20. skillgate/api/pricing_catalog.py +371 -0
  21. skillgate/api/rate_limit.py +68 -0
  22. skillgate/api/redis_circuit_breaker.py +137 -0
  23. skillgate/api/redis_rate_limit.py +61 -0
  24. skillgate/api/resilience.py +94 -0
  25. skillgate/api/roadmap_catalog.py +136 -0
  26. skillgate/api/routes/__init__.py +1 -0
  27. skillgate/api/routes/alerts.py +180 -0
  28. skillgate/api/routes/api_keys.py +267 -0
  29. skillgate/api/routes/audit.py +100 -0
  30. skillgate/api/routes/auth.py +1376 -0
  31. skillgate/api/routes/entitlements.py +381 -0
  32. skillgate/api/routes/health.py +15 -0
  33. skillgate/api/routes/hunt.py +117 -0
  34. skillgate/api/routes/license.py +57 -0
  35. skillgate/api/routes/payments.py +1213 -0
  36. skillgate/api/routes/pricing.py +15 -0
  37. skillgate/api/routes/retroscan.py +179 -0
  38. skillgate/api/routes/roadmap.py +15 -0
  39. skillgate/api/routes/scans.py +192 -0
  40. skillgate/api/routes/teams.py +256 -0
  41. skillgate/api/routes/usage.py +139 -0
  42. skillgate/api/routes/verify.py +54 -0
  43. skillgate/api/security.py +149 -0
  44. skillgate/api/settings.py +162 -0
  45. skillgate/api/supabase_auth_provider.py +514 -0
  46. skillgate/api/supabase_client.py +296 -0
  47. skillgate/api/supabase_egress.py +74 -0
  48. skillgate/api/supabase_jwt.py +197 -0
  49. skillgate/api/telemetry.py +153 -0
  50. skillgate/api/worker.py +72 -0
  51. skillgate/assets/logo.ansi +73 -0
  52. skillgate/assets/logo_compact_16.ansi +21 -0
  53. skillgate/assets/logo_compact_16_light.ansi +21 -0
  54. skillgate/assets/logo_compact_20.ansi +26 -0
  55. skillgate/assets/logo_compact_20_light.ansi +26 -0
  56. skillgate/assets/logo_compact_24.ansi +31 -0
  57. skillgate/assets/logo_compact_24_light.ansi +31 -0
  58. skillgate/assets/logo_compact_28.ansi +36 -0
  59. skillgate/assets/logo_compact_28_light.ansi +36 -0
  60. skillgate/assets/logo_compact_32.ansi +41 -0
  61. skillgate/assets/logo_compact_32_light.ansi +41 -0
  62. skillgate/assets/logo_small_48.ansi +62 -0
  63. skillgate/assets/logo_small_48_light.ansi +62 -0
  64. skillgate/assets/logo_small_48_light_old.ansi +49 -0
  65. skillgate/assets/logo_small_48_old.ansi +49 -0
  66. skillgate/ci/__init__.py +1 -0
  67. skillgate/ci/bitbucket/__init__.py +0 -0
  68. skillgate/ci/bitbucket/template.yml +75 -0
  69. skillgate/ci/github/__init__.py +1 -0
  70. skillgate/ci/github/action.yml +150 -0
  71. skillgate/ci/github/annotations.py +112 -0
  72. skillgate/ci/gitlab/__init__.py +1 -0
  73. skillgate/ci/gitlab/template.yml +68 -0
  74. skillgate/ci/noise.py +155 -0
  75. skillgate/cli/__init__.py +1 -0
  76. skillgate/cli/app.py +158 -0
  77. skillgate/cli/branding.py +175 -0
  78. skillgate/cli/commands/__init__.py +1 -0
  79. skillgate/cli/commands/approval.py +88 -0
  80. skillgate/cli/commands/auth.py +365 -0
  81. skillgate/cli/commands/bom.py +106 -0
  82. skillgate/cli/commands/dag.py +124 -0
  83. skillgate/cli/commands/doctor.py +146 -0
  84. skillgate/cli/commands/drift.py +314 -0
  85. skillgate/cli/commands/gateway.py +381 -0
  86. skillgate/cli/commands/hooks.py +141 -0
  87. skillgate/cli/commands/hunt.py +186 -0
  88. skillgate/cli/commands/init.py +43 -0
  89. skillgate/cli/commands/keys.py +59 -0
  90. skillgate/cli/commands/reputation.py +146 -0
  91. skillgate/cli/commands/retroscan.py +214 -0
  92. skillgate/cli/commands/rules_cmd.py +81 -0
  93. skillgate/cli/commands/run.py +415 -0
  94. skillgate/cli/commands/scan.py +1097 -0
  95. skillgate/cli/commands/simulate.py +414 -0
  96. skillgate/cli/commands/submit_scan.py +49 -0
  97. skillgate/cli/commands/verify.py +55 -0
  98. skillgate/cli/formatters/__init__.py +7 -0
  99. skillgate/cli/formatters/human.py +440 -0
  100. skillgate/cli/formatters/json_fmt.py +19 -0
  101. skillgate/cli/formatters/sarif.py +173 -0
  102. skillgate/cli/main.py +6 -0
  103. skillgate/cli/remote.py +341 -0
  104. skillgate/cli/scan_submit.py +90 -0
  105. skillgate/config/__init__.py +1 -0
  106. skillgate/config/entitlement.py +188 -0
  107. skillgate/config/license.py +75 -0
  108. skillgate/config/secrets.py +107 -0
  109. skillgate/core/__init__.py +0 -0
  110. skillgate/core/analyzer/__init__.py +33 -0
  111. skillgate/core/analyzer/correlation.py +248 -0
  112. skillgate/core/analyzer/engine.py +140 -0
  113. skillgate/core/analyzer/perf_guard.py +199 -0
  114. skillgate/core/analyzer/rules/__init__.py +59 -0
  115. skillgate/core/analyzer/rules/base.py +159 -0
  116. skillgate/core/analyzer/rules/command.py +305 -0
  117. skillgate/core/analyzer/rules/config.py +299 -0
  118. skillgate/core/analyzer/rules/credential.py +185 -0
  119. skillgate/core/analyzer/rules/eval.py +131 -0
  120. skillgate/core/analyzer/rules/filesystem.py +167 -0
  121. skillgate/core/analyzer/rules/go.py +281 -0
  122. skillgate/core/analyzer/rules/injection.py +119 -0
  123. skillgate/core/analyzer/rules/js_ast.py +92 -0
  124. skillgate/core/analyzer/rules/network.py +141 -0
  125. skillgate/core/analyzer/rules/obfuscation.py +146 -0
  126. skillgate/core/analyzer/rules/prompt.py +220 -0
  127. skillgate/core/analyzer/rules/ruby.py +329 -0
  128. skillgate/core/analyzer/rules/rust.py +278 -0
  129. skillgate/core/analyzer/rules/shell.py +201 -0
  130. skillgate/core/analyzer/rules/shell_ast.py +86 -0
  131. skillgate/core/analyzer/treesitter.py +156 -0
  132. skillgate/core/analyzer/unicode_normalizer.py +232 -0
  133. skillgate/core/connectors/__init__.py +35 -0
  134. skillgate/core/connectors/base.py +57 -0
  135. skillgate/core/connectors/file_tip.py +111 -0
  136. skillgate/core/connectors/manager.py +159 -0
  137. skillgate/core/connectors/models.py +66 -0
  138. skillgate/core/connectors/registry.py +69 -0
  139. skillgate/core/enricher/__init__.py +9 -0
  140. skillgate/core/enricher/catalog.py +944 -0
  141. skillgate/core/enricher/engine.py +45 -0
  142. skillgate/core/enricher/models.py +23 -0
  143. skillgate/core/entitlement/__init__.py +65 -0
  144. skillgate/core/entitlement/airgap.py +194 -0
  145. skillgate/core/entitlement/cache.py +63 -0
  146. skillgate/core/entitlement/enterprise.py +72 -0
  147. skillgate/core/entitlement/enterprise_adapter.py +137 -0
  148. skillgate/core/entitlement/gates.py +75 -0
  149. skillgate/core/entitlement/mode.py +83 -0
  150. skillgate/core/entitlement/models.py +139 -0
  151. skillgate/core/entitlement/quota.py +102 -0
  152. skillgate/core/entitlement/resilience.py +47 -0
  153. skillgate/core/entitlement/resolver.py +401 -0
  154. skillgate/core/entitlement/usage_authority.py +228 -0
  155. skillgate/core/errors.py +40 -0
  156. skillgate/core/explainer/__init__.py +9 -0
  157. skillgate/core/explainer/engine.py +458 -0
  158. skillgate/core/explainer/templates.py +122 -0
  159. skillgate/core/gateway/__init__.py +94 -0
  160. skillgate/core/gateway/allowlist.py +96 -0
  161. skillgate/core/gateway/approval.py +194 -0
  162. skillgate/core/gateway/bom_gate.py +192 -0
  163. skillgate/core/gateway/budget.py +363 -0
  164. skillgate/core/gateway/executor.py +43 -0
  165. skillgate/core/gateway/lineage.py +246 -0
  166. skillgate/core/gateway/runtime.py +67 -0
  167. skillgate/core/gateway/runtime_engine.py +147 -0
  168. skillgate/core/gateway/sandbox.py +90 -0
  169. skillgate/core/gateway/scope.py +100 -0
  170. skillgate/core/gateway/session.py +202 -0
  171. skillgate/core/gateway/top_guard.py +168 -0
  172. skillgate/core/hunt/__init__.py +25 -0
  173. skillgate/core/hunt/engine.py +290 -0
  174. skillgate/core/hunt/models.py +127 -0
  175. skillgate/core/hunt/parser.py +150 -0
  176. skillgate/core/models/__init__.py +34 -0
  177. skillgate/core/models/artifact.py +96 -0
  178. skillgate/core/models/bundle.py +48 -0
  179. skillgate/core/models/enums.py +40 -0
  180. skillgate/core/models/finding.py +81 -0
  181. skillgate/core/models/report.py +99 -0
  182. skillgate/core/orchestrator/__init__.py +59 -0
  183. skillgate/core/orchestrator/approval.py +80 -0
  184. skillgate/core/orchestrator/engine.py +166 -0
  185. skillgate/core/orchestrator/evidence.py +102 -0
  186. skillgate/core/orchestrator/models.py +78 -0
  187. skillgate/core/orchestrator/pipeline.py +166 -0
  188. skillgate/core/orchestrator/triage.py +167 -0
  189. skillgate/core/orchestrator/write_path.py +92 -0
  190. skillgate/core/parser/__init__.py +26 -0
  191. skillgate/core/parser/archive.py +672 -0
  192. skillgate/core/parser/bundle.py +100 -0
  193. skillgate/core/parser/document.py +366 -0
  194. skillgate/core/parser/fleet.py +115 -0
  195. skillgate/core/parser/manifest.py +188 -0
  196. skillgate/core/parser/markdown.py +352 -0
  197. skillgate/core/parser/source.py +90 -0
  198. skillgate/core/policy/__init__.py +36 -0
  199. skillgate/core/policy/engine.py +501 -0
  200. skillgate/core/policy/loader.py +148 -0
  201. skillgate/core/policy/presets.py +148 -0
  202. skillgate/core/policy/schema.py +276 -0
  203. skillgate/core/reputation/__init__.py +17 -0
  204. skillgate/core/reputation/models.py +49 -0
  205. skillgate/core/reputation/policy.py +147 -0
  206. skillgate/core/reputation/redaction.py +13 -0
  207. skillgate/core/reputation/store.py +116 -0
  208. skillgate/core/reputation/verifier.py +96 -0
  209. skillgate/core/retroscan/__init__.py +24 -0
  210. skillgate/core/retroscan/engine.py +222 -0
  211. skillgate/core/retroscan/models.py +80 -0
  212. skillgate/core/retroscan/store.py +138 -0
  213. skillgate/core/scorer/__init__.py +6 -0
  214. skillgate/core/scorer/engine.py +85 -0
  215. skillgate/core/scorer/severity.py +31 -0
  216. skillgate/core/scorer/weights.py +15 -0
  217. skillgate/core/signer/__init__.py +22 -0
  218. skillgate/core/signer/canonical.py +44 -0
  219. skillgate/core/signer/engine.py +150 -0
  220. skillgate/core/signer/keys.py +120 -0
  221. skillgate/py.typed +0 -0
  222. skillgate/version.py +3 -0
  223. skillgate-1.1.0.dist-info/METADATA +219 -0
  224. skillgate-1.1.0.dist-info/RECORD +229 -0
  225. skillgate-1.1.0.dist-info/WHEEL +5 -0
  226. skillgate-1.1.0.dist-info/entry_points.txt +2 -0
  227. skillgate-1.1.0.dist-info/licenses/LICENSE +50 -0
  228. skillgate-1.1.0.dist-info/top_level.txt +2 -0
  229. skillgate-docs/node_modules/flatted/python/flatted.py +149 -0
skillgate/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """SkillGate — CLI-first CI/CD policy enforcement for agent skills."""
2
+
3
+ from skillgate.version import __version__
4
+
5
+ __all__ = ["__version__"]
skillgate/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running skillgate as `python -m skillgate`."""
2
+
3
+ from skillgate.cli.app import main
4
+
5
+ main()
@@ -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