admina-framework 0.9.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 (102) hide show
  1. admina/__init__.py +34 -0
  2. admina/cli/__init__.py +14 -0
  3. admina/cli/commands/__init__.py +14 -0
  4. admina/cli/main.py +1522 -0
  5. admina/cli/templates/admina.yaml.j2 +77 -0
  6. admina/cli/templates/docker-compose.yml.j2 +254 -0
  7. admina/cli/templates/env.j2 +10 -0
  8. admina/cli/templates/main.py.j2 +95 -0
  9. admina/cli/templates/plugin.py.j2 +145 -0
  10. admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
  11. admina/cli/templates/plugin_readme.md.j2 +27 -0
  12. admina/cli/templates/plugin_test.py.j2 +48 -0
  13. admina/core/__init__.py +14 -0
  14. admina/core/config.py +497 -0
  15. admina/core/event_bus.py +112 -0
  16. admina/core/secrets.py +257 -0
  17. admina/core/types.py +146 -0
  18. admina/dashboard/__init__.py +8 -0
  19. admina/dashboard/static/heimdall.png +0 -0
  20. admina/dashboard/static/index.html +1045 -0
  21. admina/dashboard/static/vendor/alpinejs.min.js +5 -0
  22. admina/domains/__init__.py +14 -0
  23. admina/domains/agent_security/__init__.py +41 -0
  24. admina/domains/agent_security/firewall.py +634 -0
  25. admina/domains/agent_security/loop_breaker.py +176 -0
  26. admina/domains/ai_infra/__init__.py +79 -0
  27. admina/domains/ai_infra/llm_engine.py +477 -0
  28. admina/domains/ai_infra/rag.py +817 -0
  29. admina/domains/ai_infra/webui.py +292 -0
  30. admina/domains/compliance/__init__.py +109 -0
  31. admina/domains/compliance/cross_regulation.py +314 -0
  32. admina/domains/compliance/eu_ai_act.py +367 -0
  33. admina/domains/compliance/forensic.py +380 -0
  34. admina/domains/compliance/gdpr.py +331 -0
  35. admina/domains/compliance/nis2.py +258 -0
  36. admina/domains/compliance/oisg.py +658 -0
  37. admina/domains/compliance/otel.py +101 -0
  38. admina/domains/data_sovereignty/__init__.py +42 -0
  39. admina/domains/data_sovereignty/classification.py +102 -0
  40. admina/domains/data_sovereignty/pii.py +260 -0
  41. admina/domains/data_sovereignty/residency.py +121 -0
  42. admina/integrations/__init__.py +14 -0
  43. admina/integrations/_engines.py +63 -0
  44. admina/integrations/cheshirecat/__init__.py +13 -0
  45. admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
  46. admina/integrations/crewai/__init__.py +13 -0
  47. admina/integrations/crewai/callbacks.py +347 -0
  48. admina/integrations/langchain/__init__.py +13 -0
  49. admina/integrations/langchain/callbacks.py +341 -0
  50. admina/integrations/n8n/__init__.py +14 -0
  51. admina/integrations/openclaw/__init__.py +14 -0
  52. admina/plugins/__init__.py +49 -0
  53. admina/plugins/base.py +633 -0
  54. admina/plugins/builtin/__init__.py +14 -0
  55. admina/plugins/builtin/adapters/__init__.py +14 -0
  56. admina/plugins/builtin/adapters/ollama.py +120 -0
  57. admina/plugins/builtin/adapters/openai.py +138 -0
  58. admina/plugins/builtin/alerts/__init__.py +14 -0
  59. admina/plugins/builtin/alerts/log.py +66 -0
  60. admina/plugins/builtin/alerts/webhook.py +102 -0
  61. admina/plugins/builtin/auth/__init__.py +14 -0
  62. admina/plugins/builtin/auth/apikey.py +138 -0
  63. admina/plugins/builtin/compliance/__init__.py +14 -0
  64. admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
  65. admina/plugins/builtin/connectors/__init__.py +14 -0
  66. admina/plugins/builtin/connectors/chromadb.py +137 -0
  67. admina/plugins/builtin/connectors/filesystem.py +111 -0
  68. admina/plugins/builtin/forensic/__init__.py +14 -0
  69. admina/plugins/builtin/forensic/filesystem.py +163 -0
  70. admina/plugins/builtin/forensic/minio.py +180 -0
  71. admina/plugins/builtin/guards/__init__.py +0 -0
  72. admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
  73. admina/plugins/builtin/pii/__init__.py +14 -0
  74. admina/plugins/builtin/pii/spacy_regex.py +160 -0
  75. admina/plugins/builtin/transports/__init__.py +14 -0
  76. admina/plugins/builtin/transports/http_rest.py +97 -0
  77. admina/plugins/builtin/transports/mcp.py +173 -0
  78. admina/plugins/registry.py +356 -0
  79. admina/proxy/__init__.py +15 -0
  80. admina/proxy/api/__init__.py +17 -0
  81. admina/proxy/api/dashboard.py +925 -0
  82. admina/proxy/api/integration.py +153 -0
  83. admina/proxy/config.py +214 -0
  84. admina/proxy/engine_bridge.py +306 -0
  85. admina/proxy/governance.py +232 -0
  86. admina/proxy/main.py +1484 -0
  87. admina/proxy/multi_upstream.py +156 -0
  88. admina/proxy/state.py +97 -0
  89. admina/py.typed +0 -0
  90. admina/sdk/__init__.py +34 -0
  91. admina/sdk/_compat.py +43 -0
  92. admina/sdk/compliance_kit.py +359 -0
  93. admina/sdk/governed_agent.py +391 -0
  94. admina/sdk/governed_data.py +434 -0
  95. admina/sdk/governed_model.py +241 -0
  96. admina_framework-0.9.0.dist-info/METADATA +575 -0
  97. admina_framework-0.9.0.dist-info/RECORD +102 -0
  98. admina_framework-0.9.0.dist-info/WHEEL +5 -0
  99. admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
  100. admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
  101. admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
  102. admina_framework-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,120 @@
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 — Ollama model adapter.
16
+
17
+ Wraps the ``ollama`` Python client to provide local LLM inference
18
+ through the :class:`BaseModelAdapter` interface.
19
+
20
+ Requires: ``pip install ollama`` (optional dependency).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import os
27
+ import time
28
+ from typing import Any
29
+
30
+ from admina.plugins.base import BaseModelAdapter
31
+
32
+ logger = logging.getLogger("admina.plugins.adapters.ollama")
33
+
34
+
35
+ class OllamaAdapter(BaseModelAdapter):
36
+ """Model adapter for a local Ollama instance.
37
+
38
+ Args:
39
+ host: Ollama API base URL. Defaults to ``http://localhost:11434``.
40
+ default_model: Model name used when none is specified in ``send()``.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ host: str | None = None,
46
+ default_model: str | None = None,
47
+ ) -> None:
48
+ # Fall back to standard env vars so the plugin works out-of-the-box
49
+ # when the registry instantiates it with no explicit config.
50
+ self._host = host or os.environ.get("ADMINA_OLLAMA_HOST", "http://localhost:11434")
51
+ self._default_model = default_model or os.environ.get("ADMINA_OLLAMA_MODEL", "llama3.1:8b")
52
+ self._client: Any = None
53
+
54
+ # ── lazy client init ────────────────────────────────────────
55
+
56
+ def _get_client(self) -> Any:
57
+ """Lazily import and create the ollama client."""
58
+ if self._client is None:
59
+ try:
60
+ import ollama # type: ignore[import-untyped]
61
+
62
+ self._client = ollama.Client(host=self._host)
63
+ except ImportError as exc:
64
+ raise ImportError(
65
+ "The 'ollama' package is required for OllamaAdapter. "
66
+ "Install it with: pip install ollama"
67
+ ) from exc
68
+ return self._client
69
+
70
+ # ── BaseModelAdapter interface ──────────────────────────────
71
+
72
+ async def send(
73
+ self,
74
+ prompt: str,
75
+ context: Any = None,
76
+ **kwargs: Any,
77
+ ) -> dict:
78
+ """Send a prompt to Ollama and return the response.
79
+
80
+ Args:
81
+ prompt: The text prompt.
82
+ context: Optional system message.
83
+ **kwargs: Extra options forwarded to ``ollama.chat()``.
84
+ Supports ``model`` to override the default.
85
+
86
+ Returns:
87
+ ``{"text": str, "metadata": {"tokens": int, "latency_ms": float, "model": str}}``.
88
+ """
89
+ client = self._get_client()
90
+ model = kwargs.pop("model", self._default_model)
91
+
92
+ messages: list[dict[str, str]] = []
93
+ if context:
94
+ messages.append({"role": "system", "content": str(context)})
95
+ messages.append({"role": "user", "content": prompt})
96
+
97
+ start = time.monotonic()
98
+ response = client.chat(model=model, messages=messages, **kwargs)
99
+ latency_ms = (time.monotonic() - start) * 1_000
100
+
101
+ text = response.get("message", {}).get("content", "")
102
+ tokens = response.get("eval_count", 0) + response.get("prompt_eval_count", 0)
103
+
104
+ return {
105
+ "text": text,
106
+ "metadata": {
107
+ "tokens": tokens,
108
+ "latency_ms": round(latency_ms, 2),
109
+ "model": model,
110
+ },
111
+ }
112
+
113
+ def supports_model(self, model_name: str) -> bool:
114
+ """Return ``True`` for any model name (Ollama pulls on demand)."""
115
+ return True
116
+
117
+ @property
118
+ def name(self) -> str:
119
+ """Adapter name."""
120
+ return "ollama"
@@ -0,0 +1,138 @@
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 — OpenAI model adapter.
16
+
17
+ Wraps the ``openai`` Python client to provide inference through
18
+ OpenAI-compatible APIs (OpenAI, Azure OpenAI, vLLM, LiteLLM, etc.).
19
+
20
+ Requires: ``pip install openai`` (optional dependency).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import os
27
+ import time
28
+ from typing import Any
29
+
30
+ from admina.plugins.base import BaseModelAdapter
31
+
32
+ logger = logging.getLogger("admina.plugins.adapters.openai")
33
+
34
+
35
+ class OpenAIAdapter(BaseModelAdapter):
36
+ """Model adapter for OpenAI-compatible APIs.
37
+
38
+ Args:
39
+ api_key: OpenAI API key. Falls back to ``OPENAI_API_KEY`` env var.
40
+ base_url: Override for Azure / local endpoints.
41
+ default_model: Default model name.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ api_key: str | None = None,
47
+ base_url: str | None = None,
48
+ default_model: str | None = None,
49
+ ) -> None:
50
+ # ADMINA_OPENAI_* takes precedence over OPENAI_* env vars so the
51
+ # operator can use a different OpenAI account for the governance
52
+ # plane vs. the application.
53
+ self._api_key = (
54
+ api_key or os.environ.get("ADMINA_OPENAI_API_KEY") or os.environ.get("OPENAI_API_KEY")
55
+ )
56
+ self._base_url = (
57
+ base_url
58
+ or os.environ.get("ADMINA_OPENAI_BASE_URL")
59
+ or os.environ.get("OPENAI_BASE_URL")
60
+ )
61
+ self._default_model = default_model or os.environ.get("ADMINA_OPENAI_MODEL", "gpt-4o")
62
+ self._client: Any = None
63
+
64
+ # ── lazy client init ────────────────────────────────────────
65
+
66
+ def _get_client(self) -> Any:
67
+ """Lazily import and create the OpenAI client."""
68
+ if self._client is None:
69
+ try:
70
+ import openai # type: ignore[import-untyped]
71
+
72
+ kwargs: dict[str, Any] = {}
73
+ if self._api_key:
74
+ kwargs["api_key"] = self._api_key
75
+ if self._base_url:
76
+ kwargs["base_url"] = self._base_url
77
+ self._client = openai.OpenAI(**kwargs)
78
+ except ImportError as exc:
79
+ raise ImportError(
80
+ "The 'openai' package is required for OpenAIAdapter. "
81
+ "Install it with: pip install openai"
82
+ ) from exc
83
+ return self._client
84
+
85
+ # ── BaseModelAdapter interface ──────────────────────────────
86
+
87
+ async def send(
88
+ self,
89
+ prompt: str,
90
+ context: Any = None,
91
+ **kwargs: Any,
92
+ ) -> dict:
93
+ """Send a prompt to an OpenAI-compatible API.
94
+
95
+ Args:
96
+ prompt: The text prompt.
97
+ context: Optional system message.
98
+ **kwargs: Extra options forwarded to ``chat.completions.create()``.
99
+ Supports ``model`` to override the default.
100
+
101
+ Returns:
102
+ ``{"text": str, "metadata": {"tokens": int, "latency_ms": float, "model": str}}``.
103
+ """
104
+ client = self._get_client()
105
+ model = kwargs.pop("model", self._default_model)
106
+
107
+ messages: list[dict[str, str]] = []
108
+ if context:
109
+ messages.append({"role": "system", "content": str(context)})
110
+ messages.append({"role": "user", "content": prompt})
111
+
112
+ start = time.monotonic()
113
+ response = client.chat.completions.create(model=model, messages=messages, **kwargs)
114
+ latency_ms = (time.monotonic() - start) * 1_000
115
+
116
+ choice = response.choices[0] if response.choices else None
117
+ text = choice.message.content if choice else ""
118
+ usage = response.usage
119
+ tokens = (usage.total_tokens if usage else 0) or 0
120
+
121
+ return {
122
+ "text": text or "",
123
+ "metadata": {
124
+ "tokens": tokens,
125
+ "latency_ms": round(latency_ms, 2),
126
+ "model": model,
127
+ },
128
+ }
129
+
130
+ def supports_model(self, model_name: str) -> bool:
131
+ """Return ``True`` for models matching known OpenAI prefixes."""
132
+ prefixes = ("gpt-", "o1", "o3", "chatgpt-", "text-", "davinci")
133
+ return any(model_name.startswith(p) for p in prefixes)
134
+
135
+ @property
136
+ def name(self) -> str:
137
+ """Adapter name."""
138
+ return "openai"
@@ -0,0 +1,14 @@
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
+
@@ -0,0 +1,66 @@
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 — Logging alert channel.
16
+
17
+ Sends governance alerts to the Python logging system.
18
+ Always available, zero dependencies.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+
25
+ from admina.plugins.base import BaseAlertChannel
26
+
27
+ logger = logging.getLogger("admina.alerts")
28
+
29
+
30
+ class LogAlertChannel(BaseAlertChannel):
31
+ """Alert channel that writes to Python logging.
32
+
33
+ Args:
34
+ log_level: Logging level for alerts. Defaults to ``WARNING``.
35
+ """
36
+
37
+ channel_name = "log"
38
+
39
+ def __init__(self, log_level: int = logging.WARNING) -> None:
40
+ self._log_level = log_level
41
+
42
+ async def send_alert(self, alert: dict) -> bool:
43
+ """Log a governance alert.
44
+
45
+ Args:
46
+ alert: Alert dict with ``level``, ``domain``, ``summary``, etc.
47
+
48
+ Returns:
49
+ Always ``True``.
50
+ """
51
+ level_map = {
52
+ "LOW": logging.INFO,
53
+ "MEDIUM": logging.WARNING,
54
+ "HIGH": logging.ERROR,
55
+ "CRITICAL": logging.CRITICAL,
56
+ }
57
+ log_level = level_map.get(alert.get("level", ""), self._log_level)
58
+
59
+ logger.log(
60
+ log_level,
61
+ "[%s] %s — %s",
62
+ alert.get("level", "UNKNOWN"),
63
+ alert.get("domain", "unknown"),
64
+ alert.get("summary", "no summary"),
65
+ )
66
+ return True
@@ -0,0 +1,102 @@
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 — Webhook alert channel.
16
+
17
+ POSTs governance alerts as JSON to a configurable URL.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ from urllib.parse import urlparse
26
+ from urllib.request import Request, urlopen
27
+
28
+ from admina.plugins.base import BaseAlertChannel
29
+
30
+ logger = logging.getLogger("admina.plugins.alerts.webhook")
31
+
32
+ _ALLOWED_SCHEMES = frozenset({"http", "https"})
33
+
34
+
35
+ class WebhookAlertChannel(BaseAlertChannel):
36
+ """Alert channel that POSTs JSON to a webhook URL.
37
+
38
+ Args:
39
+ url: The webhook endpoint URL.
40
+ timeout: HTTP request timeout in seconds.
41
+ events: Optional list of alert levels to forward
42
+ (e.g. ``["HIGH", "CRITICAL"]``). ``None`` forwards all.
43
+ """
44
+
45
+ channel_name = "webhook"
46
+
47
+ def __init__(
48
+ self,
49
+ url: str | None = None,
50
+ timeout: int | None = None,
51
+ events: list[str] | None = None,
52
+ ) -> None:
53
+ # Read defaults from env so the plugin works when registered with
54
+ # no explicit config. ADMINA_ALERT_WEBHOOK_URL empty = disabled
55
+ # (channel still registers but send_alert short-circuits).
56
+ self._url = url if url is not None else os.environ.get("ADMINA_ALERT_WEBHOOK_URL", "")
57
+ self._timeout = (
58
+ timeout
59
+ if timeout is not None
60
+ else int(os.environ.get("ADMINA_ALERT_WEBHOOK_TIMEOUT", "10"))
61
+ )
62
+ if events is None:
63
+ env_events = os.environ.get("ADMINA_ALERT_WEBHOOK_EVENTS", "")
64
+ events = [e.strip() for e in env_events.split(",") if e.strip()] or None
65
+ self._events = set(events) if events else None
66
+
67
+ async def send_alert(self, alert: dict) -> bool:
68
+ """POST the alert as JSON to the configured webhook URL.
69
+
70
+ Args:
71
+ alert: Alert dict.
72
+
73
+ Returns:
74
+ ``True`` if the webhook responded with 2xx.
75
+ """
76
+ if not self._url:
77
+ logger.warning("Webhook URL not configured — alert dropped")
78
+ return False
79
+
80
+ if urlparse(self._url).scheme not in _ALLOWED_SCHEMES:
81
+ logger.error(
82
+ "Webhook URL must use http or https — got %r — alert dropped",
83
+ self._url,
84
+ )
85
+ return False
86
+
87
+ if self._events and alert.get("level") not in self._events:
88
+ return True # filtered out, not an error
89
+
90
+ try:
91
+ payload = json.dumps(alert, default=str).encode("utf-8")
92
+ req = Request( # nosec B310 — scheme validated above
93
+ self._url,
94
+ data=payload,
95
+ headers={"Content-Type": "application/json"},
96
+ method="POST",
97
+ )
98
+ with urlopen(req, timeout=self._timeout) as resp: # nosec B310
99
+ return 200 <= resp.status < 300
100
+ except (OSError, ValueError):
101
+ logger.exception("Webhook delivery failed")
102
+ return False
@@ -0,0 +1,14 @@
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
+
@@ -0,0 +1,138 @@
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 — API-key authentication provider.
16
+
17
+ Simple header-based authentication using constant-time comparison.
18
+ Supports both ``X-API-Key`` and ``Authorization: Bearer`` headers.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import os
25
+ import secrets
26
+ from typing import Any
27
+
28
+ from admina.plugins.base import BaseAuthProvider
29
+
30
+ logger = logging.getLogger("admina.plugins.auth.apikey")
31
+
32
+
33
+ class APIKeyAuthProvider(BaseAuthProvider):
34
+ """Auth provider that validates requests against a static API key.
35
+
36
+ Args:
37
+ api_key: The expected API key. If empty, all requests are
38
+ authenticated (local-dev mode).
39
+ exempt_paths: URL paths that bypass authentication.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ api_key: str | None = None,
45
+ exempt_paths: list[str] | None = None,
46
+ ) -> None:
47
+ # When the registry instantiates the plugin with no arguments
48
+ # (cls()), fall back to the ADMINA_API_KEY environment variable
49
+ # so the static .env key is honoured. An explicit api_key=""
50
+ # is preserved as opt-in local-dev "allow everything" mode.
51
+ if api_key is None:
52
+ api_key = os.environ.get("ADMINA_API_KEY", "")
53
+ self._api_key = api_key
54
+ self._exempt_paths = exempt_paths or [
55
+ "/health",
56
+ "/docs",
57
+ "/openapi.json",
58
+ "/redoc",
59
+ ]
60
+
61
+ # ── BaseAuthProvider interface ──────────────────────────────
62
+
63
+ async def authenticate(self, request: Any) -> dict:
64
+ """Authenticate a request by API key.
65
+
66
+ Args:
67
+ request: A dict with optional ``headers`` and ``path`` keys,
68
+ or a Starlette/FastAPI Request object.
69
+
70
+ Returns:
71
+ ``{"user_id": str, "roles": list, "metadata": dict}``.
72
+
73
+ Raises:
74
+ PermissionError: If the key is missing or invalid.
75
+ """
76
+ # If no key configured, allow everything (local dev)
77
+ if not self._api_key:
78
+ return {"user_id": "anonymous", "roles": ["admin"], "metadata": {}}
79
+
80
+ # Check exempt paths
81
+ path = self._get_path(request)
82
+ if path in self._exempt_paths:
83
+ return {"user_id": "anonymous", "roles": ["public"], "metadata": {}}
84
+
85
+ # Extract provided key
86
+ provided = self._extract_key(request)
87
+ if not provided or not secrets.compare_digest(provided, self._api_key):
88
+ raise PermissionError("Invalid or missing API key")
89
+
90
+ return {
91
+ "user_id": "api_key_user",
92
+ "roles": ["authenticated"],
93
+ "metadata": {},
94
+ }
95
+
96
+ async def authorize(
97
+ self,
98
+ user: dict,
99
+ action: str,
100
+ resource: str = "",
101
+ ) -> bool:
102
+ """API-key auth grants full access to authenticated users."""
103
+ return bool(user.get("roles"))
104
+
105
+ @property
106
+ def provider_name(self) -> str:
107
+ """Provider name."""
108
+ return "apikey"
109
+
110
+ # ── Internal helpers ────────────────────────────────────────
111
+
112
+ @staticmethod
113
+ def _get_path(request: Any) -> str:
114
+ """Extract the URL path from various request representations."""
115
+ if isinstance(request, dict):
116
+ return request.get("path", "")
117
+ # Starlette Request
118
+ return getattr(getattr(request, "url", None), "path", "")
119
+
120
+ @staticmethod
121
+ def _extract_key(request: Any) -> str:
122
+ """Extract the API key from headers or the bundled-dashboard cookie."""
123
+ if isinstance(request, dict):
124
+ headers = request.get("headers", {})
125
+ cookies = request.get("cookies", {})
126
+ else:
127
+ headers = dict(getattr(request, "headers", {}))
128
+ cookies = dict(getattr(request, "cookies", {}))
129
+
130
+ key = headers.get("x-api-key", "") or headers.get("X-API-Key", "")
131
+ if not key:
132
+ auth = headers.get("authorization", "") or headers.get("Authorization", "")
133
+ if auth.startswith("Bearer "):
134
+ key = auth.removeprefix("Bearer ").strip()
135
+ if not key:
136
+ # Bundled dashboard auth: cookie set by GET / in admina dev local mode.
137
+ key = cookies.get("admina_session", "")
138
+ return key
@@ -0,0 +1,14 @@
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
+