@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,353 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { encodeGraphPathSegment, redactGraphValue, REDACTED } from "./graph-session.js";
3
+ // WRITE-04 usage, reviewed fields, permissions and lockout requirements are
4
+ // owned by README.md; shared execution gates are owned by docs/execution.md.
5
+ // Reviewed against v1.0 conditionalaccesspolicy-update on REVIEWED_ON:
6
+ // Graph documents no ETag or If-Match precondition, so fresh reads cannot
7
+ // make the PATCH atomic. Its 204 response contains no policy body; the
8
+ // coordinator records the outcome, and callers must verify with policy show.
9
+ export const REVIEWED_ON = "2026-10-04";
10
+ // Scope name a profile hand-enables in its writes.operations allowlist.
11
+ export const CA_POLICY_UPDATE_OPERATION = "entra.conditional-access.policy.update";
12
+ export const CA_POLICY_UPDATE_SCOPES = [
13
+ "https://graph.microsoft.com/Policy.Read.All",
14
+ "https://graph.microsoft.com/Policy.ReadWrite.ConditionalAccess",
15
+ ];
16
+ export const CA_POLICY_UPDATE_READ_SCOPES = ["https://graph.microsoft.com/Policy.Read.All"];
17
+ // The only top-level policy properties this slice may send. Anything else
18
+ // (id, createdDateTime, modifiedDateTime, ...) has no flag and can never
19
+ // reach the PATCH body.
20
+ export const CA_POLICY_WRITABLE_FIELDS = ["displayName", "state", "conditions", "grantControls", "sessionControls"];
21
+ export const CA_POLICY_STATES = ["enabled", "enabledForReportingButNotEnforced", "disabled"];
22
+ const FLAG_TO_FIELD = {
23
+ "display-name": "displayName",
24
+ "state": "state",
25
+ "conditions": "conditions",
26
+ "grant-controls": "grantControls",
27
+ "session-controls": "sessionControls",
28
+ };
29
+ const ENFORCEMENT_FIELDS = ["state", "conditions", "grantControls", "sessionControls"];
30
+ const PERMISSION_GUIDANCE = "Needs D/A Policy.Read.All plus Policy.ReadWrite.ConditionalAccess (least-privileged pair per conditionalaccesspolicy-update v1.0, rechecked 2026-10-04)";
31
+ const ROLE_GUIDANCE = "Delegated callers additionally need Conditional Access Administrator or Security Administrator; CA needs P1, risk-based CA needs P2";
32
+ const CONCURRENCY_NOTE = "Graph documents no ETag or If-Match precondition for this endpoint, so fresh reads do not make the update atomic: another actor can change the policy between the read and the PATCH. The command sends no If-Match and promises no concurrency protection";
33
+ function canonicalize(value) {
34
+ if (Array.isArray(value))
35
+ return value.map(canonicalize);
36
+ if (value !== null && typeof value === "object") {
37
+ return Object.fromEntries(Object.keys(value)
38
+ .sort()
39
+ .map(key => [key, canonicalize(value[key])]));
40
+ }
41
+ return value;
42
+ }
43
+ function containsRedactedValue(value) {
44
+ return value === REDACTED || (value !== null && typeof value === "object"
45
+ && Object.values(value).some(containsRedactedValue));
46
+ }
47
+ function sameValue(current, proposed) {
48
+ return !containsRedactedValue(current)
49
+ && JSON.stringify(canonicalize(current)) === JSON.stringify(canonicalize(proposed));
50
+ }
51
+ function parseJsonObject(raw, flag, help) {
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(raw);
55
+ }
56
+ catch {
57
+ throw new AxiError(`--${flag} must be a JSON object`, "VALIDATION_ERROR", [help]);
58
+ }
59
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
60
+ throw new AxiError(`--${flag} must be a JSON object`, "VALIDATION_ERROR", [help]);
61
+ }
62
+ return parsed;
63
+ }
64
+ // Maps exactly the reviewed-field flags to PATCH properties. Unreviewed
65
+ // Graph properties have no flag, so resolveCommand already refuses them as
66
+ // unknown flags before credentials; this parser additionally refuses empty,
67
+ // mistyped and unparsable reviewed values.
68
+ export function parsePolicyUpdate(raw, help) {
69
+ const payload = {};
70
+ for (const [flag, field] of Object.entries(FLAG_TO_FIELD)) {
71
+ const value = raw[flag];
72
+ if (value === undefined)
73
+ continue;
74
+ const text = String(value);
75
+ if (field === "displayName") {
76
+ if (!text.trim())
77
+ throw new AxiError("--display-name needs a non-empty policy name", "VALIDATION_ERROR", [help]);
78
+ payload[field] = text;
79
+ }
80
+ else if (field === "state") {
81
+ if (!CA_POLICY_STATES.includes(text)) {
82
+ throw new AxiError(`--state must be ${CA_POLICY_STATES.join("|")}`, "VALIDATION_ERROR", [help]);
83
+ }
84
+ payload[field] = text;
85
+ }
86
+ else {
87
+ payload[field] = parseJsonObject(text, flag, help);
88
+ }
89
+ }
90
+ if (Object.keys(payload).length === 0) {
91
+ throw new AxiError("Policy update needs at least one reviewed field: --display-name, --state, --conditions, --grant-controls or --session-controls", "VALIDATION_ERROR", [help]);
92
+ }
93
+ return payload;
94
+ }
95
+ export function caPolicyDefinition(policyId, payload) {
96
+ const target = policyId.trim();
97
+ return {
98
+ operation: CA_POLICY_UPDATE_OPERATION,
99
+ method: "PATCH",
100
+ version: "v1.0",
101
+ path: `/v1.0/identity/conditionalAccess/policies/${encodeGraphPathSegment(target)}`,
102
+ effect: "disruptive",
103
+ target,
104
+ payload: { ...payload },
105
+ };
106
+ }
107
+ async function readPolicy(session, profile, readOperation, policyId) {
108
+ const raw = await session.execute({
109
+ profile,
110
+ operation: readOperation,
111
+ params: { "conditionalAccessPolicy-id": policyId },
112
+ query: { $select: "id,displayName,state,conditions,grantControls,sessionControls" },
113
+ ...(profile.mode === "application" ? {} : { scopes: [...CA_POLICY_UPDATE_READ_SCOPES] }),
114
+ });
115
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)
116
+ && typeof raw["id"] === "string" && raw["id"].length > 0) {
117
+ return raw;
118
+ }
119
+ throw new AxiError("Graph returned a policy without an object ID", "GRAPH_ERROR", [
120
+ "Read back the policy before attempting an update",
121
+ ]);
122
+ }
123
+ function stringArray(value) {
124
+ return Array.isArray(value) && value.every(entry => typeof entry === "string") ? [...value] : null;
125
+ }
126
+ export function analyzeLockout(current, payload) {
127
+ const effective = { ...current, ...payload };
128
+ const state = effective["state"];
129
+ if (typeof state !== "string") {
130
+ return { available: false, reason: "policy state is unreadable, so lockout risk cannot be assessed" };
131
+ }
132
+ if (state !== "enabled") {
133
+ return { available: true, level: "none", findings: [`policy state '${state}' does not enforce access`] };
134
+ }
135
+ const conditions = effective["conditions"];
136
+ if (conditions === null || typeof conditions !== "object" || Array.isArray(conditions)) {
137
+ return { available: false, reason: "policy conditions are unreadable, so lockout risk cannot be assessed" };
138
+ }
139
+ const users = conditions["users"];
140
+ if (users === null || typeof users !== "object" || Array.isArray(users)) {
141
+ return { available: false, reason: "policy user scope is unreadable, so lockout risk cannot be assessed" };
142
+ }
143
+ const includeUsers = stringArray(users["includeUsers"]);
144
+ if (includeUsers === null) {
145
+ return { available: false, reason: "policy user scope is unreadable, so lockout risk cannot be assessed" };
146
+ }
147
+ const excludeUsers = stringArray(users["excludeUsers"]);
148
+ const excludeGroups = stringArray(users["excludeGroups"]);
149
+ if (excludeUsers === null || excludeGroups === null) {
150
+ return { available: false, reason: "policy exclusions are unreadable, so lockout risk cannot be assessed" };
151
+ }
152
+ for (const field of ["includeGroups", "includeRoles"]) {
153
+ const entries = users[field];
154
+ if (entries !== undefined && stringArray(entries) === null) {
155
+ return { available: false, reason: "policy user scope is unreadable, so lockout risk cannot be assessed" };
156
+ }
157
+ }
158
+ const roles = users["excludeRoles"];
159
+ const excludeRoles = roles === undefined ? [] : stringArray(roles);
160
+ if (excludeRoles === null) {
161
+ return { available: false, reason: "policy exclusions are unreadable, so lockout risk cannot be assessed" };
162
+ }
163
+ const grantControls = effective["grantControls"];
164
+ if (grantControls === null || typeof grantControls !== "object" || Array.isArray(grantControls)) {
165
+ return { available: false, reason: "policy grant controls are unreadable, so lockout risk cannot be assessed" };
166
+ }
167
+ const builtIn = grantControls["builtInControls"];
168
+ const controls = builtIn === undefined ? [] : stringArray(builtIn);
169
+ if (controls === null) {
170
+ return { available: false, reason: "policy grant controls are unreadable, so lockout risk cannot be assessed" };
171
+ }
172
+ const clients = stringArray(conditions["clientAppTypes"]);
173
+ const applications = conditions["applications"];
174
+ if (clients === null || applications === null || typeof applications !== "object" || Array.isArray(applications)) {
175
+ return { available: false, reason: "policy client or application scope is unreadable, so lockout risk cannot be assessed" };
176
+ }
177
+ const includedApps = stringArray(applications["includeApplications"]);
178
+ const excludedApps = stringArray(applications["excludeApplications"]);
179
+ if (includedApps === null || excludedApps === null) {
180
+ return { available: false, reason: "policy application inclusions or exclusions are unreadable, so lockout risk cannot be assessed" };
181
+ }
182
+ const coversAdminClients = clients.includes("all")
183
+ || (clients.includes("browser") && clients.includes("mobileAppsAndDesktopClients"));
184
+ const coversAdminApps = (includedApps.includes("All") || includedApps.includes("MicrosoftAdminPortals"))
185
+ && !excludedApps.includes("All") && !excludedApps.includes("MicrosoftAdminPortals");
186
+ let narrowedConditions = false;
187
+ for (const field of ["signInRiskLevels", "userRiskLevels"]) {
188
+ const levels = stringArray(conditions[field]);
189
+ if (levels !== null && levels.length > 0
190
+ && levels.every(level => ["low", "medium", "high"].includes(level)))
191
+ narrowedConditions = true;
192
+ }
193
+ for (const [field, includeField, excludeField, all] of [
194
+ ["platforms", "includePlatforms", "excludePlatforms", "all"],
195
+ ["locations", "includeLocations", "excludeLocations", "All"],
196
+ ]) {
197
+ const scope = conditions[field];
198
+ if (scope == null)
199
+ continue;
200
+ if (typeof scope !== "object" || Array.isArray(scope)) {
201
+ return { available: false, reason: `policy ${field} scope is unreadable, so lockout risk cannot be assessed` };
202
+ }
203
+ const included = stringArray(scope[includeField]);
204
+ const excluded = stringArray(scope[excludeField]);
205
+ if (included === null || excluded === null) {
206
+ return { available: false, reason: `policy ${field} inclusions or exclusions are unreadable, so lockout risk cannot be assessed` };
207
+ }
208
+ if (!included.includes(all) || excluded.length > 0)
209
+ narrowedConditions = true;
210
+ }
211
+ if (includeUsers.includes("All") && controls.includes("block")
212
+ && coversAdminClients && coversAdminApps && !narrowedConditions) {
213
+ return {
214
+ available: true,
215
+ level: "refused",
216
+ findings: ["enabled all-users block policy covers browser and modern clients on all cloud apps or admin portals; no verified exclusion protects admins and break-glass access, so full lockout is refused"],
217
+ };
218
+ }
219
+ return {
220
+ available: true,
221
+ level: "elevated",
222
+ findings: [!coversAdminClients || !coversAdminApps || narrowedConditions
223
+ ? "scoped warning: client, application or other conditions limit coverage; this policy may still affect admins and break-glass access and requires acknowledgement"
224
+ : "enabled policy may affect admins and break-glass access; protected-account coverage cannot be established from policy targeting alone"],
225
+ };
226
+ }
227
+ function touchesEnforcement(payload) {
228
+ return ENFORCEMENT_FIELDS.some(field => Object.hasOwn(payload, field));
229
+ }
230
+ function executeHint(policyId, elevated, profileName) {
231
+ const shell = (value) => /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
232
+ return `mg-axi entra conditional-access policy update --id ${shell(policyId)} <reviewed field flags>`
233
+ + ` --execute --confirm ${shell(policyId)}${elevated ? " --acknowledge-lockout-risk" : ""}`
234
+ + ` --profile ${shell(profileName)}`;
235
+ }
236
+ function showHint(policyId, profileName) {
237
+ const shell = (value) => /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
238
+ return `mg-axi entra conditional-access policy show --id ${shell(policyId)} --profile ${shell(profileName)}`;
239
+ }
240
+ function assertLockoutGates(payload, assessment, acknowledged) {
241
+ if (!touchesEnforcement(payload))
242
+ return;
243
+ if (!assessment.available) {
244
+ throw new AxiError(`Refusing policy update: ${assessment.reason}; the operation stays disabled without lockout analysis`, "OPERATION_BLOCKED", ["Read back the policy and retry only when its conditions, user scope and grant controls are verifiable"]);
245
+ }
246
+ if (assessment.level === "refused") {
247
+ throw new AxiError(`Refusing policy update: ${assessment.findings.join("; ")}`, "OPERATION_BLOCKED", [
248
+ "Narrow user, client or application conditions, or replace the block control before retrying",
249
+ "This refusal cannot be overridden with an acknowledgement flag",
250
+ ]);
251
+ }
252
+ if (assessment.level === "elevated" && !acknowledged) {
253
+ throw new AxiError("Policy update needs --acknowledge-lockout-risk: protected-account coverage cannot be established from policy targeting alone", "LOCKOUT_ACK_REQUIRED", ["Re-run with --acknowledge-lockout-risk after confirming emergency-access coverage"]);
254
+ }
255
+ }
256
+ export async function updateCaPolicy(args) {
257
+ const { session, coordinator, flags, profile, profileName, readOperation, help } = args;
258
+ const policyId = String(flags.id ?? "").trim();
259
+ if (!policyId)
260
+ throw new AxiError("--id needs a Conditional Access policy object ID", "VALIDATION_ERROR", [help]);
261
+ const version = String(flags["api-version"] ?? "v1.0");
262
+ if (version !== "v1.0") {
263
+ throw new AxiError(`--api-version ${version} cannot mutate: WRITE-04 binds PATCH /identity/conditionalAccess/policies/{id} to v1.0; beta writes stay blocked`, "VALIDATION_ERROR", [help]);
264
+ }
265
+ const payload = parsePolicyUpdate(flags, help);
266
+ const acknowledged = flags["acknowledge-lockout-risk"] === true;
267
+ const definition = caPolicyDefinition(policyId, payload);
268
+ const execute = flags.execute === true;
269
+ // Gates first: preview refuses read-only, unscoped and beta-bound profiles
270
+ // before any read, credential or transport.
271
+ coordinator.preview(definition);
272
+ if (execute) {
273
+ if (flags.confirm === undefined) {
274
+ throw new AxiError(`blocked: disruptive PATCH needs --confirm '${definition.target}'`, "CONFIRM_REQUIRED", [`Re-run with --confirm '${definition.target}'`]);
275
+ }
276
+ if (String(flags.confirm) !== definition.target) {
277
+ throw new AxiError(`blocked: --confirm '${flags.confirm}' does not match target '${definition.target}'`, "CONFIRM_MISMATCH", [`Re-run with --confirm '${definition.target}'`]);
278
+ }
279
+ }
280
+ const current = await readPolicy(session, profile, readOperation, policyId);
281
+ const changes = Object.keys(payload).map(field => ({
282
+ field,
283
+ current: redactGraphValue(Object.hasOwn(current, field) ? current[field] : null),
284
+ proposed: redactGraphValue(payload[field]),
285
+ }));
286
+ // A missing property reads as absent, never as null: only an explicitly
287
+ // stored equal value counts as already-desired.
288
+ const noop = Object.keys(payload).every(field => sameValue(Object.hasOwn(current, field) ? current[field] : undefined, payload[field]));
289
+ const assessment = analyzeLockout(current, payload);
290
+ const previewUrl = coordinator.preview(definition).url;
291
+ const preview = {
292
+ operation: definition.operation,
293
+ target: definition.target,
294
+ method: definition.method,
295
+ url: previewUrl,
296
+ effect: definition.effect,
297
+ changes,
298
+ noop,
299
+ lockout: assessment,
300
+ concurrency: CONCURRENCY_NOTE,
301
+ };
302
+ if (!execute) {
303
+ const elevated = assessment.available && assessment.level === "elevated";
304
+ return {
305
+ preview,
306
+ help: [
307
+ ...(noop
308
+ ? ["Already in the desired state; no update is needed"]
309
+ : [executeHint(policyId, elevated, profileName)]),
310
+ ...(!noop
311
+ ? ["Policy updates are disruptive: the --execute run needs the typed --confirm value shown above"]
312
+ : []),
313
+ ...(elevated && !noop ? ["Unverified admin and break-glass coverage needs --acknowledge-lockout-risk on the --execute run"] : []),
314
+ showHint(policyId, profileName),
315
+ ],
316
+ };
317
+ }
318
+ if (noop)
319
+ return { noop: true, policy: { id: current["id"] } };
320
+ assertLockoutGates(payload, assessment, acknowledged);
321
+ const readState = async () => {
322
+ const fresh = await readPolicy(session, profile, readOperation, policyId);
323
+ assertLockoutGates(payload, analyzeLockout(fresh, payload), acknowledged);
324
+ return fresh;
325
+ };
326
+ const result = await coordinator.execute(definition, {
327
+ execute: true,
328
+ confirm: String(flags.confirm),
329
+ ...(profile.mode === "application" ? {} : { scopes: [...CA_POLICY_UPDATE_SCOPES] }),
330
+ readState,
331
+ isNoop: fresh => Object.keys(payload).every(field => sameValue(fresh[field], payload[field])),
332
+ });
333
+ if (result.kind === "failed") {
334
+ throw new AxiError(`Graph refused the policy update (status ${result.status})`, "GRAPH_ERROR", [
335
+ ...(result.status === 403 ? [PERMISSION_GUIDANCE, ROLE_GUIDANCE] : []),
336
+ `Audit ${result.auditId} recorded the refusal; read back '${policyId}' and never replay this intent`,
337
+ ]);
338
+ }
339
+ if (result.kind === "unknown") {
340
+ throw new AxiError(`Update of '${policyId}' may or may not have been applied (audit ${result.auditId}); never replay this intent`, "OUTCOME_UNKNOWN", [
341
+ `Audit ${result.auditId} recorded the uncertain outcome; read back '${policyId}' with ${showHint(policyId, profileName)} before doing anything else`,
342
+ "Never replay this intent",
343
+ ]);
344
+ }
345
+ // Anything but success, failure or uncertainty is the verified no-op:
346
+ // dry-run needs execute !== true, always set above.
347
+ if (result.kind !== "success") {
348
+ return { noop: true, policy: { id: current["id"] } };
349
+ }
350
+ // Success carries no proof: policy-update answers 204 with an empty body.
351
+ // The coordinator outcome is the only record; verify with policy show.
352
+ return { policy: { id: current["id"] }, auditId: result.auditId, help: [showHint(policyId, profileName)] };
353
+ }