keyrunes-python-sdk 0.3.1__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.3.1
3
+ Version: 0.5.0
4
4
  Summary: Python SDK for Keyrunes Authorization System
5
5
  License: AGPL
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  """Keyrunes SDK - Python client for Keyrunes Authorization System."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.4.0"
4
4
 
5
5
  from keyrunes_sdk.client import KeyrunesClient
6
6
  from keyrunes_sdk.config import (
@@ -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
@@ -35,6 +34,19 @@ ENDPOINT_ME = "/api/me"
35
34
  #: Exchanges a still-valid token for a fresh one.
36
35
  ENDPOINT_REFRESH_TOKEN = "/api/refresh-token"
37
36
 
37
+ #: Changes the password of whoever the bearer token identifies.
38
+ #:
39
+ #: ``/api/user/change-password``, and not ``/api/change-password``: the router
40
+ #: registers the first, and the handler behind the second is dead code marked
41
+ #: ``#[allow(dead_code)]``. Pointing at it answers 404.
42
+ ENDPOINT_CHANGE_PASSWORD = "/api/user/change-password"
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
+
38
50
 
39
51
  class KeyrunesClient:
40
52
  """
@@ -95,6 +107,7 @@ class KeyrunesClient:
95
107
  data: Optional[Dict[str, Any]] = None,
96
108
  params: Optional[Dict[str, Any]] = None,
97
109
  use_auth: bool = True,
110
+ headers: Optional[Dict[str, str]] = None,
98
111
  ) -> Dict[str, Any]:
99
112
  """
100
113
  Make HTTP request to Keyrunes API.
@@ -105,6 +118,11 @@ class KeyrunesClient:
105
118
  data: Request body data
106
119
  params: Query parameters
107
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.
108
126
 
109
127
  Returns:
110
128
  Response JSON data
@@ -114,11 +132,11 @@ class KeyrunesClient:
114
132
  AuthenticationError: If authentication fails (401)
115
133
  AuthorizationError: If authorization fails (403)
116
134
  """
117
- url = urljoin(self.base_url + "/", endpoint.lstrip("/"))
118
- headers = {}
135
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
136
+ request_headers: Dict[str, str] = dict(headers or {})
119
137
 
120
- if use_auth and self._token:
121
- 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}"
122
140
 
123
141
  try:
124
142
  response = self._client.request(
@@ -126,7 +144,7 @@ class KeyrunesClient:
126
144
  url=url,
127
145
  json=data,
128
146
  params=params,
129
- headers=headers,
147
+ headers=request_headers,
130
148
  )
131
149
 
132
150
  if response.status_code == 401:
@@ -175,15 +193,26 @@ class KeyrunesClient:
175
193
  )
176
194
  normalized["username"] = data.get("username", "")
177
195
  normalized["email"] = data.get("email", "")
178
- normalized["groups"] = data.get("groups", []) or []
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.
201
+ raw_groups = data.get("groups")
202
+ normalized["groups"] = (
203
+ list(raw_groups) if isinstance(raw_groups, list) else []
204
+ )
179
205
  normalized["attributes"] = data.get("attributes", {})
180
206
  normalized["is_active"] = data.get("is_active", True)
181
207
  groups = normalized["groups"]
182
208
  is_admin_flag = data.get("is_admin", False)
183
- has_admin_group = "admins" in groups or "superadmin" in groups
184
- has_admin_in_name = any("admin" in str(g).lower() for g in groups)
185
- normalized["is_admin"] = (
186
- 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
187
216
  )
188
217
  # Carried through untouched so a caller can key its own records off the
189
218
  # same identifier the JWT ``sub`` uses, rather than off ``id``.
