keyrunes-python-sdk 0.4.0__tar.gz → 0.5.0__tar.gz

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.
@@ -0,0 +1,330 @@
1
+ # Changelog
2
+
3
+ All notable changes to the Keyrunes Python SDK.
4
+
5
+ Generated by `git-cliff` from the conventional commits — each commit body
6
+ becomes the text of its entry. **Do not edit by hand**: the next run overwrites
7
+ it. To change an entry, reword the commit message (`git rebase -i`); to change
8
+ the shape, edit `cliff.toml` and run `poetry run task changelog`.
9
+
10
+ ## [0.5.0] - 2026-11-04
11
+
12
+ ### Fixed
13
+
14
+ - **security**: verify self group checks against the server and pin the privileged-group set (reported by Rathnadeepan / HotWater @HotWater-015)
15
+
16
+ has_group used to answer for the authenticated user straight from the
17
+ JWT claims the client decoded with verify_signature=False, so a forged
18
+ token granted any group locally. Self checks now hit GET /api/me with
19
+ the bearer token and fail closed. The is_admin heuristic that granted
20
+ admin to any group whose name merely contained 'admin' (e.g.
21
+ 'non-admin') is replaced by exact membership in
22
+ {admin, admins, superadmin}. refresh_token now sends the Authorization
23
+ header the server's require_jwt middleware needs (it was 401 on every
24
+ call). Registration/login model defaults move from 'default' to the
25
+ server's actual default namespace 'public'. register_admin documents
26
+ that server >= 0.4.2 ignores admin_key at /api/register.
27
+
28
+ - **security**: build the request URL from the base instead of joining it
29
+
30
+ urljoin() lets a second argument that looks like a URL replace the base
31
+ completely: an endpoint of "A:" produced the relative URL "A:" and an
32
+ endpoint of "https://elsewhere.test/x" would have sent the request (and
33
+ the Authorization header) to another host. The URL is now built from the
34
+ configured base and the endpoint path, so an endpoint can never point the
35
+ request somewhere else.
36
+
37
+ The fuzz suite caught it: hypothesis stores the failing example, so the
38
+ invariant "an arbitrary endpoint still yields an absolute URL" is now
39
+ pinned by a regression test.
40
+
41
+ ### Documentation
42
+
43
+ - **client**: put the groups comment back in English
44
+
45
+ Every comment and docstring in this SDK is in English, and this one was not.
46
+ It went in today with the malformed-`groups` fix, in the same session where the
47
+ commit messages also drifted to Portuguese — same slip, different file.
48
+
49
+ - **changelog**: put cliff.toml and its output in English
50
+
51
+ Everything else in this repository is in English — code, comments, docstrings,
52
+ and every commit message. The `cliff.toml` written earlier today was not: a
53
+ Portuguese header, Portuguese group names and Portuguese comments, which put
54
+ eight Portuguese lines into an otherwise English generated changelog.
55
+
56
+ Group names follow Keep a Changelog, which is what the hand-written file used
57
+ before it was generated: Added, Fixed, Performance, Refactored, Testing,
58
+ Documentation, Infrastructure, Maintenance, Other.
59
+
60
+ One thing changes besides the language: a `.*` catch-all parser. Without it a
61
+ commit that follows no convention is dropped without a trace, and a changelog
62
+ that looks complete while omitting work is the failure worth guarding against.
63
+
64
+ - **client**: point the register_admin deprecation at the 0.5.0 release
65
+
66
+ The docstring still advertised 0.2.2, a version that never shipped the
67
+ deprecation. The method is deprecated since the release that carries the
68
+ server 0.4.2 hardening.
69
+
70
+
71
+ ## [0.4.0] - 2026-09-15
72
+
73
+ ### Added
74
+
75
+ - **client**: change_password, which the API served and the SDK did not expose
76
+
77
+ `POST /api/user/change-password` has been in Keyrunes all along. The SDK exposed
78
+ `login`, `refresh_token`, `register_user` and the group queries — and not this
79
+ one.
80
+
81
+ Whoever needed it built the HTTP call by hand: that is what happened downstream
82
+ in PlayfulLMS, and before that the conclusion was worse — reading only the SDK's
83
+ surface, the operation was written off as nonexistent, and the platform nearly
84
+ told people to change their password at the provider instead.
85
+
86
+ **Details that cost you if read wrong**
87
+
88
+ `/api/user/change-password`, and **not** `/api/change-password`: the router
89
+ registers the first. The handler behind the second is dead code marked
90
+ `#[allow(dead_code)]`, and pointing at it answers 404.
91
+
92
+ The server answers errors as **plain text** (`e.to_string()`), not JSON — treat
93
+ it as JSON and you get a parsing exception instead of the sentence explaining
94
+ what went wrong.
95
+
96
+ The error is **400**, not 401, which is why it becomes `NetworkError`: that is
97
+ the class this SDK already uses for everything that is not 401/403/404. Changing
98
+ it here would change the contract for every caller because of one endpoint. The
99
+ server's wording survives, which is what matters — "invalid current password"
100
+ and "password too short" call for opposite fixes.
101
+
102
+ Without a token it refuses **before** sending: the server decides whose password
103
+ it is from the token, and nothing in the body chooses that.
104
+
105
+ And the effect that closes the loop: the server clears `first_login` on a
106
+ change — the origin of the `requires_password_change` the login reports. It is
107
+ in the docstring because callers need to know there is no second call to make.
108
+
109
+ Verified against a real Keyrunes, not only against a double: register, change,
110
+ and then the old password refused (401) and the new one accepted (200), with
111
+ `first_login` cleared.
112
+
113
+ ### Fixed
114
+
115
+ - **client**: contain a malformed `groups` instead of taking the client down
116
+
117
+ The repository's own fuzz suite found it: with `{"groups": true}` in the server
118
+ response, normalization raised
119
+ `TypeError: argument of type 'bool' is not a container or iterable`.
120
+
121
+ `data.get("groups", []) or []` handles null and empty, and **nothing else**:
122
+ `True or []` is `True`, which survives all the way to the `"admins" in groups`
123
+ just below.
124
+
125
+ A client that falls over on a malformed response is worse than one that ignores
126
+ it: whoever consumes the SDK sees a type error coming from inside the library,
127
+ far from the cause, and nothing points at the server as the origin.
128
+
129
+ Pre-existing — `git stash` and the failure stays. Detection measured: putting
130
+ the `or []` back fails the fuzz test.
131
+
132
+ - **deps**: pyjwt 2.10.1 → 2.14.0 and idna 3.11 → 3.19 (OSV advisories)
133
+
134
+ The `safety` hook does not run on this machine — it asks for an interactive
135
+ login and dies with `EOF when reading a line`. Rather than skip the security
136
+ check and move on, the 90 versions pinned in `poetry.lock` were run against the
137
+ OSV database.
138
+
139
+ Two findings in the runtime tree:
140
+
141
+ - `pyjwt` 2.10.1: nine advisories, four of them high severity. In an
142
+ authentication library this is the dependency that matters most. Fixed in
143
+ 2.13.0; `pyproject` already declared `^2.9.0`, so no API changes — only the
144
+ lock.
145
+ - `idna` 3.11: two moderate advisories, fixed in 3.15. Arrives through `httpx`.
146
+
147
+ After the update the runtime tree carries no known advisory. The 194 tests pass
148
+ unchanged.
149
+
150
+ What remains are advisories in the development tree — `authlib`, `nltk`,
151
+ `cryptography`, `requests`, `urllib3` — and it is worth saying where they come
152
+ from: **every one of them is a dependency of `safety` itself**. The scanner that
153
+ cannot run is also the largest source of vulnerable packages in the environment.
154
+ They are left alone: they are development-only, they do not ship in the
155
+ published package, and resolving it means deciding whether `safety` stays.
156
+
157
+ ### Documentation
158
+
159
+ - **changelog**: record under 0.4.0 the two commits that landed after the tag
160
+
161
+ The `v0.4.0` tag sat on `chore(release): 0.4.0`, and two commits went in after
162
+ it: the malformed-`groups` fix and dropping `towncrier`. Since the tag was
163
+ **not published** (the remote does not have it), the fix can still go into 0.4.0
164
+ rather than forcing a 0.4.1 — and it should, because it is a `TypeError`
165
+ escaping the SDK.
166
+
167
+ - **changelog**: generate CHANGELOG.md with git-cliff, commit body included
168
+
169
+ The changelog in this repository was written by hand. The first attempt at
170
+ switching to generation was measured and **thrown away**: with git-cliff's
171
+ default template, which uses only the subject line, the file dropped from 246 to
172
+ 61 lines — the "what" survived and the "why" disappeared.
173
+
174
+ What fixes it is including the commit **body**. The messages here already
175
+ explain the decision, so the information was never only in the changelog: it was
176
+ duplicated. The generated file now runs to 222 lines, and every entry carries
177
+ the text that explains it.
178
+
179
+ Three details in `cliff.toml`, each from a defect visible in the output:
180
+
181
+ - `indent(prefix=" ", first=true, blank=false)` on the body. Without
182
+ `first=true` the body's first line falls outside the list item; with
183
+ `blank=true` the empty lines pick up two spaces and the `trailing-whitespace`
184
+ hook fails the commit.
185
+ - A postprocessor demotes to bold any `#` heading arriving inside a body —
186
+ indented, ATX is still a heading (it allows up to three leading spaces) and
187
+ would collide with the changelog's own levels. The pattern uses `[ \t]` and
188
+ **not** `\s`: `\s` matches `\n`, and in the first version it reached across
189
+ the line break and ate the changelog's own `## [0.4.0]`, which turned bold.
190
+ - Another postprocessor puts a blank line before each `## [version]`, because
191
+ `trim = true` glues the heading to the last line of the previous release.
192
+
193
+ Two sections shrink, and it is worth naming them: **0.1.0** (81 → 7 lines) was
194
+ an inventory of the API — the same thing the README already carries — and
195
+ **0.3.0** (55 → 11) loses the detail of the four contract bugs, because that
196
+ release's commit has a short body. The old text stays recoverable in git
197
+ history. From here the rule is different: the commit body is the source, so a
198
+ thin message makes a thin entry.
199
+
200
+ Along with it:
201
+
202
+ - `task changelog` generates and `task changelog-check` fails when the file is
203
+ out of date with the commits (a CI gate).
204
+ - `NEWS.rst` goes. It was a third changelog, in the format towncrier would
205
+ produce, with nothing generating it and nothing referencing it — a dead file
206
+ for the same reason towncrier left in the previous commit.
207
+
208
+ One honest caveat: that commit's body ends by saying "the changelog in this
209
+ repository is written by hand". It was true when written and stopped being true
210
+ now; a changelog is a historical record, and rewording the message to hide that
211
+ would cost rewriting already-published history.
212
+
213
+ ### Maintenance
214
+
215
+ - **deps**: drop towncrier, declared and never configured
216
+
217
+ `towncrier` had been a dev dependency from before, **without a
218
+ `[tool.towncrier]` section** in `pyproject.toml`, without a `towncrier.toml` and
219
+ without a fragments directory. Running it fails: "the config file does not
220
+ contain 'version' or 'package'".
221
+
222
+ `NEWS.rst` is in the format it *would* produce, which is worse than having
223
+ nothing: whoever arrives concludes the file is generated and leaves it alone, or
224
+ tries to run the tool and finds out it cannot. That happened here today — the
225
+ changelog was written by hand without noticing there was a tool declared.
226
+
227
+ A declared, unused dependency is the same family as dead code. The changelog in
228
+ this repository is written by hand, and now `pyproject.toml` says so.
229
+
230
+
231
+ ## [0.3.1] - 2026-09-04
232
+
233
+ ### Fixed
234
+
235
+ - **deps**: move mutmut out of the runtime dependencies; release 0.3.1
236
+
237
+ mutmut was declared in [tool.poetry.dependencies] rather than the dev group,
238
+ so installing this SDK pulled a mutation-testing tool and its whole tree
239
+ (libcst, textual, rich, setproctitle, pyyaml-ft, markdown-it-py,
240
+ mdit-py-plugins, mdurl, linkify-it-py, sortedcontainers) into every consumer,
241
+ production images included.
242
+
243
+ Nothing in keyrunes_sdk imports it, so upgrading only removes packages. The
244
+ wheel now declares httpx, pydantic[email] and pyjwt and nothing else.
245
+
246
+ Affected 0.2.0 and 0.3.0.
247
+
248
+
249
+ ## [0.3.0] - 2026-09-03
250
+
251
+ ### Added
252
+
253
+ - **client**: carry the full server identity and add refresh; release 0.3.0
254
+
255
+ Migrating a real consumer (an LMS keying its user rows off the JWT `sub`)
256
+ surfaced four contract bugs against a live Keyrunes server and three fields
257
+ the SDK was discarding.
258
+
259
+
260
+ ## [0.2.0] - 2026-09-03
261
+
262
+ ### Added
263
+
264
+ - **tests**: add property-based, fuzz and contract suites; release 0.2.0
265
+
266
+ Add three new test suites and the tooling to measure them:
267
+
268
+ - tests/test_property_based.py (24 tests): Hypothesis invariants over base
269
+ URL normalization, _normalize_user id precedence and is_admin derivation,
270
+ JWT claim parsing, request URL construction, status-to-exception mapping
271
+ and model validation bounds.
272
+ - tests/test_fuzz.py (19 tests): hostile-input crawl of the public surface
273
+ asserting only KeyrunesError and pydantic ValidationError ever escape.
274
+ - tests/test_request_contract.py (33 tests): mocks only the httpx transport
275
+ so the method, URL, headers and JSON body put on the wire are asserted.
276
+
277
+ Hypothesis profiles (fast/dev/ci) are selected with HYPOTHESIS_PROFILE;
278
+ fast is derandomized and database-free so mutmut judges every mutant
279
+ against identical examples.
280
+
281
+ Two defects the new suites uncovered are fixed:
282
+
283
+ - A 2xx response with a non-JSON body raised a raw ValueError out of
284
+ _make_request. It is now wrapped in NetworkError.
285
+ - get_user, get_current_user and has_group carried unreachable
286
+ "except UserNotFoundError" fallbacks that re-tested a condition already
287
+ forced by an early return, swallowing genuine server 404s. Removed, and
288
+ the duplicated claims-to-User block extracted into
289
+ _user_from_token_claims().
290
+
291
+ ### Maintenance
292
+
293
+ - add changelogs
294
+
295
+ - update lib version in pyproject
296
+
297
+ - add pypi url in README
298
+
299
+
300
+ ## [0.1.0] - 2026-01-03
301
+
302
+ ### Added
303
+
304
+ - add organization key for keyrunes v0.2.0
305
+
306
+
307
+ ## [0.0.1] - 2025-12-08
308
+
309
+ ### Added
310
+
311
+ - add login, register, check_admin, check_user, get_current_user features
312
+
313
+ ### Fixed
314
+
315
+ - fix lib version
316
+
317
+ ### Infrastructure
318
+
319
+ - fix lint errors and add pre-commit
320
+
321
+ ### Maintenance
322
+
323
+ - add changelogs
324
+
325
+ ### Other
326
+
327
+ - Initial commit
328
+
329
+
330
+ <!-- generated by git-cliff -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keyrunes-python-sdk
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Python SDK for Keyrunes Authorization System
5
5
  License: AGPL
