pramana-store 0.0.1__tar.gz

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.
@@ -0,0 +1,15 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pramana/
5
+ .pytest_cache/
6
+ web/node_modules/
7
+ web/dist/
8
+ # Secrets. The trailing slash this line used to have (`.env/`) matched only a
9
+ # *directory* named .env, never the file — which is how .env ended up committed
10
+ # in a1434d1 with a live Postgres password and S3 keys in it. Adding it here
11
+ # does not remove it from history: those credentials must be rotated, and the
12
+ # commit purged, separately.
13
+ .env
14
+ .env.*
15
+ !.env.example
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: pramana-store
3
+ Version: 0.0.1
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: boto3>=1.34
6
+ Requires-Dist: pramana-core
7
+ Requires-Dist: pramana-proto
8
+ Requires-Dist: psycopg[binary,pool]>=3.2
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "pramana-store"
3
+ version = "0.0.1"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "pramana-core",
7
+ "pramana-proto",
8
+ "psycopg[binary,pool]>=3.2",
9
+ "boto3>=1.34",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/pramana_store"]
18
+
19
+ [tool.hatch.metadata]
20
+ allow-direct-references = true
21
+
22
+ [dependency-groups]
23
+ dev = ["pytest"]
File without changes
@@ -0,0 +1,83 @@
1
+ """API-key resolution, shared by every service that authenticates one.
2
+
3
+ Lives here rather than in `services/api` because this package already owns the
4
+ `api_keys` table (schema.sql) and both `services/api` and `services/ingest`
5
+ already depend on it. The alternative — ingest re-implementing the lookup —
6
+ means two definitions of "is this key still valid", and the one that drifts is
7
+ the one that keeps accepting revoked keys.
8
+
9
+ Only the hash is ever stored, so a key's plaintext cannot be recovered from
10
+ this table; `hash_key` is the single definition of how a presented secret maps
11
+ onto a stored row.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import threading
18
+ import time
19
+
20
+ from psycopg.rows import dict_row
21
+
22
+ from pramana_store.postgres import pooled_connect
23
+
24
+ # Every authenticated request paid its own round-trip here before doing any
25
+ # actual work — against a Postgres with real network latency (a pooler across
26
+ # a real hop; docs/plan.md §21.1 measured 525ms per query to Supabase) that was
27
+ # roughly half of every endpoint's total latency, just to answer a question
28
+ # that almost never changes between two requests a few seconds apart.
29
+ #
30
+ # Cost of caching it: a revoked key keeps authenticating for up to
31
+ # `_CACHE_TTL_S` after revocation, since revoke_api_key() only has a key_id,
32
+ # not the secret whose hash keys this cache, so it cannot evict on revoke.
33
+ # 60s is the accepted ceiling for that window; make revocation write-through
34
+ # if that stops being acceptable.
35
+ _CACHE_TTL_S = 60.0
36
+ _cache: dict[tuple[str, str], tuple[tuple[str, str] | None, float]] = {}
37
+ _cache_lock = threading.Lock()
38
+
39
+ # Roles allowed to *write* events. An auditor key is read-only by design
40
+ # (docs/PROJECT_SCOPE.md §3: auditors see structure and divergences, never raw
41
+ # payloads) — handing one to an SDK would let it author the very trace its
42
+ # holder is only supposed to inspect.
43
+ WRITER_ROLES = frozenset({"admin", "engineer"})
44
+
45
+
46
+ def hash_key(secret: str) -> str:
47
+ return hashlib.sha256(secret.encode()).hexdigest()
48
+
49
+
50
+ def resolve_key(conninfo: str, secret: str) -> tuple[str, str] | None:
51
+ """Returns (tenant_id, role) for a live key, or None if it is unknown or revoked.
52
+
53
+ Cached for `_CACHE_TTL_S` (see module docstring for the revocation-window
54
+ tradeoff this accepts). A miss (unknown/bad key) is cached too — a client
55
+ hammering a stale key would otherwise spend a full round-trip per attempt.
56
+ """
57
+ key_hash = hash_key(secret)
58
+ cache_key = (conninfo, key_hash) # tests point different conninfos at the same hash
59
+ now = time.monotonic()
60
+ with _cache_lock:
61
+ cached = _cache.get(cache_key)
62
+ if cached is not None and cached[1] > now:
63
+ return cached[0]
64
+
65
+ with pooled_connect(conninfo, row_factory=dict_row) as conn:
66
+ row = conn.execute(
67
+ "SELECT tenant_id, role FROM api_keys WHERE hash = %s AND revoked_at IS NULL",
68
+ (key_hash,),
69
+ ).fetchone()
70
+ result = (str(row["tenant_id"]), str(row["role"])) if row else None
71
+
72
+ with _cache_lock:
73
+ _cache[cache_key] = (result, now + _CACHE_TTL_S)
74
+ return result
75
+
76
+
77
+ def invalidate(conninfo: str, key_hash: str) -> None:
78
+ """Evict one cached resolution immediately — call this from the revoke
79
+ path so a revoked key stops working right away instead of riding out
80
+ `_CACHE_TTL_S`. Safe to call for a hash that was never cached.
81
+ """
82
+ with _cache_lock:
83
+ _cache.pop((conninfo, key_hash), None)
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import boto3
10
+
11
+ from pramana_store.ports import BlobStore
12
+
13
+
14
+ def _key(data: bytes) -> str:
15
+ return hashlib.sha256(data).hexdigest()
16
+
17
+
18
+ # One boto3 client per (endpoint, region) — request handlers construct a
19
+ # fresh S3BlobStore per call (it's tenant-scoped, tenant varies per
20
+ # request), but the underlying client is stateless w.r.t. tenant and
21
+ # reopening it every time paid a full TLS handshake per request against a
22
+ # remote endpoint (Supabase Storage) that a local MinIO/mock never surfaced.
23
+ _S3_CLIENTS: dict[tuple[str | None, str | None], Any] = {}
24
+
25
+
26
+ def _s3_client(endpoint_url: str | None, region_name: str | None) -> Any:
27
+ key = (endpoint_url, region_name)
28
+ client = _S3_CLIENTS.get(key)
29
+ if client is None:
30
+ # region_name: SigV4 (what every S3-compatible provider uses,
31
+ # including Supabase Storage) signs requests with a region baked in
32
+ # — omitting it relies on boto3/AWS_DEFAULT_REGION being set, which a
33
+ # fresh environment usually hasn't.
34
+ #
35
+ # max_pool_connections: botocore defaults to 10, which throttled the
36
+ # payloads-batch route's concurrent blob fetches (docs/plan.md §22) —
37
+ # the route fires up to 16 GETs in parallel, but only 10 could hold a
38
+ # pooled HTTP connection at once, so the rest queued behind them in
39
+ # waves instead of actually running concurrently. Raised to comfortably
40
+ # cover that batch size.
41
+ from botocore.config import Config
42
+
43
+ client = _S3_CLIENTS[key] = boto3.client(
44
+ "s3", endpoint_url=endpoint_url, region_name=region_name, config=Config(max_pool_connections=32)
45
+ )
46
+ return client
47
+
48
+
49
+ class S3BlobStore:
50
+ """Phase 0 `BlobStore` (docs/plan.md §4) — MinIO locally, S3 in cloud,
51
+ same code path both ways (docs/plan.md §2).
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ bucket: str,
57
+ endpoint_url: str | None = None,
58
+ region_name: str | None = None,
59
+ tenant_id: str = "",
60
+ ):
61
+ if not tenant_id:
62
+ raise ValueError("tenant_id is required")
63
+ self._bucket = bucket
64
+ self._tenant_id = tenant_id
65
+ self._s3 = _s3_client(endpoint_url, region_name)
66
+
67
+ def _object_key(self, key: str) -> str:
68
+ return f"{self._tenant_id}/blobs/{key}"
69
+
70
+ def put(self, data: bytes) -> str:
71
+ key = _key(data)
72
+ self._s3.put_object(Bucket=self._bucket, Key=self._object_key(key), Body=data)
73
+ return key
74
+
75
+ def get(self, key: str) -> bytes:
76
+ obj = self._s3.get_object(Bucket=self._bucket, Key=self._object_key(key))
77
+ return bytes(obj["Body"].read()) # boto3 has no usable stubs; .read() types as Any
78
+
79
+ def delete(self, key: str) -> None:
80
+ self._s3.delete_object(Bucket=self._bucket, Key=self._object_key(key))
81
+ # S3's delete_object is already idempotent (204 whether or not the key
82
+ # existed) — no not-found error to translate here.
83
+
84
+
85
+ class LocalBlobStore:
86
+ """Test adapter named explicitly in docs/plan.md §4 ("LocalBlobStore
87
+ (tests)"). Filesystem-backed, same content-addressing scheme.
88
+ """
89
+
90
+ def __init__(self, root: Path | str, tenant_id: str = ""):
91
+ if not tenant_id:
92
+ raise ValueError("tenant_id is required")
93
+ self._root = Path(root) / tenant_id / "blobs"
94
+ self._root.mkdir(parents=True, exist_ok=True)
95
+
96
+ def put(self, data: bytes) -> str:
97
+ key = _key(data)
98
+ path = self._root / key
99
+ if not path.exists():
100
+ path.write_bytes(data)
101
+ return key
102
+
103
+ def get(self, key: str) -> bytes:
104
+ path = self._root / key
105
+ if not path.exists():
106
+ raise KeyError(key)
107
+ return path.read_bytes()
108
+
109
+ def delete(self, key: str) -> None:
110
+ path = self._root / key
111
+ try:
112
+ path.unlink()
113
+ except FileNotFoundError:
114
+ raise KeyError(key) from None
115
+
116
+
117
+ def blob_store_factory_from_env() -> Callable[[str], BlobStore]:
118
+ """Shared by `api` and `ingest` `__main__` boot code so both point at the
119
+ same backend from the same env vars — local disk only works when both
120
+ services share a filesystem (e.g. the `make dev` bind mount); a real
121
+ multi-machine deployment needs `PRAMANA_BLOB_BACKEND=s3`.
122
+ """
123
+ backend = os.environ.get("PRAMANA_BLOB_BACKEND", "local")
124
+ if backend == "s3":
125
+ bucket = os.environ["PRAMANA_S3_BUCKET"]
126
+ endpoint_url = os.environ.get("PRAMANA_S3_ENDPOINT_URL")
127
+ region_name = os.environ.get("PRAMANA_S3_REGION")
128
+ return lambda tenant_id: S3BlobStore(
129
+ bucket, endpoint_url=endpoint_url, region_name=region_name, tenant_id=tenant_id
130
+ )
131
+ if backend != "local":
132
+ raise ValueError(f"unknown PRAMANA_BLOB_BACKEND: {backend!r}")
133
+ blob_dir = os.environ.get("PRAMANA_API_BLOB_DIR", ".pramana/blobs")
134
+ return lambda tenant_id: LocalBlobStore(blob_dir, tenant_id=tenant_id)
@@ -0,0 +1,89 @@
1
+ """Plan limits (docs/PROJECT_SCOPE.md §6), shared by every service that
2
+ needs to know what a tenant's plan allows.
3
+
4
+ Lives here, not in `pramana_api.billing`, specifically so `services/ingest`
5
+ can enforce the monthly event cap without depending on the whole api
6
+ service (accounts, Stripe, team management) — the same reason
7
+ `pramana_store.apikeys` exists instead of ingest importing `pramana_api`'s
8
+ auth module. `pramana_api.billing` re-exports everything here and adds the
9
+ write-side operations (`set_plan`, seat checks, Stripe linkage) that only
10
+ the api service needs.
11
+
12
+ ponytail: a plain dict, not a Plan class hierarchy — a handful of plans with
13
+ the same five fields each doesn't earn an abstraction yet.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ from pramana_store.postgres import pooled_connect
22
+
23
+ DEFAULT_PLAN = "free"
24
+
25
+ PLANS: dict[str, dict[str, Any]] = {
26
+ "free": {
27
+ "display_name": "Free",
28
+ "price_usd_per_month": 0,
29
+ "evidence_export": False,
30
+ "max_seats": 3,
31
+ "retention_days": 30,
32
+ # Nothing else bounds ingest volume for a free tenant — the
33
+ # per-tenant rate limiter (services/ingest) is a flat abuse-prevention
34
+ # ceiling, the same for every plan, not a billing differentiator. A
35
+ # single chatty free-tier key could otherwise ingest indefinitely at
36
+ # that ceiling for the whole 30-day retention window at real
37
+ # infrastructure cost and zero revenue. This is the actual cost
38
+ # driver (Postgres rows, S3 blobs, compute) that flat-tier pricing
39
+ # otherwise leaves completely unbounded.
40
+ "monthly_event_cap": 50_000,
41
+ },
42
+ "pro": {
43
+ "display_name": "Pro",
44
+ "price_usd_per_month": 99,
45
+ "evidence_export": True,
46
+ "max_seats": 10,
47
+ "retention_days": 365,
48
+ "monthly_event_cap": 2_000_000,
49
+ },
50
+ "enterprise": {
51
+ "display_name": "Enterprise",
52
+ # None means "contact sales" / "unlimited", not zero — same
53
+ # convention as max_seats/retention_days below.
54
+ "price_usd_per_month": None,
55
+ "evidence_export": True,
56
+ "max_seats": None,
57
+ "retention_days": None,
58
+ "monthly_event_cap": None,
59
+ },
60
+ }
61
+
62
+
63
+ class UnknownPlanError(ValueError):
64
+ pass
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class PlanLimits:
69
+ plan: str
70
+ display_name: str
71
+ price_usd_per_month: int | None
72
+ evidence_export: bool
73
+ max_seats: int | None
74
+ retention_days: int | None
75
+ monthly_event_cap: int | None
76
+
77
+
78
+ def plan_limits(plan: str) -> PlanLimits:
79
+ if plan not in PLANS:
80
+ raise UnknownPlanError(f"plan must be one of {sorted(PLANS)}, got {plan!r}")
81
+ return PlanLimits(plan=plan, **PLANS[plan])
82
+
83
+
84
+ def get_plan(conninfo: str, tenant_id: str) -> str:
85
+ with pooled_connect(conninfo) as conn:
86
+ row = conn.execute("SELECT plan FROM tenants WHERE id = %s", (tenant_id,)).fetchone()
87
+ if row is None:
88
+ raise ValueError(f"no such tenant: {tenant_id!r}")
89
+ return str(row[0])
@@ -0,0 +1,96 @@
1
+ """Two of the three seams named in docs/plan.md §4. Bare-noun Protocols, no
2
+ `I`/`Base` prefix. Each has (or is committed to have) >=2 real implementations.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Protocol
8
+
9
+ from pramana_proto.v1.event_pb2 import Event
10
+
11
+
12
+ class EventRepository(Protocol):
13
+ """Constructed with tenant_id — there is no query method that can forget
14
+ the tenant filter (docs/plan.md §4, "multi-tenancy is enforced structurally").
15
+ """
16
+
17
+ def append_if_new(self, event: Event) -> tuple[Event, bool]:
18
+ """Insert (tenant_id, event_id) if new; seals the hash chain itself
19
+ (prev_hash/this_hash are computed here, not supplied by the caller —
20
+ docs/plan.md D2). Returns (stored_event, was_new). On conflict,
21
+ returns the existing row unchanged (idempotent no-op).
22
+ """
23
+ ...
24
+
25
+ def chain_head(self, trace_id: str) -> str:
26
+ """Current this_hash at the head of trace_id's chain, or "" (genesis)."""
27
+ ...
28
+
29
+ def monthly_usage(self) -> int:
30
+ """Events accepted for this tenant in the current UTC month."""
31
+ ...
32
+
33
+ def get_by_call_site(
34
+ self, trace_id: str, agent_id: str, call_site_id: str, call_site_ordinal: int
35
+ ) -> Event | None: ...
36
+
37
+ def list_by_trace(self, trace_id: str) -> list[Event]: ...
38
+
39
+ def record_divergence(
40
+ self,
41
+ *,
42
+ trace_id: str,
43
+ agent_id: str,
44
+ call_site_id: str,
45
+ call_site_ordinal: int,
46
+ expected_input_hash: str,
47
+ actual_input_hash: str,
48
+ actual_input_ref: str = "",
49
+ ) -> None:
50
+ """Divergence is a product feature (docs/LLD.md §4b), not a fault —
51
+ recorded regardless of policy (HARD_STOP still raises after this)."""
52
+ ...
53
+
54
+ def list_divergences(self, trace_id: str) -> list[dict[str, Any]]: ...
55
+
56
+ def get_by_seq(self, trace_id: str, logical_seq: int, agent_id: str | None = None) -> Event | None: ...
57
+
58
+ def list_traces(self, limit: int = 200, before_started_at_ns: int | None = None) -> list[dict[str, Any]]: ...
59
+
60
+ def list_by_trace_range(self, trace_id: str, from_seq: int, to_seq: int) -> list[Event]: ...
61
+
62
+ def save_evidence_bundle(
63
+ self,
64
+ *,
65
+ bundle_id: str,
66
+ trace_id: str,
67
+ from_seq: int,
68
+ to_seq: int,
69
+ merkle_root: str,
70
+ signature: str,
71
+ blob_ref: str,
72
+ ) -> None: ...
73
+
74
+ def get_evidence_bundle(self, bundle_id: str) -> dict[str, Any] | None: ...
75
+
76
+ def list_evidence_bundles(self, trace_id: str) -> list[dict[str, Any]]: ...
77
+
78
+ def get_agent_graph(self, trace_id: str | None = None) -> list[dict[str, Any]]: ...
79
+
80
+
81
+ class BlobStore(Protocol):
82
+ """Content-addressed: key = sha256(bytes). Put is idempotent."""
83
+
84
+ def put(self, data: bytes) -> str:
85
+ """Returns the content-addressed key."""
86
+ ...
87
+
88
+ def get(self, key: str) -> bytes: ...
89
+
90
+ def delete(self, key: str) -> None:
91
+ """Retention (docs/plan.md §21.3) calls this only after confirming no
92
+ surviving event still references `key` — safe to raise KeyError /
93
+ FileNotFoundError on a missing key, the caller treats that as already
94
+ deleted rather than an error.
95
+ """
96
+ ...