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.
- admina/__init__.py +34 -0
- admina/cli/__init__.py +14 -0
- admina/cli/commands/__init__.py +14 -0
- admina/cli/main.py +1522 -0
- admina/cli/templates/admina.yaml.j2 +77 -0
- admina/cli/templates/docker-compose.yml.j2 +254 -0
- admina/cli/templates/env.j2 +10 -0
- admina/cli/templates/main.py.j2 +95 -0
- admina/cli/templates/plugin.py.j2 +145 -0
- admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
- admina/cli/templates/plugin_readme.md.j2 +27 -0
- admina/cli/templates/plugin_test.py.j2 +48 -0
- admina/core/__init__.py +14 -0
- admina/core/config.py +497 -0
- admina/core/event_bus.py +112 -0
- admina/core/secrets.py +257 -0
- admina/core/types.py +146 -0
- admina/dashboard/__init__.py +8 -0
- admina/dashboard/static/heimdall.png +0 -0
- admina/dashboard/static/index.html +1045 -0
- admina/dashboard/static/vendor/alpinejs.min.js +5 -0
- admina/domains/__init__.py +14 -0
- admina/domains/agent_security/__init__.py +41 -0
- admina/domains/agent_security/firewall.py +634 -0
- admina/domains/agent_security/loop_breaker.py +176 -0
- admina/domains/ai_infra/__init__.py +79 -0
- admina/domains/ai_infra/llm_engine.py +477 -0
- admina/domains/ai_infra/rag.py +817 -0
- admina/domains/ai_infra/webui.py +292 -0
- admina/domains/compliance/__init__.py +109 -0
- admina/domains/compliance/cross_regulation.py +314 -0
- admina/domains/compliance/eu_ai_act.py +367 -0
- admina/domains/compliance/forensic.py +380 -0
- admina/domains/compliance/gdpr.py +331 -0
- admina/domains/compliance/nis2.py +258 -0
- admina/domains/compliance/oisg.py +658 -0
- admina/domains/compliance/otel.py +101 -0
- admina/domains/data_sovereignty/__init__.py +42 -0
- admina/domains/data_sovereignty/classification.py +102 -0
- admina/domains/data_sovereignty/pii.py +260 -0
- admina/domains/data_sovereignty/residency.py +121 -0
- admina/integrations/__init__.py +14 -0
- admina/integrations/_engines.py +63 -0
- admina/integrations/cheshirecat/__init__.py +13 -0
- admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
- admina/integrations/crewai/__init__.py +13 -0
- admina/integrations/crewai/callbacks.py +347 -0
- admina/integrations/langchain/__init__.py +13 -0
- admina/integrations/langchain/callbacks.py +341 -0
- admina/integrations/n8n/__init__.py +14 -0
- admina/integrations/openclaw/__init__.py +14 -0
- admina/plugins/__init__.py +49 -0
- admina/plugins/base.py +633 -0
- admina/plugins/builtin/__init__.py +14 -0
- admina/plugins/builtin/adapters/__init__.py +14 -0
- admina/plugins/builtin/adapters/ollama.py +120 -0
- admina/plugins/builtin/adapters/openai.py +138 -0
- admina/plugins/builtin/alerts/__init__.py +14 -0
- admina/plugins/builtin/alerts/log.py +66 -0
- admina/plugins/builtin/alerts/webhook.py +102 -0
- admina/plugins/builtin/auth/__init__.py +14 -0
- admina/plugins/builtin/auth/apikey.py +138 -0
- admina/plugins/builtin/compliance/__init__.py +14 -0
- admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
- admina/plugins/builtin/connectors/__init__.py +14 -0
- admina/plugins/builtin/connectors/chromadb.py +137 -0
- admina/plugins/builtin/connectors/filesystem.py +111 -0
- admina/plugins/builtin/forensic/__init__.py +14 -0
- admina/plugins/builtin/forensic/filesystem.py +163 -0
- admina/plugins/builtin/forensic/minio.py +180 -0
- admina/plugins/builtin/guards/__init__.py +0 -0
- admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
- admina/plugins/builtin/pii/__init__.py +14 -0
- admina/plugins/builtin/pii/spacy_regex.py +160 -0
- admina/plugins/builtin/transports/__init__.py +14 -0
- admina/plugins/builtin/transports/http_rest.py +97 -0
- admina/plugins/builtin/transports/mcp.py +173 -0
- admina/plugins/registry.py +356 -0
- admina/proxy/__init__.py +15 -0
- admina/proxy/api/__init__.py +17 -0
- admina/proxy/api/dashboard.py +925 -0
- admina/proxy/api/integration.py +153 -0
- admina/proxy/config.py +214 -0
- admina/proxy/engine_bridge.py +306 -0
- admina/proxy/governance.py +232 -0
- admina/proxy/main.py +1484 -0
- admina/proxy/multi_upstream.py +156 -0
- admina/proxy/state.py +97 -0
- admina/py.typed +0 -0
- admina/sdk/__init__.py +34 -0
- admina/sdk/_compat.py +43 -0
- admina/sdk/compliance_kit.py +359 -0
- admina/sdk/governed_agent.py +391 -0
- admina/sdk/governed_data.py +434 -0
- admina/sdk/governed_model.py +241 -0
- admina_framework-0.9.0.dist-info/METADATA +575 -0
- admina_framework-0.9.0.dist-info/RECORD +102 -0
- admina_framework-0.9.0.dist-info/WHEEL +5 -0
- admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
- admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
- admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
- 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
|
+
|