6
6
  License-File: LICENSE
@@ -2,7 +2,6 @@
2
2
 
3
3
  import os
4
4
  from typing import Any, Dict, List, Optional
5
- from urllib.parse import urljoin
6
5
 
7
6
  import httpx
8
7
  import jwt
@@ -42,6 +41,12 @@ ENDPOINT_REFRESH_TOKEN = "/api/refresh-token"
42
41
  #: ``#[allow(dead_code)]``. Pointing at it answers 404.
43
42
  ENDPOINT_CHANGE_PASSWORD = "/api/user/change-password"
44
43
 
44
+ #: Group names that carry administrative rights in the Keyrunes server.
45
+ #: Matching is EXACT: a substring test ("admin" in g.lower()) used to treat
46
+ #: "non-admin" or "billing-admin-viewer" as privileged — a user created in
47
+ #: any such group passed ``@require_admin``.
48
+ PRIVILEGED_GROUPS = frozenset({"admin", "admins", "superadmin"})
49
+
45
50
 
46
51
  class KeyrunesClient:
47
52
  """
@@ -102,6 +107,7 @@ class KeyrunesClient:
102
107
  data: Optional[Dict[str, Any]] = None,
103
108
  params: Optional[Dict[str, Any]] = None,
104
109
  use_auth: bool = True,
110
+ headers: Optional[Dict[str, str]] = None,
105
111
  ) -> Dict[str, Any]:
106
112
  """
