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 +37 -0
- grantz/core.py +256 -0
- grantz/revocation_convex.py +88 -0
- grantz/revocation_postgres.py +68 -0
- grantz-0.0.0.dist-info/METADATA +48 -0
- grantz-0.0.0.dist-info/RECORD +7 -0
- grantz-0.0.0.dist-info/WHEEL +4 -0
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,,
|