@knowttl/mg-axi 0.1.0

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 (57) hide show
  1. package/README.md +1138 -0
  2. package/dist/api.js +953 -0
  3. package/dist/app-auth.js +47 -0
  4. package/dist/auth.js +59 -0
  5. package/dist/bin/mg-axi.js +7 -0
  6. package/dist/catalogue.js +2191 -0
  7. package/dist/cli.js +1314 -0
  8. package/dist/docs.js +559 -0
  9. package/dist/doctor.js +75 -0
  10. package/dist/entra-access-reviews.js +425 -0
  11. package/dist/entra-apps.js +374 -0
  12. package/dist/entra-audit-logs.js +381 -0
  13. package/dist/entra-auth-methods.js +296 -0
  14. package/dist/entra-ca-policy-update.js +353 -0
  15. package/dist/entra-certificate-auth.js +384 -0
  16. package/dist/entra-conditional-access.js +317 -0
  17. package/dist/entra-contacts.js +827 -0
  18. package/dist/entra-contracts.js +252 -0
  19. package/dist/entra-custom-security-attributes.js +464 -0
  20. package/dist/entra-data-policy-operations.js +274 -0
  21. package/dist/entra-delegated-admin.js +468 -0
  22. package/dist/entra-deleted-items.js +461 -0
  23. package/dist/entra-directory-objects.js +274 -0
  24. package/dist/entra-directory.js +411 -0
  25. package/dist/entra-domains.js +399 -0
  26. package/dist/entra-entitlement-management.js +645 -0
  27. package/dist/entra-federation-configurations.js +290 -0
  28. package/dist/entra-grants.js +272 -0
  29. package/dist/entra-group-lifecycle.js +349 -0
  30. package/dist/entra-group-member-add.js +278 -0
  31. package/dist/entra-groups.js +336 -0
  32. package/dist/entra-identity-providers.js +297 -0
  33. package/dist/entra-lifecycle-workflows.js +673 -0
  34. package/dist/entra-multi-tenant-organization.js +309 -0
  35. package/dist/entra-on-premises-synchronization.js +231 -0
  36. package/dist/entra-organization.js +398 -0
  37. package/dist/entra-risk-dismiss.js +191 -0
  38. package/dist/entra-risk.js +404 -0
  39. package/dist/entra-roles.js +310 -0
  40. package/dist/entra-subscriptions.js +289 -0
  41. package/dist/entra-tenant-information.js +152 -0
  42. package/dist/entra-terms-of-use.js +380 -0
  43. package/dist/entra-user-revoke-sessions.js +170 -0
  44. package/dist/entra-user-update.js +170 -0
  45. package/dist/entra-users.js +211 -0
  46. package/dist/graph-session.js +1034 -0
  47. package/dist/msal-app-provider.js +42 -0
  48. package/dist/msal-provider.js +82 -0
  49. package/dist/mutations.js +529 -0
  50. package/dist/profiles.js +139 -0
  51. package/dist/setup.js +116 -0
  52. package/dist/version.js +2 -0
  53. package/docs/coverage.md +1028 -0
  54. package/inventory/operations.json +4 -0
  55. package/inventory/schema.json +71 -0
  56. package/package.json +32 -0
  57. package/skills/mg-axi/SKILL.md +293 -0
