@knowttl/mg-axi 0.1.2 → 0.1.4

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.
@@ -1,4 +1,5 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // EXT-03 workforce identity-providers subfamily: the read mapping behind
3
4
  // `mg-axi entra identity-provider list/show/count/available-types`. Operation construction
4
5
  // stays beside its command; the shared session owns URLs, credentials,
@@ -217,20 +218,22 @@ export async function listIdentityProviders(session, flags, profile, operation,
217
218
  if (!result.complete) {
218
219
  return {
219
220
  identityProviders: providers,
220
- count: { returned: providers.length, complete: false, reason: result.reason },
221
+ ...listTotals(providers.length, result.total, "identity providers", false),
222
+ complete: false,
223
+ reason: result.reason,
221
224
  cursor: result.cursor,
222
225
  help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, showHint, WORKFORCE_NOTE],
223
226
  };
224
227
  }
225
- const count = { returned: providers.length, complete: true };
226
228
  if (!providers.length) {
227
229
  return {
228
230
  identityProviders: providers,
229
- count,
231
+ ...listTotals(providers.length, result.total, "identity providers", true),
232
+ complete: true,
230
233
  help: [WORKFORCE_NOTE, "0 identity providers matched; the absence of results is the answer, not an error"],
231
234
  };
232
235
  }
233
- return { identityProviders: providers, count, help: [...truncationHints, showHint, WORKFORCE_NOTE] };
236
+ return { identityProviders: providers, ...listTotals(providers.length, result.total, "identity providers", true), complete: true, help: [...truncationHints, showHint, WORKFORCE_NOTE] };
234
237
  }
