@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.
- package/README.md +149 -38
- package/dist/api.js +134 -8
- package/dist/bin/mg-axi-hook.js +31 -0
- package/dist/catalogue.js +390 -2
- package/dist/cli.js +116 -8
- package/dist/docs.js +45 -5
- package/dist/doctor.js +2 -2
- package/dist/entra-access-reviews.js +11 -4
- package/dist/entra-apps.js +13 -8
- package/dist/entra-audit-logs.js +8 -4
- package/dist/entra-auth-methods.js +8 -4
- package/dist/entra-certificate-auth.js +12 -8
- package/dist/entra-conditional-access.js +9 -4
- package/dist/entra-contacts.js +19 -12
- package/dist/entra-contracts.js +7 -4
- package/dist/entra-custom-security-attributes.js +19 -12
- package/dist/entra-data-policy-operations.js +7 -4
- package/dist/entra-delegated-admin.js +55 -6
- package/dist/entra-deleted-items.js +6 -4
- package/dist/entra-directory-objects.js +6 -4
- package/dist/entra-directory.js +19 -12
- package/dist/entra-domains.js +13 -8
- package/dist/entra-entitlement-management.js +12 -4
- package/dist/entra-group-pim.js +431 -0
- package/dist/entra-groups.js +13 -8
- package/dist/entra-lifecycle-workflows.js +136 -9
- package/dist/entra-organization.js +13 -8
- package/dist/entra-risk-prevention.js +436 -0
- package/dist/entra-risk.js +213 -5
- package/dist/entra-roles.js +10 -4
- package/dist/entra-subscriptions.js +6 -4
- package/dist/entra-users.js +7 -4
- package/dist/graph-session.js +29 -5
- package/dist/hook.js +84 -0
- package/dist/list-totals.js +52 -0
- package/dist/setup.js +5 -0
- package/docs/coverage.md +79 -13
- package/inventory/operations.json +2 -2
- package/package.json +4 -3
- package/skills/mg-axi/SKILL.md +56 -1
package/dist/entra-risk.js
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
import { AxiError } from "axi-sdk-js";
|
|
2
2
|
// READ-06: the Entra risky-user and risk-detection read mapping behind
|
|
3
3
|
// `mg-axi entra risky-user list/show` and `mg-axi entra risk-detection
|
|
4
|
-
// list/show
|
|
4
|
+
// list/show`, extended with the workload-identity surface behind
|
|
5
|
+
// `mg-axi entra risky-service-principal list/show`,
|
|
6
|
+
// `mg-axi entra risky-service-principal history list/show` and
|
|
7
|
+
// `mg-axi entra service-principal-risk-detection list/show`. Operation construction stays beside its command; the shared
|
|
5
8
|
// session owns URLs, credentials, paging, retries and error translation, and
|
|
6
9
|
// the SDK owns TOON rendering. This module only maps flags to session calls,
|
|
7
10
|
// bounds detection queries in time, and projects rows for compact output.
|
|
8
11
|
//
|
|
9
12
|
// Reviewed against the v1.0 riskyuser-list/get and riskdetection-list/get
|
|
10
|
-
// operation documentation on 2026-10-04
|
|
13
|
+
// operation documentation on 2026-10-04, and against the v1.0
|
|
14
|
+
// riskyserviceprincipal-list/get, history-list and
|
|
15
|
+
// serviceprincipalriskdetection-list/get operation documentation on
|
|
16
|
+
// 2026-10-06. Risky-user reads take D/A
|
|
11
17
|
// IdentityRiskyUser.Read.All and risk-detection reads take D/A
|
|
12
|
-
// IdentityRiskEvent.Read.All;
|
|
18
|
+
// IdentityRiskEvent.Read.All; risky-service-principal reads (including
|
|
19
|
+
// history) take D/A IdentityRiskyServicePrincipal.Read.All while
|
|
20
|
+
// service-principal risk detections reuse D/A IdentityRiskEvent.Read.All.
|
|
21
|
+
// Delegated callers additionally need Global
|
|
13
22
|
// Reader, Security Operator, Security Reader or Security Administrator. The
|
|
14
23
|
// riskyUsers API requires a P2 licence, while risk detection permits P1 or
|
|
15
|
-
// P2
|
|
24
|
+
// P2; the workload-identity APIs require a Microsoft Entra Workload
|
|
25
|
+
// Identities Premium licence, and service-principal detections report
|
|
26
|
+
// riskDetail and riskLevel hidden without it. Limited views stay limited: a premium detection without P2 detail
|
|
16
27
|
// reports riskEventType generic, hidden risk levels report the licence
|
|
17
28
|
// boundary instead of the level, and a null detection correlationId means no
|
|
18
29
|
// sign-in is associated. None of these is ever reinterpreted as empty or
|
|
@@ -54,8 +65,67 @@ export const KNOWN_RISK_DETECTION_FIELDS = [
|
|
|
54
65
|
"userPrincipalName",
|
|
55
66
|
"additionalInfo",
|
|
56
67
|
];
|
|
68
|
+
// Workload-identity surface. History items inherit every risky-service-
|
|
69
|
+
// principal property and add the service-principal identifier plus the
|
|
70
|
+
// actor/activity of the risk change. Service-principal detections carry the
|
|
71
|
+
// service-principal join keys (servicePrincipalId, servicePrincipalDisplayName,
|
|
72
|
+
// appId, keyIds) instead of user keys; keyIds are key-credential identifiers,
|
|
73
|
+
// never secret material. tokenIssuerType stays absent for the same redaction
|
|
74
|
+
// reason as above.
|
|
75
|
+
export const KNOWN_RISKY_SP_FIELDS = [
|
|
76
|
+
"id",
|
|
77
|
+
"isEnabled",
|
|
78
|
+
"isProcessing",
|
|
79
|
+
"riskDetail",
|
|
80
|
+
"riskLastUpdatedDateTime",
|
|
81
|
+
"riskLevel",
|
|
82
|
+
"riskState",
|
|
83
|
+
"displayName",
|
|
84
|
+
"appId",
|
|
85
|
+
"servicePrincipalType",
|
|
86
|
+
];
|
|
87
|
+
export const KNOWN_RISKY_SP_HISTORY_FIELDS = [
|
|
88
|
+
"id",
|
|
89
|
+
"isEnabled",
|
|
90
|
+
"isProcessing",
|
|
91
|
+
"riskDetail",
|
|
92
|
+
"riskLastUpdatedDateTime",
|
|
93
|
+
"riskLevel",
|
|
94
|
+
"riskState",
|
|
95
|
+
"displayName",
|
|
96
|
+
"appId",
|
|
97
|
+
"servicePrincipalType",
|
|
98
|
+
"servicePrincipalId",
|
|
99
|
+
"initiatedBy",
|
|
100
|
+
"activity",
|
|
101
|
+
];
|
|
102
|
+
export const KNOWN_SP_DETECTION_FIELDS = [
|
|
103
|
+
"id",
|
|
104
|
+
"requestId",
|
|
105
|
+
"correlationId",
|
|
106
|
+
"riskEventType",
|
|
107
|
+
"riskState",
|
|
108
|
+
"riskLevel",
|
|
109
|
+
"riskDetail",
|
|
110
|
+
"source",
|
|
111
|
+
"detectionTimingType",
|
|
112
|
+
"activity",
|
|
113
|
+
"ipAddress",
|
|
114
|
+
"location",
|
|
115
|
+
"activityDateTime",
|
|
116
|
+
"detectedDateTime",
|
|
117
|
+
"lastUpdatedDateTime",
|
|
118
|
+
"servicePrincipalId",
|
|
119
|
+
"servicePrincipalDisplayName",
|
|
120
|
+
"appId",
|
|
121
|
+
"keyIds",
|
|
122
|
+
"additionalInfo",
|
|
123
|
+
];
|
|
57
124
|
const RISKY_USER_KNOWN = new Set(KNOWN_RISKY_USER_FIELDS);
|
|
58
125
|
const RISK_DETECTION_KNOWN = new Set(KNOWN_RISK_DETECTION_FIELDS);
|
|
126
|
+
const RISKY_SP_KNOWN = new Set(KNOWN_RISKY_SP_FIELDS);
|
|
127
|
+
const RISKY_SP_HISTORY_KNOWN = new Set(KNOWN_RISKY_SP_HISTORY_FIELDS);
|
|
128
|
+
const SP_DETECTION_KNOWN = new Set(KNOWN_SP_DETECTION_FIELDS);
|
|
59
129
|
// Compact list rows: identifier, who is at risk, and the triage state.
|
|
60
130
|
const DEFAULT_RISKY_USER_LIST_SELECT = ["id", "userPrincipalName", "riskLevel", "riskState"];
|
|
61
131
|
const DEFAULT_RISK_DETECTION_LIST_SELECT = ["id", "detectedDateTime", "userPrincipalName", "riskLevel"];
|
|
@@ -64,6 +134,7 @@ const DEFAULT_RISKY_USER_SHOW_SELECT = [...KNOWN_RISKY_USER_FIELDS];
|
|
|
64
134
|
const DEFAULT_RISK_DETECTION_SHOW_SELECT = [...KNOWN_RISK_DETECTION_FIELDS];
|
|
65
135
|
export const DEFAULT_RISKY_USER_SCOPES = ["https://graph.microsoft.com/IdentityRiskyUser.Read.All"];
|
|
66
136
|
export const DEFAULT_RISK_DETECTION_SCOPES = ["https://graph.microsoft.com/IdentityRiskEvent.Read.All"];
|
|
137
|
+
export const DEFAULT_RISKY_SP_SCOPES = ["https://graph.microsoft.com/IdentityRiskyServicePrincipal.Read.All"];
|
|
67
138
|
const TRUNCATE_AT = 500;
|
|
68
139
|
function fieldList(raw, flag, known, knownList, help) {
|
|
69
140
|
const parts = String(raw)
|
|
@@ -222,6 +293,23 @@ const RISK_DETECTION_DENIAL_HINTS = [
|
|
|
222
293
|
"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
294
|
"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
295
|
];
|
|
296
|
+
const RISKY_SP_DENIAL_HINTS = [
|
|
297
|
+
"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",
|
|
298
|
+
"The riskyServicePrincipals API requires a Microsoft Entra Workload Identities Premium licence; data availability follows Entra retention policies",
|
|
299
|
+
];
|
|
300
|
+
const SP_DETECTION_DENIAL_HINTS = [
|
|
301
|
+
"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",
|
|
302
|
+
"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",
|
|
303
|
+
];
|
|
304
|
+
// Nested history routes bind the parent risky-service-principal id before
|
|
305
|
+
// credentials; a missing or empty parent fails as usage, never as Graph 404.
|
|
306
|
+
function parentServicePrincipal(flags, help) {
|
|
307
|
+
const parent = flags["service-principal"];
|
|
308
|
+
if (parent === undefined || !String(parent).trim()) {
|
|
309
|
+
throw new AxiError("--service-principal needs the risky-service-principal object ID owning this history", "VALIDATION_ERROR", [help]);
|
|
310
|
+
}
|
|
311
|
+
return { "riskyServicePrincipal-id": String(parent) };
|
|
312
|
+
}
|
|
225
313
|
const RISKY_USER_LIST = {
|
|
226
314
|
noun: "risky-user",
|
|
227
315
|
key: "riskyUsers",
|
|
@@ -257,6 +345,62 @@ const RISK_DETECTION_LIST = {
|
|
|
257
345
|
"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
346
|
],
|
|
259
347
|
};
|
|
348
|
+
// Compact workload rows: identifier, which workload, and the triage state.
|
|
349
|
+
const DEFAULT_RISKY_SP_LIST_SELECT = ["id", "displayName", "riskLevel", "riskState"];
|
|
350
|
+
const DEFAULT_SP_DETECTION_LIST_SELECT = ["id", "detectedDateTime", "servicePrincipalDisplayName", "riskLevel"];
|
|
351
|
+
const DEFAULT_RISKY_SP_SHOW_SELECT = [...KNOWN_RISKY_SP_FIELDS];
|
|
352
|
+
const DEFAULT_RISKY_SP_HISTORY_SHOW_SELECT = [...KNOWN_RISKY_SP_HISTORY_FIELDS];
|
|
353
|
+
const DEFAULT_SP_DETECTION_SHOW_SELECT = [...KNOWN_SP_DETECTION_FIELDS];
|
|
354
|
+
const RISKY_SP_LIST = {
|
|
355
|
+
noun: "risky-service-principal",
|
|
356
|
+
key: "riskyServicePrincipals",
|
|
357
|
+
known: RISKY_SP_KNOWN,
|
|
358
|
+
knownList: KNOWN_RISKY_SP_FIELDS,
|
|
359
|
+
defaultSelect: DEFAULT_RISKY_SP_LIST_SELECT,
|
|
360
|
+
defaultScopes: DEFAULT_RISKY_SP_SCOPES,
|
|
361
|
+
denialHints: RISKY_SP_DENIAL_HINTS,
|
|
362
|
+
showHint: "mg-axi entra risky-service-principal show --id <risky-service-principal-id>",
|
|
363
|
+
emptyHints: profileName => [
|
|
364
|
+
`mg-axi entra risky-service-principal list --filter <odata-filter> ${profileHint(profileName)}`,
|
|
365
|
+
"0 risky service principals matched; the absence of results is the answer, not an error",
|
|
366
|
+
"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",
|
|
367
|
+
],
|
|
368
|
+
extraHelp: () => [],
|
|
369
|
+
};
|
|
370
|
+
const RISKY_SP_HISTORY_LIST = {
|
|
371
|
+
noun: "risky-service-principal history",
|
|
372
|
+
key: "riskyServicePrincipalHistory",
|
|
373
|
+
pathParams: parentServicePrincipal,
|
|
374
|
+
known: RISKY_SP_HISTORY_KNOWN,
|
|
375
|
+
knownList: KNOWN_RISKY_SP_HISTORY_FIELDS,
|
|
376
|
+
defaultSelect: DEFAULT_RISKY_SP_LIST_SELECT,
|
|
377
|
+
defaultScopes: DEFAULT_RISKY_SP_SCOPES,
|
|
378
|
+
denialHints: RISKY_SP_DENIAL_HINTS,
|
|
379
|
+
showHint: "mg-axi entra risky-service-principal history show --service-principal <risky-service-principal-id> --id <history-item-id>",
|
|
380
|
+
emptyHints: profileName => [
|
|
381
|
+
`mg-axi entra risky-service-principal history list --service-principal <risky-service-principal-id> ${profileHint(profileName)}`,
|
|
382
|
+
"0 history items matched; the absence of results is the answer, not an error",
|
|
383
|
+
],
|
|
384
|
+
extraHelp: () => [],
|
|
385
|
+
};
|
|
386
|
+
const SP_DETECTION_LIST = {
|
|
387
|
+
noun: "service-principal-risk-detection",
|
|
388
|
+
key: "servicePrincipalRiskDetections",
|
|
389
|
+
dateField: "detectedDateTime",
|
|
390
|
+
known: SP_DETECTION_KNOWN,
|
|
391
|
+
knownList: KNOWN_SP_DETECTION_FIELDS,
|
|
392
|
+
defaultSelect: DEFAULT_SP_DETECTION_LIST_SELECT,
|
|
393
|
+
defaultScopes: DEFAULT_RISK_DETECTION_SCOPES,
|
|
394
|
+
denialHints: SP_DETECTION_DENIAL_HINTS,
|
|
395
|
+
showHint: "mg-axi entra service-principal-risk-detection show --id <service-principal-risk-detection-id>",
|
|
396
|
+
emptyHints: profileName => [
|
|
397
|
+
`mg-axi entra service-principal-risk-detection list --since <earlier-iso-time> ${profileHint(profileName)}`,
|
|
398
|
+
"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",
|
|
399
|
+
],
|
|
400
|
+
extraHelp: () => [
|
|
401
|
+
"Limited views stay limited: riskDetail and riskLevel report hidden without Workload Identities Premium detail - never read hidden as none",
|
|
402
|
+
],
|
|
403
|
+
};
|
|
260
404
|
async function listRisk(shape, session, flags, profile, operation, help, profileName) {
|
|
261
405
|
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
262
406
|
if (cursor !== undefined && !cursor.trim())
|
|
@@ -292,6 +436,8 @@ async function listRisk(shape, session, flags, profile, operation, help, profile
|
|
|
292
436
|
query.$filter = String(flags.filter);
|
|
293
437
|
}
|
|
294
438
|
const args = { profile, operation, query, scopes };
|
|
439
|
+
if (shape.pathParams !== undefined)
|
|
440
|
+
args.params = shape.pathParams(flags, help);
|
|
295
441
|
if (cursor !== undefined)
|
|
296
442
|
args.cursor = cursor;
|
|
297
443
|
if (flags.all === true) {
|
|
@@ -363,6 +509,50 @@ const RISK_DETECTION_SHOW = {
|
|
|
363
509
|
],
|
|
364
510
|
noSignInNote: "This detection carries no associated sign-in (correlationId is null); sign-in correlation does not apply",
|
|
365
511
|
};
|
|
512
|
+
const RISKY_SP_SHOW = {
|
|
513
|
+
noun: "risky-service-principal",
|
|
514
|
+
key: "riskyServicePrincipal",
|
|
515
|
+
param: "riskyServicePrincipal-id",
|
|
516
|
+
known: RISKY_SP_KNOWN,
|
|
517
|
+
knownList: KNOWN_RISKY_SP_FIELDS,
|
|
518
|
+
defaultSelect: DEFAULT_RISKY_SP_SHOW_SELECT,
|
|
519
|
+
defaultScopes: DEFAULT_RISKY_SP_SCOPES,
|
|
520
|
+
denialHints: RISKY_SP_DENIAL_HINTS,
|
|
521
|
+
correlationHelp: profileName => [
|
|
522
|
+
`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)}`,
|
|
523
|
+
`List this workload's risk history with mg-axi entra risky-service-principal history list --service-principal <risky-service-principal-id> ${profileHint(profileName)}`,
|
|
524
|
+
"Confirming compromise and dismissing risk are separately reviewed writes; this read never confirms, dismisses or remediates risk",
|
|
525
|
+
],
|
|
526
|
+
};
|
|
527
|
+
const RISKY_SP_HISTORY_SHOW = {
|
|
528
|
+
noun: "risky-service-principal history",
|
|
529
|
+
key: "riskyServicePrincipalHistoryItem",
|
|
530
|
+
param: "riskyServicePrincipalHistoryItem-id",
|
|
531
|
+
parentParams: parentServicePrincipal,
|
|
532
|
+
known: RISKY_SP_HISTORY_KNOWN,
|
|
533
|
+
knownList: KNOWN_RISKY_SP_HISTORY_FIELDS,
|
|
534
|
+
defaultSelect: DEFAULT_RISKY_SP_HISTORY_SHOW_SELECT,
|
|
535
|
+
defaultScopes: DEFAULT_RISKY_SP_SCOPES,
|
|
536
|
+
denialHints: RISKY_SP_DENIAL_HINTS,
|
|
537
|
+
correlationHelp: profileName => [
|
|
538
|
+
`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)}`,
|
|
539
|
+
],
|
|
540
|
+
};
|
|
541
|
+
const SP_DETECTION_SHOW = {
|
|
542
|
+
noun: "service-principal-risk-detection",
|
|
543
|
+
key: "servicePrincipalRiskDetection",
|
|
544
|
+
param: "servicePrincipalRiskDetection-id",
|
|
545
|
+
known: SP_DETECTION_KNOWN,
|
|
546
|
+
knownList: KNOWN_SP_DETECTION_FIELDS,
|
|
547
|
+
defaultSelect: DEFAULT_SP_DETECTION_SHOW_SELECT,
|
|
548
|
+
defaultScopes: DEFAULT_RISK_DETECTION_SCOPES,
|
|
549
|
+
denialHints: SP_DETECTION_DENIAL_HINTS,
|
|
550
|
+
correlationHelp: profileName => [
|
|
551
|
+
`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)}`,
|
|
552
|
+
"Limited views stay limited: riskDetail and riskLevel report hidden without Workload Identities Premium detail - never read hidden as none",
|
|
553
|
+
],
|
|
554
|
+
noSignInNote: "This detection carries no associated sign-in (correlationId is null); sign-in correlation does not apply",
|
|
555
|
+
};
|
|
366
556
|
async function showRisk(shape, session, flags, profile, operation, help, profileName) {
|
|
367
557
|
const { select, fields } = selectedFields(flags, shape.defaultSelect, shape.known, shape.knownList, help);
|
|
368
558
|
const scopes = scopesFor(flags, profile, shape.defaultScopes, help);
|
|
@@ -370,7 +560,7 @@ async function showRisk(shape, session, flags, profile, operation, help, profile
|
|
|
370
560
|
const raw = await withGuidance(shape.denialHints, () => session.execute({
|
|
371
561
|
profile,
|
|
372
562
|
operation,
|
|
373
|
-
params: { [shape.param]: String(flags.id) },
|
|
563
|
+
params: { ...(shape.parentParams === undefined ? {} : shape.parentParams(flags, help)), [shape.param]: String(flags.id) },
|
|
374
564
|
query: { $select: select.join(",") },
|
|
375
565
|
scopes,
|
|
376
566
|
}));
|
|
@@ -402,3 +592,21 @@ export async function listRiskDetections(session, flags, profile, operation, hel
|
|
|
402
592
|
export async function showRiskDetection(session, flags, profile, operation, help, profileName) {
|
|
403
593
|
return showRisk(RISK_DETECTION_SHOW, session, flags, profile, operation, help, profileName);
|
|
404
594
|
}
|
|
595
|
+
export async function listRiskyServicePrincipals(session, flags, profile, operation, help, profileName) {
|
|
596
|
+
return listRisk(RISKY_SP_LIST, session, flags, profile, operation, help, profileName);
|
|
597
|
+
}
|
|
598
|
+
export async function showRiskyServicePrincipal(session, flags, profile, operation, help, profileName) {
|
|
599
|
+
return showRisk(RISKY_SP_SHOW, session, flags, profile, operation, help, profileName);
|
|
600
|
+
}
|
|
601
|
+
export async function listRiskyServicePrincipalHistory(session, flags, profile, operation, help, profileName) {
|
|
602
|
+
return listRisk(RISKY_SP_HISTORY_LIST, session, flags, profile, operation, help, profileName);
|
|
603
|
+
}
|
|
604
|
+
export async function showRiskyServicePrincipalHistory(session, flags, profile, operation, help, profileName) {
|
|
605
|
+
return showRisk(RISKY_SP_HISTORY_SHOW, session, flags, profile, operation, help, profileName);
|
|
606
|
+
}
|
|
607
|
+
export async function listServicePrincipalRiskDetections(session, flags, profile, operation, help, profileName) {
|
|
608
|
+
return listRisk(SP_DETECTION_LIST, session, flags, profile, operation, help, profileName);
|
|
609
|
+
}
|
|
610
|
+
export async function showServicePrincipalRiskDetection(session, flags, profile, operation, help, profileName) {
|
|
611
|
+
return showRisk(SP_DETECTION_SHOW, session, flags, profile, operation, help, profileName);
|
|
612
|
+
}
|
package/dist/entra-roles.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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
4
|
// `mg-axi entra directory-role list/show`, `mg-axi entra role-assignment
|
|
4
5
|
// list`, `mg-axi entra pim eligible list` and `mg-axi entra pim active list`.
|
|
@@ -177,6 +178,7 @@ const ACTIVE_DENIAL_HINTS = [
|
|
|
177
178
|
const ROLE_LIST = {
|
|
178
179
|
command: "entra directory-role list",
|
|
179
180
|
key: "directoryRoles",
|
|
181
|
+
noun: "directory roles",
|
|
180
182
|
known: ROLE_KNOWN,
|
|
181
183
|
knownList: KNOWN_ROLE_FIELDS,
|
|
182
184
|
defaultSelect: DEFAULT_ROLE_LIST_SELECT,
|
|
@@ -188,6 +190,7 @@ const ROLE_LIST = {
|
|
|
188
190
|
const ASSIGNMENT_LIST = {
|
|
189
191
|
command: "entra role-assignment list",
|
|
190
192
|
key: "roleAssignments",
|
|
193
|
+
noun: "role assignments",
|
|
191
194
|
known: ASSIGNMENT_KNOWN,
|
|
192
195
|
knownList: KNOWN_ASSIGNMENT_FIELDS,
|
|
193
196
|
defaultSelect: DEFAULT_ASSIGNMENT_SELECT,
|
|
@@ -199,6 +202,7 @@ const ASSIGNMENT_LIST = {
|
|
|
199
202
|
const PIM_ELIGIBLE = {
|
|
200
203
|
command: "entra pim eligible list",
|
|
201
204
|
key: "eligibleAssignments",
|
|
205
|
+
noun: "eligible assignments",
|
|
202
206
|
known: ELIGIBLE_KNOWN,
|
|
203
207
|
knownList: KNOWN_ELIGIBLE_FIELDS,
|
|
204
208
|
defaultSelect: DEFAULT_ELIGIBLE_SELECT,
|
|
@@ -210,6 +214,7 @@ const PIM_ELIGIBLE = {
|
|
|
210
214
|
const PIM_ACTIVE = {
|
|
211
215
|
command: "entra pim active list",
|
|
212
216
|
key: "activeAssignments",
|
|
217
|
+
noun: "active assignments",
|
|
213
218
|
known: ACTIVE_KNOWN,
|
|
214
219
|
knownList: KNOWN_ACTIVE_FIELDS,
|
|
215
220
|
defaultSelect: DEFAULT_ACTIVE_SELECT,
|
|
@@ -264,16 +269,17 @@ async function listCollection(shape, session, flags, profile, operation, help, p
|
|
|
264
269
|
if (!result.complete) {
|
|
265
270
|
return {
|
|
266
271
|
[shape.key]: rows,
|
|
267
|
-
|
|
272
|
+
...listTotals(rows.length, result.total, shape.noun, false),
|
|
273
|
+
complete: false,
|
|
274
|
+
reason: result.reason,
|
|
268
275
|
cursor: result.cursor,
|
|
269
276
|
help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, ...standing],
|
|
270
277
|
};
|
|
271
278
|
}
|
|
272
|
-
const count = { returned: rows.length, complete: true };
|
|
273
279
|
if (!rows.length) {
|
|
274
|
-
return { [shape.key]: rows,
|
|
280
|
+
return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.noun, true), complete: true, help: [shape.emptyNote, ...standing] };
|
|
275
281
|
}
|
|
276
|
-
return { [shape.key]: rows,
|
|
282
|
+
return { [shape.key]: rows, ...listTotals(rows.length, result.total, shape.noun, true), complete: true, help: [...truncationHints, ...standing] };
|
|
277
283
|
}
|
|
278
284
|
export async function listDirectoryRoles(session, flags, profile, operation, help, profileName) {
|
|
279
285
|
return listCollection(ROLE_LIST, session, flags, profile, operation, help, profileName);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AxiError } from "axi-sdk-js";
|
|
2
2
|
import { resolveSessionOperation } from "./graph-session.js";
|
|
3
|
+
import { listTotals } from "./list-totals.js";
|
|
3
4
|
// EXT-01 directory-subscriptions subfamily: the read mapping behind
|
|
4
5
|
// `mg-axi entra subscription list/show/count`. Operation construction stays
|
|
5
6
|
// beside its command; the shared session owns URLs, credentials, paging,
|
|
@@ -242,17 +243,18 @@ export async function listSubscriptions(session, flags, profile, operation, help
|
|
|
242
243
|
if (!result.complete) {
|
|
243
244
|
return {
|
|
244
245
|
subscriptions,
|
|
245
|
-
|
|
246
|
+
...listTotals(subscriptions.length, result.total, "subscriptions", false),
|
|
247
|
+
complete: false,
|
|
248
|
+
reason: result.reason,
|
|
246
249
|
cursor: result.cursor,
|
|
247
250
|
help: [...truncationHints, resumeHint(profileName), showHint],
|
|
248
251
|
};
|
|
249
252
|
}
|
|
250
|
-
const count = { returned: subscriptions.length, complete: true };
|
|
251
253
|
if (!subscriptions.length) {
|
|
252
254
|
const emptyHint = common.filter === undefined ? EMPTY_NOTE : "0 subscriptions matched; the absence of results is the answer, not an error";
|
|
253
|
-
return { subscriptions,
|
|
255
|
+
return { subscriptions, ...listTotals(subscriptions.length, result.total, "subscriptions", true), complete: true, help: [emptyHint] };
|
|
254
256
|
}
|
|
255
|
-
return { subscriptions,
|
|
257
|
+
return { subscriptions, ...listTotals(subscriptions.length, result.total, "subscriptions", true), complete: true, help: [...truncationHints, showHint] };
|
|
256
258
|
}
|
|
257
259
|
export async function showSubscription(session, flags, profile, operation, help, profileName) {
|
|
258
260
|
const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
|
package/dist/entra-users.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { listTotals } from "./list-totals.js";
|
|
2
3
|
// READ-01: the Entra user read mapping behind `mg-axi entra user list/show`.
|
|
3
4
|
// Operation construction stays beside its command; the shared session owns
|
|
4
5
|
// URLs, credentials, paging, retries and error translation, and the SDK owns
|
|
@@ -169,16 +170,18 @@ export async function listUsers(session, flags, profile, operation, help, profil
|
|
|
169
170
|
if (!result.complete) {
|
|
170
171
|
return {
|
|
171
172
|
users,
|
|
172
|
-
|
|
173
|
+
...listTotals(users.length, result.total, "users", false),
|
|
174
|
+
complete: false,
|
|
175
|
+
reason: result.reason,
|
|
173
176
|
cursor: result.cursor,
|
|
174
177
|
help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, showHint],
|
|
175
178
|
};
|
|
176
179
|
}
|
|
177
|
-
const count = { returned: users.length, complete: true };
|
|
178
180
|
if (!users.length) {
|
|
179
181
|
return {
|
|
180
182
|
users,
|
|
181
|
-
|
|
183
|
+
...listTotals(users.length, result.total, "users", true),
|
|
184
|
+
complete: true,
|
|
182
185
|
help: [
|
|
183
186
|
`mg-axi entra user list --filter <odata-filter> ${profileHint(profileName)}`,
|
|
184
187
|
"0 users matched; the absence of results is the answer, not an error",
|
|
@@ -186,7 +189,7 @@ export async function listUsers(session, flags, profile, operation, help, profil
|
|
|
186
189
|
};
|
|
187
190
|
}
|
|
188
191
|
const helpHints = [...truncationHints, showHint];
|
|
189
|
-
return { users,
|
|
192
|
+
return { users, ...listTotals(users.length, result.total, "users", true), complete: true, help: helpHints };
|
|
190
193
|
}
|
|
191
194
|
export async function showUser(session, flags, profile, operation, help, profileName) {
|
|
192
195
|
const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
|
package/dist/graph-session.js
CHANGED
|
@@ -38,6 +38,7 @@ const READ_SCOPES = new Set([
|
|
|
38
38
|
"GroupMember.ReadBasic.All",
|
|
39
39
|
"IdentityProvider.Read.All",
|
|
40
40
|
"IdentityRiskEvent.Read.All",
|
|
41
|
+
"IdentityRiskyServicePrincipal.Read.All",
|
|
41
42
|
"IdentityRiskyUser.Read.All",
|
|
42
43
|
"LicenseAssignment.Read.All",
|
|
43
44
|
"LifecycleWorkflows-Reports.Read.All",
|
|
@@ -54,8 +55,10 @@ const READ_SCOPES = new Set([
|
|
|
54
55
|
"Policy.Read.All",
|
|
55
56
|
"Policy.Read.AuthenticationMethod",
|
|
56
57
|
"Policy.Read.ConditionalAccess",
|
|
58
|
+
"PrivilegedAssignmentSchedule.Read.AzureADGroup",
|
|
57
59
|
"PrivilegedEligibilitySchedule.Read.AzureADGroup",
|
|
58
60
|
"PublicKeyInfrastructure.Read.All",
|
|
61
|
+
"RiskPreventionProviders.Read.All",
|
|
59
62
|
"RoleAssignmentSchedule.Read.Directory",
|
|
60
63
|
"RoleEligibilitySchedule.Read.Directory",
|
|
61
64
|
"RoleManagement.Read.Directory",
|
|
@@ -551,7 +554,7 @@ function bodyBytes(body) {
|
|
|
551
554
|
return Buffer.byteLength(body, "utf8");
|
|
552
555
|
}
|
|
553
556
|
function encodeCursor(operation, state) {
|
|
554
|
-
const payload = { v: 3, op: operation.id, next: state.next ?? null, buffered: state.buffered, seen: state.seen.slice(-MAX_SEEN), query: state.query, consistencyLevel: state.consistencyLevel ?? null, context: state.context, identity: state.identity };
|
|
557
|
+
const payload = { v: 3, op: operation.id, next: state.next ?? null, buffered: state.buffered, seen: state.seen.slice(-MAX_SEEN), query: state.query, consistencyLevel: state.consistencyLevel ?? null, context: state.context, identity: state.identity, total: state.total };
|
|
555
558
|
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
556
559
|
}
|
|
557
560
|
function decodeCursor(operation, cursor) {
|
|
@@ -574,13 +577,17 @@ function decodeCursor(operation, cursor) {
|
|
|
574
577
|
}
|
|
575
578
|
if (record.buffered.length > 5000)
|
|
576
579
|
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["The cursor buffers at most one fetched page"]);
|
|
580
|
+
const total = record.total === undefined ? null : record.total;
|
|
581
|
+
if (!(total === null || (typeof total === "number" && Number.isSafeInteger(total) && total >= 0))) {
|
|
582
|
+
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Resume with the cursor from a partial result of the same operation"]);
|
|
583
|
+
}
|
|
577
584
|
if (record.identity === null && (record.buffered.length > 0 || record.seen.length > 0))
|
|
578
585
|
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Fetched rows and consumed pages require a bound credential identity"]);
|
|
579
586
|
const query = record.query;
|
|
580
587
|
const consistencyLevel = record.consistencyLevel ?? undefined;
|
|
581
588
|
buildQuery(query);
|
|
582
589
|
checkQueryContext(query, consistencyLevel);
|
|
583
|
-
return { next: record.next ?? undefined, buffered: record.buffered.map(redact), seen: [...record.seen], query, consistencyLevel, context: record.context, identity: record.identity };
|
|
590
|
+
return { next: record.next ?? undefined, buffered: record.buffered.map(redact), seen: [...record.seen], query, consistencyLevel, context: record.context, identity: record.identity, total };
|
|
584
591
|
}
|
|
585
592
|
function nextLinkOf(body) {
|
|
586
593
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
@@ -588,6 +595,16 @@ function nextLinkOf(body) {
|
|
|
588
595
|
const value = body["@odata.nextLink"];
|
|
589
596
|
return typeof value === "string" && value ? value : undefined;
|
|
590
597
|
}
|
|
598
|
+
// The total Graph already supplies on collection pages when the query
|
|
599
|
+
// carries $count=true. Only a valid non-negative integer becomes a total;
|
|
600
|
+
// anything else stays unknown rather than failing the read or inventing
|
|
601
|
+
// a number.
|
|
602
|
+
function countOf(body) {
|
|
603
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
604
|
+
return null;
|
|
605
|
+
const count = body["@odata.count"];
|
|
606
|
+
return typeof count === "number" && Number.isSafeInteger(count) && count >= 0 ? count : null;
|
|
607
|
+
}
|
|
591
608
|
function valuesOf(operation, body) {
|
|
592
609
|
if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "value")) {
|
|
593
610
|
throw new AxiError(`Graph returned a malformed collection body for ${operation.id}`, "GRAPH_ERROR", ["Collections carry a value array; treat anything else as unknown, not empty"]);
|
|
@@ -699,14 +716,18 @@ export class GraphSession {
|
|
|
699
716
|
let requests = 0;
|
|
700
717
|
let bytes = 0;
|
|
701
718
|
let identity = resumed?.identity ?? null;
|
|
719
|
+
// First known server total wins across pages and resumes; later pages
|
|
720
|
+
// repeat the same query total, so disagreement keeps the first.
|
|
721
|
+
let total = resumed?.total ?? null;
|
|
702
722
|
const partial = (reason, next, buffered) => ({
|
|
703
723
|
value: results,
|
|
704
724
|
query,
|
|
705
725
|
complete: false,
|
|
706
726
|
reason,
|
|
707
|
-
cursor: encodeCursor(operation, { next, buffered, seen: [...seenList], query, consistencyLevel, context, identity }),
|
|
727
|
+
cursor: encodeCursor(operation, { next, buffered, seen: [...seenList], query, consistencyLevel, context, identity, total }),
|
|
708
728
|
requests,
|
|
709
729
|
bytes,
|
|
730
|
+
total,
|
|
710
731
|
});
|
|
711
732
|
let token;
|
|
712
733
|
try {
|
|
@@ -744,11 +765,11 @@ export class GraphSession {
|
|
|
744
765
|
return partial("deadline exceeded", current, pending);
|
|
745
766
|
if (args.limit !== undefined && results.length >= args.limit) {
|
|
746
767
|
if (pending.length === 0 && !current)
|
|
747
|
-
return { value: results, query, complete: true, requests, bytes };
|
|
768
|
+
return { value: results, query, complete: true, requests, bytes, total };
|
|
748
769
|
return partial("row limit reached; buffered remainder is preserved in the cursor", current, pending);
|
|
749
770
|
}
|
|
750
771
|
if (!current)
|
|
751
|
-
return { value: results, query, complete: true, requests, bytes };
|
|
772
|
+
return { value: results, query, complete: true, requests, bytes, total };
|
|
752
773
|
if (requests >= budget.maxRequests)
|
|
753
774
|
return partial(`request budget exhausted after ${requests} requests`, current, pending);
|
|
754
775
|
const cycleKey = digestUrl(current);
|
|
@@ -837,6 +858,9 @@ export class GraphSession {
|
|
|
837
858
|
throw new AxiError(`Graph returned a non-JSON success body for ${operation.id}`, "GRAPH_ERROR", ["Successful reads are JSON; anything else is malformed"]);
|
|
838
859
|
}
|
|
839
860
|
const rows = valuesOf(operation, parsed);
|
|
861
|
+
const pageTotal = countOf(parsed);
|
|
862
|
+
if (total === null && pageTotal !== null)
|
|
863
|
+
total = pageTotal;
|
|
840
864
|
const rawNext = nextLinkOf(parsed);
|
|
841
865
|
const nextUrl = rawNext ? authorizeUrl(operation, params, rawNext, fetchUrl, consistencyLevel) : undefined;
|
|
842
866
|
remember(current);
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { encode } from "@toon-format/toon";
|
|
2
|
+
import { Profiles } from "./profiles.js";
|
|
3
|
+
// Session-start ambient summary behind the mg-axi-hook entry point.
|
|
4
|
+
// Local configuration state only: every configured profile name with its
|
|
5
|
+
// tenant label, auth-cache presence and effective write posture, plus the
|
|
6
|
+
// package version and one next-step hint. No session, transport, Graph or
|
|
7
|
+
// sign-in call of any kind; every failure collapses to a short record with
|
|
8
|
+
// exit code 0, so a missing or broken config never breaks agent startup.
|
|
9
|
+
//
|
|
10
|
+
// Auth-cache presence reads the OS credential store directly: delegated
|
|
11
|
+
// profiles keep their MSAL session blob under the "mg-axi" service with the
|
|
12
|
+
// session key below, so presence means a previous explicit login happened on
|
|
13
|
+
// this machine. Only presence is reported; tokens and key material never
|
|
14
|
+
// reach the output. Application profiles acquire tokens silently per run, so
|
|
15
|
+
// they report the client-credentials mechanism instead of cache state.
|
|
16
|
+
// Session cache key mirrors MsalProvider.application() in
|
|
17
|
+
// src/msal-provider.ts; change both together.
|
|
18
|
+
function delegatedSessionKey(profile) {
|
|
19
|
+
return JSON.stringify([profile.credentialRef.key, profile.tenantId, profile.clientId, profile.cloud]);
|
|
20
|
+
}
|
|
21
|
+
async function probeAuthPresence(profile) {
|
|
22
|
+
if (profile.mode !== "delegated")
|
|
23
|
+
return "client-credentials";
|
|
24
|
+
let store;
|
|
25
|
+
try {
|
|
26
|
+
store = await import("keytar").then(module => module.default).catch(() => undefined);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return "unknown";
|
|
30
|
+
}
|
|
31
|
+
if (!store)
|
|
32
|
+
return "unknown";
|
|
33
|
+
try {
|
|
34
|
+
return typeof await store.getPassword("mg-axi", delegatedSessionKey(profile)) === "string" ? "cached" : "sign-in required";
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Next-step hints name real catalogue leaves; test/hook.test.mjs asserts
|
|
41
|
+
// every `mg-axi ...` command mentioned here exists in the catalogue, so the
|
|
42
|
+
// hook text cannot drift from the single source.
|
|
43
|
+
const CONFIGURED_HINT = "Run npx -y @knowttl/mg-axi doctor --profile <name> to check a profile";
|
|
44
|
+
const SETUP_HINT = "Run npx -y @knowttl/mg-axi setup to find the config path and example";
|
|
45
|
+
export async function hookSummary(options) {
|
|
46
|
+
try {
|
|
47
|
+
return await renderHookSummary(options);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return "mg: status unavailable\n";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function renderHookSummary({ version, env = process.env, configPath, authPresence = probeAuthPresence }) {
|
|
54
|
+
let store;
|
|
55
|
+
let names;
|
|
56
|
+
try {
|
|
57
|
+
store = new Profiles(configPath ?? env.MG_AXI_CONFIG);
|
|
58
|
+
names = store.list().map(item => item.name);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return `${encode({ mg: "configuration invalid", help: ["Check the selected config file's path, permissions and JSON syntax"] })}\n`;
|
|
62
|
+
}
|
|
63
|
+
if (names.length === 0) {
|
|
64
|
+
return `${encode({ mg: "not configured", help: [SETUP_HINT] })}\n`;
|
|
65
|
+
}
|
|
66
|
+
const profiles = [];
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
const profile = store.resolve(name).profile;
|
|
69
|
+
let auth = "unknown";
|
|
70
|
+
try {
|
|
71
|
+
auth = await authPresence(profile);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
auth = "unknown";
|
|
75
|
+
}
|
|
76
|
+
profiles.push({
|
|
77
|
+
name,
|
|
78
|
+
tenant: profile.tenantId,
|
|
79
|
+
auth,
|
|
80
|
+
writes: profile.writes?.allowWrites === true ? profile.writes.operations.join(",") : "disabled",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return `${encode({ mg: "configured", version, profiles, help: [CONFIGURED_HINT] })}\n`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// LIST-TOTALS: the one shared helper behind every list command's uniform
|
|
2
|
+
// "N of M" total line (AXI principle: pre-computed aggregates).
|
|
3
|
+
//
|
|
4
|
+
// A list line names rows returned of total available. The total is the one
|
|
5
|
+
// Graph already supplies on the fetched pages (@odata.count, captured by the
|
|
6
|
+
// shared session when the query carries $count=true); it is never fetched
|
|
7
|
+
// with an extra request and never invented. Where the pages carry no usable
|
|
8
|
+
// total, the line stays honest: a complete read names its rows, a partial
|
|
9
|
+
// read says more is available.
|
|
10
|
+
//
|
|
11
|
+
// The shape matches az-axi's countLine and vectra-axi's collection counts:
|
|
12
|
+
// a `total` field (number when known, null when not) beside a `count`
|
|
13
|
+
// string. The noun is always plural ("groups", never "group"); the helper
|
|
14
|
+
// never inflects, so "1 groups" reads exactly like az-axi's "1 of N" lines.
|
|
15
|
+
import { AxiError } from "axi-sdk-js";
|
|
16
|
+
function checkShown(shown) {
|
|
17
|
+
if (!Number.isSafeInteger(shown) || shown < 0) {
|
|
18
|
+
throw new AxiError(`Invalid list row count ${shown}`, "VALIDATION_ERROR", [
|
|
19
|
+
"List totals count the projected rows actually returned",
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function checkTotal(total) {
|
|
24
|
+
if (total === undefined)
|
|
25
|
+
return null;
|
|
26
|
+
if (total === null)
|
|
27
|
+
return null;
|
|
28
|
+
if (!Number.isSafeInteger(total) || total < 0) {
|
|
29
|
+
throw new AxiError(`Invalid list total ${total}`, "VALIDATION_ERROR", [
|
|
30
|
+
"A list total is the server-supplied @odata.count or null when unknown; never fabricate one",
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
return total;
|
|
34
|
+
}
|
|
35
|
+
export function listTotals(shown, total, noun, complete) {
|
|
36
|
+
checkShown(shown);
|
|
37
|
+
const known = checkTotal(total);
|
|
38
|
+
if (typeof noun !== "string" || !noun) {
|
|
39
|
+
throw new AxiError("Invalid list noun for totals", "VALIDATION_ERROR", [
|
|
40
|
+
"Name the listed collection in the plural, e.g. groups, members, memberships",
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
// A partial read always names more: the known total stays visible even
|
|
44
|
+
// when the window happens to match it (az-axi countLine parity), while
|
|
45
|
+
// an unknown total says more is available. A complete read collapses a
|
|
46
|
+
// matching total to the row count.
|
|
47
|
+
if (known !== null && (known !== shown || !complete))
|
|
48
|
+
return { total: known, count: `${shown} of ${known} ${noun}` };
|
|
49
|
+
if (complete)
|
|
50
|
+
return { total: known, count: `${shown} ${noun}` };
|
|
51
|
+
return { total: known, count: `${shown} ${noun} shown, more available` };
|
|
52
|
+
}
|