@knowttl/mg-axi 0.1.1 → 0.1.3

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 (48) hide show
  1. package/README.md +251 -77
  2. package/dist/api.js +253 -8
  3. package/dist/bin/mg-axi-hook.js +31 -0
  4. package/dist/catalogue.js +693 -2
  5. package/dist/cli.js +155 -17
  6. package/dist/docs.js +175 -46
  7. package/dist/doctor.js +2 -2
  8. package/dist/entra-access-reviews.js +11 -4
  9. package/dist/entra-apps.js +171 -14
  10. package/dist/entra-audit-logs.js +101 -16
  11. package/dist/entra-auth-methods.js +8 -4
  12. package/dist/entra-certificate-auth.js +12 -8
  13. package/dist/entra-conditional-access.js +425 -15
  14. package/dist/entra-contacts.js +19 -12
  15. package/dist/entra-contracts.js +7 -4
  16. package/dist/entra-custom-security-attributes.js +19 -12
  17. package/dist/entra-data-policy-operations.js +7 -4
  18. package/dist/entra-delegated-admin.js +55 -6
  19. package/dist/entra-deleted-items.js +6 -4
  20. package/dist/entra-directory-objects.js +6 -4
  21. package/dist/entra-directory.js +19 -12
  22. package/dist/entra-domains.js +13 -8
  23. package/dist/entra-entitlement-management.js +12 -4
  24. package/dist/entra-federation-configurations.js +7 -4
  25. package/dist/entra-grants.js +8 -4
  26. package/dist/entra-group-lifecycle.js +13 -8
  27. package/dist/entra-group-pim.js +431 -0
  28. package/dist/entra-groups.js +13 -8
  29. package/dist/entra-identity-providers.js +7 -4
  30. package/dist/entra-lifecycle-workflows.js +152 -13
  31. package/dist/entra-multi-tenant-organization.js +7 -4
  32. package/dist/entra-on-premises-synchronization.js +6 -4
  33. package/dist/entra-organization.js +13 -8
  34. package/dist/entra-risk-prevention.js +442 -0
  35. package/dist/entra-risk.js +224 -9
  36. package/dist/entra-roles.js +204 -12
  37. package/dist/entra-subscriptions.js +6 -4
  38. package/dist/entra-terms-of-use.js +13 -8
  39. package/dist/entra-users.js +7 -4
  40. package/dist/graph-session.js +30 -5
  41. package/dist/hook.js +84 -0
  42. package/dist/list-totals.js +52 -0
  43. package/dist/setup.js +9 -1
  44. package/docs/coverage.md +743 -610
  45. package/inventory/operations.json +2 -2
  46. package/inventory/schema.json +1 -1
  47. package/package.json +4 -3
  48. package/skills/mg-axi/SKILL.md +86 -1
@@ -1,18 +1,30 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // READ-06: the Entra risky-user and risk-detection read mapping behind
3
4
  // `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
+ // list/show`, extended with the workload-identity surface behind
6
+ // `mg-axi entra risky-service-principal list/show`,
7
+ // `mg-axi entra risky-service-principal history list/show` and
8
+ // `mg-axi entra service-principal-risk-detection list/show`. Operation construction stays beside its command; the shared
5
9
  // session owns URLs, credentials, paging, retries and error translation, and
6
10
  // the SDK owns TOON rendering. This module only maps flags to session calls,
7
11
  // bounds detection queries in time, and projects rows for compact output.
8
12
  //
9
13
  // 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
14
+ // operation documentation on 2026-10-04, and against the v1.0
15
+ // riskyserviceprincipal-list/get, history-list and
16
+ // serviceprincipalriskdetection-list/get operation documentation on
17
+ // 2026-10-06. Risky-user reads take D/A
11
18
  // IdentityRiskyUser.Read.All and risk-detection reads take D/A
12
- // IdentityRiskEvent.Read.All; delegated callers additionally need Global
19
+ // IdentityRiskEvent.Read.All; risky-service-principal reads (including
20
+ // history) take D/A IdentityRiskyServicePrincipal.Read.All while
21
+ // service-principal risk detections reuse D/A IdentityRiskEvent.Read.All.
22
+ // Delegated callers additionally need Global
13
23
  // Reader, Security Operator, Security Reader or Security Administrator. The
14
24
  // riskyUsers API requires a P2 licence, while risk detection permits P1 or
15
- // P2. Limited views stay limited: a premium detection without P2 detail
25
+ // P2; the workload-identity APIs require a Microsoft Entra Workload
26
+ // Identities Premium licence, and service-principal detections report
27
+ // riskDetail and riskLevel hidden without it. Limited views stay limited: a premium detection without P2 detail
16
28
  // reports riskEventType generic, hidden risk levels report the licence
17
29
  // boundary instead of the level, and a null detection correlationId means no
18
30
  // sign-in is associated. None of these is ever reinterpreted as empty or
@@ -54,8 +66,67 @@ export const KNOWN_RISK_DETECTION_FIELDS = [
54
66
  "userPrincipalName",
55
67
  "additionalInfo",
56
68
  ];
