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/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
@@ -0,0 +1,8 @@
1
+ """Backend module - ODPS implementation."""
2
+
3
+ from .odps import OdpsBackend
4
+
5
+ # Re-export ODPS_ENV_ALIASES for backward compatibility
6
+ from ..helpers import ODPS_ENV_ALIASES
7
+
8
+ __all__ = ["OdpsBackend", "ODPS_ENV_ALIASES"]
@@ -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