samarkand 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.
- samarkand/__init__.py +5 -0
- samarkand/_http.py +78 -0
- samarkand/licence.py +350 -0
- samarkand/misp.py +581 -0
- samarkand/navigator.py +167 -0
- samarkand/py.typed +0 -0
- samarkand/stix.py +876 -0
- samarkand/taxii.py +127 -0
- samarkand/webhooks.py +375 -0
- samarkand-0.1.0.dist-info/METADATA +188 -0
- samarkand-0.1.0.dist-info/RECORD +13 -0
- samarkand-0.1.0.dist-info/WHEEL +4 -0
- samarkand-0.1.0.dist-info/licenses/LICENSE +96 -0
samarkand/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""samarkand: the licence, webhook and threat-intelligence wire formats shared by a family of products.
|
|
2
|
+
Pure logic only: each product keeps its own storage, routes, UI and rules, and passes in
|
|
3
|
+
what's its own (its id, public keys, event names and header prefix)."""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
samarkand/_http.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""One HTTP request, two ways: the standard library (sync) and httpx (async, the optional extra).
|
|
2
|
+
|
|
3
|
+
Redirects are never followed unless asked: a webhook, a TAXII server or a MISP instance is the
|
|
4
|
+
address an admin registered, not wherever it points next. TLS is verified unless the caller turns it
|
|
5
|
+
off for that one request.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ssl
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Response:
|
|
19
|
+
"""What came back: the status, the body and the headers (names lower-cased)."""
|
|
20
|
+
|
|
21
|
+
status: int
|
|
22
|
+
body: bytes = b""
|
|
23
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
27
|
+
def redirect_request(self, *args: object, **kwargs: object) -> None:
|
|
28
|
+
return None # the 3xx comes back to the caller as the answer
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def request(
|
|
32
|
+
method: str,
|
|
33
|
+
url: str,
|
|
34
|
+
*,
|
|
35
|
+
body: bytes | None = None,
|
|
36
|
+
headers: Mapping[str, str] | None = None,
|
|
37
|
+
timeout: float = 30.0,
|
|
38
|
+
verify: bool = True,
|
|
39
|
+
follow_redirects: bool = False,
|
|
40
|
+
) -> Response:
|
|
41
|
+
"""Send one request with the standard library. Any status is a Response; a failure to reach the
|
|
42
|
+
server raises OSError (urllib.error.URLError is one)."""
|
|
43
|
+
context = ssl.create_default_context()
|
|
44
|
+
if not verify:
|
|
45
|
+
context.check_hostname = False
|
|
46
|
+
context.verify_mode = ssl.CERT_NONE
|
|
47
|
+
handlers: list[urllib.request.BaseHandler] = [urllib.request.HTTPSHandler(context=context)]
|
|
48
|
+
if not follow_redirects:
|
|
49
|
+
handlers.append(_NoRedirects())
|
|
50
|
+
opener = urllib.request.build_opener(*handlers)
|
|
51
|
+
req = urllib.request.Request(url, data=body, headers=dict(headers or {}), method=method) # noqa: S310
|
|
52
|
+
try:
|
|
53
|
+
with opener.open(req, timeout=timeout) as answer:
|
|
54
|
+
return Response(answer.status, answer.read(), {k.lower(): v for k, v in answer.headers.items()})
|
|
55
|
+
except urllib.error.HTTPError as answer:
|
|
56
|
+
found = answer.headers.items() if answer.headers else []
|
|
57
|
+
return Response(answer.code, answer.read() or b"", {k.lower(): v for k, v in found})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def request_async(
|
|
61
|
+
method: str,
|
|
62
|
+
url: str,
|
|
63
|
+
*,
|
|
64
|
+
body: bytes | None = None,
|
|
65
|
+
headers: Mapping[str, str] | None = None,
|
|
66
|
+
timeout: float = 30.0,
|
|
67
|
+
verify: bool = True,
|
|
68
|
+
follow_redirects: bool = False,
|
|
69
|
+
) -> Response:
|
|
70
|
+
"""The same request with httpx (`pip install samarkand[async]`). A failure to reach the server
|
|
71
|
+
raises httpx.HTTPError."""
|
|
72
|
+
try:
|
|
73
|
+
import httpx
|
|
74
|
+
except ImportError as exc: # pragma: no cover - only without the extra
|
|
75
|
+
raise RuntimeError("The async sender needs httpx: install samarkand[async].") from exc
|
|
76
|
+
async with httpx.AsyncClient(timeout=timeout, verify=verify, follow_redirects=follow_redirects) as client:
|
|
77
|
+
answer = await client.request(method, url, content=body, headers=dict(headers or {}))
|
|
78
|
+
return Response(answer.status_code, answer.content, {k.lower(): v for k, v in answer.headers.items()})
|
samarkand/licence.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""The licence line shared by every product, checked offline against public keys the product supplies.
|
|
2
|
+
|
|
3
|
+
A licence is one line, `<product>-licence-1.<payload>.<signature>`:
|
|
4
|
+
|
|
5
|
+
- `payload` is base64url, without padding, of canonical JSON (sorted keys, compact separators);
|
|
6
|
+
- `signature` is Ed25519, base64url without padding, over the ASCII of `<product>-licence-1.<payload>`,
|
|
7
|
+
so the product and the format version are signed too and a line for one product can't be passed
|
|
8
|
+
off as another's.
|
|
9
|
+
|
|
10
|
+
Payload fields: `licence_id`, `product`, `licensee`, `edition` (`pro` or `services`), `seats` (a
|
|
11
|
+
number, or null for unlimited), `issued`, `expires` (a date, or null for perpetual), and optionally
|
|
12
|
+
`email`, `features` (extra feature ids, or `*` for all) and `not_before`. Dates are `YYYY-MM-DD`.
|
|
13
|
+
|
|
14
|
+
A licence runs from the start of its `not_before` day (UTC) to the end of its `expires` day, then
|
|
15
|
+
carries on for GRACE with a warning, then the installation is Free again. Before `not_before` it is
|
|
16
|
+
Free, shown as pending. A lapsed licence never deactivates anyone: that rule, the seat count and where
|
|
17
|
+
the licence is kept are each product's own.
|
|
18
|
+
|
|
19
|
+
There is no signing here. Licences are issued by each product's own admin script with the product's
|
|
20
|
+
private key, which never leaves the licensor. `head_of` gives the exact text an issuer signs and
|
|
21
|
+
`line_of` puts the line together, so an issuer can use the same encoding without this package ever
|
|
22
|
+
holding a private key.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import binascii
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
from collections.abc import Iterable
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
34
|
+
from enum import Enum
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from cryptography.exceptions import InvalidSignature
|
|
38
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
39
|
+
|
|
40
|
+
log = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
FORMAT_VERSION = 1
|
|
43
|
+
SUFFIX = f"-licence-{FORMAT_VERSION}"
|
|
44
|
+
GRACE = timedelta(days=30)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Edition(str, Enum):
|
|
48
|
+
"""The three editions. Code asks `Standing.has(feature)`, never the edition's name."""
|
|
49
|
+
|
|
50
|
+
FREE = "free"
|
|
51
|
+
PRO = "pro"
|
|
52
|
+
SERVICES = "services"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Feature ids: short, kebab-case, stable, and the same in every product.
|
|
56
|
+
REPORT_BRANDING = "report-branding" # one brand for the installation's reports
|
|
57
|
+
BRAND_LIBRARY = "brand-library" # a brand per client, chosen for each piece of work
|
|
58
|
+
AUTOMATION = "automation" # signed webhooks, checked when one is sent
|
|
59
|
+
MANAGED_SERVICE = "managed-service" # a right in the commercial terms, not a switch in the code
|
|
60
|
+
FEATURES = (REPORT_BRANDING, BRAND_LIBRARY, AUTOMATION, MANAGED_SERVICE)
|
|
61
|
+
WILDCARD = "*"
|
|
62
|
+
EDITION_FEATURES: dict[Edition, frozenset[str]] = {
|
|
63
|
+
Edition.FREE: frozenset(),
|
|
64
|
+
Edition.PRO: frozenset({REPORT_BRANDING, AUTOMATION}),
|
|
65
|
+
Edition.SERVICES: frozenset({REPORT_BRANDING, BRAND_LIBRARY, AUTOMATION, MANAGED_SERVICE}),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Why a licence was refused, for code to act on; the message is for the admin.
|
|
69
|
+
NOT_A_LICENCE = "not-a-licence"
|
|
70
|
+
OTHER_PRODUCT = "other-product"
|
|
71
|
+
DAMAGED = "damaged"
|
|
72
|
+
BAD_SIGNATURE = "bad-signature"
|
|
73
|
+
UNREADABLE = "unreadable" # signed, but fields this version doesn't understand: a newer format
|
|
74
|
+
GRANTS_NOTHING = "grants-nothing"
|
|
75
|
+
LAPSED = "lapsed"
|
|
76
|
+
|
|
77
|
+
# A Standing's state
|
|
78
|
+
FREE = "free"
|
|
79
|
+
PENDING = "pending"
|
|
80
|
+
ACTIVE = "active"
|
|
81
|
+
IN_GRACE = "grace"
|
|
82
|
+
LAPSED_STATE = "lapsed"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LicenceProblem(ValueError):
|
|
86
|
+
"""A licence that can't be used, with a reason an admin can act on (the message) and one code can
|
|
87
|
+
(`code`, one of the module's reason constants)."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, message: str, code: str) -> None:
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
self.code = code
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class Licence:
|
|
96
|
+
"""What a licence says, once its signature has checked out."""
|
|
97
|
+
|
|
98
|
+
licence_id: str
|
|
99
|
+
licensee: str
|
|
100
|
+
edition: Edition
|
|
101
|
+
seats: int | None # None: unlimited
|
|
102
|
+
issued: date
|
|
103
|
+
expires: date | None # None: perpetual
|
|
104
|
+
email: str = ""
|
|
105
|
+
features: tuple[str, ...] = () # beyond the edition's own
|
|
106
|
+
not_before: date | None = None
|
|
107
|
+
product: str = ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Standing:
|
|
112
|
+
"""What the installation may do now."""
|
|
113
|
+
|
|
114
|
+
edition: Edition
|
|
115
|
+
seats: int | None # None: unlimited
|
|
116
|
+
state: str # "free" (no licence), "pending", "active", "grace" or "lapsed"
|
|
117
|
+
licence: Licence | None = None
|
|
118
|
+
features: frozenset[str] = field(default_factory=frozenset)
|
|
119
|
+
|
|
120
|
+
def has(self, feature: str) -> bool:
|
|
121
|
+
"""Whether the installation may use `feature` now."""
|
|
122
|
+
return feature in self.features or WILDCARD in self.features
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def custom_branding(self) -> bool:
|
|
126
|
+
"""Reports may carry the installation's own brand."""
|
|
127
|
+
return self.has(REPORT_BRANDING)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def brand_library(self) -> bool:
|
|
131
|
+
"""A brand may be kept per client."""
|
|
132
|
+
return self.has(BRAND_LIBRARY)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def organisation(self) -> str:
|
|
136
|
+
"""The licensee, while the licence is in force (active or in grace), else ""."""
|
|
137
|
+
return self.licence.licensee if self.licence and self.state in (ACTIVE, IN_GRACE) else ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def format_of(product: str) -> str:
|
|
141
|
+
"""The line's prefix for a product: "acme" gives "acme-licence-1"."""
|
|
142
|
+
return f"{product}{SUFFIX}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _b64(data: bytes) -> str:
|
|
146
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _unb64(text: str) -> bytes:
|
|
150
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def canonical(payload: dict[str, Any]) -> str:
|
|
154
|
+
"""The JSON that is encoded and signed: sorted keys, compact separators."""
|
|
155
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def payload_of(licence: Licence) -> dict[str, Any]:
|
|
159
|
+
"""The signed fields. Optional ones are left out rather than sent empty."""
|
|
160
|
+
out: dict[str, Any] = {
|
|
161
|
+
"licence_id": licence.licence_id,
|
|
162
|
+
"product": licence.product,
|
|
163
|
+
"licensee": licence.licensee,
|
|
164
|
+
"edition": licence.edition.value,
|
|
165
|
+
"seats": licence.seats,
|
|
166
|
+
"issued": licence.issued.isoformat(),
|
|
167
|
+
"expires": licence.expires.isoformat() if licence.expires else None,
|
|
168
|
+
}
|
|
169
|
+
if licence.email:
|
|
170
|
+
out["email"] = licence.email
|
|
171
|
+
if licence.features:
|
|
172
|
+
out["features"] = sorted(licence.features)
|
|
173
|
+
if licence.not_before:
|
|
174
|
+
out["not_before"] = licence.not_before.isoformat()
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def head_of(licence: Licence) -> str:
|
|
179
|
+
"""`<product>-licence-1.<payload>`: the text an issuer signs (its ASCII bytes) with the product's
|
|
180
|
+
private key. For the licensor's admin script only; no product needs it."""
|
|
181
|
+
if not licence.product:
|
|
182
|
+
raise ValueError("A licence names its product.")
|
|
183
|
+
return f"{format_of(licence.product)}.{_b64(canonical(payload_of(licence)).encode())}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def line_of(head: str, signature: bytes) -> str:
|
|
187
|
+
"""The licence line from its head and the Ed25519 signature over it."""
|
|
188
|
+
return f"{head}.{_b64(signature)}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _article(name: str) -> str:
|
|
192
|
+
return "an" if name[:1].lower() in "aeiou" else "a"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load_keys(public_keys: Iterable[str]) -> tuple[Ed25519PublicKey, ...]:
|
|
196
|
+
"""Raw Ed25519 public keys given as hex. A key that isn't one is a mistake in the product, so it
|
|
197
|
+
raises ValueError rather than refusing a customer's licence."""
|
|
198
|
+
keys = []
|
|
199
|
+
for key in public_keys:
|
|
200
|
+
try:
|
|
201
|
+
keys.append(Ed25519PublicKey.from_public_bytes(bytes.fromhex(key)))
|
|
202
|
+
except ValueError as exc:
|
|
203
|
+
raise ValueError(f"Not a raw Ed25519 public key in hex: {key!r}") from exc
|
|
204
|
+
return tuple(keys)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _day(value: Any) -> date | None:
|
|
208
|
+
return date.fromisoformat(value) if value else None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse(
|
|
212
|
+
text: str,
|
|
213
|
+
*,
|
|
214
|
+
product: str,
|
|
215
|
+
public_keys: Iterable[str] | Iterable[Ed25519PublicKey],
|
|
216
|
+
name: str | None = None,
|
|
217
|
+
) -> Licence:
|
|
218
|
+
"""A licence for `product`, once its signature checks out against one of `public_keys` (a tuple,
|
|
219
|
+
so a key can be rotated by adding the new one). `name` is the product's name as people read it,
|
|
220
|
+
for the messages; it defaults to the id with a capital. Raises LicenceProblem."""
|
|
221
|
+
shown = name or product.capitalize()
|
|
222
|
+
expected = format_of(product)
|
|
223
|
+
keys = tuple(k if isinstance(k, Ed25519PublicKey) else load_keys((k,))[0] for k in public_keys)
|
|
224
|
+
parts = (text or "").strip().split(".")
|
|
225
|
+
if len(parts) != 3 or not parts[0].endswith(SUFFIX):
|
|
226
|
+
raise LicenceProblem(
|
|
227
|
+
f"That isn't {_article(shown)} {shown} licence. Paste the whole line you were sent.",
|
|
228
|
+
NOT_A_LICENCE,
|
|
229
|
+
)
|
|
230
|
+
if parts[0] != expected:
|
|
231
|
+
other = parts[0][: -len(SUFFIX)]
|
|
232
|
+
raise LicenceProblem(f"That's a licence for {other}, not {shown}.", OTHER_PRODUCT)
|
|
233
|
+
head = f"{parts[0]}.{parts[1]}"
|
|
234
|
+
try:
|
|
235
|
+
signature = _unb64(parts[2])
|
|
236
|
+
payload = json.loads(_unb64(parts[1]))
|
|
237
|
+
except (binascii.Error, ValueError) as exc:
|
|
238
|
+
raise LicenceProblem("That licence is damaged. Paste the whole line you were sent.", DAMAGED) from exc
|
|
239
|
+
for key in keys:
|
|
240
|
+
try:
|
|
241
|
+
key.verify(signature, head.encode())
|
|
242
|
+
break
|
|
243
|
+
except InvalidSignature:
|
|
244
|
+
continue
|
|
245
|
+
else:
|
|
246
|
+
raise LicenceProblem(f"That licence wasn't issued for {shown}, or has been altered.", BAD_SIGNATURE)
|
|
247
|
+
try:
|
|
248
|
+
licence = Licence(
|
|
249
|
+
licence_id=str(payload["licence_id"]),
|
|
250
|
+
product=str(payload["product"]),
|
|
251
|
+
licensee=str(payload["licensee"]),
|
|
252
|
+
email=str(payload.get("email", "")),
|
|
253
|
+
edition=Edition(payload["edition"]),
|
|
254
|
+
seats=None if payload["seats"] is None else int(payload["seats"]),
|
|
255
|
+
features=tuple(str(f) for f in payload.get("features", [])),
|
|
256
|
+
issued=date.fromisoformat(payload["issued"]),
|
|
257
|
+
not_before=_day(payload.get("not_before")),
|
|
258
|
+
expires=_day(payload["expires"]),
|
|
259
|
+
)
|
|
260
|
+
except (KeyError, ValueError, TypeError, AttributeError) as exc:
|
|
261
|
+
raise LicenceProblem(
|
|
262
|
+
f"That licence is from a newer version of {shown}. Update, then try again.", UNREADABLE
|
|
263
|
+
) from exc
|
|
264
|
+
if licence.product != product:
|
|
265
|
+
raise LicenceProblem(f"That's a licence for {licence.product}, not {shown}.", OTHER_PRODUCT)
|
|
266
|
+
if licence.edition is Edition.FREE or (licence.seats is not None and licence.seats < 1):
|
|
267
|
+
raise LicenceProblem("That licence grants nothing beyond the free edition.", GRANTS_NOTHING)
|
|
268
|
+
return licence
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _midnight(day: date) -> datetime:
|
|
272
|
+
return datetime.combine(day, time.min, timezone.utc)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def standing(licence: Licence | None, now: datetime | None = None, *, free_seats: int | None) -> Standing:
|
|
276
|
+
"""The edition a licence (or none) gives at `now` (UTC; a naive time is taken as UTC).
|
|
277
|
+
`free_seats` is the product's own free number."""
|
|
278
|
+
free = Standing(Edition.FREE, free_seats, FREE)
|
|
279
|
+
if licence is None:
|
|
280
|
+
return free
|
|
281
|
+
now = now or datetime.now(timezone.utc)
|
|
282
|
+
if now.tzinfo is None:
|
|
283
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
284
|
+
if licence.not_before and now < _midnight(licence.not_before):
|
|
285
|
+
return Standing(Edition.FREE, free_seats, PENDING, licence)
|
|
286
|
+
granted = EDITION_FEATURES[licence.edition] | frozenset(licence.features)
|
|
287
|
+
paid = Standing(licence.edition, licence.seats, ACTIVE, licence, granted)
|
|
288
|
+
if licence.expires is None:
|
|
289
|
+
return paid
|
|
290
|
+
ends = _midnight(licence.expires + timedelta(days=1))
|
|
291
|
+
if now < ends:
|
|
292
|
+
return paid
|
|
293
|
+
if now < ends + GRACE:
|
|
294
|
+
return Standing(licence.edition, licence.seats, IN_GRACE, licence, granted)
|
|
295
|
+
return Standing(Edition.FREE, free_seats, LAPSED_STATE, licence)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def grace_ends(licence: Licence) -> date | None:
|
|
299
|
+
"""The last day of grace, or None for a perpetual licence."""
|
|
300
|
+
return licence.expires + GRACE if licence.expires else None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def check_installable(licence: Licence, now: datetime | None = None) -> Licence:
|
|
304
|
+
"""`licence`, if it may be installed now: one already past its grace is refused (LicenceProblem,
|
|
305
|
+
LAPSED). A pending licence may be installed; it takes effect on its first day."""
|
|
306
|
+
if standing(licence, now, free_seats=None).state == LAPSED_STATE:
|
|
307
|
+
raise LicenceProblem(f"That licence ran out on {licence.expires}.", LAPSED)
|
|
308
|
+
return licence
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass(frozen=True)
|
|
312
|
+
class Rules:
|
|
313
|
+
"""One product's licence rules, bound once: its id, its trusted public keys (hex), its free seat
|
|
314
|
+
number and its name as people read it.
|
|
315
|
+
|
|
316
|
+
RULES = Rules("acme", ("cf9e…",), free_seats=4, name="Acme")
|
|
317
|
+
RULES.parse(line); RULES.standing(licence)
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
product: str
|
|
321
|
+
public_keys: tuple[str, ...]
|
|
322
|
+
free_seats: int | None
|
|
323
|
+
name: str = ""
|
|
324
|
+
_keys: tuple[Ed25519PublicKey, ...] = field(init=False, repr=False, compare=False)
|
|
325
|
+
|
|
326
|
+
def __post_init__(self) -> None:
|
|
327
|
+
if not self.public_keys:
|
|
328
|
+
log.warning("No public keys for %s: every licence will be refused.", self.product)
|
|
329
|
+
object.__setattr__(self, "_keys", load_keys(self.public_keys))
|
|
330
|
+
|
|
331
|
+
def parse(self, text: str) -> Licence:
|
|
332
|
+
"""See `parse`."""
|
|
333
|
+
return parse(text, product=self.product, public_keys=self._keys, name=self.name or None)
|
|
334
|
+
|
|
335
|
+
def standing(self, licence: Licence | None, now: datetime | None = None) -> Standing:
|
|
336
|
+
"""See `standing`."""
|
|
337
|
+
return standing(licence, now, free_seats=self.free_seats)
|
|
338
|
+
|
|
339
|
+
def current(self, text: str | None, now: datetime | None = None) -> Standing:
|
|
340
|
+
"""The standing from a stored line, or none. A stored line that no longer verifies (say, after
|
|
341
|
+
a key was withdrawn) counts as no licence, logged, so the installation carries on as Free."""
|
|
342
|
+
if not text:
|
|
343
|
+
return self.standing(None, now)
|
|
344
|
+
try:
|
|
345
|
+
return self.standing(self.parse(text), now)
|
|
346
|
+
except LicenceProblem as problem:
|
|
347
|
+
log.warning(
|
|
348
|
+
"The installed %s licence doesn't verify (%s): treating it as none.", self.product, problem
|
|
349
|
+
)
|
|
350
|
+
return self.standing(None, now)
|