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.
Files changed (94) hide show
  1. app/__init__.py +351 -0
  2. app/_version.py +8 -0
  3. app/agents.py +150 -0
  4. app/alembic.ini +37 -0
  5. app/auth.py +714 -0
  6. app/chat.py +122 -0
  7. app/config.py +258 -0
  8. app/costgov.py +345 -0
  9. app/db.py +56 -0
  10. app/faults.py +26 -0
  11. app/grants.py +122 -0
  12. app/idempotency.py +58 -0
  13. app/jcs.py +117 -0
  14. app/ledger/__init__.py +41 -0
  15. app/ledger/fold.py +74 -0
  16. app/ledger/hashing.py +19 -0
  17. app/ledger/projection.py +389 -0
  18. app/ledger/store.py +1042 -0
  19. app/ledger/verify.py +31 -0
  20. app/main.py +50 -0
  21. app/maintenance.py +56 -0
  22. app/metrics.py +89 -0
  23. app/migrations/env.py +20 -0
  24. app/migrations/script.py.mako +23 -0
  25. app/migrations/versions/0001_baseline.py +19 -0
  26. app/migrations/versions/0002_events_and_projection.py +78 -0
  27. app/migrations/versions/0003_slack_messages.py +27 -0
  28. app/migrations/versions/0004_sessions.py +31 -0
  29. app/migrations/versions/0005_siem_cursor.py +26 -0
  30. app/migrations/versions/0006_teams_messages.py +27 -0
  31. app/migrations/versions/0007_tenant_scoping.py +50 -0
  32. app/migrations/versions/0008_policy_view.py +39 -0
  33. app/migrations/versions/0009_api_tokens.py +37 -0
  34. app/migrations/versions/0010_session_groups.py +23 -0
  35. app/migrations/versions/0011_budgets_and_evals.py +75 -0
  36. app/migrations/versions/0012_run_event_ts_index.py +23 -0
  37. app/migrations/versions/0013_agent_chains.py +102 -0
  38. app/migrations/versions/0014_dual_control.py +33 -0
  39. app/migrations/versions/0015_sampled_mode.py +24 -0
  40. app/migrations/versions/0016_sampling_salt.py +25 -0
  41. app/migrations/versions/0017_live_tokens_index.py +25 -0
  42. app/migrations/versions/0018_webhook_outbox.py +47 -0
  43. app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
  44. app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
  45. app/migrations/versions/0021_execution_grants.py +59 -0
  46. app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
  47. app/migrations/versions/0023_idempotency_keys.py +38 -0
  48. app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
  49. app/netguard.py +73 -0
  50. app/obslog.py +105 -0
  51. app/policy.py +390 -0
  52. app/ratelimit.py +78 -0
  53. app/routes/__init__.py +0 -0
  54. app/routes/admin.py +312 -0
  55. app/routes/agents.py +111 -0
  56. app/routes/approvals.py +576 -0
  57. app/routes/audit.py +213 -0
  58. app/routes/costgov.py +150 -0
  59. app/routes/health.py +53 -0
  60. app/routes/policy.py +72 -0
  61. app/routes/runs.py +115 -0
  62. app/routes/slack.py +40 -0
  63. app/routes/teams.py +29 -0
  64. app/routes/traces.py +55 -0
  65. app/schemas.py +248 -0
  66. app/security.py +203 -0
  67. app/siem.py +248 -0
  68. app/signing.py +342 -0
  69. app/sla.py +234 -0
  70. app/slack.py +282 -0
  71. app/teams.py +276 -0
  72. app/tokens.py +117 -0
  73. app/traces.py +309 -0
  74. app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  75. app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  76. app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  77. app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  78. app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  79. app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  80. app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  81. app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  82. app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  83. app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  84. app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  85. app/web-dist/assets/index-B_JDe_G2.js +10 -0
  86. app/web-dist/assets/index-J7J-YtwM.css +1 -0
  87. app/web-dist/index.html +24 -0
  88. app/webhooks.py +405 -0
  89. primarypath_server-1.0.0.dist-info/METADATA +35 -0
  90. primarypath_server-1.0.0.dist-info/RECORD +94 -0
  91. primarypath_server-1.0.0.dist-info/WHEEL +5 -0
  92. primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
  93. primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
  94. 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")