69
+ // Workload-identity surface. History items inherit every risky-service-
70
+ // principal property and add the service-principal identifier plus the
71
+ // actor/activity of the risk change. Service-principal detections carry the
72
+ // service-principal join keys (servicePrincipalId, servicePrincipalDisplayName,
73
+ // appId, keyIds) instead of user keys; keyIds are key-credential identifiers,
74
+ // never secret material. tokenIssuerType stays absent for the same redaction
75
+ // reason as above.
76
+ export const KNOWN_RISKY_SP_FIELDS = [
77
+ "id",
78
+ "isEnabled",
79
+ "isProcessing",
80
+ "riskDetail",
81
+ "riskLastUpdatedDateTime",
82
+ "riskLevel",
83
+ "riskState",
84
+ "displayName",
85
+ "appId",
86
+ "servicePrincipalType",
87
+ ];
88
+ export const KNOWN_RISKY_SP_HISTORY_FIELDS = [
89
+ "id",
90
+ "isEnabled",
91
+ "isProcessing",
92
+ "riskDetail",
93
+ "riskLastUpdatedDateTime",
94
+ "riskLevel",
95
+ "riskState",
96
+ "displayName",
97
+ "appId",
98
+ "servicePrincipalType",
99
+ "servicePrincipalId",
100
+ "initiatedBy",
101
+ "activity",
102
+ ];
103
+ export const KNOWN_SP_DETECTION_FIELDS = [
104
+ "id",
105
+ "requestId",
106
+ "correlationId",
107
+ "riskEventType",
108
+ "riskState",
109
+ "riskLevel",
110
+ "riskDetail",
111
+ "source",
112
+ "detectionTimingType",
113
+ "activity",
114
+ "ipAddress",
115
+ "location",
116
+ "activityDateTime",
117
+ "detectedDateTime",
118
+ "lastUpdatedDateTime",
119
+ "servicePrincipalId",
120
+ "servicePrincipalDisplayName",
121
+ "appId",
122
+ "keyIds",
123
+ "additionalInfo",
124
+ ];
57
125
  const RISKY_USER_KNOWN = new Set(KNOWN_RISKY_USER_FIELDS);
58
126
  const RISK_DETECTION_KNOWN = new Set(KNOWN_RISK_DETECTION_FIELDS);
127
+ const RISKY_SP_KNOWN = new Set(KNOWN_RISKY_SP_FIELDS);
128
+ const RISKY_SP_HISTORY_KNOWN = new Set(KNOWN_RISKY_SP_HISTORY_FIELDS);
129
+ const SP_DETECTION_KNOWN = new Set(KNOWN_SP_DETECTION_FIELDS);
59
130
  // Compact list rows: identifier, who is at risk, and the triage state.
60
131
  const DEFAULT_RISKY_USER_LIST_SELECT = ["id", "userPrincipalName", "riskLevel", "riskState"];
61
132
  const DEFAULT_RISK_DETECTION_LIST_SELECT = ["id", "detectedDateTime", "userPrincipalName", "riskLevel"];
@@ -64,6 +135,7 @@ const DEFAULT_RISKY_USER_SHOW_SELECT = [...KNOWN_RISKY_USER_FIELDS];
64
135
  const DEFAULT_RISK_DETECTION_SHOW_SELECT = [...KNOWN_RISK_DETECTION_FIELDS];
65
136
  export const DEFAULT_RISKY_USER_SCOPES = ["https://graph.microsoft.com/IdentityRiskyUser.Read.All"];
66
137
  export const DEFAULT_RISK_DETECTION_SCOPES = ["https://graph.microsoft.com/IdentityRiskEvent.Read.All"];
138
+ export const DEFAULT_RISKY_SP_SCOPES = ["https://graph.microsoft.com/IdentityRiskyServicePrincipal.Read.All"];
67
139
  const TRUNCATE_AT = 500;