107
113
  Make HTTP request to Keyrunes API.
@@ -112,6 +118,11 @@ class KeyrunesClient:
112
118
  data: Request body data
113
119
  params: Query parameters
114
120
  use_auth: Whether to include authentication header
121
+ headers: Extra per-call headers. An ``Authorization`` given here
122
+ wins over the stored client token — used by
123
+ ``refresh_token``, which must authenticate with the token
124
+ being exchanged even when the client already holds another
125
+ one.
115
126
 
116
127
  Returns:
117
128
  Response JSON data
@@ -121,11 +132,11 @@ class KeyrunesClient:
121
132
  AuthenticationError: If authentication fails (401)
122
133
  AuthorizationError: If authorization fails (403)
123
134
  """
124
- url = urljoin(self.base_url + "/", endpoint.lstrip("/"))
125
- headers = {}
135
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
136
+ request_headers: Dict[str, str] = dict(headers or {})
126
137
 
127
- if use_auth and self._token:
128
- headers["Authorization"] = f"Bearer {self._token}"
138
+ if use_auth and self._token and "Authorization" not in request_headers:
139
+ request_headers["Authorization"] = f"Bearer {self._token}"
129
140
 
130
141
  try:
131
142
  response = self._client.request(
@@ -133,7 +144,7 @@ class KeyrunesClient:
133
144
  url=url,
134
145
  json=data,
135
146
  params=params,
136
- headers=headers,
147
+ headers=request_headers,
137
148
  )
138
149
 
139
150
  if response.status_code == 401:
@@ -182,10 +193,11 @@ class KeyrunesClient:
182
193
  )
183
194
  normalized["username"] = data.get("username", "")
184
195
  normalized["email"] = data.get("email", "")
185
- # `or []` trata nulo e vazio, e **não** trata o resto: com
186
- # ``{"groups": true}`` o valor sobrevivia como ``True``, e o
187
- # ``"admins" in groups`` logo abaixo estourava ``TypeError`` — o cliente
188
- # caía em vez de conter uma resposta malformada. Achado pelo fuzzer.
196
+ # `or []` handles null and empty and **nothing else**: with
197
+ # ``{"groups": true}`` the value survived as ``True``, and the
198
+ # ``"admins" in groups`` just below raised ``TypeError`` — the client
199
+ # fell over instead of containing a malformed response. Found by the
200
+ # fuzz suite.
189
201
  raw_groups = data.get("groups")
190
202
  normalized["groups"] = (
191
203
  list(raw_groups) if isinstance(raw_groups, list) else []
@@ -194,10 +206,13 @@ class KeyrunesClient:
194
206
  normalized["is_active"] = data.get("is_active", True)
195
207
  groups = normalized["groups"]
196
208
  is_admin_flag = data.get("is_admin", False)
197
- has_admin_group = "admins" in groups or "superadmin" in groups
198
- has_admin_in_name = any("admin" in str(g).lower() for g in groups)
199
- normalized["is_admin"] = (
200
- is_admin_flag or has_admin_group or has_admin_in_name
209
+ # Exact membership only. The previous heuristic (`"admin" in
210
+ # str(g).lower()`) granted admin privileges to any group whose name
211
+ # merely *contained* "admin" — "non-admin", "billing-admin-viewer"
212
+ # and friends all unlocked @require_admin. "admins" stays in the set
213
+ # because server fixtures still seed that legacy spelling.
214
+ normalized["is_admin"] = bool(is_admin_flag) or any(
215
+ isinstance(g, str) and g in PRIVILEGED_GROUPS for g in groups
201
216
  )
202
217
  # Carried through untouched so a caller can key its own records off the
203
218
  # same identifier the JWT ``sub`` uses, rather than off ``id``.
@@ -400,6 +415,16 @@ class KeyrunesClient:
400
415
  """
