@knowttl/mg-axi 0.1.1 → 0.1.2

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 (40) hide show
  1. package/README.md +149 -38
  2. package/dist/api.js +134 -8
  3. package/dist/bin/mg-axi-hook.js +31 -0
  4. package/dist/catalogue.js +390 -2
  5. package/dist/cli.js +116 -8
  6. package/dist/docs.js +45 -5
  7. package/dist/doctor.js +2 -2
  8. package/dist/entra-access-reviews.js +11 -4
  9. package/dist/entra-apps.js +13 -8
  10. package/dist/entra-audit-logs.js +8 -4
  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 +9 -4
  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-group-pim.js +431 -0
  25. package/dist/entra-groups.js +13 -8
  26. package/dist/entra-lifecycle-workflows.js +136 -9
  27. package/dist/entra-organization.js +13 -8
  28. package/dist/entra-risk-prevention.js +436 -0
  29. package/dist/entra-risk.js +213 -5
  30. package/dist/entra-roles.js +10 -4
  31. package/dist/entra-subscriptions.js +6 -4
  32. package/dist/entra-users.js +7 -4
  33. package/dist/graph-session.js +29 -5
  34. package/dist/hook.js +84 -0
  35. package/dist/list-totals.js +52 -0
  36. package/dist/setup.js +5 -0
  37. package/docs/coverage.md +79 -13
  38. package/inventory/operations.json +2 -2
  39. package/package.json +4 -3
  40. package/skills/mg-axi/SKILL.md +56 -1
@@ -1,4 +1,5 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // EXT-01 domains subfamily: the read mapping behind `mg-axi entra domain
3
4
  // list/show`, the per-domain verification and service-configuration DNS
4
5
  // record reads, and the top-level domain DNS record reads. Operation
@@ -254,20 +255,22 @@ export async function listDomains(session, flags, profile, operation, help, prof
254
255
  if (!result.complete) {
255
256
  return {
256
257
  domains,
257
- count: { returned: domains.length, complete: false, reason: result.reason },
258
+ ...listTotals(domains.length, result.total, "domains", false),
259
+ complete: false,
260
+ reason: result.reason,
258
261
  cursor: result.cursor,
259
262
  help: [...truncationHints, resumeHint(profileName), showHint, filterNote],
260
263
  };
261
264
  }
262
- const count = { returned: domains.length, complete: true };
263
265
  if (!domains.length) {
264
266
  return {
265
267
  domains,
266
- count,
268
+ ...listTotals(domains.length, result.total, "domains", true),
269
+ complete: true,
267
270
  help: [filterNote, "0 domains matched; the absence of results is the answer, not an error"],
268
271
  };
269
272
  }
270
- return { domains, count, help: [...truncationHints, showHint, filterNote] };
273
+ return { domains, ...listTotals(domains.length, result.total, "domains", true), complete: true, help: [...truncationHints, showHint, filterNote] };
271
274
  }