@@ -386,6 +415,16 @@ class KeyrunesClient:
386
415
  """
387
416
  Register a new admin user.
388
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
+
389
428
  Args:
390
429
  username: Username (3-50 characters)
391
430
  email: Admin email address
@@ -457,10 +496,17 @@ class KeyrunesClient:
457
496
  )
458
497
 
459
498
  if token_user_id and str(user_id) == token_user_id:
460
- groups = (
461
- self._token_data.get("groups", []) if self._token_data else []
462
- )
463
- 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
464
510
 
465
511
  try:
466
512
  response = self._make_request(
@@ -614,11 +660,15 @@ class KeyrunesClient:
614
660
  if not current:
615
661
  raise InvalidTokenError("No token available to refresh.")
616
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.
617
666
  response = self._make_request(
618
667
  "POST",
619
668
  ENDPOINT_REFRESH_TOKEN,
620
669
  data={"token": current},
621
670
  use_auth=False,
671
+ headers={"Authorization": f"Bearer {current}"},
622
672
  )
623
673
 
624
674
  refreshed = self._parse_token_response(response)
@@ -627,6 +677,49 @@ class KeyrunesClient:
627
677
  self.set_token(refreshed.access_token)
628
678
  return refreshed
629
679
 
680
+ def change_password(self, current_password: str, new_password: str) -> None:
681
+ """
682
+ Change the password of whoever the current token identifies.
683
+
684
+ The server also clears the account's ``first_login`` flag, which is
685
+ what ``requires_password_change`` reports at login: changing the
686
+ password here puts that warning away without a second call.
687
+
688
+ Nothing in the body says whose password it is — the token does. A body
689
+ that chose would make "change my password" mean "change anyone's".
690
+
691
+ Args:
692
+ current_password: The password being replaced. The server checks
693
+ it before accepting the change.
694
+ new_password: The new password. The server requires at least 8
695
+ characters and answers 400 when it is shorter.
696
+
697
+ Raises:
698
+ InvalidTokenError: If the client has no token set
699
+ NetworkError: If the server refuses — wrong current password, or a
700
+ new password it will not accept. The server answers **400** for
701
+ both, which this client maps to ``NetworkError`` like any
702
+ other non-401/403/404 status. The server's wording survives,
703
+ because "invalid current password" and "password too short" call
704
+ for opposite fixes.
705
+
706
+ Example:
707
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
708
+ >>> client.set_token("eyJhbGciOiJIUzI1NiIs...")
709
+ >>> client.change_password("old-secret", "a-longer-new-secret")
710
+ """
711
+ if not self._token:
712
+ raise InvalidTokenError("No token available to change a password.")
713
+
714
+ self._make_request(
715
+ "POST",
716
+ ENDPOINT_CHANGE_PASSWORD,
717
+ data={
718
+ "current_password": current_password,
719
+ "new_password": new_password,
720
+ },
721
+ )
722
+
630
723
  def clear_token(self) -> None:
631
724
  """
632
725
  Clear authentication token.
@@ -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.3.1"
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>"]
@@ -47,7 +47,6 @@ isort = "^5.12.0"
47
47
  flake8 = "^6.1.0"
48
48
  mypy = "^1.5.0"
49
49
  taskipy = "^1.12.0"
50
- towncrier = "^25.8.0"
51
50
  pre-commit = "^4.5.1"
52
51
  safety = "^3.7.0"
53
52
  hypothesis = "^6.0"
@@ -122,5 +121,7 @@ publish = "poetry build && twine check dist/* && poetry publish"
122
121
  version-patch = "poetry version patch"
123
122
  version-minor = "poetry version minor"
124
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"
125
126
  all = "poetry run task format && poetry run task lint && poetry run task type-check && poetry run task test"
126
127
  ci = "poetry run task lint && poetry run task type-check && poetry run task test"