68
140
  function fieldList(raw, flag, known, knownList, help) {
69
141
  const parts = String(raw)
@@ -222,9 +294,27 @@ const RISK_DETECTION_DENIAL_HINTS = [
222
294
  "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
295
  "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
296
  ];
297
+ const RISKY_SP_DENIAL_HINTS = [
298
+ "Risky-service-principal reads need IdentityRiskyServicePrincipal.Read.All plus a supported directory role: Global Reader, Security Operator, Security Reader or Security Administrator for delegated access, or admin-consented IdentityRiskyServicePrincipal.Read.All for application access",
299
+ "The riskyServicePrincipals API requires a Microsoft Entra Workload Identities Premium licence; data availability follows Entra retention policies",
300
+ ];
301
+ const SP_DETECTION_DENIAL_HINTS = [
302
+ "Service-principal-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",
303
+ "The servicePrincipalRiskDetection API requires a Microsoft Entra Workload Identities Premium licence; riskDetail and riskLevel report hidden without it, so hidden is the server's limited view rather than no risk",
304
+ ];
305
+ // Nested history routes bind the parent risky-service-principal id before
306
+ // credentials; a missing or empty parent fails as usage, never as Graph 404.
307
+ function parentServicePrincipal(flags, help) {
308
+ const parent = flags["service-principal"];
309
+ if (parent === undefined || !String(parent).trim()) {
310
+ throw new AxiError("--service-principal needs the risky-service-principal object ID owning this history", "VALIDATION_ERROR", [help]);
311
+ }
312
+ return { "riskyServicePrincipal-id": String(parent) };
313
+ }
225
314
  const RISKY_USER_LIST = {
226
315
  noun: "risky-user",
227
316
  key: "riskyUsers",
317
+ totalsNoun: "risky users",
228
318
  known: RISKY_USER_KNOWN,
229
319
  knownList: KNOWN_RISKY_USER_FIELDS,
230
320
  defaultSelect: DEFAULT_RISKY_USER_LIST_SELECT,
@@ -241,6 +331,7 @@ const RISKY_USER_LIST = {
241
331
  const RISK_DETECTION_LIST = {
242
332
  noun: "risk-detection",
243
333
  key: "riskDetections",
334
+ totalsNoun: "risk detections",
244
335
  dateField: "detectedDateTime",
245
336
  known: RISK_DETECTION_KNOWN,
246
337
  knownList: KNOWN_RISK_DETECTION_FIELDS,
@@ -257,6 +348,65 @@ const RISK_DETECTION_LIST = {
257
348
  "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
349
  ],
259
350
  };
351
+ // Compact workload rows: identifier, which workload, and the triage state.
352
+ const DEFAULT_RISKY_SP_LIST_SELECT = ["id", "displayName", "riskLevel", "riskState"];
353
+ const DEFAULT_SP_DETECTION_LIST_SELECT = ["id", "detectedDateTime", "servicePrincipalDisplayName", "riskLevel"];
354
+ const DEFAULT_RISKY_SP_SHOW_SELECT = [...KNOWN_RISKY_SP_FIELDS];
355
+ const DEFAULT_RISKY_SP_HISTORY_SHOW_SELECT = [...KNOWN_RISKY_SP_HISTORY_FIELDS];
356
+ const DEFAULT_SP_DETECTION_SHOW_SELECT = [...KNOWN_SP_DETECTION_FIELDS];
357
+ const RISKY_SP_LIST = {
358
+ noun: "risky-service-principal",
359
+ key: "riskyServicePrincipals",
360
+ totalsNoun: "risky service principals",
361
+ known: RISKY_SP_KNOWN,
362
+ knownList: KNOWN_RISKY_SP_FIELDS,
363
+ defaultSelect: DEFAULT_RISKY_SP_LIST_SELECT,
364
+ defaultScopes: DEFAULT_RISKY_SP_SCOPES,
365
+ denialHints: RISKY_SP_DENIAL_HINTS,
366
+ showHint: "mg-axi entra risky-service-principal show --id <risky-service-principal-id>",
367
+ emptyHints: profileName => [
368
+ `mg-axi entra risky-service-principal list --filter <odata-filter> ${profileHint(profileName)}`,
369
+ "0 risky service principals matched; the absence of results is the answer, not an error",
370
+ "Limited results stay limited: without Workload Identities Premium detail the service reports what the caller may see, so never read a short list as no risk",
371
+ ],
372
+ extraHelp: () => [],
373
+ };
374
+ const RISKY_SP_HISTORY_LIST = {
375
+ noun: "risky-service-principal history",
376
+ key: "riskyServicePrincipalHistory",
377
+ totalsNoun: "history items",
378
+ pathParams: parentServicePrincipal,
379
+ known: RISKY_SP_HISTORY_KNOWN,
380
+ knownList: KNOWN_RISKY_SP_HISTORY_FIELDS,
381
+ defaultSelect: DEFAULT_RISKY_SP_LIST_SELECT,
382
+ defaultScopes: DEFAULT_RISKY_SP_SCOPES,
383
+ denialHints: RISKY_SP_DENIAL_HINTS,
384
+ showHint: "mg-axi entra risky-service-principal history show --service-principal <risky-service-principal-id> --id <history-item-id>",
385
+ emptyHints: profileName => [
386
+ `mg-axi entra risky-service-principal history list --service-principal <risky-service-principal-id> ${profileHint(profileName)}`,
387
+ "0 history items matched; the absence of results is the answer, not an error",
388
+ ],
389
+ extraHelp: () => [],
390
+ };
391
+ const SP_DETECTION_LIST = {
392
+ noun: "service-principal-risk-detection",
393
+ key: "servicePrincipalRiskDetections",
394
+ totalsNoun: "service principal risk detections",
395
+ dateField: "detectedDateTime",
396
+ known: SP_DETECTION_KNOWN,
397
+ knownList: KNOWN_SP_DETECTION_FIELDS,
398
+ defaultSelect: DEFAULT_SP_DETECTION_LIST_SELECT,
399
+ defaultScopes: DEFAULT_RISK_DETECTION_SCOPES,
400
+ denialHints: SP_DETECTION_DENIAL_HINTS,
401
+ showHint: "mg-axi entra service-principal-risk-detection show --id <service-principal-risk-detection-id>",
402
+ emptyHints: profileName => [
403
+ `mg-axi entra service-principal-risk-detection list --since <earlier-iso-time> ${profileHint(profileName)}`,
404
+ "0 service-principal risk detections matched in this window; widen --since/--until or loosen --filter - the absence of results is the answer, not an error",
405
+ ],
406
+ extraHelp: () => [
407
+ "Limited views stay limited: riskDetail and riskLevel report hidden without Workload Identities Premium detail - never read hidden as none",
408
+ ],
409
+ };
260
410
  async function listRisk(shape, session, flags, profile, operation, help, profileName) {
261
411
  const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
262
412
  if (cursor !== undefined && !cursor.trim())
@@ -292,6 +442,8 @@ async function listRisk(shape, session, flags, profile, operation, help, profile
292
442
  query.$filter = String(flags.filter);
293
443
  }
294
444
  const args = { profile, operation, query, scopes };
445
+ if (shape.pathParams !== undefined)
446
+ args.params = shape.pathParams(flags, help);
295
447
  if (cursor !== undefined)
296
448
  args.cursor = cursor;
297
449
  if (flags.all === true) {
@@ -322,16 +474,17 @@ async function listRisk(shape, session, flags, profile, operation, help, profile
322
474
  if (!result.complete) {
323
475
  return {
324
476
  [shape.key]: rows,
325
- count: { returned: rows.length, complete: false, reason: result.reason },
477
+ ...listTotals(rows.length, result.total, shape.totalsNoun, false),
478
+ complete: false,
479
+ reason: result.reason,
326
480
  cursor: result.cursor,
327
481
  help: [...truncationHints, `Resume losslessly with the same flags plus --cursor - ${profileHint(profileName)} and supply the returned cursor on stdin`, showHint, ...shape.extraHelp(profileName)],
328
482
  };
329
483
  }
330
- const count = { returned: rows.length, complete: true };
331
484
  if (!rows.length) {
332
- return { [shape.key]: rows, count, help: shape.emptyHints(profileName) };
485
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.totalsNoun, true), complete: true, help: shape.emptyHints(profileName) };
333
486
  }
334
- return { [shape.key]: rows, count, help: [...truncationHints, showHint, ...shape.extraHelp(profileName)] };
487
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.totalsNoun, true), complete: true, help: [...truncationHints, showHint, ...shape.extraHelp(profileName)] };
335
488
  }
336
489
  const RISKY_USER_SHOW = {
337
490
  noun: "risky-user",
@@ -363,6 +516,50 @@ const RISK_DETECTION_SHOW = {
363
516
  ],
364
517
  noSignInNote: "This detection carries no associated sign-in (correlationId is null); sign-in correlation does not apply",
365
518
  };
519
+ const RISKY_SP_SHOW = {
520
+ noun: "risky-service-principal",
521
+ key: "riskyServicePrincipal",
522
+ param: "riskyServicePrincipal-id",
523
+ known: RISKY_SP_KNOWN,
524
+ knownList: KNOWN_RISKY_SP_FIELDS,
525
+ defaultSelect: DEFAULT_RISKY_SP_SHOW_SELECT,
526
+ defaultScopes: DEFAULT_RISKY_SP_SCOPES,
527
+ denialHints: RISKY_SP_DENIAL_HINTS,
528
+ correlationHelp: profileName => [
529
+ `List this workload's detections with mg-axi entra service-principal-risk-detection list --since <iso-time> --filter "servicePrincipalId eq '<risky-service-principal-id>'" ${profileHint(profileName)}`,
530
+ `List this workload's risk history with mg-axi entra risky-service-principal history list --service-principal <risky-service-principal-id> ${profileHint(profileName)}`,
531
+ "Confirming compromise and dismissing risk are separately reviewed writes; this read never confirms, dismisses or remediates risk",
532
+ ],
533
+ };
534
+ const RISKY_SP_HISTORY_SHOW = {
535
+ noun: "risky-service-principal history",
536
+ key: "riskyServicePrincipalHistoryItem",
537
+ param: "riskyServicePrincipalHistoryItem-id",
538
+ parentParams: parentServicePrincipal,
539
+ known: RISKY_SP_HISTORY_KNOWN,
540
+ knownList: KNOWN_RISKY_SP_HISTORY_FIELDS,
541
+ defaultSelect: DEFAULT_RISKY_SP_HISTORY_SHOW_SELECT,
542
+ defaultScopes: DEFAULT_RISKY_SP_SCOPES,
543
+ denialHints: RISKY_SP_DENIAL_HINTS,
544
+ correlationHelp: profileName => [
545
+ `List this workload's detections with mg-axi entra service-principal-risk-detection list --since <iso-time> --filter "servicePrincipalId eq '<risky-service-principal-id>'" ${profileHint(profileName)}`,
546
+ ],
547
+ };
548
+ const SP_DETECTION_SHOW = {
549
+ noun: "service-principal-risk-detection",
550
+ key: "servicePrincipalRiskDetection",
551
+ param: "servicePrincipalRiskDetection-id",
552
+ known: SP_DETECTION_KNOWN,
553
+ knownList: KNOWN_SP_DETECTION_FIELDS,
554
+ defaultSelect: DEFAULT_SP_DETECTION_SHOW_SELECT,
555
+ defaultScopes: DEFAULT_RISK_DETECTION_SCOPES,
556
+ denialHints: SP_DETECTION_DENIAL_HINTS,
557
+ correlationHelp: profileName => [
558
+ `List this workload's detections with mg-axi entra service-principal-risk-detection list --since <iso-time> --filter "servicePrincipalId eq '<service-principal-id>'" ${profileHint(profileName)}`,
559
+ "Limited views stay limited: riskDetail and riskLevel report hidden without Workload Identities Premium detail - never read hidden as none",
560
+ ],
561
+ noSignInNote: "This detection carries no associated sign-in (correlationId is null); sign-in correlation does not apply",
562
+ };
366
563
  async function showRisk(shape, session, flags, profile, operation, help, profileName) {
367
564
  const { select, fields } = selectedFields(flags, shape.defaultSelect, shape.known, shape.knownList, help);
368
565
  const scopes = scopesFor(flags, profile, shape.defaultScopes, help);
@@ -370,7 +567,7 @@ async function showRisk(shape, session, flags, profile, operation, help, profile
370
567
  const raw = await withGuidance(shape.denialHints, () => session.execute({
371
568
  profile,
372
569
  operation,
373
- params: { [shape.param]: String(flags.id) },
570
+ params: { ...(shape.parentParams === undefined ? {} : shape.parentParams(flags, help)), [shape.param]: String(flags.id) },
374
571
  query: { $select: select.join(",") },
375
572
  scopes,
376
573
  }));
@@ -402,3 +599,21 @@ export async function listRiskDetections(session, flags, profile, operation, hel
402
599
  export async function showRiskDetection(session, flags, profile, operation, help, profileName) {
403
600
  return showRisk(RISK_DETECTION_SHOW, session, flags, profile, operation, help, profileName);
404
601
  }
602
+ export async function listRiskyServicePrincipals(session, flags, profile, operation, help, profileName) {
603
+ return listRisk(RISKY_SP_LIST, session, flags, profile, operation, help, profileName);
604
+ }
605
+ export async function showRiskyServicePrincipal(session, flags, profile, operation, help, profileName) {
606
+ return showRisk(RISKY_SP_SHOW, session, flags, profile, operation, help, profileName);
607
+ }
608
+ export async function listRiskyServicePrincipalHistory(session, flags, profile, operation, help, profileName) {
609
+ return listRisk(RISKY_SP_HISTORY_LIST, session, flags, profile, operation, help, profileName);
610
+ }
611
+ export async function showRiskyServicePrincipalHistory(session, flags, profile, operation, help, profileName) {
612
+ return showRisk(RISKY_SP_HISTORY_SHOW, session, flags, profile, operation, help, profileName);
613
+ }
614
+ export async function listServicePrincipalRiskDetections(session, flags, profile, operation, help, profileName) {
615
+ return listRisk(SP_DETECTION_LIST, session, flags, profile, operation, help, profileName);
616
+ }
617
+ export async function showServicePrincipalRiskDetection(session, flags, profile, operation, help, profileName) {
618
+ return showRisk(SP_DETECTION_SHOW, session, flags, profile, operation, help, profileName);
619
+ }
@@ -1,26 +1,41 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // READ-09: the Entra directory-role and PIM read mapping behind
3
- // `mg-axi entra directory-role list/show`, `mg-axi entra role-assignment
4
- // list`, `mg-axi entra pim eligible list` and `mg-axi entra pim active list`.
4
+ // `mg-axi entra directory-role list/show`, `mg-axi entra directory-role-template
5
+ // list/show`, `mg-axi entra directory-role member list`, `mg-axi entra
6
+ // directory-role scoped-member list/show`, `mg-axi entra scoped-role-membership
7
+ // list/show`, `mg-axi entra role-assignment list`, `mg-axi entra pim eligible
8
+ // list` and `mg-axi entra pim active list`.
5
9
  // Operation construction stays beside its command; the shared session owns
6
10
  // URLs, credentials, paging, retries and error translation, and the SDK owns
7
11
  // TOON rendering. This module only maps flags to session calls and projects
8
12
  // rows for compact output.
9
13
  //
10
14
  // Reviewed against the v1.0 directoryrole-list, directoryrole-get,
15
+ // directoryroletemplate-list, directoryroletemplate-get,
16
+ // directoryrole-list-members, directoryrole-list-scopedmembers,
17
+ // scopedrolemembership-list, scopedrolemembership-get,
11
18
  // rbacapplication-list-roleassignments,
12
19
  // rbacapplication-list-roleeligibilityscheduleinstances and
13
20
  // rbacapplication-list-roleassignmentscheduleinstances operation
14
- // documentation on 2026-10-04. Directory-role reads and role
15
- // assignments read D/A RoleManagement.Read.Directory; eligible PIM reads
21
+ // documentation on 2026-10-04 (roles, assignments and PIM) and 2026-10-06
22
+ // (templates, members, scoped members and scoped memberships). Directory-role reads, role
23
+ // assignments, templates, members, scoped members and scoped memberships
24
+ // read D/A RoleManagement.Read.Directory; eligible PIM reads
16
25
  // D/A RoleEligibilitySchedule.Read.Directory; active PIM reads D/A
17
26
  // RoleAssignmentSchedule.Read.Directory. Delegated callers additionally need
18
27
  // a supported administrator role per operation. None of these collections
19
28
  // documents an advanced-query contract, so --filter passes through as plain
20
29
  // $filter with no $count or ConsistencyLevel attached.
21
30
  //
22
- // The four views cover: directoryRoles are activated role
31
+ // The views cover: directoryRoles are activated role
23
32
  // instances only (a role appears here after activation, never before);
33
+ // directoryRoleTemplates describe every role definition whether activated or
34
+ // not; members are the principals assigned to one activated role (@odata.type
35
+ // names each member kind: user, group, device or service principal); scoped
36
+ // members bind one role to one principal within one directory scope;
37
+ // scopedRoleMemberships are the administrative-unit-scoped admin assignments
38
+ // (directory-wide assignments live on roleAssignments);
24
39
  // roleAssignments include direct and PIM-activated assignments; eligible schedule
25
40
  // instances are PIM-eligible but not active; active schedule instances cover
26
41
  // both directly assigned (assignmentType Assigned) and activated eligible
@@ -56,10 +71,31 @@ export const KNOWN_ELIGIBLE_FIELDS = [
56
71
  "endDateTime",
57
72
  ];
58
73
  export const KNOWN_ACTIVE_FIELDS = [...KNOWN_ELIGIBLE_FIELDS, "assignmentType"];
74
+ export const KNOWN_TEMPLATE_FIELDS = [
75
+ "id",
76
+ "displayName",
77
+ "description",
78
+ ];
79
+ export const KNOWN_ROLE_MEMBER_FIELDS = [
80
+ "id",
81
+ "displayName",
82
+ "mail",
83
+ "userPrincipalName",
84
+ ];
85
+ export const KNOWN_SCOPED_MEMBER_FIELDS = [
86
+ "id",
87
+ "principalId",
88
+ "roleId",
89
+ "directoryScopeId",
90
+ "administrativeUnitId",
91
+ ];
59
92
  const ROLE_KNOWN = new Set(KNOWN_ROLE_FIELDS);
60
93
  const ASSIGNMENT_KNOWN = new Set(KNOWN_ASSIGNMENT_FIELDS);
61
94
  const ELIGIBLE_KNOWN = new Set(KNOWN_ELIGIBLE_FIELDS);
62
95
  const ACTIVE_KNOWN = new Set(KNOWN_ACTIVE_FIELDS);
96
+ const TEMPLATE_KNOWN = new Set(KNOWN_TEMPLATE_FIELDS);
97
+ const MEMBER_KNOWN = new Set(KNOWN_ROLE_MEMBER_FIELDS);
98
+ const SCOPED_KNOWN = new Set(KNOWN_SCOPED_MEMBER_FIELDS);
63
99
  // Compact rows: identifiers plus the correlation keys. For built-in roles
64
100
  // the unified roleDefinitionId matches the directory-role roleTemplateId.
65
101
  const DEFAULT_ROLE_LIST_SELECT = ["id", "displayName", "description", "roleTemplateId"];
@@ -67,6 +103,11 @@ const DEFAULT_ROLE_SHOW_SELECT = [...KNOWN_ROLE_FIELDS];
67
103
  const DEFAULT_ASSIGNMENT_SELECT = ["id", "principalId", "roleDefinitionId", "directoryScopeId"];
68
104
  const DEFAULT_ELIGIBLE_SELECT = ["id", "principalId", "roleDefinitionId", "memberType"];
69
105
  const DEFAULT_ACTIVE_SELECT = ["id", "principalId", "roleDefinitionId", "assignmentType", "memberType"];
106
+ const DEFAULT_TEMPLATE_LIST_SELECT = ["id", "displayName", "description"];
107
+ const DEFAULT_TEMPLATE_SHOW_SELECT = [...KNOWN_TEMPLATE_FIELDS];
108
+ const DEFAULT_MEMBER_SELECT = ["id", "displayName"];
109
+ const DEFAULT_SCOPED_LIST_SELECT = ["id", "principalId", "roleId", "directoryScopeId"];
110
+ const DEFAULT_SCOPED_SHOW_SELECT = [...KNOWN_SCOPED_MEMBER_FIELDS];
70
111
  export const DEFAULT_ROLE_SCOPES = ["https://graph.microsoft.com/RoleManagement.Read.Directory"];
71
112
  export const DEFAULT_ELIGIBLE_SCOPES = ["https://graph.microsoft.com/RoleEligibilitySchedule.Read.Directory"];
72
113
  export const DEFAULT_ACTIVE_SCOPES = ["https://graph.microsoft.com/RoleAssignmentSchedule.Read.Directory"];
@@ -114,9 +155,14 @@ function truncateValue(value, full) {
114
155
  }
115
156
  // Local projection preserves Graph's null/missing distinction: an explicit
116
157
  // null stays null, an absent property stays absent and is never synthesized.
117
- function project(row, fields, full) {
158
+ function project(row, fields, full, keepODataType = false) {
118
159
  const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
119
160
  const projected = {};
161
+ // Heterogeneous member rows carry their kind in @odata.type, which is
162
+ // structural rather than a selectable property, so it rides along first.
163
+ if (keepODataType && typeof source["@odata.type"] === "string") {
164
+ projected["@odata.type"] = source["@odata.type"];
165
+ }
120
166
  let truncated = false;
121
167
  for (const field of fields) {
122
168
  if (!Object.hasOwn(source, field))
@@ -174,9 +220,14 @@ const ACTIVE_DENIAL_HINTS = [
174
220
  "Active PIM reads need RoleAssignmentSchedule.Read.Directory plus Global Reader, Security Operator, Security Reader, Security Administrator or Privileged Role Administrator for delegated access, or admin-consented RoleAssignmentSchedule.Read.Directory for application access",
175
221
  "PIM reads need P2 or ID Governance, not only P2",
176
222
  ];
223
+ const SCOPED_DENIAL_HINTS = [
224
+ "Scoped membership reads need RoleManagement.Read.Directory plus Global Reader, Security Reader or Privileged Role Administrator for delegated access, or admin-consented RoleManagement.Read.Directory for application access",
225
+ "Administrative-unit-scoped memberships need P1",
226
+ ];
177
227
  const ROLE_LIST = {
178
228
  command: "entra directory-role list",
179
229
  key: "directoryRoles",
230
+ noun: "directory roles",
180
231
  known: ROLE_KNOWN,
181
232
  knownList: KNOWN_ROLE_FIELDS,
182
233
  defaultSelect: DEFAULT_ROLE_LIST_SELECT,
@@ -188,6 +239,7 @@ const ROLE_LIST = {
188
239
  const ASSIGNMENT_LIST = {
189
240
  command: "entra role-assignment list",
190
241
  key: "roleAssignments",
242
+ noun: "role assignments",
191
243
  known: ASSIGNMENT_KNOWN,
192
244
  knownList: KNOWN_ASSIGNMENT_FIELDS,
193
245
  defaultSelect: DEFAULT_ASSIGNMENT_SELECT,
@@ -199,6 +251,7 @@ const ASSIGNMENT_LIST = {
199
251
  const PIM_ELIGIBLE = {
200
252
  command: "entra pim eligible list",
201
253
  key: "eligibleAssignments",
254
+ noun: "eligible assignments",
202
255
  known: ELIGIBLE_KNOWN,
203
256
  knownList: KNOWN_ELIGIBLE_FIELDS,
204
257
  defaultSelect: DEFAULT_ELIGIBLE_SELECT,
@@ -210,6 +263,7 @@ const PIM_ELIGIBLE = {
210
263
  const PIM_ACTIVE = {
211
264
  command: "entra pim active list",
212
265
  key: "activeAssignments",
266
+ noun: "active assignments",
213
267
  known: ACTIVE_KNOWN,
214
268
  knownList: KNOWN_ACTIVE_FIELDS,
215
269
  defaultSelect: DEFAULT_ACTIVE_SELECT,
@@ -218,10 +272,68 @@ const PIM_ACTIVE = {
218
272
  scopeNote: "Active distinguishes directly assigned (assignmentType Assigned) and activated eligible (assignmentType Activated) assignments",
219
273
  emptyNote: "0 active assignments matched; the absence of results is the answer, not an error",
220
274
  };
275
+ const TEMPLATE_LIST = {
276
+ command: "entra directory-role-template list",
277
+ key: "directoryRoleTemplates",
278
+ noun: "directory-role templates",
279
+ known: TEMPLATE_KNOWN,
280
+ knownList: KNOWN_TEMPLATE_FIELDS,
281
+ defaultSelect: DEFAULT_TEMPLATE_LIST_SELECT,
282
+ defaultScopes: DEFAULT_ROLE_SCOPES,
283
+ denialHints: ROLE_DENIAL_HINTS,
284
+ scopeNote: "Templates describe every role definition; activation state lives on directoryRoles, which appear only after activation",
285
+ emptyNote: "0 directory-role templates matched; the absence of results is the answer, not an error",
286
+ };
287
+ const MEMBER_LIST = {
288
+ command: "entra directory-role member list",
289
+ key: "roleMembers",
290
+ noun: "role members",
291
+ known: MEMBER_KNOWN,
292
+ knownList: KNOWN_ROLE_MEMBER_FIELDS,
293
+ defaultSelect: DEFAULT_MEMBER_SELECT,
294
+ defaultScopes: DEFAULT_ROLE_SCOPES,
295
+ denialHints: ROLE_DENIAL_HINTS,
296
+ scopeNote: "Members are the principals assigned to the activated role; @odata.type names each member kind (user, group, device or service principal)",
297
+ emptyNote: "0 role members matched; the absence of results is the answer, not an error",
298
+ parent: { param: "directoryRole-id", flag: "role", label: "activated directory-role object ID whose members are listed" },
299
+ keepODataType: true,
300
+ };
301
+ const SCOPED_LIST = {
302
+ command: "entra directory-role scoped-member list",
303
+ key: "scopedMembers",
304
+ noun: "scoped members",
305
+ known: SCOPED_KNOWN,
306
+ knownList: KNOWN_SCOPED_MEMBER_FIELDS,
307
+ defaultSelect: DEFAULT_SCOPED_LIST_SELECT,
308
+ defaultScopes: DEFAULT_ROLE_SCOPES,
309
+ denialHints: SCOPED_DENIAL_HINTS,
310
+ scopeNote: "Scoped members bind one role to one principal within one directory scope; administrative-unit scopes need P1",
311
+ emptyNote: "0 scoped members matched; the absence of results is the answer, not an error",
312
+ parent: { param: "directoryRole-id", flag: "role", label: "activated directory-role object ID whose scoped members are listed" },
313
+ };
314
+ const MEMBERSHIP_LIST = {
315
+ command: "entra scoped-role-membership list",
316
+ key: "scopedRoleMemberships",
317
+ noun: "scoped role memberships",
318
+ known: SCOPED_KNOWN,
319
+ knownList: KNOWN_SCOPED_MEMBER_FIELDS,
320
+ defaultSelect: DEFAULT_SCOPED_LIST_SELECT,
321
+ defaultScopes: DEFAULT_ROLE_SCOPES,
322
+ denialHints: SCOPED_DENIAL_HINTS,
323
+ scopeNote: "Scoped role memberships are administrative-unit-scoped admin assignments; directory-wide assignments live on roleAssignments",
324
+ emptyNote: "0 scoped role memberships matched; the absence of results is the answer, not an error",
325
+ };
221
326
  async function listCollection(shape, session, flags, profile, operation, help, profileName) {
222
327
  const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
223
328
  if (cursor !== undefined && !cursor.trim())
224
329
  throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
330
+ let params;
331
+ if (shape.parent !== undefined) {
332
+ const parentId = String(flags[shape.parent.flag] ?? "");
333
+ if (!parentId.trim())
334
+ throw new AxiError(`--${shape.parent.flag} needs the ${shape.parent.label}`, "VALIDATION_ERROR", [help]);
335
+ params = { [shape.parent.param]: parentId };
336
+ }
225
337
  const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
226
338
  const savedSelect = saved?.$select;
227
339
  const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, help), shape.known, shape.knownList, help);
@@ -235,6 +347,8 @@ async function listCollection(shape, session, flags, profile, operation, help, p
235
347
  if (filter !== undefined)
236
348
  query.$filter = filter;
237
349
  const args = { profile, operation, query, scopes };
350
+ if (params !== undefined)
351
+ args.params = params;
238
352
  if (cursor !== undefined)
239
353
  args.cursor = cursor;
240
354
  if (flags.all === true) {
@@ -251,11 +365,13 @@ async function listCollection(shape, session, flags, profile, operation, help, p
251
365
  const rows = [];
252
366
  let truncated = false;
253
367
  for (const row of result.value) {
254
- const projected = project(row, fields, full);
368
+ const projected = project(row, fields, full, shape.keepODataType === true);
255
369
  rows.push(projected.row);
256
370
  truncated = truncated || projected.truncated;
257
371
  }
258
- const showHint = shape.key === "directoryRoles" ? `mg-axi entra directory-role show --id <role-id> ${profileHint(profileName)}` : undefined;
372
+ const showHint = shape.key === "directoryRoles" ? `mg-axi entra directory-role show --id <role-id> ${profileHint(profileName)}`
373
+ : shape.key === "directoryRoleTemplates" ? `mg-axi entra directory-role-template show --id <template-id> ${profileHint(profileName)}`
374
+ : undefined;
259
375
  const correlateHint = shape.key === "directoryRoles"
260
376
  ? undefined
261
377
  : `For built-in roles, roleDefinitionId matches the directory-role roleTemplateId: mg-axi entra directory-role list ${profileHint(profileName)}`;
@@ -264,16 +380,17 @@ async function listCollection(shape, session, flags, profile, operation, help, p
264
380
  if (!result.complete) {
265
381
  return {
266
382
  [shape.key]: rows,
267
- count: { returned: rows.length, complete: false, reason: result.reason },
383
+ ...listTotals(rows.length, result.total, shape.noun, false),
384
+ complete: false,
385
+ reason: result.reason,
268
386
  cursor: result.cursor,
269
387
  help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, ...standing],
270
388
  };
271
389
  }
272
- const count = { returned: rows.length, complete: true };
273
390
  if (!rows.length) {
274
- return { [shape.key]: rows, count, help: [shape.emptyNote, ...standing] };
391
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.noun, true), complete: true, help: [shape.emptyNote, ...standing] };
275
392
  }
276
- return { [shape.key]: rows, count, help: [...truncationHints, ...standing] };
393
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.noun, true), complete: true, help: [...truncationHints, ...standing] };
277
394
  }
278
395
  export async function listDirectoryRoles(session, flags, profile, operation, help, profileName) {
279
396
  return listCollection(ROLE_LIST, session, flags, profile, operation, help, profileName);
@@ -308,3 +425,78 @@ export async function listPimEligible(session, flags, profile, operation, help,
308
425
  export async function listPimActive(session, flags, profile, operation, help, profileName) {
309
426
  return listCollection(PIM_ACTIVE, session, flags, profile, operation, help, profileName);
310
427
  }
428
+ const TEMPLATE_SINGLE = {
429
+ command: "entra directory-role-template show",
430
+ key: "directoryRoleTemplate",
431
+ noun: "directory-role template",
432
+ known: TEMPLATE_KNOWN,
433
+ knownList: KNOWN_TEMPLATE_FIELDS,
434
+ defaultSelect: DEFAULT_TEMPLATE_SHOW_SELECT,
435
+ defaultScopes: DEFAULT_ROLE_SCOPES,
436
+ denialHints: ROLE_DENIAL_HINTS,
437
+ };
438
+ const SCOPED_SINGLE = {
439
+ command: "entra directory-role scoped-member show",
440
+ key: "scopedMember",
441
+ noun: "scoped member",
442
+ known: SCOPED_KNOWN,
443
+ knownList: KNOWN_SCOPED_MEMBER_FIELDS,
444
+ defaultSelect: DEFAULT_SCOPED_SHOW_SELECT,
445
+ defaultScopes: DEFAULT_ROLE_SCOPES,
446
+ denialHints: SCOPED_DENIAL_HINTS,
447
+ };
448
+ const MEMBERSHIP_SINGLE = {
449
+ command: "entra scoped-role-membership show",
450
+ key: "scopedRoleMembership",
451
+ noun: "scoped role membership",
452
+ known: SCOPED_KNOWN,
453
+ knownList: KNOWN_SCOPED_MEMBER_FIELDS,
454
+ defaultSelect: DEFAULT_SCOPED_SHOW_SELECT,
455
+ defaultScopes: DEFAULT_ROLE_SCOPES,
456
+ denialHints: SCOPED_DENIAL_HINTS,
457
+ };
458
+ async function showSingle(shape, params, session, flags, profile, operation, help, profileName) {
459
+ const { select, fields } = selectedFields(flags, shape.defaultSelect, shape.known, shape.knownList, help);
460
+ const scopes = scopesFor(flags, profile, shape.defaultScopes, help);
461
+ const full = flags.full === true;
462
+ const raw = await withGuidance(shape.denialHints, () => session.execute({
463
+ profile,
464
+ operation,
465
+ params,
466
+ query: { $select: select.join(",") },
467
+ scopes,
468
+ }));
469
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
470
+ throw new AxiError(`Graph returned a malformed ${shape.noun} body`, "GRAPH_ERROR", [
471
+ `Single-${shape.noun} reads carry one object; treat anything else as unknown, not empty`,
472
+ ]);
473
+ }
474
+ const { row, truncated } = project(raw, fields, full);
475
+ if (truncated)
476
+ return { [shape.key]: row, help: [fullHint(shape.command, flags, profileName)] };
477
+ return { [shape.key]: row };
478
+ }
479
+ export async function listDirectoryRoleTemplates(session, flags, profile, operation, help, profileName) {
480
+ return listCollection(TEMPLATE_LIST, session, flags, profile, operation, help, profileName);
481
+ }
482
+ export async function showDirectoryRoleTemplate(session, flags, profile, operation, help, profileName) {
483
+ return showSingle(TEMPLATE_SINGLE, { "directoryRoleTemplate-id": String(flags.id) }, session, flags, profile, operation, help, profileName);
484
+ }
485
+ export async function listRoleMembers(session, flags, profile, operation, help, profileName) {
486
+ return listCollection(MEMBER_LIST, session, flags, profile, operation, help, profileName);
487
+ }
488
+ export async function listScopedMembers(session, flags, profile, operation, help, profileName) {
489
+ return listCollection(SCOPED_LIST, session, flags, profile, operation, help, profileName);
490
+ }
491
+ export async function showScopedMember(session, flags, profile, operation, help, profileName) {
492
+ const roleId = String(flags.role ?? "");
493
+ if (!roleId.trim())
494
+ throw new AxiError("--role needs the activated directory-role object ID owning the scoped member", "VALIDATION_ERROR", [help]);
495
+ return showSingle(SCOPED_SINGLE, { "directoryRole-id": roleId, "scopedRoleMembership-id": String(flags.id) }, session, flags, profile, operation, help, profileName);
496
+ }
497
+ export async function listScopedRoleMemberships(session, flags, profile, operation, help, profileName) {
498
+ return listCollection(MEMBERSHIP_LIST, session, flags, profile, operation, help, profileName);
499
+ }
500
+ export async function showScopedRoleMembership(session, flags, profile, operation, help, profileName) {
501
+ return showSingle(MEMBERSHIP_SINGLE, { "scopedRoleMembership-id": String(flags.id) }, session, flags, profile, operation, help, profileName);
502
+ }