401
416
  Register a new admin user.
402
417
 
418
+ .. deprecated:: 0.5.0
419
+ Since Keyrunes server 0.4.2 (C11-008 remediation) ``POST
420
+ /api/register`` **ignores** ``admin_key`` and never promotes the
421
+ registrant: the endpoint only ever creates a normal user.
422
+ Administrators must be created through an authenticated
423
+ ``POST /api/admin/user`` (with the caller's JWT) or through the
424
+ server's CLI bootstrap. This method is kept so existing code
425
+ still calls a registered endpoint, but do not expect the
426
+ returned user to hold any privilege.
427
+
403
428
  Args:
404
429
  username: Username (3-50 characters)
405
430
  email: Admin email address
@@ -471,10 +496,17 @@ class KeyrunesClient:
471
496
  )
472
497
 
473
498
  if token_user_id and str(user_id) == token_user_id:
474
- groups = (
475
- self._token_data.get("groups", []) if self._token_data else []
476
- )
477
- return group_id in groups
499
+ # SECURITY: the JWT held by this client was decoded with
500
+ # ``verify_signature=False`` — anyone can mint one claiming any
501
+ # group. The claims said nothing about whether the server still
502
+ # accepts this token, so the answer comes from ``/api/me``,
503
+ # which only a live, valid token can query. Fail closed: a 404
504
+ # from the server means no access, it does not raise.
505
+ try:
506
+ user = self.get_current_user(force_refresh=True)
507
+ except UserNotFoundError:
508
+ return False
509
+ return group_id in user.groups
478
510
 
