primarypath-server 1.0.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.
- app/__init__.py +351 -0
- app/_version.py +8 -0
- app/agents.py +150 -0
- app/alembic.ini +37 -0
- app/auth.py +714 -0
- app/chat.py +122 -0
- app/config.py +258 -0
- app/costgov.py +345 -0
- app/db.py +56 -0
- app/faults.py +26 -0
- app/grants.py +122 -0
- app/idempotency.py +58 -0
- app/jcs.py +117 -0
- app/ledger/__init__.py +41 -0
- app/ledger/fold.py +74 -0
- app/ledger/hashing.py +19 -0
- app/ledger/projection.py +389 -0
- app/ledger/store.py +1042 -0
- app/ledger/verify.py +31 -0
- app/main.py +50 -0
- app/maintenance.py +56 -0
- app/metrics.py +89 -0
- app/migrations/env.py +20 -0
- app/migrations/script.py.mako +23 -0
- app/migrations/versions/0001_baseline.py +19 -0
- app/migrations/versions/0002_events_and_projection.py +78 -0
- app/migrations/versions/0003_slack_messages.py +27 -0
- app/migrations/versions/0004_sessions.py +31 -0
- app/migrations/versions/0005_siem_cursor.py +26 -0
- app/migrations/versions/0006_teams_messages.py +27 -0
- app/migrations/versions/0007_tenant_scoping.py +50 -0
- app/migrations/versions/0008_policy_view.py +39 -0
- app/migrations/versions/0009_api_tokens.py +37 -0
- app/migrations/versions/0010_session_groups.py +23 -0
- app/migrations/versions/0011_budgets_and_evals.py +75 -0
- app/migrations/versions/0012_run_event_ts_index.py +23 -0
- app/migrations/versions/0013_agent_chains.py +102 -0
- app/migrations/versions/0014_dual_control.py +33 -0
- app/migrations/versions/0015_sampled_mode.py +24 -0
- app/migrations/versions/0016_sampling_salt.py +25 -0
- app/migrations/versions/0017_live_tokens_index.py +25 -0
- app/migrations/versions/0018_webhook_outbox.py +47 -0
- app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
- app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
- app/migrations/versions/0021_execution_grants.py +59 -0
- app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
- app/migrations/versions/0023_idempotency_keys.py +38 -0
- app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
- app/netguard.py +73 -0
- app/obslog.py +105 -0
- app/policy.py +390 -0
- app/ratelimit.py +78 -0
- app/routes/__init__.py +0 -0
- app/routes/admin.py +312 -0
- app/routes/agents.py +111 -0
- app/routes/approvals.py +576 -0
- app/routes/audit.py +213 -0
- app/routes/costgov.py +150 -0
- app/routes/health.py +53 -0
- app/routes/policy.py +72 -0
- app/routes/runs.py +115 -0
- app/routes/slack.py +40 -0
- app/routes/teams.py +29 -0
- app/routes/traces.py +55 -0
- app/schemas.py +248 -0
- app/security.py +203 -0
- app/siem.py +248 -0
- app/signing.py +342 -0
- app/sla.py +234 -0
- app/slack.py +282 -0
- app/teams.py +276 -0
- app/tokens.py +117 -0
- app/traces.py +309 -0
- app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- app/web-dist/assets/index-B_JDe_G2.js +10 -0
- app/web-dist/assets/index-J7J-YtwM.css +1 -0
- app/web-dist/index.html +24 -0
- app/webhooks.py +405 -0
- primarypath_server-1.0.0.dist-info/METADATA +35 -0
- primarypath_server-1.0.0.dist-info/RECORD +94 -0
- primarypath_server-1.0.0.dist-info/WHEEL +5 -0
- primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
- primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
- primarypath_server-1.0.0.dist-info/top_level.txt +1 -0
app/chat.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The shared chat-routing core: Slack and Teams differ only in transport.
|
|
2
|
+
|
|
3
|
+
Every chat surface does the same things: post the case when an approval is
|
|
4
|
+
requested, write the decision through the SAME ledger path as the web, and
|
|
5
|
+
reflect the outcome back onto the original message. Nothing chat-specific
|
|
6
|
+
ever enters the audit chain beyond the reviewer string.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from .ledger import AlreadyDecided, Ledger, UnknownApproval
|
|
14
|
+
from .ledger.store import DEFAULT_TENANT
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("primarypath.chat")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChatRouter:
|
|
20
|
+
def __init__(self, ledger: Ledger, tenant: str = DEFAULT_TENANT,
|
|
21
|
+
policy=None):
|
|
22
|
+
self.ledger = ledger
|
|
23
|
+
# a chat surface serves one tenant (F20 seam); multi-tenant chat
|
|
24
|
+
# means one router per tenant workspace, not a shared one
|
|
25
|
+
self.tenant = tenant
|
|
26
|
+
# set after construction; used to fail closed on approver_groups
|
|
27
|
+
self.policy = policy
|
|
28
|
+
self._tasks: set[asyncio.Task] = set()
|
|
29
|
+
|
|
30
|
+
class ChatNotPermitted(Exception):
|
|
31
|
+
"""The action's approver_groups can't be proven from a chat identity."""
|
|
32
|
+
|
|
33
|
+
# -- background plumbing ---------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _spawn(self, coro) -> None:
|
|
36
|
+
task = asyncio.get_running_loop().create_task(coro)
|
|
37
|
+
self._tasks.add(task)
|
|
38
|
+
task.add_done_callback(self._tasks.discard)
|
|
39
|
+
|
|
40
|
+
async def drain(self) -> None:
|
|
41
|
+
# drop finished tasks here rather than trust the done-callback to
|
|
42
|
+
# have run: a finished task still in the set made `while self._tasks`
|
|
43
|
+
# spin forever (three chat tests timed out in CI for rc.5), and
|
|
44
|
+
# shutdown drains the same way
|
|
45
|
+
while True:
|
|
46
|
+
self._tasks.difference_update([t for t in self._tasks if t.done()])
|
|
47
|
+
if not self._tasks:
|
|
48
|
+
return
|
|
49
|
+
await asyncio.gather(*list(self._tasks), return_exceptions=True)
|
|
50
|
+
|
|
51
|
+
# -- surface hooks (transport-specific) --------------------------------------
|
|
52
|
+
|
|
53
|
+
def notify(self, approval: dict) -> None:
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def reflect(self, approval: dict) -> None:
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
# -- the one shared decision path ---------------------------------------------
|
|
60
|
+
|
|
61
|
+
async def decide_from_chat(self, approval_id: str, approved: bool,
|
|
62
|
+
reviewer: str, reason: str = "",
|
|
63
|
+
tenant: str | None = None) -> dict | None:
|
|
64
|
+
"""Write the decision exactly like the web path would, into this
|
|
65
|
+
surface's tenant chain. On a race, return the standing decision so
|
|
66
|
+
the caller can show who won.
|
|
67
|
+
|
|
68
|
+
Fail closed on approver_groups and on dual control: a chat identity
|
|
69
|
+
(Slack/Teams user) cannot prove IdP group membership, and the chat
|
|
70
|
+
card has no endorsed-awaiting-second state, so actions that pin
|
|
71
|
+
approver_groups or demand a second approver are REFUSED here and
|
|
72
|
+
must be decided in the web inbox. Otherwise the chat allowlist
|
|
73
|
+
would silently satisfy a stronger control. Raises ChatNotPermitted."""
|
|
74
|
+
tenant = tenant or self.tenant
|
|
75
|
+
await self._require_chat_decidable(approval_id, tenant)
|
|
76
|
+
try:
|
|
77
|
+
a = await self.ledger.decide(approval_id, approved=approved,
|
|
78
|
+
reviewer=reviewer, reason=reason,
|
|
79
|
+
tenant=tenant)
|
|
80
|
+
except AlreadyDecided:
|
|
81
|
+
# the racing decider already called the agent back
|
|
82
|
+
return await self.ledger.approval(approval_id, tenant=tenant)
|
|
83
|
+
except UnknownApproval:
|
|
84
|
+
log.warning("chat decision for unknown approval %s", approval_id)
|
|
85
|
+
return None
|
|
86
|
+
# the agent's callback_url is served by the ledger's decision hook
|
|
87
|
+
# (outbox row in the decision transaction) — same as a web decision
|
|
88
|
+
return a
|
|
89
|
+
|
|
90
|
+
async def _require_chat_decidable(self, approval_id: str,
|
|
91
|
+
tenant: str) -> None:
|
|
92
|
+
if self.policy is None:
|
|
93
|
+
return
|
|
94
|
+
a = await self.ledger.approval(approval_id, tenant=tenant)
|
|
95
|
+
if a is None:
|
|
96
|
+
return
|
|
97
|
+
# the case's snapshot binds as well as the live rule (B6)
|
|
98
|
+
live = await self.policy.policy_for(a["tool"], tenant)
|
|
99
|
+
stored = a.get("policy") or {}
|
|
100
|
+
if any(r and (r.get("approver_groups") or []) for r in (stored, live)):
|
|
101
|
+
raise self.ChatNotPermitted(
|
|
102
|
+
"this action restricts approvers to specific groups; "
|
|
103
|
+
"decide it in the web inbox with your SSO identity")
|
|
104
|
+
if any(r and r.get("require_second_approval") for r in (stored, live)):
|
|
105
|
+
raise self.ChatNotPermitted(
|
|
106
|
+
"this action requires two approvers (dual control); "
|
|
107
|
+
"decide it in the web inbox")
|
|
108
|
+
|
|
109
|
+
async def record_refusal(self, approval_id: str, reviewer: str,
|
|
110
|
+
surface: str, verb: str,
|
|
111
|
+
tenant: str | None = None) -> None:
|
|
112
|
+
"""An unauthorized click is refused AND becomes part of the record."""
|
|
113
|
+
log.warning("unauthorized %s %s attempt on %s by %s",
|
|
114
|
+
surface, verb, approval_id, reviewer)
|
|
115
|
+
try:
|
|
116
|
+
await self.ledger.annotate(
|
|
117
|
+
approval_id, reviewer,
|
|
118
|
+
f"unauthorized {surface} {verb} attempt refused "
|
|
119
|
+
f"(user not in the approver allowlist)",
|
|
120
|
+
tenant=tenant or self.tenant)
|
|
121
|
+
except UnknownApproval:
|
|
122
|
+
pass
|
app/config.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Service settings, env-driven. Only database_url is required to boot."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
|
|
6
|
+
from pydantic import field_validator
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
model_config = SettingsConfigDict(env_file=".env", env_prefix="PRIMARYPATH_", extra="ignore")
|
|
12
|
+
|
|
13
|
+
# compose passes unset optionals as empty strings; treat those as unset
|
|
14
|
+
@field_validator(
|
|
15
|
+
"signing_key_path", "oidc_issuer", "oidc_client_id",
|
|
16
|
+
"oidc_client_secret", "oidc_redirect_url", "session_secret",
|
|
17
|
+
"service_token", "admin_token", "slack_bot_token", "slack_signing_secret",
|
|
18
|
+
"signing_ring_path", "pkcs11_module", "pkcs11_token_label", "pkcs11_pin",
|
|
19
|
+
"siem_endpoint", "siem_token", "teams_app_id", "teams_app_password",
|
|
20
|
+
"teams_service_url", "teams_conversation_id",
|
|
21
|
+
"slack_channel", "web_dist",
|
|
22
|
+
"sla_default_seconds", "sla_escalation_channel",
|
|
23
|
+
"sla_escalation_webhook", "webhook_secret", "csp", "secure_cookies",
|
|
24
|
+
mode="before")
|
|
25
|
+
@classmethod
|
|
26
|
+
def _empty_env_is_none(cls, v):
|
|
27
|
+
return None if v == "" else v
|
|
28
|
+
|
|
29
|
+
database_url: str = "postgresql://primarypath:primarypath@localhost:5432/primarypath"
|
|
30
|
+
host: str = "127.0.0.1" # non-loopback binds require auth or insecure=true
|
|
31
|
+
port: int = 8123
|
|
32
|
+
insecure: bool = False # explicit opt-in to expose an unauthenticated deploy
|
|
33
|
+
# k8s: a pre-install/pre-upgrade Job runs migrations instead, so N
|
|
34
|
+
# replicas don't race Alembic at startup
|
|
35
|
+
migrate_on_start: bool = True
|
|
36
|
+
|
|
37
|
+
# F18 — observability
|
|
38
|
+
log_json: bool = True
|
|
39
|
+
log_level: str = "info"
|
|
40
|
+
|
|
41
|
+
# CORS: empty = no cross-origin (the inbox is same-origin with the API).
|
|
42
|
+
# Set a csv of allowed origins only if a separate front-end needs them.
|
|
43
|
+
cors_allow_origins: str = ""
|
|
44
|
+
|
|
45
|
+
# Rate limiting on abuse-sensitive endpoints (auth, decisions, chat
|
|
46
|
+
# interactions) — per client IP, fixed 1-minute window. 0 disables. Reads
|
|
47
|
+
# and agent polling are never limited. In-memory and per-replica; put a
|
|
48
|
+
# limiter at your ingress too for multi-replica deployments.
|
|
49
|
+
rate_limit_per_minute: int = 60
|
|
50
|
+
# per-credential fixed-window limit on authenticated AGENT writes
|
|
51
|
+
# (create approval, ingest run events, evals, gate). Reads and
|
|
52
|
+
# polling are never limited. In-memory, per replica.
|
|
53
|
+
agent_write_rate_per_minute: int = 600
|
|
54
|
+
# bodies over this are 413 before a handler runs
|
|
55
|
+
max_body_bytes: int = 1_048_576
|
|
56
|
+
|
|
57
|
+
# transport posture (TLS terminates at the ingress). None = derive
|
|
58
|
+
# from public_url (https => secure). Secure => cookies are Secure and
|
|
59
|
+
# __Host-prefixed, and HSTS is sent.
|
|
60
|
+
secure_cookies: bool | None = None
|
|
61
|
+
# override the computed Content-Security-Policy (see app/security.py)
|
|
62
|
+
csp: str | None = None
|
|
63
|
+
session_secret_min_length: int = 32
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def cors_origins(self) -> list[str]:
|
|
67
|
+
return [o.strip() for o in self.cors_allow_origins.split(",") if o.strip()]
|
|
68
|
+
|
|
69
|
+
# static inbox build (F04): path to web/dist; served at / when set
|
|
70
|
+
web_dist: str | None = None
|
|
71
|
+
|
|
72
|
+
# base URL used in outbound links (Slack messages, etc.)
|
|
73
|
+
public_url: str = "http://127.0.0.1:8123"
|
|
74
|
+
|
|
75
|
+
# webhook delivery (F02): a transactional outbox with a leased worker.
|
|
76
|
+
# attempts is the dead-letter threshold; backoff is base*2^n capped at
|
|
77
|
+
# max; the worker leases a row for lease_s per attempt (a crashed
|
|
78
|
+
# replica's row is retried by another after that); secret enables the
|
|
79
|
+
# HMAC signature header (the Ed25519 one rides on the audit key).
|
|
80
|
+
webhook_attempts: int = 10
|
|
81
|
+
webhook_backoff_base: float = 0.5
|
|
82
|
+
webhook_backoff_max: float = 300.0
|
|
83
|
+
webhook_poll_interval_s: float = 1.0 # <= 0 disables the worker
|
|
84
|
+
webhook_lease_s: float = 30.0
|
|
85
|
+
webhook_secret: str | None = None
|
|
86
|
+
# SSRF guard: callback_url targeting private/loopback hosts is refused
|
|
87
|
+
# unless this is set (self-hosted internal webhooks). The cloud-metadata
|
|
88
|
+
# link-local range is always refused.
|
|
89
|
+
webhook_allow_private: bool = False
|
|
90
|
+
|
|
91
|
+
# F09/F15 — audit signing. Backend "file" (default) or "pkcs11" (the
|
|
92
|
+
# private key lives in the customer's HSM/KMS and never leaves it).
|
|
93
|
+
signing_backend: str = "file"
|
|
94
|
+
signing_key_path: str | None = None
|
|
95
|
+
signing_ring_path: str | None = None # pkcs11 mode: where the pubring lives
|
|
96
|
+
pkcs11_module: str | None = None
|
|
97
|
+
pkcs11_token_label: str | None = None
|
|
98
|
+
pkcs11_pin: str | None = None
|
|
99
|
+
pkcs11_key_label: str = "primarypath-audit"
|
|
100
|
+
# execution grants: how long an executor has to redeem the grant minted
|
|
101
|
+
# with a decision. Grants need a signer (file or pkcs11); without one,
|
|
102
|
+
# approvals carry no grant and a grant-requiring executor refuses.
|
|
103
|
+
grant_ttl_s: int = 300
|
|
104
|
+
# how long an Idempotency-Key is remembered (a retry later than this
|
|
105
|
+
# is a new request); expired by the maintenance loop below
|
|
106
|
+
idempotency_ttl_s: int = 7 * 24 * 3600
|
|
107
|
+
# housekeeping cadence (idempotency-key expiry), independent of every
|
|
108
|
+
# feature loop; <= 0 disables it, with a startup warning, and nothing
|
|
109
|
+
# then expires the keys
|
|
110
|
+
maintenance_interval_s: float = 60.0
|
|
111
|
+
|
|
112
|
+
# F06 — human plane (OIDC sessions) and agent plane (service token)
|
|
113
|
+
oidc_issuer: str | None = None
|
|
114
|
+
oidc_client_id: str | None = None
|
|
115
|
+
oidc_client_secret: str | None = None
|
|
116
|
+
oidc_redirect_url: str | None = None
|
|
117
|
+
oidc_scopes: str = "openid profile email"
|
|
118
|
+
oidc_algorithms: str = "RS256" # csv; e.g. "RS256,ES256" per your IdP
|
|
119
|
+
session_secret: str | None = None
|
|
120
|
+
session_ttl_s: int = 8 * 3600
|
|
121
|
+
# agent plane: create approvals, ingest run events, poll status. This
|
|
122
|
+
# token must never satisfy admin surfaces — every governed agent holds it.
|
|
123
|
+
service_token: str | None = None
|
|
124
|
+
# operator plane: full audit export, key rotation, admin endpoints.
|
|
125
|
+
admin_token: str | None = None
|
|
126
|
+
|
|
127
|
+
# F08 — SLAs. Deadlines resolve request > per-tool > default; None = no SLA.
|
|
128
|
+
sla_default_seconds: int | None = None
|
|
129
|
+
sla_by_tool: str = "" # "refund:600,rollback_deploy:120"
|
|
130
|
+
sla_remind_before_seconds: int = 300
|
|
131
|
+
sla_escalate_to: str = "ops"
|
|
132
|
+
sla_escalation_channel: str | None = None
|
|
133
|
+
sla_escalation_webhook: str | None = None
|
|
134
|
+
sla_breach_auto_deny: bool = False
|
|
135
|
+
sla_scan_interval_s: float = 5.0 # <= 0 disables the background loop
|
|
136
|
+
|
|
137
|
+
def sla_for_tool(self, tool: str) -> int | None:
|
|
138
|
+
for part in self.sla_by_tool.split(","):
|
|
139
|
+
if ":" in part:
|
|
140
|
+
name, _, secs = part.strip().partition(":")
|
|
141
|
+
if name == tool and secs.strip().isdigit():
|
|
142
|
+
return int(secs)
|
|
143
|
+
return self.sla_default_seconds
|
|
144
|
+
|
|
145
|
+
# F17 — Microsoft Teams routing (mirrors Slack; off when unconfigured)
|
|
146
|
+
teams_app_id: str | None = None
|
|
147
|
+
teams_app_password: str | None = None
|
|
148
|
+
teams_service_url: str | None = None
|
|
149
|
+
teams_conversation_id: str | None = None
|
|
150
|
+
# AAD object ids allowed to decide; empty = NOBODY (fail closed)
|
|
151
|
+
teams_approvers: str = ""
|
|
152
|
+
teams_openid_config_url: str = (
|
|
153
|
+
"https://login.botframework.com/v1/.well-known/openidconfiguration")
|
|
154
|
+
teams_token_issuer: str = "https://api.botframework.com"
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def teams_approver_ids(self) -> set[str]:
|
|
158
|
+
return {x.strip() for x in self.teams_approvers.split(",") if x.strip()}
|
|
159
|
+
|
|
160
|
+
# F21 — policy & rollout. Enforcement consults live policy_view; an
|
|
161
|
+
# action with no rule falls back to fail-safe (require approval).
|
|
162
|
+
policy_default_requires_approval: bool = True
|
|
163
|
+
policy_graduation_min_decisions: int = 10
|
|
164
|
+
policy_graduation_approval_rate: float = 0.95
|
|
165
|
+
policy_demotion_rejection_rate: float = 0.2
|
|
166
|
+
# the rollout track record counts approvals and incidents from the
|
|
167
|
+
# last N days (0 = all history); reported on every row as window_days
|
|
168
|
+
policy_rollout_lookback_days: int = 90
|
|
169
|
+
|
|
170
|
+
# F22 — traces & cost. Optional per-model prices, USD per 1M tokens:
|
|
171
|
+
# "claude-opus-4-8:15/75,claude-sonnet-5:3/15". Empty -> tokens only,
|
|
172
|
+
# cost null (honest: tokens are exact, cost is an estimate).
|
|
173
|
+
model_prices: str = ""
|
|
174
|
+
|
|
175
|
+
# F23 — cost governance. The no-budget posture is the client's risk
|
|
176
|
+
# choice, never hard-coded. "open" (default): an action no budget covers
|
|
177
|
+
# has no cost gate. "closed": it falls back to cost_default_on_exceed.
|
|
178
|
+
# Either way the F21 safety gate stays independent and fail-safe.
|
|
179
|
+
cost_default_posture: str = "open" # open | closed
|
|
180
|
+
cost_default_on_exceed: str = "gate" # gate | deny
|
|
181
|
+
|
|
182
|
+
# F24 — admin separation of duties. When false (the shipped default),
|
|
183
|
+
# the admin ROLE grants no read of agent activity: visibility follows
|
|
184
|
+
# unit membership, and an admin in no unit sees policy/budgets/tokens/
|
|
185
|
+
# agents but no inbox, runs, or per-agent audit. When true, an admin
|
|
186
|
+
# reading activity is break-glass: every such read appends a chained
|
|
187
|
+
# admin_action. Deployments without a human plane (no OIDC) are the
|
|
188
|
+
# single-trust dev posture and are not gated. A runtime toggle (an
|
|
189
|
+
# event-sourced set_config admin_action) overrides this default.
|
|
190
|
+
admin_can_read_activity: bool = False
|
|
191
|
+
|
|
192
|
+
# F16 — SIEM forwarding, off by default. Never blocks the append path.
|
|
193
|
+
siem: str = "none" # none | splunk | elastic | syslog
|
|
194
|
+
siem_endpoint: str | None = None
|
|
195
|
+
siem_token: str | None = None
|
|
196
|
+
siem_index: str = "primarypath-audit"
|
|
197
|
+
siem_syslog_host: str = "127.0.0.1"
|
|
198
|
+
siem_syslog_port: int = 6514
|
|
199
|
+
siem_syslog_protocol: str = "tcp" # tcp | udp
|
|
200
|
+
siem_batch: int = 500
|
|
201
|
+
siem_interval_s: float = 2.0 # <= 0 disables the loop
|
|
202
|
+
|
|
203
|
+
# F07 — RBAC. Comma-separated. Default deny, always (auth.resolve_roles):
|
|
204
|
+
# once OIDC is on, a user matching none of these gets NO roles and no
|
|
205
|
+
# access. Set at least rbac_admin_emails before anyone can use the inbox.
|
|
206
|
+
rbac_admin_groups: str = ""
|
|
207
|
+
rbac_approver_groups: str = ""
|
|
208
|
+
rbac_admin_emails: str = ""
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _csv(v: str) -> set[str]:
|
|
212
|
+
return {x.strip() for x in v.split(",") if x.strip()}
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def admin_groups(self) -> set[str]:
|
|
216
|
+
return self._csv(self.rbac_admin_groups)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def approver_groups(self) -> set[str]:
|
|
220
|
+
return self._csv(self.rbac_approver_groups)
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def admin_emails(self) -> set[str]:
|
|
224
|
+
return self._csv(self.rbac_admin_emails)
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def rbac_enabled(self) -> bool:
|
|
228
|
+
return bool(self.admin_groups or self.approver_groups or self.admin_emails)
|
|
229
|
+
|
|
230
|
+
# F05
|
|
231
|
+
slack_bot_token: str | None = None
|
|
232
|
+
slack_signing_secret: str | None = None
|
|
233
|
+
slack_channel: str | None = None
|
|
234
|
+
# Slack user IDs allowed to decide (comma-separated). Empty = NOBODY can
|
|
235
|
+
# decide from Slack (fail closed): a verified signature proves the
|
|
236
|
+
# request came from Slack, not that the clicker is an approver.
|
|
237
|
+
slack_approvers: str = ""
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def slack_approver_ids(self) -> set[str]:
|
|
241
|
+
return {x.strip() for x in self.slack_approvers.split(",") if x.strip()}
|
|
242
|
+
|
|
243
|
+
# F10
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def alembic_url(self) -> str:
|
|
247
|
+
"""SQLAlchemy URL for migrations (sync psycopg driver)."""
|
|
248
|
+
url = self.database_url
|
|
249
|
+
if url.startswith("postgresql://"):
|
|
250
|
+
return url.replace("postgresql://", "postgresql+psycopg://", 1)
|
|
251
|
+
if url.startswith("postgres://"):
|
|
252
|
+
return url.replace("postgres://", "postgresql+psycopg://", 1)
|
|
253
|
+
return url
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@lru_cache
|
|
257
|
+
def get_settings() -> Settings:
|
|
258
|
+
return Settings()
|