@@ -0,0 +1,336 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // READ-02: the Entra group read mapping behind `mg-axi entra group list/show`
3
+ // and `mg-axi entra group member list` / `member-of list` (each with an
4
+ // opt-in `--transitive` flat view). Operation construction stays beside its
5
+ // command; the shared session owns URLs, credentials, paging, retries and
6
+ // error translation, and the SDK owns TOON rendering. This module only maps
7
+ // flags to session calls and projects rows for compact output.
8
+ //
9
+ // Reviewed against the v1.0 group-list, group-get, group-list-members,
10
+ // group-list-transitivemembers, group-list-memberof and
11
+ // group-list-transitivememberof operation documentation on 2026-10-04.
12
+ // Group list/get upstream tables currently mark the write permission
13
+ // Group-NestingSupport.ReadWrite.All least-privileged; the review records the
14
+ // supported read alternatives instead (GroupMember.Read.All for basics,
15
+ // Group.Read.All for further properties). Member and memberOf reads take
16
+ // GroupMember.ReadBasic.All minimum and GroupMember.Read.All for richer
17
+ // access; hidden-membership groups additionally need Member.Read.Hidden and,
18
+ // for delegated callers, a supported role with hidden-member read permission.
19
+ // The server omits hidden members it will not disclose rather than failing,
20
+ // so completion describes pagination, never visibility. Application callers
21
+ // with narrow consent receive limited-information rows carrying only
22
+ // @odata.type and id with other properties null; those rows are preserved,
23
+ // never reinterpreted as empty. The v1.0 members route has a known issue
24
+ // omitting service principals; the documented workarounds are beta or
25
+ // $expand, and neither is used silently here - the limitation rides along as
26
+ // an explicit warning instead. Role-assignable groups surface through
27
+ // isAssignableToRole; changing their membership needs role-management
28
+ // permission and is refused by the membership write
29
+ // (`mg-axi entra group member add`); it belongs to a later write slice.
30
+ // Every group property this slice may request or display, matching the
31
+ // reviewed raw surface. Anything else fails before credentials.
32
+ export const KNOWN_GROUP_FIELDS = [
33
+ "id",
34
+ "displayName",
35
+ "description",
36
+ "mail",
37
+ "mailEnabled",
38
+ "mailNickname",
39
+ "securityEnabled",
40
+ "groupTypes",
41
+ "visibility",
42
+ "classification",
43
+ "isAssignableToRole",
44
+ "createdDateTime",
45
+ "expirationDateTime",
46
+ "renewedDateTime",
47
+ "membershipRule",
48
+ "membershipRuleProcessingState",
49
+ ];
50
+ const KNOWN_GROUPS = new Set(KNOWN_GROUP_FIELDS);
51
+ // Relationship rows are directoryObjects of mixed types; richer per-type
52
+ // member fields need single-object reads, so only these stay selectable.
53
+ export const KNOWN_MEMBER_FIELDS = ["id", "displayName", "mail"];
54
+ const KNOWN_MEMBERS = new Set(KNOWN_MEMBER_FIELDS);
55
+ // @odata.type is preserved on relationship rows without being selectable: it
56
+ // names the member kind and marks limited-information rows.
57
+ const MEMBER_TYPE_PROPERTY = "@odata.type";
58
+ // Compact group rows: identifier, display name, address and kind.
59
+ const DEFAULT_LIST_SELECT = ["id", "displayName", "mail", "groupTypes"];
60
+ // Show rows: the full reviewed group set, including the role-assignable flag.
61
+ const DEFAULT_SHOW_SELECT = [...KNOWN_GROUP_FIELDS];
62
+ // Compact relationship rows: identifier and display name.
63
+ const DEFAULT_MEMBER_SELECT = ["id", "displayName"];
64
+ // GroupMember.Read.All covers group basics and member reads in both modes;
65
+ // hidden members need Member.Read.Hidden and richer group properties may
66
+ // need Group.Read.All, both passed explicitly via --scopes.
67
+ export const DEFAULT_DELEGATED_SCOPES = ["https://graph.microsoft.com/GroupMember.Read.All"];
68
+ const TRUNCATE_AT = 500;
69
+ // Direct relationship operation to its transitive flat-view counterpart.
70
+ // --transitive selects between these two catalogued routes; beta and $expand
71
+ // workarounds are never selected implicitly.
72
+ export const TRANSITIVE_OPERATION = {
73
+ "GET:/groups/{group-id}/members": "GET:/groups/{group-id}/transitiveMembers",
74
+ "GET:/groups/{group-id}/memberOf": "GET:/groups/{group-id}/transitiveMemberOf",
75
+ };
76
+ // Sourced v1.0 limitation: service principals may be absent from direct
77
+ // member results even when pagination completes.
78
+ export const SERVICE_PRINCIPAL_WARNING = "Microsoft Graph v1.0 may omit service principals from group members; completed pagination does not establish complete membership.";
79
+ function fieldList(raw, known, knownList, flag, help) {
80
+ const parts = String(raw)
81
+ .split(",")
82
+ .map(part => part.trim())
83
+ .filter(part => part.length > 0);
84
+ if (!parts.length)
85
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
86
+ const fields = [];
87
+ for (const part of parts) {
88
+ if (!known.has(part)) {
89
+ throw new AxiError(`Unknown group property ${part} in --${flag}`, "VALIDATION_ERROR", [
90
+ help,
91
+ `Known group properties: ${knownList.join(", ")}`,
92
+ ]);
93
+ }
94
+ if (!fields.includes(part))
95
+ fields.push(part);
96
+ }
97
+ return fields;
98
+ }
99
+ function scopesFor(flags, profile, help) {
100
+ if (profile.mode === "application") {
101
+ if (flags.scopes !== undefined) {
102
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
103
+ }
104
+ return undefined;
105
+ }
106
+ if (flags.scopes === undefined)
107
+ return [...DEFAULT_DELEGATED_SCOPES];
108
+ const scopes = String(flags.scopes)
109
+ .split(",")
110
+ .map(scope => scope.trim())
111
+ .filter(scope => scope.length > 0);
112
+ return scopes;
113
+ }
114
+ function truncateValue(value, full) {
115
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
116
+ ? { value: text, truncated: false }
117
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
118
+ if (typeof value === "string")
119
+ return truncate(value);
120
+ if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
121
+ const projected = value.map(truncate);
122
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
123
+ }
124
+ return { value, truncated: false };
125
+ }
126
+ // Local projection preserves Graph's null/missing distinction: an explicit
127
+ // null stays null, an absent property stays absent and is never synthesized.
128
+ function project(row, fields, full) {
129
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
130
+ const projected = {};
131
+ let truncated = false;
132
+ for (const field of fields) {
133
+ if (!Object.hasOwn(source, field))
134
+ continue;
135
+ const result = truncateValue(source[field], full);
136
+ projected[field] = result.value;
137
+ truncated = truncated || result.truncated;
138
+ }
139
+ return { row: projected, truncated };
140
+ }
141
+ // Relationship projection additionally preserves @odata.type so the member
142
+ // kind and limited-information rows survive local projection.
143
+ function projectMember(row, select, fields, full) {
144
+ const { row: projected, truncated } = project(row, fields, full);
145
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
146
+ if (typeof source[MEMBER_TYPE_PROPERTY] === "string")
147
+ projected[MEMBER_TYPE_PROPERTY] = source[MEMBER_TYPE_PROPERTY];
148
+ const descriptive = [...new Set([...select, ...Object.keys(source)])]
149
+ .filter(field => field !== "id" && !field.startsWith("@"));
150
+ const limitedInfo = Object.hasOwn(source, "id") && descriptive.length > 0
151
+ && descriptive.every(field => source[field] === null || source[field] === undefined || !Object.hasOwn(source, field));
152
+ return { row: projected, truncated, limitedInfo };
153
+ }
154
+ function selectedFields(flags, known, knownList, defaults, help) {
155
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, knownList, "select", help);
156
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, knownList, "fields", help);
157
+ const missing = fields.find(field => !select.includes(field));
158
+ if (missing) {
159
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
160
+ }
161
+ return { select, fields };
162
+ }
163
+ function profileHint(profileName) {
164
+ return `--profile ${shellValue(profileName)}`;
165
+ }
166
+ function shellValue(value) {
167
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
168
+ }
169
+ function fullHint(command, flags, profileName) {
170
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
171
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
172
+ return `mg-axi ${command} ${args.join(" ")}`;
173
+ }
174
+ // Relationship $filter runs through advanced query on every group route:
175
+ // $filter on members/memberOf/transitive collections requires
176
+ // ConsistencyLevel eventual, and group list tolerates it, so an effective
177
+ // filter always carries eventual consistency. Restoring the saved filter
178
+ // keeps cursor resumes lossless when --filter is omitted.
179
+ function collectionCommon(session, flags, known, knownList, defaults, operation, help, profile) {
180
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
181
+ if (cursor !== undefined && !cursor.trim())
182
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
183
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
184
+ const { select, fields } = selectedFields(flags, known, knownList, saved?.["$select"] === undefined ? defaults : fieldList(saved["$select"], known, knownList, "select", help), help);
185
+ const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
186
+ return { cursor, select, fields, scopes: scopesFor(flags, profile, help), full: flags.full === true, filter };
187
+ }
188
+ function collectArgs(profile, operation, common, flags, help) {
189
+ const query = { $select: common.select.join(",") };
190
+ if (common.filter !== undefined) {
191
+ query.$filter = common.filter;
192
+ query.$count = "true";
193
+ }
194
+ const args = { profile, operation, query, scopes: common.scopes };
195
+ if (common.filter !== undefined)
196
+ args.consistencyLevel = "eventual";
197
+ if (common.cursor !== undefined)
198
+ args.cursor = common.cursor;
199
+ if (flags.all === true) {
200
+ if (flags.limit !== undefined)
201
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
202
+ }
203
+ else {
204
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
205
+ }
206
+ return args;
207
+ }
208
+ function resumeHint(profileName) {
209
+ return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
210
+ }
211
+ export async function listGroups(session, flags, profile, operation, help, profileName) {
212
+ const common = collectionCommon(session, flags, KNOWN_GROUPS, KNOWN_GROUP_FIELDS, DEFAULT_LIST_SELECT, operation, help, profile);
213
+ const result = await session.collect(collectArgs(profile, operation, common, flags, help));
214
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIST_SELECT.join(",") };
215
+ if (result.query.$filter !== undefined)
216
+ effectiveFlags.filter = result.query.$filter;
217
+ const groups = [];
218
+ let truncated = false;
219
+ for (const row of result.value) {
220
+ const projected = project(row, common.fields, common.full);
221
+ groups.push(projected.row);
222
+ truncated = truncated || projected.truncated;
223
+ }
224
+ const showHint = `mg-axi entra group show --id <group-id> ${profileHint(profileName)}`;
225
+ const truncationHints = truncated ? [fullHint("entra group list", effectiveFlags, profileName)] : [];
226
+ if (!result.complete) {
227
+ return {
228
+ groups,
229
+ count: { returned: groups.length, complete: false, reason: result.reason },
230
+ cursor: result.cursor,
231
+ help: [...truncationHints, resumeHint(profileName), showHint],
232
+ };
233
+ }
234
+ const count = { returned: groups.length, complete: true };
235
+ if (!groups.length) {
236
+ return {
237
+ groups,
238
+ count,
239
+ help: [
240
+ `mg-axi entra group list --filter <odata-filter> ${profileHint(profileName)}`,
241
+ "0 groups matched; the absence of results is the answer, not an error",
242
+ ],
243
+ };
244
+ }
245
+ return { groups, count, help: [...truncationHints, showHint] };
246
+ }
247
+ export async function showGroup(session, flags, profile, operation, help, profileName) {
248
+ const { select, fields } = selectedFields(flags, KNOWN_GROUPS, KNOWN_GROUP_FIELDS, DEFAULT_SHOW_SELECT, help);
249
+ const scopes = scopesFor(flags, profile, help);
250
+ const full = flags.full === true;
251
+ const raw = await session.execute({
252
+ profile,
253
+ operation,
254
+ params: { "group-id": String(flags.id) },
255
+ query: { $select: select.join(",") },
256
+ scopes,
257
+ });
258
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
259
+ throw new AxiError("Graph returned a malformed group body", "GRAPH_ERROR", [
260
+ "Single-group reads carry one group object; treat anything else as unknown, not empty",
261
+ ]);
262
+ }
263
+ const { row, truncated } = project(raw, fields, full);
264
+ const helpHints = truncated ? [fullHint("entra group show", flags, profileName)] : [];
265
+ if (row["isAssignableToRole"] === true) {
266
+ helpHints.push("Role-assignable group: `mg-axi entra group member add` supports non-role-assignable groups only; role-assignable membership needs role-management permission and belongs to a later slice, never to this read");
267
+ }
268
+ if (helpHints.length)
269
+ return { group: row, help: helpHints };
270
+ return { group: row };
271
+ }
272
+ async function listRelationship(session, flags, profile, operation, help, profileName, command, key, emptyNoun, transitive, directWarning) {
273
+ const group = String(flags.group);
274
+ if (!group.trim())
275
+ throw new AxiError("--group needs the group object ID", "VALIDATION_ERROR", [help]);
276
+ const common = collectionCommon(session, flags, KNOWN_MEMBERS, KNOWN_MEMBER_FIELDS, DEFAULT_MEMBER_SELECT, operation, help, profile);
277
+ const args = collectArgs(profile, operation, common, flags, help);
278
+ args.params = { "group-id": group };
279
+ const result = await session.collect(args);
280
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_MEMBER_SELECT.join(",") };
281
+ if (result.query.$filter !== undefined)
282
+ effectiveFlags.filter = result.query.$filter;
283
+ const rows = [];
284
+ let truncated = false;
285
+ let limitedInfo = 0;
286
+ for (const row of result.value) {
287
+ const projected = projectMember(row, common.select, common.fields, common.full);
288
+ rows.push(projected.row);
289
+ truncated = truncated || projected.truncated;
290
+ if (projected.limitedInfo)
291
+ limitedInfo += 1;
292
+ }
293
+ const scopeHint = transitive
294
+ ? `Direct relationships only: mg-axi ${command} --group ${shellValue(group)} ${profileHint(profileName)}`
295
+ : `Flat nested view: mg-axi ${command} --group ${shellValue(group)} --transitive ${profileHint(profileName)}`;
296
+ const truncationHints = truncated ? [fullHint(command, effectiveFlags, profileName)] : [];
297
+ const limitedHints = limitedInfo > 0
298
+ ? [`${limitedInfo} of ${rows.length} rows have no non-null selected descriptive properties; this may reflect limited read consent or unset properties`]
299
+ : [];
300
+ const hiddenHint = "Hidden members are omitted without Member.Read.Hidden; completion describes pagination, not visibility";
301
+ const warnings = directWarning === undefined ? undefined : { warnings: [directWarning] };
302
+ if (!result.complete) {
303
+ return {
304
+ [key]: rows,
305
+ count: { returned: rows.length, complete: false, reason: result.reason },
306
+ cursor: result.cursor,
307
+ ...warnings,
308
+ help: [...truncationHints, ...limitedHints, hiddenHint, resumeHint(profileName), scopeHint],
309
+ };
310
+ }
311
+ const count = { returned: rows.length, complete: true };
312
+ if (!rows.length) {
313
+ return {
314
+ [key]: rows,
315
+ count,
316
+ ...warnings,
317
+ help: [
318
+ ...limitedHints,
319
+ hiddenHint,
320
+ `0 ${emptyNoun} matched; the absence of results is the answer, not an error`,
321
+ scopeHint,
322
+ ],
323
+ };
324
+ }
325
+ return { [key]: rows, count, ...warnings, help: [...truncationHints, ...limitedHints, hiddenHint, scopeHint] };
326
+ }
327
+ export async function listGroupMembers(session, flags, profile, operation, help, profileName) {
328
+ const transitive = flags.transitive === true;
329
+ // The v1.0 service-principal omission is sourced for direct members only;
330
+ // transitive results carry no such claim.
331
+ return listRelationship(session, flags, profile, operation, help, profileName, "entra group member list", "members", "members", transitive, transitive ? undefined : SERVICE_PRINCIPAL_WARNING);
332
+ }
333
+ export async function listGroupMemberOf(session, flags, profile, operation, help, profileName) {
334
+ const transitive = flags.transitive === true;
335
+ return listRelationship(session, flags, profile, operation, help, profileName, "entra group member-of list", "memberOf", "memberships", transitive, undefined);
336
+ }
@@ -0,0 +1,297 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // EXT-03 workforce identity-providers subfamily: the read mapping behind
3
+ // `mg-axi entra identity-provider list/show/count/available-types`. Operation construction
4
+ // stays beside its command; the shared session owns URLs, credentials,
5
+ // paging, retries and error translation, and the SDK owns TOON rendering.
6
+ // This module only maps flags to session calls and projects rows for compact
7
+ // output.
8
+ //
9
+ // Reviewed against the v1.0 identitycontainer-list-identityproviders,
10
+ // identityproviderbase-get and identityproviderbase-availableprovidertypes
11
+ // operation documentation cited in inventory/operations.json on 2026-10-04.
12
+ // List, get and count take D/A IdentityProvider.Read.All; delegated callers
13
+ // additionally need a directory role that can read federation configuration
14
+ // (Global Reader is the least-privileged read-only directory role).
15
+ // Personal Microsoft accounts are not supported. No per-operation licence
16
+ // prerequisite is stated for these reads; available provider types follow
17
+ // the tenant's external-identities configuration. Workforce context only:
18
+ // external-customer (B2C/External ID) user flows, authentication event
19
+ // flows, invitations, cross-tenant access, trust framework and provisioning
20
+ // are separate later EXT-03 pieces and no support for them is claimed here.
21
+ // No mutation lives here.
22
+ //
23
+ // Secrecy by construction: clientSecret (write-only on social providers)
24
+ // and certificateData (key material on Apple providers) are not selectable,
25
+ // so --select/--fields naming them fails before credentials, and rows the
26
+ // server returns carrying either key are scrubbed before projection. Secrets
27
+ // therefore can never reach stdout, errors, previews or logs through this
28
+ // slice. @odata.type is preserved on every row without being selectable: it
29
+ // names the provider kind (for example
30
+ // #microsoft.graph.socialIdentityProvider).
31
+ // Every provider property this slice may request or display, matching the
32
+ // reviewed raw surface. Anything else fails before credentials. The base
33
+ // properties ride on every row; the remaining properties are returned by the
34
+ // server only on rows of that provider kind.
35
+ export const KNOWN_PROVIDER_FIELDS = [
36
+ "id",
37
+ "displayName",
38
+ "identityProviderType",
39
+ "clientId",
40
+ ];
41
+ const KNOWN_PROVIDERS = new Set(KNOWN_PROVIDER_FIELDS);
42
+ // @odata.type is preserved on provider rows without being selectable: it
43
+ // names the derived provider kind.
44
+ const PROVIDER_TYPE_PROPERTY = "@odata.type";
45
+ // Secret-bearing keys are never selectable and never projected. The server
46
+ // is not expected to return them on GET, but any row carrying them is
47
+ // scrubbed before projection so key material can never reach output.
48
+ const SECRET_PROVIDER_FIELDS = ["clientSecret", "certificateData"];
49
+ // Compact rows: identifier and name; the kind rides as @odata.type.
50
+ const DEFAULT_LIST_SELECT = ["id", "displayName"];
51
+ // Show rows: the full reviewed provider set.
52
+ const DEFAULT_SHOW_SELECT = [...KNOWN_PROVIDER_FIELDS];
53
+ // Delegated defaults are operation-specific; application profiles use their
54
+ // configured .default audience and reject --scopes.
55
+ export const DEFAULT_PROVIDER_SCOPES = ["https://graph.microsoft.com/IdentityProvider.Read.All"];
56
+ const TRUNCATE_AT = 500;
57
+ function fieldList(raw, flag, help) {
58
+ const parts = String(raw)
59
+ .split(",")
60
+ .map(part => part.trim())
61
+ .filter(part => part.length > 0);
62
+ if (!parts.length)
63
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
64
+ const fields = [];
65
+ for (const part of parts) {
66
+ if (!KNOWN_PROVIDERS.has(part)) {
67
+ const secret = SECRET_PROVIDER_FIELDS.includes(part);
68
+ throw new AxiError(secret
69
+ ? `--${flag} cannot request ${part}: secret-bearing provider fields are never returned`
70
+ : `Unknown provider property ${part} in --${flag}`, "VALIDATION_ERROR", [
71
+ help,
72
+ `Known provider properties: ${KNOWN_PROVIDER_FIELDS.join(", ")}`,
73
+ ]);
74
+ }
75
+ if (!fields.includes(part))
76
+ fields.push(part);
77
+ }
78
+ return fields;
79
+ }
80
+ function scopesFor(flags, profile, help) {
81
+ if (profile.mode === "application") {
82
+ if (flags.scopes !== undefined) {
83
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
84
+ }
85
+ return undefined;
86
+ }
87
+ if (flags.scopes === undefined)
88
+ return [...DEFAULT_PROVIDER_SCOPES];
89
+ const scopes = String(flags.scopes)
90
+ .split(",")
91
+ .map(scope => scope.trim())
92
+ .filter(scope => scope.length > 0);
93
+ return scopes;
94
+ }
95
+ function truncateValue(value, full) {
96
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
97
+ ? { value: text, truncated: false }
98
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
99
+ if (typeof value === "string")
100
+ return truncate(value);
101
+ if (Array.isArray(value)) {
102
+ const projected = value.map(entry => truncateValue(entry, full));
103
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
104
+ }
105
+ if (value !== null && typeof value === "object") {
106
+ const projected = Object.entries(value).map(([key, entry]) => [key, truncateValue(entry, full)]);
107
+ return {
108
+ value: Object.fromEntries(projected.map(([key, entry]) => [key, entry.value])),
109
+ truncated: projected.some(([, entry]) => entry.truncated),
110
+ };
111
+ }
112
+ return { value, truncated: false };
113
+ }
114
+ // Scrubbing runs before projection: any secret-bearing key the server
115
+ // returned is dropped so it can never be selected, rendered or leaked.
116
+ function scrub(row) {
117
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
118
+ const cleaned = {};
119
+ for (const [key, value] of Object.entries(source)) {
120
+ if (SECRET_PROVIDER_FIELDS.includes(key))
121
+ continue;
122
+ cleaned[key] = value;
123
+ }
124
+ return cleaned;
125
+ }
126
+ // Local projection preserves Graph's null/missing distinction: an explicit
127
+ // null stays null, an absent property stays absent and is never synthesized.
128
+ // @odata.type survives projection so the provider kind is never lost.
129
+ function project(row, fields, full) {
130
+ const source = scrub(row);
131
+ const projected = {};
132
+ let truncated = false;
133
+ for (const field of fields) {
134
+ if (!Object.hasOwn(source, field))
135
+ continue;
136
+ const result = truncateValue(source[field], full);
137
+ projected[field] = result.value;
138
+ truncated = truncated || result.truncated;
139
+ }
140
+ if (typeof source[PROVIDER_TYPE_PROPERTY] === "string")
141
+ projected[PROVIDER_TYPE_PROPERTY] = source[PROVIDER_TYPE_PROPERTY];
142
+ return { row: projected, truncated };
143
+ }
144
+ function selectedFields(flags, defaults, help) {
145
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, "select", help);
146
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, "fields", help);
147
+ const missing = fields.find(field => !select.includes(field));
148
+ if (missing) {
149
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
150
+ }
151
+ return { select, fields };
152
+ }
153
+ function profileHint(profileName) {
154
+ return `--profile ${shellValue(profileName)}`;
155
+ }
156
+ function shellValue(value) {
157
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
158
+ }
159
+ function fullHint(command, flags, profileName) {
160
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
161
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
162
+ return `mg-axi ${command} ${args.join(" ")}`;
163
+ }
164
+ // Denials carry the session's generic grant/role/licence cause; provider
165
+ // reads add the scope, roles and licensing that actually unlock them,
166
+ // because a 403 alone never says which prerequisite is missing.
167
+ function withGuidance(hints, run) {
168
+ return run().catch(error => {
169
+ if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
170
+ throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
171
+ }
172
+ throw error;
173
+ });
174
+ }
175
+ const PROVIDER_DENIAL_HINTS = [
176
+ "Identity-provider reads need IdentityProvider.Read.All: delegated callers pass it as --scopes, application access needs admin-consented IdentityProvider.Read.All on the configured .default audience",
177
+ "Delegated callers additionally need a directory role that can read federation configuration (Global Reader is the least-privileged read-only directory role)",
178
+ "Personal Microsoft accounts are not supported for identity-provider reads",
179
+ "No per-operation licence prerequisite is stated for these reads; never diagnose licence solely from HTTP 403",
180
+ ];
181
+ const WORKFORCE_NOTE = "Workforce tenant context only; external-customer (B2C/External ID) user flows are separate scheduled operations";
182
+ export async function listIdentityProviders(session, flags, profile, operation, help, profileName) {
183
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
184
+ if (cursor !== undefined && !cursor.trim())
185
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
186
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
187
+ const { select, fields } = selectedFields(flags, saved?.["$select"] === undefined ? DEFAULT_LIST_SELECT : fieldList(saved["$select"], "select", help), help);
188
+ const scopes = scopesFor(flags, profile, help);
189
+ const full = flags.full === true;
190
+ const query = { $select: select.join(",") };
191
+ const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
192
+ if (filter !== undefined)
193
+ query.$filter = filter;
194
+ const args = { profile, operation, query, scopes };
195
+ if (cursor !== undefined)
196
+ args.cursor = cursor;
197
+ if (flags.all === true) {
198
+ if (flags.limit !== undefined)
199
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
200
+ }
201
+ else {
202
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
203
+ }
204
+ const result = await withGuidance(PROVIDER_DENIAL_HINTS, () => session.collect(args));
205
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIST_SELECT.join(",") };
206
+ if (result.query.$filter !== undefined)
207
+ effectiveFlags.filter = result.query.$filter;
208
+ const providers = [];
209
+ let truncated = false;
210
+ for (const row of result.value) {
211
+ const projected = project(row, fields, full);
212
+ providers.push(projected.row);
213
+ truncated = truncated || projected.truncated;
214
+ }
215
+ const showHint = `mg-axi entra identity-provider show --id <provider-id> ${profileHint(profileName)}`;
216
+ const truncationHints = truncated ? [fullHint("entra identity-provider list", effectiveFlags, profileName)] : [];
217
+ if (!result.complete) {
218
+ return {
219
+ identityProviders: providers,
220
+ count: { returned: providers.length, complete: false, reason: result.reason },
221
+ cursor: result.cursor,
222
+ help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, showHint, WORKFORCE_NOTE],
223
+ };
224
+ }
225
+ const count = { returned: providers.length, complete: true };
226
+ if (!providers.length) {
227
+ return {
228
+ identityProviders: providers,
229
+ count,
230
+ help: [WORKFORCE_NOTE, "0 identity providers matched; the absence of results is the answer, not an error"],
231
+ };
232
+ }
233
+ return { identityProviders: providers, count, help: [...truncationHints, showHint, WORKFORCE_NOTE] };
234
+ }
235
+ export async function showIdentityProvider(session, flags, profile, operation, help, profileName) {
236
+ const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
237
+ const scopes = scopesFor(flags, profile, help);
238
+ const full = flags.full === true;
239
+ const raw = await withGuidance(PROVIDER_DENIAL_HINTS, () => session.execute({
240
+ profile,
241
+ operation,
242
+ params: { "identityProviderBase-id": String(flags.id) },
243
+ query: { $select: select.join(",") },
244
+ scopes,
245
+ }));
246
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
247
+ throw new AxiError("Graph returned a malformed identity-provider body", "GRAPH_ERROR", [
248
+ "Single-provider reads carry one provider object; treat anything else as unknown, not empty",
249
+ ]);
250
+ }
251
+ const { row, truncated } = project(raw, fields, full);
252
+ if (truncated)
253
+ return { identityProvider: row, help: [fullHint("entra identity-provider show", flags, profileName), WORKFORCE_NOTE] };
254
+ return { identityProvider: row, help: [WORKFORCE_NOTE] };
255
+ }
256
+ export async function countIdentityProviders(session, flags, profile, operation, help, profileName) {
257
+ const scopes = scopesFor(flags, profile, help);
258
+ const query = {};
259
+ if (flags.filter !== undefined)
260
+ query.$filter = String(flags.filter);
261
+ const raw = await withGuidance(PROVIDER_DENIAL_HINTS, () => session.execute({
262
+ profile,
263
+ operation,
264
+ params: {},
265
+ query,
266
+ scopes,
267
+ scalar: true,
268
+ }));
269
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw)) {
270
+ throw new AxiError("Graph returned a malformed identity-provider count body", "GRAPH_ERROR", [
271
+ "Scalar count reads carry a plain number; treat anything else as unknown, not empty",
272
+ ]);
273
+ }
274
+ if (flags.filter !== undefined) {
275
+ return {
276
+ count: { returned: raw, complete: true },
277
+ help: [`Count reflects --filter ${shellValue(String(flags.filter))}; drop --filter for the tenant total`, WORKFORCE_NOTE],
278
+ };
279
+ }
280
+ return { count: { returned: raw, complete: true }, help: [WORKFORCE_NOTE] };
281
+ }
282
+ export async function availableIdentityProviderTypes(session, flags, profile, operation, help) {
283
+ const scopes = scopesFor(flags, profile, help);
284
+ const raw = await withGuidance(PROVIDER_DENIAL_HINTS, () => session.execute({ profile, operation, scopes }));
285
+ const value = raw !== null && typeof raw === "object" && !Array.isArray(raw)
286
+ ? raw.value : undefined;
287
+ if (!Array.isArray(value) || value.some(type => typeof type !== "string")) {
288
+ throw new AxiError("Graph returned a malformed available identity-provider types body", "GRAPH_ERROR", [
289
+ "Available-provider-type reads carry a value array of strings; treat anything else as unknown, not empty",
290
+ ]);
291
+ }
292
+ return {
293
+ availableProviderTypes: value,
294
+ count: { returned: value.length, complete: true },
295
+ help: ["Available provider types depend on tenant configuration and licensing; availability does not mean a provider is configured", WORKFORCE_NOTE],
296
+ };
297
+ }