lars-kluijtmans-admin-sdk 0.0.3__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.
- lars_kluijtmans_admin_sdk-0.0.3/.flake8 +8 -0
- lars_kluijtmans_admin_sdk-0.0.3/.gitignore +7 -0
- lars_kluijtmans_admin_sdk-0.0.3/PKG-INFO +140 -0
- lars_kluijtmans_admin_sdk-0.0.3/README.md +121 -0
- lars_kluijtmans_admin_sdk-0.0.3/examples/byo_token_example.py +28 -0
- lars_kluijtmans_admin_sdk-0.0.3/examples/m2m_example.py +34 -0
- lars_kluijtmans_admin_sdk-0.0.3/pyproject.toml +24 -0
- lars_kluijtmans_admin_sdk-0.0.3/pytest.ini +4 -0
- lars_kluijtmans_admin_sdk-0.0.3/set_version.py +47 -0
- lars_kluijtmans_admin_sdk-0.0.3/src/auth_platform_admin/__init__.py +31 -0
- lars_kluijtmans_admin_sdk-0.0.3/src/auth_platform_admin/client.py +170 -0
- lars_kluijtmans_admin_sdk-0.0.3/src/auth_platform_admin/errors.py +78 -0
- lars_kluijtmans_admin_sdk-0.0.3/src/auth_platform_admin/models.py +140 -0
- lars_kluijtmans_admin_sdk-0.0.3/src/auth_platform_admin/resources.py +146 -0
- lars_kluijtmans_admin_sdk-0.0.3/tests/test_client.py +113 -0
- lars_kluijtmans_admin_sdk-0.0.3/tests/test_e2e.py +38 -0
- lars_kluijtmans_admin_sdk-0.0.3/tests/test_resources.py +68 -0
- lars_kluijtmans_admin_sdk-0.0.3/tests/test_users.py +132 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[flake8]
|
|
2
|
+
max-line-length = 120
|
|
3
|
+
# Match the rest of the repo: pyflakes (F*) on; ignore cosmetic pycodestyle checks an
|
|
4
|
+
# autoformatter would own (this project doesn't run one).
|
|
5
|
+
extend-ignore = E501,E203,W503,E302,E303,E305,W391,E127,E128,E131
|
|
6
|
+
exclude = .venv,__pycache__,.pytest_cache,.git,dist,build
|
|
7
|
+
per-file-ignores =
|
|
8
|
+
tests/*: E402, F811
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lars-kluijtmans-admin-sdk
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Python admin SDK for the Auth Platform Management API
|
|
5
|
+
Project-URL: Homepage, https://github.com/LarsKluijtmans/auth
|
|
6
|
+
Project-URL: Source, https://github.com/LarsKluijtmans/auth/tree/main/admin-sdk-python
|
|
7
|
+
Author: Auth Platform
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: admin,auth,oauth,rbac,sdk
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: build>=1; extra == 'dev'
|
|
14
|
+
Requires-Dist: flake8>=7; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
17
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# lars-kluijtmans-admin-sdk
|
|
21
|
+
|
|
22
|
+
A Python admin SDK for the **Auth Platform** Management API — modelled on the Firebase
|
|
23
|
+
Authentication Admin SDK. From a server-side program: read projects, clients and providers,
|
|
24
|
+
and read/update users (active state, custom claims, password-reset/verification emails).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install lars-kluijtmans-admin-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The import package stays `auth_platform_admin` (`from auth_platform_admin import AdminClient`).
|
|
31
|
+
Requires Python 3.10+.
|
|
32
|
+
|
|
33
|
+
## Authenticating
|
|
34
|
+
|
|
35
|
+
Two credential modes.
|
|
36
|
+
|
|
37
|
+
### M2M (recommended for servers)
|
|
38
|
+
|
|
39
|
+
A **service client** with the `client_credentials` grant. The SDK runs the grant against the
|
|
40
|
+
Login API, then caches and refreshes the token for you:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from auth_platform_admin import AdminClient
|
|
44
|
+
|
|
45
|
+
client = AdminClient.from_client_credentials(
|
|
46
|
+
login_api_url="https://login.example.com",
|
|
47
|
+
management_url="https://manage.example.com",
|
|
48
|
+
client_id="client_xxx",
|
|
49
|
+
client_secret="…",
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Bring-your-own token
|
|
54
|
+
|
|
55
|
+
Attach an admin access token you already hold (e.g. from a logged-in session):
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
client = AdminClient(management_url="https://manage.example.com", token=ACCESS_TOKEN)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
What a credential may do is governed by RBAC: a **user** token uses that user's role; a **service
|
|
62
|
+
client** uses the role assigned to it (see *Setting up a service client* below).
|
|
63
|
+
|
|
64
|
+
`AdminClient` is a context manager and closes its HTTP connection on exit:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
with AdminClient.from_client_credentials(...) as client:
|
|
68
|
+
...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Using it
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
# Projects
|
|
75
|
+
project = client.projects.get(project_id)
|
|
76
|
+
projects = client.projects.list(company_id)
|
|
77
|
+
|
|
78
|
+
# Clients & providers
|
|
79
|
+
clients = client.clients.list(project_id)
|
|
80
|
+
provider = client.providers.catalog() # platform catalog
|
|
81
|
+
enabled = client.providers.list(project_id) # per-project, with enable state
|
|
82
|
+
|
|
83
|
+
# Users — read
|
|
84
|
+
users = client.users.list(project_id, search="ann", status="active") # one page
|
|
85
|
+
for user in client.users.iter(project_id): # all pages
|
|
86
|
+
print(user.email, user.is_active)
|
|
87
|
+
user = client.users.get(project_id, user_id)
|
|
88
|
+
|
|
89
|
+
# Users — update (active state, claims, and profile fields)
|
|
90
|
+
client.users.set_active(project_id, user_id, False)
|
|
91
|
+
client.users.set_claims(project_id, user_id, {"tier": "gold"})
|
|
92
|
+
client.users.set_profile(project_id, user_id, {"display_name": "Ann", "language": "nl"})
|
|
93
|
+
# Firebase-style: apply whichever fields you pass (profile fields accept None to clear).
|
|
94
|
+
client.users.update(project_id, user_id, active=True, claims={"tier": "silver"},
|
|
95
|
+
display_name="Ann", language="nl", profile_picture="https://cdn/x.png")
|
|
96
|
+
|
|
97
|
+
# Users — remediation
|
|
98
|
+
client.users.send_password_reset(project_id, user_id)
|
|
99
|
+
client.users.resend_verification(project_id, user_id)
|
|
100
|
+
client.users.force_logout(project_id, user_id)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Every model exposes `.raw` (the full API payload) so new/unknown fields are always reachable.
|
|
104
|
+
|
|
105
|
+
> The admin can edit **display name, language, and profile picture** (`set_profile` / `update`),
|
|
106
|
+
> flip active state, set claims, and trigger the reset/verification emails. **Email and password
|
|
107
|
+
> changes remain self-service** on the Login API — they are not part of the admin surface.
|
|
108
|
+
|
|
109
|
+
## Setting up a service client
|
|
110
|
+
|
|
111
|
+
1. **Create a client** for the project (Management console → project → Clients, or the API) and
|
|
112
|
+
enable the **`client_credentials`** grant on it. Keep the `client_id` + secret.
|
|
113
|
+
2. **Assign it a role** — the permissions an m2m token from this client gets:
|
|
114
|
+
```http
|
|
115
|
+
PUT /api/v1/projects/{project_id}/clients/{client_id}/service-role
|
|
116
|
+
{ "role_id": "<a management role id>" }
|
|
117
|
+
```
|
|
118
|
+
(Needs `role:manage`.) A client with no service role can mint a token but is denied everything.
|
|
119
|
+
3. Use `AdminClient.from_client_credentials(...)` with that client's id + secret.
|
|
120
|
+
|
|
121
|
+
The service account is always scoped to its own company/project and is never a platform admin.
|
|
122
|
+
|
|
123
|
+
## Errors
|
|
124
|
+
|
|
125
|
+
Failures raise typed exceptions — `AuthError` (401), `Forbidden` (403), `NotFound` (404),
|
|
126
|
+
`Conflict` (409), `ValidationError` (422), `NetworkError` — all subclasses of `AdminError`
|
|
127
|
+
(carrying `.status` and `.detail`):
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from auth_platform_admin import NotFound, Forbidden
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
client.users.get(project_id, user_id)
|
|
134
|
+
except NotFound:
|
|
135
|
+
...
|
|
136
|
+
except Forbidden as exc:
|
|
137
|
+
print(exc.status, exc.detail)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
See [`examples/`](examples/) for runnable scripts (M2M and bring-your-own-token).
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# lars-kluijtmans-admin-sdk
|
|
2
|
+
|
|
3
|
+
A Python admin SDK for the **Auth Platform** Management API — modelled on the Firebase
|
|
4
|
+
Authentication Admin SDK. From a server-side program: read projects, clients and providers,
|
|
5
|
+
and read/update users (active state, custom claims, password-reset/verification emails).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install lars-kluijtmans-admin-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The import package stays `auth_platform_admin` (`from auth_platform_admin import AdminClient`).
|
|
12
|
+
Requires Python 3.10+.
|
|
13
|
+
|
|
14
|
+
## Authenticating
|
|
15
|
+
|
|
16
|
+
Two credential modes.
|
|
17
|
+
|
|
18
|
+
### M2M (recommended for servers)
|
|
19
|
+
|
|
20
|
+
A **service client** with the `client_credentials` grant. The SDK runs the grant against the
|
|
21
|
+
Login API, then caches and refreshes the token for you:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from auth_platform_admin import AdminClient
|
|
25
|
+
|
|
26
|
+
client = AdminClient.from_client_credentials(
|
|
27
|
+
login_api_url="https://login.example.com",
|
|
28
|
+
management_url="https://manage.example.com",
|
|
29
|
+
client_id="client_xxx",
|
|
30
|
+
client_secret="…",
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Bring-your-own token
|
|
35
|
+
|
|
36
|
+
Attach an admin access token you already hold (e.g. from a logged-in session):
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
client = AdminClient(management_url="https://manage.example.com", token=ACCESS_TOKEN)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
What a credential may do is governed by RBAC: a **user** token uses that user's role; a **service
|
|
43
|
+
client** uses the role assigned to it (see *Setting up a service client* below).
|
|
44
|
+
|
|
45
|
+
`AdminClient` is a context manager and closes its HTTP connection on exit:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
with AdminClient.from_client_credentials(...) as client:
|
|
49
|
+
...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Using it
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
# Projects
|
|
56
|
+
project = client.projects.get(project_id)
|
|
57
|
+
projects = client.projects.list(company_id)
|
|
58
|
+
|
|
59
|
+
# Clients & providers
|
|
60
|
+
clients = client.clients.list(project_id)
|
|
61
|
+
provider = client.providers.catalog() # platform catalog
|
|
62
|
+
enabled = client.providers.list(project_id) # per-project, with enable state
|
|
63
|
+
|
|
64
|
+
# Users — read
|
|
65
|
+
users = client.users.list(project_id, search="ann", status="active") # one page
|
|
66
|
+
for user in client.users.iter(project_id): # all pages
|
|
67
|
+
print(user.email, user.is_active)
|
|
68
|
+
user = client.users.get(project_id, user_id)
|
|
69
|
+
|
|
70
|
+
# Users — update (active state, claims, and profile fields)
|
|
71
|
+
client.users.set_active(project_id, user_id, False)
|
|
72
|
+
client.users.set_claims(project_id, user_id, {"tier": "gold"})
|
|
73
|
+
client.users.set_profile(project_id, user_id, {"display_name": "Ann", "language": "nl"})
|
|
74
|
+
# Firebase-style: apply whichever fields you pass (profile fields accept None to clear).
|
|
75
|
+
client.users.update(project_id, user_id, active=True, claims={"tier": "silver"},
|
|
76
|
+
display_name="Ann", language="nl", profile_picture="https://cdn/x.png")
|
|
77
|
+
|
|
78
|
+
# Users — remediation
|
|
79
|
+
client.users.send_password_reset(project_id, user_id)
|
|
80
|
+
client.users.resend_verification(project_id, user_id)
|
|
81
|
+
client.users.force_logout(project_id, user_id)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Every model exposes `.raw` (the full API payload) so new/unknown fields are always reachable.
|
|
85
|
+
|
|
86
|
+
> The admin can edit **display name, language, and profile picture** (`set_profile` / `update`),
|
|
87
|
+
> flip active state, set claims, and trigger the reset/verification emails. **Email and password
|
|
88
|
+
> changes remain self-service** on the Login API — they are not part of the admin surface.
|
|
89
|
+
|
|
90
|
+
## Setting up a service client
|
|
91
|
+
|
|
92
|
+
1. **Create a client** for the project (Management console → project → Clients, or the API) and
|
|
93
|
+
enable the **`client_credentials`** grant on it. Keep the `client_id` + secret.
|
|
94
|
+
2. **Assign it a role** — the permissions an m2m token from this client gets:
|
|
95
|
+
```http
|
|
96
|
+
PUT /api/v1/projects/{project_id}/clients/{client_id}/service-role
|
|
97
|
+
{ "role_id": "<a management role id>" }
|
|
98
|
+
```
|
|
99
|
+
(Needs `role:manage`.) A client with no service role can mint a token but is denied everything.
|
|
100
|
+
3. Use `AdminClient.from_client_credentials(...)` with that client's id + secret.
|
|
101
|
+
|
|
102
|
+
The service account is always scoped to its own company/project and is never a platform admin.
|
|
103
|
+
|
|
104
|
+
## Errors
|
|
105
|
+
|
|
106
|
+
Failures raise typed exceptions — `AuthError` (401), `Forbidden` (403), `NotFound` (404),
|
|
107
|
+
`Conflict` (409), `ValidationError` (422), `NetworkError` — all subclasses of `AdminError`
|
|
108
|
+
(carrying `.status` and `.detail`):
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from auth_platform_admin import NotFound, Forbidden
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
client.users.get(project_id, user_id)
|
|
115
|
+
except NotFound:
|
|
116
|
+
...
|
|
117
|
+
except Forbidden as exc:
|
|
118
|
+
print(exc.status, exc.detail)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
See [`examples/`](examples/) for runnable scripts (M2M and bring-your-own-token).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Bring-your-own-token example: pass an admin access token you already hold.
|
|
2
|
+
|
|
3
|
+
export AUTH_MANAGE_URL=http://127.0.0.1:8000
|
|
4
|
+
export AUTH_TOKEN=<an admin access token>
|
|
5
|
+
export AUTH_PROJECT_ID=...
|
|
6
|
+
export AUTH_USER_ID=...
|
|
7
|
+
python examples/byo_token_example.py
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from auth_platform_admin import AdminClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
with AdminClient(management_url=os.environ["AUTH_MANAGE_URL"], token=os.environ["AUTH_TOKEN"]) as client:
|
|
16
|
+
project_id = os.environ["AUTH_PROJECT_ID"]
|
|
17
|
+
user_id = os.environ["AUTH_USER_ID"]
|
|
18
|
+
|
|
19
|
+
user = client.users.get(project_id, user_id)
|
|
20
|
+
print(f"{user.email} active={user.is_active} claims={client.users.get_claims(project_id, user_id)}")
|
|
21
|
+
|
|
22
|
+
# Tag the user with a custom claim, then read it back.
|
|
23
|
+
client.users.set_claims(project_id, user_id, {"reviewed": True})
|
|
24
|
+
print("claims now:", client.users.get_claims(project_id, user_id))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""M2M example: authenticate with client_credentials and walk projects -> users.
|
|
2
|
+
|
|
3
|
+
Run against a local stack (after setting up a service client + role, see the README):
|
|
4
|
+
|
|
5
|
+
export AUTH_LOGIN_URL=http://127.0.0.1:8010
|
|
6
|
+
export AUTH_MANAGE_URL=http://127.0.0.1:8000
|
|
7
|
+
export AUTH_CLIENT_ID=client_xxx
|
|
8
|
+
export AUTH_CLIENT_SECRET=...
|
|
9
|
+
export AUTH_COMPANY_ID=...
|
|
10
|
+
python examples/m2m_example.py
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from auth_platform_admin import AdminClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
with AdminClient.from_client_credentials(
|
|
19
|
+
login_api_url=os.environ["AUTH_LOGIN_URL"],
|
|
20
|
+
management_url=os.environ["AUTH_MANAGE_URL"],
|
|
21
|
+
client_id=os.environ["AUTH_CLIENT_ID"],
|
|
22
|
+
client_secret=os.environ["AUTH_CLIENT_SECRET"],
|
|
23
|
+
) as client:
|
|
24
|
+
for project in client.projects.list(os.environ["AUTH_COMPANY_ID"]):
|
|
25
|
+
print(f"project {project.name} ({project.id})")
|
|
26
|
+
print(f" clients: {[c.client_id for c in client.clients.list(project.id)]}")
|
|
27
|
+
enabled = [p.provider_id for p in client.providers.list(project.id) if p.enabled]
|
|
28
|
+
print(f" providers: {enabled}")
|
|
29
|
+
for user in client.users.list(project.id, page_size=5):
|
|
30
|
+
print(f" user {user.email} active={user.is_active}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
main()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lars-kluijtmans-admin-sdk"
|
|
7
|
+
version = "0.0.3"
|
|
8
|
+
description = "Python admin SDK for the Auth Platform Management API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Auth Platform" }]
|
|
13
|
+
keywords = ["auth", "admin", "sdk", "oauth", "rbac"]
|
|
14
|
+
dependencies = ["httpx>=0.27"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=8", "respx>=0.21", "flake8>=7", "build>=1", "twine>=5"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/LarsKluijtmans/auth"
|
|
21
|
+
Source = "https://github.com/LarsKluijtmans/auth/tree/main/admin-sdk-python"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/auth_platform_admin"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Set the package version across pyproject.toml and the package __init__.
|
|
2
|
+
|
|
3
|
+
Used by the "Deploy to PyPI" workflow so a release can be cut from the Actions tab
|
|
4
|
+
without a manual bump commit:
|
|
5
|
+
|
|
6
|
+
python set_version.py --version=0.2.0
|
|
7
|
+
|
|
8
|
+
Rewrites `version = "..."` in pyproject.toml and `__version__ = "..."` in
|
|
9
|
+
src/auth_platform_admin/__init__.py, then prints the new version.
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parent
|
|
17
|
+
PYPROJECT = ROOT / "pyproject.toml"
|
|
18
|
+
INIT = ROOT / "src" / "auth_platform_admin" / "__init__.py"
|
|
19
|
+
|
|
20
|
+
# A normal PEP 440 release like 1.2.3, optionally with a pre/post/dev suffix.
|
|
21
|
+
_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[._-]?(?:a|b|rc|alpha|beta|post|dev)\d*)?$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _replace(path: Path, pattern: str, replacement: str) -> None:
|
|
25
|
+
text = path.read_text(encoding="utf-8")
|
|
26
|
+
new_text, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE)
|
|
27
|
+
if count != 1:
|
|
28
|
+
raise SystemExit(f"error: could not find a version line to update in {path}")
|
|
29
|
+
path.write_text(new_text, encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> None:
|
|
33
|
+
parser = argparse.ArgumentParser(description="Set the SDK version.")
|
|
34
|
+
parser.add_argument("--version", required=True, help="New version, e.g. 0.2.0")
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
version = args.version.strip().lstrip("v")
|
|
38
|
+
if not _VERSION_RE.match(version):
|
|
39
|
+
raise SystemExit(f"error: '{args.version}' is not a valid version number")
|
|
40
|
+
|
|
41
|
+
_replace(PYPROJECT, r'^version = "[^"]*"', f'version = "{version}"')
|
|
42
|
+
_replace(INIT, r'^__version__ = "[^"]*"', f'__version__ = "{version}"')
|
|
43
|
+
print(f"version set to {version}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
sys.exit(main())
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""auth-platform-admin — a Python admin SDK for the Auth Platform Management API."""
|
|
2
|
+
from .client import AdminClient
|
|
3
|
+
from .errors import (
|
|
4
|
+
AdminError,
|
|
5
|
+
AuthError,
|
|
6
|
+
Conflict,
|
|
7
|
+
Forbidden,
|
|
8
|
+
NetworkError,
|
|
9
|
+
NotFound,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
from .models import Client, Project, ProjectProvider, Provider, User
|
|
13
|
+
|
|
14
|
+
__version__ = "0.0.3"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AdminClient",
|
|
18
|
+
"AdminError",
|
|
19
|
+
"AuthError",
|
|
20
|
+
"Conflict",
|
|
21
|
+
"Forbidden",
|
|
22
|
+
"NetworkError",
|
|
23
|
+
"NotFound",
|
|
24
|
+
"ValidationError",
|
|
25
|
+
"Client",
|
|
26
|
+
"Project",
|
|
27
|
+
"Provider",
|
|
28
|
+
"ProjectProvider",
|
|
29
|
+
"User",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The Auth Platform admin client.
|
|
2
|
+
|
|
3
|
+
``AdminClient`` is a thin, typed, synchronous wrapper over the Management API. It supports
|
|
4
|
+
two credential modes:
|
|
5
|
+
|
|
6
|
+
* **M2M** — ``AdminClient.from_client_credentials(...)`` runs the OAuth ``client_credentials``
|
|
7
|
+
grant against the Login API (the client must have that grant enabled), caches the resulting
|
|
8
|
+
``m2m`` access token, and refreshes it on expiry or a 401.
|
|
9
|
+
* **Bring-your-own token** — ``AdminClient(management_url, token=...)`` just attaches a bearer
|
|
10
|
+
token you already hold (e.g. an admin's session token); its RBAC role governs what you can do.
|
|
11
|
+
|
|
12
|
+
Resource groups (``client.projects`` etc.) are attached by the resource modules.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Iterator
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .errors import NetworkError, raise_for_response
|
|
23
|
+
|
|
24
|
+
_TOKEN_PATH = "/login/v1/token"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Token:
|
|
28
|
+
__slots__ = ("value", "expires_at")
|
|
29
|
+
|
|
30
|
+
def __init__(self, value: str, expires_at: float) -> None:
|
|
31
|
+
self.value = value
|
|
32
|
+
self.expires_at = expires_at
|
|
33
|
+
|
|
34
|
+
def is_expiring(self, skew_seconds: float = 30.0) -> bool:
|
|
35
|
+
return time.time() >= self.expires_at - skew_seconds
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _ClientCredentials:
|
|
39
|
+
"""Mints m2m access tokens via the Login API's client_credentials grant."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, login_api_url: str, client_id: str, client_secret: str) -> None:
|
|
42
|
+
self._url = login_api_url.rstrip("/")
|
|
43
|
+
self._client_id = client_id
|
|
44
|
+
self._client_secret = client_secret
|
|
45
|
+
|
|
46
|
+
def fetch(self, http: httpx.Client) -> _Token:
|
|
47
|
+
try:
|
|
48
|
+
response = http.post(
|
|
49
|
+
f"{self._url}{_TOKEN_PATH}",
|
|
50
|
+
data={
|
|
51
|
+
"grant_type": "client_credentials",
|
|
52
|
+
"client_id": self._client_id,
|
|
53
|
+
"client_secret": self._client_secret,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
except httpx.HTTPError as exc: # pragma: no cover - exercised via NetworkError tests
|
|
57
|
+
raise NetworkError(f"Token request failed: {exc}") from exc
|
|
58
|
+
raise_for_response(response)
|
|
59
|
+
body = response.json()
|
|
60
|
+
return _Token(body["access_token"], time.time() + float(body.get("expires_in", 3600)))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AdminClient:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
management_url: str,
|
|
67
|
+
*,
|
|
68
|
+
token: str | None = None,
|
|
69
|
+
timeout: float = 30.0,
|
|
70
|
+
http_client: httpx.Client | None = None,
|
|
71
|
+
_credentials: _ClientCredentials | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
if token is None and _credentials is None:
|
|
74
|
+
raise ValueError("Provide a `token=` or use AdminClient.from_client_credentials(...)")
|
|
75
|
+
self._base = management_url.rstrip("/")
|
|
76
|
+
self._static_token = token
|
|
77
|
+
self._credentials = _credentials
|
|
78
|
+
self._cached: _Token | None = None
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
81
|
+
self._owns_http = http_client is None
|
|
82
|
+
self._attach_resources()
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_client_credentials(
|
|
86
|
+
cls,
|
|
87
|
+
*,
|
|
88
|
+
login_api_url: str,
|
|
89
|
+
management_url: str,
|
|
90
|
+
client_id: str,
|
|
91
|
+
client_secret: str,
|
|
92
|
+
timeout: float = 30.0,
|
|
93
|
+
http_client: httpx.Client | None = None,
|
|
94
|
+
) -> "AdminClient":
|
|
95
|
+
credentials = _ClientCredentials(login_api_url, client_id, client_secret)
|
|
96
|
+
return cls(management_url, timeout=timeout, http_client=http_client, _credentials=credentials)
|
|
97
|
+
|
|
98
|
+
def _attach_resources(self) -> None:
|
|
99
|
+
from .resources import Clients, Projects, Providers, Users
|
|
100
|
+
|
|
101
|
+
self.projects = Projects(self)
|
|
102
|
+
self.clients = Clients(self)
|
|
103
|
+
self.providers = Providers(self)
|
|
104
|
+
self.users = Users(self)
|
|
105
|
+
|
|
106
|
+
# --- auth ---------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _access_token(self) -> str:
|
|
109
|
+
if self._static_token is not None:
|
|
110
|
+
return self._static_token
|
|
111
|
+
assert self._credentials is not None
|
|
112
|
+
with self._lock:
|
|
113
|
+
if self._cached is None or self._cached.is_expiring():
|
|
114
|
+
self._cached = self._credentials.fetch(self._http)
|
|
115
|
+
return self._cached.value
|
|
116
|
+
|
|
117
|
+
def _invalidate_token(self) -> None:
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._cached = None
|
|
120
|
+
|
|
121
|
+
# --- transport ----------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def request(
|
|
124
|
+
self, method: str, path: str, *, params: dict | None = None, json: Any = None, _retried: bool = False
|
|
125
|
+
) -> Any:
|
|
126
|
+
"""Issue an authenticated request; return the decoded JSON (or None for 204).
|
|
127
|
+
On a 401 with M2M credentials, refresh the token once and retry."""
|
|
128
|
+
token = self._access_token()
|
|
129
|
+
try:
|
|
130
|
+
response = self._http.request(
|
|
131
|
+
method,
|
|
132
|
+
f"{self._base}{path}",
|
|
133
|
+
params=params,
|
|
134
|
+
json=json,
|
|
135
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
136
|
+
)
|
|
137
|
+
except httpx.HTTPError as exc:
|
|
138
|
+
raise NetworkError(f"Request to {path} failed: {exc}") from exc
|
|
139
|
+
|
|
140
|
+
if response.status_code == 401 and not _retried and self._credentials is not None:
|
|
141
|
+
self._invalidate_token()
|
|
142
|
+
return self.request(method, path, params=params, json=json, _retried=True)
|
|
143
|
+
|
|
144
|
+
raise_for_response(response)
|
|
145
|
+
if response.status_code == 204 or not response.content:
|
|
146
|
+
return None
|
|
147
|
+
return response.json()
|
|
148
|
+
|
|
149
|
+
def paginate(self, path: str, *, params: dict | None = None) -> Iterator[dict]:
|
|
150
|
+
"""Iterate every item across the API's `{items,page,page_size,total,pages}` pages."""
|
|
151
|
+
page = int((params or {}).get("page", 1))
|
|
152
|
+
while True:
|
|
153
|
+
data = self.request("GET", path, params={**(params or {}), "page": page})
|
|
154
|
+
for item in data.get("items", []):
|
|
155
|
+
yield item
|
|
156
|
+
if page >= int(data.get("pages") or 0):
|
|
157
|
+
break
|
|
158
|
+
page += 1
|
|
159
|
+
|
|
160
|
+
# --- lifecycle ----------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def close(self) -> None:
|
|
163
|
+
if self._owns_http:
|
|
164
|
+
self._http.close()
|
|
165
|
+
|
|
166
|
+
def __enter__(self) -> "AdminClient":
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def __exit__(self, *_exc: object) -> None:
|
|
170
|
+
self.close()
|