@knowttl/mg-axi 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +251 -77
- package/dist/api.js +253 -8
- package/dist/bin/mg-axi-hook.js +31 -0
- package/dist/catalogue.js +693 -2
- package/dist/cli.js +155 -17
- package/dist/docs.js +175 -46
- package/dist/doctor.js +2 -2
- package/dist/entra-access-reviews.js +11 -4
- package/dist/entra-apps.js +171 -14
- package/dist/entra-audit-logs.js +101 -16
- package/dist/entra-auth-methods.js +8 -4
- package/dist/entra-certificate-auth.js +12 -8
- package/dist/entra-conditional-access.js +425 -15
- 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-federation-configurations.js +7 -4
- package/dist/entra-grants.js +8 -4
- package/dist/entra-group-lifecycle.js +13 -8
- package/dist/entra-group-pim.js +431 -0
- package/dist/entra-groups.js +13 -8
- package/dist/entra-identity-providers.js +7 -4
- package/dist/entra-lifecycle-workflows.js +152 -13
- package/dist/entra-multi-tenant-organization.js +7 -4
- package/dist/entra-on-premises-synchronization.js +6 -4
- package/dist/entra-organization.js +13 -8
- package/dist/entra-risk-prevention.js +442 -0
- package/dist/entra-risk.js +224 -9
- package/dist/entra-roles.js +204 -12
- package/dist/entra-subscriptions.js +6 -4
- package/dist/entra-terms-of-use.js +13 -8
- package/dist/entra-users.js +7 -4
- package/dist/graph-session.js +30 -5
- package/dist/hook.js +84 -0
- package/dist/list-totals.js +52 -0
- package/dist/setup.js +9 -1
- package/docs/coverage.md +743 -610
- package/inventory/operations.json +2 -2
- package/inventory/schema.json +1 -1
- package/package.json +4 -3
- package/skills/mg-axi/SKILL.md +86 -1
|
@@ -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);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { listTotals } from "./list-totals.js";
|
|
2
3
|
// EXT-01 terms-of-use subfamily: the read mapping behind
|
|
3
4
|
// `mg-axi entra agreement list/show`,
|
|
4
5
|
// `mg-axi entra agreement acceptance list/show --agreement <agreement-id>`
|
|
@@ -264,20 +265,22 @@ export async function listAgreements(session, flags, profile, operation, help, p
|
|
|
264
265
|
if (!result.complete) {
|
|
265
266
|
return {
|
|
266
267
|
agreements,
|
|
267
|
-
|
|
268
|
+
...listTotals(agreements.length, result.total, "agreements", false),
|
|
269
|
+
complete: false,
|
|
270
|
+
reason: result.reason,
|
|
268
271
|
cursor: result.cursor,
|
|
269
272
|
help: [...truncationHints, resumeHint(profileName), showHint],
|
|
270
273
|
};
|
|
271
274
|
}
|
|
272
|
-
const count = { returned: agreements.length, complete: true };
|
|
273
275
|
if (!agreements.length) {
|
|
274
276
|
return {
|
|
275
277
|
agreements,
|
|
276
|
-
|
|
278
|
+
...listTotals(agreements.length, result.total, "agreements", true),
|
|
279
|
+
complete: true,
|
|
277
280
|
help: ["0 agreements matched; no terms-of-use agreements are configured for the tenant, or no row passed --filter"],
|
|
278
281
|
};
|
|
279
282
|
}
|
|
280
|
-
return { agreements,
|
|
283
|
+
return { agreements, ...listTotals(agreements.length, result.total, "agreements", true), complete: true, help: [...truncationHints, showHint] };
|
|
281
284
|
}
|
|
282
285
|
export async function showAgreement(session, flags, profile, operation, help, profileName) {
|
|
283
286
|
const { select, fields } = selectedFields(flags, KNOWN_AGREEMENTS, KNOWN_AGREEMENT_FIELDS, DEFAULT_AGREEMENT_SHOW_SELECT, help, "agreement");
|
|
@@ -325,20 +328,22 @@ async function collectAcceptances(session, flags, profile, operation, help, prof
|
|
|
325
328
|
if (!result.complete) {
|
|
326
329
|
return {
|
|
327
330
|
agreementAcceptances: acceptances,
|
|
328
|
-
|
|
331
|
+
...listTotals(acceptances.length, result.total, "agreement acceptances", false),
|
|
332
|
+
complete: false,
|
|
333
|
+
reason: result.reason,
|
|
329
334
|
cursor: result.cursor,
|
|
330
335
|
help: [...truncationHints, resumeHint(profileName), showHint],
|
|
331
336
|
};
|
|
332
337
|
}
|
|
333
|
-
const count = { returned: acceptances.length, complete: true };
|
|
334
338
|
if (!acceptances.length) {
|
|
335
339
|
return {
|
|
336
340
|
agreementAcceptances: acceptances,
|
|
337
|
-
|
|
341
|
+
...listTotals(acceptances.length, result.total, "agreement acceptances", true),
|
|
342
|
+
complete: true,
|
|
338
343
|
help: ["0 agreement acceptances matched; nobody recorded an acceptance in scope yet, or no row passed --filter"],
|
|
339
344
|
};
|
|
340
345
|
}
|
|
341
|
-
return { agreementAcceptances: acceptances,
|
|
346
|
+
return { agreementAcceptances: acceptances, ...listTotals(acceptances.length, result.total, "agreement acceptances", true), complete: true, help: [...truncationHints, showHint] };
|
|
342
347
|
}
|
|
343
348
|
async function showAcceptanceById(session, flags, profile, operation, help, profileName, command, agreement) {
|
|
344
349
|
const { select, fields } = selectedFields(flags, KNOWN_ACCEPTANCES, KNOWN_ACCEPTANCE_FIELDS, DEFAULT_ACCEPTANCE_SELECT, help, "agreement-acceptance");
|
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
|
@@ -25,6 +25,7 @@ const READ_SCOPES = new Set([
|
|
|
25
25
|
"AdministrativeUnit.Read.All",
|
|
26
26
|
"Application.Read.All",
|
|
27
27
|
"AuditLog.Read.All",
|
|
28
|
+
"AuthenticationContext.Read.All",
|
|
28
29
|
"CustomSecAttributeDefinition.Read.All",
|
|
29
30
|
"CrossTenantInformation.ReadBasic.All",
|
|
30
31
|
"DelegatedAdminRelationship.Read.All",
|
|
@@ -38,6 +39,7 @@ const READ_SCOPES = new Set([
|
|
|
38
39
|
"GroupMember.ReadBasic.All",
|
|
39
40
|
"IdentityProvider.Read.All",
|
|
40
41
|
"IdentityRiskEvent.Read.All",
|
|
42
|
+
"IdentityRiskyServicePrincipal.Read.All",
|
|
41
43
|
"IdentityRiskyUser.Read.All",
|
|
42
44
|
"LicenseAssignment.Read.All",
|
|
43
45
|
"LifecycleWorkflows-Reports.Read.All",
|
|
@@ -54,8 +56,10 @@ const READ_SCOPES = new Set([
|
|
|
54
56
|
"Policy.Read.All",
|
|
55
57
|
"Policy.Read.AuthenticationMethod",
|
|
56
58
|
"Policy.Read.ConditionalAccess",
|
|
59
|
+
"PrivilegedAssignmentSchedule.Read.AzureADGroup",
|
|
57
60
|
"PrivilegedEligibilitySchedule.Read.AzureADGroup",
|
|
58
61
|
"PublicKeyInfrastructure.Read.All",
|
|
62
|
+
"RiskPreventionProviders.Read.All",
|
|
59
63
|
"RoleAssignmentSchedule.Read.Directory",
|
|
60
64
|
"RoleEligibilitySchedule.Read.Directory",
|
|
61
65
|
"RoleManagement.Read.Directory",
|
|
@@ -551,7 +555,7 @@ function bodyBytes(body) {
|
|
|
551
555
|
return Buffer.byteLength(body, "utf8");
|
|
552
556
|
}
|
|
553
557
|
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 };
|
|
558
|
+
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
559
|
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
556
560
|
}
|
|
557
561
|
function decodeCursor(operation, cursor) {
|
|
@@ -574,13 +578,17 @@ function decodeCursor(operation, cursor) {
|
|
|
574
578
|
}
|
|
575
579
|
if (record.buffered.length > 5000)
|
|
576
580
|
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["The cursor buffers at most one fetched page"]);
|
|
581
|
+
const total = record.total === undefined ? null : record.total;
|
|
582
|
+
if (!(total === null || (typeof total === "number" && Number.isSafeInteger(total) && total >= 0))) {
|
|
583
|
+
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Resume with the cursor from a partial result of the same operation"]);
|
|
584
|
+
}
|
|
577
585
|
if (record.identity === null && (record.buffered.length > 0 || record.seen.length > 0))
|
|
578
586
|
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Fetched rows and consumed pages require a bound credential identity"]);
|
|
579
587
|
const query = record.query;
|
|
580
588
|
const consistencyLevel = record.consistencyLevel ?? undefined;
|
|
581
589
|
buildQuery(query);
|
|
582
590
|
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 };
|
|
591
|
+
return { next: record.next ?? undefined, buffered: record.buffered.map(redact), seen: [...record.seen], query, consistencyLevel, context: record.context, identity: record.identity, total };
|
|
584
592
|
}
|
|
585
593
|
function nextLinkOf(body) {
|
|
586
594
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
@@ -588,6 +596,16 @@ function nextLinkOf(body) {
|
|
|
588
596
|
const value = body["@odata.nextLink"];
|
|
589
597
|
return typeof value === "string" && value ? value : undefined;
|
|
590
598
|
}
|
|
599
|
+
// The total Graph already supplies on collection pages when the query
|
|
600
|
+
// carries $count=true. Only a valid non-negative integer becomes a total;
|
|
601
|
+
// anything else stays unknown rather than failing the read or inventing
|
|
602
|
+
// a number.
|
|
603
|
+
function countOf(body) {
|
|
604
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
605
|
+
return null;
|
|
606
|
+
const count = body["@odata.count"];
|
|
607
|
+
return typeof count === "number" && Number.isSafeInteger(count) && count >= 0 ? count : null;
|
|
608
|
+
}
|
|
591
609
|
function valuesOf(operation, body) {
|
|
592
610
|
if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "value")) {
|
|
593
611
|
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 +717,18 @@ export class GraphSession {
|
|
|
699
717
|
let requests = 0;
|
|
700
718
|
let bytes = 0;
|
|
701
719
|
let identity = resumed?.identity ?? null;
|
|
720
|
+
// First known server total wins across pages and resumes; later pages
|
|
721
|
+
// repeat the same query total, so disagreement keeps the first.
|
|
722
|
+
let total = resumed?.total ?? null;
|
|
702
723
|
const partial = (reason, next, buffered) => ({
|
|
703
724
|
value: results,
|
|
704
725
|
query,
|
|
705
726
|
complete: false,
|
|
706
727
|
reason,
|
|
707
|
-
cursor: encodeCursor(operation, { next, buffered, seen: [...seenList], query, consistencyLevel, context, identity }),
|
|
728
|
+
cursor: encodeCursor(operation, { next, buffered, seen: [...seenList], query, consistencyLevel, context, identity, total }),
|
|
708
729
|
requests,
|
|
709
730
|
bytes,
|
|
731
|
+
total,
|
|
710
732
|
});
|
|
711
733
|
let token;
|
|
712
734
|
try {
|
|
@@ -744,11 +766,11 @@ export class GraphSession {
|
|
|
744
766
|
return partial("deadline exceeded", current, pending);
|
|
745
767
|
if (args.limit !== undefined && results.length >= args.limit) {
|
|
746
768
|
if (pending.length === 0 && !current)
|
|
747
|
-
return { value: results, query, complete: true, requests, bytes };
|
|
769
|
+
return { value: results, query, complete: true, requests, bytes, total };
|
|
748
770
|
return partial("row limit reached; buffered remainder is preserved in the cursor", current, pending);
|
|
749
771
|
}
|
|
750
772
|
if (!current)
|
|
751
|
-
return { value: results, query, complete: true, requests, bytes };
|
|
773
|
+
return { value: results, query, complete: true, requests, bytes, total };
|
|
752
774
|
if (requests >= budget.maxRequests)
|
|
753
775
|
return partial(`request budget exhausted after ${requests} requests`, current, pending);
|
|
754
776
|
const cycleKey = digestUrl(current);
|
|
@@ -837,6 +859,9 @@ export class GraphSession {
|
|
|
837
859
|
throw new AxiError(`Graph returned a non-JSON success body for ${operation.id}`, "GRAPH_ERROR", ["Successful reads are JSON; anything else is malformed"]);
|
|
838
860
|
}
|
|
839
861
|
const rows = valuesOf(operation, parsed);
|
|
862
|
+
const pageTotal = countOf(parsed);
|
|
863
|
+
if (total === null && pageTotal !== null)
|
|
864
|
+
total = pageTotal;
|
|
840
865
|
const rawNext = nextLinkOf(parsed);
|
|
841
866
|
const nextUrl = rawNext ? authorizeUrl(operation, params, rawNext, fetchUrl, consistencyLevel) : undefined;
|
|
842
867
|
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
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -15,7 +15,9 @@ const READ_FAMILY_PREFIXES = [
|
|
|
15
15
|
["entra user", "user"],
|
|
16
16
|
["entra registration", "registration"],
|
|
17
17
|
["entra group", "group"],
|
|
18
|
+
["entra directory-role-template", "directory-role/PIM"],
|
|
18
19
|
["entra directory-role", "directory-role/PIM"],
|
|
20
|
+
["entra scoped-role-membership", "directory-role/PIM"],
|
|
19
21
|
["entra role-assignment", "directory-role/PIM"],
|
|
20
22
|
["entra pim", "directory-role/PIM"],
|
|
21
23
|
["entra device", "device"],
|
|
@@ -49,14 +51,20 @@ const READ_FAMILY_PREFIXES = [
|
|
|
49
51
|
["entra tenant-information", "tenant information"],
|
|
50
52
|
["entra sign-in", "sign-in/directory-audit"],
|
|
51
53
|
["entra directory-audit", "sign-in/directory-audit"],
|
|
54
|
+
["entra provisioning", "sign-in/directory-audit"],
|
|
52
55
|
["entra application", "application/service-principal and consent grants"],
|
|
53
56
|
["entra service-principal", "application/service-principal and consent grants"],
|
|
54
57
|
["entra risky-user", "risk"],
|
|
55
58
|
["entra risk-detection", "risk"],
|
|
59
|
+
["entra risky-service-principal", "risk"],
|
|
60
|
+
["entra service-principal-risk-detection", "risk"],
|
|
56
61
|
["entra conditional-access", "Conditional Access"],
|
|
57
62
|
["entra identity-provider", "identity-provider"],
|
|
58
63
|
["entra federation-configuration", "federation configurations"],
|
|
59
64
|
["entra data-policy-operation", "data policy operations"],
|
|
65
|
+
["entra fraud-protection-provider", "risk prevention"],
|
|
66
|
+
["entra web-application-firewall-provider", "risk prevention"],
|
|
67
|
+
["entra web-application-firewall-verification", "risk prevention"],
|
|
60
68
|
["entra access-review", "access-review"],
|
|
61
69
|
["entra entitlement catalog", "entitlement catalogs and access packages"],
|
|
62
70
|
["entra entitlement access-package", "entitlement catalogs and access packages"],
|
|
@@ -107,7 +115,7 @@ export function setupView(store) {
|
|
|
107
115
|
reads,
|
|
108
116
|
writes,
|
|
109
117
|
local: implemented.length - reads - writes,
|
|
110
|
-
api: `Entra ${families.slice(0, -1).join(", ")} and ${families.at(-1)} reads plus reviewed raw api get, one gated group-membership write, one gated account enable/disable write, one gated session-revocation write and one gated risky-user dismissal write; every other operation is scheduled, blocked, deprecated or excluded`,
|
|
118
|
+
api: `Entra ${families.slice(0, -1).join(", ")} and ${families.at(-1)} reads plus reviewed raw api get, one gated group-membership write, one gated account enable/disable write, one gated session-revocation write and one gated risky-user dismissal write; every other v1.0 operation is deferred out of v1, and every other beta operation remains scheduled (never agreed for v1), blocked, deprecated, unavailable or excluded`,
|
|
111
119
|
report: "docs/coverage.md",
|
|
112
120
|
},
|
|
113
121
|
integration: "Install skills/mg-axi/SKILL.md explicitly through your agent's skill installation mechanism; setup only shows guidance and installs no skills or hooks",
|