@knowttl/mg-axi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +1138 -0
  2. package/dist/api.js +953 -0
  3. package/dist/app-auth.js +47 -0
  4. package/dist/auth.js +59 -0
  5. package/dist/bin/mg-axi.js +7 -0
  6. package/dist/catalogue.js +2191 -0
  7. package/dist/cli.js +1314 -0
  8. package/dist/docs.js +559 -0
  9. package/dist/doctor.js +75 -0
  10. package/dist/entra-access-reviews.js +425 -0
  11. package/dist/entra-apps.js +374 -0
  12. package/dist/entra-audit-logs.js +381 -0
  13. package/dist/entra-auth-methods.js +296 -0
  14. package/dist/entra-ca-policy-update.js +353 -0
  15. package/dist/entra-certificate-auth.js +384 -0
  16. package/dist/entra-conditional-access.js +317 -0
  17. package/dist/entra-contacts.js +827 -0
  18. package/dist/entra-contracts.js +252 -0
  19. package/dist/entra-custom-security-attributes.js +464 -0
  20. package/dist/entra-data-policy-operations.js +274 -0
  21. package/dist/entra-delegated-admin.js +468 -0
  22. package/dist/entra-deleted-items.js +461 -0
  23. package/dist/entra-directory-objects.js +274 -0
  24. package/dist/entra-directory.js +411 -0
  25. package/dist/entra-domains.js +399 -0
  26. package/dist/entra-entitlement-management.js +645 -0
  27. package/dist/entra-federation-configurations.js +290 -0
  28. package/dist/entra-grants.js +272 -0
  29. package/dist/entra-group-lifecycle.js +349 -0
  30. package/dist/entra-group-member-add.js +278 -0
  31. package/dist/entra-groups.js +336 -0
  32. package/dist/entra-identity-providers.js +297 -0
  33. package/dist/entra-lifecycle-workflows.js +673 -0
  34. package/dist/entra-multi-tenant-organization.js +309 -0
  35. package/dist/entra-on-premises-synchronization.js +231 -0
  36. package/dist/entra-organization.js +398 -0
  37. package/dist/entra-risk-dismiss.js +191 -0
  38. package/dist/entra-risk.js +404 -0
  39. package/dist/entra-roles.js +310 -0
  40. package/dist/entra-subscriptions.js +289 -0
  41. package/dist/entra-tenant-information.js +152 -0
  42. package/dist/entra-terms-of-use.js +380 -0
  43. package/dist/entra-user-revoke-sessions.js +170 -0
  44. package/dist/entra-user-update.js +170 -0
  45. package/dist/entra-users.js +211 -0
  46. package/dist/graph-session.js +1034 -0
  47. package/dist/msal-app-provider.js +42 -0
  48. package/dist/msal-provider.js +82 -0
  49. package/dist/mutations.js +529 -0
  50. package/dist/profiles.js +139 -0
  51. package/dist/setup.js +116 -0
  52. package/dist/version.js +2 -0
  53. package/docs/coverage.md +1028 -0
  54. package/inventory/operations.json +4 -0
  55. package/inventory/schema.json +71 -0
  56. package/package.json +32 -0
  57. package/skills/mg-axi/SKILL.md +293 -0
