maxc-cli 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.
- maxc_cli/__init__.py +5 -0
- maxc_cli/__main__.py +6 -0
- maxc_cli/app.py +3406 -0
- maxc_cli/audit.py +18 -0
- maxc_cli/auth_providers.py +471 -0
- maxc_cli/backend/__init__.py +8 -0
- maxc_cli/backend/auth.py +144 -0
- maxc_cli/backend/data.py +87 -0
- maxc_cli/backend/job.py +304 -0
- maxc_cli/backend/meta.py +312 -0
- maxc_cli/backend/odps.py +130 -0
- maxc_cli/backend/query.py +148 -0
- maxc_cli/cache.py +662 -0
- maxc_cli/cli.py +1274 -0
- maxc_cli/config.py +406 -0
- maxc_cli/exceptions.py +99 -0
- maxc_cli/helpers.py +964 -0
- maxc_cli/models.py +533 -0
- maxc_cli/output.py +75 -0
- maxc_cli/store.py +123 -0
- maxc_cli/utils.py +136 -0
- maxc_cli-0.1.0.dist-info/METADATA +220 -0
- maxc_cli-0.1.0.dist-info/RECORD +26 -0
- maxc_cli-0.1.0.dist-info/WHEEL +5 -0
- maxc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- maxc_cli-0.1.0.dist-info/top_level.txt +1 -0
maxc_cli/audit.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .utils import now_utc_iso
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuditLogger:
|
|
10
|
+
def __init__(self, path: 'Path') -> 'None':
|
|
11
|
+
self.path = path
|
|
12
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
def log(self, payload: 'dict[str, Any]') -> 'None':
|
|
15
|
+
record = dict(payload)
|
|
16
|
+
record.setdefault("ts", now_utc_iso())
|
|
17
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
18
|
+
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .config import AuthConfig, MaxCConfig, NcsAuthConfig
|
|
13
|
+
from .exceptions import FeatureUnavailableError, ValidationError
|
|
14
|
+
from .helpers import missing_odps_settings, odps_identity_source, resolve_odps_settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ResolvedAuthConnection:
|
|
19
|
+
auth_type: 'str'
|
|
20
|
+
provider: 'str'
|
|
21
|
+
project: 'str'
|
|
22
|
+
endpoint: 'str'
|
|
23
|
+
region_name: 'str | None'
|
|
24
|
+
tunnel_endpoint: 'str | None'
|
|
25
|
+
access_id: 'str | None'
|
|
26
|
+
secret_access_key: 'str | None'
|
|
27
|
+
security_token: 'str | None'
|
|
28
|
+
token_expires_at: 'str | None'
|
|
29
|
+
identity_source: 'str'
|
|
30
|
+
settings: 'dict[str, str | None]'
|
|
31
|
+
setting_sources: 'dict[str, str]'
|
|
32
|
+
suppressed_env_vars: 'list[str]'
|
|
33
|
+
account: 'Any | None' = None
|
|
34
|
+
|
|
35
|
+
def create_client(self):
|
|
36
|
+
try:
|
|
37
|
+
from odps import ODPS
|
|
38
|
+
except ImportError as exc:
|
|
39
|
+
raise FeatureUnavailableError("pyodps is not installed in the current environment.") from exc
|
|
40
|
+
|
|
41
|
+
kwargs = {
|
|
42
|
+
"project": self.project,
|
|
43
|
+
"endpoint": self.endpoint,
|
|
44
|
+
"region_name": self.region_name or None,
|
|
45
|
+
"tunnel_endpoint": self.tunnel_endpoint or None,
|
|
46
|
+
}
|
|
47
|
+
if self.account is not None:
|
|
48
|
+
return ODPS(self.account, **kwargs)
|
|
49
|
+
return ODPS(
|
|
50
|
+
access_id=self.access_id,
|
|
51
|
+
secret_access_key=self.secret_access_key,
|
|
52
|
+
**kwargs,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def auth_settings_available(config: 'MaxCConfig') -> 'bool':
|
|
57
|
+
settings, _, _suppressed = resolve_odps_settings(config)
|
|
58
|
+
provider = infer_auth_provider(config, settings)
|
|
59
|
+
try:
|
|
60
|
+
if provider == "ncs":
|
|
61
|
+
return not missing_odps_settings(settings, auth_type="ncs")
|
|
62
|
+
if provider == "sts_token":
|
|
63
|
+
return not missing_odps_settings(settings, auth_type="sts_token")
|
|
64
|
+
return not missing_odps_settings(settings, auth_type="access_key")
|
|
65
|
+
except ValidationError:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_auth_connection(
|
|
70
|
+
config: 'MaxCConfig',
|
|
71
|
+
*,
|
|
72
|
+
auth_override: 'AuthConfig | None' = None,
|
|
73
|
+
) -> 'ResolvedAuthConnection':
|
|
74
|
+
settings, sources, suppressed_env_vars = resolve_odps_settings(config, auth_override=auth_override)
|
|
75
|
+
provider = infer_auth_provider(config, settings, auth_override=auth_override)
|
|
76
|
+
|
|
77
|
+
if provider == "ncs":
|
|
78
|
+
missing = missing_odps_settings(settings, auth_type="ncs")
|
|
79
|
+
if missing:
|
|
80
|
+
raise ValidationError(
|
|
81
|
+
f"ncs authentication is missing required fields: {', '.join(missing)}.",
|
|
82
|
+
suggestion="Provide project, endpoint, and ncs account configuration before using the ncs provider.",
|
|
83
|
+
)
|
|
84
|
+
account = build_ncs_account(settings)
|
|
85
|
+
return ResolvedAuthConnection(
|
|
86
|
+
auth_type="ncs",
|
|
87
|
+
provider="ncs",
|
|
88
|
+
project=settings["project"] or config.default_project,
|
|
89
|
+
endpoint=settings["endpoint"] or "",
|
|
90
|
+
region_name=settings.get("region_name"),
|
|
91
|
+
tunnel_endpoint=settings.get("tunnel_endpoint"),
|
|
92
|
+
access_id=None,
|
|
93
|
+
secret_access_key=None,
|
|
94
|
+
security_token=None,
|
|
95
|
+
token_expires_at=settings.get("token_expires_at"),
|
|
96
|
+
identity_source=odps_identity_source(sources),
|
|
97
|
+
settings=settings,
|
|
98
|
+
setting_sources=sources,
|
|
99
|
+
suppressed_env_vars=suppressed_env_vars,
|
|
100
|
+
account=account,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if provider == "sts_token":
|
|
104
|
+
missing = missing_odps_settings(settings, auth_type="sts_token")
|
|
105
|
+
if missing:
|
|
106
|
+
raise ValidationError(
|
|
107
|
+
f"STS authentication is missing required fields: {', '.join(missing)}.",
|
|
108
|
+
suggestion="Provide access_id, secret_access_key, security_token, project, and endpoint.",
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
from odps.accounts import StsAccount
|
|
112
|
+
except ImportError as exc:
|
|
113
|
+
raise FeatureUnavailableError("pyodps is not installed in the current environment.") from exc
|
|
114
|
+
|
|
115
|
+
account = StsAccount(
|
|
116
|
+
settings["access_id"],
|
|
117
|
+
settings["secret_access_key"],
|
|
118
|
+
settings["security_token"],
|
|
119
|
+
)
|
|
120
|
+
return ResolvedAuthConnection(
|
|
121
|
+
auth_type="sts_token",
|
|
122
|
+
provider="sts_token",
|
|
123
|
+
project=settings["project"] or config.default_project,
|
|
124
|
+
endpoint=settings["endpoint"] or "",
|
|
125
|
+
region_name=settings.get("region_name"),
|
|
126
|
+
tunnel_endpoint=settings.get("tunnel_endpoint"),
|
|
127
|
+
access_id=settings.get("access_id"),
|
|
128
|
+
secret_access_key=settings.get("secret_access_key"),
|
|
129
|
+
security_token=settings.get("security_token"),
|
|
130
|
+
token_expires_at=settings.get("token_expires_at"),
|
|
131
|
+
identity_source=odps_identity_source(sources),
|
|
132
|
+
settings=settings,
|
|
133
|
+
setting_sources=sources,
|
|
134
|
+
suppressed_env_vars=suppressed_env_vars,
|
|
135
|
+
account=account,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
missing = missing_odps_settings(settings, auth_type="access_key")
|
|
139
|
+
if missing:
|
|
140
|
+
raise ValidationError(
|
|
141
|
+
f"MaxCompute connection settings are incomplete: {', '.join(missing)}.",
|
|
142
|
+
suggestion="Run `maxc auth login` or set the required environment variables.",
|
|
143
|
+
)
|
|
144
|
+
return ResolvedAuthConnection(
|
|
145
|
+
auth_type="access_key",
|
|
146
|
+
provider="access_key",
|
|
147
|
+
project=settings["project"] or config.default_project,
|
|
148
|
+
endpoint=settings["endpoint"] or "",
|
|
149
|
+
region_name=settings.get("region_name"),
|
|
150
|
+
tunnel_endpoint=settings.get("tunnel_endpoint"),
|
|
151
|
+
access_id=settings.get("access_id"),
|
|
152
|
+
secret_access_key=settings.get("secret_access_key"),
|
|
153
|
+
security_token=None,
|
|
154
|
+
token_expires_at=settings.get("token_expires_at"),
|
|
155
|
+
identity_source=odps_identity_source(sources),
|
|
156
|
+
settings=settings,
|
|
157
|
+
setting_sources=sources,
|
|
158
|
+
suppressed_env_vars=suppressed_env_vars,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def infer_auth_provider(
|
|
163
|
+
config: 'MaxCConfig',
|
|
164
|
+
settings: 'dict[str, str | None]',
|
|
165
|
+
*,
|
|
166
|
+
auth_override: 'AuthConfig | None' = None,
|
|
167
|
+
) -> 'str':
|
|
168
|
+
auth = auth_override or config.auth
|
|
169
|
+
explicit = (auth.provider or settings.get("provider") or "").strip().lower()
|
|
170
|
+
if explicit in {"access_key", "sts_token", "sts", "ncs"}:
|
|
171
|
+
return "sts_token" if explicit == "sts" else explicit
|
|
172
|
+
if settings.get("security_token"):
|
|
173
|
+
return "sts_token"
|
|
174
|
+
if auth.ncs.is_configured() or settings.get("ncs_process_command"):
|
|
175
|
+
return "ncs"
|
|
176
|
+
return "access_key"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_ncs_auth_config(
|
|
180
|
+
*,
|
|
181
|
+
account_type: 'str',
|
|
182
|
+
employee_id: 'str | None',
|
|
183
|
+
account_name: 'str | None',
|
|
184
|
+
app_name: 'str | None',
|
|
185
|
+
process_timeout: 'int' = 20,
|
|
186
|
+
) -> 'NcsAuthConfig':
|
|
187
|
+
normalized = account_type.strip().lower()
|
|
188
|
+
ncs = NcsAuthConfig(
|
|
189
|
+
account_type=normalized,
|
|
190
|
+
employee_id=employee_id,
|
|
191
|
+
account_name=account_name,
|
|
192
|
+
app_name=app_name,
|
|
193
|
+
process_timeout=process_timeout,
|
|
194
|
+
)
|
|
195
|
+
ncs.process_command = build_ncs_process_command_from_config(ncs)
|
|
196
|
+
return ncs
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def build_ncs_process_command_from_config(ncs: 'NcsAuthConfig') -> 'str':
|
|
200
|
+
account_type = (ncs.account_type or "").strip().lower()
|
|
201
|
+
if account_type == "user":
|
|
202
|
+
if not ncs.employee_id:
|
|
203
|
+
raise ValidationError("ncs account type `user` requires `employee_id`.")
|
|
204
|
+
return "ncs create credential odpsuser --employee-id {id} -o template -t odpscmd".format(
|
|
205
|
+
id=shlex.quote(ncs.employee_id)
|
|
206
|
+
)
|
|
207
|
+
if account_type == "account":
|
|
208
|
+
if not ncs.account_name:
|
|
209
|
+
raise ValidationError("ncs account type `account` requires `account_name`.")
|
|
210
|
+
return "ncs create credential odpsaccount --account-name {name} -o template -t odpscmd".format(
|
|
211
|
+
name=shlex.quote(ncs.account_name)
|
|
212
|
+
)
|
|
213
|
+
if account_type == "app":
|
|
214
|
+
if not ncs.app_name:
|
|
215
|
+
raise ValidationError("ncs account type `app` requires `app_name`.")
|
|
216
|
+
return "ncs create credential odpsapp --app-name {name} -o template -t odpscmd".format(
|
|
217
|
+
name=shlex.quote(ncs.app_name)
|
|
218
|
+
)
|
|
219
|
+
raise ValidationError("ncs account type must be one of: user, account, app.")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def build_ncs_account(settings: 'dict[str, str | None]'):
|
|
223
|
+
if shutil.which("ncs") is None:
|
|
224
|
+
raise FeatureUnavailableError(
|
|
225
|
+
"ncs CLI is not installed or not available on PATH.",
|
|
226
|
+
suggestion="Install ncs, or switch to access key / STS authentication.",
|
|
227
|
+
)
|
|
228
|
+
try:
|
|
229
|
+
from odps.accounts import CredentialProviderAccount
|
|
230
|
+
except ImportError as exc:
|
|
231
|
+
raise FeatureUnavailableError("pyodps is not installed in the current environment.") from exc
|
|
232
|
+
|
|
233
|
+
command = settings.get("ncs_process_command")
|
|
234
|
+
if not command:
|
|
235
|
+
command = build_ncs_process_command_from_config(
|
|
236
|
+
NcsAuthConfig(
|
|
237
|
+
account_type=settings.get("ncs_account_type"),
|
|
238
|
+
employee_id=settings.get("ncs_employee_id"),
|
|
239
|
+
account_name=settings.get("ncs_account_name"),
|
|
240
|
+
app_name=settings.get("ncs_app_name"),
|
|
241
|
+
process_timeout=int(settings.get("ncs_process_timeout") or 20),
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
timeout = int(settings.get("ncs_process_timeout") or 20)
|
|
246
|
+
return CredentialProviderAccount(NcsCredentialProvider(command=command, timeout=timeout))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class NcsCredentialProvider:
|
|
250
|
+
# Refresh this many seconds before the token actually expires to avoid
|
|
251
|
+
# races between the expiry check and the first use of the new token.
|
|
252
|
+
_EXPIRY_BUFFER_SECONDS = 60
|
|
253
|
+
|
|
254
|
+
def __init__(self, *, command: 'str', timeout: 'int') -> 'None':
|
|
255
|
+
self.command = command
|
|
256
|
+
self.timeout = timeout
|
|
257
|
+
self._cached: 'SimpleTempCredential | None' = None
|
|
258
|
+
self._lock = threading.Lock()
|
|
259
|
+
|
|
260
|
+
def _is_expired(self) -> 'bool':
|
|
261
|
+
if self._cached is None:
|
|
262
|
+
return True
|
|
263
|
+
if self._cached.expires_at is None:
|
|
264
|
+
# No expiry info from ncs — treat as expired so we always refresh.
|
|
265
|
+
return True
|
|
266
|
+
cutoff = self._cached.expires_at - timedelta(seconds=self._EXPIRY_BUFFER_SECONDS)
|
|
267
|
+
return datetime.now(timezone.utc) >= cutoff
|
|
268
|
+
|
|
269
|
+
def get_credentials(self) -> 'SimpleTempCredential':
|
|
270
|
+
with self._lock:
|
|
271
|
+
if not self._is_expired():
|
|
272
|
+
return self._cached # type: ignore[return-value]
|
|
273
|
+
|
|
274
|
+
result = subprocess.run(
|
|
275
|
+
self.command,
|
|
276
|
+
shell=True,
|
|
277
|
+
stdout=subprocess.PIPE,
|
|
278
|
+
stderr=subprocess.PIPE,
|
|
279
|
+
universal_newlines=True,
|
|
280
|
+
timeout=self.timeout,
|
|
281
|
+
)
|
|
282
|
+
if result.returncode != 0:
|
|
283
|
+
raise FeatureUnavailableError(
|
|
284
|
+
"ncs failed to issue MaxCompute credentials.",
|
|
285
|
+
suggestion=(result.stderr or "Check the ncs configuration and selected account.").strip(),
|
|
286
|
+
)
|
|
287
|
+
payload = parse_ncs_credential_output(result.stdout)
|
|
288
|
+
|
|
289
|
+
expires_at: 'datetime | None' = None
|
|
290
|
+
raw_expiry = payload.get("expires_at")
|
|
291
|
+
if raw_expiry:
|
|
292
|
+
try:
|
|
293
|
+
expires_at = datetime.fromisoformat(raw_expiry.replace("Z", "+00:00"))
|
|
294
|
+
except ValueError:
|
|
295
|
+
expires_at = None
|
|
296
|
+
|
|
297
|
+
self._cached = SimpleTempCredential(
|
|
298
|
+
access_key_id=payload["access_key_id"],
|
|
299
|
+
access_key_secret=payload["access_key_secret"],
|
|
300
|
+
security_token=payload["security_token"],
|
|
301
|
+
expires_at=expires_at,
|
|
302
|
+
)
|
|
303
|
+
return self._cached
|
|
304
|
+
|
|
305
|
+
# pyodps CredentialProviderAccount calls either spelling
|
|
306
|
+
get_credential = get_credentials
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass
|
|
310
|
+
class SimpleTempCredential:
|
|
311
|
+
access_key_id: 'str'
|
|
312
|
+
access_key_secret: 'str'
|
|
313
|
+
security_token: 'str'
|
|
314
|
+
expires_at: 'datetime | None' = field(default=None)
|
|
315
|
+
|
|
316
|
+
def get_access_key_id(self) -> 'str':
|
|
317
|
+
return self.access_key_id
|
|
318
|
+
|
|
319
|
+
def get_access_key_secret(self) -> 'str':
|
|
320
|
+
return self.access_key_secret
|
|
321
|
+
|
|
322
|
+
def get_security_token(self) -> 'str':
|
|
323
|
+
return self.security_token
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def parse_ncs_credential_output(stdout: 'str') -> 'dict[str, str]':
|
|
327
|
+
text = stdout.strip()
|
|
328
|
+
if not text:
|
|
329
|
+
raise ValidationError("ncs returned an empty credential payload.")
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
payload = json.loads(text)
|
|
333
|
+
except json.JSONDecodeError:
|
|
334
|
+
payload = None
|
|
335
|
+
|
|
336
|
+
if isinstance(payload, dict):
|
|
337
|
+
normalized = _normalize_credential_mapping(payload)
|
|
338
|
+
if normalized:
|
|
339
|
+
return normalized
|
|
340
|
+
|
|
341
|
+
mapping: 'dict[str, str]' = {}
|
|
342
|
+
for line in text.splitlines():
|
|
343
|
+
candidate = line.strip()
|
|
344
|
+
if not candidate or candidate.startswith("#"):
|
|
345
|
+
continue
|
|
346
|
+
if candidate.startswith("export "):
|
|
347
|
+
candidate = candidate[len("export ") :]
|
|
348
|
+
if "=" not in candidate:
|
|
349
|
+
continue
|
|
350
|
+
key, value = candidate.split("=", 1)
|
|
351
|
+
mapping[key.strip()] = value.strip().strip('"').strip("'")
|
|
352
|
+
|
|
353
|
+
normalized = _normalize_credential_mapping(mapping)
|
|
354
|
+
if normalized:
|
|
355
|
+
return normalized
|
|
356
|
+
|
|
357
|
+
raise ValidationError(
|
|
358
|
+
"Unable to parse credentials returned by ncs.",
|
|
359
|
+
suggestion="Ensure the ncs process command prints access key id, secret, and security token values.",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _normalize_credential_mapping(payload: 'dict[str, Any]') -> 'dict[str, str] | None':
|
|
364
|
+
candidates = {
|
|
365
|
+
"access_key_id": [
|
|
366
|
+
"AccessKeyId",
|
|
367
|
+
"accessKeyId",
|
|
368
|
+
"access_key_id",
|
|
369
|
+
"ACCESS_KEY_ID",
|
|
370
|
+
"ODPS_STS_ACCESS_KEY_ID",
|
|
371
|
+
"ALIBABA_CLOUD_ACCESS_KEY_ID",
|
|
372
|
+
],
|
|
373
|
+
"access_key_secret": [
|
|
374
|
+
"AccessKeySecret",
|
|
375
|
+
"accessKeySecret",
|
|
376
|
+
"access_key_secret",
|
|
377
|
+
"ACCESS_KEY_SECRET",
|
|
378
|
+
"ODPS_STS_ACCESS_KEY_SECRET",
|
|
379
|
+
"ALIBABA_CLOUD_ACCESS_KEY_SECRET",
|
|
380
|
+
],
|
|
381
|
+
"security_token": [
|
|
382
|
+
"SecurityToken",
|
|
383
|
+
"securityToken",
|
|
384
|
+
"security_token",
|
|
385
|
+
"SECURITY_TOKEN",
|
|
386
|
+
"ODPS_STS_TOKEN",
|
|
387
|
+
"ALIBABA_CLOUD_SECURITY_TOKEN",
|
|
388
|
+
],
|
|
389
|
+
"expires_at": [
|
|
390
|
+
"Expiration",
|
|
391
|
+
"expiration",
|
|
392
|
+
"expires_at",
|
|
393
|
+
"ExpiredTime",
|
|
394
|
+
"expiredTime",
|
|
395
|
+
],
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
normalized: 'dict[str, str]' = {}
|
|
399
|
+
for target, keys in candidates.items():
|
|
400
|
+
for key in keys:
|
|
401
|
+
value = payload.get(key)
|
|
402
|
+
if value:
|
|
403
|
+
normalized[target] = str(value).strip()
|
|
404
|
+
break
|
|
405
|
+
|
|
406
|
+
if {"access_key_id", "access_key_secret", "security_token"} <= set(normalized):
|
|
407
|
+
return normalized
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def list_ncs_accounts(account_type: 'str') -> 'dict[str, Any]':
|
|
412
|
+
normalized = account_type.strip().lower()
|
|
413
|
+
if shutil.which("ncs") is None:
|
|
414
|
+
raise FeatureUnavailableError(
|
|
415
|
+
"ncs CLI is not installed or not available on PATH.",
|
|
416
|
+
suggestion="Install ncs before listing ncs-backed MaxCompute accounts.",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
commands = {
|
|
420
|
+
"user": "ncs list authorizations odpsuser -o custom-columns=BUC_USER_ID:.extension.bucUserId,BUC_USER_TYPE:.extension.bucUserType,BUC_ACCOUNT_NAME:.extension.bucDomainAccount",
|
|
421
|
+
"account": "ncs list authorizations odpsaccount --scenario app -o custom-columns=accountName:.extension.accountName",
|
|
422
|
+
"app": "ncs list authorizations odpsapp -o custom-columns=AppName:.extension.appName",
|
|
423
|
+
}
|
|
424
|
+
if normalized not in commands:
|
|
425
|
+
raise ValidationError("ncs account type must be one of: user, account, app.")
|
|
426
|
+
|
|
427
|
+
result = subprocess.run(
|
|
428
|
+
commands[normalized],
|
|
429
|
+
shell=True,
|
|
430
|
+
stdout=subprocess.PIPE,
|
|
431
|
+
stderr=subprocess.PIPE,
|
|
432
|
+
universal_newlines=True,
|
|
433
|
+
timeout=20,
|
|
434
|
+
)
|
|
435
|
+
if result.returncode != 0:
|
|
436
|
+
raise FeatureUnavailableError(
|
|
437
|
+
"ncs failed while listing available accounts.",
|
|
438
|
+
suggestion=(result.stderr or "Check the ncs CLI setup.").strip(),
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
lines = [line.rstrip() for line in result.stdout.splitlines() if line.strip()]
|
|
442
|
+
return {
|
|
443
|
+
"account_type": normalized,
|
|
444
|
+
"raw_lines": lines,
|
|
445
|
+
"raw_output": result.stdout,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def build_auth_options(config_path: 'Path | None' = None) -> 'list[dict[str, Any]]':
|
|
450
|
+
options = [
|
|
451
|
+
{
|
|
452
|
+
"type": "access_key",
|
|
453
|
+
"description": "Authenticate with a long-lived access key.",
|
|
454
|
+
"command": "auth login --from-env",
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
"type": "sts_token",
|
|
458
|
+
"description": "Authenticate with a temporary STS token.",
|
|
459
|
+
"command": "auth login --security-token <token> --access-id <id> --secret-access-key <secret> --project <project> --endpoint <endpoint>",
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
"type": "ncs",
|
|
463
|
+
"description": "Authenticate through ncs-issued temporary credentials.",
|
|
464
|
+
"command": "auth login-ncs --interactive",
|
|
465
|
+
"requirements": ["ncs CLI"],
|
|
466
|
+
},
|
|
467
|
+
]
|
|
468
|
+
if config_path is not None:
|
|
469
|
+
for item in options:
|
|
470
|
+
item["config_path"] = str(config_path)
|
|
471
|
+
return options
|
maxc_cli/backend/auth.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Auth-related mixin for OdpsBackend."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..exceptions import BackendConnectionError, MaxCError
|
|
6
|
+
from ..helpers import (
|
|
7
|
+
build_odps_identity_payload,
|
|
8
|
+
odps_identity_source,
|
|
9
|
+
quote_table_name,
|
|
10
|
+
translate_odps_error,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthMixin:
|
|
15
|
+
"""Mixin providing authentication and authorization methods."""
|
|
16
|
+
|
|
17
|
+
def whoami_info(self, *, project: 'str | None' = None) -> 'tuple[dict[str, Any], list[str]]':
|
|
18
|
+
"""Get current identity info."""
|
|
19
|
+
target_project = project or self.project
|
|
20
|
+
try:
|
|
21
|
+
result = self.client.execute_security_query("whoami", project=target_project)
|
|
22
|
+
except Exception as exc:
|
|
23
|
+
raise translate_odps_error(exc, "whoami") from exc
|
|
24
|
+
|
|
25
|
+
owner_display_name = result.get("DisplayName") if isinstance(result, dict) else None
|
|
26
|
+
if owner_display_name:
|
|
27
|
+
self._owner_display_name = owner_display_name
|
|
28
|
+
return build_odps_identity_payload(
|
|
29
|
+
client=self.client,
|
|
30
|
+
settings=self.settings,
|
|
31
|
+
allowed_operations=self.config.allowed_operations,
|
|
32
|
+
identity_source=odps_identity_source(self.setting_sources),
|
|
33
|
+
auth_type=getattr(self.resolved_auth, "auth_type", "access_key"),
|
|
34
|
+
token_expires_at=getattr(self.resolved_auth, "token_expires_at", None),
|
|
35
|
+
project=target_project,
|
|
36
|
+
owner_display_name=owner_display_name,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def can_i_info(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
table_name: 'str',
|
|
43
|
+
operation: 'str',
|
|
44
|
+
project: 'str | None' = None,
|
|
45
|
+
) -> 'tuple[dict[str, Any], list[str]]':
|
|
46
|
+
"""Check if operation is allowed on table."""
|
|
47
|
+
normalized_operation = operation.upper().strip()
|
|
48
|
+
target_project = project or self.project
|
|
49
|
+
if normalized_operation not in self.config.allowed_operations:
|
|
50
|
+
return (
|
|
51
|
+
{
|
|
52
|
+
"resource_type": "table",
|
|
53
|
+
"table_name": table_name,
|
|
54
|
+
"project": target_project,
|
|
55
|
+
"operation": normalized_operation,
|
|
56
|
+
"allowed": False,
|
|
57
|
+
"check_mode": "config_allowed_operations",
|
|
58
|
+
"reason": f"Configured allowed operations are limited to {', '.join(self.config.allowed_operations)}.",
|
|
59
|
+
"check_error_code": "PERMISSION_DENIED",
|
|
60
|
+
},
|
|
61
|
+
[],
|
|
62
|
+
)
|
|
63
|
+
if normalized_operation != "SELECT":
|
|
64
|
+
return (
|
|
65
|
+
{
|
|
66
|
+
"resource_type": "table",
|
|
67
|
+
"table_name": table_name,
|
|
68
|
+
"project": target_project,
|
|
69
|
+
"operation": normalized_operation,
|
|
70
|
+
"allowed": False,
|
|
71
|
+
"check_mode": "cli_supported_operations",
|
|
72
|
+
"reason": "This CLI currently supports only SELECT read-path permission checks.",
|
|
73
|
+
"check_error_code": "FEATURE_UNAVAILABLE",
|
|
74
|
+
},
|
|
75
|
+
[],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
safe_table_name = quote_table_name(table_name)
|
|
79
|
+
sql = f"SELECT * FROM {safe_table_name} LIMIT 0"
|
|
80
|
+
try:
|
|
81
|
+
self._get_table(table_name, project=target_project)
|
|
82
|
+
self.client.execute_sql_cost(sql, project=target_project)
|
|
83
|
+
except MaxCError as exc:
|
|
84
|
+
if isinstance(exc, BackendConnectionError):
|
|
85
|
+
raise
|
|
86
|
+
return (
|
|
87
|
+
{
|
|
88
|
+
"resource_type": "table",
|
|
89
|
+
"table_name": table_name,
|
|
90
|
+
"project": target_project,
|
|
91
|
+
"operation": normalized_operation,
|
|
92
|
+
"allowed": False,
|
|
93
|
+
"check_mode": "odps_sql_cost_limit_0",
|
|
94
|
+
"reason": exc.message,
|
|
95
|
+
"check_error_code": exc.error_code,
|
|
96
|
+
},
|
|
97
|
+
[],
|
|
98
|
+
)
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
translated = translate_odps_error(exc)
|
|
101
|
+
if isinstance(translated, BackendConnectionError):
|
|
102
|
+
raise translated
|
|
103
|
+
return (
|
|
104
|
+
{
|
|
105
|
+
"resource_type": "table",
|
|
106
|
+
"table_name": table_name,
|
|
107
|
+
"project": target_project,
|
|
108
|
+
"operation": normalized_operation,
|
|
109
|
+
"allowed": False,
|
|
110
|
+
"check_mode": "odps_sql_cost_limit_0",
|
|
111
|
+
"reason": translated.message,
|
|
112
|
+
"check_error_code": translated.error_code,
|
|
113
|
+
},
|
|
114
|
+
[],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
{
|
|
119
|
+
"resource_type": "table",
|
|
120
|
+
"table_name": table_name,
|
|
121
|
+
"project": target_project,
|
|
122
|
+
"operation": normalized_operation,
|
|
123
|
+
"allowed": True,
|
|
124
|
+
"check_mode": "odps_sql_cost_limit_0",
|
|
125
|
+
"reason": "Metadata access and LIMIT 0 read-path preflight both succeeded.",
|
|
126
|
+
"check_error_code": None,
|
|
127
|
+
},
|
|
128
|
+
[],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _get_owner_display_name(self) -> 'str | None':
|
|
132
|
+
"""Get the current user's display name (e.g., 'ALIYUN$xxx' or 'RAM$xxx')."""
|
|
133
|
+
if self._owner_display_name is not None:
|
|
134
|
+
return self._owner_display_name
|
|
135
|
+
try:
|
|
136
|
+
result = self.client.execute_security_query("whoami", project=self.project)
|
|
137
|
+
display_name = result.get("DisplayName") if isinstance(result, dict) else None
|
|
138
|
+
if display_name:
|
|
139
|
+
self._owner_display_name = display_name
|
|
140
|
+
return display_name
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
return None
|