@knowttl/mg-axi 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +251 -77
  2. package/dist/api.js +253 -8
  3. package/dist/bin/mg-axi-hook.js +31 -0
  4. package/dist/catalogue.js +693 -2
  5. package/dist/cli.js +155 -17
  6. package/dist/docs.js +175 -46
  7. package/dist/doctor.js +2 -2
  8. package/dist/entra-access-reviews.js +11 -4
  9. package/dist/entra-apps.js +171 -14
  10. package/dist/entra-audit-logs.js +101 -16
  11. package/dist/entra-auth-methods.js +8 -4
  12. package/dist/entra-certificate-auth.js +12 -8
  13. package/dist/entra-conditional-access.js +425 -15
  14. package/dist/entra-contacts.js +19 -12
  15. package/dist/entra-contracts.js +7 -4
  16. package/dist/entra-custom-security-attributes.js +19 -12
  17. package/dist/entra-data-policy-operations.js +7 -4
  18. package/dist/entra-delegated-admin.js +55 -6
  19. package/dist/entra-deleted-items.js +6 -4
  20. package/dist/entra-directory-objects.js +6 -4
  21. package/dist/entra-directory.js +19 -12
  22. package/dist/entra-domains.js +13 -8
  23. package/dist/entra-entitlement-management.js +12 -4
  24. package/dist/entra-federation-configurations.js +7 -4
  25. package/dist/entra-grants.js +8 -4
  26. package/dist/entra-group-lifecycle.js +13 -8
  27. package/dist/entra-group-pim.js +431 -0
  28. package/dist/entra-groups.js +13 -8
  29. package/dist/entra-identity-providers.js +7 -4
  30. package/dist/entra-lifecycle-workflows.js +152 -13
  31. package/dist/entra-multi-tenant-organization.js +7 -4
  32. package/dist/entra-on-premises-synchronization.js +6 -4
  33. package/dist/entra-organization.js +13 -8
  34. package/dist/entra-risk-prevention.js +442 -0
  35. package/dist/entra-risk.js +224 -9
  36. package/dist/entra-roles.js +204 -12
  37. package/dist/entra-subscriptions.js +6 -4
  38. package/dist/entra-terms-of-use.js +13 -8
  39. package/dist/entra-users.js +7 -4
  40. package/dist/graph-session.js +30 -5
  41. package/dist/hook.js +84 -0
  42. package/dist/list-totals.js +52 -0
  43. package/dist/setup.js +9 -1
  44. package/docs/coverage.md +743 -610
  45. package/inventory/operations.json +2 -2
  46. package/inventory/schema.json +1 -1
  47. package/package.json +4 -3
  48. package/skills/mg-axi/SKILL.md +86 -1
@@ -1,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
- count: { returned: subscriptions.length, complete: false, reason: result.reason },
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, count, help: [emptyHint] };
255
+ return { subscriptions, ...listTotals(subscriptions.length, result.total, "subscriptions", true), complete: true, help: [emptyHint] };
254
256
  }
255
- return { subscriptions, count, help: [...truncationHints, showHint] };
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
- count: { returned: agreements.length, complete: false, reason: result.reason },
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
- count,
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, count, help: [...truncationHints, showHint] };
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
- count: { returned: acceptances.length, complete: false, reason: result.reason },
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
- count,
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, count, help: [...truncationHints, showHint] };
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");
@@ -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
- count: { returned: users.length, complete: false, reason: result.reason },
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
- count,
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, count, help: helpHints };
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);
@@ -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",