272
275
  export async function showDomain(session, flags, profile, operation, help, profileName) {
273
276
  const { select, fields } = selectedFields(flags, KNOWN_DOMAINS, KNOWN_DOMAIN_FIELDS, DEFAULT_DOMAIN_SHOW_SELECT, help);
@@ -321,20 +324,22 @@ async function listDnsRecords(session, flags, profile, operation, help, profileN
321
324
  if (!result.complete) {
322
325
  return {
323
326
  [command.collectionKey]: records,
324
- count: { returned: records.length, complete: false, reason: result.reason },
327
+ ...listTotals(records.length, result.total, "DNS records", false),
328
+ complete: false,
329
+ reason: result.reason,
325
330
  cursor: result.cursor,
326
331
  help: [...truncationHints, resumeHint(profileName), showHint, kindHint],
327
332
  };
328
333
  }
329
- const count = { returned: records.length, complete: true };
330
334
  if (!records.length) {
331
335
  return {
332
336
  [command.collectionKey]: records,
333
- count,
337
+ ...listTotals(records.length, result.total, "DNS records", true),
338
+ complete: true,
334
339
  help: [kindHint, "0 DNS records matched; the absence of results is the answer, not an error"],
335
340
  };
336
341
  }
337
- return { [command.collectionKey]: records, count, help: [...truncationHints, showHint, kindHint] };
342
+ return { [command.collectionKey]: records, ...listTotals(records.length, result.total, "DNS records", true), complete: true, help: [...truncationHints, showHint, kindHint] };
338
343
  }
339
344
  async function showDnsRecord(session, flags, profile, operation, help, profileName, command) {
340
345
  const { select, fields } = selectedFields(flags, KNOWN_DNS, KNOWN_DNS_FIELDS, DEFAULT_DNS_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 entitlement-management reads: the read-only catalog and
3
4
  // access-package mapping plus the assignment and request reads. See
4
5
  // README.md for the supported commands and usage. Operation construction
@@ -348,6 +349,7 @@ function flattenRequestLinks(source) {
348
349
  const CATALOG_LIST = {
349
350
  command: "entra entitlement catalog list",
350
351
  key: "catalogs",
352
+ noun: "catalogs",
351
353
  known: CATALOG_KNOWN,
352
354
  knownList: KNOWN_CATALOG_FIELDS,
353
355
  defaultSelect: DEFAULT_CATALOG_LIST_SELECT,
@@ -360,6 +362,7 @@ const CATALOG_LIST = {
360
362
  const ACCESS_PACKAGE_LIST = {
361
363
  command: "entra entitlement access-package list",
362
364
  key: "accessPackages",
365
+ noun: "access packages",
363
366
  known: ACCESS_PACKAGE_KNOWN,
364
367
  knownList: KNOWN_ACCESS_PACKAGE_FIELDS,
365
368
  defaultSelect: DEFAULT_ACCESS_PACKAGE_LIST_SELECT,
@@ -372,6 +375,7 @@ const ACCESS_PACKAGE_LIST = {
372
375
  const ASSIGNMENT_POLICY_LIST = {
373
376
  command: "entra entitlement assignment-policy list",
374
377
  key: "assignmentPolicies",
378
+ noun: "assignment policies",
375
379
  known: ASSIGNMENT_POLICY_KNOWN,
376
380
  knownList: KNOWN_ASSIGNMENT_POLICY_FIELDS,
377
381
  defaultSelect: DEFAULT_ASSIGNMENT_POLICY_LIST_SELECT,
@@ -384,6 +388,7 @@ const ASSIGNMENT_POLICY_LIST = {
384
388
  const RESOURCE_ROLE_SCOPE_LIST = {
385
389
  command: "entra entitlement resource-role-scope list",
386
390
  key: "resourceRoleScopes",
391
+ noun: "resource-role scopes",
387
392
  known: RESOURCE_ROLE_SCOPE_KNOWN,
388
393
  knownList: KNOWN_RESOURCE_ROLE_SCOPE_FIELDS,
389
394
  defaultSelect: DEFAULT_RESOURCE_ROLE_SCOPE_LIST_SELECT,
@@ -396,6 +401,7 @@ const RESOURCE_ROLE_SCOPE_LIST = {
396
401
  const ASSIGNMENT_LIST = {
397
402
  command: "entra entitlement assignment list",
398
403
  key: "assignments",
404
+ noun: "assignments",
399
405
  known: ASSIGNMENT_KNOWN,
400
406
  knownList: KNOWN_ASSIGNMENT_FIELDS,
401
407
  defaultSelect: DEFAULT_ASSIGNMENT_LIST_SELECT,
@@ -413,6 +419,7 @@ const ASSIGNMENT_LIST = {
413
419
  const ASSIGNMENT_REQUEST_LIST = {
414
420
  command: "entra entitlement assignment-request list",
415
421
  key: "assignmentRequests",
422
+ noun: "assignment requests",
416
423
  known: ASSIGNMENT_REQUEST_KNOWN,
417
424
  knownList: KNOWN_ASSIGNMENT_REQUEST_FIELDS,
418
425
  defaultSelect: DEFAULT_ASSIGNMENT_REQUEST_LIST_SELECT,
@@ -501,16 +508,17 @@ async function listCollection(shape, session, flags, profile, operation, help, p
501
508
  if (!result.complete) {
502
509
  return {
503
510
  [shape.key]: rows,
504
- count: { returned: rows.length, complete: false, reason: result.reason },
511
+ ...listTotals(rows.length, result.total, shape.noun, false),
512
+ complete: false,
513
+ reason: result.reason,
505
514
  cursor: result.cursor,
506
515
  help: [...truncationHints, resumeHint, ...standing],
507
516
  };
508
517
  }
509
- const count = { returned: rows.length, complete: true };
510
518
  if (!rows.length) {
511
- return { [shape.key]: rows, count, help: [shape.emptyNote, ...standing] };
519
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.noun, true), complete: true, help: [shape.emptyNote, ...standing] };
512
520
  }
513
- return { [shape.key]: rows, count, help: [...truncationHints, ...standing] };
521
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.noun, true), complete: true, help: [...truncationHints, ...standing] };
514
522
  }
515
523
  async function showOne(key, command, known, knownList, defaultSelect, session, flags, profile, operation, help, profileName, params, malformed, extra) {
516
524
  const { select, fields, fetch } = selectedFields(flags, defaultSelect, known, knownList, help, extra?.synthetic);
@@ -0,0 +1,431 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
3
+ // EXT-02 group PIM schedule reads: the read-only schedule and instance
4
+ // mapping behind `mg-axi entra pim group-assignment-schedule list/show`,
5
+ // `mg-axi entra pim group-assignment-instance list/show`,
6
+ // `mg-axi entra pim group-eligibility-schedule list/show`,
7
+ // `mg-axi entra pim group-eligibility-instance list/show` and
8
+ // `mg-axi entra pim group-eligibility-request list/show`.
9
+ // Operation construction stays beside its command; the shared session owns
10
+ // URLs, credentials, paging, retries and error translation, and the SDK owns
11
+ // TOON rendering. This module only maps flags to session calls and projects
12
+ // rows for compact output.
13
+ //
14
+ // Reviewed against the v1.0 privilegedaccessgroup-list-assignmentschedules,
15
+ // privilegedaccessgroupassignmentschedule-get,
16
+ // privilegedaccessgroup-list-assignmentscheduleinstances,
17
+ // privilegedaccessgroupassignmentscheduleinstance-get,
18
+ // privilegedaccessgroup-list-eligibilityschedules,
19
+ // privilegedaccessgroupeligibilityschedule-get,
20
+ // privilegedaccessgroup-list-eligibilityscheduleinstances,
21
+ // privilegedaccessgroupeligibilityscheduleinstance-get,
22
+ // privilegedaccessgroup-list-eligibilityschedulerequests and
23
+ // privilegedaccessgroupeligibilityschedulerequest-get operation
24
+ // documentation and the privilegedAccessGroupAssignmentSchedule,
25
+ // privilegedAccessGroupAssignmentScheduleInstance,
26
+ // privilegedAccessGroupEligibilitySchedule,
27
+ // privilegedAccessGroupEligibilityScheduleInstance and
28
+ // privilegedAccessGroupEligibilityScheduleRequest resource contracts on
29
+ // 2026-10-06, with a second check against the Graph permissions reference
30
+ // and the ID Governance licensing fundamentals. Assignment reads take D/A
31
+ // PrivilegedAssignmentSchedule.Read.AzureADGroup; eligibility reads take
32
+ // D/A PrivilegedEligibilitySchedule.Read.AzureADGroup. Delegated callers
33
+ // additionally need owner/member of the group or a supported directory
34
+ // role (Global Reader or Privileged Role Administrator for role-assignable
35
+ // groups; Global Reader, Directory Writer, Groups Administrator, Identity
36
+ // Governance Administrator or User Administrator otherwise), scoped at
37
+ // directory level; delegated personal Microsoft accounts are not
38
+ // supported. PIM for Groups needs P2 or ID Governance.
39
+ //
40
+ // Every list requires $filter (eq) scoped to a groupId or a principalId,
41
+ // so --filter is required and must name one of them; the requirement is
42
+ // validated before credentials. Lists document $select/$filter/$expand, so
43
+ // --filter passes through as plain $filter with no $count or
44
+ // ConsistencyLevel attached; singles document $select with $expand.
45
+ // These collections document no $count contract, so totals stay honest as
46
+ // count { returned, complete } like the extended role PIM reads rather
47
+ // than the shared list-totals helper, which applies where $count=true is
48
+ // sent. Deferred with reason, never built on a write scope:
49
+ // assignmentScheduleRequests list/get take
50
+ // PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup as least privilege.
51
+ // Out of scope: approvals, filterByCurrentUser functions, nested
52
+ // group/principal/activatedUsing/targetSchedule navigation (scalar linkage
53
+ // ids such as assignmentScheduleId stay in), resources, every
54
+ // count/ref/cast tail, all mutations and beta.
55
+ // Schedules carry the full lifecycle (scheduleInfo, status, audit stamps);
56
+ // instances carry one provisioned window plus its parent schedule linkage;
57
+ // requests carry the ask (action, justification, scheduleInfo, ticketInfo)
58
+ // plus its outcome (status, approvalId, targetScheduleId).
59
+ export const KNOWN_ASSIGNMENT_SCHEDULE_FIELDS = [
60
+ "id",
61
+ "accessId",
62
+ "assignmentType",
63
+ "memberType",
64
+ "principalId",
65
+ "groupId",
66
+ "status",
67
+ "scheduleInfo",
68
+ "createdDateTime",
69
+ "modifiedDateTime",
70
+ "createdUsing",
71
+ ];
72
+ export const KNOWN_ASSIGNMENT_INSTANCE_FIELDS = [
73
+ "id",
74
+ "accessId",
75
+ "assignmentType",
76
+ "memberType",
77
+ "principalId",
78
+ "groupId",
79
+ "assignmentScheduleId",
80
+ "startDateTime",
81
+ "endDateTime",
82
+ ];
83
+ export const KNOWN_ELIGIBILITY_SCHEDULE_FIELDS = [
84
+ "id",
85
+ "accessId",
86
+ "memberType",
87
+ "principalId",
88
+ "groupId",
89
+ "status",
90
+ "scheduleInfo",
91
+ "createdDateTime",
92
+ "modifiedDateTime",
93
+ "createdUsing",
94
+ ];
95
+ export const KNOWN_ELIGIBILITY_INSTANCE_FIELDS = [
96
+ "id",
97
+ "accessId",
98
+ "memberType",
99
+ "principalId",
100
+ "groupId",
101
+ "eligibilityScheduleId",
102
+ "startDateTime",
103
+ "endDateTime",
104
+ ];
105
+ // Requests narrow to the ask plus its outcome: createdBy is an identitySet
106
+ // for a later slice and customData is documented as unused, so neither is
107
+ // selectable here. Justification is free text and rides only behind an
108
+ // explicit --select, never in a default.
109
+ export const KNOWN_ELIGIBILITY_REQUEST_FIELDS = [
110
+ "id",
111
+ "accessId",
112
+ "action",
113
+ "status",
114
+ "principalId",
115
+ "groupId",
116
+ "justification",
117
+ "scheduleInfo",
118
+ "ticketInfo",
119
+ "createdDateTime",
120
+ "completedDateTime",
121
+ "approvalId",
122
+ "targetScheduleId",
123
+ "isValidationOnly",
124
+ ];
125
+ const ASSIGNMENT_SCHEDULE_KNOWN = new Set(KNOWN_ASSIGNMENT_SCHEDULE_FIELDS);
126
+ const ASSIGNMENT_INSTANCE_KNOWN = new Set(KNOWN_ASSIGNMENT_INSTANCE_FIELDS);
127
+ const ELIGIBILITY_SCHEDULE_KNOWN = new Set(KNOWN_ELIGIBILITY_SCHEDULE_FIELDS);
128
+ const ELIGIBILITY_INSTANCE_KNOWN = new Set(KNOWN_ELIGIBILITY_INSTANCE_FIELDS);
129
+ const ELIGIBILITY_REQUEST_KNOWN = new Set(KNOWN_ELIGIBILITY_REQUEST_FIELDS);
130
+ // Compact rows: identifiers plus the correlation keys. groupId and
131
+ // principalId are the required scoping keys; accessId names member versus
132
+ // owner; assignmentType names Assigned versus Activated; memberType names
133
+ // how the row reaches the principal (for example Direct).
134
+ const DEFAULT_ASSIGNMENT_SCHEDULE_LIST_SELECT = ["id", "principalId", "groupId", "accessId", "assignmentType"];
135
+ const DEFAULT_ASSIGNMENT_INSTANCE_LIST_SELECT = ["id", "principalId", "groupId", "accessId", "assignmentType"];
136
+ const DEFAULT_ELIGIBILITY_SCHEDULE_LIST_SELECT = ["id", "principalId", "groupId", "accessId", "memberType"];
137
+ const DEFAULT_ELIGIBILITY_INSTANCE_LIST_SELECT = ["id", "principalId", "groupId", "accessId", "memberType"];
138
+ const DEFAULT_ELIGIBILITY_REQUEST_LIST_SELECT = ["id", "action", "status", "principalId", "groupId", "accessId"];
139
+ const DEFAULT_ELIGIBILITY_REQUEST_SHOW_SELECT = KNOWN_ELIGIBILITY_REQUEST_FIELDS.filter(field => field !== "justification");
140
+ export const DEFAULT_ASSIGNMENT_SCOPES = ["https://graph.microsoft.com/PrivilegedAssignmentSchedule.Read.AzureADGroup"];
141
+ export const DEFAULT_ELIGIBILITY_SCOPES = ["https://graph.microsoft.com/PrivilegedEligibilitySchedule.Read.AzureADGroup"];
142
+ const TRUNCATE_AT = 500;
143
+ function fieldList(raw, flag, known, knownList, help) {
144
+ const parts = String(raw)
145
+ .split(",")
146
+ .map(part => part.trim())
147
+ .filter(part => part.length > 0);
148
+ if (!parts.length)
149
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
150
+ const fields = [];
151
+ for (const part of parts) {
152
+ if (!known.has(part)) {
153
+ throw new AxiError(`Unknown property ${part} in --${flag}`, "VALIDATION_ERROR", [
154
+ help,
155
+ `Known properties: ${knownList.join(", ")}`,
156
+ ]);
157
+ }
158
+ if (!fields.includes(part))
159
+ fields.push(part);
160
+ }
161
+ return fields;
162
+ }
163
+ function scopesFor(flags, profile, defaults, help) {
164
+ if (profile.mode === "application") {
165
+ if (flags.scopes !== undefined) {
166
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
167
+ }
168
+ return undefined;
169
+ }
170
+ if (flags.scopes === undefined)
171
+ return [...defaults];
172
+ const scopes = String(flags.scopes)
173
+ .split(",")
174
+ .map(scope => scope.trim())
175
+ .filter(scope => scope.length > 0);
176
+ return scopes;
177
+ }
178
+ // Request justification is free text and scheduleInfo/ticketInfo are nested
179
+ // objects, so truncation walks values recursively; --full returns them whole.
180
+ function truncateValue(value, full) {
181
+ if (full)
182
+ return { value, truncated: false };
183
+ if (typeof value === "string" && value.length > TRUNCATE_AT) {
184
+ return { value: `${value.slice(0, TRUNCATE_AT)}... (truncated, ${value.length} chars total)`, truncated: true };
185
+ }
186
+ if (Array.isArray(value)) {
187
+ const results = value.map(item => truncateValue(item, full));
188
+ return { value: results.map(result => result.value), truncated: results.some(result => result.truncated) };
189
+ }
190
+ if (value !== null && typeof value === "object") {
191
+ let truncated = false;
192
+ const entries = Object.entries(value).map(([key, item]) => {
193
+ const result = truncateValue(item, full);
194
+ truncated = truncated || result.truncated;
195
+ return [key, result.value];
196
+ });
197
+ return { value: Object.fromEntries(entries), truncated };
198
+ }
199
+ return { value, truncated: false };
200
+ }
201
+ // Local projection preserves Graph's null/missing distinction: an explicit
202
+ // null stays null, an absent property stays absent and is never synthesized.
203
+ function project(row, fields, full) {
204
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
205
+ const projected = {};
206
+ let truncated = false;
207
+ for (const field of fields) {
208
+ if (!Object.hasOwn(source, field))
209
+ continue;
210
+ const result = truncateValue(source[field], full);
211
+ projected[field] = result.value;
212
+ truncated = truncated || result.truncated;
213
+ }
214
+ return { row: projected, truncated };
215
+ }
216
+ function selectedFields(flags, defaults, known, knownList, help) {
217
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, "select", known, knownList, help);
218
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, "fields", known, knownList, help);
219
+ const missing = fields.find(field => !select.includes(field));
220
+ if (missing) {
221
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
222
+ }
223
+ return { select, fields };
224
+ }
225
+ function profileHint(profileName) {
226
+ return `--profile ${shellValue(profileName)}`;
227
+ }
228
+ function shellValue(value) {
229
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
230
+ }
231
+ function fullHint(command, flags, profileName) {
232
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
233
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
234
+ return `mg-axi ${command} ${args.join(" ")}`;
235
+ }
236
+ // Denials carry the session's generic grant/role/licence cause; each
237
+ // family adds the scope, roles and licensing that actually unlock it,
238
+ // because a 403 alone never says which prerequisite is missing.
239
+ function withGuidance(hints, run) {
240
+ return run().catch(error => {
241
+ if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
242
+ throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
243
+ }
244
+ throw error;
245
+ });
246
+ }
247
+ const ASSIGNMENT_DENIAL_HINTS = [
248
+ "Group PIM assignment reads need PrivilegedAssignmentSchedule.Read.AzureADGroup plus owner/member of the group or a supported directory role (Global Reader or Privileged Role Administrator for role-assignable groups; Global Reader, Directory Writer, Groups Administrator, Identity Governance Administrator or User Administrator otherwise, scoped at directory level) for delegated access, or admin-consented PrivilegedAssignmentSchedule.Read.AzureADGroup for application access; delegated personal Microsoft accounts are not supported",
249
+ "PIM for Groups needs P2 or ID Governance, not only P2",
250
+ ];
251
+ const ELIGIBILITY_DENIAL_HINTS = [
252
+ "Group PIM eligibility reads need PrivilegedEligibilitySchedule.Read.AzureADGroup plus owner/member of the group or a supported directory role (Global Reader or Privileged Role Administrator for role-assignable groups; Global Reader, Directory Writer, Groups Administrator, Identity Governance Administrator or User Administrator otherwise, scoped at directory level) for delegated access, or admin-consented PrivilegedEligibilitySchedule.Read.AzureADGroup for application access; delegated personal Microsoft accounts are not supported",
253
+ "PIM for Groups needs P2 or ID Governance, not only P2",
254
+ ];
255
+ const ASSIGNMENT_SCHEDULE_LIST = {
256
+ command: "entra pim group-assignment-schedule list",
257
+ key: "assignmentSchedules",
258
+ noun: "assignment schedules",
259
+ known: ASSIGNMENT_SCHEDULE_KNOWN,
260
+ knownList: KNOWN_ASSIGNMENT_SCHEDULE_FIELDS,
261
+ defaultSelect: DEFAULT_ASSIGNMENT_SCHEDULE_LIST_SELECT,
262
+ defaultScopes: DEFAULT_ASSIGNMENT_SCOPES,
263
+ denialHints: ASSIGNMENT_DENIAL_HINTS,
264
+ scopeNote: "Assignment schedules govern group membership or ownership over time; instances are the provisioned windows, never the schedule itself",
265
+ emptyNote: "0 assignment schedules matched; the absence of results is the answer, not an error",
266
+ };
267
+ const ASSIGNMENT_INSTANCE_LIST = {
268
+ command: "entra pim group-assignment-instance list",
269
+ key: "assignmentScheduleInstances",
270
+ noun: "assignment instances",
271
+ known: ASSIGNMENT_INSTANCE_KNOWN,
272
+ knownList: KNOWN_ASSIGNMENT_INSTANCE_FIELDS,
273
+ defaultSelect: DEFAULT_ASSIGNMENT_INSTANCE_LIST_SELECT,
274
+ defaultScopes: DEFAULT_ASSIGNMENT_SCOPES,
275
+ denialHints: ASSIGNMENT_DENIAL_HINTS,
276
+ scopeNote: "Assignment instances are provisioned membership or ownership windows; assignmentType Assigned names direct grants and Activated names activated eligible grants",
277
+ emptyNote: "0 assignment instances matched; the absence of results is the answer, not an error",
278
+ };
279
+ const ELIGIBILITY_SCHEDULE_LIST = {
280
+ command: "entra pim group-eligibility-schedule list",
281
+ key: "eligibilitySchedules",
282
+ noun: "eligibility schedules",
283
+ known: ELIGIBILITY_SCHEDULE_KNOWN,
284
+ knownList: KNOWN_ELIGIBILITY_SCHEDULE_FIELDS,
285
+ defaultSelect: DEFAULT_ELIGIBILITY_SCHEDULE_LIST_SELECT,
286
+ defaultScopes: DEFAULT_ELIGIBILITY_SCOPES,
287
+ denialHints: ELIGIBILITY_DENIAL_HINTS,
288
+ scopeNote: "Eligibility schedules govern who may activate group membership or ownership; they are not active grants",
289
+ emptyNote: "0 eligibility schedules matched; the absence of results is the answer, not an error",
290
+ };
291
+ const ELIGIBILITY_INSTANCE_LIST = {
292
+ command: "entra pim group-eligibility-instance list",
293
+ key: "eligibilityScheduleInstances",
294
+ noun: "eligibility instances",
295
+ known: ELIGIBILITY_INSTANCE_KNOWN,
296
+ knownList: KNOWN_ELIGIBILITY_INSTANCE_FIELDS,
297
+ defaultSelect: DEFAULT_ELIGIBILITY_INSTANCE_LIST_SELECT,
298
+ defaultScopes: DEFAULT_ELIGIBILITY_SCOPES,
299
+ denialHints: ELIGIBILITY_DENIAL_HINTS,
300
+ scopeNote: "Eligibility instances are provisioned eligibility windows; activation itself is a PIM workflow outside these reads",
301
+ emptyNote: "0 eligibility instances matched; the absence of results is the answer, not an error",
302
+ };
303
+ const ELIGIBILITY_REQUEST_LIST = {
304
+ command: "entra pim group-eligibility-request list",
305
+ key: "eligibilityScheduleRequests",
306
+ noun: "eligibility requests",
307
+ known: ELIGIBILITY_REQUEST_KNOWN,
308
+ knownList: KNOWN_ELIGIBILITY_REQUEST_FIELDS,
309
+ defaultSelect: DEFAULT_ELIGIBILITY_REQUEST_LIST_SELECT,
310
+ defaultScopes: DEFAULT_ELIGIBILITY_SCOPES,
311
+ denialHints: ELIGIBILITY_DENIAL_HINTS,
312
+ scopeNote: "Eligibility requests carry the ask and its outcome; justification text rides only behind an explicit --select",
313
+ emptyNote: "0 eligibility requests matched; the absence of results is the answer, not an error",
314
+ };
315
+ // Graph requires every one of these lists to be scoped to a groupId or a
316
+ // principalId, so --filter must name one of them; validated before any
317
+ // credential or transport is touched, with cursor resumes replaying the
318
+ // stored filter.
319
+ function requiredFilter(flags, savedFilter, help) {
320
+ const filter = flags.filter === undefined ? savedFilter : String(flags.filter);
321
+ if (filter === undefined || (!filter.includes("groupId") && !filter.includes("principalId"))) {
322
+ throw new AxiError("--filter needs a groupId or principalId eq clause scoping the list", "VALIDATION_ERROR", [
323
+ help,
324
+ "For example --filter \"groupId eq '<group-id>'\" or --filter \"principalId eq '<principal-id>'\"",
325
+ ]);
326
+ }
327
+ return filter;
328
+ }
329
+ async function listCollection(shape, session, flags, profile, operation, help, profileName) {
330
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
331
+ if (cursor !== undefined && !cursor.trim())
332
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
333
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
334
+ const savedSelect = saved?.$select;
335
+ const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, help), shape.known, shape.knownList, help);
336
+ const filter = requiredFilter(flags, saved?.$filter, help);
337
+ const scopes = scopesFor(flags, profile, shape.defaultScopes, help);
338
+ const full = flags.full === true;
339
+ // These lists document $select/$filter/$expand, so --filter passes
340
+ // through as plain $filter with no $count or ConsistencyLevel attached;
341
+ // $expand stays unreviewed and is never sent.
342
+ const query = { $select: select.join(","), $filter: filter };
343
+ const args = { profile, operation, query, scopes };
344
+ if (cursor !== undefined)
345
+ args.cursor = cursor;
346
+ if (flags.all === true) {
347
+ if (flags.limit !== undefined)
348
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
349
+ }
350
+ else {
351
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
352
+ }
353
+ const result = await withGuidance(shape.denialHints, () => session.collect(args));
354
+ const effectiveFlags = { ...flags, select: result.query.$select ?? shape.defaultSelect.join(",") };
355
+ if (result.query.$filter !== undefined)
356
+ effectiveFlags.filter = result.query.$filter;
357
+ const rows = [];
358
+ let truncated = false;
359
+ for (const row of result.value) {
360
+ const projected = project(row, fields, full);
361
+ rows.push(projected.row);
362
+ truncated = truncated || projected.truncated;
363
+ }
364
+ const truncationHints = truncated ? [fullHint(shape.command, effectiveFlags, profileName)] : [];
365
+ const standing = [shape.scopeNote];
366
+ if (!result.complete) {
367
+ return {
368
+ [shape.key]: rows,
369
+ ...listTotals(rows.length, result.total, shape.noun, false),
370
+ complete: false,
371
+ reason: result.reason,
372
+ cursor: result.cursor,
373
+ help: [...truncationHints, `Resume losslessly with the same --filter and flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, ...standing],
374
+ };
375
+ }
376
+ if (!rows.length) {
377
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.noun, true), complete: true, help: [shape.emptyNote, ...standing] };
378
+ }
379
+ return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.noun, true), complete: true, help: [...truncationHints, ...standing] };
380
+ }
381
+ async function showOne(key, command, known, knownList, defaultSelect, defaultScopes, denialHints, session, flags, profile, operation, help, profileName, params) {
382
+ const { select, fields } = selectedFields(flags, defaultSelect, known, knownList, help);
383
+ const scopes = scopesFor(flags, profile, defaultScopes, help);
384
+ const full = flags.full === true;
385
+ const raw = await withGuidance(denialHints, () => session.execute({
386
+ profile,
387
+ operation,
388
+ params,
389
+ query: { $select: select.join(",") },
390
+ scopes,
391
+ }));
392
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
393
+ throw new AxiError(`Graph returned a malformed ${key} body`, "GRAPH_ERROR", [
394
+ "Single-object reads carry one object; treat anything else as unknown, not empty",
395
+ ]);
396
+ }
397
+ const { row, truncated } = project(raw, fields, full);
398
+ if (truncated)
399
+ return { [key]: row, help: [fullHint(command, flags, profileName)] };
400
+ return { [key]: row };
401
+ }
402
+ export async function listGroupAssignmentSchedules(session, flags, profile, operation, help, profileName) {
403
+ return listCollection(ASSIGNMENT_SCHEDULE_LIST, session, flags, profile, operation, help, profileName);
404
+ }
405
+ export async function showGroupAssignmentSchedule(session, flags, profile, operation, help, profileName) {
406
+ return showOne("assignmentSchedule", "entra pim group-assignment-schedule show", ASSIGNMENT_SCHEDULE_KNOWN, KNOWN_ASSIGNMENT_SCHEDULE_FIELDS, [...KNOWN_ASSIGNMENT_SCHEDULE_FIELDS], DEFAULT_ASSIGNMENT_SCOPES, ASSIGNMENT_DENIAL_HINTS, session, flags, profile, operation, help, profileName, { "privilegedAccessGroupAssignmentSchedule-id": String(flags.id) });
407
+ }
408
+ export async function listGroupAssignmentInstances(session, flags, profile, operation, help, profileName) {
409
+ return listCollection(ASSIGNMENT_INSTANCE_LIST, session, flags, profile, operation, help, profileName);
410
+ }
411
+ export async function showGroupAssignmentInstance(session, flags, profile, operation, help, profileName) {
412
+ return showOne("assignmentScheduleInstance", "entra pim group-assignment-instance show", ASSIGNMENT_INSTANCE_KNOWN, KNOWN_ASSIGNMENT_INSTANCE_FIELDS, [...KNOWN_ASSIGNMENT_INSTANCE_FIELDS], DEFAULT_ASSIGNMENT_SCOPES, ASSIGNMENT_DENIAL_HINTS, session, flags, profile, operation, help, profileName, { "privilegedAccessGroupAssignmentScheduleInstance-id": String(flags.id) });
413
+ }
414
+ export async function listGroupEligibilitySchedules(session, flags, profile, operation, help, profileName) {
415
+ return listCollection(ELIGIBILITY_SCHEDULE_LIST, session, flags, profile, operation, help, profileName);
416
+ }
417
+ export async function showGroupEligibilitySchedule(session, flags, profile, operation, help, profileName) {
418
+ return showOne("eligibilitySchedule", "entra pim group-eligibility-schedule show", ELIGIBILITY_SCHEDULE_KNOWN, KNOWN_ELIGIBILITY_SCHEDULE_FIELDS, [...KNOWN_ELIGIBILITY_SCHEDULE_FIELDS], DEFAULT_ELIGIBILITY_SCOPES, ELIGIBILITY_DENIAL_HINTS, session, flags, profile, operation, help, profileName, { "privilegedAccessGroupEligibilitySchedule-id": String(flags.id) });
419
+ }
420
+ export async function listGroupEligibilityInstances(session, flags, profile, operation, help, profileName) {
421
+ return listCollection(ELIGIBILITY_INSTANCE_LIST, session, flags, profile, operation, help, profileName);
422
+ }
423
+ export async function showGroupEligibilityInstance(session, flags, profile, operation, help, profileName) {
424
+ return showOne("eligibilityScheduleInstance", "entra pim group-eligibility-instance show", ELIGIBILITY_INSTANCE_KNOWN, KNOWN_ELIGIBILITY_INSTANCE_FIELDS, [...KNOWN_ELIGIBILITY_INSTANCE_FIELDS], DEFAULT_ELIGIBILITY_SCOPES, ELIGIBILITY_DENIAL_HINTS, session, flags, profile, operation, help, profileName, { "privilegedAccessGroupEligibilityScheduleInstance-id": String(flags.id) });
425
+ }
426
+ export async function listGroupEligibilityRequests(session, flags, profile, operation, help, profileName) {
427
+ return listCollection(ELIGIBILITY_REQUEST_LIST, session, flags, profile, operation, help, profileName);
428
+ }
429
+ export async function showGroupEligibilityRequest(session, flags, profile, operation, help, profileName) {
430
+ return showOne("eligibilityScheduleRequest", "entra pim group-eligibility-request show", ELIGIBILITY_REQUEST_KNOWN, KNOWN_ELIGIBILITY_REQUEST_FIELDS, [...DEFAULT_ELIGIBILITY_REQUEST_SHOW_SELECT], DEFAULT_ELIGIBILITY_SCOPES, ELIGIBILITY_DENIAL_HINTS, session, flags, profile, operation, help, profileName, { "privilegedAccessGroupEligibilityScheduleRequest-id": String(flags.id) });
431
+ }
@@ -1,4 +1,5 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // READ-02: the Entra group read mapping behind `mg-axi entra group list/show`
3
4
  // and `mg-axi entra group member list` / `member-of list` (each with an
4
5
  // opt-in `--transitive` flat view). Operation construction stays beside its
@@ -226,23 +227,25 @@ export async function listGroups(session, flags, profile, operation, help, profi
226
227
  if (!result.complete) {
227
228
  return {
228
229
  groups,
229
- count: { returned: groups.length, complete: false, reason: result.reason },
230
+ ...listTotals(groups.length, result.total, "groups", false),
231
+ complete: false,
232
+ reason: result.reason,
230
233
  cursor: result.cursor,
231
234
  help: [...truncationHints, resumeHint(profileName), showHint],
232
235
  };
233
236
  }
234
- const count = { returned: groups.length, complete: true };
235
237
  if (!groups.length) {
236
238
  return {
237
239
  groups,
238
- count,
240
+ ...listTotals(groups.length, result.total, "groups", true),
241
+ complete: true,
239
242
  help: [
240
243
  `mg-axi entra group list --filter <odata-filter> ${profileHint(profileName)}`,
241
244
  "0 groups matched; the absence of results is the answer, not an error",
242
245
  ],
243
246
  };
244
247
  }
245
- return { groups, count, help: [...truncationHints, showHint] };
248
+ return { groups, ...listTotals(groups.length, result.total, "groups", true), complete: true, help: [...truncationHints, showHint] };
246
249
  }
247
250
  export async function showGroup(session, flags, profile, operation, help, profileName) {
248
251
  const { select, fields } = selectedFields(flags, KNOWN_GROUPS, KNOWN_GROUP_FIELDS, DEFAULT_SHOW_SELECT, help);
@@ -302,17 +305,19 @@ async function listRelationship(session, flags, profile, operation, help, profil
302
305
  if (!result.complete) {
303
306
  return {
304
307
  [key]: rows,
305
- count: { returned: rows.length, complete: false, reason: result.reason },
308
+ ...listTotals(rows.length, result.total, emptyNoun, false),
309
+ complete: false,
310
+ reason: result.reason,
306
311
  cursor: result.cursor,
307
312
  ...warnings,
308
313
  help: [...truncationHints, ...limitedHints, hiddenHint, resumeHint(profileName), scopeHint],
309
314
  };
310
315
  }
311
- const count = { returned: rows.length, complete: true };
312
316
  if (!rows.length) {
313
317
  return {
314
318
  [key]: rows,
315
- count,
319
+ ...listTotals(rows.length, result.total, emptyNoun, true),
320
+ complete: true,
316
321
  ...warnings,
317
322
  help: [
318
323
  ...limitedHints,
@@ -322,7 +327,7 @@ async function listRelationship(session, flags, profile, operation, help, profil
322
327
  ],
323
328
  };
324
329
  }
325
- return { [key]: rows, count, ...warnings, help: [...truncationHints, ...limitedHints, hiddenHint, scopeHint] };
330
+ return { [key]: rows, ...listTotals(rows.length, result.total, emptyNoun, true), complete: true, ...warnings, help: [...truncationHints, ...limitedHints, hiddenHint, scopeHint] };
326
331
  }
327
332
  export async function listGroupMembers(session, flags, profile, operation, help, profileName) {
328
333
  const transitive = flags.transitive === true;