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