@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,310 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// READ-09: the Entra directory-role and PIM read mapping behind
|
|
3
|
+
// `mg-axi entra directory-role list/show`, `mg-axi entra role-assignment
|
|
4
|
+
// list`, `mg-axi entra pim eligible list` and `mg-axi entra pim active list`.
|
|
5
|
+
// Operation construction stays beside its command; the shared session owns
|
|
6
|
+
// URLs, credentials, paging, retries and error translation, and the SDK owns
|
|
7
|
+
// TOON rendering. This module only maps flags to session calls and projects
|
|
8
|
+
// rows for compact output.
|
|
9
|
+
//
|
|
10
|
+
// Reviewed against the v1.0 directoryrole-list, directoryrole-get,
|
|
11
|
+
// rbacapplication-list-roleassignments,
|
|
12
|
+
// rbacapplication-list-roleeligibilityscheduleinstances and
|
|
13
|
+
// rbacapplication-list-roleassignmentscheduleinstances operation
|
|
14
|
+
// documentation on 2026-10-04. Directory-role reads and role
|
|
15
|
+
// assignments read D/A RoleManagement.Read.Directory; eligible PIM reads
|
|
16
|
+
// D/A RoleEligibilitySchedule.Read.Directory; active PIM reads D/A
|
|
17
|
+
// RoleAssignmentSchedule.Read.Directory. Delegated callers additionally need
|
|
18
|
+
// a supported administrator role per operation. None of these collections
|
|
19
|
+
// documents an advanced-query contract, so --filter passes through as plain
|
|
20
|
+
// $filter with no $count or ConsistencyLevel attached.
|
|
21
|
+
//
|
|
22
|
+
// The four views cover: directoryRoles are activated role
|
|
23
|
+
// instances only (a role appears here after activation, never before);
|
|
24
|
+
// roleAssignments include direct and PIM-activated assignments; eligible schedule
|
|
25
|
+
// instances are PIM-eligible but not active; active schedule instances cover
|
|
26
|
+
// both directly assigned (assignmentType Assigned) and activated eligible
|
|
27
|
+
// (assignmentType Activated) assignments. memberType names how the instance
|
|
28
|
+
// reaches the principal (for example Direct). Activation, assignment and
|
|
29
|
+
// every other PIM mutation belongs to later write slices, never to these
|
|
30
|
+
// reads. Built-in roles are base inventory; custom role assignments need P1;
|
|
31
|
+
// PIM reads need P2 or ID Governance.
|
|
32
|
+
// Eligible and active instances need separate reviewed property sets:
|
|
33
|
+
// only active instances expose assignmentType.
|
|
34
|
+
export const KNOWN_ROLE_FIELDS = [
|
|
35
|
+
"id",
|
|
36
|
+
"displayName",
|
|
37
|
+
"description",
|
|
38
|
+
"roleTemplateId",
|
|
39
|
+
"deletedDateTime",
|
|
40
|
+
];
|
|
41
|
+
export const KNOWN_ASSIGNMENT_FIELDS = [
|
|
42
|
+
"id",
|
|
43
|
+
"principalId",
|
|
44
|
+
"roleDefinitionId",
|
|
45
|
+
"directoryScopeId",
|
|
46
|
+
"appScopeId",
|
|
47
|
+
];
|
|
48
|
+
export const KNOWN_ELIGIBLE_FIELDS = [
|
|
49
|
+
"id",
|
|
50
|
+
"principalId",
|
|
51
|
+
"roleDefinitionId",
|
|
52
|
+
"directoryScopeId",
|
|
53
|
+
"appScopeId",
|
|
54
|
+
"memberType",
|
|
55
|
+
"startDateTime",
|
|
56
|
+
"endDateTime",
|
|
57
|
+
];
|
|
58
|
+
export const KNOWN_ACTIVE_FIELDS = [...KNOWN_ELIGIBLE_FIELDS, "assignmentType"];
|
|
59
|
+
const ROLE_KNOWN = new Set(KNOWN_ROLE_FIELDS);
|
|
60
|
+
const ASSIGNMENT_KNOWN = new Set(KNOWN_ASSIGNMENT_FIELDS);
|
|
61
|
+
const ELIGIBLE_KNOWN = new Set(KNOWN_ELIGIBLE_FIELDS);
|
|
62
|
+
const ACTIVE_KNOWN = new Set(KNOWN_ACTIVE_FIELDS);
|
|
63
|
+
// Compact rows: identifiers plus the correlation keys. For built-in roles
|
|
64
|
+
// the unified roleDefinitionId matches the directory-role roleTemplateId.
|
|
65
|
+
const DEFAULT_ROLE_LIST_SELECT = ["id", "displayName", "description", "roleTemplateId"];
|
|
66
|
+
const DEFAULT_ROLE_SHOW_SELECT = [...KNOWN_ROLE_FIELDS];
|
|
67
|
+
const DEFAULT_ASSIGNMENT_SELECT = ["id", "principalId", "roleDefinitionId", "directoryScopeId"];
|
|
68
|
+
const DEFAULT_ELIGIBLE_SELECT = ["id", "principalId", "roleDefinitionId", "memberType"];
|
|
69
|
+
const DEFAULT_ACTIVE_SELECT = ["id", "principalId", "roleDefinitionId", "assignmentType", "memberType"];
|
|
70
|
+
export const DEFAULT_ROLE_SCOPES = ["https://graph.microsoft.com/RoleManagement.Read.Directory"];
|
|
71
|
+
export const DEFAULT_ELIGIBLE_SCOPES = ["https://graph.microsoft.com/RoleEligibilitySchedule.Read.Directory"];
|
|
72
|
+
export const DEFAULT_ACTIVE_SCOPES = ["https://graph.microsoft.com/RoleAssignmentSchedule.Read.Directory"];
|
|
73
|
+
const TRUNCATE_AT = 500;
|
|
74
|
+
function fieldList(raw, flag, known, knownList, help) {
|
|
75
|
+
const parts = String(raw)
|
|
76
|
+
.split(",")
|
|
77
|
+
.map(part => part.trim())
|
|
78
|
+
.filter(part => part.length > 0);
|
|
79
|
+
if (!parts.length)
|
|
80
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
81
|
+
const fields = [];
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
if (!known.has(part)) {
|
|
84
|
+
throw new AxiError(`Unknown property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
85
|
+
help,
|
|
86
|
+
`Known properties: ${knownList.join(", ")}`,
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
if (!fields.includes(part))
|
|
90
|
+
fields.push(part);
|
|
91
|
+
}
|
|
92
|
+
return fields;
|
|
93
|
+
}
|
|
94
|
+
function scopesFor(flags, profile, defaults, help) {
|
|
95
|
+
if (profile.mode === "application") {
|
|
96
|
+
if (flags.scopes !== undefined) {
|
|
97
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (flags.scopes === undefined)
|
|
102
|
+
return [...defaults];
|
|
103
|
+
const scopes = String(flags.scopes)
|
|
104
|
+
.split(",")
|
|
105
|
+
.map(scope => scope.trim())
|
|
106
|
+
.filter(scope => scope.length > 0);
|
|
107
|
+
return scopes;
|
|
108
|
+
}
|
|
109
|
+
function truncateValue(value, full) {
|
|
110
|
+
if (typeof value === "string" && !full && value.length > TRUNCATE_AT) {
|
|
111
|
+
return { value: `${value.slice(0, TRUNCATE_AT)}... (truncated, ${value.length} chars total)`, truncated: true };
|
|
112
|
+
}
|
|
113
|
+
return { value, truncated: false };
|
|
114
|
+
}
|
|
115
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
116
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
117
|
+
function project(row, fields, full) {
|
|
118
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
119
|
+
const projected = {};
|
|
120
|
+
let truncated = false;
|
|
121
|
+
for (const field of fields) {
|
|
122
|
+
if (!Object.hasOwn(source, field))
|
|
123
|
+
continue;
|
|
124
|
+
const result = truncateValue(source[field], full);
|
|
125
|
+
projected[field] = result.value;
|
|
126
|
+
truncated = truncated || result.truncated;
|
|
127
|
+
}
|
|
128
|
+
return { row: projected, truncated };
|
|
129
|
+
}
|
|
130
|
+
function selectedFields(flags, defaults, known, knownList, help) {
|
|
131
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, "select", known, knownList, help);
|
|
132
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, "fields", known, knownList, help);
|
|
133
|
+
const missing = fields.find(field => !select.includes(field));
|
|
134
|
+
if (missing) {
|
|
135
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
136
|
+
}
|
|
137
|
+
return { select, fields };
|
|
138
|
+
}
|
|
139
|
+
function profileHint(profileName) {
|
|
140
|
+
return `--profile ${shellValue(profileName)}`;
|
|
141
|
+
}
|
|
142
|
+
function shellValue(value) {
|
|
143
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
144
|
+
}
|
|
145
|
+
function fullHint(command, flags, profileName) {
|
|
146
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
147
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
148
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
149
|
+
}
|
|
150
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
151
|
+
// operation adds the roles and licensing that actually unlock it, because a
|
|
152
|
+
// 403 alone never says which prerequisite is missing.
|
|
153
|
+
function withGuidance(hints, run) {
|
|
154
|
+
return run().catch(error => {
|
|
155
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
156
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const ROLE_DENIAL_HINTS = [
|
|
162
|
+
"Directory-role reads need RoleManagement.Read.Directory plus a supported directory role (for example Global Reader, Security Reader or Privileged Role Administrator) for delegated access, or admin-consented RoleManagement.Read.Directory for application access",
|
|
163
|
+
"Built-in roles are base inventory; custom role assignments need P1",
|
|
164
|
+
];
|
|
165
|
+
const ASSIGNMENT_DENIAL_HINTS = [
|
|
166
|
+
"Role-assignment reads need RoleManagement.Read.Directory plus Directory Readers, Global Reader or Privileged Role Administrator for delegated access, or admin-consented RoleManagement.Read.Directory for application access",
|
|
167
|
+
"Built-in role assignments are base inventory; custom role assignments need P1",
|
|
168
|
+
];
|
|
169
|
+
const ELIGIBLE_DENIAL_HINTS = [
|
|
170
|
+
"Eligible PIM reads need RoleEligibilitySchedule.Read.Directory plus Global Reader, Security Operator, Security Reader, Security Administrator or Privileged Role Administrator for delegated access, or admin-consented RoleEligibilitySchedule.Read.Directory for application access",
|
|
171
|
+
"PIM reads need P2 or ID Governance, not only P2",
|
|
172
|
+
];
|
|
173
|
+
const ACTIVE_DENIAL_HINTS = [
|
|
174
|
+
"Active PIM reads need RoleAssignmentSchedule.Read.Directory plus Global Reader, Security Operator, Security Reader, Security Administrator or Privileged Role Administrator for delegated access, or admin-consented RoleAssignmentSchedule.Read.Directory for application access",
|
|
175
|
+
"PIM reads need P2 or ID Governance, not only P2",
|
|
176
|
+
];
|
|
177
|
+
const ROLE_LIST = {
|
|
178
|
+
command: "entra directory-role list",
|
|
179
|
+
key: "directoryRoles",
|
|
180
|
+
known: ROLE_KNOWN,
|
|
181
|
+
knownList: KNOWN_ROLE_FIELDS,
|
|
182
|
+
defaultSelect: DEFAULT_ROLE_LIST_SELECT,
|
|
183
|
+
defaultScopes: DEFAULT_ROLE_SCOPES,
|
|
184
|
+
denialHints: ROLE_DENIAL_HINTS,
|
|
185
|
+
scopeNote: "Lists activated roles only; a role appears here after activation, never before",
|
|
186
|
+
emptyNote: "0 activated roles matched; the absence of results is the answer, not an error",
|
|
187
|
+
};
|
|
188
|
+
const ASSIGNMENT_LIST = {
|
|
189
|
+
command: "entra role-assignment list",
|
|
190
|
+
key: "roleAssignments",
|
|
191
|
+
known: ASSIGNMENT_KNOWN,
|
|
192
|
+
knownList: KNOWN_ASSIGNMENT_FIELDS,
|
|
193
|
+
defaultSelect: DEFAULT_ASSIGNMENT_SELECT,
|
|
194
|
+
defaultScopes: DEFAULT_ROLE_SCOPES,
|
|
195
|
+
denialHints: ASSIGNMENT_DENIAL_HINTS,
|
|
196
|
+
scopeNote: "Current role-assignment inventory includes direct and PIM-activated assignments; use pim active list for assignmentType Assigned versus Activated",
|
|
197
|
+
emptyNote: "0 current role assignments matched; the absence of results is the answer, not an error",
|
|
198
|
+
};
|
|
199
|
+
const PIM_ELIGIBLE = {
|
|
200
|
+
command: "entra pim eligible list",
|
|
201
|
+
key: "eligibleAssignments",
|
|
202
|
+
known: ELIGIBLE_KNOWN,
|
|
203
|
+
knownList: KNOWN_ELIGIBLE_FIELDS,
|
|
204
|
+
defaultSelect: DEFAULT_ELIGIBLE_SELECT,
|
|
205
|
+
defaultScopes: DEFAULT_ELIGIBLE_SCOPES,
|
|
206
|
+
denialHints: ELIGIBLE_DENIAL_HINTS,
|
|
207
|
+
scopeNote: "Eligible assignments are not active; activation is a PIM workflow outside these reads",
|
|
208
|
+
emptyNote: "0 eligible assignments matched; the absence of results is the answer, not an error",
|
|
209
|
+
};
|
|
210
|
+
const PIM_ACTIVE = {
|
|
211
|
+
command: "entra pim active list",
|
|
212
|
+
key: "activeAssignments",
|
|
213
|
+
known: ACTIVE_KNOWN,
|
|
214
|
+
knownList: KNOWN_ACTIVE_FIELDS,
|
|
215
|
+
defaultSelect: DEFAULT_ACTIVE_SELECT,
|
|
216
|
+
defaultScopes: DEFAULT_ACTIVE_SCOPES,
|
|
217
|
+
denialHints: ACTIVE_DENIAL_HINTS,
|
|
218
|
+
scopeNote: "Active distinguishes directly assigned (assignmentType Assigned) and activated eligible (assignmentType Activated) assignments",
|
|
219
|
+
emptyNote: "0 active assignments matched; the absence of results is the answer, not an error",
|
|
220
|
+
};
|
|
221
|
+
async function listCollection(shape, session, flags, profile, operation, help, profileName) {
|
|
222
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
223
|
+
if (cursor !== undefined && !cursor.trim())
|
|
224
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
225
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
226
|
+
const savedSelect = saved?.$select;
|
|
227
|
+
const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, help), shape.known, shape.knownList, help);
|
|
228
|
+
const savedFilter = saved?.$filter;
|
|
229
|
+
const filter = flags.filter === undefined ? savedFilter : String(flags.filter);
|
|
230
|
+
const scopes = scopesFor(flags, profile, shape.defaultScopes, help);
|
|
231
|
+
const full = flags.full === true;
|
|
232
|
+
// These collections document $select/$filter/$expand only, so --filter
|
|
233
|
+
// passes through as plain $filter with no $count or ConsistencyLevel.
|
|
234
|
+
const query = { $select: select.join(",") };
|
|
235
|
+
if (filter !== undefined)
|
|
236
|
+
query.$filter = filter;
|
|
237
|
+
const args = { profile, operation, query, scopes };
|
|
238
|
+
if (cursor !== undefined)
|
|
239
|
+
args.cursor = cursor;
|
|
240
|
+
if (flags.all === true) {
|
|
241
|
+
if (flags.limit !== undefined)
|
|
242
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
246
|
+
}
|
|
247
|
+
const result = await withGuidance(shape.denialHints, () => session.collect(args));
|
|
248
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? shape.defaultSelect.join(",") };
|
|
249
|
+
if (result.query.$filter !== undefined)
|
|
250
|
+
effectiveFlags.filter = result.query.$filter;
|
|
251
|
+
const rows = [];
|
|
252
|
+
let truncated = false;
|
|
253
|
+
for (const row of result.value) {
|
|
254
|
+
const projected = project(row, fields, full);
|
|
255
|
+
rows.push(projected.row);
|
|
256
|
+
truncated = truncated || projected.truncated;
|
|
257
|
+
}
|
|
258
|
+
const showHint = shape.key === "directoryRoles" ? `mg-axi entra directory-role show --id <role-id> ${profileHint(profileName)}` : undefined;
|
|
259
|
+
const correlateHint = shape.key === "directoryRoles"
|
|
260
|
+
? undefined
|
|
261
|
+
: `For built-in roles, roleDefinitionId matches the directory-role roleTemplateId: mg-axi entra directory-role list ${profileHint(profileName)}`;
|
|
262
|
+
const truncationHints = truncated ? [fullHint(shape.command, effectiveFlags, profileName)] : [];
|
|
263
|
+
const standing = [shape.scopeNote, ...(showHint === undefined ? [] : [showHint]), ...(correlateHint === undefined ? [] : [correlateHint])];
|
|
264
|
+
if (!result.complete) {
|
|
265
|
+
return {
|
|
266
|
+
[shape.key]: rows,
|
|
267
|
+
count: { returned: rows.length, complete: false, reason: result.reason },
|
|
268
|
+
cursor: result.cursor,
|
|
269
|
+
help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, ...standing],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const count = { returned: rows.length, complete: true };
|
|
273
|
+
if (!rows.length) {
|
|
274
|
+
return { [shape.key]: rows, count, help: [shape.emptyNote, ...standing] };
|
|
275
|
+
}
|
|
276
|
+
return { [shape.key]: rows, count, help: [...truncationHints, ...standing] };
|
|
277
|
+
}
|
|
278
|
+
export async function listDirectoryRoles(session, flags, profile, operation, help, profileName) {
|
|
279
|
+
return listCollection(ROLE_LIST, session, flags, profile, operation, help, profileName);
|
|
280
|
+
}
|
|
281
|
+
export async function showDirectoryRole(session, flags, profile, operation, help, profileName) {
|
|
282
|
+
const { select, fields } = selectedFields(flags, DEFAULT_ROLE_SHOW_SELECT, ROLE_KNOWN, KNOWN_ROLE_FIELDS, help);
|
|
283
|
+
const scopes = scopesFor(flags, profile, DEFAULT_ROLE_SCOPES, help);
|
|
284
|
+
const full = flags.full === true;
|
|
285
|
+
const raw = await withGuidance(ROLE_DENIAL_HINTS, () => session.execute({
|
|
286
|
+
profile,
|
|
287
|
+
operation,
|
|
288
|
+
params: { "directoryRole-id": String(flags.id) },
|
|
289
|
+
query: { $select: select.join(",") },
|
|
290
|
+
scopes,
|
|
291
|
+
}));
|
|
292
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
293
|
+
throw new AxiError("Graph returned a malformed directory-role body", "GRAPH_ERROR", [
|
|
294
|
+
"Single-role reads carry one role object; treat anything else as unknown, not empty",
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
const { row, truncated } = project(raw, fields, full);
|
|
298
|
+
if (truncated)
|
|
299
|
+
return { directoryRole: row, help: [fullHint("entra directory-role show", flags, profileName)] };
|
|
300
|
+
return { directoryRole: row };
|
|
301
|
+
}
|
|
302
|
+
export async function listRoleAssignments(session, flags, profile, operation, help, profileName) {
|
|
303
|
+
return listCollection(ASSIGNMENT_LIST, session, flags, profile, operation, help, profileName);
|
|
304
|
+
}
|
|
305
|
+
export async function listPimEligible(session, flags, profile, operation, help, profileName) {
|
|
306
|
+
return listCollection(PIM_ELIGIBLE, session, flags, profile, operation, help, profileName);
|
|
307
|
+
}
|
|
308
|
+
export async function listPimActive(session, flags, profile, operation, help, profileName) {
|
|
309
|
+
return listCollection(PIM_ACTIVE, session, flags, profile, operation, help, profileName);
|
|
310
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { resolveSessionOperation } from "./graph-session.js";
|
|
3
|
+
// EXT-01 directory-subscriptions subfamily: the read mapping behind
|
|
4
|
+
// `mg-axi entra subscription list/show/count`. Operation construction stays
|
|
5
|
+
// beside its command; the shared session owns URLs, credentials, paging,
|
|
6
|
+
// retries and error translation, and the SDK owns TOON rendering. This
|
|
7
|
+
// module only maps flags to session calls and projects rows for compact
|
|
8
|
+
// output.
|
|
9
|
+
//
|
|
10
|
+
// Reviewed against the v1.0 directory-list-subscriptions,
|
|
11
|
+
// companysubscription-get and companySubscription resource documentation on
|
|
12
|
+
// 2026-10-05. Every read takes D/A Organization.Read.All; delegated callers
|
|
13
|
+
// additionally need a supported Entra role (Global Reader, Directory Readers,
|
|
14
|
+
// or Dynamics 365 Business Central Administrator for read-only standard
|
|
15
|
+
// properties). Personal Microsoft accounts are not supported. No P1/P2
|
|
16
|
+
// prerequisite is stated for these reads. The list documents the general
|
|
17
|
+
// OData query parameters, so --filter passes through as plain $filter with
|
|
18
|
+
// no $count or ConsistencyLevel contract; the single GET documents $select
|
|
19
|
+
// only. The $count route carries no operation-level documentation page and
|
|
20
|
+
// takes no --filter/--select/--limit/--cursor. The commerceSubscriptionId
|
|
21
|
+
// alternate-key lookup ships on `show --commerce-subscription-id` through
|
|
22
|
+
// the allowlisted session function binding: the key arrives only as an
|
|
23
|
+
// explicit CLI flag and the session validates, OData-quotes and encodes it.
|
|
24
|
+
// Beta subscriptions stay out ("beta needs its own review"). No mutation
|
|
25
|
+
// lives here.
|
|
26
|
+
//
|
|
27
|
+
// Secrecy by construction: the reviewed companySubscription set carries
|
|
28
|
+
// directory identifiers and licence metadata only; ownerId and ownerTenantId
|
|
29
|
+
// name the account admin and partner tenant without carrying credentials or
|
|
30
|
+
// keys, and the shared session still redacts secret-shaped values before
|
|
31
|
+
// buffering or output.
|
|
32
|
+
// Every subscription property this slice may request or display, matching
|
|
33
|
+
// the reviewed resource. Anything else fails before credentials.
|
|
34
|
+
export const KNOWN_SUBSCRIPTION_FIELDS = [
|
|
35
|
+
"id",
|
|
36
|
+
"commerceSubscriptionId",
|
|
37
|
+
"createdDateTime",
|
|
38
|
+
"isTrial",
|
|
39
|
+
"nextLifecycleDateTime",
|
|
40
|
+
"ownerId",
|
|
41
|
+
"ownerTenantId",
|
|
42
|
+
"ownerType",
|
|
43
|
+
"serviceStatus",
|
|
44
|
+
"skuId",
|
|
45
|
+
"skuPartNumber",
|
|
46
|
+
"status",
|
|
47
|
+
"totalLicenses",
|
|
48
|
+
];
|
|
49
|
+
const KNOWN_SUBSCRIPTIONS = new Set(KNOWN_SUBSCRIPTION_FIELDS);
|
|
50
|
+
// Compact list rows: identifier, SKU name, lifecycle status and the licence
|
|
51
|
+
// count agents usually need next.
|
|
52
|
+
const DEFAULT_LIST_SELECT = ["id", "skuPartNumber", "status", "totalLicenses"];
|
|
53
|
+
// Show rows: the full reviewed subscription set.
|
|
54
|
+
const DEFAULT_SHOW_SELECT = [...KNOWN_SUBSCRIPTION_FIELDS];
|
|
55
|
+
// Delegated defaults are operation-specific; application profiles use their
|
|
56
|
+
// configured .default audience and reject --scopes.
|
|
57
|
+
export const DEFAULT_SUBSCRIPTION_SCOPES = ["https://graph.microsoft.com/Organization.Read.All"];
|
|
58
|
+
const TRUNCATE_AT = 500;
|
|
59
|
+
function fieldList(raw, known, knownList, flag, help) {
|
|
60
|
+
const parts = String(raw)
|
|
61
|
+
.split(",")
|
|
62
|
+
.map(part => part.trim())
|
|
63
|
+
.filter(part => part.length > 0);
|
|
64
|
+
if (!parts.length)
|
|
65
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
66
|
+
const fields = [];
|
|
67
|
+
for (const part of parts) {
|
|
68
|
+
if (!known.has(part)) {
|
|
69
|
+
throw new AxiError(`Unknown subscription property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
70
|
+
help,
|
|
71
|
+
`Known subscription properties: ${knownList.join(", ")}`,
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
if (!fields.includes(part))
|
|
75
|
+
fields.push(part);
|
|
76
|
+
}
|
|
77
|
+
return fields;
|
|
78
|
+
}
|
|
79
|
+
function scopesFor(flags, defaults, profile, help) {
|
|
80
|
+
if (profile.mode === "application") {
|
|
81
|
+
if (flags.scopes !== undefined) {
|
|
82
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
if (flags.scopes === undefined)
|
|
87
|
+
return [...defaults];
|
|
88
|
+
const scopes = String(flags.scopes)
|
|
89
|
+
.split(",")
|
|
90
|
+
.map(scope => scope.trim())
|
|
91
|
+
.filter(scope => scope.length > 0);
|
|
92
|
+
return scopes;
|
|
93
|
+
}
|
|
94
|
+
function truncateValue(value, full) {
|
|
95
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
96
|
+
? { value: text, truncated: false }
|
|
97
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
98
|
+
if (typeof value === "string")
|
|
99
|
+
return truncate(value);
|
|
100
|
+
if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
|
|
101
|
+
const projected = value.map(truncate);
|
|
102
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
103
|
+
}
|
|
104
|
+
return { value, truncated: false };
|
|
105
|
+
}
|
|
106
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
107
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
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
|
+
return { row: projected, truncated };
|
|
120
|
+
}
|
|
121
|
+
function selectedFields(flags, defaults, help) {
|
|
122
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, KNOWN_SUBSCRIPTIONS, KNOWN_SUBSCRIPTION_FIELDS, "select", help);
|
|
123
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, KNOWN_SUBSCRIPTIONS, KNOWN_SUBSCRIPTION_FIELDS, "fields", help);
|
|
124
|
+
const missing = fields.find(field => !select.includes(field));
|
|
125
|
+
if (missing) {
|
|
126
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
127
|
+
}
|
|
128
|
+
return { select, fields };
|
|
129
|
+
}
|
|
130
|
+
function profileHint(profileName) {
|
|
131
|
+
return `--profile ${shellValue(profileName)}`;
|
|
132
|
+
}
|
|
133
|
+
function shellValue(value) {
|
|
134
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
135
|
+
}
|
|
136
|
+
function fullHint(command, flags, profileName) {
|
|
137
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
138
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
139
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
140
|
+
}
|
|
141
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
142
|
+
// operation adds the scope, roles and licensing that actually unlock it,
|
|
143
|
+
// because a 403 alone never says which prerequisite is missing.
|
|
144
|
+
function withGuidance(hints, run) {
|
|
145
|
+
return run().catch(error => {
|
|
146
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
147
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const SUBSCRIPTION_DENIAL_HINTS = [
|
|
153
|
+
"Subscription reads need Organization.Read.All plus a supported Entra role for delegated access (Global Reader, Directory Readers, or Dynamics 365 Business Central Administrator for read-only standard properties), or admin-consented Organization.Read.All for application access",
|
|
154
|
+
"Personal Microsoft accounts are not supported for subscription reads",
|
|
155
|
+
"No P1/P2 prerequisite is stated for subscription reads; never diagnose licence solely from HTTP 403",
|
|
156
|
+
];
|
|
157
|
+
const EMPTY_NOTE = "An empty subscription list may mean the tenant holds no commercial subscriptions; the absence of rows is the answer, not an error";
|
|
158
|
+
// Restoring the saved select/filter keeps cursor resumes lossless when
|
|
159
|
+
// --select or --filter is omitted. The list documents the general OData
|
|
160
|
+
// query parameters, so --filter passes through as plain $filter with no
|
|
161
|
+
// ConsistencyLevel contract; strict input validation still refuses unknown
|
|
162
|
+
// flags before credentials.
|
|
163
|
+
function collectionCommon(session, flags, operation, help, profile) {
|
|
164
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
165
|
+
if (cursor !== undefined && !cursor.trim())
|
|
166
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
167
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
168
|
+
const { select, fields } = selectedFields(flags, saved?.["$select"] === undefined ? DEFAULT_LIST_SELECT : fieldList(saved["$select"], KNOWN_SUBSCRIPTIONS, KNOWN_SUBSCRIPTION_FIELDS, "select", help), help);
|
|
169
|
+
const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
|
|
170
|
+
return { cursor, select, fields, scopes: scopesFor(flags, DEFAULT_SUBSCRIPTION_SCOPES, profile, help), full: flags.full === true, filter };
|
|
171
|
+
}
|
|
172
|
+
function collectArgs(profile, operation, common, flags, help) {
|
|
173
|
+
const query = { $select: common.select.join(",") };
|
|
174
|
+
if (common.filter !== undefined)
|
|
175
|
+
query.$filter = common.filter;
|
|
176
|
+
const args = { profile, operation, query, scopes: common.scopes };
|
|
177
|
+
if (common.cursor !== undefined)
|
|
178
|
+
args.cursor = common.cursor;
|
|
179
|
+
if (flags.all === true) {
|
|
180
|
+
if (flags.limit !== undefined)
|
|
181
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
185
|
+
}
|
|
186
|
+
return args;
|
|
187
|
+
}
|
|
188
|
+
function resumeHint(profileName) {
|
|
189
|
+
return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
|
|
190
|
+
}
|
|
191
|
+
function subscriptionId(flags, help) {
|
|
192
|
+
const id = String(flags.id);
|
|
193
|
+
if (!id.trim())
|
|
194
|
+
throw new AxiError("--id needs the commercial-subscription object ID", "VALIDATION_ERROR", [help]);
|
|
195
|
+
return id;
|
|
196
|
+
}
|
|
197
|
+
// Exactly one subscription key selects the show route: the object ID from
|
|
198
|
+
// the catalogue-resolved operation, or the commerce-system alternate key
|
|
199
|
+
// from the allowlisted function route. The key value itself is validated by
|
|
200
|
+
// the session binding before credentials.
|
|
201
|
+
function subscriptionTarget(flags, operation, help) {
|
|
202
|
+
const id = flags.id;
|
|
203
|
+
const commerceId = flags["commerce-subscription-id"];
|
|
204
|
+
if (id !== undefined && commerceId !== undefined) {
|
|
205
|
+
throw new AxiError("--id and --commerce-subscription-id cannot be combined", "VALIDATION_ERROR", [help]);
|
|
206
|
+
}
|
|
207
|
+
if (commerceId !== undefined) {
|
|
208
|
+
if (!String(commerceId).trim())
|
|
209
|
+
throw new AxiError("--commerce-subscription-id needs the commerce-system subscription ID", "VALIDATION_ERROR", [help]);
|
|
210
|
+
return {
|
|
211
|
+
operation: resolveSessionOperation("v1.0", "GET", "/directory/subscriptions(commerceSubscriptionId='{commerceSubscriptionId}')"),
|
|
212
|
+
params: { commerceSubscriptionId: String(commerceId) },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (id === undefined)
|
|
216
|
+
throw new AxiError("Subscription show needs exactly one of --id or --commerce-subscription-id", "VALIDATION_ERROR", [help]);
|
|
217
|
+
return { operation, params: { "companySubscription-id": subscriptionId(flags, help) } };
|
|
218
|
+
}
|
|
219
|
+
function singleResult(raw, fields, full) {
|
|
220
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
221
|
+
throw new AxiError("Graph returned a malformed subscription body", "GRAPH_ERROR", [
|
|
222
|
+
"Single-subscription reads carry one companySubscription object; treat anything else as unknown, not empty",
|
|
223
|
+
]);
|
|
224
|
+
}
|
|
225
|
+
return project(raw, fields, full);
|
|
226
|
+
}
|
|
227
|
+
export async function listSubscriptions(session, flags, profile, operation, help, profileName) {
|
|
228
|
+
const common = collectionCommon(session, flags, operation, help, profile);
|
|
229
|
+
const result = await withGuidance(SUBSCRIPTION_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
|
|
230
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIST_SELECT.join(",") };
|
|
231
|
+
if (result.query.$filter !== undefined)
|
|
232
|
+
effectiveFlags.filter = result.query.$filter;
|
|
233
|
+
const subscriptions = [];
|
|
234
|
+
let truncated = false;
|
|
235
|
+
for (const row of result.value) {
|
|
236
|
+
const projected = project(row, common.fields, common.full);
|
|
237
|
+
subscriptions.push(projected.row);
|
|
238
|
+
truncated = truncated || projected.truncated;
|
|
239
|
+
}
|
|
240
|
+
const showHint = `mg-axi entra subscription show --id <subscription-id> ${profileHint(profileName)}`;
|
|
241
|
+
const truncationHints = truncated ? [fullHint("entra subscription list", effectiveFlags, profileName)] : [];
|
|
242
|
+
if (!result.complete) {
|
|
243
|
+
return {
|
|
244
|
+
subscriptions,
|
|
245
|
+
count: { returned: subscriptions.length, complete: false, reason: result.reason },
|
|
246
|
+
cursor: result.cursor,
|
|
247
|
+
help: [...truncationHints, resumeHint(profileName), showHint],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const count = { returned: subscriptions.length, complete: true };
|
|
251
|
+
if (!subscriptions.length) {
|
|
252
|
+
const emptyHint = common.filter === undefined ? EMPTY_NOTE : "0 subscriptions matched; the absence of results is the answer, not an error";
|
|
253
|
+
return { subscriptions, count, help: [emptyHint] };
|
|
254
|
+
}
|
|
255
|
+
return { subscriptions, count, help: [...truncationHints, showHint] };
|
|
256
|
+
}
|
|
257
|
+
export async function showSubscription(session, flags, profile, operation, help, profileName) {
|
|
258
|
+
const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
|
|
259
|
+
const scopes = scopesFor(flags, DEFAULT_SUBSCRIPTION_SCOPES, profile, help);
|
|
260
|
+
const full = flags.full === true;
|
|
261
|
+
const target = subscriptionTarget(flags, operation, help);
|
|
262
|
+
const raw = await withGuidance(SUBSCRIPTION_DENIAL_HINTS, () => session.execute({
|
|
263
|
+
profile,
|
|
264
|
+
operation: target.operation,
|
|
265
|
+
params: target.params,
|
|
266
|
+
query: { $select: select.join(",") },
|
|
267
|
+
scopes,
|
|
268
|
+
}));
|
|
269
|
+
const { row, truncated } = singleResult(raw, fields, full);
|
|
270
|
+
if (truncated)
|
|
271
|
+
return { subscription: row, help: [fullHint("entra subscription show", flags, profileName)] };
|
|
272
|
+
return { subscription: row };
|
|
273
|
+
}
|
|
274
|
+
// The $count route returns a text/plain integer scalar rather than a JSON
|
|
275
|
+
// collection, so the leaf reads it through session.execute with scalar mode
|
|
276
|
+
// and accepts only a non-negative integer. The $count route carries no
|
|
277
|
+
// operation-level documentation page and takes no --filter/--select/--limit/--cursor.
|
|
278
|
+
export async function countSubscriptions(session, flags, profile, operation, help, profileName) {
|
|
279
|
+
void profileName;
|
|
280
|
+
const scopes = scopesFor(flags, DEFAULT_SUBSCRIPTION_SCOPES, profile, help);
|
|
281
|
+
const raw = await withGuidance(SUBSCRIPTION_DENIAL_HINTS, () => session.execute({ profile, operation, scopes, scalar: true }));
|
|
282
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
283
|
+
throw new AxiError("Graph returned a malformed subscription count body", "GRAPH_ERROR", [
|
|
284
|
+
"Subscription counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
287
|
+
const count = { returned: raw, complete: true };
|
|
288
|
+
return raw === 0 ? { count, help: [EMPTY_NOTE] } : { count };
|
|
289
|
+
}
|