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
admina/core/config.py ADDED
@@ -0,0 +1,497 @@
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 — Configuration loader.
16
+
17
+ Reads ``admina.yaml`` if present, falls back to ``.env`` variables for
18
+ backward compatibility. Exposes a typed :class:`AdminaConfig` object.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import os
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ logger = logging.getLogger("admina.config")
30
+
31
+ # PyYAML is an optional dependency — fall back gracefully.
32
+ try:
33
+ import yaml # type: ignore[import-untyped]
34
+
35
+ _HAS_YAML = True
36
+ except ImportError: # pragma: no cover
37
+ _HAS_YAML = False
38
+
39
+
40
+ __all__ = ["AdminaConfig", "load_config"]
41
+
42
+ # ── Section dataclasses ──────────────────────────────────────
43
+
44
+
45
+ @dataclass
46
+ class PIIConfig:
47
+ """PII redaction settings."""
48
+
49
+ enabled: bool = True
50
+ categories: list[str] = field(
51
+ default_factory=lambda: [
52
+ "email",
53
+ "phone",
54
+ "credit_card",
55
+ "ssn",
56
+ "iban",
57
+ "ip",
58
+ "person",
59
+ "org",
60
+ ],
61
+ )
62
+ ner_model: str = "en_core_web_sm"
63
+
64
+
65
+ @dataclass
66
+ class ResidencyConfig:
67
+ """Data residency settings."""
68
+
69
+ enabled: bool = True
70
+ allowed_zones: list[str] = field(default_factory=lambda: ["local", "eu"])
71
+ block_outbound: bool = True
72
+
73
+
74
+ @dataclass
75
+ class DataSovereigntyConfig:
76
+ """Data-sovereignty domain."""
77
+
78
+ enabled: bool = True
79
+ pii: PIIConfig = field(default_factory=PIIConfig)
80
+ residency: ResidencyConfig = field(default_factory=ResidencyConfig)
81
+ classification_enabled: bool = True
82
+
83
+
84
+ @dataclass
85
+ class LLMConfig:
86
+ """LLM backend settings."""
87
+
88
+ enabled: bool = True
89
+ backend: str = "ollama"
90
+ model: str = "llama3.1:8b"
91
+ gpu_autodetect: bool = True
92
+ vram_limit_mb: int = 0
93
+
94
+
95
+ @dataclass
96
+ class RAGConfig:
97
+ """RAG pipeline settings."""
98
+
99
+ enabled: bool = True
100
+ backend: str = "chromadb"
101
+ chunk_size: int = 512
102
+ chunk_overlap: int = 50
103
+ embedding_backend: str = "ollama"
104
+ embedding_model: str = "nomic-embed-text"
105
+
106
+
107
+ @dataclass
108
+ class WebUIConfig:
109
+ """Web UI settings."""
110
+
111
+ enabled: bool = True
112
+ port: int = 3080
113
+ auth_mode: str = "builtin"
114
+ signup_enabled: bool = True
115
+
116
+
117
+ @dataclass
118
+ class AIInfraConfig:
119
+ """AI-infra domain (opt-in)."""
120
+
121
+ enabled: bool = False
122
+ llm: LLMConfig = field(default_factory=LLMConfig)
123
+ rag: RAGConfig = field(default_factory=RAGConfig)
124
+ webui: WebUIConfig = field(default_factory=WebUIConfig)
125
+
126
+
127
+ @dataclass
128
+ class ProxyConfig:
129
+ """Proxy settings."""
130
+
131
+ port: int = 8080
132
+ upstream: str = "http://localhost:9000"
133
+
134
+
135
+ @dataclass
136
+ class FirewallConfig:
137
+ """Anti-injection firewall settings."""
138
+
139
+ enabled: bool = True
140
+ heuristic_threshold: float = 0.7
141
+
142
+
143
+ @dataclass
144
+ class LoopBreakerConfig:
145
+ """Loop breaker settings."""
146
+
147
+ enabled: bool = True
148
+ window_size: int = 10
149
+ similarity_threshold: float = 0.85
150
+ max_consecutive: int = 3
151
+
152
+
153
+ @dataclass
154
+ class AgentSecurityConfig:
155
+ """Agent-security domain."""
156
+
157
+ enabled: bool = True
158
+ proxy: ProxyConfig = field(default_factory=ProxyConfig)
159
+ firewall: FirewallConfig = field(default_factory=FirewallConfig)
160
+ loop_breaker: LoopBreakerConfig = field(default_factory=LoopBreakerConfig)
161
+
162
+
163
+ @dataclass
164
+ class ForensicConfig:
165
+ """Forensic black-box settings."""
166
+
167
+ storage: str = "minio"
168
+ bucket: str = "forensic-blackbox"
169
+
170
+
171
+ @dataclass
172
+ class OTELConfig:
173
+ """OpenTelemetry settings."""
174
+
175
+ endpoint: str = "http://localhost:4317"
176
+
177
+
178
+ @dataclass
179
+ class ComplianceConfig:
180
+ """Compliance domain."""
181
+
182
+ enabled: bool = True
183
+ forensic: ForensicConfig = field(default_factory=ForensicConfig)
184
+ eu_ai_act_enabled: bool = True
185
+ otel: OTELConfig = field(default_factory=OTELConfig)
186
+
187
+
188
+ @dataclass
189
+ class DashboardConfig:
190
+ """Dashboard settings."""
191
+
192
+ enabled: bool = True
193
+ port: int = 3000
194
+
195
+
196
+ @dataclass
197
+ class AlertChannelConfig:
198
+ """A single alert channel."""
199
+
200
+ type: str = "log"
201
+ url: str = ""
202
+ events: list[str] = field(default_factory=list)
203
+
204
+
205
+ @dataclass
206
+ class AdminaConfig:
207
+ """Top-level Admina configuration.
208
+
209
+ Constructed by :func:`load_config` — reads ``admina.yaml`` if present,
210
+ otherwise falls back to ``.env`` variables.
211
+ """
212
+
213
+ version: str = "2.0"
214
+ data_sovereignty: DataSovereigntyConfig = field(default_factory=DataSovereigntyConfig)
215
+ ai_infra: AIInfraConfig = field(default_factory=AIInfraConfig)
216
+ agent_security: AgentSecurityConfig = field(default_factory=AgentSecurityConfig)
217
+ compliance: ComplianceConfig = field(default_factory=ComplianceConfig)
218
+ dashboard: DashboardConfig = field(default_factory=DashboardConfig)
219
+ forensic_store: str = "minio"
220
+ auth_provider: str = "apikey"
221
+ pii_engine: str = "spacy-regex"
222
+ alert_channels: list[AlertChannelConfig] = field(default_factory=list)
223
+ plugins: list[str] = field(default_factory=list)
224
+
225
+ # Storage — populated from .env fallback when YAML absent
226
+ redis_url: str = "redis://localhost:6379/0"
227
+ clickhouse_host: str = "localhost"
228
+ clickhouse_port: int = 8123
229
+ clickhouse_db: str = "admina"
230
+ clickhouse_password: str = ""
231
+ minio_endpoint: str = "localhost:9000"
232
+ minio_access_key: str = "admina"
233
+ minio_secret_key: str = ""
234
+ minio_bucket: str = "forensic-blackbox"
235
+ minio_secure: bool = False
236
+
237
+ # Auth / rate-limit — populated from .env fallback
238
+ admina_api_key: str = ""
239
+ rate_limit_max_requests: int = 100
240
+ rate_limit_window_seconds: int = 60
241
+ log_level: str = "INFO"
242
+ cors_origins: str = "http://localhost:3000"
243
+
244
+
245
+ # ── YAML parsing helpers ─────────────────────────────────────
246
+
247
+
248
+ def _build_from_yaml(data: dict[str, Any]) -> AdminaConfig:
249
+ """Build an :class:`AdminaConfig` from a parsed YAML dict."""
250
+ domains = data.get("domains", {})
251
+
252
+ # data_sovereignty
253
+ ds_raw = domains.get("data_sovereignty", {})
254
+ pii_raw = ds_raw.get("pii", {})
255
+ res_raw = ds_raw.get("residency", {})
256
+ ds = DataSovereigntyConfig(
257
+ enabled=ds_raw.get("enabled", True),
258
+ pii=PIIConfig(
259
+ enabled=pii_raw.get("enabled", True),
260
+ categories=pii_raw.get(
261
+ "categories",
262
+ [
263
+ "email",
264
+ "phone",
265
+ "credit_card",
266
+ "ssn",
267
+ "iban",
268
+ "ip",
269
+ "person",
270
+ "org",
271
+ ],
272
+ ),
273
+ ner_model=pii_raw.get("ner_model", "en_core_web_sm"),
274
+ ),
275
+ residency=ResidencyConfig(
276
+ enabled=res_raw.get("enabled", True),
277
+ allowed_zones=res_raw.get("allowed_zones", ["local", "eu"]),
278
+ block_outbound=res_raw.get("block_outbound", True),
279
+ ),
280
+ classification_enabled=ds_raw.get("classification", {}).get("enabled", True),
281
+ )
282
+
283
+ # ai_infra
284
+ ai_raw = domains.get("ai_infra", {})
285
+ llm_raw = ai_raw.get("llm", {})
286
+ rag_raw = ai_raw.get("rag", {})
287
+ ai = AIInfraConfig(
288
+ enabled=ai_raw.get("enabled", False),
289
+ llm=LLMConfig(
290
+ enabled=llm_raw.get("enabled", True),
291
+ backend=llm_raw.get("backend", "ollama"),
292
+ model=llm_raw.get("model", "llama3.1:8b"),
293
+ gpu_autodetect=llm_raw.get("gpu_autodetect", True),
294
+ vram_limit_mb=llm_raw.get("vram_limit_mb", 0),
295
+ ),
296
+ rag=RAGConfig(
297
+ enabled=rag_raw.get("enabled", True),
298
+ backend=rag_raw.get("backend", "chromadb"),
299
+ chunk_size=rag_raw.get("chunk_size", 512),
300
+ chunk_overlap=rag_raw.get("chunk_overlap", 50),
301
+ embedding_backend=rag_raw.get("embedding_backend", "ollama"),
302
+ embedding_model=rag_raw.get("embedding_model", "nomic-embed-text"),
303
+ ),
304
+ webui=WebUIConfig(
305
+ enabled=ai_raw.get("webui", {}).get("enabled", True),
306
+ port=ai_raw.get("webui", {}).get("port", 3080),
307
+ auth_mode=ai_raw.get("webui", {}).get("auth_mode", "builtin"),
308
+ signup_enabled=ai_raw.get("webui", {}).get("signup_enabled", True),
309
+ ),
310
+ )
311
+
312
+ # agent_security
313
+ as_raw = domains.get("agent_security", {})
314
+ px_raw = as_raw.get("proxy", {})
315
+ fw_raw = as_raw.get("firewall", {})
316
+ lb_raw = as_raw.get("loop_breaker", {})
317
+ agent_sec = AgentSecurityConfig(
318
+ enabled=as_raw.get("enabled", True),
319
+ proxy=ProxyConfig(
320
+ port=px_raw.get("port", 8080),
321
+ upstream=px_raw.get("upstream", "http://localhost:9000"),
322
+ ),
323
+ firewall=FirewallConfig(
324
+ enabled=fw_raw.get("enabled", True),
325
+ heuristic_threshold=fw_raw.get("heuristic_threshold", 0.7),
326
+ ),
327
+ loop_breaker=LoopBreakerConfig(
328
+ enabled=lb_raw.get("enabled", True),
329
+ window_size=lb_raw.get("window_size", 10),
330
+ similarity_threshold=lb_raw.get("similarity_threshold", 0.85),
331
+ max_consecutive=lb_raw.get("max_consecutive", 3),
332
+ ),
333
+ )
334
+
335
+ # compliance
336
+ co_raw = domains.get("compliance", {})
337
+ fo_raw = co_raw.get("forensic", {})
338
+ ot_raw = co_raw.get("otel", {})
339
+ comp = ComplianceConfig(
340
+ enabled=co_raw.get("enabled", True),
341
+ forensic=ForensicConfig(
342
+ storage=fo_raw.get("storage", "minio"),
343
+ bucket=fo_raw.get("bucket", "forensic-blackbox"),
344
+ ),
345
+ eu_ai_act_enabled=co_raw.get("eu_ai_act", {}).get("enabled", True),
346
+ otel=OTELConfig(endpoint=ot_raw.get("endpoint", "http://localhost:4317")),
347
+ )
348
+
349
+ # dashboard
350
+ dash_raw = data.get("dashboard", {})
351
+ dash = DashboardConfig(
352
+ enabled=dash_raw.get("enabled", True),
353
+ port=dash_raw.get("port", 3000),
354
+ )
355
+
356
+ # alert channels
357
+ alerts = [
358
+ AlertChannelConfig(
359
+ type=a.get("type", "log"),
360
+ url=a.get("url", ""),
361
+ events=a.get("events", []),
362
+ )
363
+ for a in data.get("alert_channels", [])
364
+ ]
365
+
366
+ return AdminaConfig(
367
+ version=data.get("version", "2.0"),
368
+ data_sovereignty=ds,
369
+ ai_infra=ai,
370
+ agent_security=agent_sec,
371
+ compliance=comp,
372
+ dashboard=dash,
373
+ forensic_store=data.get("forensic_store", "minio"),
374
+ auth_provider=data.get("auth_provider", "apikey"),
375
+ pii_engine=data.get("pii_engine", "spacy-regex"),
376
+ alert_channels=alerts,
377
+ plugins=data.get("plugins", []),
378
+ )
379
+
380
+
381
+ def _build_from_env() -> AdminaConfig:
382
+ """Build an :class:`AdminaConfig` from environment / ``.env`` variables.
383
+
384
+ Maps the flat ``UPPERCASE`` env vars to the structured config object
385
+ so the rest of the codebase can use a single interface.
386
+ """
387
+
388
+ def _env(key: str, default: str = "") -> str:
389
+ return os.environ.get(key, default)
390
+
391
+ def _env_int(key: str, default: int) -> int:
392
+ raw = os.environ.get(key)
393
+ if raw is None:
394
+ return default
395
+ try:
396
+ return int(raw)
397
+ except ValueError:
398
+ return default
399
+
400
+ def _env_float(key: str, default: float) -> float:
401
+ raw = os.environ.get(key)
402
+ if raw is None:
403
+ return default
404
+ try:
405
+ return float(raw)
406
+ except ValueError:
407
+ return default
408
+
409
+ def _env_bool(key: str, default: bool) -> bool:
410
+ raw = os.environ.get(key)
411
+ if raw is None:
412
+ return default
413
+ return raw.lower() in ("1", "true", "yes")
414
+
415
+ return AdminaConfig(
416
+ agent_security=AgentSecurityConfig(
417
+ proxy=ProxyConfig(
418
+ upstream=_env("UPSTREAM_MCP_URL", "http://localhost:9000"),
419
+ ),
420
+ firewall=FirewallConfig(
421
+ enabled=_env_bool("INJECTION_FAST_PATH_ENABLED", True),
422
+ ),
423
+ loop_breaker=LoopBreakerConfig(
424
+ window_size=_env_int("LOOP_WINDOW_SIZE", 10),
425
+ similarity_threshold=_env_float("LOOP_SIMILARITY_THRESHOLD", 0.85),
426
+ max_consecutive=_env_int("LOOP_MAX_CONSECUTIVE", 3),
427
+ ),
428
+ ),
429
+ compliance=ComplianceConfig(
430
+ otel=OTELConfig(endpoint=_env("OTEL_ENDPOINT", "http://localhost:4317")),
431
+ forensic=ForensicConfig(
432
+ bucket=_env("MINIO_BUCKET", "forensic-blackbox"),
433
+ ),
434
+ ),
435
+ redis_url=_env("REDIS_URL", "redis://localhost:6379/0"),
436
+ clickhouse_host=_env("CLICKHOUSE_HOST", "localhost"),
437
+ clickhouse_port=_env_int("CLICKHOUSE_PORT", 8123),
438
+ clickhouse_db=_env("CLICKHOUSE_DB", "admina"),
439
+ clickhouse_password=_env("CLICKHOUSE_PASSWORD", ""),
440
+ minio_endpoint=_env("MINIO_ENDPOINT", "localhost:9000"),
441
+ minio_access_key=_env("MINIO_ACCESS_KEY", "admina"),
442
+ minio_secret_key=_env("MINIO_SECRET_KEY", ""),
443
+ minio_bucket=_env("MINIO_BUCKET", "forensic-blackbox"),
444
+ minio_secure=_env_bool("MINIO_SECURE", False),
445
+ admina_api_key=_env("ADMINA_API_KEY", ""),
446
+ rate_limit_max_requests=_env_int("RATE_LIMIT_MAX_REQUESTS", 100),
447
+ rate_limit_window_seconds=_env_int("RATE_LIMIT_WINDOW_SECONDS", 60),
448
+ log_level=_env("LOG_LEVEL", "INFO"),
449
+ cors_origins=_env("CORS_ORIGINS", "http://localhost:3000"),
450
+ )
451
+
452
+
453
+ # ── Public API ───────────────────────────────────────────────
454
+
455
+
456
+ def load_config(
457
+ yaml_path: str | Path | None = None,
458
+ *,
459
+ search_paths: list[str | Path] | None = None,
460
+ ) -> AdminaConfig:
461
+ """Load configuration from ``admina.yaml`` or ``.env`` fallback.
462
+
463
+ Args:
464
+ yaml_path: Explicit path to a YAML config file.
465
+ search_paths: Directories to search for ``admina.yaml`` when
466
+ *yaml_path* is not given. Defaults to cwd and repo root.
467
+
468
+ Returns:
469
+ A fully populated :class:`AdminaConfig` instance.
470
+ """
471
+ # 1. Explicit path
472
+ if yaml_path is not None:
473
+ path = Path(yaml_path)
474
+ if path.is_file() and _HAS_YAML:
475
+ return _load_yaml(path)
476
+
477
+ # 2. Search common locations
478
+ if search_paths is None:
479
+ search_paths = [Path.cwd(), Path(__file__).resolve().parent.parent]
480
+ for base in search_paths:
481
+ candidate = Path(base) / "admina.yaml"
482
+ if candidate.is_file() and _HAS_YAML:
483
+ return _load_yaml(candidate)
484
+
485
+ # 3. Fallback to environment / .env
486
+ logger.info("No admina.yaml found — using .env fallback")
487
+ return _build_from_env()
488
+
489
+
490
+ def _load_yaml(path: Path) -> AdminaConfig:
491
+ """Parse a YAML file and return :class:`AdminaConfig`."""
492
+ logger.info("Loading config from %s", path)
493
+ with open(path) as fh:
494
+ data = yaml.safe_load(fh) or {}
495
+ if not isinstance(data, dict):
496
+ raise ValueError(f"admina.yaml must be a YAML mapping, got {type(data).__name__}")
497
+ return _build_from_yaml(data)
@@ -0,0 +1,112 @@
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
+ """Async event bus for governance events.
16
+
17
+ Provides a pub/sub mechanism for governance events across the framework.
18
+ Subscribers are wired at proxy startup and include:
19
+ - OTEL exporter (governance decision spans)
20
+ - Alert channels (BLOCK/CIRCUIT_BREAK notifications)
21
+ - Dashboard WebSocket (live event feed to connected clients)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass, field
29
+ from datetime import UTC, datetime
30
+
31
+ from admina.core.types import EventType
32
+
33
+ __all__ = ["EventBus", "EventType", "GovernanceEvent", "bus"]
34
+
35
+
36
+ @dataclass
37
+ class GovernanceEvent:
38
+ """A single governance event emitted by the framework.
39
+
40
+ Attributes:
41
+ event_type: The type of governance event.
42
+ timestamp: When the event occurred.
43
+ session_id: Optional session identifier.
44
+ user_id: Optional user identifier.
45
+ domain: Optional domain name (e.g. data-sovereignty, ai-infra).
46
+ action: Optional action taken (ALLOW, BLOCK, REDACT, CIRCUIT_BREAK).
47
+ risk_level: Optional risk level assessment.
48
+ metadata: Additional event-specific data.
49
+ """
50
+
51
+ event_type: EventType
52
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
53
+ session_id: str | None = None
54
+ user_id: str | None = None
55
+ domain: str | None = None
56
+ action: str | None = None
57
+ risk_level: str | None = None
58
+ metadata: dict = field(default_factory=dict)
59
+
60
+
61
+ class EventBus:
62
+ """Async pub/sub event bus for governance events.
63
+
64
+ Supports per-type subscriptions and wildcard subscribers that
65
+ receive all events.
66
+ """
67
+
68
+ def __init__(self) -> None:
69
+ """Initialize the event bus with empty subscriber lists."""
70
+ self._subscribers: dict[EventType, list[Callable]] = {}
71
+ self._wildcard_subscribers: list[Callable] = []
72
+
73
+ def subscribe(self, event_type: EventType, callback: Callable) -> None:
74
+ """Subscribe to a specific event type.
75
+
76
+ Args:
77
+ event_type: The event type to listen for.
78
+ callback: Callable invoked with the GovernanceEvent.
79
+ Can be sync or async.
80
+ """
81
+ if event_type not in self._subscribers:
82
+ self._subscribers[event_type] = []
83
+ self._subscribers[event_type].append(callback)
84
+
85
+ def subscribe_all(self, callback: Callable) -> None:
86
+ """Subscribe to all event types.
87
+
88
+ Args:
89
+ callback: Callable invoked with every GovernanceEvent.
90
+ Can be sync or async.
91
+ """
92
+ self._wildcard_subscribers.append(callback)
93
+
94
+ async def emit(self, event: GovernanceEvent) -> None:
95
+ """Emit an event to all matching subscribers.
96
+
97
+ Calls per-type subscribers first, then wildcard subscribers.
98
+ Both sync and async callbacks are supported.
99
+
100
+ Args:
101
+ event: The governance event to emit.
102
+ """
103
+ callbacks = list(self._subscribers.get(event.event_type, []))
104
+ callbacks.extend(self._wildcard_subscribers)
105
+
106
+ for callback in callbacks:
107
+ result = callback(event)
108
+ if asyncio.iscoroutine(result):
109
+ await result
110
+
111
+
112
+ bus = EventBus()