policy-as-code-engine 0.2.0__tar.gz → 0.2.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.
Files changed (28) hide show
  1. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/PKG-INFO +10 -2
  2. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/README.md +9 -1
  3. policy_as_code_engine-0.2.1/docs/SYNTHETIC_PILOT.md +115 -0
  4. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/pyproject.toml +2 -2
  5. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/src/policy_as_code_engine/__init__.py +1 -1
  6. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/src/policy_as_code_engine/audit_stream.py +58 -7
  7. policy_as_code_engine-0.2.1/src/policy_as_code_engine/pilot_app.py +685 -0
  8. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/tests/test_app.py +4 -2
  9. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/tests/test_audit_stream.py +82 -7
  10. policy_as_code_engine-0.2.1/tests/test_pilot_app.py +419 -0
  11. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/.gitignore +0 -0
  12. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/LICENSE +0 -0
  13. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/examples/example-bundle.yaml +0 -0
  14. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/examples/example-context.json +0 -0
  15. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/src/policy_as_code_engine/__main__.py +0 -0
  16. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/src/policy_as_code_engine/app.py +0 -0
  17. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/src/policy_as_code_engine/card_attestation.py +0 -0
  18. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/src/policy_as_code_engine/evaluator.py +0 -0
  19. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/src/policy_as_code_engine/from_decision_card.py +0 -0
  20. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/src/policy_as_code_engine/loader.py +0 -0
  21. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/src/policy_as_code_engine/models.py +0 -0
  22. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/src/policy_as_code_engine/py.typed +0 -0
  23. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/tests/__init__.py +0 -0
  24. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/tests/test_card_attestation.py +0 -0
  25. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/tests/test_evaluator.py +0 -0
  26. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/tests/test_from_decision_card.py +0 -0
  27. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/tests/test_loader.py +0 -0
  28. {policy_as_code_engine-0.2.0 → policy_as_code_engine-0.2.1}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: policy-as-code-engine
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Declarative policy evaluator with Decision Card field conversion and optional best-effort audit events.
5
5
  Project-URL: Homepage, https://github.com/mizcausevic-dev/policy-as-code-engine
6
6
  Project-URL: Repository, https://github.com/mizcausevic-dev/policy-as-code-engine
@@ -232,7 +232,15 @@ Evaluate converted cards with `PolicyEvaluator.evaluate(bundle, context)` or the
232
232
 
233
233
  The API is a local/reference integration. Bearer roles, buyer-key checks, vendor/action binding, condition assertions, and request bounds protect its basic boundary, but it has no durable bundle/condition store, revocation feed, key-rotation workflow, tenant isolation, or built-in rate limiting. `decision.scope` remains free text and is **not** parsed into the action mapping. The evaluating service must derive `action` and `resource.vendor_id` from its own authenticated request, select the right bundle, and combine the result with its own subject/resource authorization. A card approval alone is not permission to run every operation. A buyer withdrawal after registration is not discovered automatically; operators must stop using the bundle and update the source of truth. Do not treat this reference API as standalone production authorization until those controls are designed and tested for the deployment.
234
234
 
235
- When `AUDIT_STREAM_URL` is configured, registration, condition assertion, and allow/deny events are posted synchronously and best effort. Free-text reasons, condition descriptions, and bundle sources are excluded from these outbound events; IDs may still be sensitive in some deployments. Failed delivery is not retried or durably queued and can add up to the configured timeout to a request. Use a trusted local audit path and a separate persistence design when audit completeness matters.
235
+ When `AUDIT_STREAM_URL` is configured with the private sink base URL or exact `/events` endpoint, also set `AUDIT_STREAM_TOKEN` to the sink's separate bearer credential (at least 32 visible ASCII characters) from a secret store. The URL requires HTTPS except for numeric loopback HTTP addresses. Registration, condition assertion, and allow/deny events are posted synchronously and best effort. Invalid configuration prevents outbound delivery and logs a failure; HTTP errors are logged without the URL or token. Free-text reasons, condition descriptions, and bundle sources are excluded from these outbound events; IDs may still be sensitive in some deployments. Failed delivery is not retried or durably queued and can add up to the configured timeout to a request. Use a trusted local audit path and a separate persistence design when audit completeness matters.
236
+
237
+ ### Opt-in synthetic pilot
238
+
239
+ The v0.2.1 source tree packages a separate `policy_as_code_engine.pilot_app:app` for **local synthetic drills**. It uses tenant-scoped, short-lived Ed25519 assertions; a local SQLite file for buyer-key/card/condition state and committed minimal receipts; per-evaluation card/key revocation checks; and a deny-all default. It has no generic bundle or one-shot evaluation route. The main reference API above does not invoke this pilot surface, and the published v0.2.0 package does not contain it. See [the synthetic pilot guide](docs/SYNTHETIC_PILOT.md) for configuration, tests, and limitations. A local SQLite file, fictional issuer, and loopback guard do not clear hosted customer-use gates.
240
+
241
+ ### v0.2.1 changes
242
+
243
+ This version adds authenticated, redirect-safe, best-effort audit delivery and packages the opt-in synthetic pilot module. If you already set `AUDIT_STREAM_URL`, configure the sink to require a separate bearer credential and set `AUDIT_STREAM_TOKEN` before upgrading. Without a valid token, audit delivery fails and is logged, while policy evaluation continues because delivery is best effort. This version does not add hosted authorization, audit completeness, or a revocation feed to the reference API. A source version string alone does not establish PyPI availability; check the tagged workflow and published artifacts before making a release claim.
236
244
 
