vantage-sdkpy 0.1.1__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.
- vantage_sdk/__init__.py +44 -0
- vantage_sdk/admin/__init__.py +16 -0
- vantage_sdk/admin/management/__init__.py +16 -0
- vantage_sdk/admin/management/organizations.py +60 -0
- vantage_sdk/auth.py +517 -0
- vantage_sdk/base/__init__.py +21 -0
- vantage_sdk/base/crud.py +475 -0
- vantage_sdk/client.py +82 -0
- vantage_sdk/cloud/__init__.py +46 -0
- vantage_sdk/cloud/cloud_account_crud.py +433 -0
- vantage_sdk/cloud/cloud_account_schema.py +177 -0
- vantage_sdk/cloud/crud.py +174 -0
- vantage_sdk/cloud/lxd_utils.py +85 -0
- vantage_sdk/cloud/schema.py +149 -0
- vantage_sdk/cluster/__init__.py +24 -0
- vantage_sdk/cluster/application/__init__.py +15 -0
- vantage_sdk/cluster/application/kubeflow.py +121 -0
- vantage_sdk/cluster/application/service_workflow.py +280 -0
- vantage_sdk/cluster/crud.py +1064 -0
- vantage_sdk/cluster/schema.py +92 -0
- vantage_sdk/config.py +100 -0
- vantage_sdk/constants.py +56 -0
- vantage_sdk/exceptions.py +74 -0
- vantage_sdk/gql_client.py +709 -0
- vantage_sdk/job/__init__.py +27 -0
- vantage_sdk/job/crud.py +61 -0
- vantage_sdk/job/schema.py +84 -0
- vantage_sdk/jupyterhub_client.py +211 -0
- vantage_sdk/jupyterhub_sdk.py +208 -0
- vantage_sdk/license/__init__.py +44 -0
- vantage_sdk/license/crud.py +81 -0
- vantage_sdk/license/schema.py +112 -0
- vantage_sdk/schemas.py +113 -0
- vantage_sdk/support_ticket/__init__.py +23 -0
- vantage_sdk/support_ticket/crud.py +745 -0
- vantage_sdk/support_ticket/schema.py +113 -0
- vantage_sdk/team/__init__.py +16 -0
- vantage_sdk/team/crud.py +341 -0
- vantage_sdk/vantage_rest_api_client.py +220 -0
- vantage_sdk/workbench/__init__.py +27 -0
- vantage_sdk/workbench/_vdeployer.py +110 -0
- vantage_sdk/workbench/compute_pool.py +97 -0
- vantage_sdk/workbench/inference_endpoint.py +196 -0
- vantage_sdk/workbench/inference_preset.py +86 -0
- vantage_sdk/workbench/model_registry.py +185 -0
- vantage_sdk/workbench/namespace.py +72 -0
- vantage_sdk/workbench/secret.py +110 -0
- vantage_sdk/workbench/slurm.py +300 -0
- vantage_sdk/workbench/user_service.py +137 -0
- vantage_sdk/workbench/workspace_preset.py +82 -0
- vantage_sdkpy-0.1.1.dist-info/METADATA +129 -0
- vantage_sdkpy-0.1.1.dist-info/RECORD +53 -0
- vantage_sdkpy-0.1.1.dist-info/WHEEL +4 -0
vantage_sdk/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Copyright (C) 2025 Vantage Compute Corporation
|
|
2
|
+
# This program is free software: you can redistribute it and/or modify it under
|
|
3
|
+
# the terms of the GNU General Public License as published by the Free Software
|
|
4
|
+
# Foundation, version 3.
|
|
5
|
+
#
|
|
6
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
7
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
8
|
+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
9
|
+
#
|
|
10
|
+
# You should have received a copy of the GNU General Public License along with
|
|
11
|
+
# this program. If not, see <https://www.gnu.org/licenses/>.
|
|
12
|
+
"""Standalone Vantage SDK public exports."""
|
|
13
|
+
|
|
14
|
+
# Import base classes
|
|
15
|
+
from .base import BaseCRUDSDK, BaseGraphQLResourceSDK
|
|
16
|
+
from .cloud.cloud_account_crud import cloud_account_sdk
|
|
17
|
+
|
|
18
|
+
from .cluster.crud import cluster_sdk
|
|
19
|
+
from .job import JobScript, JobScriptFile, JobSubmission, JobTemplate
|
|
20
|
+
from .job import job_script_sdk, job_submission_sdk, job_template_sdk
|
|
21
|
+
from .schemas import CliContext, SDKContext
|
|
22
|
+
from .support_ticket.crud import support_ticket_sdk
|
|
23
|
+
from .team.crud import team_sdk
|
|
24
|
+
from .vantage_rest_api_client import VantageRestApiClient, create_vantage_rest_client
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"BaseCRUDSDK",
|
|
28
|
+
"BaseGraphQLResourceSDK",
|
|
29
|
+
"SDKContext",
|
|
30
|
+
"CliContext",
|
|
31
|
+
"cluster_sdk",
|
|
32
|
+
"cloud_account_sdk",
|
|
33
|
+
"team_sdk",
|
|
34
|
+
"support_ticket_sdk",
|
|
35
|
+
"job_script_sdk",
|
|
36
|
+
"job_template_sdk",
|
|
37
|
+
"job_submission_sdk",
|
|
38
|
+
"JobScript",
|
|
39
|
+
"JobScriptFile",
|
|
40
|
+
"JobSubmission",
|
|
41
|
+
"JobTemplate",
|
|
42
|
+
"VantageRestApiClient",
|
|
43
|
+
"create_vantage_rest_client",
|
|
44
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copyright (C) 2025 Vantage Compute Corporation
|
|
2
|
+
# This program is free software: you can redistribute it and/or modify it under
|
|
3
|
+
# the terms of the GNU General Public License as published by the Free Software
|
|
4
|
+
# Foundation, version 3.
|
|
5
|
+
#
|
|
6
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
7
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
8
|
+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
9
|
+
#
|
|
10
|
+
# You should have received a copy of the GNU General Public License along with
|
|
11
|
+
# this program. If not, see <https://www.gnu.org/licenses/>.
|
|
12
|
+
"""Admin SDK package for administrative operations."""
|
|
13
|
+
|
|
14
|
+
from .management import get_extra_attributes
|
|
15
|
+
|
|
16
|
+
__all__ = ["get_extra_attributes"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Copyright (C) 2025 Vantage Compute Corporation
|
|
2
|
+
# This program is free software: you can redistribute it and/or modify it under
|
|
3
|
+
# the terms of the GNU General Public License as published by the Free Software
|
|
4
|
+
# Foundation, version 3.
|
|
5
|
+
#
|
|
6
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
7
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
8
|
+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
9
|
+
#
|
|
10
|
+
# You should have received a copy of the GNU General Public License along with
|
|
11
|
+
# this program. If not, see <https://www.gnu.org/licenses/>.
|
|
12
|
+
"""Management SDK module for administrative operations."""
|
|
13
|
+
|
|
14
|
+
from .organizations import get_extra_attributes
|
|
15
|
+
|
|
16
|
+
__all__ = ["get_extra_attributes"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Copyright (C) 2025 Vantage Compute Corporation
|
|
2
|
+
# This program is free software: you can redistribute it and/or modify it under
|
|
3
|
+
# the terms of the GNU General Public License as published by the Free Software
|
|
4
|
+
# Foundation, version 3.
|
|
5
|
+
#
|
|
6
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
7
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
8
|
+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
9
|
+
#
|
|
10
|
+
# You should have received a copy of the GNU General Public License along with
|
|
11
|
+
# this program. If not, see <https://www.gnu.org/licenses/>.
|
|
12
|
+
"""Organizations management SDK functions.
|
|
13
|
+
|
|
14
|
+
This module provides SDK functions for organization-level administrative operations
|
|
15
|
+
such as managing extra attributes and other organization configurations.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def get_extra_attributes(ctx: typer.Context) -> Optional[Dict[Any, Any]]:
|
|
27
|
+
"""Get organization extra attributes.
|
|
28
|
+
|
|
29
|
+
This function retrieves the list of extra attributes configured for the organization.
|
|
30
|
+
Extra attributes are custom fields that can be added to various entities.
|
|
31
|
+
|
|
32
|
+
The REST client is automatically initialized and attached to ctx.obj.rest_client
|
|
33
|
+
by the @attach_vantage_rest_client decorator.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
ctx: Typer context with rest_client, settings, and persona already attached
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Dict of extra attribute data containing the organization's extra attributes,
|
|
40
|
+
or None if the request fails
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
httpx.HTTPStatusError: If the API request fails
|
|
44
|
+
Exception: For other request failures
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> import typer
|
|
48
|
+
>>> ctx = typer.Context(...) # Context with settings and persona
|
|
49
|
+
>>> attributes = await get_extra_attributes(ctx)
|
|
50
|
+
>>> print(attributes)
|
|
51
|
+
"""
|
|
52
|
+
path = "/admin/management/organizations/extra-attributes"
|
|
53
|
+
try:
|
|
54
|
+
# The VantageRestApiClient.get() method returns the JSON data directly
|
|
55
|
+
# (not a response object), so we can return it as-is
|
|
56
|
+
data = await ctx.obj.rest_client.get(path)
|
|
57
|
+
return data
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.warning("Failed to retrieve extra attributes: %s", e)
|
|
60
|
+
return None
|
vantage_sdk/auth.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# Copyright (C) 2025 Vantage Compute Corporation
|
|
2
|
+
# This program is free software: you can redistribute it and/or modify it under
|
|
3
|
+
# the terms of the GNU General Public License as published by the Free Software
|
|
4
|
+
# Foundation, version 3.
|
|
5
|
+
#
|
|
6
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
7
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
8
|
+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
9
|
+
#
|
|
10
|
+
# You should have received a copy of the GNU General Public License along with
|
|
11
|
+
# this program. If not, see <https://www.gnu.org/licenses/>.
|
|
12
|
+
"""Authentication and authorization functionality for the Vantage CLI."""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import base64
|
|
16
|
+
import datetime
|
|
17
|
+
import hashlib
|
|
18
|
+
import logging
|
|
19
|
+
import secrets
|
|
20
|
+
from textwrap import dedent
|
|
21
|
+
from typing import Any, Callable
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
import snick
|
|
25
|
+
from jose import jwt
|
|
26
|
+
from jose.exceptions import ExpiredSignatureError
|
|
27
|
+
from pydantic import ValidationError
|
|
28
|
+
|
|
29
|
+
from vantage_sdk.client import make_oauth_request
|
|
30
|
+
from vantage_sdk.config import Settings
|
|
31
|
+
from vantage_sdk.constants import (
|
|
32
|
+
OIDC_DEVICE_PATH,
|
|
33
|
+
OIDC_SCOPES,
|
|
34
|
+
OIDC_TOKEN_PATH,
|
|
35
|
+
TOKEN_REFRESH_THRESHOLD_SECONDS,
|
|
36
|
+
)
|
|
37
|
+
from vantage_sdk.exceptions import Abort
|
|
38
|
+
from vantage_sdk.schemas import CliContext, DeviceCodeData, IdentityData, Persona, TokenSet
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
44
|
+
"""Generate a PKCE code verifier and code challenge pair.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tuple of (code_verifier, code_challenge)
|
|
48
|
+
"""
|
|
49
|
+
# Generate a random code verifier (43-128 characters)
|
|
50
|
+
code_verifier = secrets.token_urlsafe(64)
|
|
51
|
+
|
|
52
|
+
# Create code challenge using SHA256 and base64url encoding
|
|
53
|
+
code_challenge_bytes = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
54
|
+
code_challenge = base64.urlsafe_b64encode(code_challenge_bytes).rstrip(b"=").decode("ascii")
|
|
55
|
+
|
|
56
|
+
return code_verifier, code_challenge
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_persona(token_set: TokenSet, settings: Settings | None = None) -> Persona:
|
|
60
|
+
"""Extract a persona from an explicitly provided token set."""
|
|
61
|
+
token_set = refresh_token_if_needed(token_set, settings=settings)
|
|
62
|
+
|
|
63
|
+
# Now validate and extract identity from the (potentially refreshed) token
|
|
64
|
+
identity_data = validate_token_and_extract_identity(token_set)
|
|
65
|
+
|
|
66
|
+
logger.debug(f"Persona created with identity_data: {identity_data}")
|
|
67
|
+
|
|
68
|
+
return Persona(
|
|
69
|
+
token_set=token_set,
|
|
70
|
+
identity_data=identity_data,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_token_and_extract_identity(token_set: TokenSet) -> IdentityData:
|
|
75
|
+
"""Validate access token and extract user identity information."""
|
|
76
|
+
logger.debug("Validating access token")
|
|
77
|
+
|
|
78
|
+
token_file_is_empty = not token_set.access_token
|
|
79
|
+
if token_file_is_empty:
|
|
80
|
+
logger.debug("Access token file exists but it is empty")
|
|
81
|
+
raise Abort(
|
|
82
|
+
"""
|
|
83
|
+
Access token file exists but it is empty.
|
|
84
|
+
|
|
85
|
+
Please try logging in again.
|
|
86
|
+
""",
|
|
87
|
+
subject="Empty access token file",
|
|
88
|
+
log_message="Empty access token file",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
with Abort.handle_errors(
|
|
92
|
+
"""
|
|
93
|
+
There was an unknown error while validating the access token.
|
|
94
|
+
|
|
95
|
+
Please try logging in again.
|
|
96
|
+
""",
|
|
97
|
+
ignore_exc_class=ExpiredSignatureError, # Will be handled in calling context
|
|
98
|
+
raise_kwargs={
|
|
99
|
+
"subject": "Invalid access token",
|
|
100
|
+
"log_message": "Unknown error while validating access access token",
|
|
101
|
+
},
|
|
102
|
+
):
|
|
103
|
+
token_data = jwt.decode(
|
|
104
|
+
token_set.access_token,
|
|
105
|
+
"", # Empty key is acceptable when verify_signature is False
|
|
106
|
+
options={
|
|
107
|
+
"verify_signature": False,
|
|
108
|
+
"verify_aud": False,
|
|
109
|
+
"verify_exp": True,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
logger.debug("Extracting identity data from the access token")
|
|
114
|
+
with Abort.handle_errors(
|
|
115
|
+
"""
|
|
116
|
+
There was an error extracting the user's identity from the access token.
|
|
117
|
+
|
|
118
|
+
Please try logging in again.
|
|
119
|
+
""",
|
|
120
|
+
handle_exc_class=ValidationError,
|
|
121
|
+
raise_kwargs={
|
|
122
|
+
"subject": "Missing user data",
|
|
123
|
+
"log_message": "Token data could not be extracted to identity",
|
|
124
|
+
},
|
|
125
|
+
):
|
|
126
|
+
if "organization" not in token_data or not token_data["organization"]:
|
|
127
|
+
raise Abort(
|
|
128
|
+
"""
|
|
129
|
+
The access token is missing organization information.
|
|
130
|
+
|
|
131
|
+
Please ensure your user account is associated with an organization
|
|
132
|
+
and try logging in again.
|
|
133
|
+
""",
|
|
134
|
+
subject="Missing organization info",
|
|
135
|
+
log_message="Access token missing organization information",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Extract org_id from organization structure
|
|
139
|
+
# Organization is typically: {"org-uuid": {"id": "org-uuid", ...}}
|
|
140
|
+
organization = token_data.get("organization", {})
|
|
141
|
+
logger.debug(f"Organization data extracted from token: {organization}")
|
|
142
|
+
org_key = next(iter(organization), None)
|
|
143
|
+
logger.debug(f"Organization key identified: {org_key}")
|
|
144
|
+
org_id = organization.get(org_key, {}).get("id", "") if org_key else ""
|
|
145
|
+
logger.debug(f"Extracted org_id: {org_id}")
|
|
146
|
+
|
|
147
|
+
email = token_data.get("email") or ""
|
|
148
|
+
# Extract username from email (part before @) or use preferred_username claim
|
|
149
|
+
username = token_data.get("preferred_username") or email.split("@")[0] if email else ""
|
|
150
|
+
identity = IdentityData(
|
|
151
|
+
email=email,
|
|
152
|
+
client_id=token_data.get("azp") or "unknown",
|
|
153
|
+
org_id=org_id,
|
|
154
|
+
org_name=org_key or "",
|
|
155
|
+
username=username,
|
|
156
|
+
)
|
|
157
|
+
logger.debug(f"Extracted identity data: {identity}")
|
|
158
|
+
|
|
159
|
+
return identity
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_token_expired(token: str, buffer_seconds: int = TOKEN_REFRESH_THRESHOLD_SECONDS) -> bool:
|
|
163
|
+
"""Check if a JWT token is expired or will expire within buffer_seconds.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
token: JWT access token
|
|
167
|
+
buffer_seconds: Number of seconds before actual expiry to consider token expired.
|
|
168
|
+
Defaults to TOKEN_REFRESH_THRESHOLD_SECONDS (300s) for proactive refresh.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
True if token is expired or will expire soon, False otherwise
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
# Decode token without verification to get expiration
|
|
175
|
+
token_data = jwt.decode(
|
|
176
|
+
token,
|
|
177
|
+
"", # Empty key is acceptable when verify_signature is False
|
|
178
|
+
options={
|
|
179
|
+
"verify_signature": False,
|
|
180
|
+
"verify_aud": False,
|
|
181
|
+
"verify_exp": False, # Don't verify expiration here, we want to check manually
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if "exp" not in token_data:
|
|
186
|
+
logger.debug("Token does not contain expiration claim")
|
|
187
|
+
return True # Consider token expired if no expiration claim
|
|
188
|
+
|
|
189
|
+
exp_timestamp = token_data["exp"]
|
|
190
|
+
exp_datetime = datetime.datetime.fromtimestamp(exp_timestamp)
|
|
191
|
+
now_with_buffer = datetime.datetime.now() + datetime.timedelta(seconds=buffer_seconds)
|
|
192
|
+
|
|
193
|
+
is_expired = exp_datetime <= now_with_buffer
|
|
194
|
+
|
|
195
|
+
if is_expired:
|
|
196
|
+
logger.debug(
|
|
197
|
+
f"Token expired or will expire soon. Expires at: {exp_datetime}, Current time + buffer: {now_with_buffer}"
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
logger.debug(
|
|
201
|
+
f"Token is valid. Expires at: {exp_datetime}, Current time + buffer: {now_with_buffer}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return is_expired
|
|
205
|
+
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.debug(f"Error checking token expiration: {e}")
|
|
208
|
+
return True # Consider token expired if we can't parse it
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def refresh_token_if_needed(token_set: TokenSet, settings: Settings | None = None) -> TokenSet:
|
|
212
|
+
"""Check if the access token is expired and refresh it if needed.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
token_set: Current token set
|
|
216
|
+
settings: Explicit settings used for refresh requests
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Updated token set with refreshed tokens if refresh was needed
|
|
220
|
+
"""
|
|
221
|
+
if not token_set.access_token:
|
|
222
|
+
logger.debug("No access token available")
|
|
223
|
+
return token_set
|
|
224
|
+
|
|
225
|
+
if not is_token_expired(token_set.access_token):
|
|
226
|
+
logger.debug("Access token is still valid")
|
|
227
|
+
return token_set
|
|
228
|
+
|
|
229
|
+
logger.debug("Access token is expired, attempting refresh")
|
|
230
|
+
|
|
231
|
+
if not token_set.refresh_token:
|
|
232
|
+
logger.debug("No refresh token available")
|
|
233
|
+
raise Abort(
|
|
234
|
+
"The access token is expired and no refresh token is available. Please log in again.",
|
|
235
|
+
subject="Token expired",
|
|
236
|
+
log_message="Token expired and no refresh token available",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
if settings is None:
|
|
241
|
+
raise Abort(
|
|
242
|
+
"The access token is expired and SDK settings were not provided for refresh.",
|
|
243
|
+
subject="Missing SDK settings",
|
|
244
|
+
log_message="Token refresh requested without explicit settings",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Attempt to refresh the token
|
|
248
|
+
refresh_success = refresh_access_token_standalone(token_set, settings)
|
|
249
|
+
|
|
250
|
+
if refresh_success:
|
|
251
|
+
logger.debug("Successfully refreshed access token")
|
|
252
|
+
return token_set
|
|
253
|
+
else:
|
|
254
|
+
logger.warning("Token refresh failed - check error logs above for details")
|
|
255
|
+
raise Exception("Token refresh failed")
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.debug(f"Token refresh error: {e}")
|
|
259
|
+
raise Abort(
|
|
260
|
+
dedent(
|
|
261
|
+
"""\
|
|
262
|
+
Your authentication session has expired.
|
|
263
|
+
|
|
264
|
+
Please log in again by running:
|
|
265
|
+
|
|
266
|
+
vantage login
|
|
267
|
+
"""
|
|
268
|
+
),
|
|
269
|
+
subject="Authentication Required",
|
|
270
|
+
log_message=f"Token refresh failed: {e}",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def init_persona(
|
|
275
|
+
*,
|
|
276
|
+
access_token: str,
|
|
277
|
+
refresh_token: str | None = None,
|
|
278
|
+
settings: Settings | None = None,
|
|
279
|
+
) -> Persona:
|
|
280
|
+
"""Initialize a persona from explicit token values without filesystem access."""
|
|
281
|
+
token_set = TokenSet(access_token=access_token, refresh_token=refresh_token)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
identity_data = validate_token_and_extract_identity(token_set)
|
|
285
|
+
except ExpiredSignatureError:
|
|
286
|
+
Abort.require_condition(
|
|
287
|
+
token_set.refresh_token is not None,
|
|
288
|
+
"The auth token is expired. Please retrieve a new and log in again.",
|
|
289
|
+
raise_kwargs={
|
|
290
|
+
"subject": "Expired access token",
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
logger.debug("The access token is expired. Attempting to refresh token")
|
|
295
|
+
resolved_settings = settings or Settings()
|
|
296
|
+
refresh_success = refresh_access_token_standalone(token_set, resolved_settings)
|
|
297
|
+
if not refresh_success:
|
|
298
|
+
raise Exception("Failed to refresh access token")
|
|
299
|
+
identity_data = validate_token_and_extract_identity(token_set)
|
|
300
|
+
|
|
301
|
+
logger.debug(f"Persona created with identity_data: {identity_data}")
|
|
302
|
+
|
|
303
|
+
return Persona(
|
|
304
|
+
token_set=token_set,
|
|
305
|
+
identity_data=identity_data,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def refresh_access_token_standalone(token_set: TokenSet, settings: "Settings") -> bool:
|
|
310
|
+
"""Attempt to fetch a new access token given a refresh token.
|
|
311
|
+
|
|
312
|
+
Returns True if refresh was successful, False otherwise.
|
|
313
|
+
Sets the access token in-place.
|
|
314
|
+
"""
|
|
315
|
+
if not token_set.refresh_token:
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
url = f"{settings.get_auth_url()}{OIDC_TOKEN_PATH}"
|
|
319
|
+
logger.debug(f"Requesting refreshed access token from {url}")
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
with httpx.Client() as client:
|
|
323
|
+
response = client.post(
|
|
324
|
+
url,
|
|
325
|
+
data={
|
|
326
|
+
"client_id": settings.oidc_client_id,
|
|
327
|
+
"grant_type": "refresh_token",
|
|
328
|
+
"refresh_token": token_set.refresh_token,
|
|
329
|
+
},
|
|
330
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
331
|
+
timeout=30.0,
|
|
332
|
+
)
|
|
333
|
+
response.raise_for_status()
|
|
334
|
+
|
|
335
|
+
token_data = response.json()
|
|
336
|
+
token_set.access_token = token_data["access_token"]
|
|
337
|
+
|
|
338
|
+
# Update refresh token if provided
|
|
339
|
+
if "refresh_token" in token_data:
|
|
340
|
+
token_set.refresh_token = token_data["refresh_token"]
|
|
341
|
+
|
|
342
|
+
logger.debug("Successfully refreshed access token")
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
except httpx.HTTPStatusError as e:
|
|
346
|
+
logger.debug(
|
|
347
|
+
f"Token refresh failed with status {e.response.status_code}: {e.response.text}"
|
|
348
|
+
)
|
|
349
|
+
return False
|
|
350
|
+
except httpx.TimeoutException as e:
|
|
351
|
+
logger.debug(f"Token refresh timed out: {e}")
|
|
352
|
+
return False
|
|
353
|
+
except httpx.RequestError as e:
|
|
354
|
+
logger.debug(f"Token refresh request error: {e}")
|
|
355
|
+
return False
|
|
356
|
+
except KeyError as e:
|
|
357
|
+
logger.debug(f"Token refresh response missing required field: {e}")
|
|
358
|
+
return False
|
|
359
|
+
except Exception as e:
|
|
360
|
+
logger.debug(f"Unexpected error during token refresh: {type(e).__name__}: {e}")
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def refresh_access_token(ctx: CliContext, token_set: TokenSet):
|
|
365
|
+
"""Attempt to fetch a new access token given a refresh token in a token_set.
|
|
366
|
+
|
|
367
|
+
Sets the access token in-place.
|
|
368
|
+
|
|
369
|
+
If refresh fails, notify the user that they need to log in again.
|
|
370
|
+
"""
|
|
371
|
+
if ctx.client is None:
|
|
372
|
+
raise RuntimeError("HTTP client not initialized")
|
|
373
|
+
if ctx.settings is None:
|
|
374
|
+
raise RuntimeError("Settings not initialized")
|
|
375
|
+
|
|
376
|
+
url = "/realms/vantage/protocol/openid-connect/token"
|
|
377
|
+
logger.debug(f"Requesting refreshed access token from {url}")
|
|
378
|
+
|
|
379
|
+
refreshed_token_set: TokenSet = await make_oauth_request(
|
|
380
|
+
ctx.client,
|
|
381
|
+
url,
|
|
382
|
+
data={
|
|
383
|
+
"client_id": ctx.settings.oidc_client_id,
|
|
384
|
+
"grant_type": "refresh_token",
|
|
385
|
+
"refresh_token": token_set.refresh_token,
|
|
386
|
+
},
|
|
387
|
+
response_model_cls=TokenSet,
|
|
388
|
+
abort_message="The auth token could not be refreshed. Please try logging in again.",
|
|
389
|
+
abort_subject="EXPIRED ACCESS TOKEN",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
token_set.access_token = refreshed_token_set.access_token
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def fetch_auth_tokens(
|
|
396
|
+
ctx: CliContext, status_callback: Callable[[str], None] | None = None
|
|
397
|
+
) -> TokenSet:
|
|
398
|
+
"""Fetch an access token (and possibly a refresh token) from Auth0.
|
|
399
|
+
|
|
400
|
+
Prints out a URL for the user to use to authenticate and polls the token endpoint to fetch it
|
|
401
|
+
when the browser-based process finishes.
|
|
402
|
+
"""
|
|
403
|
+
if ctx.client is None:
|
|
404
|
+
raise RuntimeError("HTTP client not initialized")
|
|
405
|
+
if ctx.settings is None:
|
|
406
|
+
raise RuntimeError("Settings not initialized")
|
|
407
|
+
|
|
408
|
+
# Use console from context - it should always be available
|
|
409
|
+
console = ctx.console
|
|
410
|
+
|
|
411
|
+
# Generate PKCE code verifier and challenge
|
|
412
|
+
code_verifier, code_challenge = generate_pkce_pair()
|
|
413
|
+
|
|
414
|
+
device_code_data: DeviceCodeData = await make_oauth_request(
|
|
415
|
+
ctx.client,
|
|
416
|
+
OIDC_DEVICE_PATH,
|
|
417
|
+
data={
|
|
418
|
+
"client_id": ctx.settings.oidc_client_id,
|
|
419
|
+
"code_challenge": code_challenge,
|
|
420
|
+
"code_challenge_method": "S256",
|
|
421
|
+
"scope": OIDC_SCOPES,
|
|
422
|
+
},
|
|
423
|
+
response_model_cls=DeviceCodeData,
|
|
424
|
+
abort_message=(
|
|
425
|
+
"""
|
|
426
|
+
There was a problem retrieving a device verification code from
|
|
427
|
+
the auth provider
|
|
428
|
+
"""
|
|
429
|
+
),
|
|
430
|
+
abort_subject="COULD NOT RETRIEVE TOKEN",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
max_poll_time = 5 * 60 # 5 minutes
|
|
434
|
+
login_message = dedent(
|
|
435
|
+
f"""
|
|
436
|
+
To complete login, please open the following link in a browser:
|
|
437
|
+
|
|
438
|
+
{device_code_data.verification_uri_complete}
|
|
439
|
+
|
|
440
|
+
Waiting up to {max_poll_time / 60} minutes for you to complete the process...
|
|
441
|
+
"""
|
|
442
|
+
).strip()
|
|
443
|
+
|
|
444
|
+
if status_callback is not None:
|
|
445
|
+
status_callback(login_message)
|
|
446
|
+
else:
|
|
447
|
+
logger.info(login_message)
|
|
448
|
+
|
|
449
|
+
# Calculate timeout and start time
|
|
450
|
+
start_time = datetime.datetime.now()
|
|
451
|
+
timeout_seconds = ctx.settings.oidc_max_poll_time # This is already in seconds (int)
|
|
452
|
+
attempt = 0
|
|
453
|
+
|
|
454
|
+
while True:
|
|
455
|
+
attempt += 1
|
|
456
|
+
elapsed = (datetime.datetime.now() - start_time).total_seconds()
|
|
457
|
+
|
|
458
|
+
if elapsed >= timeout_seconds:
|
|
459
|
+
break
|
|
460
|
+
|
|
461
|
+
response_data: dict[str, Any] = {}
|
|
462
|
+
try:
|
|
463
|
+
token_data = await make_oauth_request(
|
|
464
|
+
ctx.client,
|
|
465
|
+
OIDC_TOKEN_PATH,
|
|
466
|
+
data={
|
|
467
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
468
|
+
"device_code": device_code_data.device_code,
|
|
469
|
+
"client_id": ctx.settings.oidc_client_id,
|
|
470
|
+
"code_verifier": code_verifier,
|
|
471
|
+
},
|
|
472
|
+
response_model_cls=TokenSet,
|
|
473
|
+
abort_message="IGNORE",
|
|
474
|
+
abort_subject="IGNORE",
|
|
475
|
+
)
|
|
476
|
+
return token_data
|
|
477
|
+
except Exception:
|
|
478
|
+
response = await ctx.client.post(
|
|
479
|
+
f"{ctx.settings.get_auth_url()}{OIDC_TOKEN_PATH}",
|
|
480
|
+
data={
|
|
481
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
482
|
+
"device_code": device_code_data.device_code,
|
|
483
|
+
"client_id": ctx.settings.oidc_client_id,
|
|
484
|
+
"code_verifier": code_verifier,
|
|
485
|
+
},
|
|
486
|
+
)
|
|
487
|
+
response_data = response.json()
|
|
488
|
+
|
|
489
|
+
if "error" in response_data:
|
|
490
|
+
if response_data["error"] == "authorization_pending":
|
|
491
|
+
logger.debug(f"Token fetch attempt #{attempt} failed")
|
|
492
|
+
logger.debug(f"Will try again in {device_code_data.interval} seconds")
|
|
493
|
+
await asyncio.sleep(device_code_data.interval)
|
|
494
|
+
elif response_data["error"] == "slow_down":
|
|
495
|
+
logger.debug(f"Server requested slow down on attempt #{attempt}")
|
|
496
|
+
logger.debug(f"Will try again in {device_code_data.interval * 2} seconds")
|
|
497
|
+
await asyncio.sleep(device_code_data.interval * 2)
|
|
498
|
+
else:
|
|
499
|
+
raise Abort(
|
|
500
|
+
snick.unwrap(
|
|
501
|
+
"""
|
|
502
|
+
There was a problem retrieving a device verification code
|
|
503
|
+
from the auth provider:
|
|
504
|
+
Unexpected failure retrieving access token.
|
|
505
|
+
"""
|
|
506
|
+
),
|
|
507
|
+
subject="Unexpected error",
|
|
508
|
+
log_message=f"Unexpected error response: {response_data}",
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
return TokenSet(**response_data)
|
|
512
|
+
|
|
513
|
+
raise Abort(
|
|
514
|
+
"Login process was not completed in time. Please try again.",
|
|
515
|
+
subject="Timed out",
|
|
516
|
+
log_message="Timed out while waiting for user to complete login",
|
|
517
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Copyright (C) 2025 Vantage Compute Corporation
|
|
2
|
+
# This program is free software: you can redistribute it and/or modify it under
|
|
3
|
+
# the terms of the GNU General Public License as published by the Free Software
|
|
4
|
+
# Foundation, version 3.
|
|
5
|
+
#
|
|
6
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
7
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
8
|
+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
9
|
+
#
|
|
10
|
+
# You should have received a copy of the GNU General Public License along with
|
|
11
|
+
# this program. If not, see <https://www.gnu.org/licenses/>.
|
|
12
|
+
"""Base SDK classes for CRUD operations."""
|
|
13
|
+
|
|
14
|
+
from .crud import BaseCRUDSDK, BaseGraphQLResourceSDK, BaseLocalResourceSDK, BaseRestApiResourceSDK
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BaseCRUDSDK",
|
|
18
|
+
"BaseLocalResourceSDK",
|
|
19
|
+
"BaseGraphQLResourceSDK",
|
|
20
|
+
"BaseRestApiResourceSDK",
|
|
21
|
+
]
|