pjdev-gitlab 5.2.0__tar.gz → 5.4.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.
Files changed (44) hide show
  1. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/PKG-INFO +65 -3
  2. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/README.md +64 -2
  3. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/__about__.py +1 -1
  4. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/__init__.py +1 -0
  5. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/models.py +27 -0
  6. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/oauth_cli.py +23 -0
  7. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/oauth_service.py +86 -6
  8. pjdev_gitlab-5.4.0/src/pjdev_gitlab/users_service.py +247 -0
  9. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/test_report_pjdev-gitlab.txt +131 -107
  10. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_oauth_service.py +113 -0
  11. pjdev_gitlab-5.4.0/tests/tests_for_users_service.py +190 -0
  12. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/.gitignore +0 -0
  13. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/LICENSE.txt +0 -0
  14. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/pyproject.toml +0 -0
  15. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
  16. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
  17. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
  18. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
  19. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +0 -0
  20. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/api_utilities.py +0 -0
  21. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/config_service.py +0 -0
  22. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/git_sync_service.py +0 -0
  23. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/issues_service.py +0 -0
  24. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/merge_requests_service.py +0 -0
  25. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/notes_service.py +0 -0
  26. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/packages_service.py +0 -0
  27. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/py.typed +0 -0
  28. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/repo_files_service.py +0 -0
  29. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/sync_cli.py +0 -0
  30. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/work_items_service.py +0 -0
  31. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/test.sh +0 -0
  32. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/__init__.py +0 -0
  33. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/conftest.py +0 -0
  34. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_api_utilities.py +0 -0
  35. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_git_sync_service.py +0 -0
  36. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_issues_service.py +0 -0
  37. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_merge_requests_service.py +0 -0
  38. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_models.py +0 -0
  39. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_notes_service.py +0 -0
  40. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_oauth_cli.py +0 -0
  41. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_packages_service.py +0 -0
  42. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_repo_files_service.py +0 -0
  43. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_sync_cli.py +0 -0
  44. {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_work_items_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pjdev-gitlab
3
- Version: 5.2.0
3
+ Version: 5.4.0
4
4
  Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-gitlab/README.md
5
5
  Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
6
6
  Project-URL: Source, https://gitlab.purplejay.io/keystone/python
@@ -94,8 +94,70 @@ TOKEN="$(uvx --from 'pjdev-gitlab' pjdev-gitlab-auth \
94
94
  The library is instance-agnostic: `--client-id` (or `GL_OAUTH_CLIENT_ID`) is
95
95
  required and the host defaults to `https://gitlab.com` (override with
96
96
  `--gitlab-url` / `GL_GITLAB_URL`). Other flags: `--client-secret`,
97
- `--redirect-port`, `--scopes`, `--force` (each with a `GL_OAUTH_*` env
98
- equivalent).
97
+ `--redirect-port`, `--scopes`, `--admin-mode`, `--force` (each with a
98
+ `GL_OAUTH_*` env equivalent).
99
+
100
+ ### Admin mode (instance admin API)
101
+
102
+ GitLab gates its instance admin API behind **admin mode**: `/license`, the admin
103
+ projection of `/users` (the one carrying `email` and `external`),
104
+ `/users/:id/memberships` and friends answer `403` — or, worse, answer `200` with
105
+ a *narrower* record — unless the credential itself was granted the `admin_mode`
106
+ scope. Being an administrator is not enough; the token has to carry it.
107
+
108
+ ```python
109
+ token = oauth_service.get_access_token(
110
+ gitlab_url="https://gitlab.example.com",
111
+ client_id="<client id>",
112
+ admin_mode=True, # -> requests "api admin_mode"
113
+ )
114
+ ```
115
+
116
+ ```bash
117
+ pjdev-gitlab-auth --gitlab-url https://gitlab.example.com \
118
+ --client-id <id> --admin-mode # or GL_OAUTH_ADMIN_MODE=1
119
+ ```
120
+
121
+ Two things this handles that are easy to get wrong:
122
+
123
+ - **The cache is scope-aware.** Tokens are cached per host, so a previously
124
+ minted `api` token would otherwise be handed straight back to an
125
+ `admin_mode` request and then 403 on every admin call. A cached token whose
126
+ recorded grant does not cover what is being asked for is rejected and the
127
+ browser flow re-runs. (Refreshing cannot widen a grant either, so that path is
128
+ skipped too.)
129
+ - **A silently narrowed grant is an error.** GitLab issues whatever the
130
+ *application registration* allows, so an app registered for `api` alone
131
+ answers an `api admin_mode` request with an `api` token and no complaint.
132
+ `get_access_token` compares the granted scopes against the requested ones and
133
+ raises `OAuthError` naming the missing scope, instead of letting you discover
134
+ it as a 403 later.
135
+
136
+ The OAuth application must therefore be registered with **both** `api` and
137
+ `admin_mode`. A personal access token works too — select both scopes when
138
+ creating it, and initialize with `auth_scheme="private-token"`.
139
+
140
+ #### `users_service` — the admin user API
141
+
142
+ ```python
143
+ from pjdev_gitlab import config_service, users_service
144
+
145
+ config_service.init(token=token, gitlab_url=..., auth_scheme="bearer")
146
+
147
+ external = await users_service.list_external_users()
148
+ for user in external:
149
+ print(user.username, user.email)
150
+ ```
151
+
152
+ `list_users`, `get_user` and `list_external_users` all verify that GitLab
153
+ actually returned the admin projection and raise
154
+ `users_service.AdminModeRequired` when it did not. That check exists because the
155
+ public projection *omits* `email` and `external` rather than erroring, so
156
+ `row.get("external")` reads as `None` and a naive caller concludes "no external
157
+ users" — a confident wrong answer. `list_external_users` also filters on the
158
+ `external` field of each returned record rather than trusting the server-side
159
+ `external=true` parameter, so a silently ignored filter cannot shorten the list.
160
+ `list_memberships` wraps the admin-only `/users/:id/memberships`.
99
161
 
100
162
  #### `pjdev-gitlab-sync` console script
101
163
 
@@ -66,8 +66,70 @@ TOKEN="$(uvx --from 'pjdev-gitlab' pjdev-gitlab-auth \
66
66
  The library is instance-agnostic: `--client-id` (or `GL_OAUTH_CLIENT_ID`) is
67
67
  required and the host defaults to `https://gitlab.com` (override with
68
68
  `--gitlab-url` / `GL_GITLAB_URL`). Other flags: `--client-secret`,
69
- `--redirect-port`, `--scopes`, `--force` (each with a `GL_OAUTH_*` env
70
- equivalent).
69
+ `--redirect-port`, `--scopes`, `--admin-mode`, `--force` (each with a
70
+ `GL_OAUTH_*` env equivalent).
71
+
72
+ ### Admin mode (instance admin API)
73
+
74
+ GitLab gates its instance admin API behind **admin mode**: `/license`, the admin
75
+ projection of `/users` (the one carrying `email` and `external`),
76
+ `/users/:id/memberships` and friends answer `403` — or, worse, answer `200` with
77
+ a *narrower* record — unless the credential itself was granted the `admin_mode`
78
+ scope. Being an administrator is not enough; the token has to carry it.
79
+
80
+ ```python
81
+ token = oauth_service.get_access_token(
82
+ gitlab_url="https://gitlab.example.com",
83
+ client_id="<client id>",
84
+ admin_mode=True, # -> requests "api admin_mode"
85
+ )
86
+ ```
87
+
88
+ ```bash
89
+ pjdev-gitlab-auth --gitlab-url https://gitlab.example.com \
90
+ --client-id <id> --admin-mode # or GL_OAUTH_ADMIN_MODE=1
91
+ ```
92
+
93
+ Two things this handles that are easy to get wrong:
94
+
95
+ - **The cache is scope-aware.** Tokens are cached per host, so a previously
96
+ minted `api` token would otherwise be handed straight back to an
97
+ `admin_mode` request and then 403 on every admin call. A cached token whose
98
+ recorded grant does not cover what is being asked for is rejected and the
99
+ browser flow re-runs. (Refreshing cannot widen a grant either, so that path is
100
+ skipped too.)
101
+ - **A silently narrowed grant is an error.** GitLab issues whatever the
102
+ *application registration* allows, so an app registered for `api` alone
103
+ answers an `api admin_mode` request with an `api` token and no complaint.
104
+ `get_access_token` compares the granted scopes against the requested ones and
105
+ raises `OAuthError` naming the missing scope, instead of letting you discover
106
+ it as a 403 later.
107
+
108
+ The OAuth application must therefore be registered with **both** `api` and
109
+ `admin_mode`. A personal access token works too — select both scopes when
110
+ creating it, and initialize with `auth_scheme="private-token"`.
111
+
112
+ #### `users_service` — the admin user API
113
+
114
+ ```python
115
+ from pjdev_gitlab import config_service, users_service
116
+
117
+ config_service.init(token=token, gitlab_url=..., auth_scheme="bearer")
118
+
119
+ external = await users_service.list_external_users()
120
+ for user in external:
121
+ print(user.username, user.email)
122
+ ```
123
+
124
+ `list_users`, `get_user` and `list_external_users` all verify that GitLab
125
+ actually returned the admin projection and raise
126
+ `users_service.AdminModeRequired` when it did not. That check exists because the
127
+ public projection *omits* `email` and `external` rather than erroring, so
128
+ `row.get("external")` reads as `None` and a naive caller concludes "no external
129
+ users" — a confident wrong answer. `list_external_users` also filters on the
130
+ `external` field of each returned record rather than trusting the server-side
131
+ `external=true` parameter, so a silently ignored filter cannot shorten the list.
132
+ `list_memberships` wraps the admin-only `/users/:id/memberships`.
71
133
 
72
134
  #### `pjdev-gitlab-sync` console script
73
135
 
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "5.2.0"
4
+ __version__ = "5.4.0"
@@ -17,5 +17,6 @@ __all__ = [
17
17
  "oauth_service",
18
18
  "packages_service",
19
19
  "repo_files_service",
20
+ "users_service",
20
21
  "work_items_service",
21
22
  ]
@@ -90,6 +90,33 @@ class Author(GitlabBase):
90
90
  email: Optional[str] = None
91
91
 
92
92
 
93
+ class User(GitlabBase):
94
+ """An instance user as returned by ``GET /users``.
95
+
96
+ The admin-only fields (``email``, ``external``, ``is_admin``, ...) are
97
+ optional because GitLab omits them from the public projection; see
98
+ :mod:`pjdev_gitlab.users_service`, which refuses to hand back records that
99
+ are missing them rather than letting ``external=None`` read as "not
100
+ external".
101
+ """
102
+
103
+ id: int
104
+ username: str
105
+ name: Optional[str] = None
106
+ state: Optional[str] = None
107
+ web_url: Optional[str] = None
108
+ public_email: Optional[str] = None
109
+ created_at: Optional[datetime] = None
110
+ bot: bool = False
111
+ # Admin projection only.
112
+ email: Optional[str] = None
113
+ external: Optional[bool] = None
114
+ is_admin: Optional[bool] = None
115
+ last_sign_in_at: Optional[datetime] = None
116
+ last_activity_on: Optional[str] = None
117
+ using_license_seat: Optional[bool] = None
118
+
119
+
93
120
  class Label(GitlabBase):
94
121
  id: Optional[int] = None
95
122
  name: str
@@ -22,6 +22,11 @@ Usage::
22
22
  The registered OAuth application must be a non-confidential (PKCE) app with the
23
23
  requested scope (default ``api``) and Redirect URI exactly
24
24
  ``http://localhost:<redirect-port>/callback``.
25
+
26
+ Pass ``--admin-mode`` (or ``GL_OAUTH_ADMIN_MODE=1``) to also request the
27
+ ``admin_mode`` scope that GitLab requires for the instance admin API -- the
28
+ application registration must include it, or the flow fails with a clear error
29
+ rather than handing back a token that 403s on every admin endpoint.
25
30
  """
26
31
 
27
32
  from __future__ import annotations
@@ -31,6 +36,7 @@ import os
31
36
  import sys
32
37
 
33
38
  from pjdev_gitlab.oauth_service import (
39
+ ADMIN_MODE_SCOPE,
34
40
  DEFAULT_REDIRECT_PORT,
35
41
  DEFAULT_SCOPES,
36
42
  OAuthError,
@@ -71,6 +77,16 @@ def main() -> int:
71
77
  default=None,
72
78
  help=f'space-separated scopes (or GL_OAUTH_SCOPES; default "{DEFAULT_SCOPES}")',
73
79
  )
80
+ parser.add_argument(
81
+ "--admin-mode",
82
+ action="store_true",
83
+ default=None,
84
+ help=(
85
+ f"also request the {ADMIN_MODE_SCOPE!r} scope, required by the instance "
86
+ "admin API (or GL_OAUTH_ADMIN_MODE=1). The OAuth application must be "
87
+ "registered with that scope."
88
+ ),
89
+ )
74
90
  parser.add_argument(
75
91
  "--force",
76
92
  action="store_true",
@@ -89,6 +105,12 @@ def main() -> int:
89
105
  return 2
90
106
  client_secret = args.client_secret or os.environ.get("GL_OAUTH_CLIENT_SECRET") or None
91
107
  scopes = args.scopes or os.environ.get("GL_OAUTH_SCOPES") or DEFAULT_SCOPES
108
+ admin_mode = (
109
+ args.admin_mode
110
+ if args.admin_mode is not None
111
+ else os.environ.get("GL_OAUTH_ADMIN_MODE", "").strip().lower()
112
+ in ("1", "true", "yes", "on")
113
+ )
92
114
 
93
115
  port_raw = (
94
116
  args.redirect_port
@@ -108,6 +130,7 @@ def main() -> int:
108
130
  client_secret=client_secret,
109
131
  redirect_port=redirect_port,
110
132
  scopes=scopes,
133
+ admin_mode=admin_mode,
111
134
  force=args.force,
112
135
  )
113
136
  except OAuthError as exc:
@@ -48,6 +48,12 @@ import httpx
48
48
 
49
49
  DEFAULT_REDIRECT_PORT = 7331
50
50
  DEFAULT_SCOPES = "api"
51
+ # GitLab gates the instance admin API (/license, /users admin view,
52
+ # /users/:id/memberships, ...) behind "admin mode". A token only carries it when
53
+ # the credential itself was granted this scope, and the OAuth application must
54
+ # be registered with it -- an app registered for "api" alone will hand back an
55
+ # api-only token no matter what the authorize request asked for.
56
+ ADMIN_MODE_SCOPE = "admin_mode"
51
57
  # Treat a token as expired this many seconds early to avoid racing the clock.
52
58
  EXPIRY_SKEW_SECONDS = 60
53
59
  # How long to wait for the browser redirect before giving up.
@@ -96,7 +102,36 @@ def save_cache(path: Path, data: dict[str, Any]) -> None:
96
102
  tmp.replace(path)
97
103
 
98
104
 
99
- def token_if_valid(cache: Optional[dict[str, Any]]) -> Optional[str]:
105
+ def parse_scopes(value: Optional[str]) -> set[str]:
106
+ """Split a scope string ("api admin_mode" or "api,admin_mode") into a set."""
107
+ if not value:
108
+ return set()
109
+ return {scope for scope in value.replace(",", " ").split() if scope}
110
+
111
+
112
+ def scopes_satisfy(granted: Optional[str], required: Optional[str]) -> bool:
113
+ """True when every required scope appears in ``granted``."""
114
+ return parse_scopes(required) <= parse_scopes(granted)
115
+
116
+
117
+ def with_admin_mode(scopes: str = DEFAULT_SCOPES) -> str:
118
+ """Return ``scopes`` with :data:`ADMIN_MODE_SCOPE` added. Idempotent."""
119
+ requested = parse_scopes(scopes) | {ADMIN_MODE_SCOPE}
120
+ # "api" first, then the rest alphabetically, so the string is stable.
121
+ return " ".join(sorted(requested, key=lambda scope: (scope != "api", scope)))
122
+
123
+
124
+ def token_if_valid(
125
+ cache: Optional[dict[str, Any]], required_scopes: Optional[str] = None
126
+ ) -> Optional[str]:
127
+ """Return the cached access token when it is still usable.
128
+
129
+ The cache is keyed by host only, so a token minted earlier for a *narrower*
130
+ scope set would otherwise be handed back to a caller that has since asked
131
+ for more -- e.g. reusing an ``api`` token for an ``api admin_mode`` request,
132
+ which then 403s on every admin endpoint. Passing ``required_scopes`` rejects
133
+ that token so the caller re-authorizes instead.
134
+ """
100
135
  if not cache:
101
136
  return None
102
137
  token = cache.get("access_token")
@@ -105,6 +140,14 @@ def token_if_valid(cache: Optional[dict[str, Any]]) -> Optional[str]:
105
140
  return None
106
141
  if time.time() >= expires_at - EXPIRY_SKEW_SECONDS:
107
142
  return None
143
+ if required_scopes:
144
+ # A cache entry written before scopes were recorded has an *unknown*
145
+ # grant, not an empty one. Every token this library has ever minted
146
+ # asked for DEFAULT_SCOPES, so assume that much -- enough to keep
147
+ # existing caches working, never enough to satisfy a wider request.
148
+ granted = cache["scope"] if "scope" in cache else DEFAULT_SCOPES
149
+ if not scopes_satisfy(granted, required_scopes):
150
+ return None
108
151
  return token
109
152
 
110
153
 
@@ -292,6 +335,7 @@ def get_access_token(
292
335
  client_secret: Optional[str] = None,
293
336
  redirect_port: int = DEFAULT_REDIRECT_PORT,
294
337
  scopes: str = DEFAULT_SCOPES,
338
+ admin_mode: bool = False,
295
339
  force: bool = False,
296
340
  cache_dir: Optional[Path] = None,
297
341
  on_status: Optional[StatusFn] = None,
@@ -306,6 +350,11 @@ def get_access_token(
306
350
  reported via ``on_status`` (defaults to printing to stderr). Raises
307
351
  :class:`OAuthError` on any unrecoverable failure.
308
352
 
353
+ ``admin_mode=True`` adds the ``admin_mode`` scope, which GitLab requires for
354
+ the instance admin API. A cached token granted only the narrower scopes is
355
+ *not* reused for such a request -- it would 403 on every admin endpoint --
356
+ so the browser flow runs again to re-consent.
357
+
309
358
  The returned token is an OAuth access token: send it as a Bearer credential,
310
359
  e.g. ``config_service.init(token=<token>, auth_scheme="bearer")``.
311
360
  """
@@ -314,18 +363,33 @@ def get_access_token(
314
363
  status = on_status or _default_status
315
364
  cache_dir = cache_dir or default_cache_dir()
316
365
  redirect_uri = f"http://localhost:{redirect_port}/callback"
366
+ if admin_mode:
367
+ scopes = with_admin_mode(scopes)
317
368
 
318
369
  path = cache_path(cache_dir, gitlab_url)
319
370
  cache = None if force else load_cache(path)
320
371
 
321
- # 1. Reuse a still-valid cached access token.
322
- token = token_if_valid(cache)
372
+ # 1. Reuse a still-valid cached access token, but only if it was granted at
373
+ # least the scopes being asked for now.
374
+ token = token_if_valid(cache, scopes)
323
375
  if token:
324
376
  status("Reusing cached access token.")
325
377
  return token
326
378
 
327
- # 2. Silently refresh an expired token if we have a refresh token.
328
- if cache and cache.get("refresh_token"):
379
+ # 2. Silently refresh an expired token if we have a refresh token. A refresh
380
+ # preserves the original grant's scopes, so it cannot widen them -- when
381
+ # the caller now wants more, skip straight to re-consent.
382
+ cached_scope = cache.get("scope", DEFAULT_SCOPES) if cache else None
383
+ if (
384
+ cache
385
+ and cache.get("refresh_token")
386
+ and not scopes_satisfy(cached_scope, scopes)
387
+ ):
388
+ status(
389
+ f"Cached token was granted {cached_scope or 'no'} scope(s) but "
390
+ f"{scopes!r} is required; re-authorizing."
391
+ )
392
+ elif cache and cache.get("refresh_token"):
329
393
  status("Access token expired; refreshing silently ...")
330
394
  payload = refresh_access_token(
331
395
  gitlab_url=gitlab_url,
@@ -334,7 +398,8 @@ def get_access_token(
334
398
  refresh_value=cache["refresh_token"],
335
399
  )
336
400
  if payload:
337
- return store_token_response(path, payload, scopes)
401
+ # A refresh keeps the original grant; record that, not the request.
402
+ return store_token_response(path, payload, cached_scope or scopes)
338
403
  status("Refresh failed; falling back to browser sign-in.")
339
404
 
340
405
  # 3. Full browser flow.
@@ -350,5 +415,20 @@ def get_access_token(
350
415
  timeout_seconds=timeout_seconds,
351
416
  )
352
417
  token = store_token_response(path, payload, scopes)
418
+
419
+ # GitLab silently narrows the grant to whatever the application is
420
+ # registered for, so an app registered for "api" alone answers an
421
+ # "api admin_mode" request with an api-only token. Fail loudly here rather
422
+ # than let the caller discover it as a 403 on the first admin endpoint.
423
+ granted = payload.get("scope", scopes)
424
+ if not scopes_satisfy(granted, scopes):
425
+ missing = " ".join(sorted(parse_scopes(scopes) - parse_scopes(granted)))
426
+ raise OAuthError(
427
+ f"the OAuth application granted scope(s) {granted!r} but {scopes!r} "
428
+ f"was requested (missing: {missing}). Add the missing scope(s) to the "
429
+ f"application registration at {gitlab_url.rstrip('/')}/-/user_settings/applications "
430
+ "(or Admin area -> Applications for an instance-wide app), then retry."
431
+ )
432
+
353
433
  status("Authenticated. Short-lived token obtained.")
354
434
  return token
@@ -0,0 +1,247 @@
1
+ """Instance user administration: the admin projection of ``GET /users``.
2
+
3
+ GitLab answers ``GET /users`` for any authenticated caller, but the *shape* of
4
+ the response depends on the credential. A plain ``api`` token gets the public
5
+ projection -- username, name, avatar, web_url. Only a credential carrying the
6
+ ``admin_mode`` scope (see :mod:`pjdev_gitlab.oauth_service`) gets the admin
7
+ projection, which adds ``email``, ``external``, ``is_admin``, ``last_sign_in_at``
8
+ and the rest.
9
+
10
+ The failure mode this module exists to prevent: the public projection simply
11
+ *omits* those keys rather than erroring, so naive code reads
12
+ ``row.get("external")`` as ``None``, concludes "no external users", and reports
13
+ a confident wrong answer. Every entry point here checks for the admin-only keys
14
+ first and raises :class:`AdminModeRequired` instead.
15
+ """
16
+
17
+ from typing import Any, Optional
18
+
19
+ from httpx import AsyncClient
20
+
21
+ from pjdev_gitlab.api_utilities import (
22
+ async_retry_http,
23
+ encode_path_segment,
24
+ http_client,
25
+ paginate,
26
+ )
27
+ from pjdev_gitlab.models import User
28
+
29
+ _IGNORE_4XX = [400, 401, 403, 404]
30
+
31
+ # Keys present only in the admin projection of a user record. Their absence is
32
+ # how we detect a credential that never entered admin mode.
33
+ ADMIN_ONLY_USER_FIELDS = ("email", "external", "is_admin")
34
+
35
+
36
+ class AdminModeRequired(RuntimeError):
37
+ """The credential lacks admin mode, so admin-only user fields are invisible.
38
+
39
+ Mint a token with the ``admin_mode`` scope -- ``oauth_service`` accepts
40
+ ``admin_mode=True`` (``pjdev-gitlab-auth --admin-mode``), and a personal
41
+ access token needs ``api`` plus ``admin_mode`` selected at creation.
42
+ """
43
+
44
+
45
+ def assert_admin_projection(row: dict[str, Any]) -> None:
46
+ """Raise :class:`AdminModeRequired` if ``row`` is the public projection."""
47
+ missing = [field for field in ADMIN_ONLY_USER_FIELDS if field not in row]
48
+ if missing:
49
+ raise AdminModeRequired(
50
+ "GitLab returned the public user projection, without "
51
+ f"{', '.join(missing)}. The token is not in admin mode: mint one "
52
+ "with the 'admin_mode' scope (pjdev-gitlab-auth --admin-mode, or a "
53
+ "PAT created with both 'api' and 'admin_mode')."
54
+ )
55
+
56
+
57
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
58
+ async def _fetch_user_rows(
59
+ params: dict[str, Any], client: Optional[AsyncClient]
60
+ ) -> list[dict[str, Any]]:
61
+ async def _exec(_client: AsyncClient) -> list[dict[str, Any]]:
62
+ return await paginate(_client, "/users", params=params)
63
+
64
+ if client is None:
65
+ async with http_client() as _client:
66
+ return await _exec(_client)
67
+ return await _exec(client)
68
+
69
+
70
+ async def list_users(
71
+ *,
72
+ active: Optional[bool] = None,
73
+ blocked: Optional[bool] = None,
74
+ external: Optional[bool] = None,
75
+ exclude_internal: Optional[bool] = None,
76
+ without_project_bots: Optional[bool] = None,
77
+ search: Optional[str] = None,
78
+ extra_params: Optional[dict[str, Any]] = None,
79
+ client: Optional[AsyncClient] = None,
80
+ ) -> list[User]:
81
+ """List instance users via the admin projection of ``GET /users``.
82
+
83
+ Requires admin mode; raises :class:`AdminModeRequired` otherwise. The
84
+ boolean filters map to GitLab's query parameters, which it accepts only as
85
+ the string ``"true"`` -- passing ``False`` omits the parameter rather than
86
+ sending ``false``, matching GitLab's own semantics.
87
+ """
88
+ params: dict[str, Any] = dict(extra_params or {})
89
+ for name, value in (
90
+ ("active", active),
91
+ ("blocked", blocked),
92
+ ("external", external),
93
+ ("exclude_internal", exclude_internal),
94
+ ("without_project_bots", without_project_bots),
95
+ ):
96
+ if value:
97
+ params[name] = "true"
98
+ if search:
99
+ params["search"] = search
100
+
101
+ # The projection check runs outside the retry decorator on purpose: that
102
+ # decorator funnels every exception into an ExceptionGroup, which would bury
103
+ # the one message the caller actually needs to read.
104
+ rows = await _fetch_user_rows(params, client)
105
+ if rows:
106
+ assert_admin_projection(rows[0])
107
+ return [User.model_validate(row) for row in rows]
108
+
109
+
110
+ async def list_external_users(
111
+ *,
112
+ active_only: bool = True,
113
+ include_bots: bool = False,
114
+ client: Optional[AsyncClient] = None,
115
+ ) -> list[User]:
116
+ """List users flagged ``external`` on the instance.
117
+
118
+ Filtering happens client-side on the ``external`` field rather than through
119
+ GitLab's ``external=true`` parameter, so a silently ignored or renamed
120
+ parameter cannot turn into a falsely short list -- the flag on each returned
121
+ record is the authority.
122
+ """
123
+ users = await list_users(active=active_only or None, client=client)
124
+ return [user for user in users if user.external and (include_bots or not user.bot)]
125
+
126
+
127
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
128
+ async def _fetch_user_row(
129
+ user_id: Any, client: Optional[AsyncClient]
130
+ ) -> dict[str, Any]:
131
+ async def _exec(_client: AsyncClient) -> dict[str, Any]:
132
+ r = await _client.get(f"/users/{encode_path_segment(user_id)}")
133
+ r.raise_for_status()
134
+ return r.json()
135
+
136
+ if client is None:
137
+ async with http_client() as _client:
138
+ return await _exec(_client)
139
+ return await _exec(client)
140
+
141
+
142
+ async def get_user(user_id: Any, *, client: Optional[AsyncClient] = None) -> User:
143
+ """Fetch one user via ``GET /users/:id``, requiring the admin projection."""
144
+ row = await _fetch_user_row(user_id, client)
145
+ assert_admin_projection(row)
146
+ return User.model_validate(row)
147
+
148
+
149
+ class UserUpdateNotApplied(RuntimeError):
150
+ """GitLab answered 200 but returned a record that does not reflect the change.
151
+
152
+ ``PUT /users/:id`` drops attributes it will not apply instead of rejecting
153
+ the request -- an administrator cannot be flagged ``external``, for one.
154
+ Without this check a caller marking seats external reads the 200 as done
155
+ while the seat stays billable, which is the same class of confident-wrong
156
+ answer the projection check above exists to prevent.
157
+ """
158
+
159
+
160
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
161
+ async def _put_user_row(
162
+ user_id: Any, payload: dict[str, Any], client: Optional[AsyncClient]
163
+ ) -> dict[str, Any]:
164
+ async def _exec(_client: AsyncClient) -> dict[str, Any]:
165
+ r = await _client.put(f"/users/{encode_path_segment(user_id)}", json=payload)
166
+ r.raise_for_status()
167
+ return r.json()
168
+
169
+ if client is None:
170
+ async with http_client() as _client:
171
+ return await _exec(_client)
172
+ return await _exec(client)
173
+
174
+
175
+ async def update_user(
176
+ user_id: Any,
177
+ *,
178
+ external: Optional[bool] = None,
179
+ extra_fields: Optional[dict[str, Any]] = None,
180
+ client: Optional[AsyncClient] = None,
181
+ ) -> User:
182
+ """Update a user via ``PUT /users/:id`` and return the updated record.
183
+
184
+ Admin-only, and the response is the admin projection, so a credential
185
+ without admin mode raises :class:`AdminModeRequired` (or is refused
186
+ outright with a 403). ``external`` is sent as a JSON boolean -- the string
187
+ ``"true"`` dance in :func:`list_users` applies to query parameters, not to
188
+ a request body -- and passing ``None`` leaves the flag alone rather than
189
+ clearing it.
190
+
191
+ ``extra_fields`` is merged into the payload for attributes without a named
192
+ parameter here. Only ``external`` is verified against the response:
193
+ GitLab defers some writes rather than applying them (an ``email`` change
194
+ waits on confirmation and echoes back the old address), so checking every
195
+ field would report those as failures.
196
+ """
197
+ payload: dict[str, Any] = dict(extra_fields or {})
198
+ if external is not None:
199
+ payload["external"] = external
200
+ if not payload:
201
+ raise ValueError("update_user needs at least one field to change")
202
+
203
+ row = await _put_user_row(user_id, payload, client)
204
+
205
+ # Both checks sit outside the retry decorator on purpose: it funnels every
206
+ # exception into an ExceptionGroup, which would bury the message.
207
+ assert_admin_projection(row)
208
+ if external is not None and row.get("external") != external:
209
+ raise UserUpdateNotApplied(
210
+ f"GitLab accepted PUT /users/{user_id} but returned "
211
+ f"external={row.get('external')!r} after a request for "
212
+ f"external={external!r}. GitLab refuses to flag an administrator "
213
+ "external, and drops the attribute silently when it does."
214
+ )
215
+ return User.model_validate(row)
216
+
217
+
218
+ async def set_user_external(
219
+ user_id: Any, external: bool = True, *, client: Optional[AsyncClient] = None
220
+ ) -> User:
221
+ """Flag a user ``external``, or clear the flag with ``external=False``.
222
+
223
+ External users do not consume a billable seat, so this is the write half of
224
+ :func:`list_external_users`.
225
+ """
226
+ return await update_user(user_id, external=external, client=client)
227
+
228
+
229
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
230
+ async def list_memberships(
231
+ user_id: Any, *, client: Optional[AsyncClient] = None
232
+ ) -> list[dict[str, Any]]:
233
+ """List a user's direct group and project memberships.
234
+
235
+ ``GET /users/:id/memberships`` is admin-only and 403s without admin mode, so
236
+ the error is surfaced by the request itself rather than a projection check.
237
+ """
238
+
239
+ async def _exec(_client: AsyncClient) -> list[dict[str, Any]]:
240
+ return await paginate(
241
+ _client, f"/users/{encode_path_segment(user_id)}/memberships"
242
+ )
243
+
244
+ if client is None:
245
+ async with http_client() as _client:
246
+ return await _exec(_client)
247
+ return await _exec(client)