237
245
  ### Migrating from 0.1.1 to 0.2.0
238
246
 
@@ -185,7 +185,15 @@ Evaluate converted cards with `PolicyEvaluator.evaluate(bundle, context)` or the
185
185
 
186
186
  The API is a local/reference integration. Bearer roles, buyer-key checks, vendor/action binding, condition assertions, and request bounds protect its basic boundary, but it has no durable bundle/condition store, revocation feed, key-rotation workflow, tenant isolation, or built-in rate limiting. `decision.scope` remains free text and is **not** parsed into the action mapping. The evaluating service must derive `action` and `resource.vendor_id` from its own authenticated request, select the right bundle, and combine the result with its own subject/resource authorization. A card approval alone is not permission to run every operation. A buyer withdrawal after registration is not discovered automatically; operators must stop using the bundle and update the source of truth. Do not treat this reference API as standalone production authorization until those controls are designed and tested for the deployment.
187
187
 
188
- When `AUDIT_STREAM_URL` is configured, registration, condition assertion, and allow/deny events are posted synchronously and best effort. Free-text reasons, condition descriptions, and bundle sources are excluded from these outbound events; IDs may still be sensitive in some deployments. Failed delivery is not retried or durably queued and can add up to the configured timeout to a request. Use a trusted local audit path and a separate persistence design when audit completeness matters.
188
+ When `AUDIT_STREAM_URL` is configured with the private sink base URL or exact `/events` endpoint, also set `AUDIT_STREAM_TOKEN` to the sink's separate bearer credential (at least 32 visible ASCII characters) from a secret store. The URL requires HTTPS except for numeric loopback HTTP addresses. Registration, condition assertion, and allow/deny events are posted synchronously and best effort. Invalid configuration prevents outbound delivery and logs a failure; HTTP errors are logged without the URL or token. Free-text reasons, condition descriptions, and bundle sources are excluded from these outbound events; IDs may still be sensitive in some deployments. Failed delivery is not retried or durably queued and can add up to the configured timeout to a request. Use a trusted local audit path and a separate persistence design when audit completeness matters.
189
+
190
+ ### Opt-in synthetic pilot
191
+
192
+ The v0.2.1 source tree packages a separate `policy_as_code_engine.pilot_app:app` for **local synthetic drills**. It uses tenant-scoped, short-lived Ed25519 assertions; a local SQLite file for buyer-key/card/condition state and committed minimal receipts; per-evaluation card/key revocation checks; and a deny-all default. It has no generic bundle or one-shot evaluation route. The main reference API above does not invoke this pilot surface, and the published v0.2.0 package does not contain it. See [the synthetic pilot guide](docs/SYNTHETIC_PILOT.md) for configuration, tests, and limitations. A local SQLite file, fictional issuer, and loopback guard do not clear hosted customer-use gates.
193
+
194
+ ### v0.2.1 changes
195
+
196
+ This version adds authenticated, redirect-safe, best-effort audit delivery and packages the opt-in synthetic pilot module. If you already set `AUDIT_STREAM_URL`, configure the sink to require a separate bearer credential and set `AUDIT_STREAM_TOKEN` before upgrading. Without a valid token, audit delivery fails and is logged, while policy evaluation continues because delivery is best effort. This version does not add hosted authorization, audit completeness, or a revocation feed to the reference API. A source version string alone does not establish PyPI availability; check the tagged workflow and published artifacts before making a release claim.
189
197
 
