themis-policy 0.1.0__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.
- themis_policy-0.1.0/PKG-INFO +61 -0
- themis_policy-0.1.0/README.md +41 -0
- themis_policy-0.1.0/pyproject.toml +38 -0
- themis_policy-0.1.0/setup.cfg +4 -0
- themis_policy-0.1.0/src/themis/__init__.py +30 -0
- themis_policy-0.1.0/src/themis/engine.py +128 -0
- themis_policy-0.1.0/src/themis/guards.py +27 -0
- themis_policy-0.1.0/src/themis/policies/__init__.py +6 -0
- themis_policy-0.1.0/src/themis/policies/draft.py +122 -0
- themis_policy-0.1.0/src/themis/policies/lock.py +60 -0
- themis_policy-0.1.0/src/themis/policies/scope.py +81 -0
- themis_policy-0.1.0/src/themis/py.typed +0 -0
- themis_policy-0.1.0/src/themis/sinks.py +65 -0
- themis_policy-0.1.0/src/themis/types.py +209 -0
- themis_policy-0.1.0/src/themis_policy.egg-info/PKG-INFO +61 -0
- themis_policy-0.1.0/src/themis_policy.egg-info/SOURCES.txt +18 -0
- themis_policy-0.1.0/src/themis_policy.egg-info/dependency_links.txt +1 -0
- themis_policy-0.1.0/src/themis_policy.egg-info/requires.txt +3 -0
- themis_policy-0.1.0/src/themis_policy.egg-info/top_level.txt +1 -0
- themis_policy-0.1.0/tests/test_conformance.py +139 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: themis-policy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Themis policy kernel for bounded-autonomy LLM agents — locks, drafts, scopes, audit. Python reference implementation of Themis RFC v0.
|
|
5
|
+
Author: Adam Campbell
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/Adam-Camp-King/Themis
|
|
8
|
+
Project-URL: Specification, https://github.com/Adam-Camp-King/Themis/tree/main/spec
|
|
9
|
+
Keywords: llm,agents,policy,safety,bounded-autonomy,multi-tenant,audit
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
20
|
+
|
|
21
|
+
# themis-policy
|
|
22
|
+
|
|
23
|
+
Python reference implementation of **Themis RFC v0** — a policy kernel for
|
|
24
|
+
LLM agents that act on production systems. Four primitives — **locks**,
|
|
25
|
+
**drafts**, **scopes**, **audit** — composed by one engine into a single
|
|
26
|
+
decision per attempted action: `allow`, `deny`, `redirect`, or
|
|
27
|
+
`require_approval`.
|
|
28
|
+
|
|
29
|
+
Byte-compatible with [`themis-policy`](https://github.com/Adam-Camp-King/Themis)
|
|
30
|
+
(TypeScript): both pass the same [conformance vectors](../spec/conformance).
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from themis import (
|
|
34
|
+
PolicyEngine, DefaultLockPolicy, DefaultScopePolicy, DefaultDraftPolicy,
|
|
35
|
+
Requestor, Action, PolicyContext, ConsoleSink, is_deny,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
engine = PolicyEngine(audit_sink=ConsoleSink())
|
|
39
|
+
engine.add_policy(DefaultLockPolicy())
|
|
40
|
+
engine.add_policy(DefaultScopePolicy())
|
|
41
|
+
engine.add_policy(DefaultDraftPolicy())
|
|
42
|
+
|
|
43
|
+
decision = engine.evaluate(PolicyContext(
|
|
44
|
+
requestor=Requestor(id="key_1", kind="api_key", tenant_id=7, scopes=("pages:read",)),
|
|
45
|
+
action=Action(verb="pages.update", resource_type="page", tenant_id=7, resource_id=42,
|
|
46
|
+
required_scope="pages:write"),
|
|
47
|
+
now=0, correlation_id="req-123",
|
|
48
|
+
))
|
|
49
|
+
if is_deny(decision):
|
|
50
|
+
print(decision.reason) # missing_scope
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The engine is synchronous (every core policy is pure CPU); `evaluate_async`
|
|
54
|
+
exists for callers already inside an event loop. Stores (`LockStore`,
|
|
55
|
+
`DraftStore`) are ports you implement against your persistence; audit sinks
|
|
56
|
+
receive one event per evaluation and may never fail an evaluation.
|
|
57
|
+
|
|
58
|
+
Install: `pip install themis-policy` (Python 3.11+, no dependencies).
|
|
59
|
+
Tests: `pip install -e '.[test]' && pytest`.
|
|
60
|
+
|
|
61
|
+
Apache-2.0 — see the repository LICENSE.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# themis-policy
|
|
2
|
+
|
|
3
|
+
Python reference implementation of **Themis RFC v0** — a policy kernel for
|
|
4
|
+
LLM agents that act on production systems. Four primitives — **locks**,
|
|
5
|
+
**drafts**, **scopes**, **audit** — composed by one engine into a single
|
|
6
|
+
decision per attempted action: `allow`, `deny`, `redirect`, or
|
|
7
|
+
`require_approval`.
|
|
8
|
+
|
|
9
|
+
Byte-compatible with [`themis-policy`](https://github.com/Adam-Camp-King/Themis)
|
|
10
|
+
(TypeScript): both pass the same [conformance vectors](../spec/conformance).
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from themis import (
|
|
14
|
+
PolicyEngine, DefaultLockPolicy, DefaultScopePolicy, DefaultDraftPolicy,
|
|
15
|
+
Requestor, Action, PolicyContext, ConsoleSink, is_deny,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
engine = PolicyEngine(audit_sink=ConsoleSink())
|
|
19
|
+
engine.add_policy(DefaultLockPolicy())
|
|
20
|
+
engine.add_policy(DefaultScopePolicy())
|
|
21
|
+
engine.add_policy(DefaultDraftPolicy())
|
|
22
|
+
|
|
23
|
+
decision = engine.evaluate(PolicyContext(
|
|
24
|
+
requestor=Requestor(id="key_1", kind="api_key", tenant_id=7, scopes=("pages:read",)),
|
|
25
|
+
action=Action(verb="pages.update", resource_type="page", tenant_id=7, resource_id=42,
|
|
26
|
+
required_scope="pages:write"),
|
|
27
|
+
now=0, correlation_id="req-123",
|
|
28
|
+
))
|
|
29
|
+
if is_deny(decision):
|
|
30
|
+
print(decision.reason) # missing_scope
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The engine is synchronous (every core policy is pure CPU); `evaluate_async`
|
|
34
|
+
exists for callers already inside an event loop. Stores (`LockStore`,
|
|
35
|
+
`DraftStore`) are ports you implement against your persistence; audit sinks
|
|
36
|
+
receive one event per evaluation and may never fail an evaluation.
|
|
37
|
+
|
|
38
|
+
Install: `pip install themis-policy` (Python 3.11+, no dependencies).
|
|
39
|
+
Tests: `pip install -e '.[test]' && pytest`.
|
|
40
|
+
|
|
41
|
+
Apache-2.0 — see the repository LICENSE.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "themis-policy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Themis policy kernel for bounded-autonomy LLM agents — locks, drafts, scopes, audit. Python reference implementation of Themis RFC v0."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
authors = [{ name = "Adam Campbell" }]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["llm", "agents", "policy", "safety", "bounded-autonomy", "multi-tenant", "audit"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/Adam-Camp-King/Themis"
|
|
26
|
+
Specification = "https://github.com/Adam-Camp-King/Themis/tree/main/spec"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = ["pytest>=8"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.package-data]
|
|
35
|
+
themis = ["py.typed"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Adam Campbell
|
|
3
|
+
"""Themis — a policy kernel for bounded-autonomy LLM agents.
|
|
4
|
+
|
|
5
|
+
Python reference implementation of Themis RFC v0. Four primitives — locks,
|
|
6
|
+
drafts, scopes, audit — composed by one engine into a single decision per
|
|
7
|
+
attempted action: allow, deny, redirect, or require_approval.
|
|
8
|
+
|
|
9
|
+
Conformance: ``spec/conformance/v0`` in the Themis repository holds the
|
|
10
|
+
cross-language vectors; this package and ``themis-policy`` pass the same set.
|
|
11
|
+
"""
|
|
12
|
+
from .types import (
|
|
13
|
+
Action, Allow, AuditEvent, AuditSink, Decision, Deny, DraftableEntity, DraftStore, Entity, Id,
|
|
14
|
+
LockableEntity, LockStore, Policy, PolicyContext, Redirect, RequireApproval, Requestor, RequestorKind,
|
|
15
|
+
decision_from_dict,
|
|
16
|
+
)
|
|
17
|
+
from .engine import PolicyEngine, EmitPolicy, build_audit_event
|
|
18
|
+
from .guards import is_allow, is_deny, is_redirect, is_require_approval
|
|
19
|
+
from .sinks import ConsoleSink, MemorySink, MultiSink, NoOpSink
|
|
20
|
+
from .policies import DefaultDraftPolicy, DefaultLockPolicy, DefaultScopePolicy
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Action", "Allow", "AuditEvent", "AuditSink", "Decision", "Deny", "DraftableEntity", "DraftStore", "Entity",
|
|
25
|
+
"Id", "LockableEntity", "LockStore", "Policy", "PolicyContext", "Redirect", "RequireApproval", "Requestor",
|
|
26
|
+
"RequestorKind", "decision_from_dict", "PolicyEngine", "EmitPolicy", "build_audit_event",
|
|
27
|
+
"is_allow", "is_deny", "is_redirect", "is_require_approval",
|
|
28
|
+
"ConsoleSink", "MemorySink", "MultiSink", "NoOpSink",
|
|
29
|
+
"DefaultDraftPolicy", "DefaultLockPolicy", "DefaultScopePolicy", "__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Adam Campbell
|
|
3
|
+
"""themis.engine — the Themis kernel (RFC v0 § 6).
|
|
4
|
+
|
|
5
|
+
Evaluation rules (normative, identical to ``themis-policy``):
|
|
6
|
+
|
|
7
|
+
0. Tenancy gate — requestor.tenant_id != action.tenant_id → deny
|
|
8
|
+
{policy: 'engine', reason: 'tenancy_mismatch'} before any policy runs.
|
|
9
|
+
1. Iterate policies in registration order.
|
|
10
|
+
2. ``deny`` short-circuits.
|
|
11
|
+
3. ``require_approval`` short-circuits.
|
|
12
|
+
4. ``redirect`` does NOT short-circuit; a later deny wins; otherwise the LAST
|
|
13
|
+
redirect wins, with the full policy chain recorded.
|
|
14
|
+
5. All ``allow`` → allow.
|
|
15
|
+
6. Exactly one audit event per evaluation (when emit_policy != 'off').
|
|
16
|
+
Sink failures never fail the evaluation (§ 9.4).
|
|
17
|
+
|
|
18
|
+
The engine is synchronous: every policy here is pure CPU. ``evaluate_async``
|
|
19
|
+
is provided for callers already inside an event loop.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from typing import Any, Literal, Mapping, Optional, Sequence
|
|
26
|
+
|
|
27
|
+
from .types import (
|
|
28
|
+
Allow, AuditEvent, AuditSink, Decision, Deny, Policy, PolicyContext, Redirect, RequireApproval,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
EmitPolicy = Literal["all", "denials_only", "off"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _now_ms() -> int:
|
|
35
|
+
return int(time.time() * 1000)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_audit_event(
|
|
39
|
+
ctx: PolicyContext, decision: Decision, policy_chain: Sequence[str], latency_ms: float,
|
|
40
|
+
) -> AuditEvent:
|
|
41
|
+
"""Denormalize requestor/action — an audit event must not carry live references."""
|
|
42
|
+
requestor: dict[str, Any] = {
|
|
43
|
+
"id": ctx.requestor.id,
|
|
44
|
+
"kind": ctx.requestor.kind,
|
|
45
|
+
"scopes": list(ctx.requestor.scopes) if ctx.requestor.scopes else None,
|
|
46
|
+
"role": ctx.requestor.role,
|
|
47
|
+
}
|
|
48
|
+
action: dict[str, Any] = {
|
|
49
|
+
"verb": ctx.action.verb,
|
|
50
|
+
"resource_type": ctx.action.resource_type,
|
|
51
|
+
"resource_id": ctx.action.resource_id,
|
|
52
|
+
"area": ctx.action.area,
|
|
53
|
+
"required_scope": ctx.action.required_scope,
|
|
54
|
+
}
|
|
55
|
+
return AuditEvent(
|
|
56
|
+
id=str(uuid.uuid4()),
|
|
57
|
+
timestamp=ctx.now or _now_ms(),
|
|
58
|
+
correlation_id=ctx.correlation_id,
|
|
59
|
+
tenant_id=ctx.action.tenant_id,
|
|
60
|
+
requestor=requestor,
|
|
61
|
+
action=action,
|
|
62
|
+
decision=decision,
|
|
63
|
+
policy_chain=tuple(policy_chain),
|
|
64
|
+
latency_ms=latency_ms,
|
|
65
|
+
metadata=ctx.action.metadata,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PolicyEngine:
|
|
70
|
+
def __init__(self, audit_sink: Optional[AuditSink] = None, emit_policy: EmitPolicy = "all") -> None:
|
|
71
|
+
self.audit_sink = audit_sink
|
|
72
|
+
self._emit_policy: EmitPolicy = emit_policy
|
|
73
|
+
self._registrations: list[tuple[Policy, Mapping[str, Any]]] = []
|
|
74
|
+
|
|
75
|
+
def add_policy(self, policy: Policy, config: Optional[Mapping[str, Any]] = None) -> None:
|
|
76
|
+
self._registrations.append((policy, dict(config or {})))
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def policies(self) -> tuple[Policy, ...]:
|
|
80
|
+
return tuple(p for p, _ in self._registrations)
|
|
81
|
+
|
|
82
|
+
def evaluate(self, ctx: PolicyContext) -> Decision:
|
|
83
|
+
start = time.perf_counter()
|
|
84
|
+
chain: list[str] = []
|
|
85
|
+
|
|
86
|
+
# Rule 0 — tenancy gate
|
|
87
|
+
if ctx.requestor.tenant_id != ctx.action.tenant_id:
|
|
88
|
+
decision: Decision = Deny(
|
|
89
|
+
policy="engine",
|
|
90
|
+
reason="tenancy_mismatch",
|
|
91
|
+
message="requestor and action belong to different tenants",
|
|
92
|
+
detail={"requestor_tenant": ctx.requestor.tenant_id, "action_tenant": ctx.action.tenant_id},
|
|
93
|
+
)
|
|
94
|
+
self._emit_if_permitted(ctx, decision, chain, start)
|
|
95
|
+
return decision
|
|
96
|
+
|
|
97
|
+
current_redirect: Optional[Redirect] = None
|
|
98
|
+
for policy, _config in self._registrations:
|
|
99
|
+
d = policy.evaluate(ctx)
|
|
100
|
+
chain.append(policy.name)
|
|
101
|
+
if isinstance(d, (Deny, RequireApproval)):
|
|
102
|
+
self._emit_if_permitted(ctx, d, chain, start)
|
|
103
|
+
return d
|
|
104
|
+
if isinstance(d, Redirect):
|
|
105
|
+
current_redirect = d # does not short-circuit
|
|
106
|
+
# Allow → continue
|
|
107
|
+
|
|
108
|
+
final: Decision = current_redirect if current_redirect is not None else Allow()
|
|
109
|
+
self._emit_if_permitted(ctx, final, chain, start)
|
|
110
|
+
return final
|
|
111
|
+
|
|
112
|
+
async def evaluate_async(self, ctx: PolicyContext) -> Decision:
|
|
113
|
+
return self.evaluate(ctx)
|
|
114
|
+
|
|
115
|
+
def _emit_if_permitted(self, ctx: PolicyContext, decision: Decision, chain: list[str], start: float) -> None:
|
|
116
|
+
if self.audit_sink is None or self._emit_policy == "off":
|
|
117
|
+
return
|
|
118
|
+
if self._emit_policy == "denials_only" and isinstance(decision, Allow):
|
|
119
|
+
return
|
|
120
|
+
latency_ms = (time.perf_counter() - start) * 1000.0
|
|
121
|
+
event = build_audit_event(ctx, decision, chain, latency_ms)
|
|
122
|
+
try:
|
|
123
|
+
self.audit_sink.emit(event)
|
|
124
|
+
except Exception: # noqa: BLE001 — RFC § 9.4: audit failure never fails evaluation
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = ["PolicyEngine", "EmitPolicy", "build_audit_event"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Adam Campbell
|
|
3
|
+
"""Decision narrowing helpers — the Python twins of ``themis-policy`` guards."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import TypeGuard
|
|
7
|
+
|
|
8
|
+
from .types import Allow, Decision, Deny, Redirect, RequireApproval
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_allow(d: Decision) -> TypeGuard[Allow]:
|
|
12
|
+
return isinstance(d, Allow)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_deny(d: Decision) -> TypeGuard[Deny]:
|
|
16
|
+
return isinstance(d, Deny)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_redirect(d: Decision) -> TypeGuard[Redirect]:
|
|
20
|
+
return isinstance(d, Redirect)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_require_approval(d: Decision) -> TypeGuard[RequireApproval]:
|
|
24
|
+
return isinstance(d, RequireApproval)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["is_allow", "is_deny", "is_redirect", "is_require_approval"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Adam Campbell
|
|
3
|
+
"""DefaultDraftPolicy — the T12 primitive (RFC v0 § 5.2, § 9.3).
|
|
4
|
+
|
|
5
|
+
Decision rule:
|
|
6
|
+
1. entity is None (create) → allow
|
|
7
|
+
2. entity is not draftable → allow (inert)
|
|
8
|
+
3. entity.is_published is not True → allow
|
|
9
|
+
4. action.payload['publish'] is True → allow (explicit live write)
|
|
10
|
+
5. otherwise → redirect {target: 'draft', payload}
|
|
11
|
+
|
|
12
|
+
Preview token — byte-identical to ``themis-policy``:
|
|
13
|
+
base64url( "v1.<entity_id>.<tenant_id>.<exp_ms>" + "." + hmac_sha256_hex )
|
|
14
|
+
Verify uses constant-time comparison, rejects expired/tampered/wrong-secret/
|
|
15
|
+
wrong-version tokens, and clamps TTL to 168 hours.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac
|
|
22
|
+
import math
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
from typing import Any, Mapping, Optional, Union
|
|
26
|
+
|
|
27
|
+
from ..types import Allow, Decision, DraftableEntity, Id, PolicyContext, Redirect
|
|
28
|
+
|
|
29
|
+
TOKEN_VERSION = "v1"
|
|
30
|
+
MAX_TTL_HOURS = 168
|
|
31
|
+
DEFAULT_TTL_HOURS = 24
|
|
32
|
+
_INT = re.compile(r"^-?\d+$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _b64url(s: str) -> str:
|
|
36
|
+
return base64.urlsafe_b64encode(s.encode("utf-8")).decode("ascii").rstrip("=")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _from_b64url(s: str) -> str:
|
|
40
|
+
pad = (4 - len(s) % 4) % 4
|
|
41
|
+
return base64.urlsafe_b64decode((s + "=" * pad).encode("ascii")).decode("utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _hmac(secret: str, payload: str) -> str:
|
|
45
|
+
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _clamp_ttl(hours: float) -> int:
|
|
49
|
+
if not isinstance(hours, (int, float)) or math.isnan(hours) or math.isinf(hours) or hours <= 0:
|
|
50
|
+
return DEFAULT_TTL_HOURS
|
|
51
|
+
if hours > MAX_TTL_HOURS:
|
|
52
|
+
return MAX_TTL_HOURS
|
|
53
|
+
return int(hours)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _id_maybe_numeric(s: str) -> Id:
|
|
57
|
+
if _INT.match(s):
|
|
58
|
+
n = int(s)
|
|
59
|
+
if -(2**53 - 1) <= n <= 2**53 - 1:
|
|
60
|
+
return n
|
|
61
|
+
return s
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DefaultDraftPolicy:
|
|
65
|
+
name = "draft"
|
|
66
|
+
|
|
67
|
+
def evaluate(self, ctx: PolicyContext) -> Decision:
|
|
68
|
+
entity = ctx.entity
|
|
69
|
+
if entity is None or not isinstance(entity, DraftableEntity):
|
|
70
|
+
return Allow(policy=self.name)
|
|
71
|
+
if entity.is_published is not True:
|
|
72
|
+
return Allow(policy=self.name)
|
|
73
|
+
payload = ctx.action.payload
|
|
74
|
+
if isinstance(payload, Mapping) and payload.get("publish") is True:
|
|
75
|
+
return Allow(policy=self.name)
|
|
76
|
+
return Redirect(policy=self.name, target="draft", payload=payload if payload is not None else None)
|
|
77
|
+
|
|
78
|
+
def merge(self, entity: DraftableEntity, draft_payload: Any) -> DraftableEntity:
|
|
79
|
+
return DraftableEntity(
|
|
80
|
+
id=entity.id, tenant_id=entity.tenant_id, is_published=True,
|
|
81
|
+
has_pending_draft=False, draft_updated_at=None, draft_updated_by=None,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def clear(self, entity: DraftableEntity) -> DraftableEntity:
|
|
85
|
+
return DraftableEntity(
|
|
86
|
+
id=entity.id, tenant_id=entity.tenant_id, is_published=entity.is_published,
|
|
87
|
+
has_pending_draft=False, draft_updated_at=None, draft_updated_by=None,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def sign_preview_token(self, entity_id: Id, tenant_id: Id, ttl_hours: float, secret: str) -> str:
|
|
91
|
+
if not secret:
|
|
92
|
+
raise ValueError("sign_preview_token: secret must be non-empty")
|
|
93
|
+
exp_ms = int(time.time() * 1000) + _clamp_ttl(ttl_hours) * 3_600_000
|
|
94
|
+
payload = f"{TOKEN_VERSION}.{entity_id}.{tenant_id}.{exp_ms}"
|
|
95
|
+
return _b64url(f"{payload}.{_hmac(secret, payload)}")
|
|
96
|
+
|
|
97
|
+
def verify_preview_token(self, token: str, secret: str) -> Optional[dict[str, Id]]:
|
|
98
|
+
if not token or not secret:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
decoded = _from_b64url(token)
|
|
102
|
+
except Exception: # noqa: BLE001 — malformed token is a None, never a raise
|
|
103
|
+
return None
|
|
104
|
+
parts = decoded.split(".")
|
|
105
|
+
if len(parts) != 5:
|
|
106
|
+
return None
|
|
107
|
+
version, entity_s, tenant_s, exp_s, mac = parts
|
|
108
|
+
if version != TOKEN_VERSION:
|
|
109
|
+
return None
|
|
110
|
+
expected = _hmac(secret, f"{version}.{entity_s}.{tenant_s}.{exp_s}")
|
|
111
|
+
if len(mac) != len(expected) or not hmac.compare_digest(mac, expected):
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
exp_ms = int(exp_s)
|
|
115
|
+
except ValueError:
|
|
116
|
+
return None
|
|
117
|
+
if exp_ms <= int(time.time() * 1000):
|
|
118
|
+
return None
|
|
119
|
+
return {"entity_id": _id_maybe_numeric(entity_s), "tenant_id": _id_maybe_numeric(tenant_s)}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__all__ = ["DefaultDraftPolicy"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Adam Campbell
|
|
3
|
+
"""DefaultLockPolicy — the T10 primitive (RFC v0 § 5.1).
|
|
4
|
+
|
|
5
|
+
Decision rule, first match wins:
|
|
6
|
+
1. entity is None (create) → allow
|
|
7
|
+
2. entity.agency_owner_id is None (self-serve) → allow
|
|
8
|
+
3. requestor.is_super_admin → allow
|
|
9
|
+
4. requestor.role == 'agency' → allow
|
|
10
|
+
5. requestor.id == entity.agency_owner_id → allow
|
|
11
|
+
6. action.area not set → allow
|
|
12
|
+
7. entity.locks[area] is not True → allow
|
|
13
|
+
8. otherwise → deny
|
|
14
|
+
Bypass rules 3–5 MUST NOT be removed by implementations.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Mapping, Sequence
|
|
19
|
+
|
|
20
|
+
from ..types import Allow, Decision, Deny, LockableEntity, PolicyContext
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DefaultLockPolicy:
|
|
24
|
+
name = "lock"
|
|
25
|
+
|
|
26
|
+
def evaluate(self, ctx: PolicyContext) -> Decision:
|
|
27
|
+
entity = ctx.entity
|
|
28
|
+
if entity is None or not isinstance(entity, LockableEntity):
|
|
29
|
+
return Allow(policy=self.name)
|
|
30
|
+
if entity.agency_owner_id is None:
|
|
31
|
+
return Allow(policy=self.name)
|
|
32
|
+
if ctx.requestor.is_super_admin is True:
|
|
33
|
+
return Allow(policy=self.name)
|
|
34
|
+
if ctx.requestor.role == "agency":
|
|
35
|
+
return Allow(policy=self.name)
|
|
36
|
+
if ctx.requestor.id == entity.agency_owner_id:
|
|
37
|
+
return Allow(policy=self.name)
|
|
38
|
+
area = ctx.action.area
|
|
39
|
+
if not area:
|
|
40
|
+
return Allow(policy=self.name)
|
|
41
|
+
if entity.locks.get(area) is not True:
|
|
42
|
+
return Allow(policy=self.name)
|
|
43
|
+
return Deny(
|
|
44
|
+
policy=self.name,
|
|
45
|
+
reason="agency_lock",
|
|
46
|
+
message=f"Area '{area}' is locked by the agency for this resource.",
|
|
47
|
+
detail={"area": area, "entity_id": entity.id, "agency_owner_id": entity.agency_owner_id},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def describe(self, entity: LockableEntity, areas: Sequence[str]) -> dict[str, bool]:
|
|
51
|
+
return {a: entity.locks.get(a) is True for a in areas}
|
|
52
|
+
|
|
53
|
+
def apply(self, entity: LockableEntity, areas: Sequence[str], locked: bool) -> LockableEntity:
|
|
54
|
+
nxt: dict[str, bool] = dict(entity.locks)
|
|
55
|
+
for a in areas:
|
|
56
|
+
nxt[a] = locked
|
|
57
|
+
return LockableEntity(id=entity.id, tenant_id=entity.tenant_id, agency_owner_id=entity.agency_owner_id, locks=nxt)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["DefaultLockPolicy"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Adam Campbell
|
|
3
|
+
"""DefaultScopePolicy — flat-membership scope authorization (RFC v0 § 5.3).
|
|
4
|
+
|
|
5
|
+
Decision rule, first match wins:
|
|
6
|
+
1. requestor.is_super_admin → allow
|
|
7
|
+
2. '*' in requestor.scopes → allow
|
|
8
|
+
3. requestor.kind == 'user' and no scopes → allow (session user)
|
|
9
|
+
4. required scope resolves to None → allow
|
|
10
|
+
5. required scope in requestor.scopes → allow
|
|
11
|
+
6. otherwise → deny
|
|
12
|
+
``required_scope`` comes from the action first, else from the registered rule
|
|
13
|
+
table (method + path string/regex, or a predicate). Flat membership: no
|
|
14
|
+
implied hierarchy; wildcards beyond '*' are extensions, not core.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import Callable, Optional, Pattern, Union
|
|
20
|
+
|
|
21
|
+
from ..types import Allow, Decision, Deny, PolicyContext
|
|
22
|
+
|
|
23
|
+
Matcher = Union[tuple[str, Union[str, Pattern[str]]], Callable[[PolicyContext], bool]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DefaultScopePolicy:
|
|
27
|
+
name = "scope"
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._rules: list[tuple[Matcher, str]] = []
|
|
31
|
+
|
|
32
|
+
def evaluate(self, ctx: PolicyContext) -> Decision:
|
|
33
|
+
r = ctx.requestor
|
|
34
|
+
if r.is_super_admin is True:
|
|
35
|
+
return Allow(policy=self.name)
|
|
36
|
+
if "*" in r.scopes:
|
|
37
|
+
return Allow(policy=self.name)
|
|
38
|
+
if r.kind == "user" and len(r.scopes) == 0:
|
|
39
|
+
return Allow(policy=self.name)
|
|
40
|
+
required = ctx.action.required_scope or self._resolve_required(ctx)
|
|
41
|
+
if not required:
|
|
42
|
+
return Allow(policy=self.name)
|
|
43
|
+
if required in r.scopes:
|
|
44
|
+
return Allow(policy=self.name)
|
|
45
|
+
return Deny(
|
|
46
|
+
policy=self.name,
|
|
47
|
+
reason="missing_scope",
|
|
48
|
+
message=f"Missing required scope '{required}'.",
|
|
49
|
+
detail={"required": required, "held": list(r.scopes)},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def add_rule(self, matcher: Matcher, required_scope: str) -> None:
|
|
53
|
+
self._rules.append((matcher, required_scope))
|
|
54
|
+
|
|
55
|
+
def list_scopes(self) -> list[str]:
|
|
56
|
+
seen: dict[str, None] = {}
|
|
57
|
+
for _, s in self._rules:
|
|
58
|
+
seen.setdefault(s, None)
|
|
59
|
+
return list(seen)
|
|
60
|
+
|
|
61
|
+
def _resolve_required(self, ctx: PolicyContext) -> Optional[str]:
|
|
62
|
+
meta = ctx.action.metadata or {}
|
|
63
|
+
method = str(meta.get("method", "") or "")
|
|
64
|
+
path = str(meta.get("path", "") or "")
|
|
65
|
+
for matcher, scope in self._rules:
|
|
66
|
+
if callable(matcher):
|
|
67
|
+
if matcher(ctx):
|
|
68
|
+
return scope
|
|
69
|
+
continue
|
|
70
|
+
m_method, m_path = matcher
|
|
71
|
+
if method.upper() != m_method.upper():
|
|
72
|
+
continue
|
|
73
|
+
if isinstance(m_path, str):
|
|
74
|
+
if path == m_path:
|
|
75
|
+
return scope
|
|
76
|
+
elif m_path.search(path):
|
|
77
|
+
return scope
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = ["DefaultScopePolicy"]
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Adam Campbell
|
|
3
|
+
"""Reference audit sinks (RFC v0 § 7.3, § 9.4)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Callable, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from .types import AuditEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConsoleSink:
|
|
14
|
+
"""NDJSON to stdout — one JSON line per event."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, out: Optional[Callable[[str], None]] = None) -> None:
|
|
17
|
+
self._out = out or (lambda s: sys.stdout.write(s))
|
|
18
|
+
|
|
19
|
+
def emit(self, event: AuditEvent) -> None:
|
|
20
|
+
self._out(json.dumps(event.to_dict(), default=str) + "\n")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NoOpSink:
|
|
24
|
+
def emit(self, event: AuditEvent) -> None: # noqa: D401 — intentional
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def flush(self) -> None:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MemorySink:
|
|
32
|
+
"""Collects events in memory — for tests and short-lived processes."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.events: list[AuditEvent] = []
|
|
36
|
+
|
|
37
|
+
def emit(self, event: AuditEvent) -> None:
|
|
38
|
+
self.events.append(event)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MultiSink:
|
|
42
|
+
"""Fan-out. One child failing never stops delivery to the others; if EVERY
|
|
43
|
+
child fails the first error is raised so the engine's § 9.4 catch sees it."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, children: Sequence) -> None:
|
|
46
|
+
self._children = list(children)
|
|
47
|
+
|
|
48
|
+
def emit(self, event: AuditEvent) -> None:
|
|
49
|
+
errors: list[BaseException] = []
|
|
50
|
+
for child in self._children:
|
|
51
|
+
try:
|
|
52
|
+
child.emit(event)
|
|
53
|
+
except Exception as e: # noqa: BLE001
|
|
54
|
+
errors.append(e)
|
|
55
|
+
if errors and len(errors) == len(self._children):
|
|
56
|
+
raise errors[0]
|
|
57
|
+
|
|
58
|
+
def flush(self) -> None:
|
|
59
|
+
for child in self._children:
|
|
60
|
+
flush = getattr(child, "flush", None)
|
|
61
|
+
if callable(flush):
|
|
62
|
+
flush()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["ConsoleSink", "NoOpSink", "MemorySink", "MultiSink"]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Adam Campbell
|
|
3
|
+
"""themis.types — the public types of the Themis policy kernel.
|
|
4
|
+
|
|
5
|
+
Mirrors ``themis-policy`` ``types.ts`` one-for-one and conforms to Themis RFC v0.
|
|
6
|
+
Decisions are frozen dataclasses with a ``kind`` discriminator; ``to_dict()``
|
|
7
|
+
produces the exact wire shape the RFC specifies (keys with ``None`` omitted),
|
|
8
|
+
which is what the cross-language conformance vectors compare.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Literal, Mapping, Optional, Protocol, Sequence, Union, runtime_checkable
|
|
14
|
+
|
|
15
|
+
Id = Union[int, str]
|
|
16
|
+
RequestorKind = Literal["user", "api_key", "agent", "service"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _drop_none(d: dict[str, Any]) -> dict[str, Any]:
|
|
20
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── 1 — Requestor ──────────────────────────────────────────────────────────
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Requestor:
|
|
26
|
+
id: Id
|
|
27
|
+
kind: RequestorKind
|
|
28
|
+
tenant_id: Id
|
|
29
|
+
scopes: tuple[str, ...] = ()
|
|
30
|
+
role: Optional[str] = None
|
|
31
|
+
is_super_admin: Optional[bool] = None
|
|
32
|
+
metadata: Optional[Mapping[str, Any]] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── 2 — Action ─────────────────────────────────────────────────────────────
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Action:
|
|
38
|
+
verb: str
|
|
39
|
+
resource_type: str
|
|
40
|
+
tenant_id: Id
|
|
41
|
+
resource_id: Optional[Id] = None
|
|
42
|
+
area: Optional[str] = None
|
|
43
|
+
required_scope: Optional[str] = None
|
|
44
|
+
payload: Any = None
|
|
45
|
+
metadata: Optional[Mapping[str, Any]] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── 6/7 — Entities ─────────────────────────────────────────────────────────
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class LockableEntity:
|
|
51
|
+
id: Id
|
|
52
|
+
tenant_id: Id
|
|
53
|
+
agency_owner_id: Optional[Id]
|
|
54
|
+
locks: Mapping[str, bool]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class DraftableEntity:
|
|
59
|
+
id: Id
|
|
60
|
+
tenant_id: Id
|
|
61
|
+
is_published: bool
|
|
62
|
+
has_pending_draft: bool
|
|
63
|
+
draft_updated_at: Optional[int]
|
|
64
|
+
draft_updated_by: Optional[Id]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Entity = Union[LockableEntity, DraftableEntity]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── 3 — PolicyContext ──────────────────────────────────────────────────────
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class PolicyContext:
|
|
73
|
+
requestor: Requestor
|
|
74
|
+
action: Action
|
|
75
|
+
now: int
|
|
76
|
+
correlation_id: str
|
|
77
|
+
entity: Optional[Entity] = None
|
|
78
|
+
policy_metadata: Optional[Mapping[str, Mapping[str, Any]]] = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── 4 — Decisions (discriminated union) ────────────────────────────────────
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Allow:
|
|
84
|
+
kind: Literal["allow"] = "allow"
|
|
85
|
+
policy: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
return _drop_none({"kind": self.kind, "policy": self.policy})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Deny:
|
|
93
|
+
policy: str
|
|
94
|
+
reason: str
|
|
95
|
+
message: Optional[str] = None
|
|
96
|
+
detail: Optional[Mapping[str, Any]] = None
|
|
97
|
+
kind: Literal["deny"] = "deny"
|
|
98
|
+
|
|
99
|
+
def to_dict(self) -> dict[str, Any]:
|
|
100
|
+
return _drop_none({
|
|
101
|
+
"kind": self.kind, "policy": self.policy, "reason": self.reason,
|
|
102
|
+
"message": self.message, "detail": dict(self.detail) if self.detail is not None else None,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Redirect:
|
|
108
|
+
policy: str
|
|
109
|
+
payload: Any
|
|
110
|
+
target: Literal["draft"] = "draft"
|
|
111
|
+
kind: Literal["redirect"] = "redirect"
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> dict[str, Any]:
|
|
114
|
+
return {"kind": self.kind, "policy": self.policy, "target": self.target, "payload": self.payload}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class RequireApproval:
|
|
119
|
+
policy: str
|
|
120
|
+
approval_ref: str
|
|
121
|
+
message: Optional[str] = None
|
|
122
|
+
kind: Literal["require_approval"] = "require_approval"
|
|
123
|
+
|
|
124
|
+
def to_dict(self) -> dict[str, Any]:
|
|
125
|
+
return _drop_none({
|
|
126
|
+
"kind": self.kind, "policy": self.policy,
|
|
127
|
+
"approval_ref": self.approval_ref, "message": self.message,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
Decision = Union[Allow, Deny, Redirect, RequireApproval]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def decision_from_dict(d: Mapping[str, Any]) -> Decision:
|
|
135
|
+
"""Inverse of ``to_dict`` — used by adapters and the conformance harness."""
|
|
136
|
+
kind = d.get("kind")
|
|
137
|
+
if kind == "allow":
|
|
138
|
+
return Allow(policy=d.get("policy"))
|
|
139
|
+
if kind == "deny":
|
|
140
|
+
return Deny(policy=d["policy"], reason=d["reason"], message=d.get("message"), detail=d.get("detail"))
|
|
141
|
+
if kind == "redirect":
|
|
142
|
+
return Redirect(policy=d["policy"], payload=d.get("payload"), target=d.get("target", "draft"))
|
|
143
|
+
if kind == "require_approval":
|
|
144
|
+
return RequireApproval(policy=d["policy"], approval_ref=d["approval_ref"], message=d.get("message"))
|
|
145
|
+
raise ValueError(f"unknown decision kind: {kind!r}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── 5 — AuditEvent ─────────────────────────────────────────────────────────
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class AuditEvent:
|
|
151
|
+
id: str
|
|
152
|
+
timestamp: int
|
|
153
|
+
correlation_id: str
|
|
154
|
+
tenant_id: Id
|
|
155
|
+
requestor: Mapping[str, Any]
|
|
156
|
+
action: Mapping[str, Any]
|
|
157
|
+
decision: Decision
|
|
158
|
+
policy_chain: tuple[str, ...]
|
|
159
|
+
latency_ms: float
|
|
160
|
+
metadata: Optional[Mapping[str, Any]] = None
|
|
161
|
+
|
|
162
|
+
def to_dict(self) -> dict[str, Any]:
|
|
163
|
+
return _drop_none({
|
|
164
|
+
"id": self.id,
|
|
165
|
+
"timestamp": self.timestamp,
|
|
166
|
+
"correlation_id": self.correlation_id,
|
|
167
|
+
"tenant_id": self.tenant_id,
|
|
168
|
+
"requestor": _drop_none(dict(self.requestor)),
|
|
169
|
+
"action": _drop_none(dict(self.action)),
|
|
170
|
+
"decision": self.decision.to_dict(),
|
|
171
|
+
"policy_chain": list(self.policy_chain),
|
|
172
|
+
"latency_ms": self.latency_ms,
|
|
173
|
+
"metadata": dict(self.metadata) if self.metadata is not None else None,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ── 9/12 — Ports ───────────────────────────────────────────────────────────
|
|
178
|
+
@runtime_checkable
|
|
179
|
+
class Policy(Protocol):
|
|
180
|
+
name: str
|
|
181
|
+
|
|
182
|
+
def evaluate(self, ctx: PolicyContext) -> Decision: ...
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@runtime_checkable
|
|
186
|
+
class AuditSink(Protocol):
|
|
187
|
+
def emit(self, event: AuditEvent) -> None: ...
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class LockStore(Protocol):
|
|
191
|
+
def get(self, entity_id: Id) -> Optional[LockableEntity]: ...
|
|
192
|
+
def set_lock(self, entity_id: Id, areas: Sequence[str], locked: bool) -> LockableEntity: ...
|
|
193
|
+
def request_unlock(self, entity_id: Id, requestor_id: Id, areas: Sequence[str], reason: str) -> None: ...
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class DraftStore(Protocol):
|
|
197
|
+
def get_entity(self, entity_id: Id) -> Optional[DraftableEntity]: ...
|
|
198
|
+
def write_draft(self, entity_id: Id, draft_payload: Any, updated_by: Id) -> None: ...
|
|
199
|
+
def write_live(self, entity_id: Id, payload: Any) -> None: ...
|
|
200
|
+
def list_pending_drafts(self, tenant_id: Id) -> Sequence[DraftableEntity]: ...
|
|
201
|
+
def publish(self, entity_id: Id) -> DraftableEntity: ...
|
|
202
|
+
def discard(self, entity_id: Id) -> DraftableEntity: ...
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
__all__ = [
|
|
206
|
+
"Id", "RequestorKind", "Requestor", "Action", "LockableEntity", "DraftableEntity", "Entity",
|
|
207
|
+
"PolicyContext", "Allow", "Deny", "Redirect", "RequireApproval", "Decision", "decision_from_dict",
|
|
208
|
+
"AuditEvent", "Policy", "AuditSink", "LockStore", "DraftStore",
|
|
209
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: themis-policy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Themis policy kernel for bounded-autonomy LLM agents — locks, drafts, scopes, audit. Python reference implementation of Themis RFC v0.
|
|
5
|
+
Author: Adam Campbell
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/Adam-Camp-King/Themis
|
|
8
|
+
Project-URL: Specification, https://github.com/Adam-Camp-King/Themis/tree/main/spec
|
|
9
|
+
Keywords: llm,agents,policy,safety,bounded-autonomy,multi-tenant,audit
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
20
|
+
|
|
21
|
+
# themis-policy
|
|
22
|
+
|
|
23
|
+
Python reference implementation of **Themis RFC v0** — a policy kernel for
|
|
24
|
+
LLM agents that act on production systems. Four primitives — **locks**,
|
|
25
|
+
**drafts**, **scopes**, **audit** — composed by one engine into a single
|
|
26
|
+
decision per attempted action: `allow`, `deny`, `redirect`, or
|
|
27
|
+
`require_approval`.
|
|
28
|
+
|
|
29
|
+
Byte-compatible with [`themis-policy`](https://github.com/Adam-Camp-King/Themis)
|
|
30
|
+
(TypeScript): both pass the same [conformance vectors](../spec/conformance).
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from themis import (
|
|
34
|
+
PolicyEngine, DefaultLockPolicy, DefaultScopePolicy, DefaultDraftPolicy,
|
|
35
|
+
Requestor, Action, PolicyContext, ConsoleSink, is_deny,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
engine = PolicyEngine(audit_sink=ConsoleSink())
|
|
39
|
+
engine.add_policy(DefaultLockPolicy())
|
|
40
|
+
engine.add_policy(DefaultScopePolicy())
|
|
41
|
+
engine.add_policy(DefaultDraftPolicy())
|
|
42
|
+
|
|
43
|
+
decision = engine.evaluate(PolicyContext(
|
|
44
|
+
requestor=Requestor(id="key_1", kind="api_key", tenant_id=7, scopes=("pages:read",)),
|
|
45
|
+
action=Action(verb="pages.update", resource_type="page", tenant_id=7, resource_id=42,
|
|
46
|
+
required_scope="pages:write"),
|
|
47
|
+
now=0, correlation_id="req-123",
|
|
48
|
+
))
|
|
49
|
+
if is_deny(decision):
|
|
50
|
+
print(decision.reason) # missing_scope
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The engine is synchronous (every core policy is pure CPU); `evaluate_async`
|
|
54
|
+
exists for callers already inside an event loop. Stores (`LockStore`,
|
|
55
|
+
`DraftStore`) are ports you implement against your persistence; audit sinks
|
|
56
|
+
receive one event per evaluation and may never fail an evaluation.
|
|
57
|
+
|
|
58
|
+
Install: `pip install themis-policy` (Python 3.11+, no dependencies).
|
|
59
|
+
Tests: `pip install -e '.[test]' && pytest`.
|
|
60
|
+
|
|
61
|
+
Apache-2.0 — see the repository LICENSE.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/themis/__init__.py
|
|
4
|
+
src/themis/engine.py
|
|
5
|
+
src/themis/guards.py
|
|
6
|
+
src/themis/py.typed
|
|
7
|
+
src/themis/sinks.py
|
|
8
|
+
src/themis/types.py
|
|
9
|
+
src/themis/policies/__init__.py
|
|
10
|
+
src/themis/policies/draft.py
|
|
11
|
+
src/themis/policies/lock.py
|
|
12
|
+
src/themis/policies/scope.py
|
|
13
|
+
src/themis_policy.egg-info/PKG-INFO
|
|
14
|
+
src/themis_policy.egg-info/SOURCES.txt
|
|
15
|
+
src/themis_policy.egg-info/dependency_links.txt
|
|
16
|
+
src/themis_policy.egg-info/requires.txt
|
|
17
|
+
src/themis_policy.egg-info/top_level.txt
|
|
18
|
+
tests/test_conformance.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
themis
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Run spec/conformance/v0/*.json and preview-tokens.json through the Python kernel.
|
|
3
|
+
|
|
4
|
+
The same files are run by themis-policy. Passing them is what "conforms to
|
|
5
|
+
Themis RFC v0" means (RFC § 11).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import pathlib
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from themis import (
|
|
16
|
+
Action, Allow, DefaultDraftPolicy, DefaultLockPolicy, DefaultScopePolicy, Deny, DraftableEntity,
|
|
17
|
+
LockableEntity, MemorySink, PolicyContext, PolicyEngine, Redirect, RequireApproval, Requestor,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SPEC = pathlib.Path(__file__).resolve().parents[2] / "spec" / "conformance"
|
|
21
|
+
VECTOR_FILES = sorted((SPEC / "v0").glob("*.json"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Stub:
|
|
25
|
+
def __init__(self, spec: dict[str, Any]) -> None:
|
|
26
|
+
self.name = spec["name"]
|
|
27
|
+
self._spec = spec
|
|
28
|
+
|
|
29
|
+
def evaluate(self, ctx: PolicyContext):
|
|
30
|
+
s = self._spec
|
|
31
|
+
kind = s["stub"]
|
|
32
|
+
if kind == "allow":
|
|
33
|
+
return Allow(policy=self.name)
|
|
34
|
+
if kind == "deny":
|
|
35
|
+
return Deny(policy=self.name, reason=str(s.get("reason", "stub")), message=s.get("message"))
|
|
36
|
+
if kind == "redirect":
|
|
37
|
+
return Redirect(policy=self.name, target="draft", payload=s.get("payload"))
|
|
38
|
+
if kind == "require_approval":
|
|
39
|
+
return RequireApproval(policy=self.name, approval_ref=str(s.get("approval_ref", "ref")), message=s.get("message"))
|
|
40
|
+
raise ValueError(kind)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _build(spec: Any):
|
|
44
|
+
if spec == "lock":
|
|
45
|
+
return DefaultLockPolicy()
|
|
46
|
+
if spec == "draft":
|
|
47
|
+
return DefaultDraftPolicy()
|
|
48
|
+
if spec == "scope":
|
|
49
|
+
return DefaultScopePolicy()
|
|
50
|
+
if "stub" in spec:
|
|
51
|
+
return _Stub(spec)
|
|
52
|
+
p = DefaultScopePolicy()
|
|
53
|
+
for r in spec.get("rules", []):
|
|
54
|
+
p.add_rule((r["method"], r["path"]), r["required_scope"])
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _entity(e: Any):
|
|
59
|
+
if e is None:
|
|
60
|
+
return None
|
|
61
|
+
if "locks" in e and "agency_owner_id" in e:
|
|
62
|
+
return LockableEntity(id=e["id"], tenant_id=e["tenant_id"], agency_owner_id=e["agency_owner_id"], locks=dict(e["locks"]))
|
|
63
|
+
return DraftableEntity(
|
|
64
|
+
id=e["id"], tenant_id=e["tenant_id"], is_published=e["is_published"],
|
|
65
|
+
has_pending_draft=e.get("has_pending_draft", False),
|
|
66
|
+
draft_updated_at=e.get("draft_updated_at"), draft_updated_by=e.get("draft_updated_by"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _canon(v: Any) -> Any:
|
|
71
|
+
if isinstance(v, list):
|
|
72
|
+
return [_canon(x) for x in v]
|
|
73
|
+
if isinstance(v, dict):
|
|
74
|
+
return {k: _canon(x) for k, x in v.items() if x is not None}
|
|
75
|
+
return v
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cases():
|
|
79
|
+
for f in VECTOR_FILES:
|
|
80
|
+
for c in json.loads(f.read_text())["cases"]:
|
|
81
|
+
yield pytest.param(c, id=f"{f.name} › {c['name']}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize("c", list(_cases()))
|
|
85
|
+
def test_vector(c: dict[str, Any]) -> None:
|
|
86
|
+
sink = MemorySink()
|
|
87
|
+
engine = PolicyEngine(audit_sink=sink, emit_policy=c.get("emit_policy", "all"))
|
|
88
|
+
for p in c.get("policies", []):
|
|
89
|
+
engine.add_policy(_build(p))
|
|
90
|
+
r, a = c["requestor"], c["action"]
|
|
91
|
+
ctx = PolicyContext(
|
|
92
|
+
requestor=Requestor(id=r["id"], kind=r["kind"], tenant_id=r["tenant_id"], scopes=tuple(r.get("scopes", [])),
|
|
93
|
+
role=r.get("role"), is_super_admin=r.get("is_super_admin")),
|
|
94
|
+
action=Action(verb=a["verb"], resource_type=a["resource_type"], tenant_id=a["tenant_id"], resource_id=a.get("resource_id"),
|
|
95
|
+
area=a.get("area"), required_scope=a.get("required_scope"), payload=a.get("payload"), metadata=a.get("metadata")),
|
|
96
|
+
entity=_entity(c.get("entity")),
|
|
97
|
+
now=1_700_000_000_000,
|
|
98
|
+
correlation_id=f"conf-{c['name']}",
|
|
99
|
+
)
|
|
100
|
+
decision = engine.evaluate(ctx)
|
|
101
|
+
assert _canon(decision.to_dict()) == _canon(c["expect"]["decision"])
|
|
102
|
+
audit = c["expect"].get("audit")
|
|
103
|
+
if audit == "none":
|
|
104
|
+
assert sink.events == []
|
|
105
|
+
return
|
|
106
|
+
assert len(sink.events) == 1, "exactly one audit event per evaluation"
|
|
107
|
+
e = sink.events[0]
|
|
108
|
+
assert list(e.policy_chain) == c["expect"]["policy_chain"]
|
|
109
|
+
assert _canon(e.decision.to_dict()) == _canon(c["expect"]["decision"])
|
|
110
|
+
assert e.tenant_id == a["tenant_id"]
|
|
111
|
+
assert e.correlation_id == ctx.correlation_id
|
|
112
|
+
if isinstance(audit, dict):
|
|
113
|
+
got = _canon({"requestor": dict(e.requestor), "action": dict(e.action)})
|
|
114
|
+
assert got == _canon(audit)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_preview_tokens() -> None:
|
|
118
|
+
doc = json.loads((SPEC / "preview-tokens.json").read_text())
|
|
119
|
+
p = DefaultDraftPolicy()
|
|
120
|
+
for c in doc["cases"]:
|
|
121
|
+
assert p.verify_preview_token(c["token"], doc["secret"]) == c["expect"], c["name"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_sign_then_verify_roundtrip_and_ttl_clamp() -> None:
|
|
125
|
+
p = DefaultDraftPolicy()
|
|
126
|
+
tok = p.sign_preview_token(42, 7, 24, "s3cret")
|
|
127
|
+
assert p.verify_preview_token(tok, "s3cret") == {"entity_id": 42, "tenant_id": 7}
|
|
128
|
+
assert p.verify_preview_token(tok, "other") is None
|
|
129
|
+
with pytest.raises(ValueError):
|
|
130
|
+
p.sign_preview_token(1, 1, 1, "")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_audit_sink_failure_never_fails_evaluation() -> None:
|
|
134
|
+
class Boom:
|
|
135
|
+
def emit(self, event): # noqa: ANN001
|
|
136
|
+
raise RuntimeError("sink down")
|
|
137
|
+
engine = PolicyEngine(audit_sink=Boom())
|
|
138
|
+
r = Requestor(id=1, kind="user", tenant_id=1)
|
|
139
|
+
assert engine.evaluate(PolicyContext(requestor=r, action=Action(verb="x", resource_type="y", tenant_id=1), now=0, correlation_id="c")) == Allow()
|