clue-api 1.7.0.dev415__py3-none-any.whl → 1.7.0.dev419__py3-none-any.whl
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.
- clue/api/v1/auth.py +52 -51
- clue/api/v1/registration.py +43 -24
- clue/cronjobs/plugins.py +29 -3
- clue/helper/oauth.py +12 -12
- clue/helper/plugin_requests.py +234 -0
- clue/models/auth_user.py +61 -0
- clue/models/config.py +121 -7
- clue/remote/datatypes/set.py +28 -0
- clue/security/__init__.py +31 -55
- clue/services/action_service.py +12 -8
- clue/services/auth_service.py +65 -83
- clue/services/fetcher_service.py +12 -8
- clue/services/jwt_service.py +5 -5
- clue/services/lookup_service.py +9 -4
- clue/services/type_service.py +6 -6
- clue/services/user_service.py +16 -25
- {clue_api-1.7.0.dev415.dist-info → clue_api-1.7.0.dev419.dist-info}/METADATA +1 -1
- {clue_api-1.7.0.dev415.dist-info → clue_api-1.7.0.dev419.dist-info}/RECORD +21 -19
- {clue_api-1.7.0.dev415.dist-info → clue_api-1.7.0.dev419.dist-info}/WHEEL +0 -0
- {clue_api-1.7.0.dev415.dist-info → clue_api-1.7.0.dev419.dist-info}/entry_points.txt +0 -0
- {clue_api-1.7.0.dev415.dist-info → clue_api-1.7.0.dev419.dist-info}/licenses/LICENSE +0 -0
clue/api/v1/auth.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import typing
|
|
2
1
|
from typing import Any, Optional
|
|
3
2
|
from urllib.parse import urlparse
|
|
4
3
|
|
|
5
4
|
from authlib.integrations.base_client import OAuthError
|
|
6
5
|
from flask import current_app, request
|
|
6
|
+
from pydantic import ValidationError
|
|
7
7
|
|
|
8
|
-
import clue.services.auth_service as auth_service
|
|
9
8
|
import clue.services.user_service as user_service
|
|
10
9
|
from clue.api import (
|
|
11
10
|
bad_request,
|
|
@@ -26,6 +25,7 @@ from clue.common.logging import get_logger
|
|
|
26
25
|
from clue.common.str_utils import default_string_value
|
|
27
26
|
from clue.common.swagger import generate_swagger_docs
|
|
28
27
|
from clue.config import config
|
|
28
|
+
from clue.models.auth_user import Privilege
|
|
29
29
|
from clue.security.utils import generate_random_secret
|
|
30
30
|
|
|
31
31
|
logger = get_logger(__file__)
|
|
@@ -33,7 +33,7 @@ logger = get_logger(__file__)
|
|
|
33
33
|
|
|
34
34
|
SUB_API = "auth"
|
|
35
35
|
auth_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
36
|
-
auth_api._doc = "Allow user to authenticate to the web server"
|
|
36
|
+
auth_api._doc = "Allow user to authenticate to the web server" # type: ignore
|
|
37
37
|
|
|
38
38
|
logger = get_logger(__file__)
|
|
39
39
|
|
|
@@ -42,43 +42,43 @@ logger = get_logger(__file__)
|
|
|
42
42
|
@generate_swagger_docs()
|
|
43
43
|
@auth_api.route("/login", methods=["GET", "POST"])
|
|
44
44
|
def login(**_) -> dict[str, Any]: # noqa: C901
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
45
|
+
"""Authenticate a user through an OAuth provider.
|
|
46
|
+
|
|
47
|
+
The initial request redirects the user to the selected OAuth provider.
|
|
48
|
+
The callback exchanges the authorization code, validates the returned user,
|
|
49
|
+
and returns the provider-issued access token as the application token.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
provider: The OAuth provider to use for authentication.
|
|
53
|
+
oauth_provider: Alternative name for ``provider``.
|
|
54
|
+
code: The authorization code returned by the OAuth provider.
|
|
55
|
+
refresh_token: An OAuth refresh token used to obtain a new access token.
|
|
56
|
+
nonce: Optional nonce used during the OAuth authorization flow.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A Clue API response containing the access token, refresh token, user
|
|
60
|
+
information, and granted privileges. An initial authentication request
|
|
61
|
+
returns the provider's authorization redirect response.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
A successful authentication returns a response containing the provider
|
|
65
|
+
token, privileges, and normalized user details::
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
"app_token": "<OAuth access token>",
|
|
69
|
+
"provider": "keycloak",
|
|
70
|
+
"refresh_token": "<OAuth refresh token>",
|
|
71
|
+
"privileges": ["R", "W"],
|
|
72
|
+
"user": {
|
|
73
|
+
"uname": "user",
|
|
74
|
+
"name": "User Name",
|
|
75
|
+
"email": "user@example.com",
|
|
76
|
+
"classification": "UNCLASSIFIED",
|
|
77
|
+
"groups": ["clue_user"],
|
|
78
|
+
"roles": ["user"],
|
|
79
|
+
"avatar": "data:image/png;base64, ..."
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
82
|
"""
|
|
83
83
|
data: dict[str, Any]
|
|
84
84
|
if request.is_json and len(request.data) > 0:
|
|
@@ -100,7 +100,7 @@ def login(**_) -> dict[str, Any]: # noqa: C901
|
|
|
100
100
|
logged_in_uname = None
|
|
101
101
|
access_token = None
|
|
102
102
|
refresh_token = data.get("refresh_token", None)
|
|
103
|
-
priv: Optional[list[
|
|
103
|
+
priv: Optional[list[Privilege]] = []
|
|
104
104
|
|
|
105
105
|
try:
|
|
106
106
|
# First, we'll try oauth
|
|
@@ -173,12 +173,18 @@ def login(**_) -> dict[str, Any]: # noqa: C901
|
|
|
173
173
|
access_token = token_data.get("access_token", None)
|
|
174
174
|
refresh_token = token_data.get("refresh_token", None)
|
|
175
175
|
|
|
176
|
+
if not access_token:
|
|
177
|
+
raise AuthenticationException("The OAuth provider did not return an access token.")
|
|
178
|
+
|
|
176
179
|
# Get a useful dict of user data from the web token
|
|
177
|
-
|
|
180
|
+
try:
|
|
181
|
+
cur_user = user_service.parse_user_data(token_data, oauth_provider)
|
|
182
|
+
except ValidationError as e:
|
|
183
|
+
raise AuthenticationException("The OAuth provider returned invalid user information.") from e
|
|
178
184
|
|
|
179
|
-
logged_in_uname = cur_user
|
|
185
|
+
logged_in_uname = cur_user.uname
|
|
180
186
|
|
|
181
|
-
priv = [
|
|
187
|
+
priv = [Privilege.READ, Privilege.WRITE]
|
|
182
188
|
|
|
183
189
|
# No oauth provider was specified, so we fall back to user/pass or user/apikey
|
|
184
190
|
# elif user and (password or apikey):
|
|
@@ -226,18 +232,13 @@ def login(**_) -> dict[str, Any]: # noqa: C901
|
|
|
226
232
|
|
|
227
233
|
# Generate the token this user can use to authenticate from now on
|
|
228
234
|
|
|
229
|
-
if access_token:
|
|
230
|
-
app_token = access_token
|
|
231
|
-
else:
|
|
232
|
-
app_token = f"{logged_in_uname}:{auth_service.create_token(logged_in_uname, typing.cast(list[str], priv))}"
|
|
233
|
-
|
|
234
235
|
return ok(
|
|
235
236
|
{
|
|
236
|
-
"app_token":
|
|
237
|
+
"app_token": access_token,
|
|
237
238
|
"provider": oauth_provider,
|
|
238
239
|
"refresh_token": refresh_token,
|
|
239
240
|
"privileges": priv,
|
|
240
|
-
"user": cur_user,
|
|
241
|
+
"user": cur_user.model_dump(mode="json"),
|
|
241
242
|
},
|
|
242
243
|
cookies={"XSRF-TOKEN": xsrf_token},
|
|
243
244
|
)
|
clue/api/v1/registration.py
CHANGED
|
@@ -5,6 +5,7 @@ from clue.api import bad_request, make_subapi_blueprint, no_content, ok
|
|
|
5
5
|
from clue.common.logging import get_logger
|
|
6
6
|
from clue.common.swagger import generate_swagger_docs
|
|
7
7
|
from clue.config import config, get_redis
|
|
8
|
+
from clue.models.auth_user import Privilege, UserRole
|
|
8
9
|
from clue.models.config import ExternalSource
|
|
9
10
|
from clue.remote.datatypes.set import Set
|
|
10
11
|
from clue.security import api_login
|
|
@@ -15,12 +16,12 @@ EXTERNAL_PLUGIN_SET = Set("plugin_set", host=get_redis())
|
|
|
15
16
|
|
|
16
17
|
SUB_API = "registration"
|
|
17
18
|
registration_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
18
|
-
registration_api._doc = "Register external plugins"
|
|
19
|
+
registration_api._doc = "Register external plugins" # type: ignore
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
@generate_swagger_docs()
|
|
22
23
|
@registration_api.route("/register/", methods=["POST"])
|
|
23
|
-
@api_login()
|
|
24
|
+
@api_login(required_priv=[Privilege.WRITE], required_roles=[UserRole.ADMIN])
|
|
24
25
|
def register_application(**kwargs):
|
|
25
26
|
"""Register the plugin given the provided data via REST API.
|
|
26
27
|
|
|
@@ -56,23 +57,43 @@ def register_application(**kwargs):
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
"""
|
|
59
|
-
if not request.
|
|
60
|
+
if not request.is_json:
|
|
61
|
+
return bad_request(err="Invalid data type.")
|
|
62
|
+
|
|
63
|
+
body = request.json
|
|
64
|
+
if not (body and isinstance(body, dict)):
|
|
60
65
|
return bad_request(err="No data provided")
|
|
61
66
|
|
|
67
|
+
existing_source_names = {source.name for source in config.api.external_sources}
|
|
68
|
+
existing_source_names.update(
|
|
69
|
+
source["name"]
|
|
70
|
+
for source in EXTERNAL_PLUGIN_SET.members()
|
|
71
|
+
if isinstance(source, dict) and isinstance(source.get("name"), str)
|
|
72
|
+
)
|
|
73
|
+
|
|
62
74
|
try:
|
|
63
|
-
registration_request = ExternalSource(
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
registration_request = ExternalSource.model_validate(
|
|
76
|
+
{**request.json, "built_in": False},
|
|
77
|
+
context={
|
|
78
|
+
"registration_allowed_origins": config.api.registration_allowed_origins,
|
|
79
|
+
"existing_source_names": existing_source_names,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
except ValidationError as error:
|
|
83
|
+
return bad_request(err="; ".join(item["msg"] for item in error.errors()))
|
|
84
|
+
|
|
85
|
+
persisted_source = registration_request.model_dump(mode="json", exclude_none=True)
|
|
86
|
+
if not EXTERNAL_PLUGIN_SET.add_if_field_absent(persisted_source, "name", registration_request.name):
|
|
87
|
+
return bad_request(err="An external source with that name already exists")
|
|
66
88
|
|
|
67
89
|
config.api.external_sources.append(registration_request)
|
|
68
|
-
EXTERNAL_PLUGIN_SET.add(registration_request.model_dump(mode="json", exclude_none=True))
|
|
69
90
|
|
|
70
91
|
return ok(data=registration_request.name)
|
|
71
92
|
|
|
72
93
|
|
|
73
94
|
@generate_swagger_docs()
|
|
74
|
-
@registration_api.route("
|
|
75
|
-
@api_login()
|
|
95
|
+
@registration_api.route("/<plugin_id>", methods=["DELETE"])
|
|
96
|
+
@api_login(required_priv=[Privilege.WRITE], required_roles=[UserRole.ADMIN])
|
|
76
97
|
def remove_application(plugin_id: str, **kwargs):
|
|
77
98
|
"""Remove the given plugin from the external_sources list via REST API.
|
|
78
99
|
|
|
@@ -90,20 +111,18 @@ def remove_application(plugin_id: str, **kwargs):
|
|
|
90
111
|
"response_status": "204 NO CONTENT" # HTTP status code
|
|
91
112
|
}
|
|
92
113
|
"""
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if source
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
logger.info(no_content(data=source_to_remove.name))
|
|
107
|
-
return no_content(data=source_to_remove.name)
|
|
114
|
+
persisted_sources = [
|
|
115
|
+
source
|
|
116
|
+
for source in EXTERNAL_PLUGIN_SET.members()
|
|
117
|
+
if isinstance(source, dict) and source.get("name") == plugin_id
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
if persisted_sources:
|
|
121
|
+
config.api.external_sources = [
|
|
122
|
+
source for source in config.api.external_sources if source.name != plugin_id or source.built_in is True
|
|
123
|
+
]
|
|
124
|
+
EXTERNAL_PLUGIN_SET.remove(*persisted_sources)
|
|
125
|
+
logger.info("Removed plugin with id %s", plugin_id)
|
|
126
|
+
return no_content(data=plugin_id)
|
|
108
127
|
|
|
109
128
|
return no_content(data=f"No plugin found with id: {plugin_id}")
|
clue/cronjobs/plugins.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from apscheduler.schedulers.base import BaseScheduler
|
|
2
2
|
from gevent.queue import Queue
|
|
3
|
+
from pydantic import ValidationError
|
|
3
4
|
|
|
4
5
|
from clue.api.v1.registration import EXTERNAL_PLUGIN_SET
|
|
5
6
|
from clue.common.logging import get_logger
|
|
@@ -15,9 +16,34 @@ __scheduler_instance: BaseScheduler | None = None
|
|
|
15
16
|
|
|
16
17
|
def update_external_source_list():
|
|
17
18
|
"""Updates the external_sources list with the plugins that have been registered through the API."""
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
built_in_sources = [item for item in config.api.external_sources if item.built_in is True]
|
|
20
|
+
source_names = {source.name for source in built_in_sources}
|
|
21
|
+
plugin_list: list[ExternalSource] = []
|
|
22
|
+
|
|
23
|
+
for item in EXTERNAL_PLUGIN_SET.members():
|
|
24
|
+
if not isinstance(item, dict):
|
|
25
|
+
logger.warning("Ignoring invalid runtime external source configuration: expected an object")
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
source = ExternalSource.model_validate(
|
|
30
|
+
{**item, "built_in": False},
|
|
31
|
+
context={
|
|
32
|
+
"registration_allowed_origins": config.api.registration_allowed_origins,
|
|
33
|
+
"existing_source_names": source_names,
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
except ValidationError as error:
|
|
37
|
+
logger.warning(
|
|
38
|
+
"Ignoring invalid runtime external source configuration: %s",
|
|
39
|
+
"; ".join(item["msg"] for item in error.errors()),
|
|
40
|
+
)
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
source_names.add(source.name)
|
|
44
|
+
plugin_list.append(source)
|
|
45
|
+
|
|
46
|
+
config.api.external_sources = built_in_sources + plugin_list
|
|
21
47
|
|
|
22
48
|
|
|
23
49
|
def setup_job(sched: BaseScheduler):
|
clue/helper/oauth.py
CHANGED
|
@@ -6,7 +6,8 @@ import elasticapm
|
|
|
6
6
|
|
|
7
7
|
from clue.common.logging import get_logger
|
|
8
8
|
from clue.config import CLASSIFICATION as CL_ENGINE
|
|
9
|
-
from clue.config import
|
|
9
|
+
from clue.config import config
|
|
10
|
+
from clue.models.auth_user import UserRole
|
|
10
11
|
from clue.models.config import (
|
|
11
12
|
DEFAULT_EMAIL_FIELDS,
|
|
12
13
|
DEFAULT_USER_FIELDS,
|
|
@@ -34,7 +35,7 @@ def reorder_name(name: Optional[str]) -> Optional[str]:
|
|
|
34
35
|
return " ".join(name.split(", ", 1)[::-1])
|
|
35
36
|
|
|
36
37
|
|
|
37
|
-
@elasticapm.capture_span(span_type="authentication")
|
|
38
|
+
@elasticapm.capture_span(span_type="authentication") # type: ignore
|
|
38
39
|
def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> dict[str, Any]: # noqa: C901
|
|
39
40
|
"""Find email address and normalize it for further processing"""
|
|
40
41
|
email_adr: str | None = None
|
|
@@ -61,10 +62,10 @@ def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> di
|
|
|
61
62
|
break
|
|
62
63
|
|
|
63
64
|
# Try to find a username or use email address
|
|
64
|
-
uname = None
|
|
65
|
+
uname: str | None = None
|
|
65
66
|
for field in DEFAULT_USER_FIELDS:
|
|
66
67
|
if field in profile:
|
|
67
|
-
uname
|
|
68
|
+
uname = profile[field]
|
|
68
69
|
break
|
|
69
70
|
uname = uname or email_adr
|
|
70
71
|
|
|
@@ -101,28 +102,27 @@ def parse_profile(profile: dict[str, Any], provider_config: OAuthProvider) -> di
|
|
|
101
102
|
|
|
102
103
|
# Compute access, roles and classification using auto_properties
|
|
103
104
|
access = True
|
|
104
|
-
roles = [
|
|
105
|
+
roles = [UserRole.USER]
|
|
105
106
|
# TODO: correctly figure out the classification
|
|
106
107
|
classification = CL_ENGINE.UNRESTRICTED
|
|
107
108
|
|
|
108
109
|
# Infer roles from groups
|
|
109
110
|
if profile.get("groups") and provider_config.role_map:
|
|
110
|
-
for
|
|
111
|
+
for role in UserRole:
|
|
111
112
|
if (
|
|
112
|
-
|
|
113
|
-
and provider_config.role_map[
|
|
114
|
-
and
|
|
113
|
+
role in provider_config.role_map
|
|
114
|
+
and provider_config.role_map[role] in profile.get("groups", [])
|
|
115
|
+
and role not in roles
|
|
115
116
|
):
|
|
116
|
-
roles.append(
|
|
117
|
+
roles.append(role)
|
|
117
118
|
|
|
118
119
|
return dict(
|
|
119
120
|
access=access,
|
|
120
|
-
|
|
121
|
+
roles=roles,
|
|
121
122
|
classification=classification,
|
|
122
123
|
uname=uname,
|
|
123
124
|
name=name,
|
|
124
125
|
email=email_adr,
|
|
125
|
-
password="__NO_PASSWORD__", # noqa: S106
|
|
126
126
|
avatar=profile.get("picture", alternate),
|
|
127
127
|
groups=profile.get("groups", []),
|
|
128
128
|
)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from time import monotonic
|
|
3
|
+
from typing import Any, TypeAlias
|
|
4
|
+
from urllib.parse import urljoin, urlsplit
|
|
5
|
+
|
|
6
|
+
from requests import Response
|
|
7
|
+
from requests.exceptions import ConnectionError, Timeout
|
|
8
|
+
|
|
9
|
+
_Timeout: TypeAlias = int | float | tuple[int | float | None, int | float | None] | None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _timeout_budget(timeout: _Timeout) -> float | None:
|
|
13
|
+
"""Calculate the total duration represented by a requests timeout.
|
|
14
|
+
|
|
15
|
+
Requests accepts either a scalar timeout or a (connect, read) pair. A
|
|
16
|
+
timeout with an unbounded component has no finite total budget.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
timeout: A numeric timeout, a connect/read timeout pair, or ``None``.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The total timeout in seconds, or ``None`` when no finite budget exists.
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(timeout, tuple):
|
|
25
|
+
# If either phase is unbounded, do not pretend the pair has a deadline.
|
|
26
|
+
connect_timeout, read_timeout = timeout
|
|
27
|
+
if connect_timeout is None or read_timeout is None:
|
|
28
|
+
return None
|
|
29
|
+
return float(connect_timeout) + float(read_timeout) or None
|
|
30
|
+
return float(timeout) if timeout else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _remaining_timeout(timeout: _Timeout, remaining: float) -> _Timeout:
|
|
34
|
+
"""Fit an original timeout into the remaining overall request budget.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
timeout: The original scalar or connect/read timeout setting.
|
|
38
|
+
remaining: Time left before the overall deadline, in seconds.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A timeout value that does not exceed the remaining budget. An
|
|
42
|
+
unbounded setting is preserved as-is.
|
|
43
|
+
"""
|
|
44
|
+
if isinstance(timeout, tuple):
|
|
45
|
+
budget = _timeout_budget(timeout)
|
|
46
|
+
if budget is None:
|
|
47
|
+
return timeout
|
|
48
|
+
# Preserve the connect/read ratio while shrinking both phases together.
|
|
49
|
+
scale = min(1.0, remaining / budget)
|
|
50
|
+
connect_timeout, read_timeout = timeout
|
|
51
|
+
if connect_timeout is None or read_timeout is None:
|
|
52
|
+
return timeout
|
|
53
|
+
return float(connect_timeout) * scale, float(read_timeout) * scale
|
|
54
|
+
if timeout is None:
|
|
55
|
+
return None
|
|
56
|
+
return min(float(timeout), remaining)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _switch_to_get(status_code: int, get_method: Callable[..., Response] | None) -> bool:
|
|
60
|
+
"""Return whether a redirect should use the supplied GET method.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
status_code: HTTP status code returned by the redirect response.
|
|
64
|
+
get_method: Optional GET callable to use for method-changing redirects.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Whether a GET method is available and the status commonly changes the
|
|
68
|
+
redirected request to GET.
|
|
69
|
+
"""
|
|
70
|
+
return get_method is not None and status_code in {301, 302, 303}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clear_request_body(kwargs: dict[str, Any]) -> None:
|
|
74
|
+
"""Remove payload and query arguments from a redirected GET request.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
kwargs: Request keyword arguments, updated in place.
|
|
78
|
+
"""
|
|
79
|
+
for key in ("json", "data", "files", "params"):
|
|
80
|
+
kwargs.pop(key, None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _next_redirect(
|
|
84
|
+
response: Response,
|
|
85
|
+
url: str,
|
|
86
|
+
request_method: Callable[..., Response],
|
|
87
|
+
get_method: Callable[..., Response] | None,
|
|
88
|
+
kwargs: dict[str, Any],
|
|
89
|
+
redirect_count: int,
|
|
90
|
+
) -> tuple[Callable[..., Response], str]:
|
|
91
|
+
"""Validate and prepare the next hop in a redirect chain.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
response: The response that requested a redirect.
|
|
95
|
+
url: The URL used for the current request.
|
|
96
|
+
request_method: The method callable currently used for requests.
|
|
97
|
+
get_method: Optional callable used when a redirect changes to GET.
|
|
98
|
+
kwargs: Request keyword arguments, updated for the next hop.
|
|
99
|
+
redirect_count: Number of redirects already followed.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The request callable and absolute URL to use for the next hop. When a
|
|
103
|
+
response has no Location header, the current method and URL are returned.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ConnectionError: If the redirect target is invalid, unsafe, or exceeds
|
|
107
|
+
the redirect limit.
|
|
108
|
+
"""
|
|
109
|
+
location = response.headers.get("Location")
|
|
110
|
+
if not location:
|
|
111
|
+
return request_method, url
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
# Resolve relative locations against the response URL, as HTTP requires.
|
|
115
|
+
target_url = urljoin(response.url or url, location)
|
|
116
|
+
except ValueError as err:
|
|
117
|
+
response.close()
|
|
118
|
+
raise ConnectionError("Plugin redirected to an untrusted URL") from err
|
|
119
|
+
if not _safe_redirect(url, target_url):
|
|
120
|
+
response.close()
|
|
121
|
+
raise ConnectionError("Plugin redirected to an untrusted URL")
|
|
122
|
+
if redirect_count == 5:
|
|
123
|
+
response.close()
|
|
124
|
+
raise ConnectionError("Plugin exceeded the redirect limit")
|
|
125
|
+
|
|
126
|
+
# Some redirect codes turn a request into GET; others retain its method.
|
|
127
|
+
switch_to_get = _switch_to_get(response.status_code, get_method)
|
|
128
|
+
if switch_to_get:
|
|
129
|
+
request_method = get_method # type: ignore[assignment]
|
|
130
|
+
if response.status_code == 303 or switch_to_get:
|
|
131
|
+
_clear_request_body(kwargs)
|
|
132
|
+
else:
|
|
133
|
+
kwargs.pop("params", None)
|
|
134
|
+
|
|
135
|
+
response.close()
|
|
136
|
+
return request_method, target_url
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _safe_redirect(current_url: str, target_url: str) -> bool:
|
|
140
|
+
"""Check whether a redirect stays on the same trusted host and port.
|
|
141
|
+
|
|
142
|
+
Redirects are permitted within the same origin, and from the default HTTP
|
|
143
|
+
origin to the default HTTPS origin on the same host. Other host, port,
|
|
144
|
+
scheme, and embedded-credential changes are rejected.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
current_url: URL from which the redirect originated.
|
|
148
|
+
target_url: Resolved URL requested by the redirect response.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Whether the redirect target satisfies the allowed origin rules.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
current = urlsplit(current_url)
|
|
155
|
+
target = urlsplit(target_url)
|
|
156
|
+
# Reject non-web schemes, malformed hosts, and credential-bearing URLs.
|
|
157
|
+
if (
|
|
158
|
+
target.scheme not in {"http", "https"}
|
|
159
|
+
or not target.hostname
|
|
160
|
+
or target.username is not None
|
|
161
|
+
or target.password is not None
|
|
162
|
+
):
|
|
163
|
+
return False
|
|
164
|
+
current_port = current.port or (443 if current.scheme == "https" else 80)
|
|
165
|
+
target_port = target.port or (443 if target.scheme == "https" else 80)
|
|
166
|
+
except ValueError:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
# Never let a plugin redirect send request headers to a different host.
|
|
170
|
+
if current.hostname != target.hostname:
|
|
171
|
+
return False
|
|
172
|
+
# Permit same-origin redirects or only the conventional HTTP-to-HTTPS upgrade.
|
|
173
|
+
if current.scheme == target.scheme and current_port == target_port:
|
|
174
|
+
return True
|
|
175
|
+
return current.scheme == "http" and current_port == 80 and target.scheme == "https" and target_port == 443
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def request_with_safe_redirects(
|
|
179
|
+
request_method: Callable[..., Response],
|
|
180
|
+
url: str,
|
|
181
|
+
*,
|
|
182
|
+
get_method: Callable[..., Response] | None = None,
|
|
183
|
+
**kwargs: Any,
|
|
184
|
+
) -> Response:
|
|
185
|
+
"""Send a request while safely following a bounded chain of redirects.
|
|
186
|
+
|
|
187
|
+
Redirects are handled manually so each destination can be checked before
|
|
188
|
+
sending the request again. A single total timeout budget is maintained
|
|
189
|
+
across all hops when a finite timeout is supplied.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
request_method: Callable used to send the initial request and redirects
|
|
193
|
+
that preserve the method.
|
|
194
|
+
url: Initial request URL.
|
|
195
|
+
get_method: Optional callable to use when a redirect changes the method
|
|
196
|
+
to GET.
|
|
197
|
+
**kwargs: Additional keyword arguments passed to the request callable.
|
|
198
|
+
``allow_redirects`` is always disabled so this function can validate
|
|
199
|
+
each redirect itself.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
The first response that does not request a redirect.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
ConnectionError: If a redirect is unsafe or the redirect limit is
|
|
206
|
+
exceeded.
|
|
207
|
+
Timeout: If the total timeout budget expires between redirect hops.
|
|
208
|
+
"""
|
|
209
|
+
# Disable requests' automatic redirects to validate every target ourselves.
|
|
210
|
+
kwargs["allow_redirects"] = False
|
|
211
|
+
timeout = kwargs.get("timeout")
|
|
212
|
+
budget = _timeout_budget(timeout)
|
|
213
|
+
# Reuse one deadline so a chain cannot receive a fresh timeout per hop.
|
|
214
|
+
deadline = monotonic() + budget if budget is not None else None
|
|
215
|
+
|
|
216
|
+
for redirect_count in range(6):
|
|
217
|
+
if deadline is not None and redirect_count > 0:
|
|
218
|
+
remaining = deadline - monotonic()
|
|
219
|
+
if remaining <= 0:
|
|
220
|
+
raise Timeout("Plugin request exceeded its total timeout")
|
|
221
|
+
# Pass only the time left to the next network operation.
|
|
222
|
+
kwargs["timeout"] = _remaining_timeout(timeout, remaining)
|
|
223
|
+
|
|
224
|
+
response = request_method(url, **kwargs)
|
|
225
|
+
if response.status_code not in {301, 302, 303, 307, 308}:
|
|
226
|
+
return response
|
|
227
|
+
|
|
228
|
+
next_method, next_url = _next_redirect(response, url, request_method, get_method, kwargs, redirect_count)
|
|
229
|
+
# A redirect status without Location is not actionable; return it intact.
|
|
230
|
+
if next_url == url and not response.headers.get("Location"):
|
|
231
|
+
return response
|
|
232
|
+
request_method, url = next_method, next_url
|
|
233
|
+
|
|
234
|
+
raise ConnectionError("Plugin exceeded the redirect limit")
|
clue/models/auth_user.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UserRole(StrEnum):
|
|
7
|
+
"""Roles understood by Clue after mapping trusted identity-provider claims."""
|
|
8
|
+
|
|
9
|
+
USER = "user"
|
|
10
|
+
ADMIN = "admin"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Privilege(StrEnum):
|
|
14
|
+
"""Operations granted to an authenticated principal."""
|
|
15
|
+
|
|
16
|
+
READ = "R"
|
|
17
|
+
WRITE = "W"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIKeyConf(BaseModel):
|
|
21
|
+
"""Server-side API key and its access policy."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
roles: set[UserRole] = Field(default_factory=lambda: {UserRole.USER})
|
|
26
|
+
privileges: set[Privilege] = Field(
|
|
27
|
+
default_factory=lambda: {
|
|
28
|
+
Privilege.READ,
|
|
29
|
+
Privilege.WRITE,
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
secret: str = Field(min_length=1)
|
|
33
|
+
|
|
34
|
+
@field_validator("secret")
|
|
35
|
+
@classmethod
|
|
36
|
+
def validate_secret(cls, secret: str) -> str:
|
|
37
|
+
"""Reject API key secrets containing only whitespace."""
|
|
38
|
+
if not secret.strip():
|
|
39
|
+
raise ValueError("API key secret must not be empty")
|
|
40
|
+
return secret
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuthUser(BaseModel):
|
|
44
|
+
"""Normalized identity used after successful authentication."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
uname: str
|
|
49
|
+
name: str | None = None
|
|
50
|
+
email: str | None = None
|
|
51
|
+
classification: str
|
|
52
|
+
groups: list[str] = Field(default_factory=list)
|
|
53
|
+
roles: set[UserRole] = Field(default_factory=lambda: {UserRole.USER})
|
|
54
|
+
avatar: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AuthResult(BaseModel):
|
|
58
|
+
"""Normalized identity and effective privileges from authentication."""
|
|
59
|
+
|
|
60
|
+
user: AuthUser
|
|
61
|
+
privileges: set[Privilege]
|