@@ -0,0 +1,398 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // EXT-01 organization subfamily: the read mapping behind
3
+ // `mg-axi entra organization list/show`, the default-branding read behind
4
+ // `mg-axi entra organization branding show`, and the branding-localization
5
+ // reads behind `mg-axi entra organization branding-localization
6
+ // list/show`. Operation construction stays beside its command; the shared
7
+ // session owns URLs, credentials, paging, retries and error translation,
8
+ // and the SDK owns TOON rendering. This module only maps flags to session
9
+ // calls and projects rows for compact output.
10
+ //
11
+ // Reviewed against the v1.0 organization-list, organization-get,
12
+ // organizationalbranding-get, organizationalbranding-list-localizations and
13
+ // organizationalbrandinglocalization-get operation documentation on
14
+ // 2026-10-04. Organization list/get take D User.Read for restricted basics
15
+ // (id, displayName and verifiedDomains only; every other property returns
16
+ // null) or Organization.Read.All for full metadata, and A
17
+ // Organization.Read.All; delegated callers additionally need a supported
18
+ // Entra role (Directory Readers and Global Reader are among the supported
19
+ // least-privilege roles). Branding and localization reads take D User.Read
20
+ // least-privileged (OrganizationalBranding.Read.All is the purpose-built
21
+ // delegated alternative) and A OrganizationalBranding.Read.All; delegated
22
+ // callers additionally need Global Reader or Organizational Branding
23
+ // Administrator least-privileged. Personal Microsoft accounts are not
24
+ // supported on any of these reads. No P1/P2 prerequisite is stated for the
25
+ // reads, but configuring custom branding itself needs P1/P2. Contact
26
+ // fields on the organization (businessPhones, notification mails) are
27
+ // personal data. Every reviewed read documents $select only, so none of
28
+ // these leaves offers --filter. The branding GET documents Accept-Language
29
+ // as required; the session sends `Accept-Language: 0` on that route to read
30
+ // the default branding, and locale variants come from the localizations
31
+ // collection. Stream image properties (bannerLogo, backgroundImage and
32
+ // friends) need their own binary-output contract and stay out of this
33
+ // slice, as do certificateBasedAuthConfiguration, extensions, beta-only
34
+ // settings/partnerInformation/themes and the POST lookup actions. No
35
+ // organization mutation exists in this slice.
36
+ // Every organization property this slice may request or display, matching
37
+ // the reviewed raw surface. Anything else fails before credentials.
38
+ export const KNOWN_ORGANIZATION_FIELDS = [
39
+ "id",
40
+ "deletedDateTime",
41
+ "businessPhones",
42
+ "city",
43
+ "country",
44
+ "countryLetterCode",
45
+ "createdDateTime",
46
+ "defaultUsageLocation",
47
+ "displayName",
48
+ "isMultipleDataLocationsForServicesEnabled",
49
+ "marketingNotificationEmails",
50
+ "onPremisesLastSyncDateTime",
51
+ "onPremisesSyncEnabled",
52
+ "partnerTenantType",
53
+ "postalCode",
54
+ "preferredLanguage",
55
+ "privacyProfile",
56
+ "provisionedPlans",
57
+ "securityComplianceNotificationMails",
58
+ "securityComplianceNotificationPhones",
59
+ "state",
60
+ "street",
61
+ "technicalNotificationMails",
62
+ "tenantType",
63
+ "assignedPlans",
64
+ "verifiedDomains",
65
+ ];
66
+ const KNOWN_ORGANIZATIONS = new Set(KNOWN_ORGANIZATION_FIELDS);
67
+ // Every non-Stream branding property this slice may request or display.
68
+ // Stream image properties (backgroundImage, bannerLogo, customCSS, favicon,
69
+ // headerLogo, squareLogo, squareLogoDark) are real server properties but
70
+ // need a binary-output contract this text CLI does not have; requesting one
71
+ // fails with a targeted hint instead of an unknown-property error.
72
+ export const KNOWN_BRANDING_FIELDS = [
73
+ "id",
74
+ "backgroundColor",
75
+ "backgroundImageRelativeUrl",
76
+ "bannerLogoRelativeUrl",
77
+ "cdnList",
78
+ "signInPageText",
79
+ "squareLogoRelativeUrl",
80
+ "usernameHintText",
81
+ "customAccountResetCredentialsUrl",
82
+ "customCannotAccessYourAccountText",
83
+ "customCannotAccessYourAccountUrl",
84
+ "customCSSRelativeUrl",
85
+ "customForgotMyPasswordText",
86
+ "customPrivacyAndCookiesText",
87
+ "customPrivacyAndCookiesUrl",
88
+ "customResetItNowText",
89
+ "customTermsOfUseText",
90
+ "customTermsOfUseUrl",
91
+ "faviconRelativeUrl",
92
+ "headerBackgroundColor",
93
+ "headerLogoRelativeUrl",
94
+ "squareLogoDarkRelativeUrl",
95
+ ];
96
+ const KNOWN_BRANDING = new Set(KNOWN_BRANDING_FIELDS);
97
+ const STREAM_BRANDING_FIELDS = new Set([
98
+ "backgroundImage",
99
+ "bannerLogo",
100
+ "customCSS",
101
+ "favicon",
102
+ "headerLogo",
103
+ "squareLogo",
104
+ "squareLogoDark",
105
+ ]);
106
+ // Compact organization rows: the tenant identity, display name, tenant kind
107
+ // and the verified domains that name the tenant on the network.
108
+ const DEFAULT_ORGANIZATION_LIST_SELECT = ["id", "displayName", "tenantType", "verifiedDomains"];
109
+ // Show rows: the full reviewed organization set, including the technical
110
+ // notification mails and privacy profile.
111
+ const DEFAULT_ORGANIZATION_SHOW_SELECT = [...KNOWN_ORGANIZATION_FIELDS];
112
+ // Branding show rows: the full reviewed non-Stream set.
113
+ const DEFAULT_BRANDING_SHOW_SELECT = [...KNOWN_BRANDING_FIELDS];
114
+ // Compact localization rows: the locale id and the sign-in text a user
115
+ // actually sees.
116
+ const DEFAULT_LOCALIZATION_LIST_SELECT = ["id", "signInPageText", "usernameHintText", "backgroundColor"];
117
+ // Localization show rows: the full reviewed non-Stream set.
118
+ const DEFAULT_LOCALIZATION_SHOW_SELECT = [...KNOWN_BRANDING_FIELDS];
119
+ // Delegated defaults are operation-specific; application profiles use their
120
+ // configured .default audience and reject --scopes.
121
+ export const DEFAULT_ORGANIZATION_SCOPES = ["https://graph.microsoft.com/Organization.Read.All"];
122
+ export const DEFAULT_BRANDING_SCOPES = ["https://graph.microsoft.com/User.Read"];
123
+ const TRUNCATE_AT = 500;
124
+ function fieldList(raw, known, knownList, flag, help, streamHint) {
125
+ const parts = String(raw)
126
+ .split(",")
127
+ .map(part => part.trim())
128
+ .filter(part => part.length > 0);
129
+ if (!parts.length)
130
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
131
+ const fields = [];
132
+ for (const part of parts) {
133
+ if (streamHint !== undefined && STREAM_BRANDING_FIELDS.has(part)) {
134
+ throw new AxiError(`${part} is a Stream image route served by a later piece; this read returns non-Stream branding metadata only`, "VALIDATION_ERROR", [help, streamHint]);
135
+ }
136
+ if (!known.has(part)) {
137
+ throw new AxiError(`Unknown organization property ${part} in --${flag}`, "VALIDATION_ERROR", [
138
+ help,
139
+ `Known properties: ${knownList.join(", ")}`,
140
+ ]);
141
+ }
142
+ if (!fields.includes(part))
143
+ fields.push(part);
144
+ }
145
+ return fields;
146
+ }
147
+ function scopesFor(flags, defaults, profile, help) {
148
+ if (profile.mode === "application") {
149
+ if (flags.scopes !== undefined) {
150
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
151
+ }
152
+ return undefined;
153
+ }
154
+ if (flags.scopes === undefined)
155
+ return [...defaults];
156
+ const scopes = String(flags.scopes)
157
+ .split(",")
158
+ .map(scope => scope.trim())
159
+ .filter(scope => scope.length > 0);
160
+ return scopes;
161
+ }
162
+ function truncateValue(value, full) {
163
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
164
+ ? { value: text, truncated: false }
165
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
166
+ if (typeof value === "string")
167
+ return truncate(value);
168
+ if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
169
+ const projected = value.map(truncate);
170
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
171
+ }
172
+ return { value, truncated: false };
173
+ }
174
+ // Local projection preserves Graph's null/missing distinction: an explicit
175
+ // null stays null, an absent property stays absent and is never synthesized.
176
+ // This matters for User.Read restricted basics, where every property outside
177
+ // id, displayName and verifiedDomains returns null.
178
+ function project(row, fields, full) {
179
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
180
+ const projected = {};
181
+ let truncated = false;
182
+ for (const field of fields) {
183
+ if (!Object.hasOwn(source, field))
184
+ continue;
185
+ const result = truncateValue(source[field], full);
186
+ projected[field] = result.value;
187
+ truncated = truncated || result.truncated;
188
+ }
189
+ return { row: projected, truncated };
190
+ }
191
+ function selectedFields(flags, known, knownList, defaults, help, streamHint) {
192
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, knownList, "select", help, streamHint);
193
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, knownList, "fields", help, streamHint);
194
+ const missing = fields.find(field => !select.includes(field));
195
+ if (missing) {
196
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
197
+ }
198
+ return { select, fields };
199
+ }
200
+ function profileHint(profileName) {
201
+ return `--profile ${shellValue(profileName)}`;
202
+ }
203
+ function shellValue(value) {
204
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
205
+ }
206
+ function fullHint(command, flags, profileName) {
207
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
208
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
209
+ return `mg-axi ${command} ${args.join(" ")}`;
210
+ }
211
+ // Denials carry the session's generic grant/role/licence cause; each
212
+ // operation adds the scope, roles and licensing that actually unlock it,
213
+ // because a 403 alone never says which prerequisite is missing.
214
+ function withGuidance(hints, run) {
215
+ return run().catch(error => {
216
+ if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
217
+ throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
218
+ }
219
+ throw error;
220
+ });
221
+ }
222
+ const ORGANIZATION_DENIAL_HINTS = [
223
+ "Organization reads need Organization.Read.All for full metadata plus a supported Entra role for delegated access (Directory Readers and Global Reader are among the supported least-privilege roles), or admin-consented Organization.Read.All for application access; delegated User.Read returns only id, displayName and verifiedDomains with every other property null",
224
+ "Personal Microsoft accounts are not supported for organization reads",
225
+ "No P1/P2 prerequisite is stated for organization reads; never diagnose licence solely from HTTP 403",
226
+ ];
227
+ const BRANDING_DENIAL_HINTS = [
228
+ "Branding reads need User.Read least-privileged or the purpose-built OrganizationalBranding.Read.All (Organization.Read.All also works) plus Global Reader or Organizational Branding Administrator for delegated access, or admin-consented OrganizationalBranding.Read.All for application access",
229
+ "Personal Microsoft accounts are not supported for branding reads",
230
+ "Configuring custom branding needs P1/P2; a branding 404 may indicate unconfigured branding or a missing or inaccessible organization; never diagnose licence solely from HTTP 403",
231
+ ];
232
+ const STREAM_HINT = "Stream image properties (backgroundImage, bannerLogo, customCSS, favicon, headerLogo, squareLogo, squareLogoDark) are served by a later piece with its own binary-output contract";
233
+ // Restoring the saved select keeps cursor resumes lossless when --select is
234
+ // omitted. Organization and localization lists document $select only, so
235
+ // neither leaf offers --filter: strict input validation refuses the flag
236
+ // before credentials instead of forwarding a misleading request.
237
+ function collectionCommon(session, flags, known, knownList, defaults, defaultScopes, operation, help, profile) {
238
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
239
+ if (cursor !== undefined && !cursor.trim())
240
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
241
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
242
+ const { select, fields } = selectedFields(flags, known, knownList, saved?.["$select"] === undefined ? defaults : fieldList(saved["$select"], known, knownList, "select", help), help);
243
+ return { cursor, select, fields, scopes: scopesFor(flags, defaultScopes, profile, help), full: flags.full === true };
244
+ }
245
+ function collectArgs(profile, operation, common, flags, help) {
246
+ const query = { $select: common.select.join(",") };
247
+ const args = { profile, operation, query, scopes: common.scopes };
248
+ if (common.cursor !== undefined)
249
+ args.cursor = common.cursor;
250
+ if (flags.all === true) {
251
+ if (flags.limit !== undefined)
252
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
253
+ }
254
+ else {
255
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
256
+ }
257
+ return args;
258
+ }
259
+ function resumeHint(profileName) {
260
+ return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
261
+ }
262
+ function organizationId(flags, help, flag) {
263
+ const id = String(flags[flag]);
264
+ if (!id.trim())
265
+ throw new AxiError(`--${flag} needs the tenant organization UUID`, "VALIDATION_ERROR", [help]);
266
+ return id;
267
+ }
268
+ function singleResult(raw, fields, full, kind) {
269
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
270
+ throw new AxiError(`Graph returned a malformed ${kind} body`, "GRAPH_ERROR", [
271
+ `Single-${kind} reads carry one ${kind} object; treat anything else as unknown, not empty`,
272
+ ]);
273
+ }
274
+ return project(raw, fields, full);
275
+ }
276
+ export async function listOrganizations(session, flags, profile, operation, help, profileName) {
277
+ const common = collectionCommon(session, flags, KNOWN_ORGANIZATIONS, KNOWN_ORGANIZATION_FIELDS, DEFAULT_ORGANIZATION_LIST_SELECT, DEFAULT_ORGANIZATION_SCOPES, operation, help, profile);
278
+ const result = await withGuidance(ORGANIZATION_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
279
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_ORGANIZATION_LIST_SELECT.join(",") };
280
+ const organizations = [];
281
+ let truncated = false;
282
+ for (const row of result.value) {
283
+ const projected = project(row, common.fields, common.full);
284
+ organizations.push(projected.row);
285
+ truncated = truncated || projected.truncated;
286
+ }
287
+ const showHint = `mg-axi entra organization show --id <organization-id> ${profileHint(profileName)}`;
288
+ const filterNote = "Organization lists offer no --filter: Graph documents $select only for /organization";
289
+ const truncationHints = truncated ? [fullHint("entra organization list", effectiveFlags, profileName)] : [];
290
+ if (!result.complete) {
291
+ return {
292
+ organizations,
293
+ count: { returned: organizations.length, complete: false, reason: result.reason },
294
+ cursor: result.cursor,
295
+ help: [...truncationHints, resumeHint(profileName), showHint, filterNote],
296
+ };
297
+ }
298
+ const count = { returned: organizations.length, complete: true };
299
+ if (!organizations.length) {
300
+ return {
301
+ organizations,
302
+ count,
303
+ help: [filterNote, "0 organizations matched; the absence of results is the answer, not an error"],
304
+ };
305
+ }
306
+ return { organizations, count, help: [...truncationHints, showHint, filterNote] };
307
+ }
308
+ export async function showOrganization(session, flags, profile, operation, help, profileName) {
309
+ const { select, fields } = selectedFields(flags, KNOWN_ORGANIZATIONS, KNOWN_ORGANIZATION_FIELDS, DEFAULT_ORGANIZATION_SHOW_SELECT, help);
310
+ const scopes = scopesFor(flags, DEFAULT_ORGANIZATION_SCOPES, profile, help);
311
+ const full = flags.full === true;
312
+ const raw = await withGuidance(ORGANIZATION_DENIAL_HINTS, () => session.execute({
313
+ profile,
314
+ operation,
315
+ params: { "organization-id": organizationId(flags, help, "id") },
316
+ query: { $select: select.join(",") },
317
+ scopes,
318
+ }));
319
+ const { row, truncated } = singleResult(raw, fields, full, "organization");
320
+ const helpHints = truncated ? [fullHint("entra organization show", flags, profileName)] : [];
321
+ if (helpHints.length)
322
+ return { organization: row, help: helpHints };
323
+ return { organization: row };
324
+ }
325
+ export async function showBranding(session, flags, profile, operation, help, profileName) {
326
+ const { select, fields } = selectedFields(flags, KNOWN_BRANDING, KNOWN_BRANDING_FIELDS, DEFAULT_BRANDING_SHOW_SELECT, help, STREAM_HINT);
327
+ const scopes = scopesFor(flags, DEFAULT_BRANDING_SCOPES, profile, help);
328
+ const full = flags.full === true;
329
+ const organization = organizationId(flags, help, "organization");
330
+ const raw = await withGuidance(BRANDING_DENIAL_HINTS, () => session.execute({
331
+ profile,
332
+ operation,
333
+ params: { "organization-id": organization },
334
+ query: { $select: select.join(",") },
335
+ scopes,
336
+ }));
337
+ const { row, truncated } = singleResult(raw, fields, full, "branding");
338
+ const helpHints = [
339
+ ...(truncated ? [fullHint("entra organization branding show", flags, profileName)] : []),
340
+ `mg-axi entra organization branding-localization list --organization ${shellValue(organization)} ${profileHint(profileName)}`,
341
+ ];
342
+ return { branding: row, help: helpHints };
343
+ }
344
+ export async function listBrandingLocalizations(session, flags, profile, operation, help, profileName) {
345
+ const common = collectionCommon(session, flags, KNOWN_BRANDING, KNOWN_BRANDING_FIELDS, DEFAULT_LOCALIZATION_LIST_SELECT, DEFAULT_BRANDING_SCOPES, operation, help, profile);
346
+ const args = collectArgs(profile, operation, common, flags, help);
347
+ const organization = organizationId(flags, help, "organization");
348
+ args.params = { "organization-id": organization };
349
+ const result = await withGuidance(BRANDING_DENIAL_HINTS, () => session.collect(args));
350
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LOCALIZATION_LIST_SELECT.join(",") };
351
+ const localizations = [];
352
+ let truncated = false;
353
+ for (const row of result.value) {
354
+ const projected = project(row, common.fields, common.full);
355
+ localizations.push(projected.row);
356
+ truncated = truncated || projected.truncated;
357
+ }
358
+ const showHint = `mg-axi entra organization branding-localization show --organization ${shellValue(organization)} --id <locale-id> ${profileHint(profileName)}`;
359
+ const filterNote = "Localization lists offer no --filter: Graph documents $select only for branding localizations";
360
+ const truncationHints = truncated ? [fullHint("entra organization branding-localization list", effectiveFlags, profileName)] : [];
361
+ if (!result.complete) {
362
+ return {
363
+ brandingLocalizations: localizations,
364
+ count: { returned: localizations.length, complete: false, reason: result.reason },
365
+ cursor: result.cursor,
366
+ help: [...truncationHints, resumeHint(profileName), showHint, filterNote],
367
+ };
368
+ }
369
+ const count = { returned: localizations.length, complete: true };
370
+ if (!localizations.length) {
371
+ return {
372
+ brandingLocalizations: localizations,
373
+ count,
374
+ help: [filterNote, "0 branding localizations matched; the absence of results is the answer, not an error"],
375
+ };
376
+ }
377
+ return { brandingLocalizations: localizations, count, help: [...truncationHints, showHint, filterNote] };
378
+ }
379
+ export async function showBrandingLocalization(session, flags, profile, operation, help, profileName) {
380
+ const { select, fields } = selectedFields(flags, KNOWN_BRANDING, KNOWN_BRANDING_FIELDS, DEFAULT_LOCALIZATION_SHOW_SELECT, help, STREAM_HINT);
381
+ const scopes = scopesFor(flags, DEFAULT_BRANDING_SCOPES, profile, help);
382
+ const full = flags.full === true;
383
+ const organization = organizationId(flags, help, "organization");
384
+ const locale = String(flags.id);
385
+ if (!locale.trim())
386
+ throw new AxiError("--id needs the branding locale id, for example fr-FR", "VALIDATION_ERROR", [help]);
387
+ const raw = await withGuidance(BRANDING_DENIAL_HINTS, () => session.execute({
388
+ profile,
389
+ operation,
390
+ params: { "organization-id": organization, "organizationalBrandingLocalization-id": locale },
391
+ query: { $select: select.join(",") },
392
+ scopes,
393
+ }));
394
+ const { row, truncated } = singleResult(raw, fields, full, "branding localization");
395
+ if (truncated)
396
+ return { brandingLocalization: row, help: [fullHint("entra organization branding-localization show", flags, profileName)] };
397
+ return { brandingLocalization: row };
398
+ }
@@ -0,0 +1,191 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { RISKY_USER_DENIAL_HINTS, withGuidance } from "./entra-risk.js";
3
+ // WRITE-05: dismiss the risk of one explicitly named user through an action
4
+ // write leaf (`mg-axi entra risky-user dismiss --user <risky-user-id>`), via
5
+ // POST /identityProtection/riskyUsers/dismiss with a single-element
6
+ // { userIds: [...] } body. There is no bulk form: a comma in --user is a
7
+ // usage error and unknown flags (including any plural --user-ids) fail in
8
+ // the strict catalogue before credentials. Preview reads the user's current
9
+ // risk state through the READ-06 risky-user show route; an already-dismissed
10
+ // user is a no-op. Dismissal answers 204 with an empty body, so after a send
11
+ // the command rereads the user and reports a conflict when the state is not
12
+ // dismissed, mirroring WRITE-02.
13
+ //
14
+ // Reviewed against the v1.0 riskyuser-dismiss operation documentation on
15
+ // REVIEWED_ON: POST /identityProtection/riskyUsers/dismiss carries
16
+ // { userIds: [...] } and answers 204 No Content. The least-privileged
17
+ // permission is D/A IdentityRiskyUser.ReadWrite.All; delegated callers
18
+ // additionally need Security Administrator (least-privileged role) and the
19
+ // riskyUsers API requires a Microsoft Entra ID P2 licence. Dismissal sets
20
+ // the user's risk level to none; it is not remediation - it resets no
21
+ // credential and revokes no session. Every gate in src/mutations.ts stays in
22
+ // force through the coordinator: hand-enabled profile, immutable scope,
23
+ // preview, --execute, durable journal intent/outcome, no replay,
24
+ // coordinator-only authorization and read-only enforced at send time.
25
+ export const REVIEWED_ON = "2026-10-04";
26
+ // Scope name a profile hand-enables in its writes.operations allowlist.
27
+ export const RISKY_USER_DISMISS_OPERATION = "entra.risky-user.dismiss";
28
+ export const RISKY_USER_DISMISS_WRITE_SCOPES = ["https://graph.microsoft.com/IdentityRiskyUser.ReadWrite.All"];
29
+ export const RISKY_USER_DISMISS_READ_SCOPES = ["https://graph.microsoft.com/IdentityRiskyUser.Read.All"];
30
+ const RISK_STATE_SELECT = "id,userPrincipalName,riskLevel,riskState,riskDetail,riskLastUpdatedDateTime";
31
+ const PERMISSION_GUIDANCE = "Needs D/A IdentityRiskyUser.ReadWrite.All (least-privileged per riskyuser-dismiss v1.0, rechecked 2026-10-04)";
32
+ const ROLE_GUIDANCE = "Delegated callers additionally need Security Administrator (least-privileged role for this action)";
33
+ const LICENCE_GUIDANCE = "The riskyUsers API requires a Microsoft Entra ID P2 licence; without it Graph denies the dismissal";
34
+ const REMEDIATION_NOTE = "Dismissal is not remediation: it records the user's risk as dismissed without resetting credentials or revoking sessions";
35
+ export function riskDismissDefinition(user) {
36
+ const target = user.trim();
37
+ return {
38
+ operation: RISKY_USER_DISMISS_OPERATION,
39
+ method: "POST",
40
+ version: "v1.0",
41
+ path: "/v1.0/identityProtection/riskyUsers/dismiss",
42
+ effect: "disruptive",
43
+ target,
44
+ payload: { userIds: [target] },
45
+ };
46
+ }
47
+ function parseSingleUser(raw, help) {
48
+ const user = String(raw ?? "").trim();
49
+ if (!user)
50
+ throw new AxiError("--user needs the risky-user ID to dismiss", "VALIDATION_ERROR", [help]);
51
+ if (user.includes(",")) {
52
+ throw new AxiError("--user takes exactly one risky-user ID; there is no bulk dismissal form", "VALIDATION_ERROR", [
53
+ help,
54
+ "Dismiss one user per command; each dismissal is its own audited intent",
55
+ ]);
56
+ }
57
+ return user;
58
+ }
59
+ async function readRiskState(session, profile, readOperation, user) {
60
+ const raw = await withGuidance(RISKY_USER_DENIAL_HINTS, () => session.execute({
61
+ profile,
62
+ operation: readOperation,
63
+ params: { "riskyUser-id": user },
64
+ query: { $select: RISK_STATE_SELECT },
65
+ ...(profile.mode === "application" ? {} : { scopes: [...RISKY_USER_DISMISS_READ_SCOPES] }),
66
+ }));
67
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw) && "id" in raw && typeof raw.id === "string" && raw.id.length > 0) {
68
+ const row = raw;
69
+ const id = raw.id;
70
+ return {
71
+ id,
72
+ userPrincipalName: row.userPrincipalName ?? null,
73
+ riskLevel: row.riskLevel ?? null,
74
+ riskState: row.riskState ?? null,
75
+ riskDetail: row.riskDetail ?? null,
76
+ riskLastUpdatedDateTime: row.riskLastUpdatedDateTime ?? null,
77
+ };
78
+ }
79
+ throw new AxiError("Graph returned a risky user without an object ID", "GRAPH_ERROR", [
80
+ "Read back the user with mg-axi entra risky-user show before attempting a dismissal",
81
+ ]);
82
+ }
83
+ function executeHint(user, profileName) {
84
+ const shell = (value) => /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
85
+ return `mg-axi entra risky-user dismiss --user ${shell(user)} --execute`
86
+ + ` --confirm ${shell(user)}`
87
+ + ` --profile ${shell(profileName)}`;
88
+ }
89
+ function showHint(user, profileName) {
90
+ const shell = (value) => /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
91
+ return `mg-axi entra risky-user show --id ${shell(user)} --profile ${shell(profileName)}`;
92
+ }
93
+ export async function dismissRiskyUser(args) {
94
+ const { session, coordinator, flags, profile, profileName, readOperation, help } = args;
95
+ const user = parseSingleUser(flags.user, help);
96
+ const version = String(flags["api-version"] ?? "v1.0");
97
+ if (version !== "v1.0") {
98
+ throw new AxiError(`--api-version ${version} cannot mutate: WRITE-05 binds POST /identityProtection/riskyUsers/dismiss to v1.0; beta writes stay blocked`, "VALIDATION_ERROR", [help]);
99
+ }
100
+ if (flags.scopes !== undefined) {
101
+ throw new AxiError("--scopes is not accepted: dismissal requests only the documented write scope", "VALIDATION_ERROR", [
102
+ help,
103
+ "Delegated dismissal requests IdentityRiskyUser.ReadWrite.All; application profiles use the configured Graph .default audience",
104
+ ]);
105
+ }
106
+ const requestedDefinition = riskDismissDefinition(user);
107
+ const execute = flags.execute === true;
108
+ // Gates first: preview refuses read-only, unscoped and beta-bound profiles
109
+ // before any read, credential or transport.
110
+ coordinator.preview(requestedDefinition);
111
+ if (execute) {
112
+ if (flags.confirm === undefined) {
113
+ throw new AxiError(`blocked: disruptive POST needs --confirm '${requestedDefinition.target}'`, "CONFIRM_REQUIRED", [`Re-run with --confirm '${requestedDefinition.target}'`]);
114
+ }
115
+ if (String(flags.confirm) !== requestedDefinition.target) {
116
+ throw new AxiError(`blocked: --confirm '${flags.confirm}' does not match target '${requestedDefinition.target}'`, "CONFIRM_MISMATCH", [`Re-run with --confirm '${requestedDefinition.target}'`]);
117
+ }
118
+ }
119
+ // The target is verified as a user through the READ-06 route: a failed or
120
+ // malformed read blocks the dismissal before anything is sent.
121
+ const state = await readRiskState(session, profile, readOperation, user);
122
+ const id = state.id;
123
+ const definition = { ...requestedDefinition, payload: { userIds: [id] } };
124
+ const preview = coordinator.preview(definition);
125
+ const dismissed = state.riskState === "dismissed";
126
+ const view = {
127
+ operation: definition.operation,
128
+ target: definition.target,
129
+ method: definition.method,
130
+ url: preview.url,
131
+ effect: definition.effect,
132
+ current: state,
133
+ desired: "dismissed",
134
+ noop: dismissed,
135
+ };
136
+ if (!execute) {
137
+ return {
138
+ preview: view,
139
+ help: [
140
+ dismissed
141
+ ? "Already dismissed; no dismissal is needed"
142
+ : executeHint(user, profileName),
143
+ ...(!dismissed
144
+ ? ["Dismissing risk is disruptive: the --execute run needs the typed --confirm value shown above"]
145
+ : []),
146
+ REMEDIATION_NOTE,
147
+ LICENCE_GUIDANCE,
148
+ ],
149
+ };
150
+ }
151
+ const result = await coordinator.execute(definition, {
152
+ execute: true,
153
+ confirm: String(flags.confirm),
154
+ ...(profile.mode === "application" ? {} : { scopes: [...RISKY_USER_DISMISS_WRITE_SCOPES] }),
155
+ readState: async () => (await readRiskState(session, profile, readOperation, id)).riskState,
156
+ isNoop: currentState => currentState === "dismissed",
157
+ });
158
+ if (result.kind === "failed") {
159
+ throw new AxiError(`Graph refused the risk dismissal (status ${result.status})`, "GRAPH_ERROR", [
160
+ ...(result.status === 403 ? [PERMISSION_GUIDANCE, ROLE_GUIDANCE, LICENCE_GUIDANCE] : []),
161
+ `Audit ${result.auditId} recorded the refusal; read back '${id}' and never replay this intent`,
162
+ ]);
163
+ }
164
+ if (result.kind === "unknown") {
165
+ throw new AxiError(`Dismissal of '${id}' may or may not have been applied (audit ${result.auditId}); never replay this intent`, "OUTCOME_UNKNOWN", [
166
+ `Audit ${result.auditId} recorded the uncertain outcome; read back '${id}' with ${showHint(id, profileName)} before doing anything else`,
167
+ "Never replay this intent",
168
+ ]);
169
+ }
170
+ // Anything but success, failure or uncertainty is the verified no-op:
171
+ // dry-run needs execute !== true, always set above.
172
+ if (result.kind !== "success") {
173
+ return { noop: true, dismissal: { user: { id, riskState: "dismissed" } } };
174
+ }
175
+ // Success carries no proof: dismissal answers 204 with an empty body, so
176
+ // only the reread decides between dismissed and conflict.
177
+ let verified = null;
178
+ try {
179
+ verified = await readRiskState(session, profile, readOperation, id);
180
+ }
181
+ catch (error) {
182
+ throw new AxiError(`Dismissal of '${user}' was sent (audit ${result.auditId}) but the verification read failed; the outcome is unknown`, "OUTCOME_UNKNOWN", [
183
+ ...(error instanceof AxiError ? error.suggestions : []),
184
+ `Read back '${id}' before doing anything else; never replay this intent`,
185
+ ]);
186
+ }
187
+ if (verified.riskState !== "dismissed") {
188
+ throw new AxiError(`Dismissal of '${user}' conflicts: the reread shows riskState ${JSON.stringify(verified.riskState)} instead of dismissed (audit ${result.auditId})`, "WRITE_CONFLICT", [`Read back '${id}' with ${showHint(id, profileName)} before doing anything else`, "Never replay this intent"]);
189
+ }
190
+ return { dismissal: { user: verified }, auditId: result.auditId };
191
+ }