@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,374 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { SAFE_CREDENTIAL_FIELDS } from "./graph-session.js";
3
+ // READ-07: the Entra application and service-principal read mapping behind
4
+ // `mg-axi entra application list/show`, `mg-axi entra service-principal
5
+ // list/show` and the two `owner list` relationship reads. Operation
6
+ // construction stays beside its command; the shared session owns URLs,
7
+ // credentials, paging, retries and error translation, and the SDK owns TOON
8
+ // rendering. This module only maps flags to session calls and projects rows
9
+ // for compact output.
10
+ //
11
+ // Reviewed against the v1.0 application-list, application-get,
12
+ // servicePrincipal-list, servicePrincipal-get, application-list-owners and
13
+ // servicePrincipal-list-owners operation documentation on 2026-10-04.
14
+ // Delegated and application reads take Application.Read.All; the object id
15
+ // (`id`) is never conflated with the client ID (`appId`) - both ride in the
16
+ // compact list defaults so callers can see the distinction. Consent grants
17
+ // (appRoleAssignments, oauth2PermissionGrants) are READ-08 and stay out of
18
+ // this slice; only owners are projected as relationships.
19
+ //
20
+ // Credential safety belongs to the shared session's SAFE_CREDENTIAL_FIELDS
21
+ // projection, applied before collection buffering and again on cursor decode.
22
+ // Local projection uses that same allowlist for the displayed view.
23
+ // The six GET operations below are the only ones this module ever binds;
24
+ // secret-minting routes (addPassword, addKey) and every write stay refused before
25
+ // credentials via checkReadOperation.
26
+ // Every application property this slice may request or display. Anything
27
+ // else fails before credentials so typos never become misleading queries.
28
+ export const KNOWN_APP_FIELDS = [
29
+ "id",
30
+ "appId",
31
+ "displayName",
32
+ "signInAudience",
33
+ "publisherDomain",
34
+ "createdDateTime",
35
+ "keyCredentials",
36
+ "passwordCredentials",
37
+ ];
38
+ const KNOWN_APPS = new Set(KNOWN_APP_FIELDS);
39
+ // Every service-principal property this slice may request or display.
40
+ export const KNOWN_SP_FIELDS = [
41
+ "id",
42
+ "appId",
43
+ "displayName",
44
+ "servicePrincipalType",
45
+ "accountEnabled",
46
+ "appOwnerOrganizationId",
47
+ "appRoleAssignmentRequired",
48
+ "preferredSingleSignOnMode",
49
+ "loginUrl",
50
+ "keyCredentials",
51
+ "passwordCredentials",
52
+ ];
53
+ const KNOWN_SPS = new Set(KNOWN_SP_FIELDS);
54
+ // Owner rows are directoryObjects of mixed types; richer per-type fields
55
+ // need single-object reads, so only these stay selectable.
56
+ export const KNOWN_OWNER_FIELDS = ["id", "displayName", "mail"];
57
+ const KNOWN_OWNERS = new Set(KNOWN_OWNER_FIELDS);
58
+ // @odata.type is preserved on owner rows without being selectable: it names
59
+ // the owner kind (user, servicePrincipal, application).
60
+ const OWNER_TYPE_PROPERTY = "@odata.type";
61
+ // Compact rows: object id, client ID and display name. Both identifiers ride
62
+ // together so appId is never mistaken for the object id.
63
+ const DEFAULT_APP_LIST_SELECT = ["id", "appId", "displayName"];
64
+ const DEFAULT_SP_LIST_SELECT = ["id", "appId", "displayName"];
65
+ // Show rows: the full reviewed set including credential expiry metadata.
66
+ const DEFAULT_APP_SHOW_SELECT = [...KNOWN_APP_FIELDS];
67
+ const DEFAULT_SP_SHOW_SELECT = [...KNOWN_SP_FIELDS];
68
+ // Compact owner rows: identifier, display name and address.
69
+ const DEFAULT_OWNER_SELECT = ["id", "displayName", "mail"];
70
+ // Application.Read.All covers applications, service principals and owners in
71
+ // both modes; delegated reads default to it unless --scopes overrides it.
72
+ export const DEFAULT_DELEGATED_SCOPES = ["https://graph.microsoft.com/Application.Read.All"];
73
+ const TRUNCATE_AT = 500;
74
+ // The only operations this slice ever binds. Anything else - secret-minting
75
+ // routes, writes, consent grants - is refused before credentials.
76
+ const READ_OPERATIONS = {
77
+ "GET:/applications": "application",
78
+ "GET:/applications/{application-id}": "application",
79
+ "GET:/applications/{application-id}/owners": "application",
80
+ "GET:/servicePrincipals": "service-principal",
81
+ "GET:/servicePrincipals/{servicePrincipal-id}": "service-principal",
82
+ "GET:/servicePrincipals/{servicePrincipal-id}/owners": "service-principal",
83
+ };
84
+ function checkReadOperation(operation, help) {
85
+ // Version stays the session's decision: beta reads remain preview-gated
86
+ // there, exactly like every other named slice. This gate only ensures a
87
+ // fabricated secret-minting or write route can never reach credentials.
88
+ const route = `${operation.method}:${operation.path}`;
89
+ if (operation.method !== "GET" || !Object.hasOwn(READ_OPERATIONS, route)) {
90
+ throw new AxiError(`Refused non-read route ${operation.id}: READ-07 serves only the six catalogued application and service-principal GETs`, "VALIDATION_ERROR", [
91
+ help,
92
+ "Secret-minting routes (addPassword, addKey) and writes are never constructed here; consent grants belong to READ-08",
93
+ ]);
94
+ }
95
+ }
96
+ function fieldList(raw, known, knownList, flag, help) {
97
+ const parts = String(raw)
98
+ .split(",")
99
+ .map(part => part.trim())
100
+ .filter(part => part.length > 0);
101
+ if (!parts.length)
102
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
103
+ const fields = [];
104
+ for (const part of parts) {
105
+ if (!known.has(part)) {
106
+ throw new AxiError(`Unknown application property ${part} in --${flag}`, "VALIDATION_ERROR", [
107
+ help,
108
+ `Known application properties: ${knownList.join(", ")}`,
109
+ ]);
110
+ }
111
+ if (!fields.includes(part))
112
+ fields.push(part);
113
+ }
114
+ return fields;
115
+ }
116
+ function scopesFor(flags, profile, help) {
117
+ if (profile.mode === "application") {
118
+ if (flags.scopes !== undefined) {
119
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
120
+ }
121
+ return undefined;
122
+ }
123
+ if (flags.scopes === undefined)
124
+ return [...DEFAULT_DELEGATED_SCOPES];
125
+ const scopes = String(flags.scopes)
126
+ .split(",")
127
+ .map(scope => scope.trim())
128
+ .filter(scope => scope.length > 0);
129
+ return scopes;
130
+ }
131
+ function truncateValue(value, full) {
132
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
133
+ ? { value: text, truncated: false }
134
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
135
+ if (typeof value === "string")
136
+ return truncate(value);
137
+ if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
138
+ const projected = value.map(truncate);
139
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
140
+ }
141
+ return { value, truncated: false };
142
+ }
143
+ // Credential entries collapse to expiry metadata: only the safe subfields
144
+ // survive, each still subject to text truncation. A non-object entry is
145
+ // dropped rather than reinterpreted.
146
+ function projectCredentials(value, full) {
147
+ if (!Array.isArray(value))
148
+ return { value: [], truncated: false };
149
+ const projected = [];
150
+ let truncated = false;
151
+ for (const entry of value) {
152
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry))
153
+ continue;
154
+ const safe = {};
155
+ for (const field of SAFE_CREDENTIAL_FIELDS) {
156
+ if (!Object.hasOwn(entry, field))
157
+ continue;
158
+ const result = truncateValue(entry[field], full);
159
+ safe[field] = result.value;
160
+ truncated = truncated || result.truncated;
161
+ }
162
+ projected.push(safe);
163
+ }
164
+ return { value: projected, truncated };
165
+ }
166
+ // Missing properties stay absent. Non-credential properties preserve explicit
167
+ // nulls; credential collections use the safe projection described above.
168
+ function project(row, fields, full) {
169
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
170
+ const projected = {};
171
+ let truncated = false;
172
+ for (const field of fields) {
173
+ if (!Object.hasOwn(source, field))
174
+ continue;
175
+ if (field === "keyCredentials" || field === "passwordCredentials") {
176
+ const result = projectCredentials(source[field], full);
177
+ projected[field] = result.value;
178
+ truncated = truncated || result.truncated;
179
+ continue;
180
+ }
181
+ const result = truncateValue(source[field], full);
182
+ projected[field] = result.value;
183
+ truncated = truncated || result.truncated;
184
+ }
185
+ return { row: projected, truncated };
186
+ }
187
+ // Owner projection additionally preserves @odata.type so the owner kind
188
+ // survives local projection.
189
+ function projectOwner(row, select, fields, full) {
190
+ const { row: projected, truncated } = project(row, fields, full);
191
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
192
+ if (typeof source[OWNER_TYPE_PROPERTY] === "string")
193
+ projected[OWNER_TYPE_PROPERTY] = source[OWNER_TYPE_PROPERTY];
194
+ const descriptive = [...new Set([...select, ...Object.keys(source)])]
195
+ .filter(field => field !== "id" && !field.startsWith("@"));
196
+ const limitedInfo = Object.hasOwn(source, "id") && descriptive.length > 0
197
+ && descriptive.every(field => source[field] === null || source[field] === undefined || !Object.hasOwn(source, field));
198
+ return { row: projected, truncated, limitedInfo };
199
+ }
200
+ function selectedFields(flags, known, knownList, defaults, help) {
201
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, knownList, "select", help);
202
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, knownList, "fields", help);
203
+ const missing = fields.find(field => !select.includes(field));
204
+ if (missing) {
205
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
206
+ }
207
+ return { select, fields };
208
+ }
209
+ function profileHint(profileName) {
210
+ return `--profile ${shellValue(profileName)}`;
211
+ }
212
+ function shellValue(value) {
213
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
214
+ }
215
+ function fullHint(command, flags, profileName) {
216
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
217
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
218
+ return `mg-axi ${command} ${args.join(" ")}`;
219
+ }
220
+ function collectionCommon(session, flags, known, knownList, defaults, operation, help, profile) {
221
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
222
+ if (cursor !== undefined && !cursor.trim())
223
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
224
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
225
+ const { select, fields } = selectedFields(flags, known, knownList, saved?.["$select"] === undefined ? defaults : fieldList(saved["$select"], known, knownList, "select", help), help);
226
+ const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
227
+ return { cursor, select, fields, scopes: scopesFor(flags, profile, help), full: flags.full === true, filter };
228
+ }
229
+ function collectArgs(profile, operation, common, flags, help) {
230
+ const query = { $select: common.select.join(",") };
231
+ if (common.filter !== undefined) {
232
+ query.$filter = common.filter;
233
+ query.$count = "true";
234
+ }
235
+ const args = { profile, operation, query, scopes: common.scopes };
236
+ if (common.filter !== undefined)
237
+ args.consistencyLevel = "eventual";
238
+ if (common.cursor !== undefined)
239
+ args.cursor = common.cursor;
240
+ if (flags.all === true) {
241
+ if (flags.limit !== undefined)
242
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
243
+ }
244
+ else {
245
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
246
+ }
247
+ return args;
248
+ }
249
+ function resumeHint(profileName) {
250
+ return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
251
+ }
252
+ async function listCollection(session, flags, profile, operation, help, profileName, command, key, emptyNoun, known, knownList, defaults, showHint, params) {
253
+ checkReadOperation(operation, help);
254
+ const common = collectionCommon(session, flags, known, knownList, defaults, operation, help, profile);
255
+ const args = collectArgs(profile, operation, common, flags, help);
256
+ if (params)
257
+ args.params = params;
258
+ const result = await session.collect(args);
259
+ const effectiveFlags = { ...flags, select: result.query.$select ?? defaults.join(",") };
260
+ if (result.query.$filter !== undefined)
261
+ effectiveFlags.filter = result.query.$filter;
262
+ const rows = [];
263
+ let truncated = false;
264
+ for (const row of result.value) {
265
+ const projected = project(row, common.fields, common.full);
266
+ rows.push(projected.row);
267
+ truncated = truncated || projected.truncated;
268
+ }
269
+ const truncationHints = truncated ? [fullHint(command, effectiveFlags, profileName)] : [];
270
+ if (!result.complete) {
271
+ return {
272
+ [key]: rows,
273
+ count: { returned: rows.length, complete: false, reason: result.reason },
274
+ cursor: result.cursor,
275
+ help: [...truncationHints, resumeHint(profileName), showHint],
276
+ };
277
+ }
278
+ const count = { returned: rows.length, complete: true };
279
+ if (!rows.length) {
280
+ return {
281
+ [key]: rows,
282
+ count,
283
+ help: [
284
+ `0 ${emptyNoun} matched; the absence of results is the answer, not an error`,
285
+ showHint,
286
+ ],
287
+ };
288
+ }
289
+ return { [key]: rows, count, help: [...truncationHints, showHint] };
290
+ }
291
+ async function showSingle(session, flags, profile, operation, help, profileName, command, key, known, knownList, defaults, param, flag) {
292
+ checkReadOperation(operation, help);
293
+ const { select, fields } = selectedFields(flags, known, knownList, defaults, help);
294
+ const scopes = scopesFor(flags, profile, help);
295
+ const full = flags.full === true;
296
+ const raw = await session.execute({
297
+ profile,
298
+ operation,
299
+ params: { [param]: String(flags[flag]) },
300
+ query: { $select: select.join(",") },
301
+ scopes,
302
+ });
303
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
304
+ throw new AxiError("Graph returned a malformed application body", "GRAPH_ERROR", [
305
+ "Single-object reads carry one object; treat anything else as unknown, not empty",
306
+ ]);
307
+ }
308
+ const { row, truncated } = project(raw, fields, full);
309
+ if (truncated)
310
+ return { [key]: row, help: [fullHint(command, flags, profileName)] };
311
+ return { [key]: row };
312
+ }
313
+ export async function listApplications(session, flags, profile, operation, help, profileName) {
314
+ return listCollection(session, flags, profile, operation, help, profileName, "entra application list", "applications", "applications", KNOWN_APPS, KNOWN_APP_FIELDS, DEFAULT_APP_LIST_SELECT, `mg-axi entra application show --id <application-object-id> ${profileHint(profileName)}`);
315
+ }
316
+ export async function showApplication(session, flags, profile, operation, help, profileName) {
317
+ return showSingle(session, flags, profile, operation, help, profileName, "entra application show", "application", KNOWN_APPS, KNOWN_APP_FIELDS, DEFAULT_APP_SHOW_SELECT, "application-id", "id");
318
+ }
319
+ export async function listServicePrincipals(session, flags, profile, operation, help, profileName) {
320
+ return listCollection(session, flags, profile, operation, help, profileName, "entra service-principal list", "servicePrincipals", "service principals", KNOWN_SPS, KNOWN_SP_FIELDS, DEFAULT_SP_LIST_SELECT, `mg-axi entra service-principal show --id <service-principal-object-id> ${profileHint(profileName)}`);
321
+ }
322
+ export async function showServicePrincipal(session, flags, profile, operation, help, profileName) {
323
+ return showSingle(session, flags, profile, operation, help, profileName, "entra service-principal show", "servicePrincipal", KNOWN_SPS, KNOWN_SP_FIELDS, DEFAULT_SP_SHOW_SELECT, "servicePrincipal-id", "id");
324
+ }
325
+ async function listOwners(session, flags, profile, operation, help, profileName, command, param, flag) {
326
+ checkReadOperation(operation, help);
327
+ const owner = String(flags[flag]);
328
+ if (!owner.trim())
329
+ throw new AxiError(`--${flag} needs the owning object ID`, "VALIDATION_ERROR", [help]);
330
+ const common = collectionCommon(session, flags, KNOWN_OWNERS, KNOWN_OWNER_FIELDS, DEFAULT_OWNER_SELECT, operation, help, profile);
331
+ const args = collectArgs(profile, operation, common, flags, help);
332
+ args.params = { [param]: owner };
333
+ const result = await session.collect(args);
334
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_OWNER_SELECT.join(",") };
335
+ if (result.query.$filter !== undefined)
336
+ effectiveFlags.filter = result.query.$filter;
337
+ const rows = [];
338
+ let truncated = false;
339
+ let limitedInfo = 0;
340
+ for (const row of result.value) {
341
+ const projected = projectOwner(row, common.select, common.fields, common.full);
342
+ rows.push(projected.row);
343
+ truncated = truncated || projected.truncated;
344
+ if (projected.limitedInfo)
345
+ limitedInfo += 1;
346
+ }
347
+ const truncationHints = truncated ? [fullHint(command, effectiveFlags, profileName)] : [];
348
+ const limitedHints = limitedInfo > 0
349
+ ? [`${limitedInfo} of ${rows.length} rows have no non-null selected descriptive properties; this may reflect limited read consent or unset properties`]
350
+ : [];
351
+ if (!result.complete) {
352
+ return {
353
+ owners: rows,
354
+ count: { returned: rows.length, complete: false, reason: result.reason },
355
+ cursor: result.cursor,
356
+ help: [...truncationHints, ...limitedHints, resumeHint(profileName)],
357
+ };
358
+ }
359
+ const count = { returned: rows.length, complete: true };
360
+ if (!rows.length) {
361
+ return {
362
+ owners: rows,
363
+ count,
364
+ help: [...limitedHints, "0 owners matched; the absence of results is the answer, not an error"],
365
+ };
366
+ }
367
+ return { owners: rows, count, help: [...truncationHints, ...limitedHints] };
368
+ }
369
+ export async function listApplicationOwners(session, flags, profile, operation, help, profileName) {
370
+ return listOwners(session, flags, profile, operation, help, profileName, "entra application owner list", "application-id", "application");
371
+ }
372
+ export async function listServicePrincipalOwners(session, flags, profile, operation, help, profileName) {
373
+ return listOwners(session, flags, profile, operation, help, profileName, "entra service-principal owner list", "servicePrincipal-id", "service-principal");
374
+ }