@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,290 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // EXT-01 directory federation-configurations subfamily: the read mapping
3
+ // behind `mg-axi entra federation-configuration list/show/count/available-types`.
4
+ // Operation construction stays beside its command; the shared session owns
5
+ // URLs, credentials, paging, retries and error translation, and the SDK owns
6
+ // TOON rendering. This module only maps flags to session calls and projects
7
+ // rows for compact output.
8
+ //
9
+ // Reviewed against the v1.0 samlorwsfedexternaldomainfederation-list,
10
+ // samlorwsfedexternaldomainfederation-get and
11
+ // identityproviderbase-availableprovidertypes operation documentation and
12
+ // the samlOrWsFedExternalDomainFederation, samlOrWsFedProvider and
13
+ // identityProviderBase resource references on 2026-10-05. List, get and
14
+ // count take D/A Domain.Read.All least-privileged
15
+ // (Domain.ReadWrite.All is the documented higher-privileged alternative);
16
+ // available provider types take D/A IdentityProvider.Read.All
17
+ // least-privileged (IdentityProvider.ReadWrite.All higher). Delegated
18
+ // callers additionally need External Identity Provider Administrator, the
19
+ // least-privileged supported role documented for these reads. Personal
20
+ // Microsoft accounts are not supported. No per-operation licence
21
+ // prerequisite is stated for these reads. Workforce context only: domain
22
+ // federationConfiguration sub-reads (/domains/{id}/federationConfiguration),
23
+ // the domains navigation property ($expand stays out), beta routes and every
24
+ // mutation stay out. No mutation lives here.
25
+ //
26
+ // Secrecy by construction: federation configurations carry the public
27
+ // token-signing certificate only, but the base64 `signingCertificate` blob
28
+ // is omitted from every default select and returned only on an explicit
29
+ // --select naming it, still under the truncation marker with a --full
30
+ // escape hatch. No private key material exists on these resources.
31
+ // Every federation-configuration property this slice may request or
32
+ // display, matching the reviewed resource. The domains navigation property
33
+ // needs its own read ($expand is not reviewed here). Anything else fails
34
+ // before credentials.
35
+ export const KNOWN_FEDERATION_FIELDS = [
36
+ "id",
37
+ "displayName",
38
+ "issuerUri",
39
+ "metadataExchangeUri",
40
+ "passiveSignInUri",
41
+ "preferredAuthenticationProtocol",
42
+ "signingCertificate",
43
+ ];
44
+ const KNOWN_FEDERATIONS = new Set(KNOWN_FEDERATION_FIELDS);
45
+ // @odata.type is preserved on federation rows without being selectable: it
46
+ // names the derived configuration kind.
47
+ const FEDERATION_TYPE_PROPERTY = "@odata.type";
48
+ // Compact rows: identifier and name; the kind rides as @odata.type.
49
+ const DEFAULT_LIST_SELECT = ["id", "displayName"];
50
+ // Show rows: the full reviewed set except the public-certificate blob,
51
+ // which needs an explicit --select naming it.
52
+ const DEFAULT_SHOW_SELECT = KNOWN_FEDERATION_FIELDS.filter(field => field !== "signingCertificate");
53
+ // Delegated defaults are operation-specific; application profiles use their
54
+ // configured .default audience and reject --scopes.
55
+ export const DEFAULT_FEDERATION_SCOPES = ["https://graph.microsoft.com/Domain.Read.All"];
56
+ export const DEFAULT_FEDERATION_TYPES_SCOPES = ["https://graph.microsoft.com/IdentityProvider.Read.All"];
57
+ const TRUNCATE_AT = 500;
58
+ function fieldList(raw, flag, help) {
59
+ const parts = String(raw)
60
+ .split(",")
61
+ .map(part => part.trim())
62
+ .filter(part => part.length > 0);
63
+ if (!parts.length)
64
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
65
+ const fields = [];
66
+ for (const part of parts) {
67
+ if (!KNOWN_FEDERATIONS.has(part)) {
68
+ throw new AxiError(`Unknown federation-configuration property ${part} in --${flag}`, "VALIDATION_ERROR", [
69
+ help,
70
+ `Known federation-configuration properties: ${KNOWN_FEDERATION_FIELDS.join(", ")}`,
71
+ ]);
72
+ }
73
+ if (!fields.includes(part))
74
+ fields.push(part);
75
+ }
76
+ return fields;
77
+ }
78
+ function scopesFor(flags, defaults, profile, help) {
79
+ if (profile.mode === "application") {
80
+ if (flags.scopes !== undefined) {
81
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
82
+ }
83
+ return undefined;
84
+ }
85
+ if (flags.scopes === undefined)
86
+ return [...defaults];
87
+ const scopes = String(flags.scopes)
88
+ .split(",")
89
+ .map(scope => scope.trim())
90
+ .filter(scope => scope.length > 0);
91
+ return scopes;
92
+ }
93
+ function truncateValue(value, full) {
94
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
95
+ ? { value: text, truncated: false }
96
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
97
+ if (typeof value === "string")
98
+ return truncate(value);
99
+ if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
100
+ const projected = value.map(truncate);
101
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
102
+ }
103
+ return { value, truncated: false };
104
+ }
105
+ // Local projection preserves Graph's null/missing distinction: an explicit
106
+ // null stays null, an absent property stays absent and is never synthesized.
107
+ // @odata.type survives projection so the configuration kind is never lost.
108
+ function project(row, fields, full) {
109
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
110
+ const projected = {};
111
+ let truncated = false;
112
+ for (const field of fields) {
113
+ if (!Object.hasOwn(source, field))
114
+ continue;
115
+ const result = truncateValue(source[field], full);
116
+ projected[field] = result.value;
117
+ truncated = truncated || result.truncated;
118
+ }
119
+ if (typeof source[FEDERATION_TYPE_PROPERTY] === "string")
120
+ projected[FEDERATION_TYPE_PROPERTY] = source[FEDERATION_TYPE_PROPERTY];
121
+ return { row: projected, truncated };
122
+ }
123
+ function selectedFields(flags, defaults, help) {
124
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, "select", help);
125
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, "fields", help);
126
+ const missing = fields.find(field => !select.includes(field));
127
+ if (missing) {
128
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
129
+ }
130
+ return { select, fields };
131
+ }
132
+ function profileHint(profileName) {
133
+ return `--profile ${shellValue(profileName)}`;
134
+ }
135
+ function shellValue(value) {
136
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
137
+ }
138
+ function fullHint(command, flags, profileName) {
139
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
140
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
141
+ return `mg-axi ${command} ${args.join(" ")}`;
142
+ }
143
+ // Denials carry the session's generic grant/role/licence cause; each
144
+ // operation adds the scope, roles and licensing that actually unlock it,
145
+ // because a 403 alone never says which prerequisite is missing.
146
+ function withGuidance(hints, run) {
147
+ return run().catch(error => {
148
+ if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
149
+ throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
150
+ }
151
+ throw error;
152
+ });
153
+ }
154
+ const FEDERATION_DENIAL_HINTS = [
155
+ "Federation-configuration reads need Domain.Read.All least-privileged (Domain.ReadWrite.All is the documented higher-privileged alternative): delegated callers pass one as --scopes, application access needs admin-consented Domain.Read.All on the configured .default audience",
156
+ "Delegated callers additionally need External Identity Provider Administrator, the least-privileged supported Entra role for these reads",
157
+ "Personal Microsoft accounts are not supported for federation-configuration reads",
158
+ "No per-operation licence prerequisite is stated for these reads; never diagnose licence solely from HTTP 403",
159
+ ];
160
+ const TYPES_DENIAL_HINTS = [
161
+ "Available provider types need IdentityProvider.Read.All least-privileged (IdentityProvider.ReadWrite.All is the documented higher-privileged alternative): delegated callers pass one as --scopes, application access needs admin-consented IdentityProvider.Read.All on the configured .default audience",
162
+ "Delegated callers additionally need External Identity Provider Administrator, the least-privileged supported Entra role for this read",
163
+ "Personal Microsoft accounts are not supported for available-types reads",
164
+ "No per-operation licence prerequisite is stated for this read; never diagnose licence solely from HTTP 403",
165
+ ];
166
+ const WORKFORCE_NOTE = "Workforce tenant context only; domain federationConfiguration sub-reads and the domains navigation property belong to separate reviews and are never sent here";
167
+ const CERT_BLOB_NOTE = "Public signing-certificate blobs are omitted by default; request signingCertificate explicitly with --select signingCertificate";
168
+ export async function listFederationConfigurations(session, flags, profile, operation, help, profileName) {
169
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
170
+ if (cursor !== undefined && !cursor.trim())
171
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
172
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
173
+ const { select, fields } = selectedFields(flags, saved?.["$select"] === undefined ? DEFAULT_LIST_SELECT : fieldList(saved["$select"], "select", help), help);
174
+ const scopes = scopesFor(flags, DEFAULT_FEDERATION_SCOPES, profile, help);
175
+ const full = flags.full === true;
176
+ const query = { $select: select.join(",") };
177
+ const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
178
+ if (filter !== undefined)
179
+ query.$filter = filter;
180
+ const args = { profile, operation, query, scopes };
181
+ if (cursor !== undefined)
182
+ args.cursor = cursor;
183
+ if (flags.all === true) {
184
+ if (flags.limit !== undefined)
185
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
186
+ }
187
+ else {
188
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
189
+ }
190
+ const result = await withGuidance(FEDERATION_DENIAL_HINTS, () => session.collect(args));
191
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIST_SELECT.join(",") };
192
+ if (result.query.$filter !== undefined)
193
+ effectiveFlags.filter = result.query.$filter;
194
+ const configurations = [];
195
+ let truncated = false;
196
+ for (const row of result.value) {
197
+ const projected = project(row, fields, full);
198
+ configurations.push(projected.row);
199
+ truncated = truncated || projected.truncated;
200
+ }
201
+ const showHint = `mg-axi entra federation-configuration show --id <configuration-id> ${profileHint(profileName)}`;
202
+ const truncationHints = truncated ? [fullHint("entra federation-configuration list", effectiveFlags, profileName)] : [];
203
+ if (!result.complete) {
204
+ return {
205
+ federationConfigurations: configurations,
206
+ count: { returned: configurations.length, complete: false, reason: result.reason },
207
+ cursor: result.cursor,
208
+ help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, showHint, CERT_BLOB_NOTE, WORKFORCE_NOTE],
209
+ };
210
+ }
211
+ const count = { returned: configurations.length, complete: true };
212
+ if (!configurations.length) {
213
+ return {
214
+ federationConfigurations: configurations,
215
+ count,
216
+ help: ["0 federation configurations matched; no SAML or WS-Fed federation is configured for the tenant, or no row passed --filter", CERT_BLOB_NOTE, WORKFORCE_NOTE],
217
+ };
218
+ }
219
+ return { federationConfigurations: configurations, count, help: [...truncationHints, showHint, CERT_BLOB_NOTE, WORKFORCE_NOTE] };
220
+ }
221
+ export async function showFederationConfiguration(session, flags, profile, operation, help, profileName) {
222
+ const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
223
+ const scopes = scopesFor(flags, DEFAULT_FEDERATION_SCOPES, profile, help);
224
+ const full = flags.full === true;
225
+ const id = String(flags.id);
226
+ if (!id.trim())
227
+ throw new AxiError("--id needs the federation-configuration object ID", "VALIDATION_ERROR", [help]);
228
+ const raw = await withGuidance(FEDERATION_DENIAL_HINTS, () => session.execute({
229
+ profile,
230
+ operation,
231
+ params: { "identityProviderBase-id": id },
232
+ query: { $select: select.join(",") },
233
+ scopes,
234
+ }));
235
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
236
+ throw new AxiError("Graph returned a malformed federation-configuration body", "GRAPH_ERROR", [
237
+ "Single-configuration reads carry one federation configuration object; treat anything else as unknown, not empty",
238
+ ]);
239
+ }
240
+ const { row, truncated } = project(raw, fields, full);
241
+ const helpHints = [...(truncated ? [fullHint("entra federation-configuration show", flags, profileName)] : []), CERT_BLOB_NOTE, WORKFORCE_NOTE];
242
+ return { federationConfiguration: row, help: helpHints };
243
+ }
244
+ // The $count route returns a text/plain integer scalar rather than a JSON
245
+ // collection, so the leaf reads it through session.execute with scalar mode
246
+ // and accepts only a non-negative integer. --filter narrows the count
247
+ // server-side; there is no --select/--limit/--cursor contract on the count.
248
+ export async function countFederationConfigurations(session, flags, profile, operation, help, profileName) {
249
+ void profileName;
250
+ const scopes = scopesFor(flags, DEFAULT_FEDERATION_SCOPES, profile, help);
251
+ const query = {};
252
+ if (flags.filter !== undefined)
253
+ query.$filter = String(flags.filter);
254
+ const raw = await withGuidance(FEDERATION_DENIAL_HINTS, () => session.execute({
255
+ profile,
256
+ operation,
257
+ params: {},
258
+ query,
259
+ scopes,
260
+ scalar: true,
261
+ }));
262
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
263
+ throw new AxiError("Graph returned a malformed federation-configuration count body", "GRAPH_ERROR", [
264
+ "Federation-configuration counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
265
+ ]);
266
+ }
267
+ if (flags.filter !== undefined) {
268
+ return {
269
+ count: { returned: raw, complete: true },
270
+ help: [`Count reflects --filter ${shellValue(String(flags.filter))}; drop --filter for the tenant total`, WORKFORCE_NOTE],
271
+ };
272
+ }
273
+ return { count: { returned: raw, complete: true }, help: [WORKFORCE_NOTE] };
274
+ }
275
+ export async function availableFederationProviderTypes(session, flags, profile, operation, help) {
276
+ const scopes = scopesFor(flags, DEFAULT_FEDERATION_TYPES_SCOPES, profile, help);
277
+ const raw = await withGuidance(TYPES_DENIAL_HINTS, () => session.execute({ profile, operation, scopes }));
278
+ const value = raw !== null && typeof raw === "object" && !Array.isArray(raw)
279
+ ? raw.value : undefined;
280
+ if (!Array.isArray(value) || value.some(type => typeof type !== "string")) {
281
+ throw new AxiError("Graph returned a malformed available federation provider types body", "GRAPH_ERROR", [
282
+ "Available-provider-type reads carry a value array of strings; treat anything else as unknown, not empty",
283
+ ]);
284
+ }
285
+ return {
286
+ availableProviderTypes: value,
287
+ count: { returned: value.length, complete: true },
288
+ help: ["Available provider types depend on tenant configuration and licensing; availability does not mean a federation configuration exists", WORKFORCE_NOTE],
289
+ };
290
+ }
@@ -0,0 +1,272 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // READ-08: the Entra consent-grant read mapping behind
3
+ // `mg-axi entra service-principal oauth2-grant list` and
4
+ // `mg-axi entra service-principal app-role-assignment list` for a named
5
+ // service principal (the client). Operation construction stays beside its
6
+ // command; the shared session owns URLs, credentials, paging, retries and
7
+ // error translation, and the SDK owns TOON rendering. This module only maps
8
+ // flags to session calls and projects rows for compact output.
9
+ //
10
+ // Reviewed against the v1.0 serviceprincipal-list-oauth2permissiongrants,
11
+ // serviceprincipal-list-approleassignments, oauth2permissiongrant-resource
12
+ // and approleassignment-resource operation documentation on 2026-10-04.
13
+ // Delegated-grant reads take D/A Directory.Read.All; app-role reads take
14
+ // D/A Application.Read.All. Delegated callers additionally need a supported
15
+ // administrator role per operation. Reads never request a write-consent
16
+ // scope such as DelegatedPermissionGrant.ReadWrite.All,
17
+ // Application.ReadWrite.All or Directory.ReadWrite.All. Neither collection
18
+ // documents an advanced-query contract, so --filter passes through as plain
19
+ // $filter with no $count or ConsistencyLevel attached.
20
+ //
21
+ // The two views cover actual granted consent only: oauth2PermissionGrants
22
+ // are the delegated scopes granted to the client (consentType AllPrincipals
23
+ // for every user, Principal for one user named by principalId); an
24
+ // appRoleAssignment whose principal is the named client is an app-only
25
+ // grant of the resource API's app role. The application's requested
26
+ // permissions (requiredResourceAccess) are declared on the application
27
+ // object and are never shown here. Grant creation, revocation and consent
28
+ // belong to later write slices, never to these reads. No P1/P2 prerequisite
29
+ // is stated for these consent reads.
30
+ // Delegated-grant rows carry the full reviewed oAuth2PermissionGrant set:
31
+ // clientId is the authorizing client's object id (not appId).
32
+ export const KNOWN_GRANT_FIELDS = [
33
+ "id",
34
+ "clientId",
35
+ "consentType",
36
+ "principalId",
37
+ "resourceId",
38
+ "scope",
39
+ ];
40
+ // App-role rows carry the reviewed appRoleAssignment set minus
41
+ // deletedDateTime: listed assignments are live records, and the deleted
42
+ // timestamp is always null on them.
43
+ export const KNOWN_APP_ROLE_FIELDS = [
44
+ "id",
45
+ "appRoleId",
46
+ "createdDateTime",
47
+ "principalDisplayName",
48
+ "principalId",
49
+ "principalType",
50
+ "resourceDisplayName",
51
+ "resourceId",
52
+ ];
53
+ const GRANT_KNOWN = new Set(KNOWN_GRANT_FIELDS);
54
+ const APP_ROLE_KNOWN = new Set(KNOWN_APP_ROLE_FIELDS);
55
+ // Compact grant rows: the grant id, who it covers, which API it targets and
56
+ // the granted scopes. clientId is constant for one client's list, while
57
+ // principalId names the covered user on Principal grants and stays an
58
+ // explicit null on AllPrincipals grants.
59
+ const DEFAULT_GRANT_SELECT = ["id", "consentType", "principalId", "resourceId", "scope"];
60
+ // Compact app-role rows: the assignment id, the granted role and the
61
+ // resource API. principalId is constant for one client's list.
62
+ const DEFAULT_APP_ROLE_SELECT = ["id", "appRoleId", "resourceDisplayName", "resourceId"];
63
+ export const DEFAULT_GRANT_SCOPES = ["https://graph.microsoft.com/Directory.Read.All"];
64
+ export const DEFAULT_APP_ROLE_SCOPES = ["https://graph.microsoft.com/Application.Read.All"];
65
+ const TRUNCATE_AT = 500;
66
+ // The only operations this slice ever binds. Anything else - grant creation,
67
+ // revocation, consent, tenant-wide/user/group grant enumerations - is refused
68
+ // before credentials.
69
+ const READ_OPERATIONS = {
70
+ "GET:/servicePrincipals/{servicePrincipal-id}/oauth2PermissionGrants": "oauth2-grant",
71
+ "GET:/servicePrincipals/{servicePrincipal-id}/appRoleAssignments": "app-role-assignment",
72
+ };
73
+ function checkReadOperation(operation, help) {
74
+ // Version stays the session's decision: beta reads remain preview-gated
75
+ // there, exactly like every other named slice. This gate only ensures a
76
+ // fabricated grant-mutating or unrelated route can never reach
77
+ // credentials.
78
+ const route = `${operation.method}:${operation.path}`;
79
+ if (operation.method !== "GET" || !Object.hasOwn(READ_OPERATIONS, route)) {
80
+ throw new AxiError(`Refused non-read route ${operation.id}: READ-08 serves only the two catalogued service-principal grant GETs`, "VALIDATION_ERROR", [
81
+ help,
82
+ "Grant creation, revocation and consent are never constructed here",
83
+ ]);
84
+ }
85
+ }
86
+ function fieldList(raw, flag, known, knownList, help) {
87
+ const parts = String(raw)
88
+ .split(",")
89
+ .map(part => part.trim())
90
+ .filter(part => part.length > 0);
91
+ if (!parts.length)
92
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
93
+ const fields = [];
94
+ for (const part of parts) {
95
+ if (!known.has(part)) {
96
+ throw new AxiError(`Unknown property ${part} in --${flag}`, "VALIDATION_ERROR", [
97
+ help,
98
+ `Known properties: ${knownList.join(", ")}`,
99
+ ]);
100
+ }
101
+ if (!fields.includes(part))
102
+ fields.push(part);
103
+ }
104
+ return fields;
105
+ }
106
+ function scopesFor(flags, profile, defaults, help) {
107
+ if (profile.mode === "application") {
108
+ if (flags.scopes !== undefined) {
109
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
110
+ }
111
+ return undefined;
112
+ }
113
+ if (flags.scopes === undefined)
114
+ return [...defaults];
115
+ const scopes = String(flags.scopes)
116
+ .split(",")
117
+ .map(scope => scope.trim())
118
+ .filter(scope => scope.length > 0);
119
+ return scopes;
120
+ }
121
+ function truncateValue(value, full) {
122
+ if (typeof value === "string" && !full && value.length > TRUNCATE_AT) {
123
+ return { value: `${value.slice(0, TRUNCATE_AT)}... (truncated, ${value.length} chars total)`, truncated: true };
124
+ }
125
+ return { value, truncated: false };
126
+ }
127
+ // Local projection preserves Graph's null/missing distinction: an explicit
128
+ // null (for example principalId on an AllPrincipals grant) stays null, an
129
+ // absent property stays absent and is never synthesized.
130
+ function project(row, fields, full) {
131
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
132
+ const projected = {};
133
+ let truncated = false;
134
+ for (const field of fields) {
135
+ if (!Object.hasOwn(source, field))
136
+ continue;
137
+ const result = truncateValue(source[field], full);
138
+ projected[field] = result.value;
139
+ truncated = truncated || result.truncated;
140
+ }
141
+ return { row: projected, truncated };
142
+ }
143
+ function selectedFields(flags, defaults, known, knownList, help) {
144
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, "select", known, knownList, help);
145
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, "fields", known, knownList, help);
146
+ const missing = fields.find(field => !select.includes(field));
147
+ if (missing) {
148
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
149
+ }
150
+ return { select, fields };
151
+ }
152
+ function profileHint(profileName) {
153
+ return `--profile ${shellValue(profileName)}`;
154
+ }
155
+ function shellValue(value) {
156
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
157
+ }
158
+ function fullHint(command, flags, profileName) {
159
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
160
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
161
+ return `mg-axi ${command} ${args.join(" ")}`;
162
+ }
163
+ // Denials carry the session's generic grant/role/licence cause; each
164
+ // operation adds the roles and read scopes that actually unlock it, because
165
+ // a 403 alone never says which prerequisite is missing. Write-consent
166
+ // scopes are named only to warn against requesting them for reads.
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 GRANT_DENIAL_HINTS = [
176
+ "Delegated-grant reads need Directory.Read.All plus a supported directory role (for example Directory Readers, Global Reader or Application Administrator) for delegated access, or admin-consented Directory.Read.All for application access; reads never request DelegatedPermissionGrant.ReadWrite.All or Directory.ReadWrite.All",
177
+ ];
178
+ const APP_ROLE_DENIAL_HINTS = [
179
+ "App-role reads need Application.Read.All plus a supported directory role (for example Directory Readers, Application Administrator or Cloud Application Administrator) for delegated access, or admin-consented Application.Read.All for application access; reads never request Application.ReadWrite.All or Directory.ReadWrite.All",
180
+ ];
181
+ const OAUTH2_GRANT_LIST = {
182
+ command: "entra service-principal oauth2-grant list",
183
+ key: "oauth2PermissionGrants",
184
+ known: GRANT_KNOWN,
185
+ knownList: KNOWN_GRANT_FIELDS,
186
+ defaultSelect: DEFAULT_GRANT_SELECT,
187
+ defaultScopes: DEFAULT_GRANT_SCOPES,
188
+ denialHints: GRANT_DENIAL_HINTS,
189
+ scopeNote: "These rows are granted delegated consent for the named client; the application's requested permissions (requiredResourceAccess) are declared on the application object and are not shown here",
190
+ emptyNote: "0 delegated grants matched; the absence of results is the answer, not an error",
191
+ };
192
+ const APP_ROLE_LIST = {
193
+ command: "entra service-principal app-role-assignment list",
194
+ key: "appRoleAssignments",
195
+ known: APP_ROLE_KNOWN,
196
+ knownList: KNOWN_APP_ROLE_FIELDS,
197
+ defaultSelect: DEFAULT_APP_ROLE_SELECT,
198
+ defaultScopes: DEFAULT_APP_ROLE_SCOPES,
199
+ denialHints: APP_ROLE_DENIAL_HINTS,
200
+ scopeNote: "These rows are granted app-only consent for the named client; the application's requested permissions (requiredResourceAccess) are declared on the application object and are not shown here",
201
+ emptyNote: "0 app-role assignments matched; the absence of results is the answer, not an error",
202
+ };
203
+ async function listCollection(shape, session, flags, profile, operation, help, profileName) {
204
+ checkReadOperation(operation, help);
205
+ const client = String(flags["service-principal"] ?? "");
206
+ if (!client.trim())
207
+ throw new AxiError("--service-principal needs the client service-principal object ID", "VALIDATION_ERROR", [help]);
208
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
209
+ if (cursor !== undefined && !cursor.trim())
210
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
211
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
212
+ const savedSelect = saved?.$select;
213
+ const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, help), shape.known, shape.knownList, help);
214
+ const savedFilter = saved?.$filter;
215
+ const filter = flags.filter === undefined ? savedFilter : String(flags.filter);
216
+ const scopes = scopesFor(flags, profile, shape.defaultScopes, help);
217
+ const full = flags.full === true;
218
+ // These collections document $select/$filter only, so --filter passes
219
+ // through as plain $filter with no $count or ConsistencyLevel.
220
+ const query = { $select: select.join(",") };
221
+ if (filter !== undefined)
222
+ query.$filter = filter;
223
+ const args = {
224
+ profile,
225
+ operation,
226
+ params: { "servicePrincipal-id": client },
227
+ query,
228
+ scopes,
229
+ };
230
+ if (cursor !== undefined)
231
+ args.cursor = cursor;
232
+ if (flags.all === true) {
233
+ if (flags.limit !== undefined)
234
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
235
+ }
236
+ else {
237
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
238
+ }
239
+ const result = await withGuidance(shape.denialHints, () => session.collect(args));
240
+ const effectiveFlags = { ...flags, select: result.query.$select ?? shape.defaultSelect.join(",") };
241
+ if (result.query.$filter !== undefined)
242
+ effectiveFlags.filter = result.query.$filter;
243
+ const rows = [];
244
+ let truncated = false;
245
+ for (const row of result.value) {
246
+ const projected = project(row, fields, full);
247
+ rows.push(projected.row);
248
+ truncated = truncated || projected.truncated;
249
+ }
250
+ const clientHint = `mg-axi entra service-principal show --id ${shellValue(client)} ${profileHint(profileName)}`;
251
+ const truncationHints = truncated ? [fullHint(shape.command, effectiveFlags, profileName)] : [];
252
+ const standing = [shape.scopeNote, clientHint];
253
+ if (!result.complete) {
254
+ return {
255
+ [shape.key]: rows,
256
+ count: { returned: rows.length, complete: false, reason: result.reason },
257
+ cursor: result.cursor,
258
+ help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, ...standing],
259
+ };
260
+ }
261
+ const count = { returned: rows.length, complete: true };
262
+ if (!rows.length) {
263
+ return { [shape.key]: rows, count, help: [shape.emptyNote, ...standing] };
264
+ }
265
+ return { [shape.key]: rows, count, help: [...truncationHints, ...standing] };
266
+ }
267
+ export async function listOAuth2Grants(session, flags, profile, operation, help, profileName) {
268
+ return listCollection(OAUTH2_GRANT_LIST, session, flags, profile, operation, help, profileName);
269
+ }
270
+ export async function listAppRoleAssignments(session, flags, profile, operation, help, profileName) {
271
+ return listCollection(APP_ROLE_LIST, session, flags, profile, operation, help, profileName);
272
+ }