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.
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/PKG-INFO +65 -3
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/README.md +64 -2
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/__about__.py +1 -1
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/__init__.py +1 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/models.py +27 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/oauth_cli.py +23 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/oauth_service.py +86 -6
- pjdev_gitlab-5.4.0/src/pjdev_gitlab/users_service.py +247 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/test_report_pjdev-gitlab.txt +131 -107
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_oauth_service.py +113 -0
- pjdev_gitlab-5.4.0/tests/tests_for_users_service.py +190 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/.gitignore +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/LICENSE.txt +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/pyproject.toml +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/api_utilities.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/config_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/git_sync_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/issues_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/merge_requests_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/notes_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/packages_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/py.typed +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/repo_files_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/sync_cli.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/src/pjdev_gitlab/work_items_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/test.sh +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/__init__.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/conftest.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_api_utilities.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_git_sync_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_issues_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_merge_requests_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_models.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_notes_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_oauth_cli.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_packages_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_repo_files_service.py +0 -0
- {pjdev_gitlab-5.2.0 → pjdev_gitlab-5.4.0}/tests/tests_for_sync_cli.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|