@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,381 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// READ-05: the Entra sign-in and directory-audit read mapping behind
|
|
3
|
+
// `mg-axi entra sign-in list/show` and `mg-axi entra directory-audit
|
|
4
|
+
// list/show`. Operation construction stays beside its command; the shared
|
|
5
|
+
// session owns URLs, credentials, paging, retries and error translation, and
|
|
6
|
+
// the SDK owns TOON rendering. This module only maps flags to session calls,
|
|
7
|
+
// bounds every collection query in time, and projects rows for compact output.
|
|
8
|
+
//
|
|
9
|
+
// Reviewed against the v1.0 signin-list and directoryaudit-list operation
|
|
10
|
+
// documentation on 2026-10-04. Both collections read D/A AuditLog.Read.All;
|
|
11
|
+
// delegated sign-in reads additionally accept Global Reader, Reports Reader,
|
|
12
|
+
// Security Administrator, Security Operator or Security Reader, while
|
|
13
|
+
// delegated directory-audit reads accept Reports Reader, Security
|
|
14
|
+
// Administrator or Security Reader. Graph omits appliedConditionalAccessPolicies
|
|
15
|
+
// without a CA-data permission/role, so an absent property is reported as
|
|
16
|
+
// unavailable rather than invented. Sign-in/audit Graph reporting carries a
|
|
17
|
+
// conservative P1/P2 deployment prerequisite; retention and premium fields
|
|
18
|
+
// stay separate constraints.
|
|
19
|
+
// Every property this slice may request or display. Anything else fails
|
|
20
|
+
// before credentials so typos never become misleading server queries.
|
|
21
|
+
export const KNOWN_SIGNIN_FIELDS = [
|
|
22
|
+
"id",
|
|
23
|
+
"createdDateTime",
|
|
24
|
+
"userId",
|
|
25
|
+
"userPrincipalName",
|
|
26
|
+
"userDisplayName",
|
|
27
|
+
"appDisplayName",
|
|
28
|
+
"appId",
|
|
29
|
+
"ipAddress",
|
|
30
|
+
"location",
|
|
31
|
+
"status",
|
|
32
|
+
"conditionalAccessStatus",
|
|
33
|
+
"appliedConditionalAccessPolicies",
|
|
34
|
+
"riskDetail",
|
|
35
|
+
"riskLevelAggregated",
|
|
36
|
+
"riskLevelDuringSignIn",
|
|
37
|
+
"riskState",
|
|
38
|
+
"resourceDisplayName",
|
|
39
|
+
"resourceId",
|
|
40
|
+
"clientAppUsed",
|
|
41
|
+
];
|
|
42
|
+
export const KNOWN_AUDIT_FIELDS = [
|
|
43
|
+
"id",
|
|
44
|
+
"activityDateTime",
|
|
45
|
+
"activityDisplayName",
|
|
46
|
+
"category",
|
|
47
|
+
"loggedByService",
|
|
48
|
+
"operationType",
|
|
49
|
+
"result",
|
|
50
|
+
"resultReason",
|
|
51
|
+
"correlationId",
|
|
52
|
+
"initiatedBy",
|
|
53
|
+
"targetResources",
|
|
54
|
+
];
|
|
55
|
+
const SIGNIN_KNOWN = new Set(KNOWN_SIGNIN_FIELDS);
|
|
56
|
+
const AUDIT_KNOWN = new Set(KNOWN_AUDIT_FIELDS);
|
|
57
|
+
// Compact list rows: identifier, time, who/what happened, and outcome.
|
|
58
|
+
const DEFAULT_SIGNIN_LIST_SELECT = ["id", "createdDateTime", "userPrincipalName", "appDisplayName"];
|
|
59
|
+
const DEFAULT_AUDIT_LIST_SELECT = ["id", "activityDateTime", "activityDisplayName", "result"];
|
|
60
|
+
// Show rows: the full reviewed server set for one object.
|
|
61
|
+
const DEFAULT_SIGNIN_SHOW_SELECT = [...KNOWN_SIGNIN_FIELDS];
|
|
62
|
+
const DEFAULT_AUDIT_SHOW_SELECT = [...KNOWN_AUDIT_FIELDS];
|
|
63
|
+
export const DEFAULT_DELEGATED_SCOPES = ["https://graph.microsoft.com/AuditLog.Read.All"];
|
|
64
|
+
const TRUNCATE_AT = 500;
|
|
65
|
+
// Graph omits this property without CA-data access instead of failing, so an
|
|
66
|
+
// absent value reports the missing prerequisite rather than an empty result.
|
|
67
|
+
const SIGNIN_UNAVAILABLE = {
|
|
68
|
+
appliedConditionalAccessPolicies: "unavailable: Graph omits CA policy detail without CA-data access - both modes need Policy.Read.All or Policy.Read.ConditionalAccess in addition to AuditLog.Read.All; delegated also needs Conditional Access Administrator, Global Reader, Security Administrator or Security Reader. For delegated access, log in and repeat this read with --scopes https://graph.microsoft.com/AuditLog.Read.All,https://graph.microsoft.com/Policy.Read.All",
|
|
69
|
+
};
|
|
70
|
+
function fieldList(raw, flag, known, knownList, help) {
|
|
71
|
+
const parts = String(raw)
|
|
72
|
+
.split(",")
|
|
73
|
+
.map(part => part.trim())
|
|
74
|
+
.filter(part => part.length > 0);
|
|
75
|
+
if (!parts.length)
|
|
76
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
77
|
+
const fields = [];
|
|
78
|
+
for (const part of parts) {
|
|
79
|
+
if (!known.has(part)) {
|
|
80
|
+
throw new AxiError(`Unknown property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
81
|
+
help,
|
|
82
|
+
`Known properties: ${knownList.join(", ")}`,
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
if (!fields.includes(part))
|
|
86
|
+
fields.push(part);
|
|
87
|
+
}
|
|
88
|
+
return fields;
|
|
89
|
+
}
|
|
90
|
+
function scopesFor(flags, profile, help) {
|
|
91
|
+
if (profile.mode === "application") {
|
|
92
|
+
if (flags.scopes !== undefined) {
|
|
93
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (flags.scopes === undefined)
|
|
98
|
+
return [...DEFAULT_DELEGATED_SCOPES];
|
|
99
|
+
const scopes = String(flags.scopes)
|
|
100
|
+
.split(",")
|
|
101
|
+
.map(scope => scope.trim())
|
|
102
|
+
.filter(scope => scope.length > 0);
|
|
103
|
+
return scopes;
|
|
104
|
+
}
|
|
105
|
+
function isoInstant(raw, flag, help) {
|
|
106
|
+
const text = String(raw).trim();
|
|
107
|
+
const match = /^(\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d)(?:\.(\d+))?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.exec(text);
|
|
108
|
+
const local = match === null ? NaN : Date.parse(`${match[1]}Z`);
|
|
109
|
+
const parsed = match === null ? NaN : Date.parse(`${match[1]}${match[3]}`);
|
|
110
|
+
if (Number.isNaN(local) || Number.isNaN(parsed) || new Date(local).toISOString().slice(0, 19) !== match[1]
|
|
111
|
+
|| new Date(parsed).toISOString().length !== 24) {
|
|
112
|
+
throw new AxiError(`--${flag} needs an ISO-8601 instant such as 2026-09-01T00:00:00Z`, "VALIDATION_ERROR", [help]);
|
|
113
|
+
}
|
|
114
|
+
const fraction = (match[2] ?? "").replace(/0+$/, "").padEnd(3, "0");
|
|
115
|
+
return `${new Date(parsed).toISOString().slice(0, 19)}.${fraction}Z`;
|
|
116
|
+
}
|
|
117
|
+
// Every collection query carries an explicit time bound; there is no
|
|
118
|
+
// unbounded default. Returns undefined when the caller passed no time or
|
|
119
|
+
// filter flags so a cursor resume restores the saved query untouched.
|
|
120
|
+
function boundedFilter(dateField, flags, help) {
|
|
121
|
+
const since = flags.since === undefined ? undefined : isoInstant(flags.since, "since", help);
|
|
122
|
+
const until = flags.until === undefined ? undefined : isoInstant(flags.until, "until", help);
|
|
123
|
+
if (since !== undefined && until !== undefined && until.slice(0, -1) <= since.slice(0, -1)) {
|
|
124
|
+
throw new AxiError("--until must be after --since", "VALIDATION_ERROR", [help]);
|
|
125
|
+
}
|
|
126
|
+
const parts = [];
|
|
127
|
+
if (since !== undefined)
|
|
128
|
+
parts.push(`${dateField} ge ${since}`);
|
|
129
|
+
if (until !== undefined)
|
|
130
|
+
parts.push(`${dateField} le ${until}`);
|
|
131
|
+
if (flags.filter !== undefined) {
|
|
132
|
+
const filter = String(flags.filter).trim();
|
|
133
|
+
if (!filter.length)
|
|
134
|
+
throw new AxiError("--filter needs a non-empty OData expression", "VALIDATION_ERROR", [help]);
|
|
135
|
+
let quoted = false;
|
|
136
|
+
let depth = 0;
|
|
137
|
+
for (const char of filter) {
|
|
138
|
+
if (char === "'")
|
|
139
|
+
quoted = !quoted;
|
|
140
|
+
else if (!quoted && char === "(")
|
|
141
|
+
depth++;
|
|
142
|
+
else if (!quoted && char === ")" && --depth < 0)
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (quoted || depth !== 0)
|
|
146
|
+
throw new AxiError("--filter needs balanced parentheses and quoted strings to preserve the time bounds", "VALIDATION_ERROR", [help]);
|
|
147
|
+
parts.push(`(${filter})`);
|
|
148
|
+
}
|
|
149
|
+
return parts.length ? parts.join(" and ") : undefined;
|
|
150
|
+
}
|
|
151
|
+
function truncateValue(value, full) {
|
|
152
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
153
|
+
? { value: text, truncated: false }
|
|
154
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
155
|
+
if (typeof value === "string")
|
|
156
|
+
return truncate(value);
|
|
157
|
+
if (Array.isArray(value)) {
|
|
158
|
+
const projected = value.map(entry => truncateValue(entry, full));
|
|
159
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
160
|
+
}
|
|
161
|
+
if (value !== null && typeof value === "object") {
|
|
162
|
+
const projected = Object.entries(value).map(([key, entry]) => [key, truncateValue(entry, full)]);
|
|
163
|
+
return {
|
|
164
|
+
value: Object.fromEntries(projected.map(([key, entry]) => [key, entry.value])),
|
|
165
|
+
truncated: projected.some(([, entry]) => entry.truncated),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return { value, truncated: false };
|
|
169
|
+
}
|
|
170
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
171
|
+
// null stays null, an absent base property stays absent and is never
|
|
172
|
+
// synthesized. Properties Graph omits without an entitlement report the
|
|
173
|
+
// missing prerequisite instead.
|
|
174
|
+
function project(row, fields, unavailable, full) {
|
|
175
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
176
|
+
const projected = {};
|
|
177
|
+
let truncated = false;
|
|
178
|
+
for (const field of fields) {
|
|
179
|
+
if (!Object.hasOwn(source, field)) {
|
|
180
|
+
if (unavailable[field] !== undefined)
|
|
181
|
+
projected[field] = unavailable[field];
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const result = truncateValue(source[field], full);
|
|
185
|
+
projected[field] = result.value;
|
|
186
|
+
truncated = truncated || result.truncated;
|
|
187
|
+
}
|
|
188
|
+
return { row: projected, truncated };
|
|
189
|
+
}
|
|
190
|
+
function selectedFields(flags, defaults, known, knownList, help) {
|
|
191
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, "select", known, knownList, help);
|
|
192
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, "fields", known, knownList, help);
|
|
193
|
+
const missing = fields.find(field => !select.includes(field));
|
|
194
|
+
if (missing) {
|
|
195
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
196
|
+
}
|
|
197
|
+
return { select, fields };
|
|
198
|
+
}
|
|
199
|
+
function profileHint(profileName) {
|
|
200
|
+
return `--profile ${shellValue(profileName)}`;
|
|
201
|
+
}
|
|
202
|
+
function shellValue(value) {
|
|
203
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
204
|
+
}
|
|
205
|
+
function fullHint(noun, action, flags, profileName) {
|
|
206
|
+
const args = Object.entries({ ...flags, ...(flags.cursor === undefined ? {} : { cursor: "-" }), profile: profileName, full: true })
|
|
207
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
208
|
+
return `mg-axi entra ${noun} ${action} ${args.join(" ")}`;
|
|
209
|
+
}
|
|
210
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
211
|
+
// operation adds the roles and licensing that actually unlock it, because a
|
|
212
|
+
// 403 alone never says which prerequisite is missing.
|
|
213
|
+
function withGuidance(hints, run) {
|
|
214
|
+
return run().catch(error => {
|
|
215
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
216
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
217
|
+
}
|
|
218
|
+
throw error;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const SIGNIN_DENIAL_HINTS = [
|
|
222
|
+
"Sign-in reads need AuditLog.Read.All plus a supported directory role: Global Reader, Reports Reader, Security Administrator, Security Operator or Security Reader for delegated access, or admin-consented AuditLog.Read.All for application access",
|
|
223
|
+
"Sign-in Graph reporting carries a conservative P1/P2 deployment prerequisite; retention and premium fields stay separate constraints",
|
|
224
|
+
];
|
|
225
|
+
const AUDIT_DENIAL_HINTS = [
|
|
226
|
+
"Directory-audit reads need AuditLog.Read.All plus a supported directory role: Reports Reader, Security Administrator or Security Reader for delegated access, or admin-consented AuditLog.Read.All for application access",
|
|
227
|
+
"Audit Graph reporting carries a conservative P1/P2 deployment prerequisite; retention stays a separate constraint",
|
|
228
|
+
];
|
|
229
|
+
const SIGNIN_LIST = {
|
|
230
|
+
noun: "sign-in",
|
|
231
|
+
key: "signIns",
|
|
232
|
+
dateField: "createdDateTime",
|
|
233
|
+
known: SIGNIN_KNOWN,
|
|
234
|
+
knownList: KNOWN_SIGNIN_FIELDS,
|
|
235
|
+
defaultSelect: DEFAULT_SIGNIN_LIST_SELECT,
|
|
236
|
+
unavailable: SIGNIN_UNAVAILABLE,
|
|
237
|
+
denialHints: SIGNIN_DENIAL_HINTS,
|
|
238
|
+
showHint: "mg-axi entra sign-in show --id <sign-in-id>",
|
|
239
|
+
emptyHints: profileName => [
|
|
240
|
+
`mg-axi entra sign-in list --since <earlier-iso-time> ${profileHint(profileName)}`,
|
|
241
|
+
"0 sign-ins matched in this window; widen --since/--until or loosen --filter - the absence of results is the answer, not an error",
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
const AUDIT_LIST = {
|
|
245
|
+
noun: "directory-audit",
|
|
246
|
+
key: "directoryAudits",
|
|
247
|
+
dateField: "activityDateTime",
|
|
248
|
+
known: AUDIT_KNOWN,
|
|
249
|
+
knownList: KNOWN_AUDIT_FIELDS,
|
|
250
|
+
defaultSelect: DEFAULT_AUDIT_LIST_SELECT,
|
|
251
|
+
unavailable: {},
|
|
252
|
+
denialHints: AUDIT_DENIAL_HINTS,
|
|
253
|
+
showHint: "mg-axi entra directory-audit show --id <directory-audit-id>",
|
|
254
|
+
emptyHints: profileName => [
|
|
255
|
+
`mg-axi entra directory-audit list --since <earlier-iso-time> ${profileHint(profileName)}`,
|
|
256
|
+
"0 directory audits matched in this window; widen --since/--until or loosen --filter - the absence of results is the answer, not an error",
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
async function listLogs(shape, session, flags, profile, operation, help, profileName) {
|
|
260
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
261
|
+
if (cursor !== undefined && !cursor.trim())
|
|
262
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
263
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
264
|
+
const bounds = saved === undefined ? undefined : new RegExp(`^${shape.dateField} ge (\\S+)(?: and ${shape.dateField} le (\\S+))?(?: and \\(([\\s\\S]*)\\))?$`).exec(saved.$filter ?? "");
|
|
265
|
+
const savedFlags = bounds ? {
|
|
266
|
+
since: bounds[1],
|
|
267
|
+
...(bounds[2] === undefined ? {} : { until: bounds[2] }),
|
|
268
|
+
...(bounds[3] === undefined ? {} : { filter: bounds[3] }),
|
|
269
|
+
} : undefined;
|
|
270
|
+
if ((saved === undefined && flags.since === undefined)
|
|
271
|
+
|| (saved !== undefined && (savedFlags === undefined || boundedFilter(shape.dateField, savedFlags, help) !== saved.$filter))) {
|
|
272
|
+
throw new AxiError(`Log queries are bounded in time: pass --since <ISO-time> to bound ${shape.dateField}`, "VALIDATION_ERROR", [
|
|
273
|
+
help,
|
|
274
|
+
"Start a new query with --since instead of resuming a cursor without a valid saved time bound",
|
|
275
|
+
`Example: mg-axi entra ${shape.noun} list --since 2026-09-01T00:00:00Z ${profileHint(profileName)}`,
|
|
276
|
+
]);
|
|
277
|
+
}
|
|
278
|
+
const savedSelect = saved?.$select;
|
|
279
|
+
const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, help), shape.known, shape.knownList, help);
|
|
280
|
+
const scopes = scopesFor(flags, profile, help);
|
|
281
|
+
const full = flags.full === true;
|
|
282
|
+
const filter = boundedFilter(shape.dateField, flags, help);
|
|
283
|
+
const query = { $select: select.join(",") };
|
|
284
|
+
const explicitFilter = filter !== undefined;
|
|
285
|
+
if (explicitFilter)
|
|
286
|
+
query.$filter = filter;
|
|
287
|
+
const args = { profile, operation, query, scopes };
|
|
288
|
+
if (cursor !== undefined)
|
|
289
|
+
args.cursor = cursor;
|
|
290
|
+
if (flags.all === true) {
|
|
291
|
+
if (flags.limit !== undefined)
|
|
292
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
296
|
+
}
|
|
297
|
+
const result = await withGuidance(shape.denialHints, () => session.collect(args));
|
|
298
|
+
// The hint replays the caller's own time flags (or the cursor alone, which
|
|
299
|
+
// restores the saved query); copying back the composed $filter would wrap
|
|
300
|
+
// it a second time and conflict with the cursor context.
|
|
301
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? shape.defaultSelect.join(",") };
|
|
302
|
+
const rows = [];
|
|
303
|
+
let truncated = false;
|
|
304
|
+
for (const row of result.value) {
|
|
305
|
+
const projected = project(row, fields, shape.unavailable, full);
|
|
306
|
+
rows.push(projected.row);
|
|
307
|
+
truncated = truncated || projected.truncated;
|
|
308
|
+
}
|
|
309
|
+
const showHint = `${shape.showHint} ${profileHint(profileName)}`;
|
|
310
|
+
const truncationHints = truncated ? [fullHint(shape.noun, "list", effectiveFlags, profileName)] : [];
|
|
311
|
+
if (truncated && cursor !== undefined)
|
|
312
|
+
truncationHints.push("Supply the original input cursor on stdin to replay this result with --full");
|
|
313
|
+
if (!result.complete) {
|
|
314
|
+
return {
|
|
315
|
+
[shape.key]: rows,
|
|
316
|
+
count: { returned: rows.length, complete: false, reason: result.reason },
|
|
317
|
+
cursor: result.cursor,
|
|
318
|
+
help: [...truncationHints, `Resume losslessly with the same flags plus --cursor - ${profileHint(profileName)} and supply the returned cursor on stdin`, showHint],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const count = { returned: rows.length, complete: true };
|
|
322
|
+
if (!rows.length) {
|
|
323
|
+
return { [shape.key]: rows, count, help: shape.emptyHints(profileName) };
|
|
324
|
+
}
|
|
325
|
+
return { [shape.key]: rows, count, help: [...truncationHints, showHint] };
|
|
326
|
+
}
|
|
327
|
+
const SIGNIN_SHOW = {
|
|
328
|
+
noun: "sign-in",
|
|
329
|
+
key: "signIn",
|
|
330
|
+
param: "signIn-id",
|
|
331
|
+
known: SIGNIN_KNOWN,
|
|
332
|
+
knownList: KNOWN_SIGNIN_FIELDS,
|
|
333
|
+
defaultSelect: DEFAULT_SIGNIN_SHOW_SELECT,
|
|
334
|
+
unavailable: SIGNIN_UNAVAILABLE,
|
|
335
|
+
denialHints: SIGNIN_DENIAL_HINTS,
|
|
336
|
+
idFlag: "<sign-in-id>",
|
|
337
|
+
};
|
|
338
|
+
const AUDIT_SHOW = {
|
|
339
|
+
noun: "directory-audit",
|
|
340
|
+
key: "directoryAudit",
|
|
341
|
+
param: "directoryAudit-id",
|
|
342
|
+
known: AUDIT_KNOWN,
|
|
343
|
+
knownList: KNOWN_AUDIT_FIELDS,
|
|
344
|
+
defaultSelect: DEFAULT_AUDIT_SHOW_SELECT,
|
|
345
|
+
unavailable: {},
|
|
346
|
+
denialHints: AUDIT_DENIAL_HINTS,
|
|
347
|
+
idFlag: "<directory-audit-id>",
|
|
348
|
+
};
|
|
349
|
+
async function showLog(shape, session, flags, profile, operation, help, profileName) {
|
|
350
|
+
const { select, fields } = selectedFields(flags, shape.defaultSelect, shape.known, shape.knownList, help);
|
|
351
|
+
const scopes = scopesFor(flags, profile, help);
|
|
352
|
+
const full = flags.full === true;
|
|
353
|
+
const raw = await withGuidance(shape.denialHints, () => session.execute({
|
|
354
|
+
profile,
|
|
355
|
+
operation,
|
|
356
|
+
params: { [shape.param]: String(flags.id) },
|
|
357
|
+
query: { $select: select.join(",") },
|
|
358
|
+
scopes,
|
|
359
|
+
}));
|
|
360
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
361
|
+
throw new AxiError("Graph returned a malformed log body", "GRAPH_ERROR", [
|
|
362
|
+
"Single-object reads carry one object; treat anything else as unknown, not empty",
|
|
363
|
+
]);
|
|
364
|
+
}
|
|
365
|
+
const { row, truncated } = project(raw, fields, shape.unavailable, full);
|
|
366
|
+
if (truncated)
|
|
367
|
+
return { [shape.key]: row, help: [fullHint(shape.noun, "show", flags, profileName)] };
|
|
368
|
+
return { [shape.key]: row };
|
|
369
|
+
}
|
|
370
|
+
export async function listSignIns(session, flags, profile, operation, help, profileName) {
|
|
371
|
+
return listLogs(SIGNIN_LIST, session, flags, profile, operation, help, profileName);
|
|
372
|
+
}
|
|
373
|
+
export async function showSignIn(session, flags, profile, operation, help, profileName) {
|
|
374
|
+
return showLog(SIGNIN_SHOW, session, flags, profile, operation, help, profileName);
|
|
375
|
+
}
|
|
376
|
+
export async function listDirectoryAudits(session, flags, profile, operation, help, profileName) {
|
|
377
|
+
return listLogs(AUDIT_LIST, session, flags, profile, operation, help, profileName);
|
|
378
|
+
}
|
|
379
|
+
export async function showDirectoryAudit(session, flags, profile, operation, help, profileName) {
|
|
380
|
+
return showLog(AUDIT_SHOW, session, flags, profile, operation, help, profileName);
|
|
381
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// READ-04: the Entra authentication-method and registration-report read
|
|
3
|
+
// mapping behind `mg-axi entra user authentication-method list` and
|
|
4
|
+
// `mg-axi entra registration list`. 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 authentication-list-methods and
|
|
10
|
+
// authenticationmethodsroot-list-userregistrationdetails operation
|
|
11
|
+
// documentation on 2026-10-04. Per-user method reads take D/A
|
|
12
|
+
// UserAuthenticationMethod.Read.All for other users (delegated self-reads use
|
|
13
|
+
// UserAuthenticationMethod.Read); delegated callers acting on another user
|
|
14
|
+
// additionally need Global Reader, Authentication Administrator or Privileged
|
|
15
|
+
// Authentication Administrator. The registration report reads D/A
|
|
16
|
+
// AuditLog.Read.All; delegated callers additionally need Reports Reader,
|
|
17
|
+
// Security Reader, Security Administrator or Global Reader.
|
|
18
|
+
//
|
|
19
|
+
// Upstream discourages iterating per-user methods across the tenant for
|
|
20
|
+
// auditing; aggregate coverage belongs to the registration report. This slice
|
|
21
|
+
// therefore serves one named user per method-list call and offers no tenant
|
|
22
|
+
// scan: there is no command that loops users, and the standing help points
|
|
23
|
+
// aggregate questions at the report. The report itself does not work for
|
|
24
|
+
// disabled users, so absence from it is never proof of no MFA; that gap rides
|
|
25
|
+
// in the leaf help and every report output.
|
|
26
|
+
//
|
|
27
|
+
// Phone numbers are protected values: the shared session replaces them with
|
|
28
|
+
// the redaction marker before collection buffering and on cursor decode, so
|
|
29
|
+
// neither output nor resume cursors ever carry one. Method rows additionally
|
|
30
|
+
// preserve @odata.type (naming the method kind) without making it selectable.
|
|
31
|
+
// No method registration or deletion is constructed here.
|
|
32
|
+
// Every authentication-method property this slice may select locally,
|
|
33
|
+
// matching the reviewed API-01 field set. Anything else fails before
|
|
34
|
+
// credentials so typos never become misleading output projections.
|
|
35
|
+
export const KNOWN_METHOD_FIELDS = [
|
|
36
|
+
"id",
|
|
37
|
+
"createdDateTime",
|
|
38
|
+
"displayName",
|
|
39
|
+
"phoneNumber",
|
|
40
|
+
"phoneType",
|
|
41
|
+
"smsSignInState",
|
|
42
|
+
"emailAddress",
|
|
43
|
+
];
|
|
44
|
+
const KNOWN_METHODS = new Set(KNOWN_METHOD_FIELDS);
|
|
45
|
+
// Every registration-report property this slice may request or display,
|
|
46
|
+
// matching the reviewed API-01 field set.
|
|
47
|
+
export const KNOWN_REPORT_FIELDS = [
|
|
48
|
+
"id",
|
|
49
|
+
"userPrincipalName",
|
|
50
|
+
"userDisplayName",
|
|
51
|
+
"userType",
|
|
52
|
+
"isAdmin",
|
|
53
|
+
"isMfaRegistered",
|
|
54
|
+
"isMfaCapable",
|
|
55
|
+
"isPasswordlessCapable",
|
|
56
|
+
"isSsprRegistered",
|
|
57
|
+
"isSsprEnabled",
|
|
58
|
+
"isSsprCapable",
|
|
59
|
+
"userPreferredMethodForSecondaryAuthentication",
|
|
60
|
+
"lastUpdatedDateTime",
|
|
61
|
+
];
|
|
62
|
+
const KNOWN_REPORT = new Set(KNOWN_REPORT_FIELDS);
|
|
63
|
+
// @odata.type is preserved on method rows without being selectable: it names
|
|
64
|
+
// the method kind (phone, email, fido2 and the other derived types).
|
|
65
|
+
const METHOD_TYPE_PROPERTY = "@odata.type";
|
|
66
|
+
// Compact method rows: identifier, display name and registration time. The
|
|
67
|
+
// method kind rides along via @odata.type; phoneNumber stays out of the
|
|
68
|
+
// default selection and is redacted whenever it is fetched.
|
|
69
|
+
const DEFAULT_METHOD_SELECT = ["id", "displayName", "createdDateTime"];
|
|
70
|
+
// Compact report rows: identifier, user keys and the headline MFA posture.
|
|
71
|
+
const DEFAULT_REPORT_SELECT = ["id", "userPrincipalName", "userDisplayName", "isMfaRegistered"];
|
|
72
|
+
// UserAuthenticationMethod.Read.All covers other-user reads in both modes;
|
|
73
|
+
// delegated self-reads may use UserAuthenticationMethod.Read instead.
|
|
74
|
+
export const DEFAULT_METHOD_SCOPES = ["https://graph.microsoft.com/UserAuthenticationMethod.Read.All"];
|
|
75
|
+
// AuditLog.Read.All covers the registration report in both modes.
|
|
76
|
+
export const DEFAULT_REPORT_SCOPES = ["https://graph.microsoft.com/AuditLog.Read.All"];
|
|
77
|
+
const TRUNCATE_AT = 500;
|
|
78
|
+
// The only operations this slice ever binds. Method registration, deletion
|
|
79
|
+
// and every other route are refused before credentials.
|
|
80
|
+
const READ_OPERATIONS = {
|
|
81
|
+
"GET:/users/{user-id}/authentication/methods": "method",
|
|
82
|
+
"GET:/reports/authenticationMethods/userRegistrationDetails": "report",
|
|
83
|
+
};
|
|
84
|
+
function checkReadOperation(operation, help) {
|
|
85
|
+
// Version stays the session's decision: beta reads remain preview-gated
|
|
86
|
+
// there, exactly like every other named slice. This gate only ensures a
|
|
87
|
+
// registration/deletion route or any other non-read can never reach
|
|
88
|
+
// credentials from this module.
|
|
89
|
+
const route = `${operation.method}:${operation.path}`;
|
|
90
|
+
if (operation.method !== "GET" || !Object.hasOwn(READ_OPERATIONS, route)) {
|
|
91
|
+
throw new AxiError(`Refused non-read route ${operation.id}: READ-04 serves only the catalogued per-user method list and registration report`, "VALIDATION_ERROR", [
|
|
92
|
+
help,
|
|
93
|
+
"Method registration and deletion belong to no read slice; aggregate coverage belongs to the registration report",
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function fieldList(raw, known, knownList, flag, help) {
|
|
98
|
+
const parts = String(raw)
|
|
99
|
+
.split(",")
|
|
100
|
+
.map(part => part.trim())
|
|
101
|
+
.filter(part => part.length > 0);
|
|
102
|
+
if (!parts.length)
|
|
103
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
104
|
+
const fields = [];
|
|
105
|
+
for (const part of parts) {
|
|
106
|
+
if (!known.has(part)) {
|
|
107
|
+
throw new AxiError(`Unknown property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
108
|
+
help,
|
|
109
|
+
`Known properties: ${knownList.join(", ")}`,
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
if (!fields.includes(part))
|
|
113
|
+
fields.push(part);
|
|
114
|
+
}
|
|
115
|
+
return fields;
|
|
116
|
+
}
|
|
117
|
+
function scopesFor(flags, profile, defaults, help) {
|
|
118
|
+
if (profile.mode === "application") {
|
|
119
|
+
if (flags.scopes !== undefined) {
|
|
120
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
if (flags.scopes === undefined)
|
|
125
|
+
return [...defaults];
|
|
126
|
+
const scopes = String(flags.scopes)
|
|
127
|
+
.split(",")
|
|
128
|
+
.map(scope => scope.trim())
|
|
129
|
+
.filter(scope => scope.length > 0);
|
|
130
|
+
return scopes;
|
|
131
|
+
}
|
|
132
|
+
function truncateValue(value, full) {
|
|
133
|
+
if (typeof value === "string" && !full && value.length > TRUNCATE_AT) {
|
|
134
|
+
return { value: `${value.slice(0, TRUNCATE_AT)}... (truncated, ${value.length} chars total)`, truncated: true };
|
|
135
|
+
}
|
|
136
|
+
return { value, truncated: false };
|
|
137
|
+
}
|
|
138
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
139
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
140
|
+
// Redacted phone numbers arrive from the session as the redaction marker and
|
|
141
|
+
// pass through unchanged; truncation never hides the marker.
|
|
142
|
+
function project(row, fields, full) {
|
|
143
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
144
|
+
const projected = {};
|
|
145
|
+
let truncated = false;
|
|
146
|
+
for (const field of fields) {
|
|
147
|
+
if (!Object.hasOwn(source, field))
|
|
148
|
+
continue;
|
|
149
|
+
const result = truncateValue(source[field], full);
|
|
150
|
+
projected[field] = result.value;
|
|
151
|
+
truncated = truncated || result.truncated;
|
|
152
|
+
}
|
|
153
|
+
return { row: projected, truncated };
|
|
154
|
+
}
|
|
155
|
+
// Method projection additionally preserves @odata.type so the method kind
|
|
156
|
+
// survives local projection.
|
|
157
|
+
function projectMethod(row, fields, full) {
|
|
158
|
+
const { row: projected, truncated } = project(row, fields, full);
|
|
159
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
160
|
+
if (typeof source[METHOD_TYPE_PROPERTY] === "string")
|
|
161
|
+
projected[METHOD_TYPE_PROPERTY] = source[METHOD_TYPE_PROPERTY];
|
|
162
|
+
return { row: projected, truncated };
|
|
163
|
+
}
|
|
164
|
+
function selectedFields(flags, defaults, known, knownList, help) {
|
|
165
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, knownList, "select", help);
|
|
166
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, knownList, "fields", help);
|
|
167
|
+
const missing = fields.find(field => !select.includes(field));
|
|
168
|
+
if (missing) {
|
|
169
|
+
throw new AxiError(`--fields ${missing} was not selected; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
170
|
+
}
|
|
171
|
+
return { select, fields };
|
|
172
|
+
}
|
|
173
|
+
function profileHint(profileName) {
|
|
174
|
+
return `--profile ${shellValue(profileName)}`;
|
|
175
|
+
}
|
|
176
|
+
function shellValue(value) {
|
|
177
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
178
|
+
}
|
|
179
|
+
function fullHint(command, flags, profileName) {
|
|
180
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
181
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
182
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
183
|
+
}
|
|
184
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
185
|
+
// operation adds the roles that actually unlock it, because a 403 alone
|
|
186
|
+
// never says which prerequisite is missing.
|
|
187
|
+
function withGuidance(hints, run) {
|
|
188
|
+
return run().catch(error => {
|
|
189
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
190
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
191
|
+
}
|
|
192
|
+
throw error;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const METHOD_DENIAL_HINTS = [
|
|
196
|
+
"Authentication-method reads need UserAuthenticationMethod.Read.All plus Global Reader, Authentication Administrator or Privileged Authentication Administrator for delegated access to another user (delegated self-reads use UserAuthenticationMethod.Read), or admin-consented UserAuthenticationMethod.Read.All for application access",
|
|
197
|
+
"Phone numbers stay redacted for every role; an Authentication Administrator additionally sees masked phone numbers server-side",
|
|
198
|
+
];
|
|
199
|
+
const REPORT_DENIAL_HINTS = [
|
|
200
|
+
"Registration-report reads need AuditLog.Read.All plus Reports Reader, Security Reader, Security Administrator or Global Reader for delegated access, or admin-consented AuditLog.Read.All for application access",
|
|
201
|
+
"The report does not cover disabled users; a 403 is a grant or role denial, never evidence about a disabled account",
|
|
202
|
+
];
|
|
203
|
+
const METHOD_LIST = {
|
|
204
|
+
command: "entra user authentication-method list",
|
|
205
|
+
key: "authenticationMethods",
|
|
206
|
+
known: KNOWN_METHODS,
|
|
207
|
+
knownList: KNOWN_METHOD_FIELDS,
|
|
208
|
+
defaultSelect: DEFAULT_METHOD_SELECT,
|
|
209
|
+
defaultScopes: DEFAULT_METHOD_SCOPES,
|
|
210
|
+
denialHints: METHOD_DENIAL_HINTS,
|
|
211
|
+
standing: profileName => [
|
|
212
|
+
"Targeted inspection of one named user only; for tenant-wide MFA coverage use "
|
|
213
|
+
+ `mg-axi entra registration list ${profileHint(profileName)}, never a scan across users`,
|
|
214
|
+
],
|
|
215
|
+
emptyNote: "0 authentication methods matched; the absence of results is the answer, not an error",
|
|
216
|
+
preserveType: true,
|
|
217
|
+
};
|
|
218
|
+
const REPORT_LIST = {
|
|
219
|
+
command: "entra registration list",
|
|
220
|
+
key: "registrationDetails",
|
|
221
|
+
known: KNOWN_REPORT,
|
|
222
|
+
knownList: KNOWN_REPORT_FIELDS,
|
|
223
|
+
defaultSelect: DEFAULT_REPORT_SELECT,
|
|
224
|
+
defaultScopes: DEFAULT_REPORT_SCOPES,
|
|
225
|
+
denialHints: REPORT_DENIAL_HINTS,
|
|
226
|
+
standing: () => [
|
|
227
|
+
"The registration report does not cover disabled users; absence from this report is not proof of no MFA",
|
|
228
|
+
],
|
|
229
|
+
emptyNote: "0 registration rows matched; the absence of results is the answer, not an error",
|
|
230
|
+
preserveType: false,
|
|
231
|
+
};
|
|
232
|
+
async function listCollection(shape, session, flags, profile, operation, help, profileName, params) {
|
|
233
|
+
checkReadOperation(operation, help);
|
|
234
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
235
|
+
if (cursor !== undefined && !cursor.trim())
|
|
236
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
237
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
238
|
+
const savedSelect = saved?.$select;
|
|
239
|
+
const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, shape.known, shape.knownList, "select", help), shape.known, shape.knownList, help);
|
|
240
|
+
const savedFilter = saved?.$filter;
|
|
241
|
+
const filter = flags.filter === undefined ? savedFilter : String(flags.filter);
|
|
242
|
+
if (shape.preserveType && filter !== undefined)
|
|
243
|
+
throw new AxiError("Authentication-method reads do not support --filter", "VALIDATION_ERROR", [help]);
|
|
244
|
+
const scopes = scopesFor(flags, profile, shape.defaultScopes, help);
|
|
245
|
+
const full = flags.full === true;
|
|
246
|
+
const query = { $select: select.join(",") };
|
|
247
|
+
if (filter !== undefined)
|
|
248
|
+
query.$filter = filter;
|
|
249
|
+
const args = { profile, operation, query, scopes };
|
|
250
|
+
if (params)
|
|
251
|
+
args.params = params;
|
|
252
|
+
if (cursor !== undefined)
|
|
253
|
+
args.cursor = cursor;
|
|
254
|
+
if (flags.all === true) {
|
|
255
|
+
if (flags.limit !== undefined)
|
|
256
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
260
|
+
}
|
|
261
|
+
const result = await withGuidance(shape.denialHints, () => session.collect(args));
|
|
262
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? shape.defaultSelect.join(",") };
|
|
263
|
+
if (!shape.preserveType && result.query.$filter !== undefined)
|
|
264
|
+
effectiveFlags.filter = result.query.$filter;
|
|
265
|
+
const rows = [];
|
|
266
|
+
let truncated = false;
|
|
267
|
+
for (const row of result.value) {
|
|
268
|
+
const projected = shape.preserveType ? projectMethod(row, fields, full) : project(row, fields, full);
|
|
269
|
+
rows.push(projected.row);
|
|
270
|
+
truncated = truncated || projected.truncated;
|
|
271
|
+
}
|
|
272
|
+
const truncationHints = truncated ? [fullHint(shape.command, effectiveFlags, profileName)] : [];
|
|
273
|
+
const standing = shape.standing(profileName);
|
|
274
|
+
if (!result.complete) {
|
|
275
|
+
return {
|
|
276
|
+
[shape.key]: rows,
|
|
277
|
+
count: { returned: rows.length, complete: false, reason: result.reason },
|
|
278
|
+
cursor: result.cursor,
|
|
279
|
+
help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, ...standing],
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const count = { returned: rows.length, complete: true };
|
|
283
|
+
if (!rows.length) {
|
|
284
|
+
return { [shape.key]: rows, count, help: [shape.emptyNote, ...standing] };
|
|
285
|
+
}
|
|
286
|
+
return { [shape.key]: rows, count, help: [...truncationHints, ...standing] };
|
|
287
|
+
}
|
|
288
|
+
export async function listAuthenticationMethods(session, flags, profile, operation, help, profileName) {
|
|
289
|
+
const user = String(flags.user);
|
|
290
|
+
if (!user.trim())
|
|
291
|
+
throw new AxiError("--user needs the target user ID or UPN", "VALIDATION_ERROR", [help]);
|
|
292
|
+
return listCollection(METHOD_LIST, session, flags, profile, operation, help, profileName, { "user-id": user });
|
|
293
|
+
}
|
|
294
|
+
export async function listRegistrationDetails(session, flags, profile, operation, help, profileName) {
|
|
295
|
+
return listCollection(REPORT_LIST, session, flags, profile, operation, help, profileName);
|
|
296
|
+
}
|