edge-authz 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
edge_authz/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """edge-authz: building blocks for an authorization enforcement point at the edge.
2
+
3
+ - :class:`Principal`, :class:`Decision`, :class:`PolicyBackend` — who asks, what was decided, who decides
4
+ - :class:`StaticTokenVerifier`, :func:`generate_token`, :func:`hash_token` — per-client bearer tokens
5
+ - :func:`load_policy`, :func:`create_client_token`, :func:`revoke_client_token` — a versioned, archived TOML policy
6
+ - :class:`JsonlAuditSink`, :func:`pseudonymize` — an audit log that must not lose entries or leak identifiers
7
+
8
+ What a policy allows is up to the application; this package knows nothing about it.
9
+ """
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ from .audit import AuditSink, JsonlAuditSink, pseudonymize
14
+ from .decision import Decision, PolicyBackend, Principal
15
+ from .policy_file import (
16
+ LoadedPolicy,
17
+ PolicyError,
18
+ create_client_token,
19
+ edit_policy,
20
+ load_policy,
21
+ revoke_client_token,
22
+ )
23
+ from .tokens import StaticTokenVerifier, generate_token, hash_token
24
+
25
+ try:
26
+ __version__ = version("edge-authz")
27
+ except PackageNotFoundError:
28
+ __version__ = "0.0.0-dev"
29
+
30
+ __all__ = [
31
+ "AuditSink",
32
+ "Decision",
33
+ "JsonlAuditSink",
34
+ "LoadedPolicy",
35
+ "PolicyBackend",
36
+ "PolicyError",
37
+ "Principal",
38
+ "StaticTokenVerifier",
39
+ "create_client_token",
40
+ "edit_policy",
41
+ "generate_token",
42
+ "hash_token",
43
+ "load_policy",
44
+ "pseudonymize",
45
+ "revoke_client_token",
46
+ ]
edge_authz/audit.py ADDED
@@ -0,0 +1,51 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import os
5
+ import threading
6
+ from collections.abc import Mapping
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any, Protocol
10
+
11
+
12
+ class AuditSink(Protocol):
13
+ """Receives one audit entry per decision. Raising means the entry was not kept."""
14
+
15
+ def write(self, entry: Mapping[str, Any]) -> None: ...
16
+
17
+
18
+ class JsonlAuditSink:
19
+ """Appends audit entries as JSON lines and flushes each to disk before returning.
20
+
21
+ A failed write raises, so a caller can refuse the request it was about to
22
+ perform when its trace could not be kept. Entries carry a ``time`` field
23
+ (UTC, ISO 8601) unless the caller sets one.
24
+
25
+ Write only what may be kept: identifiers that are personal data go in
26
+ pseudonymized with :func:`pseudonymize`.
27
+ """
28
+
29
+ def __init__(self, path: str | Path):
30
+ self.path = Path(path)
31
+ self.path.parent.mkdir(parents=True, exist_ok=True)
32
+ self._lock = threading.Lock()
33
+
34
+ def write(self, entry: Mapping[str, Any]) -> None:
35
+ record = {"time": datetime.now(UTC).isoformat(timespec="milliseconds"), **entry}
36
+ line = json.dumps(
37
+ record, ensure_ascii=False, separators=(",", ":"), sort_keys=True
38
+ )
39
+ with self._lock, self.path.open("a", encoding="utf-8", newline="\n") as f:
40
+ f.write(line + "\n")
41
+ f.flush()
42
+ os.fsync(f.fileno())
43
+
44
+
45
+ def pseudonymize(key: bytes, value: str) -> str:
46
+ """Return a keyed hash of ``value`` for the audit log.
47
+
48
+ Equal values give equal pseudonyms, so entries can be correlated, but
49
+ without ``key`` a short identifier cannot be recovered by trying values.
50
+ """
51
+ return hmac.new(key, value.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
edge_authz/decision.py ADDED
@@ -0,0 +1,50 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Protocol
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Principal:
8
+ """The authenticated caller a decision is about, e.g. one client of a proxy.
9
+
10
+ ``attributes`` carries whatever the policy needs about the caller, such
11
+ as permissions read from the policy file.
12
+ """
13
+
14
+ id: str
15
+ attributes: Mapping[str, Any] = field(default_factory=dict)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Decision:
20
+ """The outcome of a policy check.
21
+
22
+ ``reason_code`` is a stable, machine-readable word (``"allowed"``,
23
+ ``"method_not_enabled"``, …) and ``rule_id`` names the policy rule that
24
+ decided. ``details`` holds extra facts for the audit log; it must not
25
+ contain the secret or personal data the request carried.
26
+ """
27
+
28
+ allowed: bool
29
+ reason_code: str
30
+ rule_id: str = ""
31
+ details: Mapping[str, Any] = field(default_factory=dict)
32
+
33
+ @classmethod
34
+ def allow(cls, rule_id: str = "", **details: Any) -> "Decision":
35
+ return cls(True, "allowed", rule_id, details)
36
+
37
+ @classmethod
38
+ def deny(cls, reason_code: str, rule_id: str = "", **details: Any) -> "Decision":
39
+ return cls(False, reason_code, rule_id, details)
40
+
41
+
42
+ class PolicyBackend(Protocol):
43
+ """Decides whether ``principal`` may perform ``action`` with ``attributes``.
44
+
45
+ Implementations must deny when they cannot decide.
46
+ """
47
+
48
+ async def decide(
49
+ self, principal: Principal, action: str, attributes: Mapping[str, Any]
50
+ ) -> Decision: ...
@@ -0,0 +1,134 @@
1
+ """A policy kept in one TOML file, versioned and archived for traceability.
2
+
3
+ The file carries an integer ``version`` in a table of the caller's choosing
4
+ (``[proxy] version = 3``). Each load archives the exact bytes as
5
+ ``<archive_dir>/<version>-<sha256>.toml`` and refuses a version that was
6
+ archived before with different content, so every decision can be traced to
7
+ the policy that made it, even though the file itself lives outside version
8
+ control.
9
+ """
10
+
11
+ import hashlib
12
+ import tomllib
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import tomlkit
19
+
20
+ from .tokens import generate_token, hash_token
21
+
22
+
23
+ class PolicyError(Exception):
24
+ """The policy file is missing, malformed, or inconsistent with its archive."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class LoadedPolicy:
29
+ path: Path
30
+ version: int
31
+ sha256: str
32
+ data: dict[str, Any]
33
+
34
+
35
+ def load_policy(
36
+ path: str | Path, archive_dir: str | Path, *, version_table: str
37
+ ) -> LoadedPolicy:
38
+ """Read, version-check and archive the policy at ``path``.
39
+
40
+ Raises:
41
+ PolicyError: The file is unreadable or not TOML, ``[version_table].version``
42
+ is missing or not a positive integer, or the archive already holds this
43
+ version with different content (an edit without a version bump).
44
+ """
45
+ path = Path(path)
46
+ try:
47
+ raw = path.read_bytes()
48
+ except OSError as e:
49
+ raise PolicyError(f"cannot read policy file {path}: {type(e).__name__}") from e
50
+ try:
51
+ data = tomllib.loads(raw.decode("utf-8"))
52
+ except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
53
+ raise PolicyError(f"policy file {path} is not valid TOML: {e}") from e
54
+
55
+ version = data.get(version_table, {}).get("version")
56
+ if not isinstance(version, int) or isinstance(version, bool) or version < 1:
57
+ raise PolicyError(
58
+ f"policy file {path} needs [{version_table}] version = <positive integer>"
59
+ )
60
+
61
+ sha = hashlib.sha256(raw).hexdigest()
62
+ archive = Path(archive_dir)
63
+ archive.mkdir(parents=True, exist_ok=True)
64
+ for existing in archive.glob(f"{version}-*.toml"):
65
+ if existing.name != f"{version}-{sha}.toml":
66
+ raise PolicyError(
67
+ f"policy version {version} was archived before with different content; "
68
+ f"raise [{version_table}] version in {path}"
69
+ )
70
+ target = archive / f"{version}-{sha}.toml"
71
+ if not target.exists():
72
+ target.write_bytes(raw)
73
+ return LoadedPolicy(path, version, sha, data)
74
+
75
+
76
+ def edit_policy(
77
+ path: str | Path,
78
+ change: Callable[[tomlkit.TOMLDocument], None],
79
+ *,
80
+ version_table: str,
81
+ ) -> int:
82
+ """Apply ``change`` to the policy document, raise its version, and save it.
83
+
84
+ Comments and layout are kept. Returns the new version.
85
+ """
86
+ path = Path(path)
87
+ doc = tomlkit.parse(path.read_text(encoding="utf-8"))
88
+ change(doc)
89
+ table = doc.setdefault(version_table, tomlkit.table())
90
+ version = int(table.get("version", 0)) + 1
91
+ table["version"] = version
92
+ path.write_text(tomlkit.dumps(doc), encoding="utf-8", newline="\n")
93
+ return version
94
+
95
+
96
+ def create_client_token(
97
+ path: str | Path,
98
+ client: str,
99
+ *,
100
+ version_table: str,
101
+ clients_table: str = "clients",
102
+ prefix: str = "",
103
+ ) -> str:
104
+ """Issue a new token for ``client``: store its hash in the policy, return the token.
105
+
106
+ The token itself is not stored anywhere; show it once. Replaces an earlier
107
+ token of the same client.
108
+ """
109
+ token = generate_token(prefix)
110
+
111
+ def change(doc: tomlkit.TOMLDocument) -> None:
112
+ clients = doc.setdefault(clients_table, tomlkit.table(is_super_table=True))
113
+ entry = clients.setdefault(client, tomlkit.table())
114
+ entry["token_sha256"] = hash_token(token)
115
+
116
+ edit_policy(path, change, version_table=version_table)
117
+ return token
118
+
119
+
120
+ def revoke_client_token(
121
+ path: str | Path, client: str, *, version_table: str, clients_table: str = "clients"
122
+ ) -> bool:
123
+ """Remove ``client``'s token hash from the policy. Returns whether it had one."""
124
+ removed = False
125
+
126
+ def change(doc: tomlkit.TOMLDocument) -> None:
127
+ nonlocal removed
128
+ entry = doc.get(clients_table, {}).get(client)
129
+ if entry is not None and "token_sha256" in entry:
130
+ del entry["token_sha256"]
131
+ removed = True
132
+
133
+ edit_policy(path, change, version_table=version_table)
134
+ return removed
edge_authz/py.typed ADDED
File without changes
edge_authz/tokens.py ADDED
@@ -0,0 +1,65 @@
1
+ import hashlib
2
+ import hmac
3
+ import secrets
4
+ from collections.abc import Mapping
5
+
6
+ from .decision import Principal
7
+
8
+ #: Random bytes per generated token (256 bits).
9
+ TOKEN_BYTES = 32
10
+
11
+
12
+ def generate_token(prefix: str = "") -> str:
13
+ """Return a new random token, URL-safe, optionally with an identifying prefix."""
14
+ return f"{prefix}{secrets.token_urlsafe(TOKEN_BYTES)}"
15
+
16
+
17
+ def hash_token(token: str) -> str:
18
+ """Return the hex SHA-256 of ``token``, the form in which tokens are stored.
19
+
20
+ A plain hash is enough: generated tokens carry 256 random bits, so there is
21
+ nothing to guess, and no salt or slow hash would add protection.
22
+ """
23
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
24
+
25
+
26
+ class StaticTokenVerifier:
27
+ """Maps bearer tokens to principals through a fixed table of token hashes.
28
+
29
+ ``token_hashes`` maps a principal id to the hex SHA-256 of its token.
30
+ ``attributes`` optionally maps a principal id to its attributes.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ token_hashes: Mapping[str, str],
36
+ attributes: Mapping[str, Mapping[str, object]] | None = None,
37
+ ):
38
+ self._hashes = {pid: h.lower() for pid, h in token_hashes.items()}
39
+ self._attributes = attributes or {}
40
+
41
+ def verify(self, token: str | None) -> Principal | None:
42
+ """Return the principal ``token`` belongs to, or ``None``.
43
+
44
+ Every entry is compared in constant time, so timing reveals neither a
45
+ matching hash nor which entry it belongs to.
46
+ """
47
+ if not token:
48
+ return None
49
+ presented = hash_token(token)
50
+ found: str | None = None
51
+ for pid, expected in self._hashes.items():
52
+ if hmac.compare_digest(presented, expected):
53
+ found = pid
54
+ if found is None:
55
+ return None
56
+ return Principal(found, dict(self._attributes.get(found, {})))
57
+
58
+ def verify_authorization(self, header_value: str | None) -> Principal | None:
59
+ """Verify an ``Authorization`` header value of the form ``Bearer <token>``."""
60
+ if not header_value:
61
+ return None
62
+ scheme, _, token = header_value.partition(" ")
63
+ if scheme.lower() != "bearer" or not token or " " in token.strip():
64
+ return None
65
+ return self.verify(token.strip())
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: edge-authz
3
+ Version: 0.1.0
4
+ Summary: Edge authorization building blocks: client tokens, principals, decisions, a versioned and archived policy file, and a JSONL audit log. Framework-neutral.
5
+ Author-email: Stephan Eberle <stephaneberle9@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: tomlkit>=0.13
10
+ Description-Content-Type: text/markdown
11
+
12
+ # edge-authz <!-- omit in toc -->
13
+
14
+ Building blocks for an authorization enforcement point at the edge — a proxy
15
+ or gateway that decides, per request, whether a caller may do something, and
16
+ keeps a trace of every decision. Framework-neutral: no web framework, no
17
+ policy engine.
18
+
19
+ - [What it provides](#what-it-provides)
20
+ - [Installation](#installation)
21
+ - [Usage](#usage)
22
+ - [Client tokens](#client-tokens)
23
+ - [A versioned policy file](#a-versioned-policy-file)
24
+ - [Decisions](#decisions)
25
+ - [Audit log](#audit-log)
26
+ - [Contributing](#contributing)
27
+ - [License](#license)
28
+
29
+ ## What it provides
30
+
31
+ | Piece | Purpose |
32
+ | --- | --- |
33
+ | `generate_token`, `hash_token`, `StaticTokenVerifier` | One random bearer token per client; only its SHA-256 is stored |
34
+ | `load_policy` | Reads a TOML policy file, requires a version number, and archives every version it loads |
35
+ | `create_client_token`, `revoke_client_token`, `edit_policy` | Change the policy file in place, keeping comments and raising its version |
36
+ | `Principal`, `Decision`, `PolicyBackend` | Who asks, what was decided and why, and the interface of whatever decides |
37
+ | `JsonlAuditSink`, `pseudonymize` | An append-only audit log flushed to disk per entry, and keyed pseudonyms for identifiers |
38
+
39
+ What a policy allows is the application's business; this package only
40
+ supplies the plumbing around it.
41
+
42
+ ## Installation
43
+
44
+ The package is not published to PyPI. Install it from its Git repository:
45
+
46
+ ```bash
47
+ uv add "edge-authz @ git+https://github.com/stephaneberle9/python-edge-authz.git"
48
+ ```
49
+
50
+ Requires Python 3.11+.
51
+
52
+ ## Usage
53
+
54
+ ### Client tokens
55
+
56
+ ```python
57
+ from edge_authz import StaticTokenVerifier
58
+
59
+ verifier = StaticTokenVerifier(
60
+ {"agent-a": "<sha256 of agent-a's token>"},
61
+ {"agent-a": {"may_write": True}},
62
+ )
63
+ principal = verifier.verify_authorization(request.headers.get("authorization"))
64
+ if principal is None:
65
+ ... # 401
66
+ ```
67
+
68
+ Tokens carry 256 random bits, so a plain SHA-256 is enough to store them; all
69
+ stored hashes are compared in constant time.
70
+
71
+ ### A versioned policy file
72
+
73
+ ```toml
74
+ [proxy]
75
+ version = 3
76
+
77
+ [clients.agent-a]
78
+ token_sha256 = "…"
79
+ may_write = true
80
+ ```
81
+
82
+ ```python
83
+ from edge_authz import create_client_token, load_policy
84
+
85
+ policy = load_policy("policy.toml", "policy-archive", version_table="proxy")
86
+ token = create_client_token("policy.toml", "agent-b", version_table="proxy", prefix="dcp_")
87
+ print(token) # shown once; only its hash is in the file
88
+ ```
89
+
90
+ `load_policy` copies each version it loads to `<archive>/<version>-<sha256>.toml`
91
+ and refuses a version that was archived before with different content, so a
92
+ decision logged with a version and hash can always be traced back to the exact
93
+ policy, even when the file itself is not under version control. Editing the
94
+ file by hand means raising `version`; the token functions do that themselves.
95
+
96
+ ### Decisions
97
+
98
+ ```python
99
+ from edge_authz import Decision, Principal
100
+
101
+ class MyPolicy:
102
+ async def decide(self, principal: Principal, action: str, attributes) -> Decision:
103
+ if action in {"read"}:
104
+ return Decision.allow("reads")
105
+ return Decision.deny("action_not_enabled", "default")
106
+ ```
107
+
108
+ ### Audit log
109
+
110
+ ```python
111
+ from edge_authz import JsonlAuditSink, pseudonymize
112
+
113
+ audit = JsonlAuditSink("audit/audit.jsonl")
114
+ audit.write({"client": principal.id, "decision": "allow", "subject": pseudonymize(key, subject_id)})
115
+ ```
116
+
117
+ `write` raises when the entry could not be stored; refuse the request then.
118
+ Pseudonymize identifiers that are personal data with a secret key: plain
119
+ hashes of short identifiers are easy to reverse by trying values.
120
+
121
+ ## Contributing
122
+
123
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
124
+
125
+ ## License
126
+
127
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ edge_authz/__init__.py,sha256=V2HMjrz_FZc6e1OqUvS-GZNg2zfgCdeznzi5Vf5uTfc,1441
2
+ edge_authz/audit.py,sha256=Tk0zyIReTlyoVZxmcFt64daqgbIlWFL8555WfUNAjOQ,1767
3
+ edge_authz/decision.py,sha256=roZxuwCxfC6Xhug-z1-Z1TjgEe7tDsStYA3q_k_HzCk,1543
4
+ edge_authz/policy_file.py,sha256=N5amWtapcdEnARA2erjDMJmSW8h15O-OyvFplMMoZwU,4464
5
+ edge_authz/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ edge_authz/tokens.py,sha256=XsOnRTOfc9vnhIo4ImmUR5hLUZDOQcLD7QH71WjL1fU,2303
7
+ edge_authz-0.1.0.dist-info/METADATA,sha256=kb9AazkrmzSaFBwyj29dcJlEJ17TAMlmmbDuxkAOHr0,4132
8
+ edge_authz-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ edge_authz-0.1.0.dist-info/licenses/LICENSE,sha256=gj8yMQmXVQ-u8qsZ6UdnofKnIp2wv0tBByT9E-mkpYE,1071
10
+ edge_authz-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephan Eberle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.