admina-framework 0.12.0__tar.gz → 0.12.1__tar.gz

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 (174) hide show
  1. {admina_framework-0.12.0/admina_framework.egg-info → admina_framework-0.12.1}/PKG-INFO +5 -1
  2. {admina_framework-0.12.0 → admina_framework-0.12.1}/README.md +4 -0
  3. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/__init__.py +1 -1
  4. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/main.py +1 -0
  5. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/dashboard/static/index.html +145 -4
  6. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/auth/apikey.py +10 -47
  7. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/api/dashboard.py +33 -12
  8. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/config.py +16 -4
  9. admina_framework-0.12.1/admina/proxy/dashboard_session.py +133 -0
  10. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/main.py +190 -81
  11. {admina_framework-0.12.0 → admina_framework-0.12.1/admina_framework.egg-info}/PKG-INFO +5 -1
  12. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina_framework.egg-info/SOURCES.txt +2 -0
  13. {admina_framework-0.12.0 → admina_framework-0.12.1}/pyproject.toml +1 -1
  14. admina_framework-0.12.1/tests/test_proxy_dashboard_session.py +800 -0
  15. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_security.py +25 -72
  16. {admina_framework-0.12.0 → admina_framework-0.12.1}/LICENSE +0 -0
  17. {admina_framework-0.12.0 → admina_framework-0.12.1}/NOTICE +0 -0
  18. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/__init__.py +0 -0
  19. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/commands/__init__.py +0 -0
  20. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/admina.yaml.j2 +0 -0
  21. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/docker-compose.yml.j2 +0 -0
  22. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/env.j2 +0 -0
  23. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/main.py.j2 +0 -0
  24. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/plugin.py.j2 +0 -0
  25. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/plugin_pyproject.toml.j2 +0 -0
  26. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/plugin_readme.md.j2 +0 -0
  27. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/plugin_test.py.j2 +0 -0
  28. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/core/__init__.py +0 -0
  29. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/core/config.py +0 -0
  30. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/core/event_bus.py +0 -0
  31. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/core/secrets.py +0 -0
  32. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/core/types.py +0 -0
  33. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/dashboard/__init__.py +0 -0
  34. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/dashboard/static/heimdall.png +0 -0
  35. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/dashboard/static/vendor/alpinejs.min.js +0 -0
  36. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/__init__.py +0 -0
  37. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/__init__.py +0 -0
  38. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/coordination.py +0 -0
  39. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/egress.py +0 -0
  40. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/fingerprint.py +0 -0
  41. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/firewall.py +0 -0
  42. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/loop_breaker.py +0 -0
  43. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/ai_infra/__init__.py +0 -0
  44. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/ai_infra/llm_engine.py +0 -0
  45. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/ai_infra/rag.py +0 -0
  46. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/ai_infra/webui.py +0 -0
  47. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/__init__.py +0 -0
  48. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/cross_regulation.py +0 -0
  49. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/eu_ai_act.py +0 -0
  50. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/forensic.py +0 -0
  51. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/gdpr.py +0 -0
  52. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/nis2.py +0 -0
  53. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/oisg.py +0 -0
  54. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/otel.py +0 -0
  55. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/data_sovereignty/__init__.py +0 -0
  56. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/data_sovereignty/classification.py +0 -0
  57. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/data_sovereignty/pii.py +0 -0
  58. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/data_sovereignty/residency.py +0 -0
  59. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/governance.py +0 -0
  60. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/engines/__init__.py +0 -0
  61. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/engines/presidio.py +0 -0
  62. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/__init__.py +0 -0
  63. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/_engines.py +0 -0
  64. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/cheshirecat/__init__.py +0 -0
  65. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/cheshirecat/admina-plugin/admina_governance.py +0 -0
  66. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/crewai/__init__.py +0 -0
  67. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/crewai/callbacks.py +0 -0
  68. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/langchain/__init__.py +0 -0
  69. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/langchain/callbacks.py +0 -0
  70. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/n8n/__init__.py +0 -0
  71. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/openclaw/__init__.py +0 -0
  72. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/__init__.py +0 -0
  73. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/base.py +0 -0
  74. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/__init__.py +0 -0
  75. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/__init__.py +0 -0
  76. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/_streaming.py +0 -0
  77. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/anthropic.py +0 -0
  78. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/bedrock.py +0 -0
  79. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/gemini.py +0 -0
  80. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/mistral.py +0 -0
  81. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/ollama.py +0 -0
  82. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/openai.py +0 -0
  83. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/vllm.py +0 -0
  84. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/alerts/__init__.py +0 -0
  85. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/alerts/log.py +0 -0
  86. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/alerts/webhook.py +0 -0
  87. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/auth/__init__.py +0 -0
  88. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/compliance/__init__.py +0 -0
  89. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/compliance/eu_ai_act.py +0 -0
  90. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/connectors/__init__.py +0 -0
  91. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/connectors/chromadb.py +0 -0
  92. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/connectors/filesystem.py +0 -0
  93. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/forensic/__init__.py +0 -0
  94. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/forensic/filesystem.py +0 -0
  95. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/guards/__init__.py +0 -0
  96. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/guards/guardrailsai_guard.py +0 -0
  97. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/pii/__init__.py +0 -0
  98. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/pii/spacy_regex.py +0 -0
  99. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/transports/__init__.py +0 -0
  100. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/transports/http_rest.py +0 -0
  101. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/transports/mcp.py +0 -0
  102. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/registry.py +0 -0
  103. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/__init__.py +0 -0
  104. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/api/__init__.py +0 -0
  105. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/api/gateway.py +0 -0
  106. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/api/integration.py +0 -0
  107. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/engine_bridge.py +0 -0
  108. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/governance.py +0 -0
  109. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/multi_upstream.py +0 -0
  110. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/state.py +0 -0
  111. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/py.typed +0 -0
  112. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/__init__.py +0 -0
  113. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/baselines/baseline.json +0 -0
  114. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora/SHA256SUMS +0 -0
  115. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora/coordination.jsonl +0 -0
  116. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora/injection.jsonl +0 -0
  117. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora/loop.jsonl +0 -0
  118. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora/pii.jsonl +0 -0
  119. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora.py +0 -0
  120. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/detectors.py +0 -0
  121. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/gate.py +0 -0
  122. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/metrics.py +0 -0
  123. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/report.py +0 -0
  124. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/runner.py +0 -0
  125. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/__init__.py +0 -0
  126. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/_compat.py +0 -0
  127. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/compliance_kit.py +0 -0
  128. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/errors.py +0 -0
  129. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/governed_agent.py +0 -0
  130. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/governed_data.py +0 -0
  131. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/governed_model.py +0 -0
  132. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/retry.py +0 -0
  133. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/streaming.py +0 -0
  134. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina_framework.egg-info/dependency_links.txt +0 -0
  135. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina_framework.egg-info/entry_points.txt +0 -0
  136. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina_framework.egg-info/requires.txt +0 -0
  137. {admina_framework-0.12.0 → admina_framework-0.12.1}/admina_framework.egg-info/top_level.txt +0 -0
  138. {admina_framework-0.12.0 → admina_framework-0.12.1}/setup.cfg +0 -0
  139. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_benchmark_14us.py +0 -0
  140. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_coordination.py +0 -0
  141. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_coordination_cli.py +0 -0
  142. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_coordination_corpus.py +0 -0
  143. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_coordination_wiring.py +0 -0
  144. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_core_config.py +0 -0
  145. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_domains.py +0 -0
  146. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_domains_governance.py +0 -0
  147. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_egress_analyze.py +0 -0
  148. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_egress_cli.py +0 -0
  149. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_egress_pipeline.py +0 -0
  150. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_egress_policy.py +0 -0
  151. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_egress_surfaces.py +0 -0
  152. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_engines.py +0 -0
  153. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_error_disclosure.py +0 -0
  154. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_fingerprint.py +0 -0
  155. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_firewall_parity.py +0 -0
  156. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_forensic_blackbox.py +0 -0
  157. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_gateway_config.py +0 -0
  158. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_gateway_endpoints.py +0 -0
  159. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_gateway_guard_fail_mode.py +0 -0
  160. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_gateway_helpers.py +0 -0
  161. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_gateway_mount.py +0 -0
  162. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_governance_pipeline.py +0 -0
  163. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_guard_fail_mode.py +0 -0
  164. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_mcp_transport.py +0 -0
  165. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_presidio_engine.py +0 -0
  166. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_forensic_would_action.py +0 -0
  167. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_governance_mode_config.py +0 -0
  168. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_guard_fail_mode.py +0 -0
  169. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_mcp_response_pii.py +0 -0
  170. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_metrics_endpoint.py +0 -0
  171. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_plugins.py +0 -0
  172. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_redteam_efficacy.py +0 -0
  173. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_redteam_lib.py +0 -0
  174. {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: admina-framework
3
- Version: 0.12.0
3
+ Version: 0.12.1
4
4
  Summary: Admina — governed AI development framework
5
5
  Author-email: Stefano Noferi <info@admina.org>
6
6
  Maintainer-email: Stefano Noferi <info@admina.org>
@@ -382,6 +382,10 @@ Real-time governance dashboard on port 3000:
382
382
 
383
383
  API backend: `GET /api/dashboard/score`, `/feed`, `/compliance`, `/sovereignty`, `/infra`, `/models`
384
384
 
385
+ In `admina dev` local mode the dashboard asks for the API key (`admina password show`)
386
+ and keeps a short-lived browser session that only the read-only dashboard API accepts.
387
+ Set `ADMINA_DASHBOARD_ENABLED=false` to stop serving the bundled dashboard.
388
+
385
389
  ## Configuration
386
390
 
387
391
  Admina uses `admina.yaml` as the primary config file (with `.env` fallback for backward compatibility):
@@ -305,6 +305,10 @@ Real-time governance dashboard on port 3000:
305
305
 
306
306
  API backend: `GET /api/dashboard/score`, `/feed`, `/compliance`, `/sovereignty`, `/infra`, `/models`
307
307
 
308
+ In `admina dev` local mode the dashboard asks for the API key (`admina password show`)
309
+ and keeps a short-lived browser session that only the read-only dashboard API accepts.
310
+ Set `ADMINA_DASHBOARD_ENABLED=false` to stop serving the bundled dashboard.
311
+
308
312
  ## Configuration
309
313
 
310
314
  Admina uses `admina.yaml` as the primary config file (with `.env` fallback for backward compatibility):
@@ -23,7 +23,7 @@ from __future__ import annotations
23
23
 
24
24
  from admina.sdk import ComplianceKit, GovernedAgent, GovernedData, GovernedModel
25
25
 
26
- __version__ = "0.12.0"
26
+ __version__ = "0.12.1"
27
27
 
28
28
  __all__ = [
29
29
  "__version__",
@@ -643,6 +643,7 @@ def _run_local(
643
643
  click.echo(f" http://{ip}:{port} ({label})")
644
644
  else:
645
645
  click.echo(f" Ready → http://{display_host}:{port}")
646
+ click.echo(" Dashboard sign-in: the API key (show it with `admina password show`)")
646
647
  if not no_browser:
647
648
  webbrowser.open(f"http://{display_host}:{port}")
648
649
  else:
@@ -330,6 +330,33 @@
330
330
  .link-card:hover { border-color: var(--teal); }
331
331
  .link-card .lc-label { font-size: 11px; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
332
332
  .link-card .lc-title { font-size: 14px; font-weight: 600; }
333
+
334
+ /* ── Sign-in ─────────────────────────────────────────── */
335
+ .signin {
336
+ position: fixed; inset: 0; z-index: 300; background: var(--bg);
337
+ display: flex; align-items: center; justify-content: center; padding: 24px;
338
+ }
339
+ .signin-card {
340
+ width: 100%; max-width: 420px; background: var(--surface);
341
+ border: 1px solid var(--border2); border-radius: 12px; padding: 28px;
342
+ }
343
+ .signin-card .logo { display: flex; align-items: center; gap: 10px; font-size: 18px; font-weight: 700; margin-bottom: 16px; }
344
+ .signin-card .logo img { width: 32px; height: 32px; object-fit: contain; }
345
+ .signin-card .logo .accent { color: var(--teal); }
346
+ .signin-card p { font-size: 13px; color: var(--muted); line-height: 1.6; margin-bottom: 16px; }
347
+ .signin-card code { font-family: var(--mono); font-size: 12px; color: var(--text); }
348
+ .signin-card label { display: block; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-bottom: 6px; }
349
+ .signin-card input {
350
+ width: 100%; padding: 9px 12px; border-radius: 6px; border: 1px solid var(--border2);
351
+ background: var(--surface2); color: var(--text); font-family: var(--mono); font-size: 13px;
352
+ }
353
+ .signin-card input:focus { outline: none; border-color: var(--teal); }
354
+ .signin-card button {
355
+ margin-top: 14px; width: 100%; padding: 9px 12px; border-radius: 6px; cursor: pointer;
356
+ background: var(--teal-dark); color: #fff; border: 1px solid var(--teal); font-size: 14px; font-weight: 600;
357
+ }
358
+ .signin-card button:disabled { opacity: 0.6; cursor: default; }
359
+ .signin-card .error { margin-top: 12px; font-size: 13px; color: var(--red); }
333
360
  </style>
334
361
  </head>
335
362
  <body>
@@ -339,6 +366,25 @@
339
366
  ════════════════════════════════════════════════════════ -->
340
367
  <div x-data="dashboard()" x-init="init()">
341
368
 
369
+ <!-- Sign-in: the API key is exchanged for a short-lived browser session -->
370
+ <div class="signin" x-show="needsLogin" x-cloak>
371
+ <form class="signin-card" @submit.prevent="login()">
372
+ <div class="logo">
373
+ <img src="heimdall.png" alt="">
374
+ <span><span class="accent">Admina</span> Governance Dashboard</span>
375
+ </div>
376
+ <p>
377
+ Sign in with this instance's API key. In an <code>admina dev</code> project,
378
+ run <code>admina password show</code> to display it.
379
+ </p>
380
+ <label for="admina-api-key">API key</label>
381
+ <input id="admina-api-key" type="password" x-model="loginKey" x-ref="loginKey"
382
+ autocomplete="off" autocapitalize="off" spellcheck="false" required>
383
+ <button type="submit" :disabled="loginBusy || !loginKey">Sign in</button>
384
+ <div class="error" x-show="loginError" x-text="loginError" role="alert"></div>
385
+ </form>
386
+ </div>
387
+
342
388
  <!-- Top Bar -->
343
389
  <div class="topbar">
344
390
  <div class="logo">
@@ -349,6 +395,9 @@
349
395
  <span class="ts" x-text="lastUpdate"></span>
350
396
  <div class="sep"></div>
351
397
  <button class="refresh-btn" @click="refresh()">Refresh</button>
398
+ <template x-if="sessionActive">
399
+ <button class="refresh-btn" @click="logout()">Sign out</button>
400
+ </template>
352
401
  <div class="sep"></div>
353
402
  <div class="status">
354
403
  <div class="dot" :style="{ background: healthy ? 'var(--green)' : 'var(--red)' }"></div>
@@ -807,7 +856,9 @@
807
856
  * Admina Governance Dashboard — Alpine.js application.
808
857
  *
809
858
  * Reads from the Phase 3.1 API endpoints. Does NOT write.
810
- * All API calls go through the nginx reverse proxy (/api/*).
859
+ * All API calls go to /api/* on the same origin: the proxy itself in
860
+ * `admina dev` local mode (browser session after sign-in), or the nginx
861
+ * reverse proxy of the Docker stack (which adds the API key).
811
862
  */
812
863
  function dashboard() {
813
864
  return {
@@ -824,6 +875,12 @@ function dashboard() {
824
875
  modelData: {},
825
876
  oisg: {},
826
877
  showAssessmentHelp: false,
878
+ needsLogin: false,
879
+ sessionActive: false,
880
+ loginKey: '',
881
+ loginError: '',
882
+ loginBusy: false,
883
+ _started: false,
827
884
  _ws: null,
828
885
  _feedId: 0,
829
886
  _refreshTimer: null,
@@ -920,17 +977,96 @@ function dashboard() {
920
977
 
921
978
  // ── Init ───────────────────────────────────────────
922
979
  async init() {
923
- await this.refresh();
980
+ if (await this.checkSession()) this.start();
981
+ },
982
+
983
+ start() {
984
+ // Alpine calls init() both automatically and via x-init: start once.
985
+ if (this._started) return;
986
+ this._started = true;
987
+ this.refresh();
924
988
  this.connectWebSocket();
925
989
  // Auto-refresh every 10s
926
990
  this._refreshTimer = setInterval(() => this.refresh(), 10000);
927
991
  },
928
992
 
993
+ // ── Session ────────────────────────────────────────
994
+ // Serving this page never signs the browser in. The API key is exchanged
995
+ // once for a short-lived HttpOnly session cookie that only the read-only
996
+ // dashboard API accepts. Behind the Docker nginx the proxy already
997
+ // receives the key, so no sign-in is needed there.
998
+ async checkSession() {
999
+ try {
1000
+ const r = await fetch('/api/dashboard/session', { cache: 'no-store' });
1001
+ if (r.status === 401) { this.showLogin(''); return false; }
1002
+ if (r.ok) this.sessionActive = !!(await r.json()).session;
1003
+ } catch (e) { /* network error: start anyway and show Disconnected */ }
1004
+ return true;
1005
+ },
1006
+
1007
+ async login() {
1008
+ if (!this.loginKey) return;
1009
+ this.loginBusy = true;
1010
+ this.loginError = '';
1011
+ try {
1012
+ const r = await fetch('/api/dashboard/session', {
1013
+ method: 'POST',
1014
+ headers: { 'X-API-Key': this.loginKey },
1015
+ cache: 'no-store',
1016
+ });
1017
+ if (r.ok) {
1018
+ this.loginKey = '';
1019
+ this.needsLogin = false;
1020
+ this.sessionActive = true;
1021
+ this.start();
1022
+ } else if (r.status === 401) {
1023
+ this.loginError = 'Invalid API key.';
1024
+ } else {
1025
+ let detail = '';
1026
+ try { detail = (await r.json()).detail || ''; } catch (e) { /* not JSON */ }
1027
+ this.loginError = detail || `Sign-in failed (HTTP ${r.status}).`;
1028
+ }
1029
+ } catch (e) {
1030
+ this.loginError = 'Admina is not reachable.';
1031
+ } finally {
1032
+ this.loginBusy = false;
1033
+ }
1034
+ },
1035
+
1036
+ async logout() {
1037
+ try { await fetch('/api/dashboard/session', { method: 'DELETE', cache: 'no-store' }); }
1038
+ catch (e) { /* cookie expires on its own */ }
1039
+ this.showLogin('');
1040
+ },
1041
+
1042
+ showLogin(message) {
1043
+ clearInterval(this._refreshTimer);
1044
+ this._refreshTimer = null;
1045
+ this._started = false;
1046
+ if (this._ws) {
1047
+ const ws = this._ws;
1048
+ this._ws = null;
1049
+ ws.onclose = null;
1050
+ ws.close();
1051
+ }
1052
+ this.wsConnected = false;
1053
+ this.sessionActive = false;
1054
+ this.feedEvents = [];
1055
+ this.loginError = message;
1056
+ this.needsLogin = true;
1057
+ this.$nextTick(() => this.$refs.loginKey && this.$refs.loginKey.focus());
1058
+ },
1059
+
929
1060
  // ── Fetch all data ─────────────────────────────────
930
1061
  async refresh() {
931
1062
  // allSettled so one failing endpoint never blanks the whole dashboard.
932
1063
  const okJson = async (url) => {
933
1064
  const r = await fetch(url);
1065
+ if (r.status === 401) {
1066
+ const err = new Error(`${url} -> 401`);
1067
+ err.unauthorized = true;
1068
+ throw err;
1069
+ }
934
1070
  if (!r.ok) throw new Error(`${url} -> ${r.status}`);
935
1071
  return r.json();
936
1072
  };
@@ -944,6 +1080,10 @@ function dashboard() {
944
1080
  okJson('/api/dashboard/models'),
945
1081
  okJson('/api/dashboard/oisg'),
946
1082
  ]);
1083
+ if (results.some(r => r.status === 'rejected' && r.reason && r.reason.unauthorized)) {
1084
+ this.showLogin('Your session has expired. Sign in again.');
1085
+ return;
1086
+ }
947
1087
  const [scoreRes, compRes, sovRes, statsRes, feedRes, infraRes, modelsRes, oisgRes] =
948
1088
  results.map(r => r.status === 'fulfilled' ? r.value : null);
949
1089
 
@@ -998,8 +1138,9 @@ function dashboard() {
998
1138
 
999
1139
  this._ws.onclose = () => {
1000
1140
  this.wsConnected = false;
1141
+ if (this.needsLogin) return;
1001
1142
  console.log('[Admina] WebSocket disconnected, reconnecting in 3s...');
1002
- setTimeout(() => this.connectWebSocket(), 3000);
1143
+ setTimeout(() => { if (!this.needsLogin) this.connectWebSocket(); }, 3000);
1003
1144
  };
1004
1145
 
1005
1146
  this._ws.onerror = () => {
@@ -1007,7 +1148,7 @@ function dashboard() {
1007
1148
  };
1008
1149
  } catch (e) {
1009
1150
  this.wsConnected = false;
1010
- setTimeout(() => this.connectWebSocket(), 5000);
1151
+ setTimeout(() => { if (!this.needsLogin) this.connectWebSocket(); }, 5000);
1011
1152
  }
1012
1153
  },
1013
1154
 
@@ -16,17 +16,17 @@
16
16
 
17
17
  Simple header-based authentication using constant-time comparison.
18
18
  Supports both ``X-API-Key`` and ``Authorization: Bearer`` headers.
19
+
20
+ Cookies are never accepted here. The bundled dashboard's browser session is
21
+ handled by the proxy itself, which admits it only on the read-only dashboard
22
+ API (see ``admina.proxy.dashboard_session``).
19
23
  """
20
24
 
21
25
  from __future__ import annotations
22
26
 
23
- import base64
24
- import hashlib
25
- import hmac
26
27
  import logging
27
28
  import os
28
29
  import secrets
29
- import time
30
30
  from typing import Any
31
31
 
32
32
  from admina.plugins.base import BaseAuthProvider
@@ -94,12 +94,13 @@ class APIKeyAuthProvider(BaseAuthProvider):
94
94
  if path in self._exempt_paths:
95
95
  return {"user_id": "anonymous", "roles": ["public"], "metadata": {}}
96
96
 
97
- # Accept a raw API key from headers/Bearer, or a signed session cookie.
97
+ # Accept only the raw API key from X-API-Key or Authorization: Bearer.
98
98
  provided = self._extract_key(request)
99
- if provided and secrets.compare_digest(provided, self._api_key):
99
+ # Compare bytes: compare_digest raises TypeError on non-ASCII str.
100
+ if provided and secrets.compare_digest(
101
+ provided.encode("utf-8"), self._api_key.encode("utf-8")
102
+ ):
100
103
  return {"user_id": "api_key_user", "roles": ["authenticated"], "metadata": {}}
101
- if self._verify_session_cookie(self._extract_session_cookie(request)):
102
- return {"user_id": "dashboard_session", "roles": ["authenticated"], "metadata": {}}
103
104
  raise PermissionError("Invalid or missing API key")
104
105
 
105
106
  async def authorize(
@@ -128,12 +129,7 @@ class APIKeyAuthProvider(BaseAuthProvider):
128
129
 
129
130
  @staticmethod
130
131
  def _extract_key(request: Any) -> str:
131
- """Extract the raw API key from request headers (X-API-Key or Bearer).
132
-
133
- The admina_session cookie is NOT extracted here — it carries a signed
134
- token, not the raw key, and is handled separately by
135
- ``_extract_session_cookie`` + ``_verify_session_cookie``.
136
- """
132
+ """Extract the raw API key from request headers (X-API-Key or Bearer)."""
137
133
  if isinstance(request, dict):
138
134
  headers = request.get("headers", {})
139
135
  else:
@@ -145,36 +141,3 @@ class APIKeyAuthProvider(BaseAuthProvider):
145
141
  if auth.startswith("Bearer "):
146
142
  key = auth.removeprefix("Bearer ").strip()
147
143
  return key
148
-
149
- @staticmethod
150
- def _extract_session_cookie(request: Any) -> str:
151
- """Extract the admina_session cookie value from a request."""
152
- if isinstance(request, dict):
153
- cookies = request.get("cookies", {})
154
- else:
155
- cookies = dict(getattr(request, "cookies", {}))
156
- return cookies.get("admina_session", "")
157
-
158
- def _verify_session_cookie(self, token: str) -> bool:
159
- """Verify a signed admina_session cookie against the configured key.
160
-
161
- Mirrors the proxy's dashboard token format (HMAC-SHA256 over the
162
- expiry). Replicated locally so this plugin does not depend on the
163
- proxy package.
164
- """
165
- if not self._api_key or not token:
166
- return False
167
- try:
168
- raw = base64.urlsafe_b64decode(token.encode("ascii")).decode("utf-8")
169
- payload, sig = raw.rsplit(".", 1)
170
- except (ValueError, UnicodeDecodeError):
171
- return False
172
- expected = hmac.new(
173
- self._api_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
174
- ).hexdigest()
175
- if not secrets.compare_digest(sig, expected):
176
- return False
177
- try:
178
- return int(time.time()) < int(payload)
179
- except ValueError:
180
- return False
@@ -175,6 +175,7 @@ def create_dashboard_endpoints(
175
175
  get_governance_guards: Any = None,
176
176
  get_config: Any = None,
177
177
  verify_credential: Any = None,
178
+ session_expiry: Any = None,
178
179
  ) -> APIRouter:
179
180
  """Create a new APIRouter with dashboard endpoints.
180
181
 
@@ -200,6 +201,11 @@ def create_dashboard_endpoints(
200
201
  Shared credential verifier for header/query/cookie auth. When
201
202
  provided, the WebSocket live endpoint routes all auth through it
202
203
  so the signed session cookie is validated correctly.
204
+ session_expiry: Callable(headers, query_params, cookies) -> int | None.
205
+ Expiry (Unix seconds) of the browser session that authorized a
206
+ live-feed connection, or None when the API key itself was
207
+ presented. A connection opened with a session is closed when the
208
+ session expires.
203
209
 
204
210
  Returns:
205
211
  The configured APIRouter.
@@ -231,34 +237,49 @@ def create_dashboard_endpoints(
231
237
  return
232
238
 
233
239
  expected = getattr(settings, "ADMINA_API_KEY", "") or ""
240
+ # Unix time at which the connection must end: set when a browser
241
+ # session (not the API key) authorized it, so the feed never
242
+ # outlives the session.
243
+ deadline: int | None = None
234
244
  if expected:
235
- ok = (
236
- bool(
237
- verify_credential(
238
- headers=dict(websocket.headers),
239
- query_params=dict(websocket.query_params),
240
- cookies=dict(websocket.cookies),
241
- )
242
- )
243
- if verify_credential is not None
244
- else False
245
- )
245
+ credentials = {
246
+ "headers": dict(websocket.headers),
247
+ "query_params": dict(websocket.query_params),
248
+ "cookies": dict(websocket.cookies),
249
+ }
250
+ ok = bool(verify_credential(**credentials)) if verify_credential is not None else False
246
251
  if not ok:
247
252
  await websocket.close(code=1008)
248
253
  return
254
+ if session_expiry is not None:
255
+ deadline = session_expiry(**credentials)
249
256
  elif not getattr(settings, "ALLOW_UNAUTHENTICATED", False):
250
257
  await websocket.close(code=1008)
251
258
  return
252
259
 
253
260
  await websocket.accept()
254
261
  _ws_clients.add(websocket)
262
+ expired = False
255
263
  try:
256
264
  while True:
257
- await websocket.receive_text()
265
+ if deadline is None:
266
+ await websocket.receive_text()
267
+ continue
268
+ remaining = deadline - time.time()
269
+ if remaining <= 0:
270
+ expired = True
271
+ break
272
+ try:
273
+ await asyncio.wait_for(websocket.receive_text(), timeout=remaining)
274
+ except TimeoutError:
275
+ expired = True
276
+ break
258
277
  except WebSocketDisconnect:
259
278
  pass
260
279
  finally:
261
280
  _ws_clients.discard(websocket)
281
+ if expired:
282
+ await websocket.close(code=1008)
262
283
 
263
284
  @router.get("/score")
264
285
  async def dashboard_score() -> dict[str, Any]:
@@ -89,13 +89,25 @@ class Settings(BaseSettings):
89
89
  CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8080"
90
90
 
91
91
  # Auth — set a strong random key in production: openssl rand -hex 32
92
- # If empty, auth is disabled (local development only).
92
+ # If empty, protected routes are refused (fail-closed) unless
93
+ # ALLOW_UNAUTHENTICATED=true is set explicitly (local development only).
93
94
  ADMINA_API_KEY: str = ""
94
95
  ALLOW_UNAUTHENTICATED: bool = False
95
- # Set the `Secure` flag on the dashboard session cookie so the browser
96
- # only sends it over HTTPS. Default False because local dev is HTTP;
97
- # set DASHBOARD_COOKIE_SECURE=true in any production / TLS deployment.
96
+ # Force the `Secure` flag on the dashboard session cookie. The flag is
97
+ # already set automatically when the request arrives over HTTPS; set
98
+ # DASHBOARD_COOKIE_SECURE=true when TLS terminates at a reverse proxy
99
+ # that does not forward the original scheme.
98
100
  DASHBOARD_COOKIE_SECURE: bool = False
101
+ # Bundled dashboard (GET /, /vendor/*, sign-in at /api/dashboard/session).
102
+ # false removes the SPA and browser sign-in; the /api/dashboard/* data
103
+ # API stays available with the API key. `dashboard.enabled: false` in
104
+ # admina.yaml has the same effect.
105
+ ADMINA_DASHBOARD_ENABLED: bool = True
106
+ # Lifetime in seconds of a dashboard browser session (60 s to 12 h).
107
+ # The browser signs in again with the API key when it expires.
108
+ ADMINA_DASHBOARD_SESSION_TTL: int = Field(default=3600, ge=60, le=43200)
109
+ # Public OpenAPI documentation (/docs, /redoc, /openapi.json).
110
+ ADMINA_API_DOCS_ENABLED: bool = True
99
111
 
100
112
  # Rate limiting (per session, requires Redis)
101
113
  RATE_LIMIT_MAX_REQUESTS: int = 100 # requests per window
@@ -0,0 +1,133 @@
1
+ # Copyright © 2025–2026 Stefano Noferi & Admina contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Admina — dashboard browser sessions.
16
+
17
+ The bundled dashboard is a browser application: it cannot hold the API key
18
+ in a header without exposing it to page scripts. Instead, the browser
19
+ presents the key once to ``POST /api/dashboard/session`` and receives a
20
+ short-lived session cookie. This module holds the two rules that make that
21
+ safe:
22
+
23
+ * **What a session token is.** A signed, expiring token bound to the current
24
+ ``ADMINA_API_KEY``. The signature uses a key derived from the API key with a
25
+ fixed purpose label, so a token cannot be confused with any other HMAC
26
+ computed from the same secret, and rotating the API key invalidates every
27
+ outstanding session. The API key itself never leaves the server.
28
+
29
+ * **Where a session is accepted.** Only on read-only requests to the
30
+ dashboard API (``/api/dashboard/*`` and ``/api/stats``), plus reading and
31
+ ending the session itself. The MCP proxy, the OpenAI-compatible gateway and
32
+ the integration and compliance APIs always require the API key.
33
+
34
+ Token format (base64url, unpadded)::
35
+
36
+ v2.<expiry-unix-seconds>.<nonce-hex>.<hmac-sha256-hex>
37
+
38
+ where the HMAC covers ``v2.<expiry>.<nonce>``.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import base64
44
+ import binascii
45
+ import hashlib
46
+ import hmac
47
+ import secrets
48
+ import time
49
+
50
+ COOKIE_NAME = "admina_dashboard_session"
51
+ #: Cookie name used by releases up to 0.12.0. Never accepted; only cleared.
52
+ LEGACY_COOKIE_NAME = "admina_session"
53
+ #: Browsers send the cookie only to the API, never to ``/mcp`` or ``/v1``.
54
+ COOKIE_PATH = "/api/"
55
+ SESSION_PATH = "/api/dashboard/session"
56
+
57
+ DEFAULT_TTL_SECONDS = 3600
58
+ MIN_TTL_SECONDS = 60
59
+ MAX_TTL_SECONDS = 12 * 3600
60
+
61
+ _VERSION = "v2"
62
+ _PURPOSE = b"admina/dashboard-session/v2"
63
+ _MAX_TOKEN_LEN = 256
64
+
65
+ _READ_METHODS = frozenset({"GET", "HEAD"})
66
+ _SESSION_METHODS = frozenset({"GET", "HEAD", "DELETE"})
67
+ _SCOPE_PREFIX = "/api/dashboard/"
68
+ _SCOPE_PATHS = frozenset({"/api/stats"})
69
+
70
+
71
+ def _signing_key(api_key: str) -> bytes:
72
+ return hmac.new(api_key.encode("utf-8"), _PURPOSE, hashlib.sha256).digest()
73
+
74
+
75
+ def _sign(api_key: str, payload: str) -> str:
76
+ return hmac.new(_signing_key(api_key), payload.encode("utf-8"), hashlib.sha256).hexdigest()
77
+
78
+
79
+ def issue_token(api_key: str, *, ttl: int, now: int | None = None) -> str:
80
+ """Mint a session token valid for *ttl* seconds.
81
+
82
+ Raises:
83
+ ValueError: if no API key is configured or *ttl* is out of range.
84
+ """
85
+ if not api_key:
86
+ raise ValueError("a dashboard session requires ADMINA_API_KEY")
87
+ if not MIN_TTL_SECONDS <= ttl <= MAX_TTL_SECONDS:
88
+ raise ValueError(
89
+ f"session ttl must be between {MIN_TTL_SECONDS} and {MAX_TTL_SECONDS} seconds"
90
+ )
91
+ exp = (now if now is not None else int(time.time())) + ttl
92
+ payload = f"{_VERSION}.{exp}.{secrets.token_hex(16)}"
93
+ raw = f"{payload}.{_sign(api_key, payload)}".encode("ascii")
94
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
95
+
96
+
97
+ def token_expiry(api_key: str, token: str, *, now: int | None = None) -> int | None:
98
+ """Return the expiry of a valid, unexpired token, else ``None``."""
99
+ if not api_key or not token or len(token) > _MAX_TOKEN_LEN:
100
+ return None
101
+ try:
102
+ padded = token + "=" * (-len(token) % 4)
103
+ raw = base64.urlsafe_b64decode(padded.encode("ascii")).decode("ascii")
104
+ except (ValueError, UnicodeError, binascii.Error):
105
+ return None
106
+ parts = raw.split(".")
107
+ if len(parts) != 4 or parts[0] != _VERSION:
108
+ return None
109
+ payload, sig = ".".join(parts[:3]), parts[3]
110
+ if not secrets.compare_digest(sig, _sign(api_key, payload)):
111
+ return None
112
+ try:
113
+ exp = int(parts[1])
114
+ except ValueError:
115
+ return None
116
+ if (now if now is not None else int(time.time())) >= exp:
117
+ return None
118
+ return exp
119
+
120
+
121
+ def verify_token(api_key: str, token: str, *, now: int | None = None) -> bool:
122
+ """True if *token* is a valid, unexpired session for *api_key*."""
123
+ return token_expiry(api_key, token, now=now) is not None
124
+
125
+
126
+ def session_allowed(method: str, path: str) -> bool:
127
+ """True if a dashboard session may authenticate *method* on *path*."""
128
+ method = method.upper()
129
+ if path == SESSION_PATH:
130
+ return method in _SESSION_METHODS
131
+ if method not in _READ_METHODS:
132
+ return False
133
+ return path.startswith(_SCOPE_PREFIX) or path in _SCOPE_PATHS