@@ -1,218 +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.3.1] - 2026-09-04
9
-
10
- ### Fixed
11
-
12
- - `mutmut` is no longer a runtime dependency. It was declared in
13
- `[tool.poetry.dependencies]` instead of the dev group, so installing this SDK
14
- pulled a mutation-testing tool and its whole tree — `libcst`, `textual`,
15
- `rich`, `setproctitle`, `pyyaml-ft`, `markdown-it-py`, `mdit-py-plugins`,
16
- `mdurl`, `linkify-it-py`, `sortedcontainers` — into every consumer, including
17
- production images. Affected 0.2.0 and 0.3.0; nothing in the library imported
18
- it, so upgrading only removes packages.
19
-
20
- ## [0.3.0] - 2026-09-03
21
-
22
- ### Added
23
-
24
- - `User.user_id`, carrying the internal identifier Keyrunes puts in the JWT
25
- `sub` claim. It is a different value from `User.id` (the external UUID), and
26
- a consumer that keys its own records off `sub` needs it; before this release
27
- it was discarded during normalization and could only be recovered by
28
- re-parsing the token.
29
- - `User.namespace`, `User.organization_id` and `User.first_login`, all of which
30
- the server returns and normalization used to drop.
31
- - `Token.requires_password_change`, so a login that must be followed by a
32
- password change can be detected without reading the raw response.
33
- - `KeyrunesClient.refresh_token()`, wrapping `POST /api/refresh-token`. It
34
- accepts an explicit token or falls back to the client's current one, raises
35
- `InvalidTokenError` when neither is available, and adopts the refreshed
36
- token the way `login()` does.
37
- - `get_current_user(force_refresh=True)`, which always asks the server. The
38
- claims shortcut reads a token the SDK never verified, so it cannot answer
39
- "is this token still accepted"; a caller validating a token needs the round
40
- trip.
41
- - `KeyrunesError.status_code`, carrying the HTTP status when the error came
42
- from a response rather than from the transport. Without it a caller cannot
43
- tell "the server refused this request" (4xx) from "the server or the network
44
- failed" (5xx, or no response at all), and both are raised as `NetworkError`.
45
- - `register_user(group=...)`, sent as a top-level field.
46
- - A `py.typed` marker (PEP 561). The package was already fully typed, but
47
- without the marker a consumer running mypy saw every SDK import as
48
- untyped.
49
-
50
- ### Fixed
51
-
52
- - `get_current_user()` asked for `/api/users/me`, which the Keyrunes router
53
- does not expose — the real route is `/api/me`, so the call answered 404
54
- against a live server whenever the claims shortcut did not cover it. The
55
- endpoint is now a named constant, `ENDPOINT_ME`.
56
- - `register_user()` and `register_admin()` required the response to be
57
- `{"user": {...}}`, but `POST /api/register` answers with the bare user
58
- object. Every registration against a real server failed with "Unexpected
59
- response format". Both shapes are now accepted.
60
- - Extra keyword arguments to `register_user()` were nested under `attributes`,
61
- so a `group` never reached the server, which reads it as a top-level field.
62
- `group` is now an explicit parameter placed at the top level; other keyword
63
- attributes keep nesting under `attributes`.
64
-
65
- ### Testing
66
-
67
- - Test count raised from 159 to 189.
68
-
69
- ### Notes
70
-
71
- - Every added field is optional with a backwards-compatible default, and
72
- `User.id` keeps its existing precedence (`id` → `user_id` → `external_id`).
73
- Code written against 0.2.0 keeps working unchanged.
74
-
75
- ## [0.2.0] - 2026-09-03
76
-
77
- ### Added
78
-
79
- - Property-based test suite (`tests/test_property_based.py`, 24 tests) built on
80
- Hypothesis, covering base URL normalization, `_normalize_user` id precedence
81
- and `is_admin` derivation, JWT claim parsing, request URL construction, HTTP
82
- status to exception mapping, and model validation bounds.
83
- - Input fuzzing ("spider") suite (`tests/test_fuzz.py`, 19 tests) that crawls the
84
- public surface with hostile payloads and asserts that only `KeyrunesError` and
85
- pydantic `ValidationError` ever escape, and that nothing panics or leaks a raw
86
- `ValueError`/`TypeError`.
87
- - Request contract suite (`tests/test_request_contract.py`, 33 tests) that mocks
88
- only the httpx transport, so the method, URL, headers and JSON body actually
89
- put on the wire are asserted for every endpoint.
90
- - Hypothesis profiles in `tests/conftest.py` (`fast`, `dev`, `ci`) selected via
91
- the `HYPOTHESIS_PROFILE` environment variable. `fast` is derandomized and
92
- database-free so mutation runs judge every mutant against identical examples.
93
- - `[tool.mutmut]` configuration in `pyproject.toml` for mutation testing.
94
- - `hypothesis` added as a development dependency.
95
-
96
- ### Fixed
97
-
98
- - A 2xx response carrying a non-JSON body raised a raw `ValueError` out of
99
- `KeyrunesClient._make_request`. It is now wrapped in `NetworkError`, so the
100
- documented exception contract holds for malformed responses.
101
-
102
- ### Changed
103
-
104
- - Extracted the duplicated "build a `User` from JWT claims" block into
105
- `KeyrunesClient._user_from_token_claims()`.
106
-
107
- ### Removed
108
-
109
- - Unreachable `except UserNotFoundError` fallbacks in `get_user`,
110
- `get_current_user` and `has_group`. Each re-tested a condition that had
111
- already forced an early return, so a 404 from the server was being swallowed
112
- instead of propagated.
113
-
114
- ### Testing
115
-
116
- - Test count raised from 83 to 159.
117
- - Mutation score (mutmut) raised from 44% (294/669 mutants killed) to 72%
118
- (387/537). The remaining survivors are predominantly equivalent mutants that
119
- only alter error message prose.
120
-
121
- ## [0.1.0] - 2025-12-03
122
-
123
- ### Adicionado
124
-
125
- #### Funcionalidades Core
126
- - Cliente `KeyrunesClient` completo para interação com Keyrunes API
127
- - Autenticação com login de usuário e admin
128
- - Registro de usuário e admin com validação
129
- - Verificação de pertencimento a grupos
130
- - Obtenção de informações de usuários
131
-
132
- #### Decorators
133
- - `@require_group()` - Decorator para verificar grupos de usuários
134
- - `@require_admin()` - Decorator para verificar privilégios de admin
135
- - Suporte para múltiplos grupos (ANY ou ALL)
136
- - Sistema de client global para uso sem passar client explicitamente
137
-
138
- #### Modelos Pydantic
139
- - `User` - Modelo de usuário com validação
140
- - `Token` - Modelo de token JWT
141
- - `Group` - Modelo de grupo
142
- - `UserRegistration` - Dados de registro de usuário
143
- - `AdminRegistration` - Dados de registro de admin
144
- - `LoginCredentials` - Credenciais de login
145
- - `GroupCheck` - Resultado de verificação de grupo
146
-
147
- #### Exceções Customizadas
148
- - `KeyrunesError` - Exceção base
149
- - `AuthenticationError` - Erro de autenticação
150
- - `AuthorizationError` - Erro de autorização
151
- - `GroupNotFoundError` - Grupo não encontrado
152
- - `UserNotFoundError` - Usuário não encontrado
153
- - `InvalidTokenError` - Token inválido
154
- - `NetworkError` - Erro de rede
155
-
156
- #### Sistema de Configuração Global
157
- - `configure()` - Configura client global
158
- - `get_global_client()` - Obtém client global
159
- - `clear_global_client()` - Limpa client global
160
- - Thread-safe com Lock
161
-
162
- #### Desenvolvimento e Testes
163
- - Docker Compose completo com Keyrunes, PostgreSQL e Redis
164
- - 78 testes com 99% de cobertura
165
- - Testes usando pytest, factory-boy e faker
166
- - Exemplos práticos de uso
167
- - Makefile com comandos úteis
168
- - Configuração completa de CI/CD
169
-
170
- #### Documentação
171
- - README.md completo com exemplos
172
- - TESTING.md com guia de testes
173
- - Docstrings em todas as funções
174
- - Type hints completos
175
- - Exemplos práticos em `examples/`
176
-
177
- ### Detalhes Técnicos
178
-
179
- - Python 3.8.1+ compatível
180
- - Gerenciamento com Poetry
181
- - Validação com Pydantic 2.0
182
- - Type hints completos
183
- - Thread-safe
184
- - Context manager support
185
-
186
- ### Testes
187
-
188
- - 78 testes implementados
189
- - 99% de cobertura de código
190
- - Testes unitários e de integração
191
- - Factories com factory-boy
192
- - Dados fake com Faker
193
-
194
- ### Ferramentas de Desenvolvimento
195
-
196
- - Black para formatação
197
- - isort para organização de imports
198
- - flake8 para linting
199
- - mypy para type checking
200
- - pytest para testes
201
-
202
- ## [Unreleased]
203
-
204
- ### Planejado
205
-
206
- - Suporte para refresh token automático
207
- - Cache de verificações de grupo
208
- - Suporte para OIDC
209
- - Integração com FastAPI
210
- - Integração com Flask
211
- - Integração com Django
212
- - Mais exemplos práticos
213
- - Documentação com Sphinx
214
- - Publicação no PyPI
215
-
216
- ---
217
-
218
- Para mais detalhes sobre cada versão, veja os [releases no GitHub](https://github.com/jonatasoli/keyurnes-sdk-python-dark/releases).