jep-authority-runtime 0.1.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.
- jep_authority_runtime-0.1.1/PKG-INFO +90 -0
- jep_authority_runtime-0.1.1/README.md +78 -0
- jep_authority_runtime-0.1.1/jep_authority/__init__.py +19 -0
- jep_authority_runtime-0.1.1/jep_authority/cli.py +43 -0
- jep_authority_runtime-0.1.1/jep_authority/runtime.py +420 -0
- jep_authority_runtime-0.1.1/jep_authority_runtime.egg-info/PKG-INFO +90 -0
- jep_authority_runtime-0.1.1/jep_authority_runtime.egg-info/SOURCES.txt +13 -0
- jep_authority_runtime-0.1.1/jep_authority_runtime.egg-info/dependency_links.txt +1 -0
- jep_authority_runtime-0.1.1/jep_authority_runtime.egg-info/entry_points.txt +2 -0
- jep_authority_runtime-0.1.1/jep_authority_runtime.egg-info/top_level.txt +1 -0
- jep_authority_runtime-0.1.1/pyproject.toml +23 -0
- jep_authority_runtime-0.1.1/setup.cfg +4 -0
- jep_authority_runtime-0.1.1/tests/test_hardening.py +27 -0
- jep_authority_runtime-0.1.1/tests/test_resource_bounds.py +15 -0
- jep_authority_runtime-0.1.1/tests/test_runtime.py +92 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jep-authority-runtime
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Reference runtime for JEP-compatible authority scope semantics.
|
|
5
|
+
Author: JEP Authority Runtime contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: jep,authority,delegation,attenuation,replay
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# jep-authority-runtime
|
|
14
|
+
|
|
15
|
+
Reference runtime for JEP authority scope, delegation boundaries, attenuation, revocation, and replay verification.
|
|
16
|
+
|
|
17
|
+
This project is intentionally **not** real IAM and does not replace OAuth, X.509, DID, or any production authorization protocol. It is a small JEP-compatible reference runtime for making delegation semantics replayable and verifiable.
|
|
18
|
+
|
|
19
|
+
## AuthorityScope
|
|
20
|
+
|
|
21
|
+
`AuthorityScope` models the authority passed along a delegation chain:
|
|
22
|
+
|
|
23
|
+
- `actor`: principal that issued the scope.
|
|
24
|
+
- `subject`: principal receiving the scope.
|
|
25
|
+
- `allowed_actions`: actions the subject may perform.
|
|
26
|
+
- `denied_actions`: actions explicitly forbidden and inherited by descendants.
|
|
27
|
+
- `resource_scope`: resource prefix the scope applies to (`*` means every resource).
|
|
28
|
+
- `expires_at`: optional UTC expiration timestamp.
|
|
29
|
+
- `parent_scope`: optional parent scope id for delegated or attenuated authority.
|
|
30
|
+
- `attenuation_rules`: reference metadata describing how authority was narrowed.
|
|
31
|
+
|
|
32
|
+
The runtime also stores `scope_id` and `revoked_at` so archives can refer to scopes deterministically and model revocation.
|
|
33
|
+
|
|
34
|
+
## DelegationRuntime
|
|
35
|
+
|
|
36
|
+
`DelegationRuntime` provides:
|
|
37
|
+
|
|
38
|
+
- `create_scope()` for root authority grants.
|
|
39
|
+
- `delegate_scope()` for child scopes that must be narrower than their parent.
|
|
40
|
+
- `attenuate_scope()` as a convenience wrapper for narrower delegation.
|
|
41
|
+
- `revoke_scope()` to invalidate a scope and its descendants for later checks.
|
|
42
|
+
- `verify_scope()` to check action, resource, expiration, revocation, and chain validity.
|
|
43
|
+
- `verify_delegation_path()` to check parent continuity and attenuation constraints.
|
|
44
|
+
|
|
45
|
+
A child scope is valid only when it does not add actions, omit inherited denials, widen resources, outlive its parent, or rely on a missing/revoked/expired parent.
|
|
46
|
+
|
|
47
|
+
## Replay archive format
|
|
48
|
+
|
|
49
|
+
Archives are JSON Lines (`archive.jsonl`). Each line is an event with an `event` field:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{"event":"create_scope","scope_id":"human-search","actor":"human:alice","subject":"agent:searcher","allowed_actions":["search"],"denied_actions":["payment"],"resource_scope":"web/search","expires_at":"2030-01-01T00:00:00Z"}
|
|
53
|
+
{"event":"delegate_scope","scope_id":"agent-news","parent_scope":"human-search","actor":"agent:searcher","subject":"agent:sub-searcher","allowed_actions":["search"],"resource_scope":"web/search/news"}
|
|
54
|
+
{"event":"action","scope_id":"agent-news","action":"search","resource":"web/search/news/politics","at":"2028-01-01T00:00:00Z"}
|
|
55
|
+
{"event":"revoke_scope","scope_id":"human-search","revoked_at":"2028-06-01T00:00:00Z"}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Replay checks whether propagation or actions are invalid because authority is exceeded, a scope is expired, a scope or ancestor is revoked, a child exceeds the parent scope, or the delegation chain is broken.
|
|
59
|
+
|
|
60
|
+
## CLI
|
|
61
|
+
|
|
62
|
+
Install in editable mode:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
python -m pip install -e .
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Replay an archive:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
jep-authority replay examples/archive.jsonl
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Verify an archive and emit JSON:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
jep-authority verify examples/archive.jsonl --json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Both commands exit with status `0` when the report has no violations and `1` when violations are found.
|
|
81
|
+
|
|
82
|
+
## Example scenario
|
|
83
|
+
|
|
84
|
+
`examples/archive.jsonl` demonstrates:
|
|
85
|
+
|
|
86
|
+
1. A human delegates limited `search` permission to an agent.
|
|
87
|
+
2. The agent delegates narrower `web/search/news` permission to a sub-agent.
|
|
88
|
+
3. The sub-agent performs an allowed search.
|
|
89
|
+
4. The sub-agent attempts a forbidden `payment` action.
|
|
90
|
+
5. Revocation of the parent invalidates a later delegated action.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# jep-authority-runtime
|
|
2
|
+
|
|
3
|
+
Reference runtime for JEP authority scope, delegation boundaries, attenuation, revocation, and replay verification.
|
|
4
|
+
|
|
5
|
+
This project is intentionally **not** real IAM and does not replace OAuth, X.509, DID, or any production authorization protocol. It is a small JEP-compatible reference runtime for making delegation semantics replayable and verifiable.
|
|
6
|
+
|
|
7
|
+
## AuthorityScope
|
|
8
|
+
|
|
9
|
+
`AuthorityScope` models the authority passed along a delegation chain:
|
|
10
|
+
|
|
11
|
+
- `actor`: principal that issued the scope.
|
|
12
|
+
- `subject`: principal receiving the scope.
|
|
13
|
+
- `allowed_actions`: actions the subject may perform.
|
|
14
|
+
- `denied_actions`: actions explicitly forbidden and inherited by descendants.
|
|
15
|
+
- `resource_scope`: resource prefix the scope applies to (`*` means every resource).
|
|
16
|
+
- `expires_at`: optional UTC expiration timestamp.
|
|
17
|
+
- `parent_scope`: optional parent scope id for delegated or attenuated authority.
|
|
18
|
+
- `attenuation_rules`: reference metadata describing how authority was narrowed.
|
|
19
|
+
|
|
20
|
+
The runtime also stores `scope_id` and `revoked_at` so archives can refer to scopes deterministically and model revocation.
|
|
21
|
+
|
|
22
|
+
## DelegationRuntime
|
|
23
|
+
|
|
24
|
+
`DelegationRuntime` provides:
|
|
25
|
+
|
|
26
|
+
- `create_scope()` for root authority grants.
|
|
27
|
+
- `delegate_scope()` for child scopes that must be narrower than their parent.
|
|
28
|
+
- `attenuate_scope()` as a convenience wrapper for narrower delegation.
|
|
29
|
+
- `revoke_scope()` to invalidate a scope and its descendants for later checks.
|
|
30
|
+
- `verify_scope()` to check action, resource, expiration, revocation, and chain validity.
|
|
31
|
+
- `verify_delegation_path()` to check parent continuity and attenuation constraints.
|
|
32
|
+
|
|
33
|
+
A child scope is valid only when it does not add actions, omit inherited denials, widen resources, outlive its parent, or rely on a missing/revoked/expired parent.
|
|
34
|
+
|
|
35
|
+
## Replay archive format
|
|
36
|
+
|
|
37
|
+
Archives are JSON Lines (`archive.jsonl`). Each line is an event with an `event` field:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{"event":"create_scope","scope_id":"human-search","actor":"human:alice","subject":"agent:searcher","allowed_actions":["search"],"denied_actions":["payment"],"resource_scope":"web/search","expires_at":"2030-01-01T00:00:00Z"}
|
|
41
|
+
{"event":"delegate_scope","scope_id":"agent-news","parent_scope":"human-search","actor":"agent:searcher","subject":"agent:sub-searcher","allowed_actions":["search"],"resource_scope":"web/search/news"}
|
|
42
|
+
{"event":"action","scope_id":"agent-news","action":"search","resource":"web/search/news/politics","at":"2028-01-01T00:00:00Z"}
|
|
43
|
+
{"event":"revoke_scope","scope_id":"human-search","revoked_at":"2028-06-01T00:00:00Z"}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Replay checks whether propagation or actions are invalid because authority is exceeded, a scope is expired, a scope or ancestor is revoked, a child exceeds the parent scope, or the delegation chain is broken.
|
|
47
|
+
|
|
48
|
+
## CLI
|
|
49
|
+
|
|
50
|
+
Install in editable mode:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
python -m pip install -e .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Replay an archive:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
jep-authority replay examples/archive.jsonl
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Verify an archive and emit JSON:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
jep-authority verify examples/archive.jsonl --json
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Both commands exit with status `0` when the report has no violations and `1` when violations are found.
|
|
69
|
+
|
|
70
|
+
## Example scenario
|
|
71
|
+
|
|
72
|
+
`examples/archive.jsonl` demonstrates:
|
|
73
|
+
|
|
74
|
+
1. A human delegates limited `search` permission to an agent.
|
|
75
|
+
2. The agent delegates narrower `web/search/news` permission to a sub-agent.
|
|
76
|
+
3. The sub-agent performs an allowed search.
|
|
77
|
+
4. The sub-agent attempts a forbidden `payment` action.
|
|
78
|
+
5. Revocation of the parent invalidates a later delegated action.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""JEP-compatible authority semantics reference runtime."""
|
|
2
|
+
|
|
3
|
+
from .runtime import (
|
|
4
|
+
AuthorityScope,
|
|
5
|
+
DelegationRuntime,
|
|
6
|
+
ReplayEvent,
|
|
7
|
+
ReplayReport,
|
|
8
|
+
ReplayViolation,
|
|
9
|
+
replay_archive,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AuthorityScope",
|
|
14
|
+
"DelegationRuntime",
|
|
15
|
+
"ReplayEvent",
|
|
16
|
+
"ReplayReport",
|
|
17
|
+
"ReplayViolation",
|
|
18
|
+
"replay_archive",
|
|
19
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Command-line interface for the JEP authority reference runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .runtime import replay_archive
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="jep-authority",
|
|
15
|
+
description="Replay and verify JEP-compatible authority semantics archives.",
|
|
16
|
+
)
|
|
17
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
18
|
+
for command in ("replay", "verify"):
|
|
19
|
+
subparser = subcommands.add_parser(command, help=f"{command} archive.jsonl")
|
|
20
|
+
subparser.add_argument("archive", help="Path to a JSON Lines authority archive")
|
|
21
|
+
subparser.add_argument(
|
|
22
|
+
"--json",
|
|
23
|
+
action="store_true",
|
|
24
|
+
help="Emit a machine-readable JSON report",
|
|
25
|
+
)
|
|
26
|
+
return parser
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> int:
|
|
30
|
+
args = build_parser().parse_args(argv)
|
|
31
|
+
report = replay_archive(args.archive, verify_only=args.command == "verify")
|
|
32
|
+
if args.json:
|
|
33
|
+
print(json.dumps(report.to_dict(), indent=2, sort_keys=True))
|
|
34
|
+
else:
|
|
35
|
+
status = "ok" if report.ok else "violations"
|
|
36
|
+
print(f"{args.command}: {status} ({report.events} events)")
|
|
37
|
+
for violation in report.violations:
|
|
38
|
+
print(f"line {violation.line}: {violation.event}: {violation.reason}")
|
|
39
|
+
return 0 if report.ok else 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
sys.exit(main())
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Reference authority semantics for JEP delegation archives.
|
|
2
|
+
|
|
3
|
+
This module intentionally does not implement real IAM and does not replace
|
|
4
|
+
OAuth, X.509, DID, or any production identity/security protocol. It provides a
|
|
5
|
+
small deterministic runtime for checking JEP-compatible authority propagation:
|
|
6
|
+
allowed/denied actions, resource attenuation, expiration, revocation, and
|
|
7
|
+
parent-chain continuity.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Iterable, Mapping
|
|
18
|
+
from uuid import uuid4
|
|
19
|
+
|
|
20
|
+
ISO_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthorityError(ValueError):
|
|
24
|
+
"""Raised when an authority operation violates scope semantics."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _utc_now() -> datetime:
|
|
28
|
+
return datetime.now(timezone.utc).replace(microsecond=0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_time(value: str | datetime | None) -> datetime | None:
|
|
32
|
+
"""Parse UTC-ish timestamps accepted by archive events."""
|
|
33
|
+
if value is None or isinstance(value, datetime):
|
|
34
|
+
return value
|
|
35
|
+
normalized = value.strip()
|
|
36
|
+
if normalized.endswith("Z"):
|
|
37
|
+
normalized = normalized[:-1] + "+00:00"
|
|
38
|
+
parsed = datetime.fromisoformat(normalized)
|
|
39
|
+
if parsed.tzinfo is None:
|
|
40
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
41
|
+
return parsed.astimezone(timezone.utc).replace(microsecond=0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_time(value: datetime | None) -> str | None:
|
|
45
|
+
"""Format timestamps as stable UTC strings."""
|
|
46
|
+
if value is None:
|
|
47
|
+
return None
|
|
48
|
+
return value.astimezone(timezone.utc).strftime(ISO_FORMAT)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _as_set(values: Iterable[str] | None) -> set[str]:
|
|
52
|
+
return set(values or [])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resource_within(child: str, parent: str) -> bool:
|
|
56
|
+
"""Return whether child resource scope is equal to or narrower than parent.
|
|
57
|
+
|
|
58
|
+
The reference runtime uses simple prefix resource semantics. The wildcard
|
|
59
|
+
"*" grants every resource; otherwise a child resource is in scope when it is
|
|
60
|
+
equal to the parent resource or is a slash-delimited descendant of it.
|
|
61
|
+
"""
|
|
62
|
+
if any(not isinstance(value, str) or not value or "\\" in value or any(part in {".", ".."} for part in value.split("/")) or re.search(r"%(?:2e|2f|5c|25)", value, re.I) for value in (child, parent)):
|
|
63
|
+
return False
|
|
64
|
+
if parent == "*":
|
|
65
|
+
return True
|
|
66
|
+
if child == parent:
|
|
67
|
+
return True
|
|
68
|
+
normalized_parent = parent.rstrip("/") + "/"
|
|
69
|
+
return child.startswith(normalized_parent)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class AuthorityScope:
|
|
74
|
+
"""Delegable authority scope used by the reference runtime."""
|
|
75
|
+
|
|
76
|
+
actor: str
|
|
77
|
+
subject: str
|
|
78
|
+
allowed_actions: set[str] = field(default_factory=set)
|
|
79
|
+
denied_actions: set[str] = field(default_factory=set)
|
|
80
|
+
resource_scope: str = "*"
|
|
81
|
+
expires_at: datetime | None = None
|
|
82
|
+
parent_scope: str | None = None
|
|
83
|
+
attenuation_rules: dict[str, Any] = field(default_factory=dict)
|
|
84
|
+
scope_id: str = field(default_factory=lambda: f"scope-{uuid4().hex}")
|
|
85
|
+
revoked_at: datetime | None = None
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
self.allowed_actions = _as_set(self.allowed_actions)
|
|
89
|
+
self.denied_actions = _as_set(self.denied_actions)
|
|
90
|
+
self.expires_at = parse_time(self.expires_at)
|
|
91
|
+
self.revoked_at = parse_time(self.revoked_at)
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
"scope_id": self.scope_id,
|
|
96
|
+
"actor": self.actor,
|
|
97
|
+
"subject": self.subject,
|
|
98
|
+
"allowed_actions": sorted(self.allowed_actions),
|
|
99
|
+
"denied_actions": sorted(self.denied_actions),
|
|
100
|
+
"resource_scope": self.resource_scope,
|
|
101
|
+
"expires_at": format_time(self.expires_at),
|
|
102
|
+
"parent_scope": self.parent_scope,
|
|
103
|
+
"attenuation_rules": self.attenuation_rules,
|
|
104
|
+
"revoked_at": format_time(self.revoked_at),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_dict(cls, payload: Mapping[str, Any]) -> "AuthorityScope":
|
|
109
|
+
return cls(
|
|
110
|
+
scope_id=str(payload.get("scope_id") or f"scope-{uuid4().hex}"),
|
|
111
|
+
actor=str(payload["actor"]),
|
|
112
|
+
subject=str(payload["subject"]),
|
|
113
|
+
allowed_actions=set(payload.get("allowed_actions") or []),
|
|
114
|
+
denied_actions=set(payload.get("denied_actions") or []),
|
|
115
|
+
resource_scope=str(payload.get("resource_scope") or "*"),
|
|
116
|
+
expires_at=parse_time(payload.get("expires_at")),
|
|
117
|
+
parent_scope=payload.get("parent_scope"),
|
|
118
|
+
attenuation_rules=dict(payload.get("attenuation_rules") or {}),
|
|
119
|
+
revoked_at=parse_time(payload.get("revoked_at")),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def permits(self, action: str, resource: str, at: datetime | None = None) -> tuple[bool, str]:
|
|
123
|
+
checked_at = at or _utc_now()
|
|
124
|
+
if self.revoked_at is not None and checked_at >= self.revoked_at:
|
|
125
|
+
return False, "scope revoked"
|
|
126
|
+
if self.expires_at is not None and checked_at >= self.expires_at:
|
|
127
|
+
return False, "scope expired"
|
|
128
|
+
if action in self.denied_actions:
|
|
129
|
+
return False, "action denied"
|
|
130
|
+
if action not in self.allowed_actions:
|
|
131
|
+
return False, "action not allowed"
|
|
132
|
+
if not _resource_within(resource, self.resource_scope):
|
|
133
|
+
return False, "resource out of scope"
|
|
134
|
+
return True, "ok"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(slots=True)
|
|
138
|
+
class VerificationResult:
|
|
139
|
+
ok: bool
|
|
140
|
+
reason: str = "ok"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class DelegationRuntime:
|
|
144
|
+
"""In-memory authority runtime for scopes, attenuation, and revocation."""
|
|
145
|
+
|
|
146
|
+
def __init__(self) -> None:
|
|
147
|
+
self.scopes: dict[str, AuthorityScope] = {}
|
|
148
|
+
|
|
149
|
+
def create_scope(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
actor: str,
|
|
153
|
+
subject: str,
|
|
154
|
+
allowed_actions: Iterable[str],
|
|
155
|
+
denied_actions: Iterable[str] | None = None,
|
|
156
|
+
resource_scope: str = "*",
|
|
157
|
+
expires_at: str | datetime | None = None,
|
|
158
|
+
attenuation_rules: Mapping[str, Any] | None = None,
|
|
159
|
+
scope_id: str | None = None,
|
|
160
|
+
) -> AuthorityScope:
|
|
161
|
+
scope = AuthorityScope(
|
|
162
|
+
scope_id=scope_id or f"scope-{uuid4().hex}",
|
|
163
|
+
actor=actor,
|
|
164
|
+
subject=subject,
|
|
165
|
+
allowed_actions=set(allowed_actions),
|
|
166
|
+
denied_actions=set(denied_actions or []),
|
|
167
|
+
resource_scope=resource_scope,
|
|
168
|
+
expires_at=parse_time(expires_at),
|
|
169
|
+
parent_scope=None,
|
|
170
|
+
attenuation_rules=dict(attenuation_rules or {}),
|
|
171
|
+
)
|
|
172
|
+
if scope.scope_id in self.scopes:
|
|
173
|
+
raise AuthorityError("scope_id already exists")
|
|
174
|
+
self.scopes[scope.scope_id] = scope
|
|
175
|
+
return scope
|
|
176
|
+
|
|
177
|
+
def delegate_scope(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
parent_scope: str,
|
|
181
|
+
actor: str,
|
|
182
|
+
subject: str,
|
|
183
|
+
allowed_actions: Iterable[str] | None = None,
|
|
184
|
+
denied_actions: Iterable[str] | None = None,
|
|
185
|
+
resource_scope: str | None = None,
|
|
186
|
+
expires_at: str | datetime | None = None,
|
|
187
|
+
attenuation_rules: Mapping[str, Any] | None = None,
|
|
188
|
+
scope_id: str | None = None,
|
|
189
|
+
at: str | datetime | None = None,
|
|
190
|
+
) -> AuthorityScope:
|
|
191
|
+
parent = self._require_scope(parent_scope)
|
|
192
|
+
allowed = set(allowed_actions) if allowed_actions is not None else set(parent.allowed_actions)
|
|
193
|
+
denied = parent.denied_actions | set(denied_actions or [])
|
|
194
|
+
child_resource = resource_scope or parent.resource_scope
|
|
195
|
+
child_expires = parse_time(expires_at) or parent.expires_at
|
|
196
|
+
child = AuthorityScope(
|
|
197
|
+
scope_id=scope_id or f"scope-{uuid4().hex}",
|
|
198
|
+
actor=actor,
|
|
199
|
+
subject=subject,
|
|
200
|
+
allowed_actions=allowed,
|
|
201
|
+
denied_actions=denied,
|
|
202
|
+
resource_scope=child_resource,
|
|
203
|
+
expires_at=child_expires,
|
|
204
|
+
parent_scope=parent.scope_id,
|
|
205
|
+
attenuation_rules={**parent.attenuation_rules, **dict(attenuation_rules or {})},
|
|
206
|
+
)
|
|
207
|
+
result = self.verify_delegation_path(child, at=at)
|
|
208
|
+
if not result.ok:
|
|
209
|
+
raise AuthorityError(result.reason)
|
|
210
|
+
if child.scope_id in self.scopes:
|
|
211
|
+
raise AuthorityError("scope_id already exists")
|
|
212
|
+
self.scopes[child.scope_id] = child
|
|
213
|
+
return child
|
|
214
|
+
|
|
215
|
+
def attenuate_scope(self, scope_id: str, **updates: Any) -> AuthorityScope:
|
|
216
|
+
existing = self._require_scope(scope_id)
|
|
217
|
+
return self.delegate_scope(
|
|
218
|
+
parent_scope=scope_id,
|
|
219
|
+
actor=updates.get("actor", existing.subject),
|
|
220
|
+
subject=updates["subject"],
|
|
221
|
+
allowed_actions=updates.get("allowed_actions", existing.allowed_actions),
|
|
222
|
+
denied_actions=updates.get("denied_actions", set()),
|
|
223
|
+
resource_scope=updates.get("resource_scope", existing.resource_scope),
|
|
224
|
+
expires_at=updates.get("expires_at", existing.expires_at),
|
|
225
|
+
attenuation_rules=updates.get("attenuation_rules", {}),
|
|
226
|
+
scope_id=updates.get("scope_id"),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def revoke_scope(self, scope_id: str, revoked_at: str | datetime | None = None) -> AuthorityScope:
|
|
230
|
+
scope = self._require_scope(scope_id)
|
|
231
|
+
revoked = parse_time(revoked_at) or _utc_now()
|
|
232
|
+
scope.revoked_at = min(scope.revoked_at, revoked) if scope.revoked_at else revoked
|
|
233
|
+
return scope
|
|
234
|
+
|
|
235
|
+
def verify_scope(
|
|
236
|
+
self,
|
|
237
|
+
scope_id: str,
|
|
238
|
+
*,
|
|
239
|
+
action: str,
|
|
240
|
+
resource: str,
|
|
241
|
+
at: str | datetime | None = None,
|
|
242
|
+
) -> VerificationResult:
|
|
243
|
+
scope = self._require_scope(scope_id)
|
|
244
|
+
ok, reason = scope.permits(action, resource, parse_time(at))
|
|
245
|
+
if not ok:
|
|
246
|
+
return VerificationResult(False, reason)
|
|
247
|
+
path = self.verify_delegation_path(scope, at=parse_time(at))
|
|
248
|
+
if not path.ok:
|
|
249
|
+
return path
|
|
250
|
+
return VerificationResult(True)
|
|
251
|
+
|
|
252
|
+
def verify_delegation_path(
|
|
253
|
+
self,
|
|
254
|
+
scope_or_id: AuthorityScope | str,
|
|
255
|
+
*,
|
|
256
|
+
at: str | datetime | None = None,
|
|
257
|
+
) -> VerificationResult:
|
|
258
|
+
scope = self._require_scope(scope_or_id) if isinstance(scope_or_id, str) else scope_or_id
|
|
259
|
+
checked_at = parse_time(at) or _utc_now()
|
|
260
|
+
if scope.revoked_at is not None and checked_at >= scope.revoked_at:
|
|
261
|
+
return VerificationResult(False, "scope revoked")
|
|
262
|
+
if scope.expires_at is not None and checked_at >= scope.expires_at:
|
|
263
|
+
return VerificationResult(False, "scope expired")
|
|
264
|
+
seen: set[str] = set()
|
|
265
|
+
child = scope
|
|
266
|
+
while child.parent_scope is not None:
|
|
267
|
+
if child.scope_id in seen:
|
|
268
|
+
return VerificationResult(False, "delegation chain cycle")
|
|
269
|
+
seen.add(child.scope_id)
|
|
270
|
+
parent = self.scopes.get(child.parent_scope)
|
|
271
|
+
if parent is None:
|
|
272
|
+
return VerificationResult(False, "delegation chain broken")
|
|
273
|
+
if child.actor != parent.subject:
|
|
274
|
+
return VerificationResult(False, "delegating actor is not parent subject")
|
|
275
|
+
if parent.revoked_at is not None and checked_at >= parent.revoked_at:
|
|
276
|
+
return VerificationResult(False, "parent scope revoked")
|
|
277
|
+
if parent.expires_at is not None and checked_at >= parent.expires_at:
|
|
278
|
+
return VerificationResult(False, "parent scope expired")
|
|
279
|
+
if not child.allowed_actions <= parent.allowed_actions:
|
|
280
|
+
return VerificationResult(False, "child allows actions outside parent scope")
|
|
281
|
+
if not parent.denied_actions <= child.denied_actions:
|
|
282
|
+
return VerificationResult(False, "child omits parent denied actions")
|
|
283
|
+
if child.allowed_actions & parent.denied_actions:
|
|
284
|
+
return VerificationResult(False, "child allows parent-denied action")
|
|
285
|
+
if not _resource_within(child.resource_scope, parent.resource_scope):
|
|
286
|
+
return VerificationResult(False, "child resource outside parent scope")
|
|
287
|
+
if parent.expires_at is not None and (child.expires_at is None or child.expires_at > parent.expires_at):
|
|
288
|
+
return VerificationResult(False, "child expires after parent")
|
|
289
|
+
child = parent
|
|
290
|
+
return VerificationResult(True)
|
|
291
|
+
|
|
292
|
+
def _require_scope(self, scope_id: str) -> AuthorityScope:
|
|
293
|
+
try:
|
|
294
|
+
return self.scopes[scope_id]
|
|
295
|
+
except KeyError as exc:
|
|
296
|
+
raise AuthorityError(f"unknown scope: {scope_id}") from exc
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclass(slots=True)
|
|
300
|
+
class ReplayEvent:
|
|
301
|
+
line: int
|
|
302
|
+
event: str
|
|
303
|
+
payload: dict[str, Any]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass(slots=True)
|
|
307
|
+
class ReplayViolation:
|
|
308
|
+
line: int
|
|
309
|
+
event: str
|
|
310
|
+
reason: str
|
|
311
|
+
payload: dict[str, Any]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@dataclass(slots=True)
|
|
315
|
+
class ReplayReport:
|
|
316
|
+
ok: bool
|
|
317
|
+
events: int
|
|
318
|
+
violations: list[ReplayViolation]
|
|
319
|
+
|
|
320
|
+
def to_dict(self) -> dict[str, Any]:
|
|
321
|
+
return {
|
|
322
|
+
"ok": self.ok,
|
|
323
|
+
"events": self.events,
|
|
324
|
+
"violations": [
|
|
325
|
+
{
|
|
326
|
+
"line": violation.line,
|
|
327
|
+
"event": violation.event,
|
|
328
|
+
"reason": violation.reason,
|
|
329
|
+
"payload": violation.payload,
|
|
330
|
+
}
|
|
331
|
+
for violation in self.violations
|
|
332
|
+
],
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def load_archive(path: str | Path) -> list[ReplayEvent]:
|
|
337
|
+
events: list[ReplayEvent] = []
|
|
338
|
+
with Path(path).open("r", encoding="utf-8") as handle:
|
|
339
|
+
for line_number, raw in enumerate(handle, 1):
|
|
340
|
+
stripped = raw.strip()
|
|
341
|
+
if not stripped:
|
|
342
|
+
continue
|
|
343
|
+
payload = json.loads(stripped)
|
|
344
|
+
event = str(payload.get("event") or payload.get("type") or "")
|
|
345
|
+
events.append(ReplayEvent(line_number, event, payload))
|
|
346
|
+
return events
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def replay_archive(path: str | Path, *, verify_only: bool = False) -> ReplayReport:
|
|
350
|
+
"""Replay an archive.jsonl and report authority propagation violations."""
|
|
351
|
+
runtime = DelegationRuntime()
|
|
352
|
+
violations: list[ReplayViolation] = []
|
|
353
|
+
events = load_archive(path)
|
|
354
|
+
|
|
355
|
+
for item in events:
|
|
356
|
+
try:
|
|
357
|
+
_apply_event(runtime, item, verify_only=verify_only)
|
|
358
|
+
except (AuthorityError, KeyError, TypeError, ValueError) as exc:
|
|
359
|
+
violations.append(ReplayViolation(item.line, item.event, str(exc), item.payload))
|
|
360
|
+
|
|
361
|
+
return ReplayReport(ok=not violations, events=len(events), violations=violations)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _apply_event(runtime: DelegationRuntime, item: ReplayEvent, *, verify_only: bool) -> None:
|
|
365
|
+
payload = item.payload
|
|
366
|
+
event = item.event
|
|
367
|
+
if event in {"delegate_scope", "attenuate_scope", "action", "verify_scope", "verify_delegation_path"} and not payload.get("at"):
|
|
368
|
+
raise AuthorityError("archive event requires recorded at timestamp")
|
|
369
|
+
if event == "revoke_scope" and not (payload.get("revoked_at") or payload.get("at")):
|
|
370
|
+
raise AuthorityError("archive revocation requires recorded timestamp")
|
|
371
|
+
if event == "create_scope":
|
|
372
|
+
runtime.create_scope(
|
|
373
|
+
scope_id=payload.get("scope_id"),
|
|
374
|
+
actor=payload["actor"],
|
|
375
|
+
subject=payload["subject"],
|
|
376
|
+
allowed_actions=payload.get("allowed_actions") or [],
|
|
377
|
+
denied_actions=payload.get("denied_actions") or [],
|
|
378
|
+
resource_scope=payload.get("resource_scope") or "*",
|
|
379
|
+
expires_at=payload.get("expires_at"),
|
|
380
|
+
attenuation_rules=payload.get("attenuation_rules") or {},
|
|
381
|
+
)
|
|
382
|
+
return
|
|
383
|
+
if event in {"delegate_scope", "attenuate_scope"}:
|
|
384
|
+
runtime.delegate_scope(
|
|
385
|
+
scope_id=payload.get("scope_id"),
|
|
386
|
+
at=payload["at"],
|
|
387
|
+
parent_scope=payload["parent_scope"],
|
|
388
|
+
actor=payload["actor"],
|
|
389
|
+
subject=payload["subject"],
|
|
390
|
+
allowed_actions=payload.get("allowed_actions"),
|
|
391
|
+
denied_actions=payload.get("denied_actions") or [],
|
|
392
|
+
resource_scope=payload.get("resource_scope"),
|
|
393
|
+
expires_at=payload.get("expires_at"),
|
|
394
|
+
attenuation_rules=payload.get("attenuation_rules") or {},
|
|
395
|
+
)
|
|
396
|
+
return
|
|
397
|
+
if event == "revoke_scope":
|
|
398
|
+
runtime.revoke_scope(payload["scope_id"], payload.get("revoked_at") or payload.get("at"))
|
|
399
|
+
return
|
|
400
|
+
if event in {"action", "verify_scope"}:
|
|
401
|
+
result = runtime.verify_scope(
|
|
402
|
+
payload["scope_id"],
|
|
403
|
+
action=payload["action"],
|
|
404
|
+
resource=payload["resource"],
|
|
405
|
+
at=payload.get("at"),
|
|
406
|
+
)
|
|
407
|
+
expected = payload.get("expect")
|
|
408
|
+
if expected in {"deny", "denied", False}:
|
|
409
|
+
if result.ok:
|
|
410
|
+
raise AuthorityError("expected denial but action was allowed")
|
|
411
|
+
return
|
|
412
|
+
if not result.ok:
|
|
413
|
+
raise AuthorityError(result.reason)
|
|
414
|
+
return
|
|
415
|
+
if event == "verify_delegation_path":
|
|
416
|
+
result = runtime.verify_delegation_path(payload["scope_id"], at=payload.get("at"))
|
|
417
|
+
if not result.ok:
|
|
418
|
+
raise AuthorityError(result.reason)
|
|
419
|
+
return
|
|
420
|
+
raise AuthorityError(f"unknown event: {event}")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jep-authority-runtime
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Reference runtime for JEP-compatible authority scope semantics.
|
|
5
|
+
Author: JEP Authority Runtime contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: jep,authority,delegation,attenuation,replay
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# jep-authority-runtime
|
|
14
|
+
|
|
15
|
+
Reference runtime for JEP authority scope, delegation boundaries, attenuation, revocation, and replay verification.
|
|
16
|
+
|
|
17
|
+
This project is intentionally **not** real IAM and does not replace OAuth, X.509, DID, or any production authorization protocol. It is a small JEP-compatible reference runtime for making delegation semantics replayable and verifiable.
|
|
18
|
+
|
|
19
|
+
## AuthorityScope
|
|
20
|
+
|
|
21
|
+
`AuthorityScope` models the authority passed along a delegation chain:
|
|
22
|
+
|
|
23
|
+
- `actor`: principal that issued the scope.
|
|
24
|
+
- `subject`: principal receiving the scope.
|
|
25
|
+
- `allowed_actions`: actions the subject may perform.
|
|
26
|
+
- `denied_actions`: actions explicitly forbidden and inherited by descendants.
|
|
27
|
+
- `resource_scope`: resource prefix the scope applies to (`*` means every resource).
|
|
28
|
+
- `expires_at`: optional UTC expiration timestamp.
|
|
29
|
+
- `parent_scope`: optional parent scope id for delegated or attenuated authority.
|
|
30
|
+
- `attenuation_rules`: reference metadata describing how authority was narrowed.
|
|
31
|
+
|
|
32
|
+
The runtime also stores `scope_id` and `revoked_at` so archives can refer to scopes deterministically and model revocation.
|
|
33
|
+
|
|
34
|
+
## DelegationRuntime
|
|
35
|
+
|
|
36
|
+
`DelegationRuntime` provides:
|
|
37
|
+
|
|
38
|
+
- `create_scope()` for root authority grants.
|
|
39
|
+
- `delegate_scope()` for child scopes that must be narrower than their parent.
|
|
40
|
+
- `attenuate_scope()` as a convenience wrapper for narrower delegation.
|
|
41
|
+
- `revoke_scope()` to invalidate a scope and its descendants for later checks.
|
|
42
|
+
- `verify_scope()` to check action, resource, expiration, revocation, and chain validity.
|
|
43
|
+
- `verify_delegation_path()` to check parent continuity and attenuation constraints.
|
|
44
|
+
|
|
45
|
+
A child scope is valid only when it does not add actions, omit inherited denials, widen resources, outlive its parent, or rely on a missing/revoked/expired parent.
|
|
46
|
+
|
|
47
|
+
## Replay archive format
|
|
48
|
+
|
|
49
|
+
Archives are JSON Lines (`archive.jsonl`). Each line is an event with an `event` field:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{"event":"create_scope","scope_id":"human-search","actor":"human:alice","subject":"agent:searcher","allowed_actions":["search"],"denied_actions":["payment"],"resource_scope":"web/search","expires_at":"2030-01-01T00:00:00Z"}
|
|
53
|
+
{"event":"delegate_scope","scope_id":"agent-news","parent_scope":"human-search","actor":"agent:searcher","subject":"agent:sub-searcher","allowed_actions":["search"],"resource_scope":"web/search/news"}
|
|
54
|
+
{"event":"action","scope_id":"agent-news","action":"search","resource":"web/search/news/politics","at":"2028-01-01T00:00:00Z"}
|
|
55
|
+
{"event":"revoke_scope","scope_id":"human-search","revoked_at":"2028-06-01T00:00:00Z"}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Replay checks whether propagation or actions are invalid because authority is exceeded, a scope is expired, a scope or ancestor is revoked, a child exceeds the parent scope, or the delegation chain is broken.
|
|
59
|
+
|
|
60
|
+
## CLI
|
|
61
|
+
|
|
62
|
+
Install in editable mode:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
python -m pip install -e .
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Replay an archive:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
jep-authority replay examples/archive.jsonl
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Verify an archive and emit JSON:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
jep-authority verify examples/archive.jsonl --json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Both commands exit with status `0` when the report has no violations and `1` when violations are found.
|
|
81
|
+
|
|
82
|
+
## Example scenario
|
|
83
|
+
|
|
84
|
+
`examples/archive.jsonl` demonstrates:
|
|
85
|
+
|
|
86
|
+
1. A human delegates limited `search` permission to an agent.
|
|
87
|
+
2. The agent delegates narrower `web/search/news` permission to a sub-agent.
|
|
88
|
+
3. The sub-agent performs an allowed search.
|
|
89
|
+
4. The sub-agent attempts a forbidden `payment` action.
|
|
90
|
+
5. Revocation of the parent invalidates a later delegated action.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
jep_authority/__init__.py
|
|
4
|
+
jep_authority/cli.py
|
|
5
|
+
jep_authority/runtime.py
|
|
6
|
+
jep_authority_runtime.egg-info/PKG-INFO
|
|
7
|
+
jep_authority_runtime.egg-info/SOURCES.txt
|
|
8
|
+
jep_authority_runtime.egg-info/dependency_links.txt
|
|
9
|
+
jep_authority_runtime.egg-info/entry_points.txt
|
|
10
|
+
jep_authority_runtime.egg-info/top_level.txt
|
|
11
|
+
tests/test_hardening.py
|
|
12
|
+
tests/test_resource_bounds.py
|
|
13
|
+
tests/test_runtime.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jep_authority
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jep-authority-runtime"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Reference runtime for JEP-compatible authority scope semantics."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "JEP Authority Runtime contributors"}]
|
|
13
|
+
keywords = ["jep", "authority", "delegation", "attenuation", "replay"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
jep-authority = "jep_authority.cli:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
include = ["jep_authority*"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pytest
|
|
3
|
+
from jep_authority.runtime import AuthorityError, DelegationRuntime, replay_archive
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_actor_binding_and_scope_ids_cannot_be_replaced():
|
|
7
|
+
runtime = DelegationRuntime()
|
|
8
|
+
runtime.create_scope(scope_id="root", actor="owner", subject="worker", allowed_actions=["read"])
|
|
9
|
+
with pytest.raises(AuthorityError, match="parent subject"):
|
|
10
|
+
runtime.delegate_scope(parent_scope="root", actor="stranger", subject="child")
|
|
11
|
+
runtime.revoke_scope("root", "2020-01-01T00:00:00Z")
|
|
12
|
+
with pytest.raises(AuthorityError, match="already exists"):
|
|
13
|
+
runtime.create_scope(scope_id="root", actor="owner", subject="worker", allowed_actions=["read"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_historical_delegation_uses_recorded_time(tmp_path):
|
|
17
|
+
rows = [
|
|
18
|
+
{"event": "create_scope", "scope_id": "root", "actor": "owner", "subject": "worker", "allowed_actions": ["read"], "expires_at": "2021-01-01T00:00:00Z"},
|
|
19
|
+
{"event": "delegate_scope", "scope_id": "child", "parent_scope": "root", "actor": "worker", "subject": "child", "at": "2020-01-01T00:00:00Z"},
|
|
20
|
+
{"event": "action", "scope_id": "child", "action": "read", "resource": "file", "at": "2020-02-01T00:00:00Z"},
|
|
21
|
+
]
|
|
22
|
+
path = tmp_path / "archive.jsonl"
|
|
23
|
+
path.write_text("\n".join(map(json.dumps, rows)))
|
|
24
|
+
assert replay_archive(path).ok
|
|
25
|
+
rows[-1]["expect"] = "deny"
|
|
26
|
+
path.write_text("\n".join(map(json.dumps, rows)))
|
|
27
|
+
assert not replay_archive(path, verify_only=True).ok
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from jep_authority.runtime import AuthorityScope
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_resources_do_not_escape_by_path_traversal():
|
|
6
|
+
scope = AuthorityScope(actor="a", subject="b", allowed_actions={"read"}, resource_scope="repo/private")
|
|
7
|
+
for resource in ["repo/private/../public", "repo/private/%2e%2e/public", "repo/private/..\\public", "repo/private2"]:
|
|
8
|
+
assert not scope.permits("read", resource)[0]
|
|
9
|
+
assert scope.permits("read", "repo/private/file")[0]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_expiry_is_exclusive():
|
|
13
|
+
end = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
14
|
+
scope = AuthorityScope(actor="a", subject="b", allowed_actions={"read"}, expires_at=end)
|
|
15
|
+
assert not scope.permits("read", "anything", end)[0]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from jep_authority.runtime import AuthorityError, DelegationRuntime, replay_archive
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthorityRuntimeTests(unittest.TestCase):
|
|
9
|
+
def test_delegation_and_verification(self):
|
|
10
|
+
runtime = DelegationRuntime()
|
|
11
|
+
parent = runtime.create_scope(
|
|
12
|
+
scope_id="human-search",
|
|
13
|
+
actor="human:alice",
|
|
14
|
+
subject="agent:searcher",
|
|
15
|
+
allowed_actions=["search"],
|
|
16
|
+
denied_actions=["payment"],
|
|
17
|
+
resource_scope="web/search",
|
|
18
|
+
expires_at="2030-01-01T00:00:00Z",
|
|
19
|
+
)
|
|
20
|
+
child = runtime.delegate_scope(
|
|
21
|
+
scope_id="agent-news",
|
|
22
|
+
parent_scope=parent.scope_id,
|
|
23
|
+
actor="agent:searcher",
|
|
24
|
+
subject="agent:sub-searcher",
|
|
25
|
+
allowed_actions=["search"],
|
|
26
|
+
resource_scope="web/search/news",
|
|
27
|
+
expires_at="2029-01-01T00:00:00Z",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
self.assertTrue(
|
|
31
|
+
runtime.verify_scope(
|
|
32
|
+
child.scope_id,
|
|
33
|
+
action="search",
|
|
34
|
+
resource="web/search/news/politics",
|
|
35
|
+
at="2028-01-01T00:00:00Z",
|
|
36
|
+
).ok
|
|
37
|
+
)
|
|
38
|
+
payment = runtime.verify_scope(
|
|
39
|
+
child.scope_id,
|
|
40
|
+
action="payment",
|
|
41
|
+
resource="payments/card",
|
|
42
|
+
at="2028-01-01T00:00:00Z",
|
|
43
|
+
)
|
|
44
|
+
self.assertFalse(payment.ok)
|
|
45
|
+
self.assertEqual(payment.reason, "action denied")
|
|
46
|
+
|
|
47
|
+
def test_delegate_cannot_exceed_parent_scope(self):
|
|
48
|
+
runtime = DelegationRuntime()
|
|
49
|
+
runtime.create_scope(
|
|
50
|
+
scope_id="parent",
|
|
51
|
+
actor="human:alice",
|
|
52
|
+
subject="agent:a",
|
|
53
|
+
allowed_actions=["search"],
|
|
54
|
+
resource_scope="web/search",
|
|
55
|
+
)
|
|
56
|
+
with self.assertRaisesRegex(AuthorityError, "outside parent"):
|
|
57
|
+
runtime.delegate_scope(
|
|
58
|
+
scope_id="child",
|
|
59
|
+
parent_scope="parent",
|
|
60
|
+
actor="agent:a",
|
|
61
|
+
subject="agent:b",
|
|
62
|
+
allowed_actions=["payment"],
|
|
63
|
+
resource_scope="web/search",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def test_replay_reports_revocation_violation(self):
|
|
67
|
+
archive = "\n".join(
|
|
68
|
+
[
|
|
69
|
+
'{"event":"create_scope","scope_id":"s1","actor":"human","subject":"agent","allowed_actions":["search"],"resource_scope":"web/search"}',
|
|
70
|
+
'{"event":"revoke_scope","scope_id":"s1","revoked_at":"2028-01-01T00:00:00Z"}',
|
|
71
|
+
'{"event":"action","scope_id":"s1","action":"search","resource":"web/search/news","at":"2028-01-02T00:00:00Z"}',
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
75
|
+
path = Path(tmp) / "archive.jsonl"
|
|
76
|
+
path.write_text(archive, encoding="utf-8")
|
|
77
|
+
report = replay_archive(path)
|
|
78
|
+
self.assertFalse(report.ok)
|
|
79
|
+
self.assertEqual(report.violations[0].reason, "scope revoked")
|
|
80
|
+
|
|
81
|
+
def test_replay_reports_broken_chain(self):
|
|
82
|
+
archive = '{"event":"delegate_scope","at":"2025-01-01T00:00:00Z","scope_id":"orphan","parent_scope":"missing","actor":"a","subject":"b","allowed_actions":["search"],"resource_scope":"web/search"}\n'
|
|
83
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
84
|
+
path = Path(tmp) / "archive.jsonl"
|
|
85
|
+
path.write_text(archive, encoding="utf-8")
|
|
86
|
+
report = replay_archive(path)
|
|
87
|
+
self.assertFalse(report.ok)
|
|
88
|
+
self.assertIn("unknown scope", report.violations[0].reason)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
unittest.main()
|