235
238
  export async function showIdentityProvider(session, flags, profile, operation, help, profileName) {
236
239
  const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
@@ -1,4 +1,5 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // EXT-02 lifecycle-workflows reads: the read-only workflow, workflow
3
4
  // template, task definition and tenant-settings first part, the
4
5
  // second-part top-level run and user/subject processing-result reads, the
@@ -403,6 +404,7 @@ function runId(flags, help) {
403
404
  const WORKFLOW_LIST = {
404
405
  command: "entra lifecycle workflow list",
405
406
  key: "workflows",
407
+ totalsNoun: "workflows",
406
408
  known: WORKFLOW_KNOWN,
407
409
  knownList: KNOWN_WORKFLOW_FIELDS,
408
410
  defaultSelect: DEFAULT_WORKFLOW_LIST_SELECT,
@@ -417,6 +419,7 @@ const WORKFLOW_LIST = {
417
419
  const WORKFLOW_TEMPLATE_LIST = {
418
420
  command: "entra lifecycle workflow-template list",
419
421
  key: "workflowTemplates",
422
+ totalsNoun: "workflow templates",
420
423
  known: WORKFLOW_TEMPLATE_KNOWN,
421
424
  knownList: KNOWN_WORKFLOW_TEMPLATE_FIELDS,
422
425
  defaultSelect: DEFAULT_WORKFLOW_TEMPLATE_LIST_SELECT,
@@ -431,6 +434,7 @@ const WORKFLOW_TEMPLATE_LIST = {
431
434
  const TASK_DEFINITION_LIST = {
432
435
  command: "entra lifecycle task-definition list",
433
436
  key: "taskDefinitions",
437
+ totalsNoun: "task definitions",
434
438
  known: TASK_DEFINITION_KNOWN,
435
439
  knownList: KNOWN_TASK_DEFINITION_FIELDS,
436
440
  defaultSelect: DEFAULT_TASK_DEFINITION_LIST_SELECT,
@@ -445,6 +449,7 @@ const TASK_DEFINITION_LIST = {
445
449
  const RUN_LIST = {
446
450
  command: "entra lifecycle run list",
447
451
  key: "runs",
452
+ totalsNoun: "runs",
448
453
  known: RUN_KNOWN,
449
454
  knownList: KNOWN_RUN_FIELDS,
450
455
  defaultSelect: DEFAULT_RUN_LIST_SELECT,
@@ -459,6 +464,7 @@ const RUN_LIST = {
459
464
  const USER_PROCESSING_LIST = {
460
465
  command: "entra lifecycle user-processing-result list",
461
466
  key: "userProcessingResults",
467
+ totalsNoun: "user processing results",
462
468
  known: USER_PROCESSING_KNOWN,
463
469
  knownList: KNOWN_USER_PROCESSING_FIELDS,
464
470
  defaultSelect: DEFAULT_USER_PROCESSING_LIST_SELECT,
@@ -473,6 +479,7 @@ const USER_PROCESSING_LIST = {
473
479
  const SUBJECT_PROCESSING_LIST = {
474
480
  command: "entra lifecycle subject-processing-result list",
475
481
  key: "subjectProcessingResults",
482
+ totalsNoun: "subject processing results",
476
483
  known: SUBJECT_PROCESSING_KNOWN,
477
484
  knownList: KNOWN_SUBJECT_PROCESSING_FIELDS,
478
485
  defaultSelect: DEFAULT_SUBJECT_PROCESSING_FIELDS,
@@ -488,6 +495,7 @@ const SUBJECT_PROCESSING_LIST = {
488
495
  const TASK_REPORT_LIST = {
489
496
  command: "entra lifecycle task-report list",
490
497
  key: "taskReports",
498
+ totalsNoun: "task reports",
491
499
  known: TASK_REPORT_KNOWN,
492
500
  knownList: KNOWN_TASK_REPORT_FIELDS,
493
501
  defaultSelect: DEFAULT_TASK_REPORT_LIST_SELECT,
@@ -502,6 +510,7 @@ const TASK_REPORT_LIST = {
502
510
  const RUN_USER_PROCESSING_LIST = {
503
511
  command: "entra lifecycle run user-processing-result list",
504
512
  key: "userProcessingResults",
513
+ totalsNoun: "user processing results",
505
514
  known: USER_PROCESSING_KNOWN,
506
515
  knownList: KNOWN_USER_PROCESSING_FIELDS,
507
516
  defaultSelect: DEFAULT_USER_PROCESSING_LIST_SELECT,
@@ -516,6 +525,7 @@ const RUN_USER_PROCESSING_LIST = {
516
525
  const RUN_SUBJECT_PROCESSING_LIST = {
517
526
  command: "entra lifecycle run subject-processing-result list",
518
527
  key: "subjectProcessingResults",
528
+ totalsNoun: "subject processing results",
519
529
  known: SUBJECT_PROCESSING_KNOWN,
520
530
  knownList: KNOWN_SUBJECT_PROCESSING_FIELDS,
521
531
  defaultSelect: DEFAULT_SUBJECT_PROCESSING_FIELDS,
@@ -531,6 +541,7 @@ const RUN_SUBJECT_PROCESSING_LIST = {
531
541
  const RUN_TASK_PROCESSING_LIST = {
532
542
  command: "entra lifecycle run task-processing-result list",
533
543
  key: "taskProcessingResults",
544
+ totalsNoun: "task processing results",
534
545
  known: TASK_PROCESSING_KNOWN,
535
546
  knownList: KNOWN_TASK_PROCESSING_FIELDS,
536
547
  defaultSelect: DEFAULT_TASK_PROCESSING_LIST_SELECT,
@@ -605,16 +616,17 @@ async function listCollection(shape, session, flags, profile, operation, help, p
605
616
  if (!result.complete) {
606
617
  return {
607
618
  [shape.key]: rows,
608
- count: { returned: rows.length, complete: false, reason: result.reason },
619
+ ...listTotals(rows.length, result.total, shape.totalsNoun, false),
620
+ complete: false,
621
+ reason: result.reason,
609
622
  cursor: result.cursor,
610
623
  help: [...truncationHints, resume, ...standing],
611
624
  };
612
625
  }
613
- const count = { returned: rows.length, complete: true };
614
626
  if (!rows.length) {
615
- return { [shape.key]: rows, count, help: [shape.emptyNote, ...standing] };
627
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.totalsNoun, true), complete: true, help: [shape.emptyNote, ...standing] };
616
628
  }
617
- return { [shape.key]: rows, count, help: [...truncationHints, ...standing] };
629
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.totalsNoun, true), complete: true, help: [...truncationHints, ...standing] };
618
630
  }
619
631
  async function showOne(key, command, known, knownList, defaultSelect, defaultScopes, hints, session, flags, profile, operation, help, profileName, params, malformed, queryless = false) {
620
632
  if (queryless && flags.select !== undefined) {
@@ -1,4 +1,5 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // EXT-04 multi-tenant-organization subfamily: the read mapping behind
3
4
  // `mg-axi entra multi-tenant-organization show`,
4
5
  // `mg-axi entra multi-tenant-organization join-request show`,
@@ -276,20 +277,22 @@ export async function listMultiTenantOrganizationTenants(session, flags, profile
276
277
  if (!result.complete) {
277
278
  return {
278
279
  multiTenantOrganizationTenants: rows,
279
- count: { returned: rows.length, complete: false, reason: result.reason },
280
+ ...listTotals(rows.length, result.total, "tenants", false),
281
+ complete: false,
282
+ reason: result.reason,
280
283
  cursor: result.cursor,
281
284
  help: [...truncationHints, resumeHint(profileName), countHint, MTO_NOTE],
282
285
  };
283
286
  }
284
- const count = { returned: rows.length, complete: true };
285
287
  if (!rows.length) {
286
288
  return {
287
289
  multiTenantOrganizationTenants: rows,
288
- count,
290
+ ...listTotals(rows.length, result.total, "tenants", true),
291
+ complete: true,
289
292
  help: ["0 tenants matched; the absence of results is the answer, not an error", MTO_NOTE],
290
293
  };
291
294
  }
292
- return { multiTenantOrganizationTenants: rows, count, help: [...truncationHints, countHint, MTO_NOTE] };
295
+ return { multiTenantOrganizationTenants: rows, ...listTotals(rows.length, result.total, "tenants", true), complete: true, help: [...truncationHints, countHint, MTO_NOTE] };
293
296
  }
294
297
  // The $count route returns a text/plain integer scalar rather than a JSON
295
298
  // collection, so the leaf reads it through session.execute and accepts only
@@ -1,4 +1,5 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // EXT-01 on-premises-synchronization subfamily: the read mapping behind
3
4
  // `mg-axi entra on-premises-synchronization list/show`. Operation
4
5
  // construction stays beside its command; the shared session owns URLs,
@@ -201,16 +202,17 @@ export async function listSynchronizations(session, flags, profile, operation, h
201
202
  if (!result.complete) {
202
203
  return {
203
204
  synchronizations,
204
- count: { returned: synchronizations.length, complete: false, reason: result.reason },
205
+ ...listTotals(synchronizations.length, result.total, "synchronizations", false),
206
+ complete: false,
207
+ reason: result.reason,
205
208
  cursor: result.cursor,
206
209
  help: [...truncationHints, resumeHint(profileName), showHint],
207
210
  };
208
211
  }
209
- const count = { returned: synchronizations.length, complete: true };
210
212
  if (!synchronizations.length) {
211
- return { synchronizations, count, help: [EMPTY_NOTE] };
213
+ return { synchronizations, ...listTotals(synchronizations.length, result.total, "synchronizations", true), complete: true, help: [EMPTY_NOTE] };
212
214
  }
213
- return { synchronizations, count, help: [...truncationHints, showHint] };
215
+ return { synchronizations, ...listTotals(synchronizations.length, result.total, "synchronizations", true), complete: true, help: [...truncationHints, showHint] };
214
216
  }
215
217
  export async function showSynchronization(session, flags, profile, operation, help, profileName) {
216
218
  const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
@@ -1,4 +1,5 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // EXT-03 risk-prevention subfamily: the read mapping behind
3
4
  // `mg-axi entra fraud-protection-provider list/show/count`,
4
5
  // `mg-axi entra web-application-firewall-provider list/show/count` and
@@ -127,6 +128,7 @@ export const DEFAULT_RISK_SCOPES = ["https://graph.microsoft.com/RiskPreventionP
127
128
  const TRUNCATE_AT = 500;
128
129
  const FRAUD_KIND = {
129
130
  noun: "fraud-protection-provider",
131
+ totalsNoun: "fraud protection providers",
130
132
  known: KNOWN_FRAUD,
131
133
  knownList: KNOWN_FRAUD_FIELDS,
132
134
  listSelect: DEFAULT_FRAUD_LIST_SELECT,
@@ -137,6 +139,7 @@ const FRAUD_KIND = {
137
139
  };
138
140
  const WAF_KIND = {
139
141
  noun: "web-application-firewall-provider",
142
+ totalsNoun: "web application firewall providers",
140
143
  known: KNOWN_WAF,
141
144
  knownList: KNOWN_WAF_FIELDS,
142
145
  listSelect: DEFAULT_WAF_LIST_SELECT,
@@ -147,6 +150,7 @@ const WAF_KIND = {
147
150
  };
148
151
  const VERIFICATION_KIND = {
149
152
  noun: "web-application-firewall-verification",
153
+ totalsNoun: "web application firewall verifications",
150
154
  known: KNOWN_VERIFICATIONS,
151
155
  knownList: KNOWN_VERIFICATION_FIELDS,
152
156
  listSelect: DEFAULT_VERIFICATION_LIST_SELECT,
@@ -344,20 +348,22 @@ async function collectKind(session, flags, kind, command, showCommand, profile,
344
348
  if (!result.complete) {
345
349
  return {
346
350
  [kind.collectionKey]: rows,
347
- count: { returned: rows.length, complete: false, reason: result.reason },
351
+ ...listTotals(rows.length, result.total, kind.totalsNoun, false),
352
+ complete: false,
353
+ reason: result.reason,
348
354
  cursor: result.cursor,
349
355
  help: [...truncationHints, resumeHint(profileName), showHint, WORKFORCE_NOTE],
350
356
  };
351
357
  }
352
- const count = { returned: rows.length, complete: true };
353
358
  if (!rows.length) {
354
359
  return {
355
360
  [kind.collectionKey]: rows,
356
- count,
361
+ ...listTotals(rows.length, result.total, kind.totalsNoun, true),
362
+ complete: true,
357
363
  help: [`0 ${kind.noun}s matched; the absence of results is the answer, not an error`, WORKFORCE_NOTE],
358
364
  };
359
365
  }
360
- return { [kind.collectionKey]: rows, count, help: [...truncationHints, showHint, WORKFORCE_NOTE] };
366
+ return { [kind.collectionKey]: rows, ...listTotals(rows.length, result.total, kind.totalsNoun, true), complete: true, help: [...truncationHints, showHint, WORKFORCE_NOTE] };
361
367
  }
362
368
  async function showKind(session, flags, kind, command, param, profile, operation, help, profileName) {
363
369
  const { select, fields } = selectedFields(flags, kind, kind.showSelect, help);
@@ -1,4 +1,5 @@
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
5
  // list/show`, extended with the workload-identity surface behind
@@ -313,6 +314,7 @@ function parentServicePrincipal(flags, help) {
313
314
  const RISKY_USER_LIST = {
314
315
  noun: "risky-user",
315
316
  key: "riskyUsers",
317
+ totalsNoun: "risky users",
316
318
  known: RISKY_USER_KNOWN,
317
319
  knownList: KNOWN_RISKY_USER_FIELDS,
318
320
  defaultSelect: DEFAULT_RISKY_USER_LIST_SELECT,
@@ -329,6 +331,7 @@ const RISKY_USER_LIST = {
329
331
  const RISK_DETECTION_LIST = {
330
332
  noun: "risk-detection",
331
333
  key: "riskDetections",
334
+ totalsNoun: "risk detections",
332
335
  dateField: "detectedDateTime",
333
336
  known: RISK_DETECTION_KNOWN,
334
337
  knownList: KNOWN_RISK_DETECTION_FIELDS,
@@ -354,6 +357,7 @@ const DEFAULT_SP_DETECTION_SHOW_SELECT = [...KNOWN_SP_DETECTION_FIELDS];
354
357
  const RISKY_SP_LIST = {
355
358
  noun: "risky-service-principal",
356
359
  key: "riskyServicePrincipals",
360
+ totalsNoun: "risky service principals",
357
361
  known: RISKY_SP_KNOWN,
358
362
  knownList: KNOWN_RISKY_SP_FIELDS,
359
363
  defaultSelect: DEFAULT_RISKY_SP_LIST_SELECT,
@@ -370,6 +374,7 @@ const RISKY_SP_LIST = {
370
374
  const RISKY_SP_HISTORY_LIST = {
371
375
  noun: "risky-service-principal history",
372
376
  key: "riskyServicePrincipalHistory",
377
+ totalsNoun: "history items",
373
378
  pathParams: parentServicePrincipal,
374
379
  known: RISKY_SP_HISTORY_KNOWN,
375
380
  knownList: KNOWN_RISKY_SP_HISTORY_FIELDS,
@@ -386,6 +391,7 @@ const RISKY_SP_HISTORY_LIST = {
386
391
  const SP_DETECTION_LIST = {
387
392
  noun: "service-principal-risk-detection",
388
393
  key: "servicePrincipalRiskDetections",
394
+ totalsNoun: "service principal risk detections",
389
395
  dateField: "detectedDateTime",
390
396
  known: SP_DETECTION_KNOWN,
391
397
  knownList: KNOWN_SP_DETECTION_FIELDS,
@@ -468,16 +474,17 @@ async function listRisk(shape, session, flags, profile, operation, help, profile
468
474
  if (!result.complete) {
469
475
  return {
470
476
  [shape.key]: rows,
471
- 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,
472
480
  cursor: result.cursor,
473
481
  help: [...truncationHints, `Resume losslessly with the same flags plus --cursor - ${profileHint(profileName)} and supply the returned cursor on stdin`, showHint, ...shape.extraHelp(profileName)],
474
482
  };
475
483
  }
476
- const count = { returned: rows.length, complete: true };
477
484
  if (!rows.length) {
478
- 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) };
479
486
  }
480
- 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)] };
481
488
  }
482
489
  const RISKY_USER_SHOW = {
483
490
  noun: "risky-user",
@@ -1,27 +1,41 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
2
  import { listTotals } from "./list-totals.js";
3
3
  // READ-09: the Entra directory-role and PIM read mapping behind
4
- // `mg-axi entra directory-role list/show`, `mg-axi entra role-assignment
5
- // 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`.
6
9
  // Operation construction stays beside its command; the shared session owns
7
10
  // URLs, credentials, paging, retries and error translation, and the SDK owns
8
11
  // TOON rendering. This module only maps flags to session calls and projects
9
12
  // rows for compact output.
10
13
  //
11
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,
12
18
  // rbacapplication-list-roleassignments,
13
19
  // rbacapplication-list-roleeligibilityscheduleinstances and
14
20
  // rbacapplication-list-roleassignmentscheduleinstances operation
15
- // documentation on 2026-10-04. Directory-role reads and role
16
- // 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
17
25
  // D/A RoleEligibilitySchedule.Read.Directory; active PIM reads D/A
18
26
  // RoleAssignmentSchedule.Read.Directory. Delegated callers additionally need
19
27
  // a supported administrator role per operation. None of these collections
20
28
  // documents an advanced-query contract, so --filter passes through as plain
21
29
  // $filter with no $count or ConsistencyLevel attached.
22
30
  //
23
- // The four views cover: directoryRoles are activated role
31
+ // The views cover: directoryRoles are activated role
24
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);
25
39
  // roleAssignments include direct and PIM-activated assignments; eligible schedule
26
40
  // instances are PIM-eligible but not active; active schedule instances cover
27
41
  // both directly assigned (assignmentType Assigned) and activated eligible
@@ -57,10 +71,31 @@ export const KNOWN_ELIGIBLE_FIELDS = [
57
71
  "endDateTime",
58
72
  ];
59
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
+ ];
60
92
  const ROLE_KNOWN = new Set(KNOWN_ROLE_FIELDS);
61
93
  const ASSIGNMENT_KNOWN = new Set(KNOWN_ASSIGNMENT_FIELDS);
62
94
  const ELIGIBLE_KNOWN = new Set(KNOWN_ELIGIBLE_FIELDS);
63
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);
64
99
  // Compact rows: identifiers plus the correlation keys. For built-in roles
65
100
  // the unified roleDefinitionId matches the directory-role roleTemplateId.
66
101
  const DEFAULT_ROLE_LIST_SELECT = ["id", "displayName", "description", "roleTemplateId"];
@@ -68,6 +103,11 @@ const DEFAULT_ROLE_SHOW_SELECT = [...KNOWN_ROLE_FIELDS];
68
103
  const DEFAULT_ASSIGNMENT_SELECT = ["id", "principalId", "roleDefinitionId", "directoryScopeId"];
69
104
  const DEFAULT_ELIGIBLE_SELECT = ["id", "principalId", "roleDefinitionId", "memberType"];
70
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];
71
111
  export const DEFAULT_ROLE_SCOPES = ["https://graph.microsoft.com/RoleManagement.Read.Directory"];
72
112
  export const DEFAULT_ELIGIBLE_SCOPES = ["https://graph.microsoft.com/RoleEligibilitySchedule.Read.Directory"];
73
113
  export const DEFAULT_ACTIVE_SCOPES = ["https://graph.microsoft.com/RoleAssignmentSchedule.Read.Directory"];
@@ -115,9 +155,14 @@ function truncateValue(value, full) {
115
155
  }
116
156
  // Local projection preserves Graph's null/missing distinction: an explicit
117
157
  // null stays null, an absent property stays absent and is never synthesized.
118
- function project(row, fields, full) {
158
+ function project(row, fields, full, keepODataType = false) {
119
159
  const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
120
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
+ }
121
166
  let truncated = false;
122
167
  for (const field of fields) {
123
168
  if (!Object.hasOwn(source, field))
@@ -175,6 +220,10 @@ const ACTIVE_DENIAL_HINTS = [
175
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",
176
221
  "PIM reads need P2 or ID Governance, not only P2",
177
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
+ ];
178
227
  const ROLE_LIST = {
179
228
  command: "entra directory-role list",
180
229
  key: "directoryRoles",
@@ -223,10 +272,68 @@ const PIM_ACTIVE = {
223
272
  scopeNote: "Active distinguishes directly assigned (assignmentType Assigned) and activated eligible (assignmentType Activated) assignments",
224
273
  emptyNote: "0 active assignments matched; the absence of results is the answer, not an error",
225
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
+ };
226
326
  async function listCollection(shape, session, flags, profile, operation, help, profileName) {
227
327
  const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
228
328
  if (cursor !== undefined && !cursor.trim())
229
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
+ }
230
337
  const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
231
338
  const savedSelect = saved?.$select;
232
339
  const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, help), shape.known, shape.knownList, help);
@@ -240,6 +347,8 @@ async function listCollection(shape, session, flags, profile, operation, help, p
240
347
  if (filter !== undefined)
241
348
  query.$filter = filter;
242
349
  const args = { profile, operation, query, scopes };
350
+ if (params !== undefined)
351
+ args.params = params;
243
352
  if (cursor !== undefined)
244
353
  args.cursor = cursor;
245
354
  if (flags.all === true) {
@@ -256,11 +365,13 @@ async function listCollection(shape, session, flags, profile, operation, help, p
256
365
  const rows = [];
257
366
  let truncated = false;
258
367
  for (const row of result.value) {
259
- const projected = project(row, fields, full);
368
+ const projected = project(row, fields, full, shape.keepODataType === true);
260
369
  rows.push(projected.row);
261
370
  truncated = truncated || projected.truncated;
262
371
  }
263
- 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;
264
375
  const correlateHint = shape.key === "directoryRoles"
265
376
  ? undefined
266
377
  : `For built-in roles, roleDefinitionId matches the directory-role roleTemplateId: mg-axi entra directory-role list ${profileHint(profileName)}`;
@@ -314,3 +425,78 @@ export async function listPimEligible(session, flags, profile, operation, help,
314
425
  export async function listPimActive(session, flags, profile, operation, help, profileName) {
315
426
  return listCollection(PIM_ACTIVE, session, flags, profile, operation, help, profileName);
316
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
+ }