@knowttl/mg-axi 0.1.0

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.
Files changed (57) hide show
  1. package/README.md +1138 -0
  2. package/dist/api.js +953 -0
  3. package/dist/app-auth.js +47 -0
  4. package/dist/auth.js +59 -0
  5. package/dist/bin/mg-axi.js +7 -0
  6. package/dist/catalogue.js +2191 -0
  7. package/dist/cli.js +1314 -0
  8. package/dist/docs.js +559 -0
  9. package/dist/doctor.js +75 -0
  10. package/dist/entra-access-reviews.js +425 -0
  11. package/dist/entra-apps.js +374 -0
  12. package/dist/entra-audit-logs.js +381 -0
  13. package/dist/entra-auth-methods.js +296 -0
  14. package/dist/entra-ca-policy-update.js +353 -0
  15. package/dist/entra-certificate-auth.js +384 -0
  16. package/dist/entra-conditional-access.js +317 -0
  17. package/dist/entra-contacts.js +827 -0
  18. package/dist/entra-contracts.js +252 -0
  19. package/dist/entra-custom-security-attributes.js +464 -0
  20. package/dist/entra-data-policy-operations.js +274 -0
  21. package/dist/entra-delegated-admin.js +468 -0
  22. package/dist/entra-deleted-items.js +461 -0
  23. package/dist/entra-directory-objects.js +274 -0
  24. package/dist/entra-directory.js +411 -0
  25. package/dist/entra-domains.js +399 -0
  26. package/dist/entra-entitlement-management.js +645 -0
  27. package/dist/entra-federation-configurations.js +290 -0
  28. package/dist/entra-grants.js +272 -0
  29. package/dist/entra-group-lifecycle.js +349 -0
  30. package/dist/entra-group-member-add.js +278 -0
  31. package/dist/entra-groups.js +336 -0
  32. package/dist/entra-identity-providers.js +297 -0
  33. package/dist/entra-lifecycle-workflows.js +673 -0
  34. package/dist/entra-multi-tenant-organization.js +309 -0
  35. package/dist/entra-on-premises-synchronization.js +231 -0
  36. package/dist/entra-organization.js +398 -0
  37. package/dist/entra-risk-dismiss.js +191 -0
  38. package/dist/entra-risk.js +404 -0
  39. package/dist/entra-roles.js +310 -0
  40. package/dist/entra-subscriptions.js +289 -0
  41. package/dist/entra-tenant-information.js +152 -0
  42. package/dist/entra-terms-of-use.js +380 -0
  43. package/dist/entra-user-revoke-sessions.js +170 -0
  44. package/dist/entra-user-update.js +170 -0
  45. package/dist/entra-users.js +211 -0
  46. package/dist/graph-session.js +1034 -0
  47. package/dist/msal-app-provider.js +42 -0
  48. package/dist/msal-provider.js +82 -0
  49. package/dist/mutations.js +529 -0
  50. package/dist/profiles.js +139 -0
  51. package/dist/setup.js +116 -0
  52. package/dist/version.js +2 -0
  53. package/docs/coverage.md +1028 -0
  54. package/inventory/operations.json +4 -0
  55. package/inventory/schema.json +71 -0
  56. package/package.json +32 -0
  57. package/skills/mg-axi/SKILL.md +293 -0
