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 +46 -0
- edge_authz/audit.py +51 -0
- edge_authz/decision.py +50 -0
- edge_authz/policy_file.py +134 -0
- edge_authz/py.typed +0 -0
- edge_authz/tokens.py +65 -0
- edge_authz-0.1.0.dist-info/METADATA +127 -0
- edge_authz-0.1.0.dist-info/RECORD +10 -0
- edge_authz-0.1.0.dist-info/WHEEL +4 -0
- edge_authz-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|