@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.
- package/README.md +1138 -0
- package/dist/api.js +953 -0
- package/dist/app-auth.js +47 -0
- package/dist/auth.js +59 -0
- package/dist/bin/mg-axi.js +7 -0
- package/dist/catalogue.js +2191 -0
- package/dist/cli.js +1314 -0
- package/dist/docs.js +559 -0
- package/dist/doctor.js +75 -0
- package/dist/entra-access-reviews.js +425 -0
- package/dist/entra-apps.js +374 -0
- package/dist/entra-audit-logs.js +381 -0
- package/dist/entra-auth-methods.js +296 -0
- package/dist/entra-ca-policy-update.js +353 -0
- package/dist/entra-certificate-auth.js +384 -0
- package/dist/entra-conditional-access.js +317 -0
- package/dist/entra-contacts.js +827 -0
- package/dist/entra-contracts.js +252 -0
- package/dist/entra-custom-security-attributes.js +464 -0
- package/dist/entra-data-policy-operations.js +274 -0
- package/dist/entra-delegated-admin.js +468 -0
- package/dist/entra-deleted-items.js +461 -0
- package/dist/entra-directory-objects.js +274 -0
- package/dist/entra-directory.js +411 -0
- package/dist/entra-domains.js +399 -0
- package/dist/entra-entitlement-management.js +645 -0
- package/dist/entra-federation-configurations.js +290 -0
- package/dist/entra-grants.js +272 -0
- package/dist/entra-group-lifecycle.js +349 -0
- package/dist/entra-group-member-add.js +278 -0
- package/dist/entra-groups.js +336 -0
- package/dist/entra-identity-providers.js +297 -0
- package/dist/entra-lifecycle-workflows.js +673 -0
- package/dist/entra-multi-tenant-organization.js +309 -0
- package/dist/entra-on-premises-synchronization.js +231 -0
- package/dist/entra-organization.js +398 -0
- package/dist/entra-risk-dismiss.js +191 -0
- package/dist/entra-risk.js +404 -0
- package/dist/entra-roles.js +310 -0
- package/dist/entra-subscriptions.js +289 -0
- package/dist/entra-tenant-information.js +152 -0
- package/dist/entra-terms-of-use.js +380 -0
- package/dist/entra-user-revoke-sessions.js +170 -0
- package/dist/entra-user-update.js +170 -0
- package/dist/entra-users.js +211 -0
- package/dist/graph-session.js +1034 -0
- package/dist/msal-app-provider.js +42 -0
- package/dist/msal-provider.js +82 -0
- package/dist/mutations.js +529 -0
- package/dist/profiles.js +139 -0
- package/dist/setup.js +116 -0
- package/dist/version.js +2 -0
- package/docs/coverage.md +1028 -0
- package/inventory/operations.json +4 -0
- package/inventory/schema.json +71 -0
- package/package.json +32 -0
- 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
|
+
}
|