190
198
  ### Migrating from 0.1.1 to 0.2.0
191
199
 
@@ -0,0 +1,115 @@
1
+ # Local synthetic policy pilot
2
+
3
+ The v0.2.1 source tree packages `policy_as_code_engine.pilot_app:app`
4
+ as a separate, opt-in FastAPI surface. The published v0.2.0 package does not
5
+ contain it, and the main reference HTTP API does not invoke it. **Use fictional
6
+ buyers, vendors, cards, and assertions only.**
7
+ The pilot is not a customer authorization service or a hosted deployment.
8
+
9
+ ## What it proves locally
10
+
11
+ - A short-lived Ed25519 assertion must name the configured issuer and audience,
12
+ tenant, subject, and `admin` or `evaluate` role. Evaluation assertions must
13
+ also name the exact `use` action and vendor. The service builds the policy
14
+ context from that signed assertion, not from an evaluation request body.
15
+ - Buyer public keys are pinned to a tenant, buyer ID, key ID, and URL by a
16
+ pilot administrator. A positive v0.1 Decision Card needs its exact v2
17
+ attestation and a currently active key. The URL selects the enrolled key;
18
+ it does not prove the buyer's organizational authority.
19
+ - SQLite holds keys, derived bundles, condition assertions, and minimal audit
20
+ receipts on one local disk. Each state mutation and its receipt commit in one
21
+ transaction. Evaluation inserts a receipt before returning an `allow`.
22
+ Missing or unwritable state returns 503. A card or buyer-key revocation is
23
+ checked again on the next evaluation, including after process restart.
24
+ - Positive results require **both** `POLICY_PILOT_ALLOW_SYNTHETIC=1` and
25
+ `POLICY_PILOT_DENY_ALL=0`. The default is deny-all. The app's own entry point
26
+ binds `127.0.0.1:8090`, and a non-loopback caller is rejected. Requests are
27
+ capped at 128 KiB, 32 JSON nesting levels, and five seconds; responses
28
+ request `Cache-Control: no-store`.
29
+
30
+ ## Configuration
31
+
32
+ Install the repository with `pip install -e ".[dev]"`, then set these values
33
+ in the local process environment. Keep private signing keys outside the repo.
34
+ The service receives **public keys only**; an independent trusted caller mints
35
+ the signed assertions. Do not use the fictional keys in `tests/` for any real
36
+ identity or buyer enrollment.
37
+
38
+ | Variable | Purpose |
39
+ | --- | --- |
40
+ | `POLICY_PILOT_DB_PATH` | Absolute path to a local SQLite file in an existing private directory. It is created on startup. Do not place it on Cloud Run's ephemeral filesystem for a hosted claim. |
41
+ | `POLICY_PILOT_ISSUER` | Exact synthetic assertion issuer string. |
42
+ | `POLICY_PILOT_AUDIENCE` | Exact audience of this pilot service. |
43
+ | `POLICY_PILOT_ISSUER_PUBLIC_KEY_B64` | Base64 encoding of the issuer's 32-byte Ed25519 public key. |
44
+ | `POLICY_PILOT_ALLOW_SYNTHETIC` | Must equal `1` before any `allow` is possible. |
45
+ | `POLICY_PILOT_DENY_ALL` | Defaults to `1`; must equal `0` for a synthetic `allow`. Set to `1` first in a local rollback drill. |
46
+
47
+ The compact bearer assertion has a fixed `{"alg":"EdDSA","typ":"JWT"}`
48
+ header and claims `iss`, `aud`, `sub`, `tenant`, `role`, `iat`, `nbf`, and `exp`.
49
+ The `evaluate` role also needs signed `action="use"` and `vendor_id`. Maximum
50
+ lifetime is five minutes. The tenant is taken only from the verified assertion.
51
+ The issuer key and the process environment are operator trust boundaries, not
52
+ customer identity proof.
53
+
54
+ Run the app locally with:
55
+
56
+ ```powershell
57
+ $env:PYTHONPATH = 'C:\Users\chaus\OneDrive\Documents\ChatGPT\buyer-side governance Kinetic Gain\policy-as-code-engine\src'
58
+ python -m policy_as_code_engine.pilot_app
59
+ ```
60
+
61
+ The command requires the four mandatory variables above to use protected
62
+ routes. `GET /healthz` is liveness. `GET /readyz` checks the configured issuer,
63
+ database schema version, required tables and columns, and SQLite quick check;
64
+ it is **not** evidence that a hosted gateway, buyer
65
+ authority, egress policy, backup, or rollback exists.
66
+
67
+ ## API surface
68
+
69
+ | Route | Role | Result |
70
+ | --- | --- | --- |
71
+ | `POST /v1/keys` | tenant admin | Enroll one synthetic buyer public key and receipt. |
72
+ | `POST /v1/keys/{key_id}/revoke` | tenant admin | Revoke key; next evaluation of its cards denies. |
73
+ | `POST /v1/cards` | tenant admin | Verify a signed positive v0.1 card, derive one scoped bundle, record hash and receipt. Duplicate tenant/bundle IDs are rejected. |
74
+ | `POST /v1/bundles/{bundle_id}/revoke` | tenant admin | Revoke the card bundle. |
75
+ | `PUT /v1/bundles/{bundle_id}/conditions/{condition_id}` | tenant admin | Record a condition assertion valid for at most seven days. |
76
+ | `POST /v1/bundles/{bundle_id}/evaluate` | tenant evaluator | Derive action and vendor from the signed assertion; return `allow` or `deny` plus a committed receipt ID. Request bodies are rejected. |
77
+ | `GET /v1/receipts` | tenant admin | Last 100 minimal receipts for that tenant only. |
78
+
79
+ The pilot has no generic `/bundles`, one-shot `/evaluate`, raw card read,
80
+ customer login, or remote fetch route. Its stored bundle descriptions can
81
+ contain the synthetic card's vendor text, so the database remains synthetic.
82
+
83
+ ## Reproducible local drill
84
+
85
+ ```powershell
86
+ Set-Location 'C:\Users\chaus\OneDrive\Documents\ChatGPT\buyer-side governance Kinetic Gain\policy-as-code-engine'
87
+ $env:PYTHONPATH = (Resolve-Path 'src').Path
88
+ python -m pytest -q tests/test_pilot_app.py
89
+ ```
90
+
91
+ The tests generate temporary issuer and buyer keys and a temporary SQLite
92
+ file. They exercise default deny, scoped allow, a deny-all rollback switch,
93
+ wrong issuer audience, expired or tampered assertions, cross-tenant lookup,
94
+ wrong vendor, role separation, signed-card tampering, condition state,
95
+ old/new key overlap, key/card revocation across a restart, strict body limits,
96
+ headerless evaluation bodies, missing schema tables, and a known-positive audit
97
+ write failure. A 503 on the injected receipt write
98
+ failure is the expected fail-closed outcome.
99
+
100
+ ## What remains blocked
101
+
102
+ This SQLite file is not a shared multi-instance store; it has no backed-up
103
+ restore, retention scheduler, migration playbook, or transactional delivery
104
+ outbox to another audit sink. The pilot issuer is a configured synthetic key,
105
+ not enrolled customer identity; it has no issuer rotation or replay prevention
106
+ for write assertions. Key enrollment is an operator assertion and does not
107
+ verify a buyer's real-world authority. The local loopback guard is not a
108
+ hosted gateway, distributed rate limit, or network egress policy. No remote
109
+ vendor fetching or customer traffic should be enabled from this pilot.
110
+
111
+ For hosted use, see the separate workspace release plan and the live release
112
+ gates. A real rollback needs an immutable deployed prior revision, a traffic
113
+ switch, and a database backup or compatible forward repair demonstrated on
114
+ the named deployment target. Setting a local deny-all flag is only a local
115
+ control drill.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "policy-as-code-engine"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Declarative policy evaluator with Decision Card field conversion and optional best-effort audit events."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -70,7 +70,7 @@ Issues = "https://github.com/mizcausevic-dev/policy-as-code-engine/issues
70
70
  packages = ["src/policy_as_code_engine"]
