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/ledger/verify.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Server-side chain verification: same algorithm as the offline verifier.
|
|
2
|
+
|
|
3
|
+
Takes export-shaped events (payload as canonical JSON string) and names the
|
|
4
|
+
exact broken record on failure.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .hashing import GENESIS, chain_hash
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify_chain(events: list[dict], head: str | None = None) -> tuple[bool, str]:
|
|
12
|
+
"""Recompute the chain; when `head` is given, also require the chain to
|
|
13
|
+
REACH it — without that check, silently dropping the newest events is
|
|
14
|
+
undetectable from the event list alone."""
|
|
15
|
+
prev = GENESIS
|
|
16
|
+
for e in events:
|
|
17
|
+
if e["prev_hash"] != prev:
|
|
18
|
+
return False, f"broken at seq {e['seq']}: prev_hash mismatch"
|
|
19
|
+
recomputed = chain_hash(e["seq"], e["ts_ms"], e["type"], e["payload"], prev)
|
|
20
|
+
if recomputed != e["hash"]:
|
|
21
|
+
return False, (
|
|
22
|
+
f"broken at seq {e['seq']}: recomputed hash differs, "
|
|
23
|
+
f"record was modified after writing"
|
|
24
|
+
)
|
|
25
|
+
prev = recomputed
|
|
26
|
+
if head is not None and prev != head:
|
|
27
|
+
return False, (
|
|
28
|
+
f"chain ends at {prev[:16]}... but the head says {head[:16]}...: "
|
|
29
|
+
f"events were dropped from the tail"
|
|
30
|
+
)
|
|
31
|
+
return True, f"verified: {len(events)} events, head {prev[:16]}..."
|
app/main.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Uvicorn entrypoint: python -m app.main"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from . import create_app
|
|
11
|
+
from .config import get_settings
|
|
12
|
+
|
|
13
|
+
if sys.platform == "win32":
|
|
14
|
+
# psycopg's async pool cannot run on Windows' default ProactorEventLoop;
|
|
15
|
+
# without this the pool times out on startup. Deploys run in Linux
|
|
16
|
+
# containers; this keeps `python -m app.main` working for Windows dev.
|
|
17
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
18
|
+
|
|
19
|
+
LOOPBACK = ("127.0.0.1", "::1", "localhost")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_bind_security(settings) -> None:
|
|
23
|
+
"""Fail closed: a network-reachable bind with no auth at all needs an
|
|
24
|
+
explicit PRIMARYPATH_INSECURE=1."""
|
|
25
|
+
auth_on = bool(settings.oidc_issuer or settings.service_token
|
|
26
|
+
or settings.admin_token)
|
|
27
|
+
if settings.host not in LOOPBACK and not auth_on and not settings.insecure:
|
|
28
|
+
raise SystemExit(
|
|
29
|
+
f"refusing to bind {settings.host}:{settings.port} with no "
|
|
30
|
+
"authentication configured. Set PRIMARYPATH_OIDC_ISSUER and/or "
|
|
31
|
+
"PRIMARYPATH_SERVICE_TOKEN/PRIMARYPATH_ADMIN_TOKEN, bind to 127.0.0.1, or set "
|
|
32
|
+
"PRIMARYPATH_INSECURE=1 to accept an unauthenticated, network-reachable "
|
|
33
|
+
"deploy (evaluation only).")
|
|
34
|
+
if not auth_on:
|
|
35
|
+
logging.getLogger("primarypath").warning(
|
|
36
|
+
"no authentication configured: every surface is open. "
|
|
37
|
+
"Evaluation only — do not expose this deployment.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> None:
|
|
41
|
+
settings = get_settings()
|
|
42
|
+
check_bind_security(settings)
|
|
43
|
+
# loop="none" on Windows: uvicorn's own loop factory would force the
|
|
44
|
+
# Proactor loop back, and psycopg async cannot run on it
|
|
45
|
+
uvicorn.run(create_app(settings), host=settings.host, port=settings.port,
|
|
46
|
+
loop="none" if sys.platform == "win32" else "auto")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
app/maintenance.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Housekeeping that must run whether or not any feature loop does.
|
|
2
|
+
|
|
3
|
+
The SLA monitor is optional (PRIMARYPATH_SLA_SCAN_INTERVAL_S <= 0 turns it
|
|
4
|
+
off); expiring Idempotency-Keys is not. It used to ride the SLA loop, so
|
|
5
|
+
switching SLAs off silently stopped the purge: the documented TTL went
|
|
6
|
+
unenforced, the table grew without bound, and keys never became reusable.
|
|
7
|
+
This loop has one job and its own switch, and it runs once at startup so
|
|
8
|
+
a process that was down for longer than the TTL catches up immediately.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from .ledger import Ledger
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("primarypath.maintenance")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Maintenance:
|
|
21
|
+
def __init__(self, ledger: Ledger, settings):
|
|
22
|
+
self.ledger = ledger
|
|
23
|
+
self.settings = settings
|
|
24
|
+
self._task: asyncio.Task | None = None
|
|
25
|
+
|
|
26
|
+
def start(self) -> None:
|
|
27
|
+
if self.settings.maintenance_interval_s > 0:
|
|
28
|
+
self._task = asyncio.get_running_loop().create_task(self._loop())
|
|
29
|
+
else:
|
|
30
|
+
log.warning("maintenance loop disabled (PRIMARYPATH_MAINTENANCE_INTERVAL_S <= 0): "
|
|
31
|
+
"Idempotency-Keys will not expire and idempotency_keys will grow")
|
|
32
|
+
|
|
33
|
+
async def stop(self) -> None:
|
|
34
|
+
if self._task is not None:
|
|
35
|
+
self._task.cancel()
|
|
36
|
+
try:
|
|
37
|
+
await self._task
|
|
38
|
+
except asyncio.CancelledError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
async def _loop(self) -> None:
|
|
42
|
+
while True:
|
|
43
|
+
try:
|
|
44
|
+
await self.run_once()
|
|
45
|
+
except Exception:
|
|
46
|
+
log.exception("maintenance pass failed; will retry")
|
|
47
|
+
await asyncio.sleep(self.settings.maintenance_interval_s)
|
|
48
|
+
|
|
49
|
+
async def run_once(self) -> dict[str, int]:
|
|
50
|
+
"""One pass; returns what it did, for tests and for the log."""
|
|
51
|
+
purged = await self.ledger.purge_idempotency_keys(
|
|
52
|
+
self.settings.idempotency_ttl_s * 1000)
|
|
53
|
+
if purged:
|
|
54
|
+
log.info("expired %d idempotency key(s) older than %ds",
|
|
55
|
+
purged, self.settings.idempotency_ttl_s)
|
|
56
|
+
return {"idempotency_keys_purged": purged}
|
app/metrics.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Prometheus metrics (/metrics). Operational only: counts, durations,
|
|
2
|
+
saturation — never approval payloads, reasoning text, or identities.
|
|
3
|
+
|
|
4
|
+
Event-derived series are computed from the log at scrape time (the log is
|
|
5
|
+
the truth here too); transport series are process counters. Documented in
|
|
6
|
+
docs/METRICS.md; a per-app registry keeps test instances isolated.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from prometheus_client import (
|
|
13
|
+
CONTENT_TYPE_LATEST,
|
|
14
|
+
CollectorRegistry,
|
|
15
|
+
Counter,
|
|
16
|
+
Gauge,
|
|
17
|
+
Histogram,
|
|
18
|
+
generate_latest,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
DECISION_PCTS = (0.5, 0.9, 0.99)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Metrics:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
r = self.registry = CollectorRegistry()
|
|
27
|
+
self.http_requests = Counter(
|
|
28
|
+
"primarypath_http_requests_total", "HTTP requests",
|
|
29
|
+
["method", "path", "status"], registry=r)
|
|
30
|
+
self.http_seconds = Histogram(
|
|
31
|
+
"primarypath_http_request_seconds", "HTTP request latency",
|
|
32
|
+
["method", "path"], registry=r)
|
|
33
|
+
self.append_seconds = Histogram(
|
|
34
|
+
"primarypath_append_seconds", "Event append (lock+insert+projection)",
|
|
35
|
+
registry=r)
|
|
36
|
+
self.webhook_deliveries = Counter(
|
|
37
|
+
"primarypath_webhook_deliveries_total",
|
|
38
|
+
"callback_url decision deliveries", ["result"], registry=r)
|
|
39
|
+
|
|
40
|
+
self.events_total = Gauge(
|
|
41
|
+
"primarypath_events_total", "Events in the chain, by type (from the log)",
|
|
42
|
+
["type"], registry=r)
|
|
43
|
+
self.pending = Gauge(
|
|
44
|
+
"primarypath_pending_approvals", "Approvals awaiting a decision",
|
|
45
|
+
registry=r)
|
|
46
|
+
self.oldest_pending_age = Gauge(
|
|
47
|
+
"primarypath_oldest_pending_age_seconds",
|
|
48
|
+
"Age of the oldest pending approval", registry=r)
|
|
49
|
+
self.time_to_decision = Gauge(
|
|
50
|
+
"primarypath_time_to_decision_seconds",
|
|
51
|
+
"Decision latency percentiles over decided approvals",
|
|
52
|
+
["quantile"], registry=r)
|
|
53
|
+
self.pool_size = Gauge(
|
|
54
|
+
"primarypath_db_pool_size", "Connections in the pool", registry=r)
|
|
55
|
+
self.pool_waiting = Gauge(
|
|
56
|
+
"primarypath_db_pool_requests_waiting",
|
|
57
|
+
"Requests waiting for a connection", registry=r)
|
|
58
|
+
|
|
59
|
+
async def scrape(self, pool) -> bytes:
|
|
60
|
+
"""Refresh log-derived gauges, then render the registry."""
|
|
61
|
+
now_ms = int(time.time() * 1000)
|
|
62
|
+
async with pool.connection() as conn:
|
|
63
|
+
cur = await conn.execute(
|
|
64
|
+
"SELECT type, count(*) FROM events GROUP BY type")
|
|
65
|
+
for type_, count in await cur.fetchall():
|
|
66
|
+
self.events_total.labels(type=type_).set(count)
|
|
67
|
+
cur = await conn.execute(
|
|
68
|
+
"SELECT count(*), min(requested_ts_ms) FROM approvals_view "
|
|
69
|
+
"WHERE status = 'pending'")
|
|
70
|
+
pending, oldest = await cur.fetchone()
|
|
71
|
+
self.pending.set(pending)
|
|
72
|
+
self.oldest_pending_age.set(
|
|
73
|
+
max(0.0, (now_ms - oldest) / 1000) if oldest else 0.0)
|
|
74
|
+
cur = await conn.execute(
|
|
75
|
+
"SELECT percentile_cont(ARRAY[0.5, 0.9, 0.99]) WITHIN GROUP "
|
|
76
|
+
"(ORDER BY (decided_ts_ms - requested_ts_ms) / 1000.0) "
|
|
77
|
+
"FROM approvals_view WHERE decided_ts_ms IS NOT NULL")
|
|
78
|
+
row = await cur.fetchone()
|
|
79
|
+
if row and row[0]:
|
|
80
|
+
for q, v in zip(DECISION_PCTS, row[0]):
|
|
81
|
+
if v is not None:
|
|
82
|
+
self.time_to_decision.labels(quantile=str(q)).set(v)
|
|
83
|
+
stats = pool.get_stats()
|
|
84
|
+
self.pool_size.set(stats.get("pool_size", 0))
|
|
85
|
+
self.pool_waiting.set(stats.get("requests_waiting", 0))
|
|
86
|
+
return generate_latest(self.registry)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
CONTENT_TYPE = CONTENT_TYPE_LATEST
|
app/migrations/env.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from alembic import context
|
|
4
|
+
from sqlalchemy import create_engine
|
|
5
|
+
|
|
6
|
+
config = context.config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_migrations_online() -> None:
|
|
10
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
11
|
+
assert url, "sqlalchemy.url is set by app.db before migrations run"
|
|
12
|
+
engine = create_engine(url)
|
|
13
|
+
with engine.connect() as connection:
|
|
14
|
+
context.configure(connection=connection, target_metadata=None)
|
|
15
|
+
with context.begin_transaction():
|
|
16
|
+
context.run_migrations()
|
|
17
|
+
engine.dispose()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
run_migrations_online()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from alembic import op
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
${imports if imports else ""}
|
|
11
|
+
|
|
12
|
+
revision = ${repr(up_revision)}
|
|
13
|
+
down_revision = ${repr(down_revision)}
|
|
14
|
+
branch_labels = ${repr(branch_labels)}
|
|
15
|
+
depends_on = ${repr(depends_on)}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def upgrade() -> None:
|
|
19
|
+
${upgrades if upgrades else "pass"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def downgrade() -> None:
|
|
23
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Baseline: empty schema anchor.
|
|
2
|
+
|
|
3
|
+
Revision ID: 0001
|
|
4
|
+
Revises:
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
revision = "0001"
|
|
9
|
+
down_revision = None
|
|
10
|
+
branch_labels = None
|
|
11
|
+
depends_on = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def upgrade() -> None:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def downgrade() -> None:
|
|
19
|
+
pass
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""events log (append-only, hash-chained) + approvals_view projection.
|
|
2
|
+
|
|
3
|
+
Revision ID: 0002
|
|
4
|
+
Revises: 0001
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from alembic import op
|
|
9
|
+
|
|
10
|
+
revision = "0002"
|
|
11
|
+
down_revision = "0001"
|
|
12
|
+
branch_labels = None
|
|
13
|
+
depends_on = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def upgrade() -> None:
|
|
17
|
+
# payload is the EXACT canonical string that was hashed: hashing, storage,
|
|
18
|
+
# and export must be byte-identical or honest data reads as tampering
|
|
19
|
+
# (JSONB does not preserve numeric representation). payload_json is a
|
|
20
|
+
# derived column for querying only; the TEXT column is the truth.
|
|
21
|
+
op.execute("""
|
|
22
|
+
CREATE TABLE events (
|
|
23
|
+
seq BIGINT PRIMARY KEY,
|
|
24
|
+
ts_ms BIGINT NOT NULL,
|
|
25
|
+
type TEXT NOT NULL,
|
|
26
|
+
payload TEXT NOT NULL,
|
|
27
|
+
payload_json JSONB GENERATED ALWAYS AS (payload::jsonb) STORED,
|
|
28
|
+
prev_hash TEXT NOT NULL,
|
|
29
|
+
hash TEXT NOT NULL,
|
|
30
|
+
run_id TEXT
|
|
31
|
+
)
|
|
32
|
+
""")
|
|
33
|
+
op.execute("CREATE INDEX events_run_id_idx ON events (run_id)")
|
|
34
|
+
op.execute("""
|
|
35
|
+
CREATE FUNCTION events_append_only() RETURNS trigger AS $$
|
|
36
|
+
BEGIN
|
|
37
|
+
RAISE EXCEPTION 'events is append-only';
|
|
38
|
+
END
|
|
39
|
+
$$ LANGUAGE plpgsql
|
|
40
|
+
""")
|
|
41
|
+
op.execute("""
|
|
42
|
+
CREATE TRIGGER events_no_update_delete
|
|
43
|
+
BEFORE UPDATE OR DELETE ON events
|
|
44
|
+
FOR EACH ROW EXECUTE FUNCTION events_append_only()
|
|
45
|
+
""")
|
|
46
|
+
op.execute("""
|
|
47
|
+
CREATE TRIGGER events_no_truncate
|
|
48
|
+
BEFORE TRUNCATE ON events
|
|
49
|
+
FOR EACH STATEMENT EXECUTE FUNCTION events_append_only()
|
|
50
|
+
""")
|
|
51
|
+
op.execute("""
|
|
52
|
+
CREATE TABLE approvals_view (
|
|
53
|
+
approval_id TEXT PRIMARY KEY,
|
|
54
|
+
run_id TEXT NOT NULL,
|
|
55
|
+
call_id TEXT NOT NULL,
|
|
56
|
+
tool TEXT NOT NULL,
|
|
57
|
+
arguments JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
58
|
+
reasoning TEXT,
|
|
59
|
+
framework TEXT,
|
|
60
|
+
callback_url TEXT,
|
|
61
|
+
status TEXT NOT NULL,
|
|
62
|
+
reviewer TEXT,
|
|
63
|
+
reason TEXT,
|
|
64
|
+
requested_ts_ms BIGINT NOT NULL,
|
|
65
|
+
decided_ts_ms BIGINT,
|
|
66
|
+
deadline_ms BIGINT,
|
|
67
|
+
annotations JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
68
|
+
)
|
|
69
|
+
""")
|
|
70
|
+
op.execute("CREATE INDEX approvals_view_status_idx ON approvals_view (status)")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def downgrade() -> None:
|
|
74
|
+
op.execute("DROP TABLE approvals_view")
|
|
75
|
+
op.execute("DROP TRIGGER events_no_truncate ON events")
|
|
76
|
+
op.execute("DROP TRIGGER events_no_update_delete ON events")
|
|
77
|
+
op.execute("DROP FUNCTION events_append_only()")
|
|
78
|
+
op.execute("DROP TABLE events")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""slack message refs: operational state, not part of the audit chain.
|
|
2
|
+
|
|
3
|
+
Revision ID: 0003
|
|
4
|
+
Revises: 0002
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from alembic import op
|
|
9
|
+
|
|
10
|
+
revision = "0003"
|
|
11
|
+
down_revision = "0002"
|
|
12
|
+
branch_labels = None
|
|
13
|
+
depends_on = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def upgrade() -> None:
|
|
17
|
+
op.execute("""
|
|
18
|
+
CREATE TABLE slack_messages (
|
|
19
|
+
approval_id TEXT PRIMARY KEY,
|
|
20
|
+
channel TEXT NOT NULL,
|
|
21
|
+
ts TEXT NOT NULL
|
|
22
|
+
)
|
|
23
|
+
""")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def downgrade() -> None:
|
|
27
|
+
op.execute("DROP TABLE slack_messages")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""reviewer sessions: operational state, not part of the audit chain.
|
|
2
|
+
|
|
3
|
+
Revision ID: 0004
|
|
4
|
+
Revises: 0003
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from alembic import op
|
|
9
|
+
|
|
10
|
+
revision = "0004"
|
|
11
|
+
down_revision = "0003"
|
|
12
|
+
branch_labels = None
|
|
13
|
+
depends_on = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def upgrade() -> None:
|
|
17
|
+
op.execute("""
|
|
18
|
+
CREATE TABLE sessions (
|
|
19
|
+
id TEXT PRIMARY KEY,
|
|
20
|
+
subject TEXT NOT NULL,
|
|
21
|
+
email TEXT,
|
|
22
|
+
name TEXT,
|
|
23
|
+
roles JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
24
|
+
csrf TEXT NOT NULL,
|
|
25
|
+
expires_at BIGINT NOT NULL
|
|
26
|
+
)
|
|
27
|
+
""")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def downgrade() -> None:
|
|
31
|
+
op.execute("DROP TABLE sessions")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""siem forwarder cursor: operational state, not part of the audit chain.
|
|
2
|
+
|
|
3
|
+
Revision ID: 0005
|
|
4
|
+
Revises: 0004
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from alembic import op
|
|
9
|
+
|
|
10
|
+
revision = "0005"
|
|
11
|
+
down_revision = "0004"
|
|
12
|
+
branch_labels = None
|
|
13
|
+
depends_on = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def upgrade() -> None:
|
|
17
|
+
op.execute("""
|
|
18
|
+
CREATE TABLE siem_cursor (
|
|
19
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
20
|
+
last_seq BIGINT NOT NULL
|
|
21
|
+
)
|
|
22
|
+
""")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def downgrade() -> None:
|
|
26
|
+
op.execute("DROP TABLE siem_cursor")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""teams message refs: operational state, not part of the audit chain.
|
|
2
|
+
|
|
3
|
+
Revision ID: 0006
|
|
4
|
+
Revises: 0005
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from alembic import op
|
|
9
|
+
|
|
10
|
+
revision = "0006"
|
|
11
|
+
down_revision = "0005"
|
|
12
|
+
branch_labels = None
|
|
13
|
+
depends_on = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def upgrade() -> None:
|
|
17
|
+
op.execute("""
|
|
18
|
+
CREATE TABLE teams_messages (
|
|
19
|
+
approval_id TEXT PRIMARY KEY,
|
|
20
|
+
conversation_id TEXT NOT NULL,
|
|
21
|
+
activity_id TEXT NOT NULL
|
|
22
|
+
)
|
|
23
|
+
""")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def downgrade() -> None:
|
|
27
|
+
op.execute("DROP TABLE teams_messages")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tenant seam: chain-per-tenant. seq and the hash chain are scoped per
|
|
2
|
+
tenant_id, so each tenant's audit bundle is self-contained and verifiable
|
|
3
|
+
independently. Single-tenant deployments ride the 'default' tenant and
|
|
4
|
+
notice nothing.
|
|
5
|
+
|
|
6
|
+
Revision ID: 0007
|
|
7
|
+
Revises: 0006
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from alembic import op
|
|
12
|
+
|
|
13
|
+
revision = "0007"
|
|
14
|
+
down_revision = "0006"
|
|
15
|
+
branch_labels = None
|
|
16
|
+
depends_on = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade() -> None:
|
|
20
|
+
# ADD COLUMN ... DEFAULT is metadata-only (no row rewrite), so the
|
|
21
|
+
# append-only trigger is not in play; PK swaps are index DDL.
|
|
22
|
+
op.execute("ALTER TABLE events ADD COLUMN tenant_id TEXT NOT NULL "
|
|
23
|
+
"DEFAULT 'default'")
|
|
24
|
+
op.execute("ALTER TABLE events DROP CONSTRAINT events_pkey")
|
|
25
|
+
op.execute("ALTER TABLE events ADD PRIMARY KEY (tenant_id, seq)")
|
|
26
|
+
op.execute("DROP INDEX IF EXISTS events_run_id_idx")
|
|
27
|
+
op.execute("CREATE INDEX events_tenant_run_idx ON events (tenant_id, run_id)")
|
|
28
|
+
|
|
29
|
+
op.execute("ALTER TABLE approvals_view ADD COLUMN tenant_id TEXT NOT NULL "
|
|
30
|
+
"DEFAULT 'default'")
|
|
31
|
+
op.execute("ALTER TABLE approvals_view DROP CONSTRAINT approvals_view_pkey")
|
|
32
|
+
op.execute("ALTER TABLE approvals_view ADD PRIMARY KEY (tenant_id, approval_id)")
|
|
33
|
+
|
|
34
|
+
for table in ("slack_messages", "teams_messages"):
|
|
35
|
+
op.execute(f"ALTER TABLE {table} ADD COLUMN tenant_id TEXT NOT NULL "
|
|
36
|
+
f"DEFAULT 'default'")
|
|
37
|
+
op.execute(f"ALTER TABLE {table} DROP CONSTRAINT {table}_pkey")
|
|
38
|
+
op.execute(f"ALTER TABLE {table} ADD PRIMARY KEY (tenant_id, approval_id)")
|
|
39
|
+
|
|
40
|
+
op.execute("DROP TABLE siem_cursor")
|
|
41
|
+
op.execute("""
|
|
42
|
+
CREATE TABLE siem_cursor (
|
|
43
|
+
tenant_id TEXT PRIMARY KEY,
|
|
44
|
+
last_seq BIGINT NOT NULL
|
|
45
|
+
)
|
|
46
|
+
""")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def downgrade() -> None:
|
|
50
|
+
raise NotImplementedError("tenancy is a one-way seam")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""policy_view: the folded current policy per tenant (a cache of policy_set
|
|
2
|
+
events, last-write-wins per action). The policy's own history lives in the
|
|
3
|
+
chain as policy_set events; this table is a rebuildable projection, same
|
|
4
|
+
discipline as approvals_view.
|
|
5
|
+
|
|
6
|
+
Revision ID: 0008
|
|
7
|
+
Revises: 0007
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from alembic import op
|
|
12
|
+
|
|
13
|
+
revision = "0008"
|
|
14
|
+
down_revision = "0007"
|
|
15
|
+
branch_labels = None
|
|
16
|
+
depends_on = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade() -> None:
|
|
20
|
+
op.execute("""
|
|
21
|
+
CREATE TABLE policy_view (
|
|
22
|
+
tenant_id TEXT NOT NULL,
|
|
23
|
+
action TEXT NOT NULL,
|
|
24
|
+
requires_approval BOOLEAN NOT NULL,
|
|
25
|
+
reversible BOOLEAN NOT NULL DEFAULT false,
|
|
26
|
+
mode TEXT NOT NULL,
|
|
27
|
+
allow JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
28
|
+
deny JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
29
|
+
approver_groups JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
30
|
+
updated_by TEXT,
|
|
31
|
+
reason TEXT,
|
|
32
|
+
updated_ts_ms BIGINT NOT NULL,
|
|
33
|
+
PRIMARY KEY (tenant_id, action)
|
|
34
|
+
)
|
|
35
|
+
""")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def downgrade() -> None:
|
|
39
|
+
op.execute("DROP TABLE policy_view")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""api_tokens: issued service/admin tokens (F22 config console).
|
|
2
|
+
|
|
3
|
+
Operational state, not part of the audit chain — but issuance/revocation
|
|
4
|
+
is recorded as an admin_action in the chain. Only the sha256 of a token is
|
|
5
|
+
stored; the plaintext is shown once at issue time and never again.
|
|
6
|
+
|
|
7
|
+
Revision ID: 0009
|
|
8
|
+
Revises: 0008
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from alembic import op
|
|
13
|
+
|
|
14
|
+
revision = "0009"
|
|
15
|
+
down_revision = "0008"
|
|
16
|
+
branch_labels = None
|
|
17
|
+
depends_on = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def upgrade() -> None:
|
|
21
|
+
op.execute("""
|
|
22
|
+
CREATE TABLE api_tokens (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
25
|
+
kind TEXT NOT NULL CHECK (kind IN ('service', 'admin')),
|
|
26
|
+
token_sha256 TEXT NOT NULL UNIQUE,
|
|
27
|
+
label TEXT,
|
|
28
|
+
created_ts_ms BIGINT NOT NULL,
|
|
29
|
+
created_by TEXT,
|
|
30
|
+
revoked_ts_ms BIGINT
|
|
31
|
+
)
|
|
32
|
+
""")
|
|
33
|
+
op.execute("CREATE INDEX api_tokens_hash_idx ON api_tokens (token_sha256)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def downgrade() -> None:
|
|
37
|
+
op.execute("DROP TABLE api_tokens")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""sessions.groups: the reviewer's raw IdP groups, kept so per-action
|
|
2
|
+
approver_groups can be enforced at decision time (F21 review fix).
|
|
3
|
+
|
|
4
|
+
Revision ID: 0010
|
|
5
|
+
Revises: 0009
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from alembic import op
|
|
10
|
+
|
|
11
|
+
revision = "0010"
|
|
12
|
+
down_revision = "0009"
|
|
13
|
+
branch_labels = None
|
|
14
|
+
depends_on = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upgrade() -> None:
|
|
18
|
+
op.execute("ALTER TABLE sessions ADD COLUMN groups JSONB "
|
|
19
|
+
"NOT NULL DEFAULT '[]'::jsonb")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def downgrade() -> None:
|
|
23
|
+
op.execute("ALTER TABLE sessions DROP COLUMN groups")
|