479
511
  try:
480
512
  response = self._make_request(
@@ -628,11 +660,15 @@ class KeyrunesClient:
628
660
  if not current:
629
661
  raise InvalidTokenError("No token available to refresh.")
630
662
 
663
+ # The server binds the refreshed identity to the token in the
664
+ # Authorization header (require_jwt middleware) and ignores the body
665
+ # since the 0.4.1 fix. Authenticate with the token being exchanged.
631
666
  response = self._make_request(
632
667
  "POST",
633
668
  ENDPOINT_REFRESH_TOKEN,
634
669
  data={"token": current},
635
670
  use_auth=False,
671
+ headers={"Authorization": f"Bearer {current}"},
636
672
  )
637
673
 
638
674
  refreshed = self._parse_token_response(response)
@@ -3,7 +3,7 @@
3
3
  from functools import wraps
4
4
  from typing import Any, Callable, Optional
5
5
 
6
- from keyrunes_sdk.client import KeyrunesClient
6
+ from keyrunes_sdk.client import PRIVILEGED_GROUPS, KeyrunesClient
7
7
  from keyrunes_sdk.exceptions import AuthorizationError, UserNotFoundError
8
8
 
9
9
 
@@ -218,11 +218,14 @@ def require_admin(
218
218
  if keyrunes_client._token_data
219
219
  else []
220
220
  )
221
- is_admin = (
222
- "admins" in groups
223
- or "superadmin" in groups
224
- or any("admin" in str(g).lower() for g in groups)
225
- )
221
+ # Exact membership only. The old heuristic treated ANY
222
+ # group whose name contained the substring "admin"
223
+ # ("non-admin", "billing-admin-viewer") as admin — a
224
+ # group-name collision silently granted privileges.
225
+ # KeyRunes' own names are "admin" and "superadmin";
226
+ # "admins" is kept because deployments and the SDK
227
+ # fixtures historically use it.
228
+ is_admin = any(g in PRIVILEGED_GROUPS for g in groups)
226
229
  if not is_admin:
227
230
  raise AuthorizationError(
228
231
  f"User '{user_id}' does not have admin privileges."
@@ -85,7 +85,9 @@ class UserRegistration(BaseModel):
85
85
  )
86
86
  email: EmailStr = Field(..., description="User email")
87
87
  password: str = Field(..., min_length=8, description="Password")
88
- namespace: str = Field("default", description="User namespace")
88
+ # The server's default tenant is "public", not "default"; a payload that
89
+ # registered into "default" created a stray namespace nobody configured.
90
+ namespace: str = Field("public", description="User namespace")
89
91
  attributes: Dict[str, Any] = Field(
90
92
  default_factory=dict, description="Additional attributes"
91
93
  )
@@ -102,11 +104,12 @@ class LoginCredentials(BaseModel):
102
104
 
103
105
  identity: str = Field(..., description="Username or email")
104
106
  password: str = Field(..., description="Password")
105
- namespace: str = Field("default", description="User namespace")
107
+ # "public" is the server's default tenant; see UserRegistration.namespace.
108
+ namespace: str = Field("public", description="User namespace")
106
109
 
107
110
  @classmethod
108
111
  def from_username(
109
- cls, username: str, password: str, namespace: str = "default"
112
+ cls, username: str, password: str, namespace: str = "public"
110
113
  ) -> "LoginCredentials":
111
114
  """Create from username parameter (for backward compatibility)."""
112
115
  return cls(identity=username, password=password, namespace=namespace)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "keyrunes-python-sdk"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Python SDK for Keyrunes Authorization System"
5
5
  authors = ["keyrunes <contact@singularjourney.host>"]
6
6
  maintainers = ["jonatasoli <contact@jonatasoli.dev>"]
@@ -121,5 +121,7 @@ publish = "poetry build && twine check dist/* && poetry publish"
121
121
  version-patch = "poetry version patch"
122
122
  version-minor = "poetry version minor"
123
123
  version-major = "poetry version major"
124
+ changelog = "git cliff -o CHANGELOG.md"
125
+ changelog-check = "git cliff -o /dev/stdout | diff -q - CHANGELOG.md"
124
126
  all = "poetry run task format && poetry run task lint && poetry run task type-check && poetry run task test"
125
127
  ci = "poetry run task lint && poetry run task type-check && poetry run task test"
@@ -1,271 +0,0 @@
1
- # Changelog
2
-
3
- Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.
4
-
5
- O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),
6
- e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
7
-
8
- ## [0.4.0] - 2026-09-15
9
-
10
- ### Added
11
-
12
- - `KeyrunesClient.change_password(current_password, new_password)`, wrapping
13
- `POST /api/user/change-password`. The server has served this endpoint all
14
- along; the SDK exposed `login`, `refresh_token`, `register_user` and the group
15
- queries, and not this one. Anyone who needed it built the HTTP call by hand —
16
- which is what happened downstream, after first concluding from the SDK's
17
- surface that the operation did not exist at all.
18
-
19
- Three details the endpoint carries, and each one bites if read wrong:
20
-
21
- - it lives at `/api/user/change-password`, **not** `/api/change-password` —
22
- the router registers the first, and the handler behind the second is dead
23
- code marked `#[allow(dead_code)]`, so pointing at it answers 404;
24
- - it answers errors as **plain text** (`e.to_string()`), not JSON, and with
25
- status **400** rather than 401 — so this client raises `NetworkError`, the
26
- same class it already uses for every non-401/403/404 status, and preserves
27
- the server's own wording, because "invalid current password" and "password
28
- too short" call for opposite fixes;
29
- - changing the password clears the account's `first_login` flag server-side,
30
- which is what `requires_password_change` reports at login. There is no
31
- second call to make.
32
-
33
- Without a token the client refuses before sending: the server decides whose
34
- password it is from the token, and nothing in the body chooses that.
35
-
36
- ### Fixed
37
-
38
- - A malformed `groups` field in a server response no longer crashes the client.
39
- `get_current_user` and `check_user` called `list(data["groups"])` on whatever
40
- came back; a JSON `true`, a number, or `null` raised `TypeError: 'bool' object
41
- is not iterable` out of the SDK, from a line that reads like a safe coercion.
42
- Anything that is not a JSON array is now read as no groups. Found by the fuzz
43
- suite, and it predates this release — `git stash` confirmed it on the previous
44
- tag.
45
-
46
- ### Security
47
-
48
- - `pyjwt` 2.10.1 → 2.14.0 and `idna` 3.11 → 3.19 in the lockfile. Both are
49
- runtime dependencies (`idna` arrives through `httpx`), and OSV reports known
50
- advisories against the pinned versions — nine for `pyjwt`, four of them high.
51
- The declared constraint was already `^2.9.0`, so this is a lockfile refresh
52
- with no API change; the 194 tests pass unchanged.
53
-
54
- ### Removed
55
-
56
- - `towncrier` left the dev dependencies. It was declared and never configured:
57
- no `[tool.towncrier]` section, no `newsfragments/` directory, and this
58
- changelog was written by hand. A tool nobody runs still gets installed,
59
- resolved, and audited on every environment build.
60
-
61
- ## [0.3.1] - 2026-09-04
62
-
63
- ### Fixed
64
-
65
- - `mutmut` is no longer a runtime dependency. It was declared in
66
- `[tool.poetry.dependencies]` instead of the dev group, so installing this SDK
67
- pulled a mutation-testing tool and its whole tree — `libcst`, `textual`,
68
- `rich`, `setproctitle`, `pyyaml-ft`, `markdown-it-py`, `mdit-py-plugins`,
69
- `mdurl`, `linkify-it-py`, `sortedcontainers` — into every consumer, including
70
- production images. Affected 0.2.0 and 0.3.0; nothing in the library imported
71
- it, so upgrading only removes packages.
72
-
73
- ## [0.3.0] - 2026-09-03
74
-
75
- ### Added
76
-
77
- - `User.user_id`, carrying the internal identifier Keyrunes puts in the JWT
78
- `sub` claim. It is a different value from `User.id` (the external UUID), and
79
- a consumer that keys its own records off `sub` needs it; before this release
80
- it was discarded during normalization and could only be recovered by
81
- re-parsing the token.
82
- - `User.namespace`, `User.organization_id` and `User.first_login`, all of which
83
- the server returns and normalization used to drop.
84
- - `Token.requires_password_change`, so a login that must be followed by a
85
- password change can be detected without reading the raw response.
86
- - `KeyrunesClient.refresh_token()`, wrapping `POST /api/refresh-token`. It
87
- accepts an explicit token or falls back to the client's current one, raises
88
- `InvalidTokenError` when neither is available, and adopts the refreshed
89
- token the way `login()` does.
90
- - `get_current_user(force_refresh=True)`, which always asks the server. The
91
- claims shortcut reads a token the SDK never verified, so it cannot answer
92
- "is this token still accepted"; a caller validating a token needs the round
93
- trip.
94
- - `KeyrunesError.status_code`, carrying the HTTP status when the error came
95
- from a response rather than from the transport. Without it a caller cannot
96
- tell "the server refused this request" (4xx) from "the server or the network
97
- failed" (5xx, or no response at all), and both are raised as `NetworkError`.
98
- - `register_user(group=...)`, sent as a top-level field.
99
- - A `py.typed` marker (PEP 561). The package was already fully typed, but
100
- without the marker a consumer running mypy saw every SDK import as
101
- untyped.
102
-
103
- ### Fixed
104
-
105
- - `get_current_user()` asked for `/api/users/me`, which the Keyrunes router
106
- does not expose — the real route is `/api/me`, so the call answered 404
107
- against a live server whenever the claims shortcut did not cover it. The
108
- endpoint is now a named constant, `ENDPOINT_ME`.
109
- - `register_user()` and `register_admin()` required the response to be
110
- `{"user": {...}}`, but `POST /api/register` answers with the bare user
111
- object. Every registration against a real server failed with "Unexpected
112
- response format". Both shapes are now accepted.
113
- - Extra keyword arguments to `register_user()` were nested under `attributes`,
114
- so a `group` never reached the server, which reads it as a top-level field.
115
- `group` is now an explicit parameter placed at the top level; other keyword
116
- attributes keep nesting under `attributes`.
117
-
118
- ### Testing
119
-
120
- - Test count raised from 159 to 189.
121
-
122
- ### Notes
123
-
124
- - Every added field is optional with a backwards-compatible default, and
125
- `User.id` keeps its existing precedence (`id` → `user_id` → `external_id`).
126
- Code written against 0.2.0 keeps working unchanged.
127
-
128
- ## [0.2.0] - 2026-09-03
129
-
130
- ### Added
131
-
132
- - Property-based test suite (`tests/test_property_based.py`, 24 tests) built on
133
- Hypothesis, covering base URL normalization, `_normalize_user` id precedence
134
- and `is_admin` derivation, JWT claim parsing, request URL construction, HTTP
135
- status to exception mapping, and model validation bounds.
136
- - Input fuzzing ("spider") suite (`tests/test_fuzz.py`, 19 tests) that crawls the
137
- public surface with hostile payloads and asserts that only `KeyrunesError` and
138
- pydantic `ValidationError` ever escape, and that nothing panics or leaks a raw
139
- `ValueError`/`TypeError`.
140
- - Request contract suite (`tests/test_request_contract.py`, 33 tests) that mocks
141
- only the httpx transport, so the method, URL, headers and JSON body actually
142
- put on the wire are asserted for every endpoint.
143
- - Hypothesis profiles in `tests/conftest.py` (`fast`, `dev`, `ci`) selected via
144
- the `HYPOTHESIS_PROFILE` environment variable. `fast` is derandomized and
145
- database-free so mutation runs judge every mutant against identical examples.
146
- - `[tool.mutmut]` configuration in `pyproject.toml` for mutation testing.
147
- - `hypothesis` added as a development dependency.
148
-
149
- ### Fixed
150
-
151
- - A 2xx response carrying a non-JSON body raised a raw `ValueError` out of
152
- `KeyrunesClient._make_request`. It is now wrapped in `NetworkError`, so the
153
- documented exception contract holds for malformed responses.
154
-
155
- ### Changed
156
-
157
- - Extracted the duplicated "build a `User` from JWT claims" block into
158
- `KeyrunesClient._user_from_token_claims()`.
159
-
160
- ### Removed
161
-
162
- - Unreachable `except UserNotFoundError` fallbacks in `get_user`,
163
- `get_current_user` and `has_group`. Each re-tested a condition that had
164
- already forced an early return, so a 404 from the server was being swallowed
165
- instead of propagated.
166
-
167
- ### Testing
168
-
169
- - Test count raised from 83 to 159.
170
- - Mutation score (mutmut) raised from 44% (294/669 mutants killed) to 72%
171
- (387/537). The remaining survivors are predominantly equivalent mutants that
172
- only alter error message prose.
173
-
174
- ## [0.1.0] - 2025-12-03
175
-
176
- ### Adicionado
177
-
178
- #### Funcionalidades Core
179
- - Cliente `KeyrunesClient` completo para interação com Keyrunes API
180
- - Autenticação com login de usuário e admin
181
- - Registro de usuário e admin com validação
182
- - Verificação de pertencimento a grupos
183
- - Obtenção de informações de usuários
184
-
185
- #### Decorators
186
- - `@require_group()` - Decorator para verificar grupos de usuários
187
- - `@require_admin()` - Decorator para verificar privilégios de admin
188
- - Suporte para múltiplos grupos (ANY ou ALL)
189
- - Sistema de client global para uso sem passar client explicitamente
190
-
191
- #### Modelos Pydantic
192
- - `User` - Modelo de usuário com validação
193
- - `Token` - Modelo de token JWT
194
- - `Group` - Modelo de grupo
195
- - `UserRegistration` - Dados de registro de usuário
196
- - `AdminRegistration` - Dados de registro de admin
197
- - `LoginCredentials` - Credenciais de login
198
- - `GroupCheck` - Resultado de verificação de grupo
199
-
200
- #### Exceções Customizadas
201
- - `KeyrunesError` - Exceção base
202
- - `AuthenticationError` - Erro de autenticação
203
- - `AuthorizationError` - Erro de autorização
204
- - `GroupNotFoundError` - Grupo não encontrado
205
- - `UserNotFoundError` - Usuário não encontrado
206
- - `InvalidTokenError` - Token inválido
207
- - `NetworkError` - Erro de rede
208
-
209
- #### Sistema de Configuração Global
210
- - `configure()` - Configura client global
211
- - `get_global_client()` - Obtém client global
212
- - `clear_global_client()` - Limpa client global
213
- - Thread-safe com Lock
214
-
215
- #### Desenvolvimento e Testes
216
- - Docker Compose completo com Keyrunes, PostgreSQL e Redis
217
- - 78 testes com 99% de cobertura
218
- - Testes usando pytest, factory-boy e faker
219
- - Exemplos práticos de uso
220
- - Makefile com comandos úteis
221
- - Configuração completa de CI/CD
222
-
223
- #### Documentação
224
- - README.md completo com exemplos
225
- - TESTING.md com guia de testes
226
- - Docstrings em todas as funções
227
- - Type hints completos
228
- - Exemplos práticos em `examples/`
229
-
230
- ### Detalhes Técnicos
231
-
232
- - Python 3.8.1+ compatível
233
- - Gerenciamento com Poetry
234
- - Validação com Pydantic 2.0
235
- - Type hints completos
236
- - Thread-safe
237
- - Context manager support
238
-
239
- ### Testes
240
-
241
- - 78 testes implementados
242
- - 99% de cobertura de código
243
- - Testes unitários e de integração
244
- - Factories com factory-boy
245
- - Dados fake com Faker
246
-
247
- ### Ferramentas de Desenvolvimento
248
-
249
- - Black para formatação
250
- - isort para organização de imports
251
- - flake8 para linting
252
- - mypy para type checking
253
- - pytest para testes
254
-
255
- ## [Unreleased]
256
-
257
- ### Planejado
258
-
259
- - Suporte para refresh token automático
260
- - Cache de verificações de grupo
261
- - Suporte para OIDC
262
- - Integração com FastAPI
263
- - Integração com Flask
264
- - Integração com Django
265
- - Mais exemplos práticos
266
- - Documentação com Sphinx
267
- - Publicação no PyPI
268
-
269
- ---
270
-
271
- Para mais detalhes sobre cada versão, veja os [releases no GitHub](https://github.com/jonatasoli/keyurnes-sdk-python-dark/releases).