@@ -0,0 +1,404 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // READ-06: the Entra risky-user and risk-detection read mapping behind
3
+ // `mg-axi entra risky-user list/show` and `mg-axi entra risk-detection
4
+ // list/show`. Operation construction stays beside its command; the shared
5
+ // session owns URLs, credentials, paging, retries and error translation, and
6
+ // the SDK owns TOON rendering. This module only maps flags to session calls,
7
+ // bounds detection queries in time, and projects rows for compact output.
8
+ //
9
+ // Reviewed against the v1.0 riskyuser-list/get and riskdetection-list/get
10
+ // operation documentation on 2026-10-04. Risky-user reads take D/A
11
+ // IdentityRiskyUser.Read.All and risk-detection reads take D/A
12
+ // IdentityRiskEvent.Read.All; delegated callers additionally need Global
13
+ // Reader, Security Operator, Security Reader or Security Administrator. The
14
+ // riskyUsers API requires a P2 licence, while risk detection permits P1 or
15
+ // P2. Limited views stay limited: a premium detection without P2 detail
16
+ // reports riskEventType generic, hidden risk levels report the licence
17
+ // boundary instead of the level, and a null detection correlationId means no
18
+ // sign-in is associated. None of these is ever reinterpreted as empty or
19
+ // invented into a fuller value, and risk dismissal stays WRITE-05.
20
+ // Every property this slice may request or display. Anything else fails
21
+ // before credentials so typos never become misleading server queries.
22
+ // tokenIssuerType is deliberately absent: the shared session redacts every
23
+ // *token* key, so the enum could only ever render as ***redacted*** and is
24
+ // left out rather than displayed as hidden material.
25
+ export const KNOWN_RISKY_USER_FIELDS = [
26
+ "id",
27
+ "isDeleted",
28
+ "isProcessing",
29
+ "riskDetail",
30
+ "riskLastUpdatedDateTime",
31
+ "riskLevel",
32
+ "riskState",
33
+ "userDisplayName",
34
+ "userPrincipalName",
35
+ ];
36
+ export const KNOWN_RISK_DETECTION_FIELDS = [
37
+ "id",
38
+ "requestId",
39
+ "correlationId",
40
+ "riskEventType",
41
+ "riskState",
42
+ "riskLevel",
43
+ "riskDetail",
44
+ "source",
45
+ "detectionTimingType",
46
+ "activity",
47
+ "ipAddress",
48
+ "location",
49
+ "activityDateTime",
50
+ "detectedDateTime",
51
+ "lastUpdatedDateTime",
52
+ "userId",
53
+ "userDisplayName",
54
+ "userPrincipalName",
55
+ "additionalInfo",
56
+ ];
57
+ const RISKY_USER_KNOWN = new Set(KNOWN_RISKY_USER_FIELDS);
58
+ const RISK_DETECTION_KNOWN = new Set(KNOWN_RISK_DETECTION_FIELDS);
59
+ // Compact list rows: identifier, who is at risk, and the triage state.
60
+ const DEFAULT_RISKY_USER_LIST_SELECT = ["id", "userPrincipalName", "riskLevel", "riskState"];
61
+ const DEFAULT_RISK_DETECTION_LIST_SELECT = ["id", "detectedDateTime", "userPrincipalName", "riskLevel"];
62
+ // Show rows: the full reviewed server set for one object.
63
+ const DEFAULT_RISKY_USER_SHOW_SELECT = [...KNOWN_RISKY_USER_FIELDS];
64
+ const DEFAULT_RISK_DETECTION_SHOW_SELECT = [...KNOWN_RISK_DETECTION_FIELDS];
65
+ export const DEFAULT_RISKY_USER_SCOPES = ["https://graph.microsoft.com/IdentityRiskyUser.Read.All"];
66
+ export const DEFAULT_RISK_DETECTION_SCOPES = ["https://graph.microsoft.com/IdentityRiskEvent.Read.All"];
67
+ const TRUNCATE_AT = 500;
68
+ function fieldList(raw, flag, known, knownList, help) {
69
+ const parts = String(raw)
70
+ .split(",")
71
+ .map(part => part.trim())
72
+ .filter(part => part.length > 0);
73
+ if (!parts.length)
74
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
75
+ const fields = [];
76
+ for (const part of parts) {
77
+ if (!known.has(part)) {
78
+ throw new AxiError(`Unknown property ${part} in --${flag}`, "VALIDATION_ERROR", [
79
+ help,
80
+ `Known properties: ${knownList.join(", ")}`,
81
+ ]);
82
+ }
83
+ if (!fields.includes(part))
84
+ fields.push(part);
85
+ }
86
+ return fields;
87
+ }
88
+ function scopesFor(flags, profile, defaults, help) {
89
+ if (profile.mode === "application") {
90
+ if (flags.scopes !== undefined) {
91
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
92
+ }
93
+ return undefined;
94
+ }
95
+ if (flags.scopes === undefined)
96
+ return [...defaults];
97
+ const scopes = String(flags.scopes)
98
+ .split(",")
99
+ .map(scope => scope.trim())
100
+ .filter(scope => scope.length > 0);
101
+ return scopes;
102
+ }
103
+ function isoInstant(raw, flag, help) {
104
+ const text = String(raw).trim();
105
+ const match = /^(\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d)(?:\.(\d+))?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.exec(text);
106
+ const local = match === null ? NaN : Date.parse(`${match[1]}Z`);
107
+ const parsed = match === null ? NaN : Date.parse(`${match[1]}${match[3]}`);
108
+ if (Number.isNaN(local) || Number.isNaN(parsed) || new Date(local).toISOString().slice(0, 19) !== match[1]
109
+ || new Date(parsed).toISOString().length !== 24) {
110
+ throw new AxiError(`--${flag} needs an ISO-8601 instant such as 2026-09-01T00:00:00Z`, "VALIDATION_ERROR", [help]);
111
+ }
112
+ const fraction = (match[2] ?? "").replace(/0+$/, "").padEnd(3, "0");
113
+ return `${new Date(parsed).toISOString().slice(0, 19)}.${fraction}Z`;
114
+ }
115
+ // Detection collection queries carry an explicit time bound; there is no
116
+ // unbounded default. Returns undefined when the caller passed no time or
117
+ // filter flags so a cursor resume restores the saved query untouched.
118
+ function boundedFilter(dateField, flags, help) {
119
+ const since = flags.since === undefined ? undefined : isoInstant(flags.since, "since", help);
120
+ const until = flags.until === undefined ? undefined : isoInstant(flags.until, "until", help);
121
+ if (since !== undefined && until !== undefined && until.slice(0, -1) <= since.slice(0, -1)) {
122
+ throw new AxiError("--until must be after --since", "VALIDATION_ERROR", [help]);
123
+ }
124
+ const parts = [];
125
+ if (since !== undefined)
126
+ parts.push(`${dateField} ge ${since}`);
127
+ if (until !== undefined)
128
+ parts.push(`${dateField} le ${until}`);
129
+ if (flags.filter !== undefined) {
130
+ const filter = String(flags.filter).trim();
131
+ if (!filter.length)
132
+ throw new AxiError("--filter needs a non-empty OData expression", "VALIDATION_ERROR", [help]);
133
+ let quoted = false;
134
+ let depth = 0;
135
+ for (const char of filter) {
136
+ if (char === "'")
137
+ quoted = !quoted;
138
+ else if (!quoted && char === "(")
139
+ depth++;
140
+ else if (!quoted && char === ")" && --depth < 0)
141
+ break;
142
+ }
143
+ if (quoted || depth !== 0)
144
+ throw new AxiError("--filter needs balanced parentheses and quoted strings to preserve the time bounds", "VALIDATION_ERROR", [help]);
145
+ parts.push(`(${filter})`);
146
+ }
147
+ return parts.length ? parts.join(" and ") : undefined;
148
+ }
149
+ function truncateValue(value, full) {
150
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
151
+ ? { value: text, truncated: false }
152
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
153
+ if (typeof value === "string")
154
+ return truncate(value);
155
+ if (Array.isArray(value)) {
156
+ const projected = value.map(entry => truncateValue(entry, full));
157
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
158
+ }
159
+ if (value !== null && typeof value === "object") {
160
+ const projected = Object.entries(value).map(([key, entry]) => [key, truncateValue(entry, full)]);
161
+ return {
162
+ value: Object.fromEntries(projected.map(([key, entry]) => [key, entry.value])),
163
+ truncated: projected.some(([, entry]) => entry.truncated),
164
+ };
165
+ }
166
+ return { value, truncated: false };
167
+ }
168
+ // Local projection preserves Graph's null/missing distinction: an explicit
169
+ // null stays null, an absent property stays absent and is never synthesized.
170
+ // A null detection correlationId means no sign-in is associated, not a
171
+ // missing join key; generic and hidden risk values are the server's limited
172
+ // view and are never rewritten into a fuller claim.
173
+ function project(row, fields, full) {
174
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
175
+ const projected = {};
176
+ let truncated = false;
177
+ for (const field of fields) {
178
+ if (!Object.hasOwn(source, field))
179
+ continue;
180
+ const result = truncateValue(source[field], full);
181
+ projected[field] = result.value;
182
+ truncated = truncated || result.truncated;
183
+ }
184
+ return { row: projected, truncated };
185
+ }
186
+ function selectedFields(flags, defaults, known, knownList, help) {
187
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, "select", known, knownList, help);
188
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, "fields", known, knownList, help);
189
+ const missing = fields.find(field => !select.includes(field));
190
+ if (missing) {
191
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
192
+ }
193
+ return { select, fields };
194
+ }
195
+ function profileHint(profileName) {
196
+ return `--profile ${shellValue(profileName)}`;
197
+ }
198
+ function shellValue(value) {
199
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
200
+ }
201
+ function fullHint(noun, action, flags, profileName) {
202
+ const args = Object.entries({ ...flags, ...(flags.cursor === undefined ? {} : { cursor: "-" }), profile: profileName, full: true })
203
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
204
+ return `mg-axi entra ${noun} ${action} ${args.join(" ")}`;
205
+ }
206
+ // Denials carry the session's generic grant/role/licence cause; each
207
+ // operation adds the roles and licensing that actually unlock it, because a
208
+ // 403 alone never says which prerequisite is missing.
209
+ export function withGuidance(hints, run) {
210
+ return run().catch(error => {
211
+ if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
212
+ throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
213
+ }
214
+ throw error;
215
+ });
216
+ }
217
+ export const RISKY_USER_DENIAL_HINTS = [
218
+ "Risky-user reads need IdentityRiskyUser.Read.All plus a supported directory role: Global Reader, Security Operator, Security Reader or Security Administrator for delegated access, or admin-consented IdentityRiskyUser.Read.All for application access",
219
+ "The riskyUsers API requires a Microsoft Entra ID P2 licence; data availability follows Entra retention policies",
220
+ ];
221
+ const RISK_DETECTION_DENIAL_HINTS = [
222
+ "Risk-detection reads need IdentityRiskEvent.Read.All plus a supported directory role: Global Reader, Security Operator, Security Reader or Security Administrator for delegated access, or admin-consented IdentityRiskEvent.Read.All for application access",
223
+ "Risk detection needs a P1 or P2 licence; premium detections report riskEventType generic without P2 detail, so generic is the server's limited view rather than the real type",
224
+ ];
225
+ const RISKY_USER_LIST = {
226
+ noun: "risky-user",
227
+ key: "riskyUsers",
228
+ known: RISKY_USER_KNOWN,
229
+ knownList: KNOWN_RISKY_USER_FIELDS,
230
+ defaultSelect: DEFAULT_RISKY_USER_LIST_SELECT,
231
+ defaultScopes: DEFAULT_RISKY_USER_SCOPES,
232
+ denialHints: RISKY_USER_DENIAL_HINTS,
233
+ showHint: "mg-axi entra risky-user show --id <risky-user-id>",
234
+ emptyHints: profileName => [
235
+ `mg-axi entra risky-user list --filter <odata-filter> ${profileHint(profileName)}`,
236
+ "0 risky users matched; the absence of results is the answer, not an error",
237
+ "Limited results stay limited: without P2 detail the service reports what the caller may see, so never read a short list as no risk",
238
+ ],
239
+ extraHelp: () => [],
240
+ };
241
+ const RISK_DETECTION_LIST = {
242
+ noun: "risk-detection",
243
+ key: "riskDetections",
244
+ dateField: "detectedDateTime",
245
+ known: RISK_DETECTION_KNOWN,
246
+ knownList: KNOWN_RISK_DETECTION_FIELDS,
247
+ defaultSelect: DEFAULT_RISK_DETECTION_LIST_SELECT,
248
+ defaultScopes: DEFAULT_RISK_DETECTION_SCOPES,
249
+ denialHints: RISK_DETECTION_DENIAL_HINTS,
250
+ showHint: "mg-axi entra risk-detection show --id <risk-detection-id>",
251
+ emptyHints: profileName => [
252
+ `mg-axi entra risk-detection list --since <earlier-iso-time> ${profileHint(profileName)}`,
253
+ "0 risk detections matched in this window; widen --since/--until or loosen --filter - the absence of results is the answer, not an error",
254
+ ],
255
+ extraHelp: profileName => [
256
+ `Correlate a detection to its sign-in with mg-axi entra sign-in list --since <activity-window-start> --filter "userPrincipalName eq '<user-principal-name>'" ${profileHint(profileName)}; there is no riskySignIns endpoint`,
257
+ "Limited views stay limited: riskEventType generic can hide a premium detection without P2 detail, and hidden risk levels report the licence boundary instead of the level - never read them as none",
258
+ ],
259
+ };
260
+ async function listRisk(shape, session, flags, profile, operation, help, profileName) {
261
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
262
+ if (cursor !== undefined && !cursor.trim())
263
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
264
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
265
+ if (shape.dateField !== undefined) {
266
+ const bounds = saved === undefined ? undefined : new RegExp(`^${shape.dateField} ge (\\S+)(?: and ${shape.dateField} le (\\S+))?(?: and \\(([\\s\\S]*)\\))?$`).exec(saved.$filter ?? "");
267
+ const savedFlags = bounds ? {
268
+ since: bounds[1],
269
+ ...(bounds[2] === undefined ? {} : { until: bounds[2] }),
270
+ ...(bounds[3] === undefined ? {} : { filter: bounds[3] }),
271
+ } : undefined;
272
+ if ((saved === undefined && flags.since === undefined)
273
+ || (saved !== undefined && (savedFlags === undefined || boundedFilter(shape.dateField, savedFlags, help) !== saved.$filter))) {
274
+ throw new AxiError(`Detection queries are bounded in time: pass --since <ISO-time> to bound ${shape.dateField}`, "VALIDATION_ERROR", [
275
+ help,
276
+ "Start a new query with --since instead of resuming a cursor without a valid saved time bound",
277
+ `Example: mg-axi entra ${shape.noun} list --since 2026-09-01T00:00:00Z ${profileHint(profileName)}`,
278
+ ]);
279
+ }
280
+ }
281
+ const savedSelect = saved?.$select;
282
+ const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, help), shape.known, shape.knownList, help);
283
+ const scopes = scopesFor(flags, profile, shape.defaultScopes, help);
284
+ const full = flags.full === true;
285
+ const query = { $select: select.join(",") };
286
+ if (shape.dateField !== undefined) {
287
+ const filter = boundedFilter(shape.dateField, flags, help);
288
+ if (filter !== undefined)
289
+ query.$filter = filter;
290
+ }
291
+ else if (flags.filter !== undefined) {
292
+ query.$filter = String(flags.filter);
293
+ }
294
+ const args = { profile, operation, query, scopes };
295
+ if (cursor !== undefined)
296
+ args.cursor = cursor;
297
+ if (flags.all === true) {
298
+ if (flags.limit !== undefined)
299
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
300
+ }
301
+ else {
302
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
303
+ }
304
+ const result = await withGuidance(shape.denialHints, () => session.collect(args));
305
+ // The hint replays the caller's own time flags (or the cursor alone, which
306
+ // restores the saved query); copying back the composed $filter would wrap
307
+ // it a second time and conflict with the cursor context.
308
+ const effectiveFlags = { ...flags, select: result.query.$select ?? shape.defaultSelect.join(",") };
309
+ if (result.query.$filter !== undefined && shape.dateField === undefined)
310
+ effectiveFlags.filter = result.query.$filter;
311
+ const rows = [];
312
+ let truncated = false;
313
+ for (const row of result.value) {
314
+ const projected = project(row, fields, full);
315
+ rows.push(projected.row);
316
+ truncated = truncated || projected.truncated;
317
+ }
318
+ const showHint = `${shape.showHint} ${profileHint(profileName)}`;
319
+ const truncationHints = truncated ? [fullHint(shape.noun, "list", effectiveFlags, profileName)] : [];
320
+ if (truncated && cursor !== undefined)
321
+ truncationHints.push("Supply the original input cursor on stdin to replay this result with --full");
322
+ if (!result.complete) {
323
+ return {
324
+ [shape.key]: rows,
325
+ count: { returned: rows.length, complete: false, reason: result.reason },
326
+ cursor: result.cursor,
327
+ help: [...truncationHints, `Resume losslessly with the same flags plus --cursor - ${profileHint(profileName)} and supply the returned cursor on stdin`, showHint, ...shape.extraHelp(profileName)],
328
+ };
329
+ }
330
+ const count = { returned: rows.length, complete: true };
331
+ if (!rows.length) {
332
+ return { [shape.key]: rows, count, help: shape.emptyHints(profileName) };
333
+ }
334
+ return { [shape.key]: rows, count, help: [...truncationHints, showHint, ...shape.extraHelp(profileName)] };
335
+ }
336
+ const RISKY_USER_SHOW = {
337
+ noun: "risky-user",
338
+ key: "riskyUser",
339
+ param: "riskyUser-id",
340
+ known: RISKY_USER_KNOWN,
341
+ knownList: KNOWN_RISKY_USER_FIELDS,
342
+ defaultSelect: DEFAULT_RISKY_USER_SHOW_SELECT,
343
+ defaultScopes: DEFAULT_RISKY_USER_SCOPES,
344
+ denialHints: RISKY_USER_DENIAL_HINTS,
345
+ correlationHelp: profileName => [
346
+ `List this user's detections with mg-axi entra risk-detection list --since <iso-time> --filter "userId eq '<risky-user-id>'" ${profileHint(profileName)}`,
347
+ `List this user's sign-ins with mg-axi entra sign-in list --since <iso-time> --filter "userPrincipalName eq '<user-principal-name>'" ${profileHint(profileName)}`,
348
+ "Risk dismissal is a separately reviewed write; this read never confirms, dismisses or remediates risk",
349
+ ],
350
+ };
351
+ const RISK_DETECTION_SHOW = {
352
+ noun: "risk-detection",
353
+ key: "riskDetection",
354
+ param: "riskDetection-id",
355
+ known: RISK_DETECTION_KNOWN,
356
+ knownList: KNOWN_RISK_DETECTION_FIELDS,
357
+ defaultSelect: DEFAULT_RISK_DETECTION_SHOW_SELECT,
358
+ defaultScopes: DEFAULT_RISK_DETECTION_SCOPES,
359
+ denialHints: RISK_DETECTION_DENIAL_HINTS,
360
+ correlationHelp: profileName => [
361
+ `Correlate this detection to its sign-in with mg-axi entra sign-in list --since <activity-window-start> --filter "userPrincipalName eq '<user-principal-name>'" ${profileHint(profileName)}; there is no riskySignIns endpoint`,
362
+ "Limited views stay limited: riskEventType generic can hide a premium detection without P2 detail, and hidden risk levels report the licence boundary instead of the level - never read them as none",
363
+ ],
364
+ noSignInNote: "This detection carries no associated sign-in (correlationId is null); sign-in correlation does not apply",
365
+ };
366
+ async function showRisk(shape, session, flags, profile, operation, help, profileName) {
367
+ const { select, fields } = selectedFields(flags, shape.defaultSelect, shape.known, shape.knownList, help);
368
+ const scopes = scopesFor(flags, profile, shape.defaultScopes, help);
369
+ const full = flags.full === true;
370
+ const raw = await withGuidance(shape.denialHints, () => session.execute({
371
+ profile,
372
+ operation,
373
+ params: { [shape.param]: String(flags.id) },
374
+ query: { $select: select.join(",") },
375
+ scopes,
376
+ }));
377
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
378
+ throw new AxiError("Graph returned a malformed risk body", "GRAPH_ERROR", [
379
+ "Single-object reads carry one object; treat anything else as unknown, not empty",
380
+ ]);
381
+ }
382
+ const { row, truncated } = project(raw, fields, full);
383
+ const hints = [...shape.correlationHelp(profileName)];
384
+ // A null correlationId is the server's statement that no sign-in is
385
+ // associated, so sign-in correlation explicitly does not apply.
386
+ if (shape.noSignInNote !== undefined && Object.hasOwn(row, "correlationId") && row.correlationId === null) {
387
+ hints.push(shape.noSignInNote);
388
+ }
389
+ if (truncated)
390
+ return { [shape.key]: row, help: [fullHint(shape.noun, "show", flags, profileName), ...hints] };
391
+ return { [shape.key]: row, help: hints };
392
+ }
393
+ export async function listRiskyUsers(session, flags, profile, operation, help, profileName) {
394
+ return listRisk(RISKY_USER_LIST, session, flags, profile, operation, help, profileName);
395
+ }
396
+ export async function showRiskyUser(session, flags, profile, operation, help, profileName) {
397
+ return showRisk(RISKY_USER_SHOW, session, flags, profile, operation, help, profileName);
398
+ }
399
+ export async function listRiskDetections(session, flags, profile, operation, help, profileName) {
400
+ return listRisk(RISK_DETECTION_LIST, session, flags, profile, operation, help, profileName);
401
+ }
402
+ export async function showRiskDetection(session, flags, profile, operation, help, profileName) {
403
+ return showRisk(RISK_DETECTION_SHOW, session, flags, profile, operation, help, profileName);
404
+ }