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.
- {admina_framework-0.12.0/admina_framework.egg-info → admina_framework-0.12.1}/PKG-INFO +5 -1
- {admina_framework-0.12.0 → admina_framework-0.12.1}/README.md +4 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/__init__.py +1 -1
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/main.py +1 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/dashboard/static/index.html +145 -4
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/auth/apikey.py +10 -47
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/api/dashboard.py +33 -12
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/config.py +16 -4
- admina_framework-0.12.1/admina/proxy/dashboard_session.py +133 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/main.py +190 -81
- {admina_framework-0.12.0 → admina_framework-0.12.1/admina_framework.egg-info}/PKG-INFO +5 -1
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina_framework.egg-info/SOURCES.txt +2 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/pyproject.toml +1 -1
- admina_framework-0.12.1/tests/test_proxy_dashboard_session.py +800 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_security.py +25 -72
- {admina_framework-0.12.0 → admina_framework-0.12.1}/LICENSE +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/NOTICE +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/commands/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/admina.yaml.j2 +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/docker-compose.yml.j2 +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/env.j2 +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/main.py.j2 +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/plugin.py.j2 +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/plugin_pyproject.toml.j2 +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/plugin_readme.md.j2 +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/cli/templates/plugin_test.py.j2 +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/core/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/core/config.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/core/event_bus.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/core/secrets.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/core/types.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/dashboard/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/dashboard/static/heimdall.png +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/dashboard/static/vendor/alpinejs.min.js +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/coordination.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/egress.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/fingerprint.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/firewall.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/agent_security/loop_breaker.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/ai_infra/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/ai_infra/llm_engine.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/ai_infra/rag.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/ai_infra/webui.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/cross_regulation.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/eu_ai_act.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/forensic.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/gdpr.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/nis2.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/oisg.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/compliance/otel.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/data_sovereignty/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/data_sovereignty/classification.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/data_sovereignty/pii.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/data_sovereignty/residency.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/domains/governance.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/engines/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/engines/presidio.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/_engines.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/cheshirecat/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/cheshirecat/admina-plugin/admina_governance.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/crewai/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/crewai/callbacks.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/langchain/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/langchain/callbacks.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/n8n/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/integrations/openclaw/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/base.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/_streaming.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/anthropic.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/bedrock.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/gemini.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/mistral.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/ollama.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/openai.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/adapters/vllm.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/alerts/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/alerts/log.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/alerts/webhook.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/auth/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/compliance/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/compliance/eu_ai_act.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/connectors/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/connectors/chromadb.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/connectors/filesystem.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/forensic/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/forensic/filesystem.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/guards/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/guards/guardrailsai_guard.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/pii/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/pii/spacy_regex.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/transports/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/transports/http_rest.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/builtin/transports/mcp.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/plugins/registry.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/api/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/api/gateway.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/api/integration.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/engine_bridge.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/governance.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/multi_upstream.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/proxy/state.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/py.typed +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/baselines/baseline.json +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora/SHA256SUMS +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora/coordination.jsonl +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora/injection.jsonl +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora/loop.jsonl +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora/pii.jsonl +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/corpora.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/detectors.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/gate.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/metrics.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/report.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/redteam/runner.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/__init__.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/_compat.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/compliance_kit.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/errors.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/governed_agent.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/governed_data.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/governed_model.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/retry.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina/sdk/streaming.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina_framework.egg-info/dependency_links.txt +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina_framework.egg-info/entry_points.txt +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina_framework.egg-info/requires.txt +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/admina_framework.egg-info/top_level.txt +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/setup.cfg +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_benchmark_14us.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_coordination.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_coordination_cli.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_coordination_corpus.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_coordination_wiring.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_core_config.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_domains.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_domains_governance.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_egress_analyze.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_egress_cli.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_egress_pipeline.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_egress_policy.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_egress_surfaces.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_engines.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_error_disclosure.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_fingerprint.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_firewall_parity.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_forensic_blackbox.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_gateway_config.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_gateway_endpoints.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_gateway_guard_fail_mode.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_gateway_helpers.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_gateway_mount.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_governance_pipeline.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_guard_fail_mode.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_mcp_transport.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_presidio_engine.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_forensic_would_action.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_governance_mode_config.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_guard_fail_mode.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_mcp_response_pii.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_metrics_endpoint.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_proxy_plugins.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_redteam_efficacy.py +0 -0
- {admina_framework-0.12.0 → admina_framework-0.12.1}/tests/test_redteam_lib.py +0 -0
- {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.
|
|
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):
|
|
@@ -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
|
|
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.
|
|
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
|
|
97
|
+
# Accept only the raw API key from X-API-Key or Authorization: Bearer.
|
|
98
98
|
provided = self._extract_key(request)
|
|
99
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
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
|