@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,349 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // EXT-01 group lifecycle subfamily: the read mapping behind
3
+ // `mg-axi entra group-lifecycle-policy list/show/count` and
4
+ // `mg-axi entra group-setting-template list/show/count`. Operation
5
+ // construction stays beside its command; the shared session owns URLs,
6
+ // credentials, paging, retries and error translation, and the SDK owns TOON
7
+ // rendering. This module only maps flags to session calls and projects rows
8
+ // for compact output.
9
+ //
10
+ // Reviewed against the v1.0 grouplifecyclepolicy-list,
11
+ // grouplifecyclepolicy-get, groupsettingtemplate-list,
12
+ // groupsettingtemplate-get and groupsettingtemplate resource documentation
13
+ // on 2026-10-05. Lifecycle-policy reads take D/A Directory.Read.All; the
14
+ // operation pages state no delegated role prerequisite and no P1/P2
15
+ // prerequisite. Setting-template reads take D/A GroupSettings.Read.All;
16
+ // delegated callers additionally need a supported Entra role (Directory
17
+ // Readers or Global Reader are the least-privileged roles). Personal
18
+ // Microsoft accounts are not supported on either family, and no P1/P2
19
+ // prerequisite is stated for either. Lifecycle-policy lists document the
20
+ // general OData query parameters, so --filter passes through as plain
21
+ // $filter with no $count or ConsistencyLevel contract. Template lists
22
+ // document $select only ($filter is not supported), so the template list
23
+ // offers no --filter. The $count routes carry no operation-level
24
+ // documentation page and take no --filter/--select/--limit/--cursor.
25
+ // groupSettingTemplates/delta() stays out: delta-token sync needs its own
26
+ // paging and change-tracking contract. Beta policies and templates and the
27
+ // POST lookup actions (getByIds, validateProperties, membership checks)
28
+ // belong to later EXT-01 subfamilies. No lifecycle or template mutation
29
+ // exists in this slice.
30
+ // Every lifecycle-policy property this slice may request or display,
31
+ // matching the reviewed resource. Anything else fails before credentials.
32
+ export const KNOWN_LIFECYCLE_FIELDS = [
33
+ "id",
34
+ "alternateNotificationEmails",
35
+ "groupLifetimeInDays",
36
+ "managedGroupTypes",
37
+ ];
38
+ const KNOWN_LIFECYCLE = new Set(KNOWN_LIFECYCLE_FIELDS);
39
+ // Every setting-template property this slice may request or display,
40
+ // matching the reviewed resource. Anything else fails before credentials.
41
+ export const KNOWN_TEMPLATE_FIELDS = [
42
+ "id",
43
+ "deletedDateTime",
44
+ "displayName",
45
+ "description",
46
+ "values",
47
+ ];
48
+ const KNOWN_TEMPLATES = new Set(KNOWN_TEMPLATE_FIELDS);
49
+ // Compact lifecycle rows: the policy id, the expiry window and the group
50
+ // types the policy governs.
51
+ const DEFAULT_LIFECYCLE_LIST_SELECT = ["id", "groupLifetimeInDays", "managedGroupTypes"];
52
+ // Show rows: the full reviewed lifecycle set.
53
+ const DEFAULT_LIFECYCLE_SHOW_SELECT = [...KNOWN_LIFECYCLE_FIELDS];
54
+ // Compact template rows: the template id and the display name/description
55
+ // that decide which template a setting is built from.
56
+ const DEFAULT_TEMPLATE_LIST_SELECT = ["id", "displayName", "description"];
57
+ // Show rows: the full reviewed template set, including the values
58
+ // collection carrying setting names, types and defaults.
59
+ const DEFAULT_TEMPLATE_SHOW_SELECT = [...KNOWN_TEMPLATE_FIELDS];
60
+ // Delegated defaults are operation-specific; application profiles use their
61
+ // configured .default audience and reject --scopes.
62
+ export const DEFAULT_LIFECYCLE_SCOPES = ["https://graph.microsoft.com/Directory.Read.All"];
63
+ export const DEFAULT_TEMPLATE_SCOPES = ["https://graph.microsoft.com/GroupSettings.Read.All"];
64
+ const TRUNCATE_AT = 500;
65
+ function fieldList(raw, known, knownList, flag, help) {
66
+ const parts = String(raw)
67
+ .split(",")
68
+ .map(part => part.trim())
69
+ .filter(part => part.length > 0);
70
+ if (!parts.length)
71
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
72
+ const fields = [];
73
+ for (const part of parts) {
74
+ if (!known.has(part)) {
75
+ throw new AxiError(`Unknown group lifecycle property ${part} in --${flag}`, "VALIDATION_ERROR", [
76
+ help,
77
+ `Known properties: ${knownList.join(", ")}`,
78
+ ]);
79
+ }
80
+ if (!fields.includes(part))
81
+ fields.push(part);
82
+ }
83
+ return fields;
84
+ }
85
+ function scopesFor(flags, defaults, profile, help) {
86
+ if (profile.mode === "application") {
87
+ if (flags.scopes !== undefined) {
88
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
89
+ }
90
+ return undefined;
91
+ }
92
+ if (flags.scopes === undefined)
93
+ return [...defaults];
94
+ const scopes = String(flags.scopes)
95
+ .split(",")
96
+ .map(scope => scope.trim())
97
+ .filter(scope => scope.length > 0);
98
+ return scopes;
99
+ }
100
+ function truncateValue(value, full) {
101
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
102
+ ? { value: text, truncated: false }
103
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
104
+ if (typeof value === "string")
105
+ return truncate(value);
106
+ if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
107
+ const projected = value.map(truncate);
108
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
109
+ }
110
+ return { value, truncated: false };
111
+ }
112
+ // Local projection preserves Graph's null/missing distinction: an explicit
113
+ // null stays null, an absent property stays absent and is never synthesized.
114
+ // Template `values` rows are objects, so they pass through untruncated;
115
+ // long descriptions still truncate with a --full escape hatch.
116
+ function project(row, fields, full) {
117
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
118
+ const projected = {};
119
+ let truncated = false;
120
+ for (const field of fields) {
121
+ if (!Object.hasOwn(source, field))
122
+ continue;
123
+ const result = truncateValue(source[field], full);
124
+ projected[field] = result.value;
125
+ truncated = truncated || result.truncated;
126
+ }
127
+ return { row: projected, truncated };
128
+ }
129
+ function selectedFields(flags, known, knownList, defaults, help) {
130
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, knownList, "select", help);
131
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, knownList, "fields", help);
132
+ const missing = fields.find(field => !select.includes(field));
133
+ if (missing) {
134
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
135
+ }
136
+ return { select, fields };
137
+ }
138
+ function profileHint(profileName) {
139
+ return `--profile ${shellValue(profileName)}`;
140
+ }
141
+ function shellValue(value) {
142
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
143
+ }
144
+ function fullHint(command, flags, profileName) {
145
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
146
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
147
+ return `mg-axi ${command} ${args.join(" ")}`;
148
+ }
149
+ // Denials carry the session's generic grant/role/licence cause; each
150
+ // operation adds the scope, roles and licensing that actually unlock it,
151
+ // because a 403 alone never says which prerequisite is missing.
152
+ function withGuidance(hints, run) {
153
+ return run().catch(error => {
154
+ if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
155
+ throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
156
+ }
157
+ throw error;
158
+ });
159
+ }
160
+ const LIFECYCLE_DENIAL_HINTS = [
161
+ "Lifecycle-policy reads need Directory.Read.All for delegated or application access; delegated callers pass it as --scopes",
162
+ "Personal Microsoft accounts are not supported for lifecycle-policy reads",
163
+ "No delegated role or P1/P2 prerequisite is stated for lifecycle-policy reads; never diagnose role or licence solely from HTTP 403",
164
+ ];
165
+ const TEMPLATE_DENIAL_HINTS = [
166
+ "Setting-template reads need GroupSettings.Read.All plus a supported Entra role for delegated access (Directory Readers or Global Reader are the least-privileged roles), or admin-consented GroupSettings.Read.All for application access",
167
+ "Personal Microsoft accounts are not supported for setting-template reads",
168
+ "No P1/P2 prerequisite is stated for setting-template reads; never diagnose licence solely from HTTP 403",
169
+ ];
170
+ // Restoring the saved select/filter keeps cursor resumes lossless when
171
+ // --select or --filter is omitted. The lifecycle list passes --filter
172
+ // through as plain $filter with no $count or ConsistencyLevel contract;
173
+ // the template list documents $select only and offers no --filter flag, so
174
+ // strict input validation refuses the flag before credentials instead of
175
+ // forwarding a misleading request.
176
+ function collectionCommon(session, flags, known, knownList, defaults, defaultScopes, operation, help, profile, withFilter) {
177
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
178
+ if (cursor !== undefined && !cursor.trim())
179
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
180
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
181
+ const { select, fields } = selectedFields(flags, known, knownList, saved?.["$select"] === undefined ? defaults : fieldList(saved["$select"], known, knownList, "select", help), help);
182
+ const filter = withFilter
183
+ ? (flags.filter === undefined ? saved?.["$filter"] : String(flags.filter))
184
+ : undefined;
185
+ return { cursor, select, fields, scopes: scopesFor(flags, defaultScopes, profile, help), full: flags.full === true, filter };
186
+ }
187
+ function collectArgs(profile, operation, common, flags, help) {
188
+ const query = { $select: common.select.join(",") };
189
+ if (common.filter !== undefined)
190
+ query.$filter = common.filter;
191
+ const args = { profile, operation, query, scopes: common.scopes };
192
+ if (common.cursor !== undefined)
193
+ args.cursor = common.cursor;
194
+ if (flags.all === true) {
195
+ if (flags.limit !== undefined)
196
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
197
+ }
198
+ else {
199
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
200
+ }
201
+ return args;
202
+ }
203
+ function resumeHint(profileName) {
204
+ return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
205
+ }
206
+ function policyId(flags, help) {
207
+ const id = String(flags.id);
208
+ if (!id.trim())
209
+ throw new AxiError("--id needs the lifecycle policy identifier", "VALIDATION_ERROR", [help]);
210
+ return id;
211
+ }
212
+ function templateId(flags, help) {
213
+ const id = String(flags.id);
214
+ if (!id.trim())
215
+ throw new AxiError("--id needs the setting template identifier", "VALIDATION_ERROR", [help]);
216
+ return id;
217
+ }
218
+ function singleResult(raw, fields, full, kind) {
219
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
220
+ throw new AxiError(`Graph returned a malformed ${kind} body`, "GRAPH_ERROR", [
221
+ `Single-${kind} reads carry one ${kind} object; treat anything else as unknown, not empty`,
222
+ ]);
223
+ }
224
+ return project(raw, fields, full);
225
+ }
226
+ export async function listLifecyclePolicies(session, flags, profile, operation, help, profileName) {
227
+ const common = collectionCommon(session, flags, KNOWN_LIFECYCLE, KNOWN_LIFECYCLE_FIELDS, DEFAULT_LIFECYCLE_LIST_SELECT, DEFAULT_LIFECYCLE_SCOPES, operation, help, profile, true);
228
+ const result = await withGuidance(LIFECYCLE_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
229
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIFECYCLE_LIST_SELECT.join(",") };
230
+ if (result.query.$filter !== undefined)
231
+ effectiveFlags.filter = result.query.$filter;
232
+ const policies = [];
233
+ let truncated = false;
234
+ for (const row of result.value) {
235
+ const projected = project(row, common.fields, common.full);
236
+ policies.push(projected.row);
237
+ truncated = truncated || projected.truncated;
238
+ }
239
+ const showHint = `mg-axi entra group-lifecycle-policy show --id <policy-id> ${profileHint(profileName)}`;
240
+ const truncationHints = truncated ? [fullHint("entra group-lifecycle-policy list", effectiveFlags, profileName)] : [];
241
+ if (!result.complete) {
242
+ return {
243
+ groupLifecyclePolicies: policies,
244
+ count: { returned: policies.length, complete: false, reason: result.reason },
245
+ cursor: result.cursor,
246
+ help: [...truncationHints, resumeHint(profileName), showHint],
247
+ };
248
+ }
249
+ const count = { returned: policies.length, complete: true };
250
+ if (!policies.length) {
251
+ return {
252
+ groupLifecyclePolicies: policies,
253
+ count,
254
+ help: ["0 lifecycle policies matched; the absence of results is the answer, not an error"],
255
+ };
256
+ }
257
+ return { groupLifecyclePolicies: policies, count, help: [...truncationHints, showHint] };
258
+ }
259
+ export async function showLifecyclePolicy(session, flags, profile, operation, help, profileName) {
260
+ const { select, fields } = selectedFields(flags, KNOWN_LIFECYCLE, KNOWN_LIFECYCLE_FIELDS, DEFAULT_LIFECYCLE_SHOW_SELECT, help);
261
+ const scopes = scopesFor(flags, DEFAULT_LIFECYCLE_SCOPES, profile, help);
262
+ const full = flags.full === true;
263
+ const raw = await withGuidance(LIFECYCLE_DENIAL_HINTS, () => session.execute({
264
+ profile,
265
+ operation,
266
+ params: { "groupLifecyclePolicy-id": policyId(flags, help) },
267
+ query: { $select: select.join(",") },
268
+ scopes,
269
+ }));
270
+ const { row, truncated } = singleResult(raw, fields, full, "lifecycle policy");
271
+ if (truncated)
272
+ return { groupLifecyclePolicy: row, help: [fullHint("entra group-lifecycle-policy show", flags, profileName)] };
273
+ return { groupLifecyclePolicy: row };
274
+ }
275
+ // The $count route returns a text/plain integer scalar rather than a JSON
276
+ // collection, so the leaf reads it through session.execute and accepts only
277
+ // a non-negative integer. There is no --filter/--select/--limit contract on
278
+ // the count: the catalogue declares no such flags and strict input
279
+ // validation refuses them before credentials.
280
+ export async function countLifecyclePolicies(session, flags, profile, operation, help, profileName) {
281
+ void profileName;
282
+ const scopes = scopesFor(flags, DEFAULT_LIFECYCLE_SCOPES, profile, help);
283
+ const raw = await withGuidance(LIFECYCLE_DENIAL_HINTS, () => session.execute({ profile, operation, scopes }));
284
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
285
+ throw new AxiError("Graph returned a malformed lifecycle-policy count body", "GRAPH_ERROR", [
286
+ "Lifecycle-policy counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
287
+ ]);
288
+ }
289
+ return { groupLifecyclePolicyCount: raw };
290
+ }
291
+ export async function listSettingTemplates(session, flags, profile, operation, help, profileName) {
292
+ const common = collectionCommon(session, flags, KNOWN_TEMPLATES, KNOWN_TEMPLATE_FIELDS, DEFAULT_TEMPLATE_LIST_SELECT, DEFAULT_TEMPLATE_SCOPES, operation, help, profile, false);
293
+ const result = await withGuidance(TEMPLATE_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
294
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_TEMPLATE_LIST_SELECT.join(",") };
295
+ const templates = [];
296
+ let truncated = false;
297
+ for (const row of result.value) {
298
+ const projected = project(row, common.fields, common.full);
299
+ templates.push(projected.row);
300
+ truncated = truncated || projected.truncated;
301
+ }
302
+ const showHint = `mg-axi entra group-setting-template show --id <template-id> ${profileHint(profileName)}`;
303
+ const filterNote = "Template lists offer no --filter: Graph documents $select only for /groupSettingTemplates";
304
+ const truncationHints = truncated ? [fullHint("entra group-setting-template list", effectiveFlags, profileName)] : [];
305
+ if (!result.complete) {
306
+ return {
307
+ groupSettingTemplates: templates,
308
+ count: { returned: templates.length, complete: false, reason: result.reason },
309
+ cursor: result.cursor,
310
+ help: [...truncationHints, resumeHint(profileName), showHint, filterNote],
311
+ };
312
+ }
313
+ const count = { returned: templates.length, complete: true };
314
+ if (!templates.length) {
315
+ return {
316
+ groupSettingTemplates: templates,
317
+ count,
318
+ help: [filterNote, "0 setting templates matched; the absence of results is the answer, not an error"],
319
+ };
320
+ }
321
+ return { groupSettingTemplates: templates, count, help: [...truncationHints, showHint, filterNote] };
322
+ }
323
+ export async function showSettingTemplate(session, flags, profile, operation, help, profileName) {
324
+ const { select, fields } = selectedFields(flags, KNOWN_TEMPLATES, KNOWN_TEMPLATE_FIELDS, DEFAULT_TEMPLATE_SHOW_SELECT, help);
325
+ const scopes = scopesFor(flags, DEFAULT_TEMPLATE_SCOPES, profile, help);
326
+ const full = flags.full === true;
327
+ const raw = await withGuidance(TEMPLATE_DENIAL_HINTS, () => session.execute({
328
+ profile,
329
+ operation,
330
+ params: { "groupSettingTemplate-id": templateId(flags, help) },
331
+ query: { $select: select.join(",") },
332
+ scopes,
333
+ }));
334
+ const { row, truncated } = singleResult(raw, fields, full, "setting template");
335
+ if (truncated)
336
+ return { groupSettingTemplate: row, help: [fullHint("entra group-setting-template show", flags, profileName)] };
337
+ return { groupSettingTemplate: row };
338
+ }
339
+ export async function countSettingTemplates(session, flags, profile, operation, help, profileName) {
340
+ void profileName;
341
+ const scopes = scopesFor(flags, DEFAULT_TEMPLATE_SCOPES, profile, help);
342
+ const raw = await withGuidance(TEMPLATE_DENIAL_HINTS, () => session.execute({ profile, operation, scopes }));
343
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
344
+ throw new AxiError("Graph returned a malformed setting-template count body", "GRAPH_ERROR", [
345
+ "Setting-template counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
346
+ ]);
347
+ }
348
+ return { groupSettingTemplateCount: raw };
349
+ }
@@ -0,0 +1,278 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { resolveSessionOperation } from "./graph-session.js";
3
+ import { createMutationCoordinator, mutationGraphFault, mutationHttpStatus } from "./mutations.js";
4
+ // WRITE-01: the first named mutation, `mg-axi entra group member add`, behind
5
+ // the WRITE-00 coordinator. One user joins one group through
6
+ // POST /groups/{group-id}/members/$ref with a directoryObjects $ref target.
7
+ //
8
+ // Reviewed against the v1.0 group-post-members operation documentation on
9
+ // 2026-10-04. Success is 204 No Content with an empty body. Graph returns
10
+ // 400 when the object is already a member, when the member type is
11
+ // unsupported, or when a recently created group has not finished replicating
12
+ // ("the source resource object or one of the objects being referenced don't
13
+ // exist" - retry later as a new command, never by replaying the intent).
14
+ // It returns 404 when the referenced object does not exist and 403 for
15
+ // unmanageable group types, missing permission, or role-assignable groups
16
+ // without role-management grants. Only the already-member 400 is a no-op;
17
+ // every other 400 stays a failure.
18
+ //
19
+ // Supported scope is deliberately narrow: non-role-assignable security and
20
+ // Microsoft 365 groups, user members only. User members take D/A
21
+ // GroupMember.ReadWrite.All; every other member type needs additional
22
+ // permissions and belongs to WRITE-N, never to this slice. Delegated callers
23
+ // additionally need an Entra role carrying
24
+ // microsoft.directory/groups/members/update (least-privileged choices include
25
+ // group owner, Directory Writers, Groups Administrator, Identity Governance
26
+ // Administrator and User Administrator). Role-assignable groups need
27
+ // RoleManagement.ReadWrite.Directory plus Privileged Role Administrator and
28
+ // are refused before sending, as are dynamic-membership and distribution
29
+ // groups, which Graph cannot add members to.
30
+ // Profile write-scope operation name. Hand-edit this exact string into the
31
+ // profile's writes.operations allowlist; no command writes that object.
32
+ export const GROUP_MEMBER_ADD_OPERATION = "mg.entra.group.member.add";
33
+ // The only delegated scope this slice requests for the mutation itself.
34
+ export const GROUP_MEMBER_ADD_SCOPES = ["https://graph.microsoft.com/GroupMember.ReadWrite.All"];
35
+ // Preview reads travel the READ-02 route with its standard read scope.
36
+ const PREVIEW_SCOPES = ["https://graph.microsoft.com/GroupMember.Read.All"];
37
+ const GROUP_SELECT = [
38
+ "id",
39
+ "displayName",
40
+ "isAssignableToRole",
41
+ "securityEnabled",
42
+ "groupTypes",
43
+ "mailEnabled",
44
+ "membershipRule",
45
+ "membershipRuleProcessingState",
46
+ ];
47
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
48
+ function fail(message, code, suggestions) {
49
+ throw new AxiError(message, code, suggestions);
50
+ }
51
+ function groupRef(groupId) {
52
+ return `https://graph.microsoft.com/v1.0/groups/${groupId}`;
53
+ }
54
+ function memberRef(userId) {
55
+ return `https://graph.microsoft.com/v1.0/directoryObjects/${userId}`;
56
+ }
57
+ function executeHint(groupId, userId, profileName) {
58
+ return `mg-axi entra group member add --group ${groupId} --user ${userId} --profile ${profileName} --execute --confirm ${groupId}`;
59
+ }
60
+ function readBackHint(groupId, profileName) {
61
+ return `mg-axi entra group member list --group ${groupId} --profile ${profileName}`;
62
+ }
63
+ // Pre-send target refusal. Runs inside the preview read so the group is
64
+ // rechecked on the fresh read before the single send. An initial refusal
65
+ // journals nothing; a refusal after intent reservation records NOT_SENT.
66
+ function assertGroupWritable(group, groupId) {
67
+ if (group === null || typeof group !== "object" || Array.isArray(group)) {
68
+ fail("Graph returned a malformed group body", "GRAPH_ERROR", [
69
+ "Single-group reads carry one group object; treat anything else as unknown, not empty",
70
+ ]);
71
+ }
72
+ const row = group;
73
+ if (row["isAssignableToRole"] === true) {
74
+ fail(`Refusing role-assignable group ${groupId}: this command adds users to non-role-assignable groups only`, "OPERATION_BLOCKED", [
75
+ "Role-assignable membership needs RoleManagement.ReadWrite.Directory and Privileged Role Administrator; that grant belongs to a later slice, never to this command",
76
+ "Verify the group with mg-axi entra group show --id <group-id>",
77
+ ]);
78
+ }
79
+ if (row["isAssignableToRole"] !== false && row["isAssignableToRole"] !== null) {
80
+ fail(`Refusing group ${groupId}: its role-assignable state is unknown`, "OPERATION_BLOCKED", [
81
+ "Read back the group with mg-axi entra group show --id <group-id> before doing anything else",
82
+ ]);
83
+ }
84
+ if (typeof row["membershipRule"] === "string" && row["membershipRule"].trim() !== "") {
85
+ fail(`Refusing dynamic-membership group ${groupId}: members come from its rule, not manual adds`, "OPERATION_BLOCKED", [
86
+ "Manage membership through the group's membershipRule instead",
87
+ ]);
88
+ }
89
+ const groupTypes = Array.isArray(row["groupTypes"]) ? row["groupTypes"] : [];
90
+ if (row["securityEnabled"] !== true && !groupTypes.includes("Unified")) {
91
+ fail(`Refusing group ${groupId}: Graph manages members of security and Microsoft 365 groups only`, "OPERATION_BLOCKED", [
92
+ "Verify the group with mg-axi entra group show --id <group-id>",
93
+ ]);
94
+ }
95
+ return { id: groupId, displayName: row["displayName"] };
96
+ }
97
+ function isDuplicateReference(error) {
98
+ if (mutationHttpStatus(error) !== 400)
99
+ return false;
100
+ const fault = mutationGraphFault(error);
101
+ return fault !== null && /already exist/i.test(`${fault.code} ${fault.message}`);
102
+ }
103
+ function failedResult(status, groupId, userId, profileName) {
104
+ const readBack = readBackHint(groupId, profileName);
105
+ if (status === 400) {
106
+ fail(`Graph refused the membership add (400) without applying it`, "GRAPH_ERROR", [
107
+ "Verify the user object ID exists: an unknown user reports 404, while an unsupported member type or an unreplicated recently created group reports 400",
108
+ "If the group was created moments ago, wait for replication and retry as a new command with a new intent; never replay a reserved intent",
109
+ readBack,
110
+ ]);
111
+ }
112
+ if (status === 403) {
113
+ fail(`Graph denied the membership add (403): the grant, role, licence or policy prerequisite is missing and Graph does not say which`, "GRAPH_ERROR", [
114
+ "User members need D/A GroupMember.ReadWrite.All; other member types need additional permissions and belong to a later slice",
115
+ "Delegated callers additionally need an Entra role with microsoft.directory/groups/members/update (for example Groups Administrator, User Administrator, Directory Writers, Identity Governance Administrator, or group owner)",
116
+ "A 403 never proves which prerequisite is missing; do not retry blindly",
117
+ readBack,
118
+ ]);
119
+ }
120
+ if (status === 404) {
121
+ fail(`Graph reports the group or user as not found or inaccessible (404); Graph does not distinguish missing from hidden`, "GRAPH_ERROR", [
122
+ "Verify both object IDs; --user takes the user object ID, never a UPN",
123
+ readBack,
124
+ ]);
125
+ }
126
+ fail(`Graph membership add returned status ${status}`, "GRAPH_ERROR", [
127
+ `Read back the membership with ${readBack} before doing anything else; never replay this intent`,
128
+ ]);
129
+ }
130
+ export async function addGroupMember(args) {
131
+ const { session, profile, delegated, application, transport, flags, profileName, help } = args;
132
+ // Strict flag validation runs before any credential or HTTP.
133
+ const apiVersion = String(flags["api-version"] ?? "v1.0");
134
+ if (apiVersion !== "v1.0") {
135
+ fail(`No reviewed ${apiVersion} membership write: this command binds to v1.0 only`, "VALIDATION_ERROR", [help]);
136
+ }
137
+ const groupId = String(flags.group ?? "").trim();
138
+ if (!groupId)
139
+ fail("--group needs the group object ID", "VALIDATION_ERROR", [help]);
140
+ const userId = String(flags.user ?? "").trim();
141
+ if (!userId)
142
+ fail("--user needs the user object ID", "VALIDATION_ERROR", [help]);
143
+ if (!GUID.test(groupId)) {
144
+ fail(`--group ${groupId} is not a group object ID`, "VALIDATION_ERROR", [help, "Pass the group object ID (GUID); names and mail addresses are not accepted"]);
145
+ }
146
+ if (!GUID.test(userId)) {
147
+ fail(`--user ${userId} is not a user object ID`, "VALIDATION_ERROR", [help, "Pass the user object ID (GUID); UPNs are not resolved by this command"]);
148
+ }
149
+ if (profile.mode === "application" && flags.scopes !== undefined) {
150
+ fail("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [
151
+ help,
152
+ "mg-axi profile show --profile <name>",
153
+ ]);
154
+ }
155
+ let writeScopes;
156
+ if (profile.mode === "delegated") {
157
+ if (flags.scopes === undefined)
158
+ writeScopes = [...GROUP_MEMBER_ADD_SCOPES];
159
+ else {
160
+ writeScopes = String(flags.scopes).split(",").map(scope => scope.trim()).filter(scope => scope.length > 0);
161
+ if (!writeScopes.length)
162
+ fail("--scopes needs at least one Graph scope", "VALIDATION_ERROR", [help]);
163
+ }
164
+ }
165
+ const readScopes = profile.mode === "delegated" ? [...PREVIEW_SCOPES] : undefined;
166
+ const userReadScopes = profile.mode === "delegated" ? ["https://graph.microsoft.com/User.ReadBasic.All"] : undefined;
167
+ const groupOperation = resolveSessionOperation("v1.0", "GET", "/groups/{group-id}");
168
+ const userOperation = resolveSessionOperation("v1.0", "GET", "/users/{user-id}");
169
+ const membersOperation = resolveSessionOperation("v1.0", "GET", "/groups/{group-id}/members");
170
+ // Desired-state read through the READ-02 route. An incomplete page window
171
+ // cannot prove absence, so a missed member proceeds to the POST and lands
172
+ // as a duplicate no-op instead of a failure.
173
+ const readState = async () => {
174
+ const group = await session.execute({
175
+ profile,
176
+ operation: groupOperation,
177
+ params: { "group-id": groupId },
178
+ query: { $select: GROUP_SELECT.join(",") },
179
+ scopes: readScopes,
180
+ });
181
+ const writable = assertGroupWritable(group, groupId);
182
+ const user = await session.execute({
183
+ profile,
184
+ operation: userOperation,
185
+ params: { "user-id": userId },
186
+ query: { $select: "id" },
187
+ scopes: userReadScopes,
188
+ });
189
+ const userRow = user;
190
+ if (userRow === null || typeof userRow !== "object" || Array.isArray(userRow)
191
+ || typeof userRow["id"] !== "string" || userRow["id"].toLowerCase() !== userId.toLowerCase()
192
+ || (userRow["@odata.type"] !== undefined && userRow["@odata.type"] !== "#microsoft.graph.user")) {
193
+ fail(`Graph did not establish user identity for ${userId}`, "GRAPH_ERROR", [
194
+ "Verify --user is an accessible user object ID; non-user objects cannot be added by this command",
195
+ ]);
196
+ }
197
+ const collected = await session.collect({
198
+ profile,
199
+ operation: membersOperation,
200
+ params: { "group-id": groupId },
201
+ query: { $select: "id" },
202
+ scopes: readScopes,
203
+ });
204
+ const wanted = userId.toLowerCase();
205
+ let alreadyMember = false;
206
+ let checked = 0;
207
+ for (const row of collected.value) {
208
+ if (row === null || typeof row !== "object" || Array.isArray(row))
209
+ continue;
210
+ const id = row["id"];
211
+ checked += 1;
212
+ if (typeof id === "string" && id.toLowerCase() === wanted)
213
+ alreadyMember = true;
214
+ }
215
+ return { group: writable, membersChecked: checked, membersComplete: collected.complete, alreadyMember };
216
+ };
217
+ const coordinator = createMutationCoordinator({
218
+ profile,
219
+ delegated,
220
+ application,
221
+ transport,
222
+ ...(args.clock === undefined ? {} : { clock: args.clock }),
223
+ ...(args.journalPath === undefined ? {} : { journalPath: args.journalPath }),
224
+ });
225
+ const definition = {
226
+ operation: GROUP_MEMBER_ADD_OPERATION,
227
+ method: "POST",
228
+ version: "v1.0",
229
+ path: `/v1.0/groups/${groupId}/members/$ref`,
230
+ effect: "disruptive",
231
+ target: groupId,
232
+ payload: { "@odata.id": memberRef(userId) },
233
+ };
234
+ const result = await coordinator.execute(definition, {
235
+ ...(flags.execute === true ? { execute: true } : {}),
236
+ ...(flags.confirm === undefined ? {} : { confirm: String(flags.confirm) }),
237
+ ...(writeScopes === undefined ? {} : { scopes: writeScopes }),
238
+ readState,
239
+ isNoop: current => current.alreadyMember === true,
240
+ isDuplicate: isDuplicateReference,
241
+ });
242
+ const membership = { group: groupRef(groupId), user: memberRef(userId) };
243
+ if (result.kind === "dry-run") {
244
+ return {
245
+ membership: { ...membership, status: "preview" },
246
+ preview: result.preview,
247
+ help: [executeHint(groupId, userId, profileName), readBackHint(groupId, profileName)],
248
+ };
249
+ }
250
+ if (result.kind === "noop") {
251
+ if (result.duplicate === true) {
252
+ return {
253
+ membership: { ...membership, status: "already-member" },
254
+ noop: true,
255
+ auditId: result.auditId,
256
+ note: "Graph reported the membership already exists; no change was made",
257
+ };
258
+ }
259
+ return { membership: { ...membership, status: "already-member" }, noop: true };
260
+ }
261
+ if (result.kind === "success") {
262
+ return {
263
+ membership: { ...membership, status: "added" },
264
+ auditId: result.auditId,
265
+ help: [readBackHint(groupId, profileName)],
266
+ };
267
+ }
268
+ if (result.kind === "failed") {
269
+ failedResult(result.status, groupId, userId, profileName);
270
+ }
271
+ return {
272
+ membership: { ...membership, status: "unknown" },
273
+ auditId: result.auditId,
274
+ httpStatus: result.httpStatus,
275
+ guidance: result.guidance,
276
+ help: [readBackHint(groupId, profileName)],
277
+ };
278
+ }