71
71
 
72
72
  [tool.hatch.build.targets.sdist]
73
- only-include = ["src", "tests", "examples", "LICENSE", "README.md"]
73
+ only-include = ["src", "tests", "examples", "docs", "LICENSE", "README.md"]
74
74
 
75
75
  [tool.pytest.ini_options]
76
76
  testpaths = ["tests"]
@@ -33,7 +33,7 @@ from .models import (
33
33
  Rule,
34
34
  )
35
35
 
36
- __version__ = "0.2.0"
36
+ __version__ = "0.2.1"
37
37
 
38
38
  __all__ = [
39
39
  "Decision",
@@ -1,8 +1,9 @@
1
1
  """
2
2
  Optional audit-stream-py integration.
3
3
 
4
- When the `AUDIT_STREAM_URL` env var is set, this module fires governance
5
- events at `{AUDIT_STREAM_URL}/events` for the moments the service produces.
4
+ When `AUDIT_STREAM_URL` is set to the sink base URL (or its `/events`
5
+ endpoint), this module fires governance events at the normalized endpoint.
6
+ `AUDIT_STREAM_TOKEN` supplies the sink's bearer credential.
6
7
  Best-effort: a failed POST is logged, not raised — audit-stream outages
7
8
  must never block policy registration or evaluation.
8
9
 
@@ -15,15 +16,17 @@ Event kinds this service emits:
15
16
  is "deny"
16
17
  policy_condition_asserted on a successful admin condition assertion
17
18
 
18
- Same opt-in pattern as procurement-decision-api.audit_stream and
19
- aeo-validator-service.audit_stream. Identical config envvars.
19
+ The sink contract is aligned with procurement-decision-api.audit_stream.
20
20
  """
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
+ import ipaddress
24
25
  import math
25
26
  import os
27
+ import re
26
28
  from typing import Any
29
+ from urllib.parse import urlsplit, urlunsplit
27
30
 
28
31
  import httpx
29
32
 
@@ -43,6 +46,43 @@ def base_url() -> str | None:
43
46
  return raw.rstrip("/")
44
47
 
45
48
 
49
+ def events_url() -> str | None:
50
+ """Normalize a sink base or exact `/events` URL without forwarding URL credentials."""
51
+ raw = base_url()
52
+ if raw is None:
53
+ return None
54
+ try:
55
+ parsed = urlsplit(raw)
56
+ hostname = parsed.hostname
57
+ except ValueError:
58
+ return None
59
+ if (
60
+ parsed.scheme not in {"http", "https"}
61
+ or not parsed.netloc
62
+ or parsed.username is not None
63
+ or parsed.password is not None
64
+ or parsed.query
65
+ or parsed.fragment
66
+ ):
67
+ return None
68
+ if parsed.scheme == "http":
69
+ try:
70
+ if not ipaddress.ip_address(hostname or "").is_loopback:
71
+ return None
72
+ except ValueError:
73
+ return None
74
+ path = parsed.path.rstrip("/")
75
+ if not path.endswith("/events"):
76
+ path += "/events"
77
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
78
+
79
+
80
+ def audit_token() -> str | None:
81
+ """Return only a token accepted by the sink's configured-token syntax."""
82
+ token = os.environ.get("AUDIT_STREAM_TOKEN", "")
83
+ return token if re.fullmatch(r"[!-~]{32,}", token) else None
84
+
85
+
46
86
  def timeout_s() -> float:
47
87
  """Configured per-call timeout. Defaults to 2.5s."""
48
88
  raw = os.environ.get("AUDIT_STREAM_TIMEOUT_S", "").strip()
@@ -62,8 +102,12 @@ async def emit(
62
102
  payload: dict[str, Any],
63
103
  ) -> None:
64
104
  """Fire one event. Silent no-op when AUDIT_STREAM_URL is unset."""
65
- url = base_url()
66
- if url is None:
105
+ if not is_enabled():
106
+ return
107
+ url = events_url()
108
+ token = audit_token()
109
+ if url is None or token is None:
110
+ print(f"audit-stream emit failed (kind={kind}): InvalidConfiguration", flush=True)
67
111
  return
68
112
 
69
113
  body = {
@@ -73,11 +117,18 @@ async def emit(
73
117
  }
74
118
  try:
75
119
  response = await client.post(
76
- f"{url}/events",
120
+ url,
77
121
  json=body,
122
+ headers={"Authorization": f"Bearer {token}"},
123
+ follow_redirects=False,
78
124
  timeout=timeout_s(),
79
125
  )
80
126
  response.raise_for_status()
127
+ except httpx.HTTPStatusError as err:
128
+ print(
129
+ f"audit-stream emit failed (kind={kind}): HTTPStatusError status={err.response.status_code}",
130
+ flush=True,
131
+ )
81
132
  except (httpx.HTTPError, OSError) as err:
82
133
  print(
83
134
  f"audit-stream emit failed (kind={kind}): {type(err).__name__}",