grantz 0.0.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.
grantz/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ from .core import (
2
+ AttenuationError,
3
+ Grant,
4
+ InMemoryRevocationStore,
5
+ LocalKeyPair,
6
+ RevocationStore,
7
+ Scope,
8
+ TokenClaims,
9
+ attenuate,
10
+ authorize,
11
+ covers,
12
+ generate_local_key_pair,
13
+ mint,
14
+ verify,
15
+ )
16
+ from .revocation_convex import ConvexRevocationStore, create_revocation_operations
17
+ from .revocation_postgres import DriverRevocationStore, PostgresRevocationStore
18
+
19
+ __all__ = [
20
+ "AttenuationError",
21
+ "DriverRevocationStore",
22
+ "Grant",
23
+ "InMemoryRevocationStore",
24
+ "LocalKeyPair",
25
+ "RevocationStore",
26
+ "Scope",
27
+ "TokenClaims",
28
+ "attenuate",
29
+ "authorize",
30
+ "covers",
31
+ "ConvexRevocationStore",
32
+ "create_revocation_operations",
33
+ "generate_local_key_pair",
34
+ "mint",
35
+ "PostgresRevocationStore",
36
+ "verify",
37
+ ]
grantz/core.py ADDED
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from typing import Any, NotRequired, Protocol, TypedDict
8
+
9
+ from cryptography.exceptions import InvalidSignature
10
+ from cryptography.hazmat.primitives import serialization
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
12
+
13
+
14
+ class Scope(TypedDict):
15
+ action: str
16
+ resource: str
17
+ qualifier: NotRequired[str | dict[str, Any]]
18
+
19
+
20
+ class Grant(TypedDict, total=False):
21
+ issuer: str
22
+ subject: str
23
+ audience: str
24
+ actAs: str
25
+ scopes: list[Scope]
26
+ constraints: dict[str, int | float | bool | str | list[str]]
27
+ binding: dict[str, str]
28
+ notBefore: str
29
+ expiresAt: str
30
+
31
+
32
+ class TokenClaims(Grant):
33
+ id: str
34
+ depth: int
35
+ issuedAt: str
36
+ parentId: NotRequired[str]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class LocalKeyPair:
41
+ key_id: str
42
+ public_key_pem: str
43
+ private_key_pem: str
44
+
45
+
46
+ class AttenuationError(Exception):
47
+ pass
48
+
49
+
50
+ class RevocationStore(Protocol):
51
+ async def is_revoked(
52
+ self, jti: str, subject: str | None = None, issued_at: str | None = None
53
+ ) -> bool: ...
54
+
55
+ async def revoke(self, jti: str) -> None: ...
56
+
57
+
58
+ class InMemoryRevocationStore:
59
+ def __init__(self) -> None:
60
+ self.revoked: set[str] = set()
61
+ self.subject_epochs: dict[str, str] = {}
62
+
63
+ async def is_revoked(
64
+ self, jti: str, subject: str | None = None, issued_at: str | None = None
65
+ ) -> bool:
66
+ if jti in self.revoked:
67
+ return True
68
+ if subject is None or issued_at is None:
69
+ return False
70
+ epoch = self.subject_epochs.get(subject)
71
+ return epoch is not None and _parse(issued_at) <= _parse(epoch)
72
+
73
+ async def revoke(self, jti: str) -> None:
74
+ self.revoked.add(jti)
75
+
76
+ async def revoke_subject(self, subject: str, revoked_at: str) -> None:
77
+ self.subject_epochs[subject] = revoked_at
78
+
79
+
80
+ _next_jti = 1
81
+
82
+
83
+ def covers(granted: Scope, requested: Scope) -> bool:
84
+ if granted["action"] not in {"*", requested["action"]}:
85
+ return False
86
+ resource = granted["resource"]
87
+ requested_resource = requested["resource"]
88
+ if (
89
+ resource != "*"
90
+ and resource != requested_resource
91
+ and not (resource.endswith(".*") and requested_resource.startswith(resource[:-1]))
92
+ ):
93
+ return False
94
+ granted_qualifier = granted.get("qualifier")
95
+ if granted_qualifier is None:
96
+ return True
97
+ return _canonical(granted_qualifier) == _canonical(requested.get("qualifier"))
98
+
99
+
100
+ async def mint(
101
+ grant: Grant, key_pair: LocalKeyPair, *, now: str | None = None, jti: str | None = None
102
+ ) -> str:
103
+ claims: TokenClaims = {
104
+ **grant,
105
+ "id": jti or _next_id(),
106
+ "depth": 0,
107
+ "issuedAt": now or _now(),
108
+ } # type: ignore[typeddict-item]
109
+ return _sign(_canonical(claims), key_pair)
110
+
111
+
112
+ async def verify(
113
+ token: str,
114
+ key_pair: LocalKeyPair,
115
+ *,
116
+ now: str | None = None,
117
+ audience: str | None = None,
118
+ revocation: RevocationStore | None = None,
119
+ ) -> tuple[bool, TokenClaims | str]:
120
+ try:
121
+ payload = _verify_signature(token, key_pair)
122
+ claims = json.loads(payload)
123
+ except (InvalidSignature, ValueError, json.JSONDecodeError) as error:
124
+ return False, f"bad_sig:{error}"
125
+ now_dt = _parse(now or _now())
126
+ if "notBefore" in claims and now_dt < _parse(claims["notBefore"]):
127
+ return False, "not_yet"
128
+ if now_dt >= _parse(claims["expiresAt"]):
129
+ return False, "expired"
130
+ if audience is not None and claims.get("audience") != audience:
131
+ return False, "wrong_audience"
132
+ if revocation is not None and await revocation.is_revoked(
133
+ claims["id"], claims["subject"], claims["issuedAt"]
134
+ ):
135
+ return False, "revoked"
136
+ return True, claims
137
+
138
+
139
+ async def attenuate(
140
+ parent_token: str,
141
+ narrowing: Grant,
142
+ key_pair: LocalKeyPair,
143
+ *,
144
+ now: str | None = None,
145
+ jti: str | None = None,
146
+ ) -> str:
147
+ ok, value = await verify(parent_token, key_pair, now=now)
148
+ if not ok or not isinstance(value, dict):
149
+ raise AttenuationError(str(value))
150
+ parent: TokenClaims = value # type: ignore[assignment]
151
+ child_scopes = narrowing.get("scopes", parent["scopes"])
152
+ if not all(
153
+ any(covers(parent_scope, child_scope) for parent_scope in parent["scopes"])
154
+ for child_scope in child_scopes
155
+ ):
156
+ raise AttenuationError("Child scopes must be covered by parent scopes")
157
+ expires_at = narrowing.get("expiresAt", parent["expiresAt"])
158
+ if _parse(expires_at) > _parse(parent["expiresAt"]):
159
+ raise AttenuationError("Child expiry cannot exceed parent expiry")
160
+ claims: TokenClaims = {
161
+ "issuer": parent["issuer"],
162
+ "subject": narrowing.get("subject", parent["subject"]),
163
+ "audience": narrowing.get("audience", parent.get("audience", "")),
164
+ "scopes": child_scopes,
165
+ "constraints": {**parent.get("constraints", {}), **narrowing.get("constraints", {})},
166
+ "binding": {**parent.get("binding", {}), **narrowing.get("binding", {})},
167
+ "expiresAt": expires_at,
168
+ "id": jti or _next_id(),
169
+ "parentId": parent["id"],
170
+ "depth": parent["depth"] + 1,
171
+ "issuedAt": now or _now(),
172
+ } # type: ignore[typeddict-item]
173
+ return _sign(_canonical(claims), key_pair)
174
+
175
+
176
+ def authorize(
177
+ claims: TokenClaims, scope: Scope, context: dict[str, str] | None = None
178
+ ) -> tuple[bool, str]:
179
+ if not any(covers(granted, scope) for granted in claims["scopes"]):
180
+ return False, "scope"
181
+ binding = claims.get("binding", {})
182
+ context = context or {}
183
+ for key, value in binding.items():
184
+ if context.get(key) != value:
185
+ return False, "binding"
186
+ return True, "ok"
187
+
188
+
189
+ def generate_local_key_pair(key_id: str = "local-ed25519") -> LocalKeyPair:
190
+ private_key = Ed25519PrivateKey.generate()
191
+ public_key = private_key.public_key()
192
+ return LocalKeyPair(
193
+ key_id=key_id,
194
+ public_key_pem=public_key.public_bytes(
195
+ encoding=serialization.Encoding.PEM,
196
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
197
+ ).decode(),
198
+ private_key_pem=private_key.private_bytes(
199
+ encoding=serialization.Encoding.PEM,
200
+ format=serialization.PrivateFormat.PKCS8,
201
+ encryption_algorithm=serialization.NoEncryption(),
202
+ ).decode(),
203
+ )
204
+
205
+
206
+ def _sign(payload: str, key_pair: LocalKeyPair) -> str:
207
+ private_key = serialization.load_pem_private_key(
208
+ key_pair.private_key_pem.encode(), password=None
209
+ )
210
+ assert isinstance(private_key, Ed25519PrivateKey)
211
+ header = _b64(
212
+ json.dumps({"alg": "EdDSA", "typ": "JWT", "kid": key_pair.key_id}, separators=(",", ":"))
213
+ )
214
+ body = _b64(payload)
215
+ signing_input = f"{header}.{body}"
216
+ signature = private_key.sign(signing_input.encode())
217
+ return f"{signing_input}.{_b64(signature)}"
218
+
219
+
220
+ def _verify_signature(token: str, key_pair: LocalKeyPair) -> str:
221
+ header, payload, signature = token.split(".")
222
+ header_value = json.loads(_unb64(header).decode())
223
+ if header_value.get("alg") != "EdDSA" or header_value.get("kid") != key_pair.key_id:
224
+ raise ValueError("unsupported header")
225
+ public_key = serialization.load_pem_public_key(key_pair.public_key_pem.encode())
226
+ assert isinstance(public_key, Ed25519PublicKey)
227
+ public_key.verify(_unb64(signature), f"{header}.{payload}".encode())
228
+ return _unb64(payload).decode()
229
+
230
+
231
+ def _canonical(value: object) -> str:
232
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
233
+
234
+
235
+ def _b64(value: str | bytes) -> str:
236
+ data = value.encode() if isinstance(value, str) else value
237
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
238
+
239
+
240
+ def _unb64(value: str) -> bytes:
241
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
242
+
243
+
244
+ def _parse(value: str) -> datetime:
245
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
246
+
247
+
248
+ def _now() -> str:
249
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
250
+
251
+
252
+ def _next_id() -> str:
253
+ global _next_jti
254
+ value = f"jti_{_next_jti}"
255
+ _next_jti += 1
256
+ return value
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from dockbay import (
6
+ ConvexOperationContext,
7
+ ConvexOperationDriver,
8
+ ConvexStoreOperation,
9
+ JsonValue,
10
+ )
11
+
12
+ REVOKE_JTI = "revocation.revokeJti"
13
+ REVOKE_SUBJECT = "revocation.revokeSubject"
14
+ IS_REVOKED = "revocation.isRevoked"
15
+
16
+
17
+ class ConvexRevocationStore:
18
+ def __init__(
19
+ self,
20
+ driver: ConvexOperationDriver,
21
+ *,
22
+ operations: dict[str, str] | None = None,
23
+ ) -> None:
24
+ self._driver = driver
25
+ self._operations = {
26
+ "revoke_jti": REVOKE_JTI,
27
+ "revoke_subject": REVOKE_SUBJECT,
28
+ "is_revoked": IS_REVOKED,
29
+ **(operations or {}),
30
+ }
31
+
32
+ async def is_revoked(
33
+ self, jti: str, subject: str | None = None, issued_at: str | None = None
34
+ ) -> bool:
35
+ result = await self._driver.call(
36
+ self._operations["is_revoked"],
37
+ {"jti": jti, "subject": subject, "issuedAt": issued_at},
38
+ )
39
+ assert isinstance(result, dict)
40
+ return result["revoked"] is True
41
+
42
+ async def revoke(self, jti: str) -> None:
43
+ await self._driver.call(self._operations["revoke_jti"], {"jti": jti})
44
+
45
+ async def revoke_subject(self, subject: str, revoked_at: str) -> None:
46
+ await self._driver.call(
47
+ self._operations["revoke_subject"], {"subject": subject, "revokedAt": revoked_at}
48
+ )
49
+
50
+ async def close(self) -> None:
51
+ return None
52
+
53
+
54
+ def create_revocation_operations() -> list[ConvexStoreOperation]:
55
+ revoked_jtis: set[str] = set()
56
+ subject_epochs: dict[str, str] = {}
57
+
58
+ async def revoke_jti(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
59
+ assert isinstance(input_value, dict)
60
+ revoked_jtis.add(str(input_value["jti"]))
61
+ return None
62
+
63
+ async def revoke_subject(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
64
+ assert isinstance(input_value, dict)
65
+ subject_epochs[str(input_value["subject"])] = str(input_value["revokedAt"])
66
+ return None
67
+
68
+ async def is_revoked(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
69
+ assert isinstance(input_value, dict)
70
+ jti = str(input_value["jti"])
71
+ if jti in revoked_jtis:
72
+ return {"revoked": True}
73
+ subject = input_value.get("subject")
74
+ issued_at = input_value.get("issuedAt")
75
+ if not isinstance(subject, str) or not isinstance(issued_at, str):
76
+ return {"revoked": False}
77
+ epoch = subject_epochs.get(subject)
78
+ return {"revoked": epoch is not None and _parse_ms(issued_at) <= _parse_ms(epoch)}
79
+
80
+ return [
81
+ ConvexStoreOperation(name=REVOKE_JTI, kind="mutation", run=revoke_jti),
82
+ ConvexStoreOperation(name=REVOKE_SUBJECT, kind="mutation", run=revoke_subject),
83
+ ConvexStoreOperation(name=IS_REVOKED, kind="query", run=is_revoked),
84
+ ]
85
+
86
+
87
+ def _parse_ms(value: str) -> float:
88
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from dockbay import (
4
+ PostgresStoreDriver,
5
+ PostgresStoreDriverOptions,
6
+ StoreDriver,
7
+ create_postgres_driver,
8
+ )
9
+ from psycopg_pool import AsyncConnectionPool
10
+
11
+ REVOKED_JTIS = "revoked_jtis"
12
+ SUBJECT_EPOCHS = "subject_epochs"
13
+
14
+
15
+ class DriverRevocationStore:
16
+ def __init__(self, driver: StoreDriver) -> None:
17
+ self._driver = driver
18
+
19
+ async def is_revoked(
20
+ self, jti: str, subject: str | None = None, issued_at: str | None = None
21
+ ) -> bool:
22
+ async def work(txn) -> bool:
23
+ if await txn.get(REVOKED_JTIS, {"jti": jti}) is not None:
24
+ return True
25
+ if subject is None or issued_at is None:
26
+ return False
27
+ row = await txn.get(SUBJECT_EPOCHS, {"subject": subject})
28
+ if row is None or not isinstance(row.get("revokedAt"), str):
29
+ return False
30
+ return _parse_ms(issued_at) <= _parse_ms(row["revokedAt"])
31
+
32
+ return await self._driver.transaction(work)
33
+
34
+ async def revoke(self, jti: str) -> None:
35
+ async def work(txn) -> None:
36
+ await txn.upsert(REVOKED_JTIS, {"jti": jti}, {"jti": jti})
37
+
38
+ await self._driver.transaction(work)
39
+
40
+ async def revoke_subject(self, subject: str, revoked_at: str) -> None:
41
+ async def work(txn) -> None:
42
+ await txn.upsert(
43
+ SUBJECT_EPOCHS, {"subject": subject}, {"subject": subject, "revokedAt": revoked_at}
44
+ )
45
+
46
+ await self._driver.transaction(work)
47
+
48
+ async def close(self) -> None:
49
+ await self._driver.close()
50
+
51
+
52
+ class PostgresRevocationStore(DriverRevocationStore):
53
+ def __init__(
54
+ self,
55
+ pool: AsyncConnectionPool,
56
+ *,
57
+ table: str = "grantz_revocation_store",
58
+ ) -> None:
59
+ self.postgres_driver: PostgresStoreDriver = create_postgres_driver(
60
+ pool, PostgresStoreDriverOptions(table=table)
61
+ )
62
+ super().__init__(self.postgres_driver)
63
+
64
+
65
+ def _parse_ms(value: str) -> float:
66
+ from datetime import datetime
67
+
68
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: grantz
3
+ Version: 0.0.0
4
+ Summary: Scoped, signed, monotonically attenuable delegation tokens.
5
+ Project-URL: Homepage, https://github.com/cachetronaut/grantz
6
+ Project-URL: Repository, https://github.com/cachetronaut/grantz
7
+ Project-URL: Issues, https://github.com/cachetronaut/grantz/issues
8
+ License: MIT
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: cryptography>=42
11
+ Requires-Dist: dockbay>=0.0.0
12
+ Requires-Dist: psycopg-pool>=3.2
13
+ Requires-Dist: psycopg[binary]>=3.2
14
+ Description-Content-Type: text/markdown
15
+
16
+ # grantz
17
+
18
+ Python implementation of Grantz.
19
+
20
+ For product-level context, shared contracts, and cross-language repository information, see the public repository: https://github.com/cachetronaut/grantz.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ pip install grantz
26
+ ```
27
+
28
+ ## Import
29
+
30
+ ```python
31
+ import grantz
32
+ ```
33
+
34
+ ## Development
35
+
36
+ Run from `py/`:
37
+
38
+ ```sh
39
+ uv sync --dev
40
+ uv run --with ruff ruff check .
41
+ uv run --with ruff ruff format --check .
42
+ uv run --with ty ty check
43
+ uv run --with pytest --with pytest-asyncio python -m pytest
44
+ ```
45
+
46
+ ## License
47
+
48
+ MIT
@@ -0,0 +1,7 @@
1
+ grantz/__init__.py,sha256=T1bzqGu_xhxTRn4NnfijmfTITDVnZnDLT1GbYzDLC_c,795
2
+ grantz/core.py,sha256=rfV7HuzWUtoJmBpSEsliKQiL3AyJn5FS4tYoVntjhyE,8214
3
+ grantz/revocation_convex.py,sha256=JEPLcWoC9uxt5HrPIELC-zROyWXdQcjq8ZARn4XkloM,3035
4
+ grantz/revocation_postgres.py,sha256=OY8SJ73956xLzWKA0LamLoiJRwzsyIFO1iZkqWQRhx0,2134
5
+ grantz-0.0.0.dist-info/METADATA,sha256=Ep3h84ZfI4kzL8lb5C4zcixm22RYTs69Pb05xnp8Kok,1036
6
+ grantz-0.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ grantz-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any