f40-sdk 0.1.0__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.
- f40_sdk/__init__.py +48 -0
- f40_sdk/clients/__init__.py +19 -0
- f40_sdk/clients/ai_agents.py +61 -0
- f40_sdk/clients/auth_manager.py +440 -0
- f40_sdk/clients/base.py +316 -0
- f40_sdk/clients/cmms.py +69 -0
- f40_sdk/clients/data_manager.py +1487 -0
- f40_sdk/clients/models.py +90 -0
- f40_sdk/clients/wilson.py +141 -0
- f40_sdk/gunicorn.py +213 -0
- f40_sdk/mat/__init__.py +49 -0
- f40_sdk/mat/configs/__init__.py +6 -0
- f40_sdk/mat/configs/app_config.py +73 -0
- f40_sdk/mat/configs/profiles.py +1122 -0
- f40_sdk/mat/configs/toml_app_config/__init__.py +18 -0
- f40_sdk/mat/configs/toml_app_config/_common.py +13 -0
- f40_sdk/mat/configs/toml_app_config/conversions.py +65 -0
- f40_sdk/mat/configs/toml_app_config/deep_merge.py +34 -0
- f40_sdk/mat/configs/toml_app_config/feature_flags.py +80 -0
- f40_sdk/mat/configs/toml_app_config/handler.py +82 -0
- f40_sdk/mat/configs/toml_app_config/icon_utils.py +56 -0
- f40_sdk/mat/configs/toml_app_config/languages.py +28 -0
- f40_sdk/mat/configs/toml_app_config/module_settings.py +251 -0
- f40_sdk/mat/configs/toml_app_config/registries.py +141 -0
- f40_sdk/mat/configs/toml_app_config/sidenav.py +303 -0
- f40_sdk/mat/configs/toml_app_config/toolbar_options.py +26 -0
- f40_sdk/mat/configs/toml_app_config/translations_utils.py +54 -0
- f40_sdk/mat/configs/toml_profile/__init__.py +16 -0
- f40_sdk/mat/configs/toml_profile/components_health.py +29 -0
- f40_sdk/mat/configs/toml_profile/handler.py +276 -0
- f40_sdk/mat/configs/toml_profile/mappers/__init__.py +38 -0
- f40_sdk/mat/configs/toml_profile/mappers/advanced_changeover.py +48 -0
- f40_sdk/mat/configs/toml_profile/mappers/aggregations.py +83 -0
- f40_sdk/mat/configs/toml_profile/mappers/cmms.py +32 -0
- f40_sdk/mat/configs/toml_profile/mappers/consumables.py +78 -0
- f40_sdk/mat/configs/toml_profile/mappers/cycle_traceability.py +109 -0
- f40_sdk/mat/configs/toml_profile/mappers/flags.py +6 -0
- f40_sdk/mat/configs/toml_profile/mappers/homepage.py +233 -0
- f40_sdk/mat/configs/toml_profile/mappers/kpis.py +125 -0
- f40_sdk/mat/configs/toml_profile/mappers/mappings.py +29 -0
- f40_sdk/mat/configs/toml_profile/mappers/monitoring_states.py +29 -0
- f40_sdk/mat/configs/toml_profile/mappers/prices.py +36 -0
- f40_sdk/mat/configs/toml_profile/mappers/production_config.py +46 -0
- f40_sdk/mat/configs/toml_profile/mappers/scrap_columns.py +14 -0
- f40_sdk/mat/configs/toml_profile/mappers/sidenav_visibility.py +30 -0
- f40_sdk/mat/configs/toml_profile/mappers/sub_assets.py +16 -0
- f40_sdk/mat/configs/toml_profile/mappers/tables.py +142 -0
- f40_sdk/mat/configs/toml_profile/mappers/time_state_categories.py +47 -0
- f40_sdk/mat/configs/toml_profile/mappers/time_states.py +37 -0
- f40_sdk/mat/configs/toml_profile/mappers/units.py +15 -0
- f40_sdk/mat/configs/toml_profile/overlay.py +26 -0
- f40_sdk/mat/configs/toml_profile/source.py +81 -0
- f40_sdk/mat/configs/toml_profile/utils/__init__.py +12 -0
- f40_sdk/mat/configs/toml_profile/utils/deep_merge.py +26 -0
- f40_sdk/mat/configs/toml_profile/utils/icon.py +13 -0
- f40_sdk/mat/configs/toml_profile/utils/kpi_expansion.py +56 -0
- f40_sdk/mat/configs/toml_profile/utils/sub_machines_infos.py +17 -0
- f40_sdk/mat/configs/toml_profile/utils/subassets.py +20 -0
- f40_sdk/mat/configs/toml_variables/__init__.py +18 -0
- f40_sdk/mat/configs/toml_variables/handler.py +147 -0
- f40_sdk/mat/configs/toml_variables/mappers.py +375 -0
- f40_sdk/mat/configs/translations.py +179 -0
- f40_sdk/mat/configs/variables.py +56 -0
- f40_sdk/mat/console.py +498 -0
- f40_sdk/mat/tools.py +1257 -0
- f40_sdk/mat/utils/__init__.py +79 -0
- f40_sdk/mat/utils/_globals.py +143 -0
- f40_sdk/mat/utils/_numpy.py +57 -0
- f40_sdk/mat/utils/calculator/__init__.py +268 -0
- f40_sdk/mat/utils/calculator/builtin_methods.py +458 -0
- f40_sdk/mat/utils/calculator/expression.py +956 -0
- f40_sdk/mat/utils/cmms.py +434 -0
- f40_sdk/mat/utils/data_frame/__init__.py +11 -0
- f40_sdk/mat/utils/data_frame/arrays.py +1774 -0
- f40_sdk/mat/utils/data_frame/blocks.py +770 -0
- f40_sdk/mat/utils/data_frame/core.py +1117 -0
- f40_sdk/mat/utils/data_frame/formatter.py +153 -0
- f40_sdk/mat/utils/data_frame/index.py +299 -0
- f40_sdk/mat/utils/data_frame/io.py +21 -0
- f40_sdk/mat/utils/data_frame/matrixes.py +687 -0
- f40_sdk/mat/utils/data_frame/merger.py +423 -0
- f40_sdk/mat/utils/dt/__init__.py +1595 -0
- f40_sdk/mat/utils/exceptions.py +64 -0
- f40_sdk/mat/utils/filtering.py +929 -0
- f40_sdk/mat/utils/folder.py +580 -0
- f40_sdk/mat/utils/load_toml.py +1109 -0
- f40_sdk/mat/utils/multithreading.py +200 -0
- f40_sdk/mat/utils/objects.py +521 -0
- f40_sdk/mat/utils/parser.py +55 -0
- f40_sdk/py.typed +0 -0
- f40_sdk-0.1.0.dist-info/METADATA +64 -0
- f40_sdk-0.1.0.dist-info/RECORD +95 -0
- f40_sdk-0.1.0.dist-info/WHEEL +5 -0
- f40_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- f40_sdk-0.1.0.dist-info/top_level.txt +1 -0
f40_sdk/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
f40_sdk — SDK clients for the MAT Auth Manager, Data Manager and related services.
|
|
3
|
+
|
|
4
|
+
Built on the dependency-light HTTP building block in :mod:`f40_toolkit.common.http`
|
|
5
|
+
(a dependency of this package). Those primitives are re-exported here under their
|
|
6
|
+
historical ``Mat*`` names for convenience:
|
|
7
|
+
|
|
8
|
+
MatHttpClient -> f40_toolkit.common.http.HttpClient
|
|
9
|
+
MatHttpToken -> f40_toolkit.common.http.BearerToken
|
|
10
|
+
MatHttpError -> f40_toolkit.common.http.HttpError
|
|
11
|
+
MatAuthError -> f40_toolkit.common.http.HttpAuthError
|
|
12
|
+
|
|
13
|
+
Requires: ``pip install f40-sdk``
|
|
14
|
+
|
|
15
|
+
Quick imports
|
|
16
|
+
-------------
|
|
17
|
+
from f40_sdk import MatDataManagerClient, MatAuthManagerClient
|
|
18
|
+
from f40_sdk import MatHttpClient, MatBasicClient # building blocks
|
|
19
|
+
from f40_sdk import MatHttpError # exception
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from f40_toolkit.common.http import BearerToken as MatHttpToken
|
|
23
|
+
from f40_toolkit.common.http import HttpAuthError as MatAuthError
|
|
24
|
+
from f40_toolkit.common.http import HttpClient as MatHttpClient
|
|
25
|
+
from f40_toolkit.common.http import HttpError as MatHttpError
|
|
26
|
+
from .clients import (
|
|
27
|
+
MatAiAgentsMngClient,
|
|
28
|
+
MatAuthManagerClient,
|
|
29
|
+
MatBasicClient,
|
|
30
|
+
MatCmmsMngClient,
|
|
31
|
+
MatCompData,
|
|
32
|
+
MatDataManagerClient,
|
|
33
|
+
WilsonMngClient,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"MatHttpClient",
|
|
38
|
+
"MatBasicClient",
|
|
39
|
+
"MatHttpToken",
|
|
40
|
+
"MatAuthManagerClient",
|
|
41
|
+
"MatDataManagerClient",
|
|
42
|
+
"MatCompData",
|
|
43
|
+
"MatAiAgentsMngClient",
|
|
44
|
+
"MatCmmsMngClient",
|
|
45
|
+
"WilsonMngClient",
|
|
46
|
+
"MatHttpError",
|
|
47
|
+
"MatAuthError",
|
|
48
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Service SDK clients for the MAT platform."""
|
|
2
|
+
|
|
3
|
+
from .ai_agents import MatAiAgentsMngClient
|
|
4
|
+
from .auth_manager import MatAuthManagerClient
|
|
5
|
+
from .base import MatBasicClient
|
|
6
|
+
from .cmms import MatCmmsMngClient
|
|
7
|
+
from .data_manager import MatDataManagerClient
|
|
8
|
+
from .models import MatCompData
|
|
9
|
+
from .wilson import WilsonMngClient
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"MatBasicClient",
|
|
13
|
+
"MatAuthManagerClient",
|
|
14
|
+
"MatDataManagerClient",
|
|
15
|
+
"MatCompData",
|
|
16
|
+
"MatAiAgentsMngClient",
|
|
17
|
+
"MatCmmsMngClient",
|
|
18
|
+
"WilsonMngClient",
|
|
19
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SDK client for the MAT AI-Agents (IoT-Agents) service.
|
|
3
|
+
|
|
4
|
+
Auth is handled by the parent :class:`MatBasicClient` (auth_mng / basic /
|
|
5
|
+
bearer_token / env_name). This client adds the agent listing and invocation API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING, List, Optional, Union
|
|
13
|
+
|
|
14
|
+
from .base import MatBasicClient
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from key_vault_interface.key_vault_interface import KeyVaultInterface # type: ignore[import]
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MatAiAgentsMngClient(MatBasicClient):
|
|
23
|
+
"""SDK client for the MAT AI-Agents service."""
|
|
24
|
+
|
|
25
|
+
def get_agents(self) -> List[dict]:
|
|
26
|
+
"""Return the list of agents (empty list when none / unexpected shape)."""
|
|
27
|
+
res = self._send("GET", "iota/agents")
|
|
28
|
+
if isinstance(res, dict):
|
|
29
|
+
res = res.get("agents")
|
|
30
|
+
return res if isinstance(res, list) else []
|
|
31
|
+
|
|
32
|
+
def run_agent(self, agent_id: str, run_message: str) -> None:
|
|
33
|
+
"""Invoke an agent with an alert message."""
|
|
34
|
+
self._send(
|
|
35
|
+
"POST", f"iota/agents/invoke/{agent_id}",
|
|
36
|
+
data=json.dumps({"alertMessage": run_message}), res_type="code",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_json( # type: ignore[override]
|
|
41
|
+
cls,
|
|
42
|
+
_json: Union[dict, str],
|
|
43
|
+
kvi: Optional["KeyVaultInterface"] = None,
|
|
44
|
+
) -> "MatAiAgentsMngClient":
|
|
45
|
+
"""
|
|
46
|
+
Construct from a config dict or a path to a JSON file.
|
|
47
|
+
|
|
48
|
+
The JSON may have the config nested under an ``"ai_agents"`` key::
|
|
49
|
+
|
|
50
|
+
{"ai_agents": {"url": "...", "auth": {...}}}
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(_json, str):
|
|
53
|
+
with open(_json, "r") as fh:
|
|
54
|
+
_json = json.load(fh)
|
|
55
|
+
if isinstance(_json, dict) and "ai_agents" in _json:
|
|
56
|
+
_json = _json["ai_agents"]
|
|
57
|
+
|
|
58
|
+
if not isinstance(_json, dict):
|
|
59
|
+
raise TypeError(f"Expected a dict or JSON file path, got {type(_json).__name__}.")
|
|
60
|
+
|
|
61
|
+
return cls.from_dict(_json, kvi=kvi)
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SDK client for the MAT Auth Manager service.
|
|
3
|
+
|
|
4
|
+
Handles OAuth application-token acquisition and credential validation. Typically
|
|
5
|
+
used internally by :class:`MatBasicClient` to set up automatic bearer-token refresh
|
|
6
|
+
for downstream services.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from base64 import b64encode
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from f40_toolkit.common.http import HttpClient
|
|
18
|
+
from key_vault_interface.key_vault_interface import KeyVaultInterface as KVI
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_TOKEN_ENDPOINT = "apif/oauth/application-token"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MatAuthManagerClient(HttpClient):
|
|
26
|
+
"""
|
|
27
|
+
HTTP client for the MAT Auth Manager service.
|
|
28
|
+
|
|
29
|
+
Extends :class:`~f40_toolkit.common.http.HttpClient` directly (not
|
|
30
|
+
:class:`MatBasicClient`) because the auth manager is itself the token issuer —
|
|
31
|
+
it authenticates with HTTP Basic credentials to obtain tokens for downstream
|
|
32
|
+
services.
|
|
33
|
+
|
|
34
|
+
Example
|
|
35
|
+
-------
|
|
36
|
+
am = MatAuthManagerClient(
|
|
37
|
+
base_url="http://mat-auth-manager",
|
|
38
|
+
auth=("svc-user", "svc-password"),
|
|
39
|
+
credentials={"client_id": "my-app", "client_secret": "abc123"},
|
|
40
|
+
)
|
|
41
|
+
token = am.get_bearer_token()
|
|
42
|
+
# {"access_token": "eyJ...", "expires_in": 3600, "token_type": "Bearer"}
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
base_url : Auth Manager base URL.
|
|
47
|
+
auth : (user, password) tuple for HTTP Basic auth to the auth manager itself.
|
|
48
|
+
credentials : OAuth credentials forwarded to the token endpoint. Must contain
|
|
49
|
+
"client_id" and "client_secret" (or kvi resolves the secret).
|
|
50
|
+
app_name : Injected as the "App-Name" header on token requests.
|
|
51
|
+
kvi : KeyVaultInterface used to resolve a missing client_secret.
|
|
52
|
+
verify : TLS verification flag.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
base_url: str,
|
|
58
|
+
auth: tuple,
|
|
59
|
+
*,
|
|
60
|
+
credentials: Optional[dict] = None,
|
|
61
|
+
app_name: Optional[str] = None,
|
|
62
|
+
kvi: Optional["KVI"] = None,
|
|
63
|
+
verify: Optional[bool] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
super().__init__(base_url=base_url, auth=auth, verify=verify)
|
|
66
|
+
self.app_name = app_name
|
|
67
|
+
self.credentials: Optional[dict] = credentials
|
|
68
|
+
# Cached "Bearer <token>" application token for user-management endpoints.
|
|
69
|
+
self._app_token: Optional[str] = None
|
|
70
|
+
self._app_token_exp: float = 0.0
|
|
71
|
+
if self.credentials is not None:
|
|
72
|
+
self.validate_credentials(self.credentials, kvi=kvi)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def from_json(
|
|
76
|
+
config,
|
|
77
|
+
kvi=None,
|
|
78
|
+
):
|
|
79
|
+
|
|
80
|
+
if isinstance(config, str):
|
|
81
|
+
with open(config) as rf:
|
|
82
|
+
config = json.load(rf)
|
|
83
|
+
|
|
84
|
+
if not isinstance(config, dict):
|
|
85
|
+
raise TypeError("Invalid config ...")
|
|
86
|
+
|
|
87
|
+
if "auth_mng" in config:
|
|
88
|
+
auth_mng = config.get("auth_mng")
|
|
89
|
+
if not isinstance(auth_mng, dict):
|
|
90
|
+
raise TypeError("Invalid config ...")
|
|
91
|
+
else:
|
|
92
|
+
auth_mng = config
|
|
93
|
+
|
|
94
|
+
base_url = auth_mng.get("url")
|
|
95
|
+
if not isinstance(base_url, str):
|
|
96
|
+
raise TypeError("Invalid config ...")
|
|
97
|
+
|
|
98
|
+
# kvi = KVI.from_json(
|
|
99
|
+
# config=config.get("key_vault"),
|
|
100
|
+
# secrets={
|
|
101
|
+
# "webapp-registration-secret": "webapp-registration-secret-1",
|
|
102
|
+
# "dm-to-am-secret": "dm-to-am-secret",
|
|
103
|
+
# },
|
|
104
|
+
# )
|
|
105
|
+
|
|
106
|
+
auth_basic_creds = auth_mng.get("auth")
|
|
107
|
+
|
|
108
|
+
am_usr = None
|
|
109
|
+
am_pwd = None
|
|
110
|
+
if isinstance(auth_basic_creds, (list, tuple)):
|
|
111
|
+
|
|
112
|
+
if len(auth_basic_creds) == 1:
|
|
113
|
+
am_usr = auth_basic_creds[0]
|
|
114
|
+
|
|
115
|
+
elif len(auth_basic_creds) == 2:
|
|
116
|
+
am_usr, am_pwd = auth_basic_creds
|
|
117
|
+
|
|
118
|
+
else:
|
|
119
|
+
raise Exception("....")
|
|
120
|
+
|
|
121
|
+
elif isinstance(auth_basic_creds, dict):
|
|
122
|
+
|
|
123
|
+
am_usr = auth_basic_creds.get("user")
|
|
124
|
+
am_pwd = auth_basic_creds.get("pwd")
|
|
125
|
+
|
|
126
|
+
elif isinstance(auth_basic_creds, str):
|
|
127
|
+
am_usr = auth_basic_creds
|
|
128
|
+
|
|
129
|
+
if not isinstance(am_usr, str):
|
|
130
|
+
am_usr = "dm-to-am-secret"
|
|
131
|
+
|
|
132
|
+
if not isinstance(am_pwd, str):
|
|
133
|
+
if not isinstance(kvi, KVI):
|
|
134
|
+
raise Exception("....")
|
|
135
|
+
|
|
136
|
+
am_pwd = kvi.get("dm-to-am-secret")
|
|
137
|
+
if not isinstance(am_pwd, str):
|
|
138
|
+
raise Exception("....")
|
|
139
|
+
|
|
140
|
+
app_name = auth_mng.get("app_name")
|
|
141
|
+
if not isinstance(app_name, str):
|
|
142
|
+
raise TypeError("Invalid config ...")
|
|
143
|
+
|
|
144
|
+
return MatAuthManagerClient(
|
|
145
|
+
base_url=base_url,
|
|
146
|
+
auth=(am_usr, am_pwd),
|
|
147
|
+
app_name=app_name,
|
|
148
|
+
credentials=auth_mng.get("credentials"),
|
|
149
|
+
kvi=kvi,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# Token acquisition
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def get_bearer_token(self, credentials: Optional[dict] = None) -> dict:
|
|
157
|
+
"""
|
|
158
|
+
Request an application bearer token from the auth manager.
|
|
159
|
+
|
|
160
|
+
Returns the full token response dict
|
|
161
|
+
``{"access_token": "…", "expires_in": 3600, …}``.
|
|
162
|
+
|
|
163
|
+
:class:`BearerToken` calls this automatically when the token is near expiry.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
credentials : Per-call override; uses instance credentials when None.
|
|
168
|
+
"""
|
|
169
|
+
if credentials is not None:
|
|
170
|
+
self.validate_credentials(credentials)
|
|
171
|
+
else:
|
|
172
|
+
credentials = self.credentials
|
|
173
|
+
|
|
174
|
+
if credentials is None:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
"No credentials available. Supply them at construction time "
|
|
177
|
+
"or pass them to get_bearer_token()."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
headers: dict[str, str] = {}
|
|
181
|
+
if self.app_name is not None:
|
|
182
|
+
headers["App-Name"] = self.app_name
|
|
183
|
+
|
|
184
|
+
logger.debug("Requesting bearer token from %s.", _TOKEN_ENDPOINT)
|
|
185
|
+
return self.request(
|
|
186
|
+
"POST",
|
|
187
|
+
_TOKEN_ENDPOINT,
|
|
188
|
+
data=json.dumps({"credentials": credentials}),
|
|
189
|
+
headers=headers,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def refresh_token(self, refresh_token: str) -> Any:
|
|
193
|
+
"""
|
|
194
|
+
Exchange a refresh token for a fresh user token (POST ``refresh-token``).
|
|
195
|
+
|
|
196
|
+
The app credentials (OAuth ``client_id``/``client_secret``) are sent as a
|
|
197
|
+
Basic ``credentials`` header and the refresh token in the JSON body. Returns
|
|
198
|
+
the parsed response dict (contains ``access_token`` on success).
|
|
199
|
+
"""
|
|
200
|
+
headers: dict[str, str] = {}
|
|
201
|
+
creds = self.credentials or {}
|
|
202
|
+
client_id = creds.get("client_id")
|
|
203
|
+
client_secret = creds.get("client_secret")
|
|
204
|
+
if client_id and client_secret:
|
|
205
|
+
basic = b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
|
206
|
+
headers["credentials"] = f"Basic {basic}"
|
|
207
|
+
if self.app_name is not None:
|
|
208
|
+
headers["App-Name"] = self.app_name
|
|
209
|
+
|
|
210
|
+
return self.request(
|
|
211
|
+
"POST",
|
|
212
|
+
"refresh-token",
|
|
213
|
+
data=json.dumps({"refresh_token": refresh_token}),
|
|
214
|
+
headers=headers,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Credential validation
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def validate_credentials(
|
|
222
|
+
self,
|
|
223
|
+
credentials: dict,
|
|
224
|
+
*,
|
|
225
|
+
kvi: Optional["KVI"] = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Ensure credentials contain "client_id" and "client_secret".
|
|
229
|
+
|
|
230
|
+
Mutates the dict in place: a missing/invalid client_secret is fetched
|
|
231
|
+
from the key vault under the key "webapp-registration-secret".
|
|
232
|
+
|
|
233
|
+
Raises
|
|
234
|
+
------
|
|
235
|
+
TypeError : credentials is not a dict, or client_id has the wrong type.
|
|
236
|
+
KeyError : a required key is missing and can't be sourced from the KV.
|
|
237
|
+
ValueError : the secret could not be retrieved from the key vault.
|
|
238
|
+
"""
|
|
239
|
+
if not isinstance(credentials, dict):
|
|
240
|
+
raise TypeError(
|
|
241
|
+
f"credentials must be a dict, got {type(credentials).__name__}."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
cs = credentials.get("client_secret")
|
|
245
|
+
if not isinstance(cs, str):
|
|
246
|
+
if kvi is None:
|
|
247
|
+
raise KeyError(
|
|
248
|
+
"'client_secret' is missing from credentials and no "
|
|
249
|
+
"KeyVaultInterface was provided to fetch it."
|
|
250
|
+
)
|
|
251
|
+
secret = kvi.get("webapp-registration-secret")
|
|
252
|
+
if not isinstance(secret, str):
|
|
253
|
+
raise ValueError(
|
|
254
|
+
"Could not retrieve 'client_secret' from the key vault "
|
|
255
|
+
"(key: 'webapp-registration-secret')."
|
|
256
|
+
)
|
|
257
|
+
credentials["client_secret"] = secret
|
|
258
|
+
|
|
259
|
+
if "client_id" not in credentials:
|
|
260
|
+
raise KeyError("'client_id' is missing from credentials.")
|
|
261
|
+
|
|
262
|
+
if not isinstance(credentials["client_id"], str):
|
|
263
|
+
raise TypeError(
|
|
264
|
+
f"'client_id' must be a string, got {type(credentials['client_id']).__name__}."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def check_user_permissions(
|
|
268
|
+
self,
|
|
269
|
+
token,
|
|
270
|
+
permissions=None,
|
|
271
|
+
):
|
|
272
|
+
headers = {"appName": self.app_name, "App-Name": self.app_name}
|
|
273
|
+
|
|
274
|
+
if permissions is None:
|
|
275
|
+
permissions = {"permissions": {}}
|
|
276
|
+
body = {"user_token": token, "permissions": permissions}
|
|
277
|
+
|
|
278
|
+
return self.request(
|
|
279
|
+
method="POST",
|
|
280
|
+
endpoint="apif/check-user-permissions",
|
|
281
|
+
headers=headers,
|
|
282
|
+
json=body,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
# User & group management
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
_USERS_ENDPOINT = "apif/users"
|
|
290
|
+
_USERS_SELECT = (
|
|
291
|
+
"id,displayName,mail,givenName,surname,"
|
|
292
|
+
"externalUserState,userType,userPrincipalName"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _application_token(self) -> str:
|
|
296
|
+
"""
|
|
297
|
+
Return a cached ``"Bearer <token>"`` application token for the user-management
|
|
298
|
+
endpoints, refreshing it from ``get_bearer_token`` shortly before expiry.
|
|
299
|
+
|
|
300
|
+
The token is cached in-memory on the client (a process-wide singleton), so
|
|
301
|
+
it is fetched at most once per ``expires_in`` window.
|
|
302
|
+
"""
|
|
303
|
+
now = time.monotonic()
|
|
304
|
+
if self._app_token is not None and now < self._app_token_exp:
|
|
305
|
+
return self._app_token
|
|
306
|
+
|
|
307
|
+
resp = self.get_bearer_token()
|
|
308
|
+
access = resp.get("access_token") if isinstance(resp, dict) else None
|
|
309
|
+
if not access:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
"Auth Manager did not return an application 'access_token'."
|
|
312
|
+
)
|
|
313
|
+
try:
|
|
314
|
+
ttl = int(resp.get("expires_in", 600))
|
|
315
|
+
except (TypeError, ValueError):
|
|
316
|
+
ttl = 600
|
|
317
|
+
self._app_token = f"Bearer {access}"
|
|
318
|
+
# refresh 180s early to avoid using a token that expires mid-flight
|
|
319
|
+
self._app_token_exp = now + max(0, ttl - 180)
|
|
320
|
+
return self._app_token
|
|
321
|
+
|
|
322
|
+
def _um_send(self, method: str, endpoint: str, **kwargs: Any) -> Any:
|
|
323
|
+
"""
|
|
324
|
+
``request`` variant for user-management endpoints: injects the cached
|
|
325
|
+
application token as the ``Application-Token`` header. Caller-supplied
|
|
326
|
+
headers take precedence on overlap.
|
|
327
|
+
"""
|
|
328
|
+
headers = {
|
|
329
|
+
"Application-Token": self._application_token(),
|
|
330
|
+
**(kwargs.pop("headers", None) or {}),
|
|
331
|
+
}
|
|
332
|
+
return self.request(method, endpoint, headers=headers, **kwargs)
|
|
333
|
+
|
|
334
|
+
def get_users(self) -> list:
|
|
335
|
+
"""List users from the Auth Manager user-management API.
|
|
336
|
+
|
|
337
|
+
Returns the parsed JSON list of users (a curated ``$select`` projection).
|
|
338
|
+
"""
|
|
339
|
+
return self._um_send(
|
|
340
|
+
"GET", f"{self._USERS_ENDPOINT}?$select={self._USERS_SELECT}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def create_users(self, users_data) -> Any:
|
|
344
|
+
"""Create users via the Auth Manager user-management API."""
|
|
345
|
+
return self._um_send("POST", self._USERS_ENDPOINT, data=json.dumps(users_data))
|
|
346
|
+
|
|
347
|
+
def invite_users(self, users_data) -> Any:
|
|
348
|
+
"""Invite users (POST ``apif/invitations``)."""
|
|
349
|
+
return self._um_send("POST", "apif/invitations", data=json.dumps(users_data))
|
|
350
|
+
|
|
351
|
+
def get_user_um(self, user_id: str) -> Any:
|
|
352
|
+
"""Fetch a single user (GET ``apif/users/<id>``)."""
|
|
353
|
+
return self._um_send("GET", f"{self._USERS_ENDPOINT}/{user_id}")
|
|
354
|
+
|
|
355
|
+
def put_user(self, user_id: str, user_data) -> Any:
|
|
356
|
+
"""Update a user (PUT ``apif/users/<id>``)."""
|
|
357
|
+
return self._um_send(
|
|
358
|
+
"PUT", f"{self._USERS_ENDPOINT}/{user_id}", data=json.dumps(user_data)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def delete_user(self, user_id: str) -> Any:
|
|
362
|
+
"""Delete a user (DELETE ``apif/users/<id>``)."""
|
|
363
|
+
return self._um_send("DELETE", f"{self._USERS_ENDPOINT}/{user_id}")
|
|
364
|
+
|
|
365
|
+
def get_user_memberships(self, user_id: str) -> Any:
|
|
366
|
+
"""List a user's group memberships (GET ``apif/users/<id>/memberships``)."""
|
|
367
|
+
return self._um_send("GET", f"{self._USERS_ENDPOINT}/{user_id}/memberships")
|
|
368
|
+
|
|
369
|
+
# --- groups ---
|
|
370
|
+
|
|
371
|
+
def get_groups(self, filters: Optional[str] = None) -> Any:
|
|
372
|
+
"""List groups (GET ``apif/groups``), optionally filtered."""
|
|
373
|
+
endpoint = "apif/groups?$select=id,displayName"
|
|
374
|
+
if filters:
|
|
375
|
+
endpoint += f"&{filters}"
|
|
376
|
+
return self._um_send("GET", endpoint)
|
|
377
|
+
|
|
378
|
+
def create_groups(self, group_data) -> Any:
|
|
379
|
+
"""Create groups (POST ``apif/groups``)."""
|
|
380
|
+
return self._um_send("POST", "apif/groups", data=json.dumps(group_data))
|
|
381
|
+
|
|
382
|
+
def get_group(self, group_id: str) -> Any:
|
|
383
|
+
"""Fetch a single group (GET ``apif/groups/<id>``)."""
|
|
384
|
+
return self._um_send("GET", f"apif/groups/{group_id}")
|
|
385
|
+
|
|
386
|
+
def delete_group(self, group_id: str) -> Any:
|
|
387
|
+
"""Delete a group (DELETE ``apif/groups/<id>``)."""
|
|
388
|
+
return self._um_send("DELETE", f"apif/groups/{group_id}")
|
|
389
|
+
|
|
390
|
+
def get_group_members(self, group_id: str) -> Any:
|
|
391
|
+
"""List a group's members (GET ``apif/groups/<id>/members``)."""
|
|
392
|
+
return self._um_send("GET", f"apif/groups/{group_id}/members")
|
|
393
|
+
|
|
394
|
+
def add_members_to_group(
|
|
395
|
+
self, group_id: str, members, raise_on_error: bool = True
|
|
396
|
+
) -> Any:
|
|
397
|
+
"""Add members to a group (POST ``apif/groups/<id>/members``)."""
|
|
398
|
+
params = None if raise_on_error else {"raise": 0}
|
|
399
|
+
return self._um_send(
|
|
400
|
+
"POST",
|
|
401
|
+
f"apif/groups/{group_id}/members",
|
|
402
|
+
data=json.dumps(members),
|
|
403
|
+
params=params,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def remove_member_from_group(self, group_id: str, member_id: str) -> Any:
|
|
407
|
+
"""Remove a member from a group (DELETE ``apif/groups/<id>/members?user_id=…``)."""
|
|
408
|
+
return self._um_send(
|
|
409
|
+
"DELETE", f"apif/groups/{group_id}/members", params={"user_id": member_id}
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def remove_user_from_group(self, group_id: str, user_id: str) -> Any:
|
|
413
|
+
"""Remove a user from a group (alias of :meth:`remove_member_from_group`)."""
|
|
414
|
+
return self.remove_member_from_group(group_id, user_id)
|
|
415
|
+
|
|
416
|
+
def put_group(self, group_id: str, group_data) -> Any:
|
|
417
|
+
"""Update a group (PUT ``apif/groups/<id>``)."""
|
|
418
|
+
return self._um_send(
|
|
419
|
+
"PUT", f"apif/groups/{group_id}", data=json.dumps(group_data)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# --- profiles ---
|
|
423
|
+
|
|
424
|
+
def get_profiles(self, simple: bool = False) -> Any:
|
|
425
|
+
"""List profiles (GET ``apif/profiles``)."""
|
|
426
|
+
endpoint = "apif/profiles?simple=1" if simple else "apif/profiles"
|
|
427
|
+
return self._um_send("GET", endpoint)
|
|
428
|
+
|
|
429
|
+
def create_profile(self, profile_data) -> Any:
|
|
430
|
+
"""Create a profile (POST ``apif/profiles``)."""
|
|
431
|
+
return self._um_send("POST", "apif/profiles", data=json.dumps(profile_data))
|
|
432
|
+
|
|
433
|
+
def put_profile(self, profile_id: str, profile_data) -> Any:
|
|
434
|
+
"""Update a profile (PUT ``apif/profiles/<id>``)."""
|
|
435
|
+
return self._um_send(
|
|
436
|
+
"PUT", f"apif/profiles/{profile_id}", data=json.dumps(profile_data)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def __repr__(self) -> str:
|
|
440
|
+
return f"MatAuthManagerClient(base_url={self.base_url!r}, app_name={self.app_name!r})"
|