systemlink-cli 1.3.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.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/user_click.py
ADDED
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
"""CLI commands for managing SystemLink users via the SystemLink User Service API.
|
|
2
|
+
|
|
3
|
+
Provides CLI commands for listing, creating, updating, deleting, and querying users.
|
|
4
|
+
All commands use Click for robust CLI interfaces and error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import questionary
|
|
16
|
+
|
|
17
|
+
from .cli_utils import paginate_list_output, validate_output_format
|
|
18
|
+
from .utils import (
|
|
19
|
+
ExitCodes,
|
|
20
|
+
format_success,
|
|
21
|
+
get_base_url,
|
|
22
|
+
handle_api_error,
|
|
23
|
+
make_api_request,
|
|
24
|
+
)
|
|
25
|
+
from .workspace_utils import get_workspace_display_name, resolve_workspace_id
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_policy_details(policy_id: str) -> Optional[dict]:
|
|
29
|
+
"""Fetch policy details from the Auth service.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
policy_id: The policy ID to fetch
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Policy details dictionary, or None if not found or no permission
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
url = f"{get_base_url()}/niauth/v1/policies/{policy_id}"
|
|
39
|
+
resp = make_api_request("GET", url, payload=None, handle_errors=False)
|
|
40
|
+
return resp.json()
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
# Check if this is a permission error
|
|
43
|
+
response = getattr(exc, "response", None)
|
|
44
|
+
if response is not None and response.status_code == 401:
|
|
45
|
+
try:
|
|
46
|
+
error_data = response.json()
|
|
47
|
+
if "error" in error_data and error_data["error"].get("name") == "Unauthorized":
|
|
48
|
+
# Return a special marker indicating permission denied
|
|
49
|
+
return {"_permission_error": True, "id": policy_id}
|
|
50
|
+
except (ValueError, KeyError):
|
|
51
|
+
pass
|
|
52
|
+
# If policy fetch fails for other reasons, return None
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_policy_template_details(template_id: str) -> Optional[dict]:
|
|
57
|
+
"""Fetch policy template details from the Auth service.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
template_id: The policy template ID to fetch
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Policy template details dictionary, or None if not found or no permission
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
url = f"{get_base_url()}/niauth/v1/policy-templates/{template_id}"
|
|
67
|
+
resp = make_api_request("GET", url, payload=None, handle_errors=False)
|
|
68
|
+
return resp.json()
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
# Check if this is a permission error
|
|
71
|
+
response = getattr(exc, "response", None)
|
|
72
|
+
if response is not None and response.status_code == 401:
|
|
73
|
+
try:
|
|
74
|
+
error_data = response.json()
|
|
75
|
+
if "error" in error_data and error_data["error"].get("name") == "Unauthorized":
|
|
76
|
+
# Return a special marker indicating permission denied
|
|
77
|
+
return {"_permission_error": True, "id": template_id}
|
|
78
|
+
except (ValueError, KeyError):
|
|
79
|
+
pass
|
|
80
|
+
# If template fetch fails for other reasons, return None
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_policy_template(template_id_or_name: str) -> str:
|
|
85
|
+
"""Resolve a policy template by ID or name.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
template_id_or_name: Either a template ID or a template name
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The policy template ID
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
SystemExit: If template cannot be found or resolved
|
|
95
|
+
"""
|
|
96
|
+
from urllib.parse import urlencode
|
|
97
|
+
|
|
98
|
+
# Try to look up by name first (more user-friendly), then fall back to ID lookup
|
|
99
|
+
base_url = f"{get_base_url()}/niauth/v1/policy-templates"
|
|
100
|
+
query_params = {"name": template_id_or_name}
|
|
101
|
+
url = f"{base_url}?{urlencode(query_params)}"
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
resp = make_api_request("GET", url, payload=None, handle_errors=False)
|
|
105
|
+
resp.raise_for_status()
|
|
106
|
+
data = resp.json()
|
|
107
|
+
|
|
108
|
+
templates = data.get("policyTemplates", [])
|
|
109
|
+
if templates:
|
|
110
|
+
if len(templates) > 1:
|
|
111
|
+
click.echo(
|
|
112
|
+
f"✗ Multiple policy templates found with name '{template_id_or_name}'. "
|
|
113
|
+
"Please use the template ID instead.",
|
|
114
|
+
err=True,
|
|
115
|
+
)
|
|
116
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
117
|
+
|
|
118
|
+
# Found exactly one template by name - return its ID
|
|
119
|
+
template_id = templates[0].get("id")
|
|
120
|
+
if not template_id:
|
|
121
|
+
click.echo(
|
|
122
|
+
f"✗ Policy template '{template_id_or_name}' found but has no ID.",
|
|
123
|
+
err=True,
|
|
124
|
+
)
|
|
125
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
126
|
+
return template_id
|
|
127
|
+
# If no templates found by name, fall through to try as ID
|
|
128
|
+
except Exception:
|
|
129
|
+
# If name lookup fails (network error, etc.), we'll try as ID below
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
# Try as template ID
|
|
133
|
+
try:
|
|
134
|
+
url = f"{get_base_url()}/niauth/v1/policy-templates/{template_id_or_name}"
|
|
135
|
+
resp = make_api_request("GET", url, payload=None, handle_errors=False)
|
|
136
|
+
resp.raise_for_status()
|
|
137
|
+
return template_id_or_name
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
# Check if this is a 404 or other not found error
|
|
140
|
+
response = getattr(exc, "response", None)
|
|
141
|
+
if response is not None and response.status_code == 404:
|
|
142
|
+
click.echo(
|
|
143
|
+
f"✗ Policy template '{template_id_or_name}' not found by ID or name.",
|
|
144
|
+
err=True,
|
|
145
|
+
)
|
|
146
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
147
|
+
# For other errors, propagate through standard error handling
|
|
148
|
+
handle_api_error(exc)
|
|
149
|
+
# This should never be reached, but make mypy happy
|
|
150
|
+
raise RuntimeError(f"Failed to resolve policy template: {template_id_or_name}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _create_workspace_policy_from_template(
|
|
154
|
+
template_id: str, workspace: str, name_hint: Optional[str] = None
|
|
155
|
+
) -> str:
|
|
156
|
+
"""Create a workspace-scoped policy from a template and return its ID."""
|
|
157
|
+
policy_name = name_hint or "workspace-policy"
|
|
158
|
+
# Ensure a reasonably unique name to avoid conflicts
|
|
159
|
+
generated_name = f"{policy_name}-{workspace}-{uuid4().hex[:8]}"
|
|
160
|
+
|
|
161
|
+
payload: Dict[str, Any] = {
|
|
162
|
+
"name": generated_name,
|
|
163
|
+
"type": "custom",
|
|
164
|
+
"templateId": template_id,
|
|
165
|
+
"workspace": workspace,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
url = f"{get_base_url()}/niauth/v1/policies"
|
|
169
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
170
|
+
data = resp.json()
|
|
171
|
+
policy_id = data.get("id")
|
|
172
|
+
if not policy_id:
|
|
173
|
+
raise ValueError("Policy creation did not return an ID")
|
|
174
|
+
return policy_id
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _process_workspace_policies(
|
|
178
|
+
workspace_policies: str, name_hint: Optional[str] = None
|
|
179
|
+
) -> List[str]:
|
|
180
|
+
"""Process workspace-policies string and create policies from templates.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
workspace_policies: Comma-separated list of workspace:templateId pairs (or
|
|
184
|
+
workspace:templateName to lookup by name)
|
|
185
|
+
name_hint: Optional name hint for generated policy names
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
List of created policy IDs
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
SystemExit: If format is invalid, values are empty, or workspace cannot be resolved
|
|
192
|
+
"""
|
|
193
|
+
policy_ids: List[str] = []
|
|
194
|
+
mappings = []
|
|
195
|
+
|
|
196
|
+
for item in workspace_policies.split(","):
|
|
197
|
+
pair = item.strip()
|
|
198
|
+
if not pair:
|
|
199
|
+
continue
|
|
200
|
+
if ":" not in pair:
|
|
201
|
+
click.echo(
|
|
202
|
+
"✗ Invalid workspace-policies format. Use workspace:templateId or "
|
|
203
|
+
"workspace:templateName (e.g., 'myWorkspace:template-123' or "
|
|
204
|
+
"'myWorkspace:MyPolicyTemplate')",
|
|
205
|
+
err=True,
|
|
206
|
+
)
|
|
207
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
208
|
+
ws, template_id_or_name = pair.split(":", 1)
|
|
209
|
+
ws = ws.strip()
|
|
210
|
+
template_id_or_name = template_id_or_name.strip()
|
|
211
|
+
if not ws or not template_id_or_name:
|
|
212
|
+
click.echo(
|
|
213
|
+
"✗ Invalid workspace-policies entry. Both workspace and template ID/name are required.",
|
|
214
|
+
err=True,
|
|
215
|
+
)
|
|
216
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
217
|
+
|
|
218
|
+
# Resolve workspace name to ID
|
|
219
|
+
ws_id = resolve_workspace_id(ws)
|
|
220
|
+
if not ws_id:
|
|
221
|
+
click.echo(
|
|
222
|
+
f"✗ Could not resolve workspace '{ws}'. Please verify the workspace exists.",
|
|
223
|
+
err=True,
|
|
224
|
+
)
|
|
225
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
226
|
+
|
|
227
|
+
mappings.append((ws_id, template_id_or_name))
|
|
228
|
+
|
|
229
|
+
# Create policies for each mapping
|
|
230
|
+
for ws_id, template_id_or_name in mappings:
|
|
231
|
+
try:
|
|
232
|
+
# Resolve template name/ID to actual template ID
|
|
233
|
+
template_id = _resolve_policy_template(template_id_or_name)
|
|
234
|
+
|
|
235
|
+
created_policy_id = _create_workspace_policy_from_template(
|
|
236
|
+
template_id=template_id,
|
|
237
|
+
workspace=ws_id,
|
|
238
|
+
name_hint=name_hint or "user",
|
|
239
|
+
)
|
|
240
|
+
policy_ids.append(created_policy_id)
|
|
241
|
+
except ValueError as e:
|
|
242
|
+
click.echo(f"✗ Error: {str(e)}", err=True)
|
|
243
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
244
|
+
except SystemExit:
|
|
245
|
+
# Re-raise SystemExit (from _resolve_policy_template) to propagate errors
|
|
246
|
+
raise
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
handle_api_error(exc)
|
|
249
|
+
|
|
250
|
+
return policy_ids
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _calculate_policy_column_widths() -> List[int]:
|
|
254
|
+
"""Calculate dynamic column widths for policy statements based on terminal size."""
|
|
255
|
+
try:
|
|
256
|
+
terminal_width = shutil.get_terminal_size().columns
|
|
257
|
+
except Exception:
|
|
258
|
+
terminal_width = 120
|
|
259
|
+
|
|
260
|
+
actions_width = 48
|
|
261
|
+
resources_width = 24
|
|
262
|
+
workspace_width = 18
|
|
263
|
+
border_overhead = 14 # Table borders plus spacing for four columns
|
|
264
|
+
|
|
265
|
+
description_width = terminal_width - (
|
|
266
|
+
actions_width + resources_width + workspace_width + border_overhead
|
|
267
|
+
)
|
|
268
|
+
description_width = max(30, description_width)
|
|
269
|
+
|
|
270
|
+
return [actions_width, resources_width, workspace_width, description_width]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _truncate_cell(value: str, width: int) -> str:
|
|
274
|
+
"""Truncate cell content to fit within the specified width."""
|
|
275
|
+
if len(value) > width:
|
|
276
|
+
return value[: max(0, width - 3)] + "..."
|
|
277
|
+
return value
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _format_policy_table(policies: list) -> None:
|
|
281
|
+
"""Format and display policies in a table format.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
policies: List of policy IDs to expand and display
|
|
285
|
+
"""
|
|
286
|
+
if not policies:
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
click.echo("\nPolicies:")
|
|
290
|
+
click.echo("=" * 80)
|
|
291
|
+
|
|
292
|
+
# Fetch policy details for each policy ID
|
|
293
|
+
policy_details = []
|
|
294
|
+
permission_errors = []
|
|
295
|
+
|
|
296
|
+
for policy_id in policies:
|
|
297
|
+
details = _get_policy_details(policy_id)
|
|
298
|
+
if details:
|
|
299
|
+
# Check if this is a permission error
|
|
300
|
+
if details.get("_permission_error"):
|
|
301
|
+
permission_errors.append(policy_id)
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
# If policy has a templateId, fetch template details too
|
|
305
|
+
template_id = details.get("templateId")
|
|
306
|
+
if template_id:
|
|
307
|
+
template_details = _get_policy_template_details(template_id)
|
|
308
|
+
if template_details:
|
|
309
|
+
# Check if template access failed due to permissions
|
|
310
|
+
if template_details.get("_permission_error"):
|
|
311
|
+
details["template_permission_error"] = True
|
|
312
|
+
else:
|
|
313
|
+
# Merge template details into policy details
|
|
314
|
+
details["template"] = template_details
|
|
315
|
+
# If policy doesn't have statements but template does
|
|
316
|
+
if not details.get("statements") and template_details.get("statements"):
|
|
317
|
+
details["statements"] = template_details.get("statements", [])
|
|
318
|
+
policy_details.append(details)
|
|
319
|
+
else:
|
|
320
|
+
# If we can't fetch details, show just the ID
|
|
321
|
+
policy_details.append({"id": policy_id, "name": "Unknown", "statements": []})
|
|
322
|
+
|
|
323
|
+
if not policy_details and not permission_errors:
|
|
324
|
+
click.echo("No policy details available.")
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
# Show permission errors if any
|
|
328
|
+
if permission_errors:
|
|
329
|
+
click.echo("✗ Access denied to the following policies (insufficient permissions):")
|
|
330
|
+
for policy_id in permission_errors:
|
|
331
|
+
click.echo(f" - Policy ID: {policy_id}")
|
|
332
|
+
if policy_details:
|
|
333
|
+
click.echo() # Add spacing before showing accessible policies
|
|
334
|
+
|
|
335
|
+
# Display policy table
|
|
336
|
+
for i, policy in enumerate(policy_details):
|
|
337
|
+
if i > 0:
|
|
338
|
+
click.echo() # Add spacing between policies
|
|
339
|
+
|
|
340
|
+
policy_name = policy.get("name", "Unknown")
|
|
341
|
+
policy_id = policy.get("id", "Unknown")
|
|
342
|
+
policy_type = policy.get("type", "Unknown")
|
|
343
|
+
|
|
344
|
+
click.echo(f"Policy: {policy_name} (ID: {policy_id}, Type: {policy_type})")
|
|
345
|
+
click.echo("-" * 60)
|
|
346
|
+
|
|
347
|
+
statements = policy.get("statements", [])
|
|
348
|
+
if statements:
|
|
349
|
+
column_widths = _calculate_policy_column_widths()
|
|
350
|
+
headers = ["Actions", "Resources", "Workspace", "Description"]
|
|
351
|
+
|
|
352
|
+
def _border(left: str, junction: str, right: str) -> str:
|
|
353
|
+
parts = [left] + ["─" * (w + 2) for w in column_widths]
|
|
354
|
+
border_line = parts[0] + parts[1]
|
|
355
|
+
for segment in parts[2:]:
|
|
356
|
+
border_line += junction + segment
|
|
357
|
+
return border_line + right
|
|
358
|
+
|
|
359
|
+
click.echo(_border("┌", "┬", "┐"))
|
|
360
|
+
header_parts = ["│"]
|
|
361
|
+
for header, width in zip(headers, column_widths):
|
|
362
|
+
header_parts.append(f" {header:<{width}} │")
|
|
363
|
+
click.echo("".join(header_parts))
|
|
364
|
+
click.echo(_border("├", "┼", "┤"))
|
|
365
|
+
|
|
366
|
+
for statement in statements:
|
|
367
|
+
row_data = [
|
|
368
|
+
", ".join(statement.get("actions", [])),
|
|
369
|
+
", ".join(statement.get("resource", [])),
|
|
370
|
+
statement.get("workspace", ""),
|
|
371
|
+
statement.get("description", "") or "",
|
|
372
|
+
]
|
|
373
|
+
row_parts = ["│"]
|
|
374
|
+
for value, width in zip(row_data, column_widths):
|
|
375
|
+
cell = _truncate_cell(str(value), width)
|
|
376
|
+
row_parts.append(f" {cell:<{width}} │")
|
|
377
|
+
click.echo("".join(row_parts))
|
|
378
|
+
|
|
379
|
+
click.echo(_border("└", "┴", "┘"))
|
|
380
|
+
else:
|
|
381
|
+
click.echo(" No statements defined for this policy.")
|
|
382
|
+
|
|
383
|
+
# Show additional policy info if available
|
|
384
|
+
template = policy.get("template")
|
|
385
|
+
if template:
|
|
386
|
+
template_name = template.get("name", "Unknown")
|
|
387
|
+
template_id = policy.get("templateId", "Unknown")
|
|
388
|
+
click.echo(f" Template: {template_name} (ID: {template_id})")
|
|
389
|
+
template_type = template.get("type")
|
|
390
|
+
if template_type:
|
|
391
|
+
click.echo(f" Template Type: {template_type}")
|
|
392
|
+
elif policy.get("template_permission_error"):
|
|
393
|
+
template_id = policy.get("templateId", "Unknown")
|
|
394
|
+
click.echo(f" Template ID: {template_id} (access denied - insufficient permissions)")
|
|
395
|
+
elif policy.get("templateId"):
|
|
396
|
+
click.echo(f" Template ID: {policy.get('templateId')} (details unavailable)")
|
|
397
|
+
|
|
398
|
+
if policy.get("builtIn"):
|
|
399
|
+
click.echo(" Built-in: Yes")
|
|
400
|
+
|
|
401
|
+
workspace = policy.get("workspace")
|
|
402
|
+
if workspace:
|
|
403
|
+
workspace_name = get_workspace_display_name(workspace)
|
|
404
|
+
if workspace_name and workspace_name != workspace:
|
|
405
|
+
click.echo(f" Workspace: {workspace_name} (ID: {workspace})")
|
|
406
|
+
else:
|
|
407
|
+
click.echo(f" Workspace: {workspace}")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _query_all_users(
|
|
411
|
+
filter_str: Optional[str] = None,
|
|
412
|
+
sortby: str = "firstName",
|
|
413
|
+
order: str = "asc",
|
|
414
|
+
include_disabled: bool = False,
|
|
415
|
+
) -> list:
|
|
416
|
+
"""Query all users from the API with server-side pagination using continuation tokens.
|
|
417
|
+
|
|
418
|
+
Uses proper Dynamic LINQ filter syntax as specified in the User Service OpenAPI spec.
|
|
419
|
+
Pagination uses continuationToken (not skip/take) as per API specification.
|
|
420
|
+
Filter syntax follows SystemLink User Service API specification:
|
|
421
|
+
- Uses 'and'/'or' operators (not '&&'/'||')
|
|
422
|
+
- String values in double quotes
|
|
423
|
+
- Uses 'status = "active"' for filtering disabled users
|
|
424
|
+
|
|
425
|
+
TODO: Follow this pattern for other API clients that support continuation tokens
|
|
426
|
+
Reference: https://dev-api.lifecyclesolutions.ni.com/niuser/swagger/v1/niuser.yaml
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
filter_str: Filter expression for users
|
|
430
|
+
sortby: Field to sort by
|
|
431
|
+
order: Sort order ('asc' or 'desc')
|
|
432
|
+
include_disabled: Whether to include disabled users
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
List of all users
|
|
436
|
+
"""
|
|
437
|
+
url = f"{get_base_url()}/niuser/v1/users/query"
|
|
438
|
+
all_users = []
|
|
439
|
+
continuation_token = None
|
|
440
|
+
page_size = 100 # API maximum take limit is 100
|
|
441
|
+
|
|
442
|
+
# Build the base filter - combine user filter with active status filter if needed
|
|
443
|
+
combined_filter = filter_str
|
|
444
|
+
if not include_disabled:
|
|
445
|
+
# Add active status filter to the query using correct Dynamic LINQ syntax
|
|
446
|
+
# Note: User API uses 'status' field with values 'pending' or 'active'
|
|
447
|
+
active_filter = 'status = "active"'
|
|
448
|
+
if filter_str:
|
|
449
|
+
combined_filter = f"({filter_str}) and {active_filter}"
|
|
450
|
+
else:
|
|
451
|
+
combined_filter = active_filter
|
|
452
|
+
|
|
453
|
+
while True:
|
|
454
|
+
payload = {
|
|
455
|
+
"take": page_size,
|
|
456
|
+
"sortby": sortby,
|
|
457
|
+
"order": "ascending" if order == "asc" else "descending",
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if combined_filter:
|
|
461
|
+
payload["filter"] = combined_filter
|
|
462
|
+
|
|
463
|
+
if continuation_token:
|
|
464
|
+
payload["continuationToken"] = continuation_token
|
|
465
|
+
|
|
466
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
467
|
+
data = resp.json()
|
|
468
|
+
users = data.get("users", [])
|
|
469
|
+
|
|
470
|
+
if not users:
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
all_users.extend(users)
|
|
474
|
+
|
|
475
|
+
# Check for continuation token to get next page
|
|
476
|
+
continuation_token = data.get("continuationToken")
|
|
477
|
+
if not continuation_token:
|
|
478
|
+
break # No more pages available
|
|
479
|
+
|
|
480
|
+
return all_users
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def register_user_commands(cli: click.Group) -> None:
|
|
484
|
+
"""Register CLI commands for managing SystemLink users."""
|
|
485
|
+
|
|
486
|
+
@cli.group()
|
|
487
|
+
def user() -> None:
|
|
488
|
+
"""Manage SystemLink users."""
|
|
489
|
+
pass
|
|
490
|
+
|
|
491
|
+
@user.command(name="list")
|
|
492
|
+
@click.option(
|
|
493
|
+
"--workspace",
|
|
494
|
+
"-w",
|
|
495
|
+
help="Filter by workspace name or ID",
|
|
496
|
+
)
|
|
497
|
+
@click.option(
|
|
498
|
+
"--take",
|
|
499
|
+
"-t",
|
|
500
|
+
type=int,
|
|
501
|
+
default=25,
|
|
502
|
+
show_default=True,
|
|
503
|
+
help="Maximum number of users to return",
|
|
504
|
+
)
|
|
505
|
+
@click.option(
|
|
506
|
+
"--format",
|
|
507
|
+
"-f",
|
|
508
|
+
type=click.Choice(["table", "json"]),
|
|
509
|
+
default="table",
|
|
510
|
+
show_default=True,
|
|
511
|
+
help="Output format",
|
|
512
|
+
)
|
|
513
|
+
@click.option(
|
|
514
|
+
"--include-disabled",
|
|
515
|
+
is_flag=True,
|
|
516
|
+
help="Include disabled users in the results",
|
|
517
|
+
)
|
|
518
|
+
@click.option(
|
|
519
|
+
"--sortby",
|
|
520
|
+
type=click.Choice(["firstName", "lastName", "email"]),
|
|
521
|
+
default="firstName",
|
|
522
|
+
show_default=True,
|
|
523
|
+
help="Sort users by field",
|
|
524
|
+
)
|
|
525
|
+
@click.option(
|
|
526
|
+
"--order",
|
|
527
|
+
type=click.Choice(["asc", "desc"]),
|
|
528
|
+
default="asc",
|
|
529
|
+
show_default=True,
|
|
530
|
+
help="Sort order",
|
|
531
|
+
)
|
|
532
|
+
@click.option(
|
|
533
|
+
"--filter",
|
|
534
|
+
help="Search text to filter users by first name, last name, or email",
|
|
535
|
+
)
|
|
536
|
+
@click.option(
|
|
537
|
+
"--type",
|
|
538
|
+
"user_type",
|
|
539
|
+
type=click.Choice(["all", "user", "service"]),
|
|
540
|
+
default="all",
|
|
541
|
+
show_default=True,
|
|
542
|
+
help="Filter by account type",
|
|
543
|
+
)
|
|
544
|
+
def list_users(
|
|
545
|
+
workspace: Optional[str] = None,
|
|
546
|
+
take: int = 25,
|
|
547
|
+
format: str = "table",
|
|
548
|
+
include_disabled: bool = False,
|
|
549
|
+
sortby: str = "firstName",
|
|
550
|
+
order: str = "asc",
|
|
551
|
+
filter: Optional[str] = None,
|
|
552
|
+
user_type: str = "all",
|
|
553
|
+
) -> None:
|
|
554
|
+
"""List users with optional filtering and sorting."""
|
|
555
|
+
format_output = validate_output_format(format)
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
# Build search filter from user's filter text
|
|
559
|
+
# Convert simple search text to Dynamic LINQ query across name/email fields
|
|
560
|
+
search_filter = None
|
|
561
|
+
if filter:
|
|
562
|
+
# Escape quotes in the search text
|
|
563
|
+
escaped_filter = filter.replace('"', '\\"')
|
|
564
|
+
# Build a LINQ query that searches firstName, lastName, and email
|
|
565
|
+
search_filter = (
|
|
566
|
+
f'firstName.Contains("{escaped_filter}") or '
|
|
567
|
+
f'lastName.Contains("{escaped_filter}") or '
|
|
568
|
+
f'email.Contains("{escaped_filter}")'
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# Build type filter if specified
|
|
572
|
+
type_filter = None
|
|
573
|
+
if user_type != "all":
|
|
574
|
+
type_filter = f'type = "{user_type}"'
|
|
575
|
+
|
|
576
|
+
# For JSON format, we can respect the take parameter and use server-side pagination
|
|
577
|
+
# For table format, we fetch all users and do client-side pagination for better UX
|
|
578
|
+
if format_output.lower() == "json":
|
|
579
|
+
# Use server-side pagination for JSON output
|
|
580
|
+
url = f"{get_base_url()}/niuser/v1/users/query"
|
|
581
|
+
|
|
582
|
+
# Build the filter - combine search filter with active status filter if needed
|
|
583
|
+
combined_filter = search_filter
|
|
584
|
+
if not include_disabled:
|
|
585
|
+
# Add active status filter to the query using correct Dynamic LINQ syntax
|
|
586
|
+
# Note: User API uses 'status' field with values 'pending' or 'active'
|
|
587
|
+
active_filter = 'status = "active"'
|
|
588
|
+
if combined_filter:
|
|
589
|
+
combined_filter = f"({combined_filter}) and {active_filter}"
|
|
590
|
+
else:
|
|
591
|
+
combined_filter = active_filter
|
|
592
|
+
|
|
593
|
+
# Add type filter
|
|
594
|
+
if type_filter:
|
|
595
|
+
if combined_filter:
|
|
596
|
+
combined_filter = f"({combined_filter}) and {type_filter}"
|
|
597
|
+
else:
|
|
598
|
+
combined_filter = type_filter
|
|
599
|
+
|
|
600
|
+
payload = {
|
|
601
|
+
"take": take,
|
|
602
|
+
"sortby": sortby,
|
|
603
|
+
"order": "ascending" if order == "asc" else "descending",
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if combined_filter:
|
|
607
|
+
payload["filter"] = combined_filter
|
|
608
|
+
|
|
609
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
610
|
+
data = resp.json()
|
|
611
|
+
users = data.get("users", [])
|
|
612
|
+
|
|
613
|
+
click.echo(json.dumps(users, indent=2))
|
|
614
|
+
return
|
|
615
|
+
else:
|
|
616
|
+
# For table format, fetch all users for proper client-side pagination
|
|
617
|
+
# Combine filters for table output
|
|
618
|
+
combined_filter_for_table = search_filter
|
|
619
|
+
if type_filter:
|
|
620
|
+
if combined_filter_for_table:
|
|
621
|
+
combined_filter_for_table = (
|
|
622
|
+
f"({combined_filter_for_table}) and {type_filter}"
|
|
623
|
+
)
|
|
624
|
+
else:
|
|
625
|
+
combined_filter_for_table = type_filter
|
|
626
|
+
|
|
627
|
+
all_users = _query_all_users(
|
|
628
|
+
filter_str=combined_filter_for_table,
|
|
629
|
+
sortby=sortby,
|
|
630
|
+
order=order,
|
|
631
|
+
include_disabled=include_disabled,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
def user_formatter(user: dict) -> list:
|
|
635
|
+
status = "Active" if user.get("active", True) else "Inactive"
|
|
636
|
+
acct_type = user.get("type", "user")
|
|
637
|
+
type_display = "Service" if acct_type == "service" else "User"
|
|
638
|
+
return [
|
|
639
|
+
user.get("id", ""),
|
|
640
|
+
user.get("firstName", ""),
|
|
641
|
+
user.get("lastName", ""),
|
|
642
|
+
user.get("email", "") or "-",
|
|
643
|
+
type_display,
|
|
644
|
+
status,
|
|
645
|
+
]
|
|
646
|
+
|
|
647
|
+
# Use client-side pagination with all fetched users
|
|
648
|
+
paginate_list_output(
|
|
649
|
+
items=all_users,
|
|
650
|
+
page_size=take,
|
|
651
|
+
format_output=format_output,
|
|
652
|
+
formatter_func=user_formatter,
|
|
653
|
+
headers=["ID", "First Name", "Last Name", "Email", "Type", "Status"],
|
|
654
|
+
column_widths=[36, 15, 15, 25, 10, 10],
|
|
655
|
+
empty_message="No users found.",
|
|
656
|
+
total_label="user(s)",
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
except Exception as exc:
|
|
660
|
+
handle_api_error(exc)
|
|
661
|
+
|
|
662
|
+
@user.command(name="get")
|
|
663
|
+
@click.option("--id", "-i", "user_id", help="User ID to retrieve")
|
|
664
|
+
@click.option("--email", "user_email", help="User email to retrieve")
|
|
665
|
+
@click.option(
|
|
666
|
+
"--format",
|
|
667
|
+
"-f",
|
|
668
|
+
type=click.Choice(["table", "json"], case_sensitive=False),
|
|
669
|
+
default="table",
|
|
670
|
+
show_default=True,
|
|
671
|
+
help="Output format: table or json",
|
|
672
|
+
)
|
|
673
|
+
def get_user(
|
|
674
|
+
user_id: Optional[str] = None, user_email: Optional[str] = None, format: str = "table"
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Get details for a specific user by ID or email."""
|
|
677
|
+
if not user_id and not user_email:
|
|
678
|
+
click.echo("✗ Must provide either --id or --email.", err=True)
|
|
679
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
680
|
+
|
|
681
|
+
if user_id and user_email:
|
|
682
|
+
click.echo("✗ Cannot specify both --id and --email. Choose one.", err=True)
|
|
683
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
user = None
|
|
687
|
+
|
|
688
|
+
if user_email:
|
|
689
|
+
# Search for user by email using query endpoint
|
|
690
|
+
query_url = f"{get_base_url()}/niuser/v1/users/query"
|
|
691
|
+
query_payload = {"filter": f'email = "{user_email}"', "take": 1}
|
|
692
|
+
|
|
693
|
+
query_resp = make_api_request(
|
|
694
|
+
"POST", query_url, payload=query_payload, handle_errors=False
|
|
695
|
+
)
|
|
696
|
+
query_data = query_resp.json()
|
|
697
|
+
users = query_data.get("users", [])
|
|
698
|
+
|
|
699
|
+
if not users:
|
|
700
|
+
click.echo(f"✗ User with email '{user_email}' not found.", err=True)
|
|
701
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
702
|
+
|
|
703
|
+
if len(users) > 1:
|
|
704
|
+
click.echo(
|
|
705
|
+
f"✗ Multiple users found with email '{user_email}'. This should not happen.",
|
|
706
|
+
err=True,
|
|
707
|
+
)
|
|
708
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
709
|
+
|
|
710
|
+
user = users[0]
|
|
711
|
+
else:
|
|
712
|
+
# Get user by ID using direct endpoint
|
|
713
|
+
url = f"{get_base_url()}/niuser/v1/users/{user_id}"
|
|
714
|
+
resp = make_api_request("GET", url, payload=None, handle_errors=False)
|
|
715
|
+
user = resp.json()
|
|
716
|
+
|
|
717
|
+
if format.lower() == "json":
|
|
718
|
+
# For JSON output, optionally expand policies
|
|
719
|
+
if user.get("policies"):
|
|
720
|
+
expanded_policies = []
|
|
721
|
+
policy_permission_errors = []
|
|
722
|
+
|
|
723
|
+
for policy_id in user.get("policies", []):
|
|
724
|
+
policy_details = _get_policy_details(policy_id)
|
|
725
|
+
if policy_details:
|
|
726
|
+
# Check if this is a permission error
|
|
727
|
+
if policy_details.get("_permission_error"):
|
|
728
|
+
policy_permission_errors.append(policy_id)
|
|
729
|
+
continue
|
|
730
|
+
|
|
731
|
+
# If policy has a templateId, fetch template details too
|
|
732
|
+
template_id = policy_details.get("templateId")
|
|
733
|
+
if template_id:
|
|
734
|
+
template_details = _get_policy_template_details(template_id)
|
|
735
|
+
if template_details:
|
|
736
|
+
# Check if template access failed due to permissions
|
|
737
|
+
if template_details.get("_permission_error"):
|
|
738
|
+
policy_details["template_permission_error"] = True
|
|
739
|
+
policy_details["templateId"] = template_id
|
|
740
|
+
else:
|
|
741
|
+
# Include template details in the expanded policy
|
|
742
|
+
policy_details["template"] = template_details
|
|
743
|
+
# If policy doesn't have statements but template does
|
|
744
|
+
if not policy_details.get(
|
|
745
|
+
"statements"
|
|
746
|
+
) and template_details.get("statements"):
|
|
747
|
+
policy_details["statements"] = template_details.get(
|
|
748
|
+
"statements", []
|
|
749
|
+
)
|
|
750
|
+
expanded_policies.append(policy_details)
|
|
751
|
+
else:
|
|
752
|
+
expanded_policies.append({"id": policy_id, "name": "Unknown"})
|
|
753
|
+
|
|
754
|
+
user["expanded_policies"] = expanded_policies
|
|
755
|
+
if policy_permission_errors:
|
|
756
|
+
user["policy_permission_errors"] = policy_permission_errors
|
|
757
|
+
|
|
758
|
+
click.echo(json.dumps(user, indent=2))
|
|
759
|
+
return
|
|
760
|
+
|
|
761
|
+
# Table format
|
|
762
|
+
user_type = user.get("type", "user")
|
|
763
|
+
type_display = "Service Account" if user_type == "service" else "User"
|
|
764
|
+
click.echo(f"{type_display} Details:")
|
|
765
|
+
click.echo("=" * 50)
|
|
766
|
+
click.echo(f"ID: {user.get('id', 'N/A')}")
|
|
767
|
+
click.echo(f"Type: {type_display}")
|
|
768
|
+
click.echo(f"First Name: {user.get('firstName', 'N/A')}")
|
|
769
|
+
click.echo(f"Last Name: {user.get('lastName', 'N/A')}")
|
|
770
|
+
# Only show user-specific fields for non-service accounts
|
|
771
|
+
if user_type != "service":
|
|
772
|
+
click.echo(f"Email: {user.get('email', 'N/A')}")
|
|
773
|
+
click.echo(f"Phone: {user.get('phone', 'N/A')}")
|
|
774
|
+
click.echo(f"Login: {user.get('login', 'N/A')}")
|
|
775
|
+
click.echo(f"NIUA ID: {user.get('niuaId', 'N/A')}")
|
|
776
|
+
click.echo(f"Status: {user.get('status', 'N/A')}")
|
|
777
|
+
click.echo(f"Organization ID: {user.get('orgId', 'N/A')}")
|
|
778
|
+
click.echo(f"Created: {user.get('created', 'N/A')}")
|
|
779
|
+
click.echo(f"Updated: {user.get('updated', 'N/A')}")
|
|
780
|
+
|
|
781
|
+
policies = user.get("policies", [])
|
|
782
|
+
if policies:
|
|
783
|
+
_format_policy_table(policies)
|
|
784
|
+
|
|
785
|
+
keywords = user.get("keywords", [])
|
|
786
|
+
if keywords:
|
|
787
|
+
click.echo(f"\nKeywords: {', '.join(keywords)}")
|
|
788
|
+
|
|
789
|
+
properties = user.get("properties", {})
|
|
790
|
+
if properties:
|
|
791
|
+
click.echo("\nProperties:")
|
|
792
|
+
for key, value in properties.items():
|
|
793
|
+
click.echo(f" {key}: {value}")
|
|
794
|
+
|
|
795
|
+
except Exception as exc:
|
|
796
|
+
# Check if this is a permission error for user access
|
|
797
|
+
response = getattr(exc, "response", None)
|
|
798
|
+
if response is not None and response.status_code == 401:
|
|
799
|
+
try:
|
|
800
|
+
error_data = response.json()
|
|
801
|
+
if "error" in error_data and error_data["error"].get("name") == "Unauthorized":
|
|
802
|
+
click.echo(
|
|
803
|
+
"✗ Access denied to user information (insufficient permissions).",
|
|
804
|
+
err=True,
|
|
805
|
+
)
|
|
806
|
+
sys.exit(ExitCodes.PERMISSION_DENIED)
|
|
807
|
+
except (ValueError, KeyError):
|
|
808
|
+
pass
|
|
809
|
+
|
|
810
|
+
# Fall back to standard error handling
|
|
811
|
+
handle_api_error(exc)
|
|
812
|
+
|
|
813
|
+
@user.command(name="create")
|
|
814
|
+
@click.option(
|
|
815
|
+
"--type",
|
|
816
|
+
"user_type",
|
|
817
|
+
type=click.Choice(["user", "service"]),
|
|
818
|
+
help="Type of account: 'user' for human users, 'service' for API/automation accounts",
|
|
819
|
+
)
|
|
820
|
+
@click.option("--first-name", help="User's first name (or service account name)")
|
|
821
|
+
@click.option(
|
|
822
|
+
"--last-name",
|
|
823
|
+
help="User's last name (defaults to 'ServiceAccount' for service accounts)",
|
|
824
|
+
)
|
|
825
|
+
@click.option("--email", help="User's email address (not valid for service accounts)")
|
|
826
|
+
@click.option("--niua-id", help="User's NIUA ID (not valid for service accounts)")
|
|
827
|
+
@click.option("--login", help="User's login name (not valid for service accounts)")
|
|
828
|
+
@click.option("--phone", help="User's phone number (not valid for service accounts)")
|
|
829
|
+
@click.option("--accepted-tos", is_flag=True, help="Whether user has accepted terms of service")
|
|
830
|
+
@click.option(
|
|
831
|
+
"--policies",
|
|
832
|
+
help="Comma-separated list of policy IDs to assign to the user",
|
|
833
|
+
)
|
|
834
|
+
@click.option(
|
|
835
|
+
"--policy",
|
|
836
|
+
help="Single policy ID to assign to the user",
|
|
837
|
+
)
|
|
838
|
+
@click.option(
|
|
839
|
+
"--workspace-policies",
|
|
840
|
+
help=(
|
|
841
|
+
"Comma-separated list of workspace:template entries (workspace can be name or"
|
|
842
|
+
" ID; template can be template ID or template name); a policy will be created"
|
|
843
|
+
" per workspace from the template and assigned to the user"
|
|
844
|
+
),
|
|
845
|
+
)
|
|
846
|
+
@click.option(
|
|
847
|
+
"--keywords",
|
|
848
|
+
help="Comma-separated list of keywords to associate with the user",
|
|
849
|
+
)
|
|
850
|
+
@click.option(
|
|
851
|
+
"--properties",
|
|
852
|
+
help="JSON string of key-value properties to associate with the user",
|
|
853
|
+
)
|
|
854
|
+
def create_user(
|
|
855
|
+
user_type: Optional[str] = None,
|
|
856
|
+
first_name: Optional[str] = None,
|
|
857
|
+
last_name: Optional[str] = None,
|
|
858
|
+
email: Optional[str] = None,
|
|
859
|
+
niua_id: Optional[str] = None,
|
|
860
|
+
login: Optional[str] = None,
|
|
861
|
+
phone: Optional[str] = None,
|
|
862
|
+
accepted_tos: bool = False,
|
|
863
|
+
policy: Optional[str] = None,
|
|
864
|
+
policies: Optional[str] = None,
|
|
865
|
+
workspace_policies: Optional[str] = None,
|
|
866
|
+
keywords: Optional[str] = None,
|
|
867
|
+
properties: Optional[str] = None,
|
|
868
|
+
) -> None:
|
|
869
|
+
"""Create a new user or service account.
|
|
870
|
+
|
|
871
|
+
For regular users (--type user):
|
|
872
|
+
If niuaId is not provided, it will default to the email address.
|
|
873
|
+
Required fields (first name, last name, email) will be prompted for.
|
|
874
|
+
|
|
875
|
+
For service accounts (--type service):
|
|
876
|
+
First name is required. Last name defaults to "ServiceAccount" if not provided.
|
|
877
|
+
Email, phone, niuaId, and login are not valid for service accounts.
|
|
878
|
+
"""
|
|
879
|
+
from .utils import check_readonly_mode
|
|
880
|
+
|
|
881
|
+
check_readonly_mode("create a user")
|
|
882
|
+
|
|
883
|
+
# If user_type wasn't specified via CLI, prompt for it first
|
|
884
|
+
if user_type is None:
|
|
885
|
+
user_type = questionary.select(
|
|
886
|
+
"Account type?",
|
|
887
|
+
choices=["user", "service"],
|
|
888
|
+
default="user",
|
|
889
|
+
).ask()
|
|
890
|
+
if user_type is None:
|
|
891
|
+
raise click.Abort()
|
|
892
|
+
|
|
893
|
+
is_service_account = user_type == "service"
|
|
894
|
+
|
|
895
|
+
# Validate that service accounts don't have invalid fields
|
|
896
|
+
if is_service_account:
|
|
897
|
+
invalid_fields = []
|
|
898
|
+
if email:
|
|
899
|
+
invalid_fields.append("--email")
|
|
900
|
+
if niua_id:
|
|
901
|
+
invalid_fields.append("--niua-id")
|
|
902
|
+
if login:
|
|
903
|
+
invalid_fields.append("--login")
|
|
904
|
+
if phone:
|
|
905
|
+
invalid_fields.append("--phone")
|
|
906
|
+
|
|
907
|
+
if invalid_fields:
|
|
908
|
+
click.echo(
|
|
909
|
+
f"✗ Service accounts cannot have: {', '.join(invalid_fields)}",
|
|
910
|
+
err=True,
|
|
911
|
+
)
|
|
912
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
913
|
+
|
|
914
|
+
# Prompt for required fields if not provided
|
|
915
|
+
if not first_name:
|
|
916
|
+
prompt_text = "Service account name" if is_service_account else "User's first name"
|
|
917
|
+
first_name = click.prompt(prompt_text, type=str)
|
|
918
|
+
|
|
919
|
+
# lastName is required for all account types
|
|
920
|
+
# For service accounts, default to "ServiceAccount" if not provided
|
|
921
|
+
if not last_name:
|
|
922
|
+
if is_service_account:
|
|
923
|
+
last_name = "ServiceAccount"
|
|
924
|
+
else:
|
|
925
|
+
last_name = click.prompt("User's last name", type=str)
|
|
926
|
+
|
|
927
|
+
if not is_service_account:
|
|
928
|
+
# Regular user also requires email
|
|
929
|
+
if not email:
|
|
930
|
+
email = click.prompt("User's email address", type=str)
|
|
931
|
+
|
|
932
|
+
# Validate email format (basic validation)
|
|
933
|
+
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
934
|
+
if email and not re.match(email_pattern, email):
|
|
935
|
+
click.echo("✗ Invalid email format.", err=True)
|
|
936
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
937
|
+
|
|
938
|
+
# If niua_id is not provided, default it to the email
|
|
939
|
+
if not niua_id:
|
|
940
|
+
niua_id = email
|
|
941
|
+
|
|
942
|
+
url = f"{get_base_url()}/niuser/v1/users"
|
|
943
|
+
|
|
944
|
+
# Build user payload
|
|
945
|
+
payload: Dict[str, Any] = {
|
|
946
|
+
"type": user_type,
|
|
947
|
+
"firstName": first_name,
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
# lastName is required for all account types
|
|
951
|
+
payload["lastName"] = last_name
|
|
952
|
+
|
|
953
|
+
# Add additional fields for regular users
|
|
954
|
+
if not is_service_account:
|
|
955
|
+
payload["email"] = email
|
|
956
|
+
payload["niuaId"] = niua_id
|
|
957
|
+
payload["acceptedToS"] = accepted_tos
|
|
958
|
+
if login:
|
|
959
|
+
payload["login"] = login
|
|
960
|
+
if phone:
|
|
961
|
+
payload["phone"] = phone
|
|
962
|
+
|
|
963
|
+
policy_ids: list[str] = []
|
|
964
|
+
if policy:
|
|
965
|
+
policy_ids.append(policy.strip())
|
|
966
|
+
if policies:
|
|
967
|
+
policy_ids.extend([p.strip() for p in policies.split(",")])
|
|
968
|
+
|
|
969
|
+
if workspace_policies:
|
|
970
|
+
policy_ids.extend(_process_workspace_policies(workspace_policies, first_name))
|
|
971
|
+
|
|
972
|
+
if policy_ids:
|
|
973
|
+
# de-duplicate while preserving order
|
|
974
|
+
seen: set[str] = set()
|
|
975
|
+
deduped: list[str] = []
|
|
976
|
+
for pid in policy_ids:
|
|
977
|
+
if pid and pid not in seen:
|
|
978
|
+
seen.add(pid)
|
|
979
|
+
deduped.append(pid)
|
|
980
|
+
payload["policies"] = deduped
|
|
981
|
+
|
|
982
|
+
if keywords:
|
|
983
|
+
payload["keywords"] = [k.strip() for k in keywords.split(",")]
|
|
984
|
+
|
|
985
|
+
# Start properties with provided JSON, if any
|
|
986
|
+
props_obj: Dict[str, Any] = {}
|
|
987
|
+
if properties:
|
|
988
|
+
try:
|
|
989
|
+
props_obj = json.loads(properties)
|
|
990
|
+
except json.JSONDecodeError:
|
|
991
|
+
click.echo("✗ Invalid JSON format for properties.", err=True)
|
|
992
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
993
|
+
|
|
994
|
+
if props_obj:
|
|
995
|
+
payload["properties"] = props_obj
|
|
996
|
+
|
|
997
|
+
try:
|
|
998
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
999
|
+
user = resp.json()
|
|
1000
|
+
user_id = user.get("id")
|
|
1001
|
+
|
|
1002
|
+
if is_service_account:
|
|
1003
|
+
format_success(
|
|
1004
|
+
"Service account created",
|
|
1005
|
+
{"ID": user_id, "Name": user.get("firstName")},
|
|
1006
|
+
)
|
|
1007
|
+
else:
|
|
1008
|
+
format_success("User created", {"ID": user_id, "Email": user.get("email")})
|
|
1009
|
+
|
|
1010
|
+
except Exception as exc:
|
|
1011
|
+
# Try to parse API error response for better error messages
|
|
1012
|
+
# Check if this is an HTTP error with JSON response
|
|
1013
|
+
response = getattr(exc, "response", None)
|
|
1014
|
+
if response is not None:
|
|
1015
|
+
try:
|
|
1016
|
+
error_data = response.json()
|
|
1017
|
+
if "error" in error_data:
|
|
1018
|
+
error_info = error_data["error"]
|
|
1019
|
+
api_message = error_info.get("message", "")
|
|
1020
|
+
error_name = error_info.get("name", "")
|
|
1021
|
+
|
|
1022
|
+
if api_message:
|
|
1023
|
+
click.echo(f"✗ {api_message}", err=True)
|
|
1024
|
+
if error_name == "Auth.ValidationError":
|
|
1025
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1026
|
+
else:
|
|
1027
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1028
|
+
except (ValueError, KeyError, AttributeError):
|
|
1029
|
+
# Fall back to original error if we can't parse the JSON
|
|
1030
|
+
pass
|
|
1031
|
+
|
|
1032
|
+
# Fall back to standard error handling
|
|
1033
|
+
handle_api_error(exc)
|
|
1034
|
+
|
|
1035
|
+
@user.command(name="update")
|
|
1036
|
+
@click.option("--id", "-i", "user_id", required=True, help="User ID to update")
|
|
1037
|
+
@click.option("--first-name", help="User's first name (or service account name)")
|
|
1038
|
+
@click.option("--last-name", help="User's last name")
|
|
1039
|
+
@click.option("--email", help="User's email address (not valid for service accounts)")
|
|
1040
|
+
@click.option("--login", help="User's login name (not valid for service accounts)")
|
|
1041
|
+
@click.option("--phone", help="User's phone number (not valid for service accounts)")
|
|
1042
|
+
@click.option("--niua-id", help="User's NIUA ID (not valid for service accounts)")
|
|
1043
|
+
@click.option(
|
|
1044
|
+
"--accepted-tos",
|
|
1045
|
+
type=click.Choice(["true", "false"]),
|
|
1046
|
+
help="Whether user has accepted terms of service",
|
|
1047
|
+
)
|
|
1048
|
+
@click.option(
|
|
1049
|
+
"--policy",
|
|
1050
|
+
help="Single policy ID to assign to the user",
|
|
1051
|
+
)
|
|
1052
|
+
@click.option(
|
|
1053
|
+
"--policies",
|
|
1054
|
+
help="Comma-separated list of policy IDs to assign to the user",
|
|
1055
|
+
)
|
|
1056
|
+
@click.option(
|
|
1057
|
+
"--workspace-policies",
|
|
1058
|
+
help=(
|
|
1059
|
+
"Comma-separated list of workspace:template entries (workspace can be name or"
|
|
1060
|
+
" ID; template can be template ID or template name); a policy will be created"
|
|
1061
|
+
" per workspace from the template and assigned to the user"
|
|
1062
|
+
),
|
|
1063
|
+
)
|
|
1064
|
+
@click.option(
|
|
1065
|
+
"--keywords",
|
|
1066
|
+
help="Comma-separated list of keywords to associate with the user",
|
|
1067
|
+
)
|
|
1068
|
+
@click.option(
|
|
1069
|
+
"--properties",
|
|
1070
|
+
help="JSON string of key-value properties to associate with the user",
|
|
1071
|
+
)
|
|
1072
|
+
def update_user(
|
|
1073
|
+
user_id: str,
|
|
1074
|
+
first_name: Optional[str] = None,
|
|
1075
|
+
last_name: Optional[str] = None,
|
|
1076
|
+
email: Optional[str] = None,
|
|
1077
|
+
login: Optional[str] = None,
|
|
1078
|
+
phone: Optional[str] = None,
|
|
1079
|
+
niua_id: Optional[str] = None,
|
|
1080
|
+
accepted_tos: Optional[str] = None,
|
|
1081
|
+
policy: Optional[str] = None,
|
|
1082
|
+
policies: Optional[str] = None,
|
|
1083
|
+
workspace_policies: Optional[str] = None,
|
|
1084
|
+
keywords: Optional[str] = None,
|
|
1085
|
+
properties: Optional[str] = None,
|
|
1086
|
+
) -> None:
|
|
1087
|
+
"""Update an existing user or service account."""
|
|
1088
|
+
from .utils import check_readonly_mode
|
|
1089
|
+
|
|
1090
|
+
check_readonly_mode("update a user")
|
|
1091
|
+
|
|
1092
|
+
# First, fetch the user to check if it's a service account
|
|
1093
|
+
get_url = f"{get_base_url()}/niuser/v1/users/{user_id}"
|
|
1094
|
+
try:
|
|
1095
|
+
get_resp = make_api_request("GET", get_url, payload=None, handle_errors=False)
|
|
1096
|
+
existing_user = get_resp.json()
|
|
1097
|
+
is_service_account = existing_user.get("type") == "service"
|
|
1098
|
+
except Exception:
|
|
1099
|
+
# If we can't fetch the user, proceed without validation
|
|
1100
|
+
# The API will reject invalid fields anyway
|
|
1101
|
+
is_service_account = False
|
|
1102
|
+
|
|
1103
|
+
# Validate that service accounts don't get invalid field updates
|
|
1104
|
+
if is_service_account:
|
|
1105
|
+
invalid_fields = []
|
|
1106
|
+
if email:
|
|
1107
|
+
invalid_fields.append("--email")
|
|
1108
|
+
if login:
|
|
1109
|
+
invalid_fields.append("--login")
|
|
1110
|
+
if phone:
|
|
1111
|
+
invalid_fields.append("--phone")
|
|
1112
|
+
if niua_id:
|
|
1113
|
+
invalid_fields.append("--niua-id")
|
|
1114
|
+
if accepted_tos:
|
|
1115
|
+
invalid_fields.append("--accepted-tos")
|
|
1116
|
+
|
|
1117
|
+
if invalid_fields:
|
|
1118
|
+
click.echo(
|
|
1119
|
+
f"✗ Service accounts cannot be updated with: {', '.join(invalid_fields)}",
|
|
1120
|
+
err=True,
|
|
1121
|
+
)
|
|
1122
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1123
|
+
|
|
1124
|
+
url = f"{get_base_url()}/niuser/v1/users/{user_id}"
|
|
1125
|
+
|
|
1126
|
+
# Build update payload (only include provided fields)
|
|
1127
|
+
payload: Dict[str, Any] = {}
|
|
1128
|
+
|
|
1129
|
+
if first_name:
|
|
1130
|
+
payload["firstName"] = first_name
|
|
1131
|
+
|
|
1132
|
+
if last_name:
|
|
1133
|
+
payload["lastName"] = last_name
|
|
1134
|
+
|
|
1135
|
+
if email:
|
|
1136
|
+
payload["email"] = email
|
|
1137
|
+
|
|
1138
|
+
if login:
|
|
1139
|
+
payload["login"] = login
|
|
1140
|
+
|
|
1141
|
+
if phone:
|
|
1142
|
+
payload["phone"] = phone
|
|
1143
|
+
|
|
1144
|
+
if niua_id:
|
|
1145
|
+
payload["niuaId"] = niua_id
|
|
1146
|
+
|
|
1147
|
+
if accepted_tos:
|
|
1148
|
+
payload["acceptedToS"] = accepted_tos.lower() == "true"
|
|
1149
|
+
|
|
1150
|
+
policy_ids_upd: list[str] = []
|
|
1151
|
+
if policy:
|
|
1152
|
+
policy_ids_upd.append(policy.strip())
|
|
1153
|
+
if policies:
|
|
1154
|
+
policy_ids_upd.extend([p.strip() for p in policies.split(",")])
|
|
1155
|
+
|
|
1156
|
+
if workspace_policies:
|
|
1157
|
+
policy_ids_upd.extend(_process_workspace_policies(workspace_policies, first_name))
|
|
1158
|
+
|
|
1159
|
+
if policy_ids_upd:
|
|
1160
|
+
seen_upd: set[str] = set()
|
|
1161
|
+
deduped_upd: list[str] = []
|
|
1162
|
+
for pid in policy_ids_upd:
|
|
1163
|
+
if pid and pid not in seen_upd:
|
|
1164
|
+
seen_upd.add(pid)
|
|
1165
|
+
deduped_upd.append(pid)
|
|
1166
|
+
payload["policies"] = deduped_upd
|
|
1167
|
+
|
|
1168
|
+
if keywords:
|
|
1169
|
+
payload["keywords"] = [k.strip() for k in keywords.split(",")]
|
|
1170
|
+
|
|
1171
|
+
props_upd: Dict[str, Any] = {}
|
|
1172
|
+
if properties:
|
|
1173
|
+
try:
|
|
1174
|
+
props_upd = json.loads(properties)
|
|
1175
|
+
except json.JSONDecodeError:
|
|
1176
|
+
click.echo("✗ Invalid JSON format for properties.", err=True)
|
|
1177
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1178
|
+
|
|
1179
|
+
if props_upd:
|
|
1180
|
+
payload["properties"] = props_upd
|
|
1181
|
+
|
|
1182
|
+
if not payload:
|
|
1183
|
+
click.echo("✗ No fields provided to update.", err=True)
|
|
1184
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1185
|
+
|
|
1186
|
+
try:
|
|
1187
|
+
resp = make_api_request("PUT", url, payload=payload)
|
|
1188
|
+
user = resp.json()
|
|
1189
|
+
if is_service_account:
|
|
1190
|
+
format_success(
|
|
1191
|
+
"Service account updated",
|
|
1192
|
+
{"ID": user.get("id"), "Name": user.get("firstName")},
|
|
1193
|
+
)
|
|
1194
|
+
else:
|
|
1195
|
+
format_success("User updated", {"ID": user.get("id"), "Email": user.get("email")})
|
|
1196
|
+
|
|
1197
|
+
except Exception as exc:
|
|
1198
|
+
handle_api_error(exc)
|
|
1199
|
+
|
|
1200
|
+
@user.command(name="delete")
|
|
1201
|
+
@click.option("--id", "-i", "user_id", required=True, help="User ID to delete")
|
|
1202
|
+
@click.confirmation_option(
|
|
1203
|
+
prompt="Are you sure you want to delete this user? This action cannot be undone."
|
|
1204
|
+
)
|
|
1205
|
+
def delete_user(user_id: str) -> None:
|
|
1206
|
+
"""Delete a user by ID."""
|
|
1207
|
+
from .utils import check_readonly_mode
|
|
1208
|
+
|
|
1209
|
+
check_readonly_mode("delete a user")
|
|
1210
|
+
|
|
1211
|
+
url = f"{get_base_url()}/niuser/v1/users/{user_id}"
|
|
1212
|
+
|
|
1213
|
+
try:
|
|
1214
|
+
make_api_request("DELETE", url, payload=None)
|
|
1215
|
+
format_success("User deleted", {"ID": user_id})
|
|
1216
|
+
|
|
1217
|
+
except Exception as exc:
|
|
1218
|
+
handle_api_error(exc)
|