@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,170 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { encodeGraphPathSegment } from "./graph-session.js";
3
+ // WRITE-03: revoke one user's sign-in sessions through an action write leaf
4
+ // (`mg-axi entra user revoke-sessions --user <id>`), via
5
+ // POST /users/{id}/revokeSignInSessions with no request body. Graph resets
6
+ // the user's signInSessionsValidFromDateTime, invalidating issued refresh
7
+ // tokens and browser session cookies so the user must sign in again, and
8
+ // normally answers 2xx with {"value": true}; acceptance is based on status,
9
+ // not response-body validation, so
10
+ // unlike the 204 account update there is no verification reread.
11
+ //
12
+ // Reviewed against the v1.0 user-revokeSignInSessions operation
13
+ // documentation on REVIEWED_ON. The least-privileged permission is
14
+ // User.RevokeSessions.All in both delegated and application modes; the
15
+ // documentation names no administrator-role prerequisite for this action, so
16
+ // denial guidance names only the action permission. This is an action, not a
17
+ // desired-state write: the preview says what will happen and that it cannot
18
+ // be undone (there is no rollback), never a state diff. Two Microsoft-stated
19
+ // limits ride along in the preview: token revocation can lag a few minutes
20
+ // after the call returns, and external users are unaffected because they
21
+ // sign in through their home tenant. A timeout or 5xx after send is
22
+ // OUTCOME_UNKNOWN with no automatic replay. Every gate in src/mutations.ts
23
+ // stays in force through the coordinator: hand-enabled profile, immutable
24
+ // scope, preview, --execute, typed confirmation, durable journal
25
+ // intent/outcome, no replay, coordinator-only authorization and read-only
26
+ // enforced at send time.
27
+ export const REVIEWED_ON = "2026-10-04";
28
+ // Scope name a profile hand-enables in its writes.operations allowlist.
29
+ export const USER_REVOKE_SESSIONS_OPERATION = "entra.user.revokeSessions";
30
+ export const USER_REVOKE_SESSIONS_SCOPES = [
31
+ "https://graph.microsoft.com/User.RevokeSessions.All",
32
+ ];
33
+ export const USER_REVOKE_READ_SCOPES = ["https://graph.microsoft.com/User.ReadBasic.All"];
34
+ const PERMISSION_GUIDANCE = "Needs D/A User.RevokeSessions.All (least-privileged per user-revokeSignInSessions v1.0, rechecked 2026-10-04)";
35
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
36
+ export function userRevokeSessionsDefinition(user) {
37
+ const target = user.trim();
38
+ return {
39
+ operation: USER_REVOKE_SESSIONS_OPERATION,
40
+ method: "POST",
41
+ version: "v1.0",
42
+ path: `/v1.0/users/${encodeGraphPathSegment(target)}/revokeSignInSessions`,
43
+ effect: "disruptive",
44
+ target,
45
+ };
46
+ }
47
+ // The target must be an accessible user before preview and again before the
48
+ // single send; anything else blocks the operation. Returns the bound object
49
+ // ID for the mutation path.
50
+ async function verifyUserTarget(session, profile, readOperation, user) {
51
+ const raw = await session.execute({
52
+ profile,
53
+ operation: readOperation,
54
+ params: { "user-id": user },
55
+ query: { $select: "id" },
56
+ ...(profile.mode === "application" ? {} : { scopes: [...USER_REVOKE_READ_SCOPES] }),
57
+ });
58
+ const row = raw;
59
+ if (row === null || typeof row !== "object" || Array.isArray(row)
60
+ || typeof row["id"] !== "string" || row["id"].toLowerCase() !== user.toLowerCase()
61
+ || (row["@odata.type"] !== undefined && row["@odata.type"] !== "#microsoft.graph.user")) {
62
+ throw new AxiError(`Graph did not establish user identity for ${user}`, "GRAPH_ERROR", [
63
+ "Verify --user is an accessible user object ID; non-user objects have no sessions to revoke",
64
+ ]);
65
+ }
66
+ return row["id"];
67
+ }
68
+ function executeHint(user, profileName) {
69
+ const shell = (value) => /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
70
+ return `mg-axi entra user revoke-sessions --user ${shell(user)} --execute`
71
+ + ` --confirm ${shell(user)}`
72
+ + ` --profile ${shell(profileName)}`;
73
+ }
74
+ function showHint(user, profileName, profile) {
75
+ const shell = (value) => /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
76
+ return `mg-axi entra user show --id ${shell(user)} --profile ${shell(profileName)} --select id`
77
+ + (profile.mode === "application" ? "" : ` --scopes ${USER_REVOKE_READ_SCOPES.join(",")}`);
78
+ }
79
+ const ACTION_TEXT = "Revoke every sign-in session for the target user: Graph resets signInSessionsValidFromDateTime, "
80
+ + "invalidating issued refresh tokens and browser session cookies, and the user must sign in again to every consented application.";
81
+ const LIMITATIONS = [
82
+ "Token revocation can lag a few minutes after the call returns; a 2xx response does not promise immediate universal session termination",
83
+ "External users are unaffected: they sign in through their home tenant, which this call cannot revoke",
84
+ "This action cannot be undone; there is no rollback",
85
+ ];
86
+ export async function revokeUserSessions(args) {
87
+ const { session, coordinator, flags, profile, profileName, readOperation, help } = args;
88
+ const user = String(flags.user ?? "").trim();
89
+ if (!user)
90
+ throw new AxiError("--user needs the user object ID", "VALIDATION_ERROR", [help]);
91
+ if (!GUID.test(user)) {
92
+ throw new AxiError(`--user ${user} is not a user object ID`, "VALIDATION_ERROR", [
93
+ help,
94
+ "Pass the user object ID (GUID); UPNs are not resolved by this command",
95
+ ]);
96
+ }
97
+ const version = String(flags["api-version"] ?? "v1.0");
98
+ if (version !== "v1.0") {
99
+ throw new AxiError(`--api-version ${version} cannot mutate: WRITE-03 binds POST /users/{id}/revokeSignInSessions to v1.0; beta writes stay blocked`, "VALIDATION_ERROR", [help]);
100
+ }
101
+ if (flags.scopes !== undefined) {
102
+ throw new AxiError("This command requests only its documented write scope; caller-supplied --scopes is unavailable", "VALIDATION_ERROR", [help, "Delegated revocation requests https://graph.microsoft.com/User.RevokeSessions.All"]);
103
+ }
104
+ const requestedDefinition = userRevokeSessionsDefinition(user);
105
+ const execute = flags.execute === true;
106
+ // Gates first: preview refuses read-only, unscoped and beta-bound profiles
107
+ // before any read, credential or transport.
108
+ coordinator.preview(requestedDefinition);
109
+ if (execute) {
110
+ if (flags.confirm === undefined) {
111
+ throw new AxiError(`blocked: disruptive POST needs --confirm '${requestedDefinition.target}'`, "CONFIRM_REQUIRED", [`Re-run with --confirm '${requestedDefinition.target}'`]);
112
+ }
113
+ if (String(flags.confirm) !== requestedDefinition.target) {
114
+ throw new AxiError(`blocked: --confirm '${flags.confirm}' does not match target '${requestedDefinition.target}'`, "CONFIRM_MISMATCH", [`Re-run with --confirm '${requestedDefinition.target}'`]);
115
+ }
116
+ }
117
+ const id = await verifyUserTarget(session, profile, readOperation, user);
118
+ const definition = { ...requestedDefinition, path: `/v1.0/users/${encodeGraphPathSegment(id)}/revokeSignInSessions` };
119
+ const preview = coordinator.preview(definition);
120
+ const state = {
121
+ operation: definition.operation,
122
+ target: definition.target,
123
+ method: definition.method,
124
+ url: preview.url,
125
+ effect: definition.effect,
126
+ action: ACTION_TEXT,
127
+ reversible: false,
128
+ limitations: [...LIMITATIONS],
129
+ noop: false,
130
+ };
131
+ if (!execute) {
132
+ return {
133
+ preview: state,
134
+ help: [
135
+ executeHint(user, profileName),
136
+ `Revoking sessions is disruptive: the --execute run needs the typed --confirm value shown above`,
137
+ showHint(user, profileName, profile),
138
+ ],
139
+ };
140
+ }
141
+ const result = await coordinator.execute(definition, {
142
+ execute: true,
143
+ confirm: String(flags.confirm),
144
+ ...(profile.mode === "application" ? {} : { scopes: [...USER_REVOKE_SESSIONS_SCOPES] }),
145
+ // An action always sends: no desired state exists to satisfy early, and
146
+ // the fresh read below re-verifies the user before the single send.
147
+ readState: async () => verifyUserTarget(session, profile, readOperation, id),
148
+ });
149
+ if (result.kind === "failed") {
150
+ throw new AxiError(`Graph refused the session revocation (status ${result.status})`, "GRAPH_ERROR", [
151
+ ...(result.status === 403 ? [PERMISSION_GUIDANCE] : []),
152
+ `Audit ${result.auditId} recorded the refusal; read back '${id}' and never replay this intent`,
153
+ ]);
154
+ }
155
+ if (result.kind === "unknown") {
156
+ throw new AxiError(`Revocation for '${id}' may or may not have been applied (audit ${result.auditId}); never replay this intent`, "OUTCOME_UNKNOWN", [
157
+ `Audit ${result.auditId} recorded the uncertain outcome; read back '${id}' with ${showHint(id, profileName, profile)} before doing anything else`,
158
+ "Never replay this intent",
159
+ ]);
160
+ }
161
+ if (result.kind !== "success") {
162
+ // Unreachable: execute is always true above (no dry-run) and no
163
+ // desired-state check exists to report a no-op. Fail closed rather than
164
+ // inventing an outcome.
165
+ throw new AxiError(`Revocation for '${id}' returned an unexpected outcome; read back the target before doing anything else`, "OUTCOME_UNKNOWN", [
166
+ "Never replay this intent",
167
+ ]);
168
+ }
169
+ return { user: { id, sessionsRevoked: true }, auditId: result.auditId };
170
+ }
@@ -0,0 +1,170 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { encodeGraphPathSegment } from "./graph-session.js";
3
+ // WRITE-02: enable or disable one user's account through a desired-state
4
+ // write leaf (`mg-axi entra user update --user <id> --account-enabled
5
+ // true|false`), via PATCH /users/{id} with only { accountEnabled } sent.
6
+ // Preview reads the current value through the READ-01 user route and shows
7
+ // the diff; an already-desired value is a no-op; after the PATCH a reread
8
+ // reports a conflict when the value is not what was sent.
9
+ //
10
+ // Reviewed against the v1.0 user-update operation documentation on
11
+ // REVIEWED_ON: PATCH /users/{id | userPrincipalName} carries only the
12
+ // properties to update and answers 204 No Content, so the reread is the only
13
+ // proof the change landed. The least-privileged pair for accountEnabled is
14
+ // User.EnableDisableAccount.All plus User.Read.All in both delegated and
15
+ // application modes. Sensitive-target hierarchy: delegated callers need
16
+ // Privileged Authentication Administrator for admin targets and generally
17
+ // must hold a higher-privileged administrator role than the target; app-only
18
+ // callers need the pair plus a higher-privileged admin role assignment (Who
19
+ // can perform sensitive actions). Graph enforces that hierarchy and a 403
20
+ // never says which prerequisite is missing, so this module surfaces the pair
21
+ // and the hierarchy as denial guidance and never invents a local role
22
+ // verdict. Every gate in src/mutations.ts stays in force through the
23
+ // coordinator: hand-enabled profile, immutable scope, preview, --execute,
24
+ // durable journal intent/outcome, no replay, coordinator-only authorization
25
+ // and read-only enforced at send time.
26
+ export const REVIEWED_ON = "2026-10-04";
27
+ // Scope name a profile hand-enables in its writes.operations allowlist.
28
+ export const USER_ACCOUNT_UPDATE_OPERATION = "entra.user.update";
29
+ export const USER_ACCOUNT_WRITE_SCOPES = [
30
+ "https://graph.microsoft.com/User.EnableDisableAccount.All",
31
+ "https://graph.microsoft.com/User.Read.All",
32
+ ];
33
+ export const USER_ACCOUNT_READ_SCOPES = ["https://graph.microsoft.com/User.Read.All"];
34
+ const PERMISSION_GUIDANCE = "Needs D/A User.EnableDisableAccount.All plus User.Read.All for accountEnabled (least-privileged pair per user-update v1.0, rechecked 2026-10-04)";
35
+ const ROLE_GUIDANCE = "Sensitive-target hierarchy: delegated callers need Privileged Authentication Administrator for admin targets and must generally outrank the target; app-only callers need the pair plus a higher-privileged admin role assignment (Who can perform sensitive actions)";
36
+ export function parseAccountEnabled(raw, help) {
37
+ if (raw === "true")
38
+ return true;
39
+ if (raw === "false")
40
+ return false;
41
+ throw new AxiError("--account-enabled must be true or false", "VALIDATION_ERROR", [help]);
42
+ }
43
+ export function userAccountDefinition(user, desired) {
44
+ const target = user.trim();
45
+ return {
46
+ operation: USER_ACCOUNT_UPDATE_OPERATION,
47
+ method: "PATCH",
48
+ version: "v1.0",
49
+ path: `/v1.0/users/${encodeGraphPathSegment(target)}`,
50
+ effect: "disruptive",
51
+ target,
52
+ payload: { accountEnabled: desired },
53
+ };
54
+ }
55
+ async function readUserAccount(session, profile, readOperation, user) {
56
+ const raw = await session.execute({
57
+ profile,
58
+ operation: readOperation,
59
+ params: { "user-id": user },
60
+ query: { $select: "id,accountEnabled" },
61
+ ...(profile.mode === "application" ? {} : { scopes: [...USER_ACCOUNT_READ_SCOPES] }),
62
+ });
63
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw) && "id" in raw && typeof raw.id === "string" && raw.id.length > 0) {
64
+ const value = raw.accountEnabled;
65
+ // Null, missing or malformed is unverifiable, never a desired state.
66
+ return { id: raw.id, accountEnabled: typeof value === "boolean" ? value : null };
67
+ }
68
+ throw new AxiError("Graph returned a user without an object ID", "GRAPH_ERROR", [
69
+ "Read back the user before attempting an account update",
70
+ ]);
71
+ }
72
+ function executeHint(user, desired, profileName) {
73
+ const shell = (value) => /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
74
+ return `mg-axi entra user update --user ${shell(user)} --account-enabled ${desired} --execute`
75
+ + ` --confirm ${shell(user)}`
76
+ + ` --profile ${shell(profileName)}`;
77
+ }
78
+ function showHint(user, profileName) {
79
+ const shell = (value) => /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
80
+ return `mg-axi entra user show --id ${shell(user)} --select id,accountEnabled --profile ${shell(profileName)}`;
81
+ }
82
+ export async function updateUserAccount(args) {
83
+ const { session, coordinator, flags, profile, profileName, readOperation, help } = args;
84
+ const user = String(flags.user ?? "").trim();
85
+ if (!user)
86
+ throw new AxiError("--user needs a user ID or UPN", "VALIDATION_ERROR", [help]);
87
+ const desired = parseAccountEnabled(flags["account-enabled"], help);
88
+ const version = String(flags["api-version"] ?? "v1.0");
89
+ if (version !== "v1.0") {
90
+ throw new AxiError(`--api-version ${version} cannot mutate: WRITE-02 binds PATCH /users/{id} to v1.0; beta writes stay blocked`, "VALIDATION_ERROR", [help]);
91
+ }
92
+ const requestedDefinition = userAccountDefinition(user, desired);
93
+ const execute = flags.execute === true;
94
+ // Gates first: preview refuses read-only, unscoped and beta-bound profiles
95
+ // before any read, credential or transport.
96
+ coordinator.preview(requestedDefinition);
97
+ if (execute) {
98
+ if (flags.confirm === undefined) {
99
+ throw new AxiError(`blocked: disruptive PATCH needs --confirm '${requestedDefinition.target}'`, "CONFIRM_REQUIRED", [`Re-run with --confirm '${requestedDefinition.target}'`]);
100
+ }
101
+ if (String(flags.confirm) !== requestedDefinition.target) {
102
+ throw new AxiError(`blocked: --confirm '${flags.confirm}' does not match target '${requestedDefinition.target}'`, "CONFIRM_MISMATCH", [`Re-run with --confirm '${requestedDefinition.target}'`]);
103
+ }
104
+ }
105
+ const account = await readUserAccount(session, profile, readOperation, user);
106
+ const id = account.id;
107
+ const definition = { ...requestedDefinition, path: `/v1.0/users/${encodeGraphPathSegment(id)}` };
108
+ const preview = coordinator.preview(definition);
109
+ const current = account.accountEnabled;
110
+ const state = {
111
+ operation: definition.operation,
112
+ target: definition.target,
113
+ method: definition.method,
114
+ url: preview.url,
115
+ effect: definition.effect,
116
+ current,
117
+ desired,
118
+ noop: current === desired,
119
+ };
120
+ if (!execute) {
121
+ return {
122
+ preview: state,
123
+ help: [
124
+ state.noop
125
+ ? "Already in the desired state; no update is needed"
126
+ : executeHint(user, desired, profileName),
127
+ ...(!state.noop
128
+ ? ["Changing account access is disruptive: the --execute run needs the typed --confirm value shown above"]
129
+ : []),
130
+ ],
131
+ };
132
+ }
133
+ const result = await coordinator.execute(definition, {
134
+ execute: true,
135
+ confirm: String(flags.confirm),
136
+ ...(profile.mode === "application" ? {} : { scopes: [...USER_ACCOUNT_WRITE_SCOPES] }),
137
+ readState: async () => (await readUserAccount(session, profile, readOperation, id)).accountEnabled,
138
+ isNoop: currentState => currentState === desired,
139
+ });
140
+ if (result.kind === "failed") {
141
+ throw new AxiError(`Graph refused the account update (status ${result.status})`, "GRAPH_ERROR", [
142
+ ...(result.status === 403 ? [PERMISSION_GUIDANCE, ROLE_GUIDANCE] : []),
143
+ `Audit ${result.auditId} recorded the refusal; read back '${id}' and never replay this intent`,
144
+ ]);
145
+ }
146
+ if (result.kind === "unknown") {
147
+ throw new AxiError(`Update of '${id}' may or may not have been applied (audit ${result.auditId}); never replay this intent`, "OUTCOME_UNKNOWN", [
148
+ `Audit ${result.auditId} recorded the uncertain outcome; read back '${id}' with ${showHint(id, profileName)} before doing anything else`,
149
+ "Never replay this intent",
150
+ ]);
151
+ }
152
+ // Anything but success, failure or uncertainty is the verified no-op:
153
+ // dry-run needs execute !== true, always set above.
154
+ if (result.kind !== "success") {
155
+ return { noop: true, user: { id, accountEnabled: desired } };
156
+ }
157
+ // Success carries no proof: user-update answers 204 with an empty body, so
158
+ // only the reread decides between updated and conflict.
159
+ let verified;
160
+ try {
161
+ verified = (await readUserAccount(session, profile, readOperation, id)).accountEnabled;
162
+ }
163
+ catch {
164
+ throw new AxiError(`Update of '${user}' was sent (audit ${result.auditId}) but the verification read failed; the outcome is unknown`, "OUTCOME_UNKNOWN", [`Read back '${id}' before doing anything else; never replay this intent`]);
165
+ }
166
+ if (verified !== desired) {
167
+ throw new AxiError(`Update of '${user}' conflicts: the reread shows accountEnabled ${verified === null ? "unreadable" : String(verified)} instead of the sent ${String(desired)} (audit ${result.auditId})`, "WRITE_CONFLICT", [`Read back '${id}' with ${showHint(id, profileName)} before doing anything else`, "Never replay this intent"]);
168
+ }
169
+ return { user: { id, accountEnabled: desired }, auditId: result.auditId };
170
+ }
@@ -0,0 +1,211 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // READ-01: the Entra user read mapping behind `mg-axi entra user list/show`.
3
+ // Operation construction stays beside its command; the shared session owns
4
+ // URLs, credentials, paging, retries and error translation, and the SDK owns
5
+ // TOON rendering. This module only maps flags to session calls and projects
6
+ // rows for compact output.
7
+ //
8
+ // Reviewed against the v1.0 user-list and user-get operation documentation on
9
+ // 2026-10-04. List and get return the same default property set without
10
+ // $select; accountEnabled is never default and needs User.EnableDisableAccount.All
11
+ // with User.Read.All. Delegated list reads User.ReadBasic.All for basics and
12
+ // User.Read.All for richer data; application reads User.Read.All.
13
+ // Every property this slice may request or display. Anything else fails
14
+ // before credentials so typos never become misleading server queries.
15
+ export const KNOWN_USER_FIELDS = [
16
+ "businessPhones",
17
+ "displayName",
18
+ "givenName",
19
+ "id",
20
+ "jobTitle",
21
+ "mail",
22
+ "mobilePhone",
23
+ "officeLocation",
24
+ "preferredLanguage",
25
+ "surname",
26
+ "userPrincipalName",
27
+ "accountEnabled",
28
+ "department",
29
+ "userType",
30
+ ];
31
+ const KNOWN = new Set(KNOWN_USER_FIELDS);
32
+ // Compact list rows: identifier, display name and addresses, all
33
+ // readable with basic consent.
34
+ const DEFAULT_LIST_SELECT = ["id", "displayName", "userPrincipalName", "mail"];
35
+ // Show rows: the full default server set, a richer view of the same user.
36
+ const DEFAULT_SHOW_SELECT = [
37
+ "businessPhones",
38
+ "displayName",
39
+ "givenName",
40
+ "id",
41
+ "jobTitle",
42
+ "mail",
43
+ "mobilePhone",
44
+ "officeLocation",
45
+ "preferredLanguage",
46
+ "surname",
47
+ "userPrincipalName",
48
+ ];
49
+ // Default consent covers the basic list and richer show defaults;
50
+ // accountEnabled additionally needs the permission noted above.
51
+ export const DEFAULT_DELEGATED_SCOPES = ["https://graph.microsoft.com/User.Read.All"];
52
+ const TRUNCATE_AT = 500;
53
+ function fieldList(raw, flag, help) {
54
+ const parts = String(raw)
55
+ .split(",")
56
+ .map(part => part.trim())
57
+ .filter(part => part.length > 0);
58
+ if (!parts.length)
59
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
60
+ const fields = [];
61
+ for (const part of parts) {
62
+ if (!KNOWN.has(part)) {
63
+ throw new AxiError(`Unknown user property ${part} in --${flag}`, "VALIDATION_ERROR", [
64
+ help,
65
+ `Known user properties: ${KNOWN_USER_FIELDS.join(", ")}`,
66
+ ]);
67
+ }
68
+ if (!fields.includes(part))
69
+ fields.push(part);
70
+ }
71
+ return fields;
72
+ }
73
+ function scopesFor(flags, profile, help) {
74
+ if (profile.mode === "application") {
75
+ if (flags.scopes !== undefined) {
76
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
77
+ }
78
+ return undefined;
79
+ }
80
+ if (flags.scopes === undefined)
81
+ return [...DEFAULT_DELEGATED_SCOPES];
82
+ const scopes = String(flags.scopes)
83
+ .split(",")
84
+ .map(scope => scope.trim())
85
+ .filter(scope => scope.length > 0);
86
+ return scopes;
87
+ }
88
+ function truncateValue(value, full) {
89
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
90
+ ? { value: text, truncated: false }
91
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
92
+ if (typeof value === "string")
93
+ return truncate(value);
94
+ if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
95
+ const projected = value.map(truncate);
96
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
97
+ }
98
+ return { value, truncated: false };
99
+ }
100
+ // Local projection preserves Graph's null/missing distinction: an explicit
101
+ // null stays null, an absent property stays absent and is never synthesized.
102
+ function project(row, fields, full) {
103
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
104
+ const projected = {};
105
+ let truncated = false;
106
+ for (const field of fields) {
107
+ if (!Object.hasOwn(source, field))
108
+ continue;
109
+ const result = truncateValue(source[field], full);
110
+ projected[field] = result.value;
111
+ truncated = truncated || result.truncated;
112
+ }
113
+ return { row: projected, truncated };
114
+ }
115
+ function selectedFields(flags, defaults, help) {
116
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, "select", help);
117
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, "fields", help);
118
+ const missing = fields.find(field => !select.includes(field));
119
+ if (missing) {
120
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
121
+ }
122
+ return { select, fields };
123
+ }
124
+ function profileHint(profileName) {
125
+ return `--profile ${shellValue(profileName)}`;
126
+ }
127
+ function shellValue(value) {
128
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
129
+ }
130
+ function fullHint(action, flags, profileName) {
131
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
132
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
133
+ return `mg-axi entra user ${action} ${args.join(" ")}`;
134
+ }
135
+ export async function listUsers(session, flags, profile, operation, help, profileName) {
136
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
137
+ if (cursor !== undefined && !cursor.trim())
138
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
139
+ const savedSelect = cursor === undefined ? undefined : session.cursorQuery(operation, cursor).$select;
140
+ const { select, fields } = selectedFields(flags, savedSelect === undefined ? DEFAULT_LIST_SELECT : fieldList(savedSelect, "select", help), help);
141
+ const scopes = scopesFor(flags, profile, help);
142
+ const full = flags.full === true;
143
+ const query = { $select: select.join(",") };
144
+ if (flags.filter !== undefined)
145
+ query.$filter = String(flags.filter);
146
+ const args = { profile, operation, query, scopes };
147
+ if (cursor !== undefined)
148
+ args.cursor = cursor;
149
+ if (flags.all === true) {
150
+ if (flags.limit !== undefined)
151
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
152
+ }
153
+ else {
154
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
155
+ }
156
+ const result = await session.collect(args);
157
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIST_SELECT.join(",") };
158
+ if (result.query.$filter !== undefined)
159
+ effectiveFlags.filter = result.query.$filter;
160
+ const users = [];
161
+ let truncated = false;
162
+ for (const row of result.value) {
163
+ const projected = project(row, fields, full);
164
+ users.push(projected.row);
165
+ truncated = truncated || projected.truncated;
166
+ }
167
+ const showHint = `mg-axi entra user show --id <user-id-or-upn> ${profileHint(profileName)}`;
168
+ const truncationHints = truncated ? [fullHint("list", effectiveFlags, profileName)] : [];
169
+ if (!result.complete) {
170
+ return {
171
+ users,
172
+ count: { returned: users.length, complete: false, reason: result.reason },
173
+ cursor: result.cursor,
174
+ help: [...truncationHints, `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`, showHint],
175
+ };
176
+ }
177
+ const count = { returned: users.length, complete: true };
178
+ if (!users.length) {
179
+ return {
180
+ users,
181
+ count,
182
+ help: [
183
+ `mg-axi entra user list --filter <odata-filter> ${profileHint(profileName)}`,
184
+ "0 users matched; the absence of results is the answer, not an error",
185
+ ],
186
+ };
187
+ }
188
+ const helpHints = [...truncationHints, showHint];
189
+ return { users, count, help: helpHints };
190
+ }
191
+ export async function showUser(session, flags, profile, operation, help, profileName) {
192
+ const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
193
+ const scopes = scopesFor(flags, profile, help);
194
+ const full = flags.full === true;
195
+ const raw = await session.execute({
196
+ profile,
197
+ operation,
198
+ params: { "user-id": String(flags.id) },
199
+ query: { $select: select.join(",") },
200
+ scopes,
201
+ });
202
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
203
+ throw new AxiError("Graph returned a malformed user body", "GRAPH_ERROR", [
204
+ "Single-user reads carry one user object; treat anything else as unknown, not empty",
205
+ ]);
206
+ }
207
+ const { row, truncated } = project(raw, fields, full);
208
+ if (truncated)
209
+ return { user: row, help: [fullHint("show", flags, profileName)] };
210
+ return { user: row };
211
+ }