@knowttl/mg-axi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1138 -0
- package/dist/api.js +953 -0
- package/dist/app-auth.js +47 -0
- package/dist/auth.js +59 -0
- package/dist/bin/mg-axi.js +7 -0
- package/dist/catalogue.js +2191 -0
- package/dist/cli.js +1314 -0
- package/dist/docs.js +559 -0
- package/dist/doctor.js +75 -0
- package/dist/entra-access-reviews.js +425 -0
- package/dist/entra-apps.js +374 -0
- package/dist/entra-audit-logs.js +381 -0
- package/dist/entra-auth-methods.js +296 -0
- package/dist/entra-ca-policy-update.js +353 -0
- package/dist/entra-certificate-auth.js +384 -0
- package/dist/entra-conditional-access.js +317 -0
- package/dist/entra-contacts.js +827 -0
- package/dist/entra-contracts.js +252 -0
- package/dist/entra-custom-security-attributes.js +464 -0
- package/dist/entra-data-policy-operations.js +274 -0
- package/dist/entra-delegated-admin.js +468 -0
- package/dist/entra-deleted-items.js +461 -0
- package/dist/entra-directory-objects.js +274 -0
- package/dist/entra-directory.js +411 -0
- package/dist/entra-domains.js +399 -0
- package/dist/entra-entitlement-management.js +645 -0
- package/dist/entra-federation-configurations.js +290 -0
- package/dist/entra-grants.js +272 -0
- package/dist/entra-group-lifecycle.js +349 -0
- package/dist/entra-group-member-add.js +278 -0
- package/dist/entra-groups.js +336 -0
- package/dist/entra-identity-providers.js +297 -0
- package/dist/entra-lifecycle-workflows.js +673 -0
- package/dist/entra-multi-tenant-organization.js +309 -0
- package/dist/entra-on-premises-synchronization.js +231 -0
- package/dist/entra-organization.js +398 -0
- package/dist/entra-risk-dismiss.js +191 -0
- package/dist/entra-risk.js +404 -0
- package/dist/entra-roles.js +310 -0
- package/dist/entra-subscriptions.js +289 -0
- package/dist/entra-tenant-information.js +152 -0
- package/dist/entra-terms-of-use.js +380 -0
- package/dist/entra-user-revoke-sessions.js +170 -0
- package/dist/entra-user-update.js +170 -0
- package/dist/entra-users.js +211 -0
- package/dist/graph-session.js +1034 -0
- package/dist/msal-app-provider.js +42 -0
- package/dist/msal-provider.js +82 -0
- package/dist/mutations.js +529 -0
- package/dist/profiles.js +139 -0
- package/dist/setup.js +116 -0
- package/dist/version.js +2 -0
- package/docs/coverage.md +1028 -0
- package/inventory/operations.json +4 -0
- package/inventory/schema.json +71 -0
- package/package.json +32 -0
- package/skills/mg-axi/SKILL.md +293 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// EXT-01 contacts subfamily plus EXT-01m/EXT-01n navigation: the read mapping
|
|
3
|
+
// behind `mg-axi entra contact list/show/count`, the manager/directReports
|
|
4
|
+
// navigation reads (`show-manager`, `list/show/count-direct-reports`) and the
|
|
5
|
+
// memberOf/transitiveMemberOf membership reads (`list/show/count-member-of`).
|
|
6
|
+
// Operation construction stays beside its command; the shared session owns URLs, credentials, paging,
|
|
7
|
+
// retries and error translation, and the SDK owns TOON rendering. This
|
|
8
|
+
// module only maps flags to session calls and projects rows for compact
|
|
9
|
+
// output.
|
|
10
|
+
//
|
|
11
|
+
// Reviewed against the v1.0 orgcontact-list, orgcontact-get and orgContact
|
|
12
|
+
// resource documentation on 2026-10-05. Both reads take delegated or
|
|
13
|
+
// application OrgContact.Read.All, the documented least privilege in each
|
|
14
|
+
// mode (Directory.Read.All and Directory.ReadWrite.All are documented only
|
|
15
|
+
// as higher-privileged alternatives); personal Microsoft accounts are not
|
|
16
|
+
// supported. Delegated callers additionally need a supported Entra role:
|
|
17
|
+
// Directory Readers reads basic properties, and Global Reader, Directory
|
|
18
|
+
// Writers, Intune Administrator or User Administrator also work. No P1/P2
|
|
19
|
+
// prerequisite is stated for these reads. The $count scalar carries no
|
|
20
|
+
// operation-level documentation page and follows the same OrgContact.Read.All
|
|
21
|
+
// contract; the list page's $count example sends ConsistencyLevel eventual,
|
|
22
|
+
// so the scalar does too. Membership reads are reviewed against the v1.0
|
|
23
|
+
// orgcontact-list-memberof and orgcontact-list-transitivememberof operation
|
|
24
|
+
// documentation plus the orgContact resource reference on 2026-10-05 (see the
|
|
25
|
+
// EXT-01n block below): direct reads take delegated or application
|
|
26
|
+
// OrgContact.Read.All, the documented least privilege in each mode
|
|
27
|
+
// (Directory.Read.All, Directory.ReadWrite.All and Group.Read.All are
|
|
28
|
+
// documented only as higher-privileged alternatives), while transitive reads
|
|
29
|
+
// take delegated or application OrgContact.Read.All and Group.Read.All
|
|
30
|
+
// together (Directory.Read.All is the documented higher-privileged
|
|
31
|
+
// alternative); the same supported Entra roles, personal-account exclusion
|
|
32
|
+
// and no-P1/P2 prerequisite apply. The delta() sync, the serviceProvisioningErrors and
|
|
33
|
+
// onPremisesSyncBehavior reads (unavailable with no documented permission
|
|
34
|
+
// contract), every POST lookup action, beta and every mutation stay out. No mutation lives here.
|
|
35
|
+
//
|
|
36
|
+
// Secrecy by construction: contacts are personal data, so default rows carry
|
|
37
|
+
// only id, displayName, mail and companyName; identifying fields beyond that
|
|
38
|
+
// need an explicit --select. Top-level list and show request and project
|
|
39
|
+
// only flat scalar properties; $expand is not offered, so navigation objects
|
|
40
|
+
// can never appear there, and the nested phones/addresses collections need
|
|
41
|
+
// their own projection review. Navigation rows are directory objects with
|
|
42
|
+
// the @odata.type discriminator plus minimal fields (see the EXT-01m block
|
|
43
|
+
// below). Membership rows are directory objects too: the @odata.type
|
|
44
|
+
// discriminator plus id and displayName by default (see the EXT-01n block
|
|
45
|
+
// below).
|
|
46
|
+
// Every contact property this slice may request or display, matching the
|
|
47
|
+
// reviewed resource. Anything else fails before credentials.
|
|
48
|
+
export const KNOWN_CONTACT_FIELDS = [
|
|
49
|
+
"id",
|
|
50
|
+
"deletedDateTime",
|
|
51
|
+
"companyName",
|
|
52
|
+
"department",
|
|
53
|
+
"displayName",
|
|
54
|
+
"givenName",
|
|
55
|
+
"surname",
|
|
56
|
+
"mail",
|
|
57
|
+
"mailNickname",
|
|
58
|
+
"jobTitle",
|
|
59
|
+
"onPremisesLastSyncDateTime",
|
|
60
|
+
"onPremisesSyncEnabled",
|
|
61
|
+
"proxyAddresses",
|
|
62
|
+
];
|
|
63
|
+
const KNOWN_CONTACTS = new Set(KNOWN_CONTACT_FIELDS);
|
|
64
|
+
// Compact rows stay minimal on purpose: contacts are personal data, so list
|
|
65
|
+
// and show both default to id, displayName, mail and companyName only.
|
|
66
|
+
const DEFAULT_LIST_SELECT = ["id", "displayName", "mail", "companyName"];
|
|
67
|
+
const DEFAULT_SHOW_SELECT = [...DEFAULT_LIST_SELECT];
|
|
68
|
+
// OrgContact.Read.All covers list, show and count in both modes; delegated
|
|
69
|
+
// reads default to it unless --scopes overrides it. It is new to the shared
|
|
70
|
+
// READ_SCOPES allowlist, and it is a read scope, so no deferral is needed
|
|
71
|
+
// here.
|
|
72
|
+
export const DEFAULT_CONTACT_SCOPES = ["https://graph.microsoft.com/OrgContact.Read.All"];
|
|
73
|
+
const TRUNCATE_AT = 500;
|
|
74
|
+
// The only operations this slice ever binds: the three catalogued v1.0
|
|
75
|
+
// top-level contacts GETs, the ten EXT-01m manager/directReports navigation
|
|
76
|
+
// GETs and the eighteen EXT-01n memberOf/transitiveMemberOf membership GETs
|
|
77
|
+
// (lists, $count scalars, singles and typed casts). Anything else - delta
|
|
78
|
+
// sync, error/sync reads, POST lookup actions, writes - is refused before
|
|
79
|
+
// credentials.
|
|
80
|
+
const READ_OPERATIONS = {
|
|
81
|
+
"GET:/contacts": "contact",
|
|
82
|
+
"GET:/contacts/{orgContact-id}": "contact",
|
|
83
|
+
"GET:/contacts/$count": "contact",
|
|
84
|
+
"GET:/contacts/{orgContact-id}/manager": "contact-navigation",
|
|
85
|
+
"GET:/contacts/{orgContact-id}/directReports": "contact-navigation",
|
|
86
|
+
"GET:/contacts/{orgContact-id}/directReports/$count": "contact-navigation",
|
|
87
|
+
"GET:/contacts/{orgContact-id}/directReports/graph.orgContact": "contact-navigation",
|
|
88
|
+
"GET:/contacts/{orgContact-id}/directReports/graph.orgContact/$count": "contact-navigation",
|
|
89
|
+
"GET:/contacts/{orgContact-id}/directReports/graph.user": "contact-navigation",
|
|
90
|
+
"GET:/contacts/{orgContact-id}/directReports/graph.user/$count": "contact-navigation",
|
|
91
|
+
"GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}": "contact-navigation",
|
|
92
|
+
"GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}/graph.orgContact": "contact-navigation",
|
|
93
|
+
"GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}/graph.user": "contact-navigation",
|
|
94
|
+
"GET:/contacts/{orgContact-id}/memberOf": "contact-membership",
|
|
95
|
+
"GET:/contacts/{orgContact-id}/memberOf/$count": "contact-membership",
|
|
96
|
+
"GET:/contacts/{orgContact-id}/memberOf/graph.administrativeUnit": "contact-membership",
|
|
97
|
+
"GET:/contacts/{orgContact-id}/memberOf/graph.administrativeUnit/$count": "contact-membership",
|
|
98
|
+
"GET:/contacts/{orgContact-id}/memberOf/graph.group": "contact-membership",
|
|
99
|
+
"GET:/contacts/{orgContact-id}/memberOf/graph.group/$count": "contact-membership",
|
|
100
|
+
"GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}": "contact-membership",
|
|
101
|
+
"GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}/graph.administrativeUnit": "contact-membership",
|
|
102
|
+
"GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}/graph.group": "contact-membership",
|
|
103
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf": "contact-membership",
|
|
104
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf/$count": "contact-membership",
|
|
105
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.administrativeUnit": "contact-membership",
|
|
106
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.administrativeUnit/$count": "contact-membership",
|
|
107
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.group": "contact-membership",
|
|
108
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.group/$count": "contact-membership",
|
|
109
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}": "contact-membership",
|
|
110
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}/graph.administrativeUnit": "contact-membership",
|
|
111
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}/graph.group": "contact-membership",
|
|
112
|
+
};
|
|
113
|
+
function checkReadOperation(operation, help) {
|
|
114
|
+
const route = `${operation.method}:${operation.path}`;
|
|
115
|
+
if (operation.method !== "GET" || !Object.hasOwn(READ_OPERATIONS, route)) {
|
|
116
|
+
throw new AxiError(`Refused non-read route ${operation.id}: contact serves only the catalogued v1.0 top-level, manager/directReports and memberOf/transitiveMemberOf membership GETs`, "VALIDATION_ERROR", [
|
|
117
|
+
help,
|
|
118
|
+
"Delta sync, error/sync reads, POST lookup actions and writes are never constructed here; beta needs its own review",
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function fieldList(raw, known, knownList, flag, help) {
|
|
123
|
+
const parts = String(raw)
|
|
124
|
+
.split(",")
|
|
125
|
+
.map(part => part.trim())
|
|
126
|
+
.filter(part => part.length > 0);
|
|
127
|
+
if (!parts.length)
|
|
128
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
129
|
+
const fields = [];
|
|
130
|
+
for (const part of parts) {
|
|
131
|
+
if (!known.has(part)) {
|
|
132
|
+
throw new AxiError(`Unknown contact property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
133
|
+
help,
|
|
134
|
+
`Known contact properties: ${knownList.join(", ")}`,
|
|
135
|
+
]);
|
|
136
|
+
}
|
|
137
|
+
if (!fields.includes(part))
|
|
138
|
+
fields.push(part);
|
|
139
|
+
}
|
|
140
|
+
return fields;
|
|
141
|
+
}
|
|
142
|
+
function scopesFor(flags, defaults, profile, help) {
|
|
143
|
+
if (profile.mode === "application") {
|
|
144
|
+
if (flags.scopes !== undefined) {
|
|
145
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
if (flags.scopes === undefined)
|
|
150
|
+
return [...defaults];
|
|
151
|
+
const scopes = String(flags.scopes)
|
|
152
|
+
.split(",")
|
|
153
|
+
.map(scope => scope.trim())
|
|
154
|
+
.filter(scope => scope.length > 0);
|
|
155
|
+
return scopes;
|
|
156
|
+
}
|
|
157
|
+
function truncateValue(value, full) {
|
|
158
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
159
|
+
? { value: text, truncated: false }
|
|
160
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
161
|
+
if (typeof value === "string")
|
|
162
|
+
return truncate(value);
|
|
163
|
+
if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
|
|
164
|
+
const projected = value.map(truncate);
|
|
165
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
166
|
+
}
|
|
167
|
+
return { value, truncated: false };
|
|
168
|
+
}
|
|
169
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
170
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
171
|
+
function project(row, fields, full) {
|
|
172
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
173
|
+
const projected = {};
|
|
174
|
+
let truncated = false;
|
|
175
|
+
for (const field of fields) {
|
|
176
|
+
if (!Object.hasOwn(source, field))
|
|
177
|
+
continue;
|
|
178
|
+
const result = truncateValue(source[field], full);
|
|
179
|
+
projected[field] = result.value;
|
|
180
|
+
truncated = truncated || result.truncated;
|
|
181
|
+
}
|
|
182
|
+
return { row: projected, truncated };
|
|
183
|
+
}
|
|
184
|
+
function selectedFields(flags, defaults, help) {
|
|
185
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, KNOWN_CONTACTS, KNOWN_CONTACT_FIELDS, "select", help);
|
|
186
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, KNOWN_CONTACTS, KNOWN_CONTACT_FIELDS, "fields", help);
|
|
187
|
+
const missing = fields.find(field => !select.includes(field));
|
|
188
|
+
if (missing) {
|
|
189
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
190
|
+
}
|
|
191
|
+
return { select, fields };
|
|
192
|
+
}
|
|
193
|
+
function profileHint(profileName) {
|
|
194
|
+
return `--profile ${shellValue(profileName)}`;
|
|
195
|
+
}
|
|
196
|
+
function shellValue(value) {
|
|
197
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
198
|
+
}
|
|
199
|
+
function fullHint(command, flags, profileName) {
|
|
200
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
201
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
202
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
203
|
+
}
|
|
204
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
205
|
+
// operation adds the scope, roles and licensing that actually unlock it,
|
|
206
|
+
// because a 403 alone never says which prerequisite is missing.
|
|
207
|
+
function withGuidance(hints, run) {
|
|
208
|
+
return run().catch(error => {
|
|
209
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
210
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
211
|
+
}
|
|
212
|
+
throw error;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const EMPTY_NOTE = "0 organizational contacts returned; tenants without synced or Exchange-provisioned contacts carry none, so the absence of rows is the answer, not an error";
|
|
216
|
+
const CONTACT_DENIAL_HINTS = [
|
|
217
|
+
"Contact reads need OrgContact.Read.All for delegated or application access (Directory.Read.All is a documented higher-privileged alternative); delegated callers pass it as --scopes",
|
|
218
|
+
"Delegated contact reads additionally need a supported Entra role: Directory Readers reads basic properties, and Global Reader, Directory Writers, Intune Administrator or User Administrator also work",
|
|
219
|
+
"Personal Microsoft accounts are not supported for contact reads",
|
|
220
|
+
"No P1/P2 prerequisite is stated for contact reads; never diagnose role or licence solely from HTTP 403",
|
|
221
|
+
];
|
|
222
|
+
// Restoring the saved select and filter keeps cursor resumes lossless when
|
|
223
|
+
// --select/--filter are omitted. --filter passes through as plain $filter
|
|
224
|
+
// with $count=true and ConsistencyLevel eventual, the documented
|
|
225
|
+
// advanced-query contract for directory-object filtering; $search and
|
|
226
|
+
// $orderby stay unreviewed and strict input validation refuses them before
|
|
227
|
+
// credentials.
|
|
228
|
+
function collectionCommon(session, flags, operation, help, profile) {
|
|
229
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
230
|
+
if (cursor !== undefined && !cursor.trim())
|
|
231
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
232
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
233
|
+
const { select, fields } = selectedFields(flags, saved?.["$select"] === undefined ? DEFAULT_LIST_SELECT : fieldList(saved["$select"], KNOWN_CONTACTS, KNOWN_CONTACT_FIELDS, "select", help), help);
|
|
234
|
+
const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
|
|
235
|
+
return { cursor, select, fields, filter, scopes: scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help), full: flags.full === true };
|
|
236
|
+
}
|
|
237
|
+
function collectArgs(profile, operation, common, flags, help) {
|
|
238
|
+
const query = { $select: common.select.join(",") };
|
|
239
|
+
if (common.filter !== undefined) {
|
|
240
|
+
query.$filter = common.filter;
|
|
241
|
+
query.$count = "true";
|
|
242
|
+
}
|
|
243
|
+
const args = { profile, operation, query, scopes: common.scopes };
|
|
244
|
+
if (common.filter !== undefined)
|
|
245
|
+
args.consistencyLevel = "eventual";
|
|
246
|
+
if (common.cursor !== undefined)
|
|
247
|
+
args.cursor = common.cursor;
|
|
248
|
+
if (flags.all === true) {
|
|
249
|
+
if (flags.limit !== undefined)
|
|
250
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
254
|
+
}
|
|
255
|
+
return args;
|
|
256
|
+
}
|
|
257
|
+
function resumeHint(profileName) {
|
|
258
|
+
return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
|
|
259
|
+
}
|
|
260
|
+
function contactId(flags, help) {
|
|
261
|
+
const id = String(flags.id);
|
|
262
|
+
if (!id.trim())
|
|
263
|
+
throw new AxiError("--id needs the organizational-contact object ID", "VALIDATION_ERROR", [help]);
|
|
264
|
+
return id;
|
|
265
|
+
}
|
|
266
|
+
function singleResult(raw, fields, full) {
|
|
267
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
268
|
+
throw new AxiError("Graph returned a malformed contact body", "GRAPH_ERROR", [
|
|
269
|
+
"Single-contact reads carry one orgContact; treat anything else as unknown, not empty",
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
return project(raw, fields, full);
|
|
273
|
+
}
|
|
274
|
+
export async function listContacts(session, flags, profile, operation, help, profileName) {
|
|
275
|
+
checkReadOperation(operation, help);
|
|
276
|
+
const common = collectionCommon(session, flags, operation, help, profile);
|
|
277
|
+
const result = await withGuidance(CONTACT_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
|
|
278
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIST_SELECT.join(",") };
|
|
279
|
+
if (result.query.$filter !== undefined)
|
|
280
|
+
effectiveFlags.filter = result.query.$filter;
|
|
281
|
+
const contacts = [];
|
|
282
|
+
let truncated = false;
|
|
283
|
+
for (const row of result.value) {
|
|
284
|
+
const projected = project(row, common.fields, common.full);
|
|
285
|
+
contacts.push(projected.row);
|
|
286
|
+
truncated = truncated || projected.truncated;
|
|
287
|
+
}
|
|
288
|
+
const showHint = `mg-axi entra contact show --id <contact-id> ${profileHint(profileName)}`;
|
|
289
|
+
const truncationHints = truncated ? [fullHint("entra contact list", effectiveFlags, profileName)] : [];
|
|
290
|
+
if (!result.complete) {
|
|
291
|
+
return {
|
|
292
|
+
contacts,
|
|
293
|
+
count: { returned: contacts.length, complete: false, reason: result.reason },
|
|
294
|
+
cursor: result.cursor,
|
|
295
|
+
help: [...truncationHints, resumeHint(profileName), showHint],
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const count = { returned: contacts.length, complete: true };
|
|
299
|
+
if (!contacts.length) {
|
|
300
|
+
return {
|
|
301
|
+
contacts,
|
|
302
|
+
count,
|
|
303
|
+
help: [
|
|
304
|
+
`mg-axi entra contact list --filter <odata-filter> ${profileHint(profileName)}`,
|
|
305
|
+
"0 contacts matched; the absence of results is the answer, not an error",
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return { contacts, count, help: [...truncationHints, showHint] };
|
|
310
|
+
}
|
|
311
|
+
export async function showContact(session, flags, profile, operation, help, profileName) {
|
|
312
|
+
checkReadOperation(operation, help);
|
|
313
|
+
const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
|
|
314
|
+
const scopes = scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help);
|
|
315
|
+
const full = flags.full === true;
|
|
316
|
+
const id = contactId(flags, help);
|
|
317
|
+
const raw = await withGuidance(CONTACT_DENIAL_HINTS, () => session.execute({
|
|
318
|
+
profile,
|
|
319
|
+
operation,
|
|
320
|
+
params: { "orgContact-id": id },
|
|
321
|
+
query: { $select: select.join(",") },
|
|
322
|
+
scopes,
|
|
323
|
+
}));
|
|
324
|
+
const { row, truncated } = singleResult(raw, fields, full);
|
|
325
|
+
if (truncated)
|
|
326
|
+
return { contact: row, help: [fullHint("entra contact show", flags, profileName)] };
|
|
327
|
+
return { contact: row };
|
|
328
|
+
}
|
|
329
|
+
// The $count route returns a text/plain integer scalar rather than a JSON
|
|
330
|
+
// collection, so the leaf reads it through session.execute and accepts only
|
|
331
|
+
// a non-negative integer. The list page's $count example sends
|
|
332
|
+
// ConsistencyLevel eventual, so the scalar declares it too. There is no
|
|
333
|
+
// --filter/--select/--limit contract on the count: the catalogue declares no
|
|
334
|
+
// such flags and strict input validation refuses them before credentials.
|
|
335
|
+
export async function countContacts(session, flags, profile, operation, help, profileName) {
|
|
336
|
+
void profileName;
|
|
337
|
+
checkReadOperation(operation, help);
|
|
338
|
+
const scopes = scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help);
|
|
339
|
+
const raw = await withGuidance(CONTACT_DENIAL_HINTS, () => session.execute({ profile, operation, scopes, consistencyLevel: "eventual", scalar: true }));
|
|
340
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
341
|
+
throw new AxiError("Graph returned a malformed contact count body", "GRAPH_ERROR", [
|
|
342
|
+
"Contact counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
|
|
343
|
+
]);
|
|
344
|
+
}
|
|
345
|
+
const count = { returned: raw, complete: true };
|
|
346
|
+
return raw === 0 ? { count, help: [EMPTY_NOTE] } : { count };
|
|
347
|
+
}
|
|
348
|
+
// EXT-01m navigation (second part): the read mapping behind
|
|
349
|
+
// `mg-axi entra contact show-manager`, `list-direct-reports`,
|
|
350
|
+
// `show-direct-report` and `count-direct-reports`. Results are directory
|
|
351
|
+
// objects of mixed user/contact type, so rows carry the @odata.type
|
|
352
|
+
// discriminator plus minimal personal-data fields by default (id and
|
|
353
|
+
// displayName); mail needs an explicit --select and richer per-type fields
|
|
354
|
+
// need the subtype's single-object reads.
|
|
355
|
+
//
|
|
356
|
+
// Reviewed against the v1.0 orgcontact-get-manager and
|
|
357
|
+
// orgcontact-list-directreports operation documentation plus the orgContact
|
|
358
|
+
// resource reference on 2026-10-05. Both reads take delegated or
|
|
359
|
+
// application OrgContact.Read.All, the documented least privilege in each
|
|
360
|
+
// mode (Directory.Read.All, Directory.ReadWrite.All and Group.Read.All are
|
|
361
|
+
// documented only as higher-privileged alternatives); personal Microsoft
|
|
362
|
+
// accounts are not supported. Delegated callers additionally need a
|
|
363
|
+
// supported Entra role: Directory Readers reads basic properties, and
|
|
364
|
+
// Global Reader, Directory Writers, Intune Administrator or User
|
|
365
|
+
// Administrator also work. No P1/P2 prerequisite is stated for these reads.
|
|
366
|
+
// The directReports page documents $select only, so $filter/$search/$top
|
|
367
|
+
// stay unreviewed and strict input validation refuses them before
|
|
368
|
+
// credentials. The $count scalars carry no operation-level documentation
|
|
369
|
+
// page and follow the contacts $count contract (ConsistencyLevel eventual).
|
|
370
|
+
// The typed casts (graph.user, graph.orgContact) select one subtype through
|
|
371
|
+
// the documented OData-cast route with the same permission contract. The
|
|
372
|
+
// memberOf/transitiveMemberOf membership reads live in the EXT-01n block
|
|
373
|
+
// below; serviceProvisioningErrors and onPremisesSyncBehavior stay out as
|
|
374
|
+
// unavailable with no documented permission contract. Application callers with narrow consent receive
|
|
375
|
+
// limited-information rows carrying only @odata.type and id; those rows are
|
|
376
|
+
// preserved, never reinterpreted as empty.
|
|
377
|
+
// Every navigation property this slice may request or display. Users and
|
|
378
|
+
// contacts both carry these; anything else fails before credentials.
|
|
379
|
+
export const KNOWN_NAV_FIELDS = ["id", "displayName", "mail"];
|
|
380
|
+
const KNOWN_NAV = new Set(KNOWN_NAV_FIELDS);
|
|
381
|
+
// @odata.type is preserved on navigation rows without being selectable: it
|
|
382
|
+
// names the directory-object kind and marks limited-information rows.
|
|
383
|
+
const NAV_TYPE_PROPERTY = "@odata.type";
|
|
384
|
+
// Compact navigation rows stay minimal on purpose: id and displayName only.
|
|
385
|
+
const DEFAULT_NAV_SELECT = ["id", "displayName"];
|
|
386
|
+
// The --as values mirror the Graph subtype names in the cast routes.
|
|
387
|
+
export const NAV_CAST_VALUES = ["user", "orgContact"];
|
|
388
|
+
// Base direct-report operation to its typed-cast counterparts. --as selects
|
|
389
|
+
// between these catalogued routes; anything else is refused before
|
|
390
|
+
// credentials and never falls back to the unfiltered route silently.
|
|
391
|
+
const NAV_CASTS = {
|
|
392
|
+
"GET:/contacts/{orgContact-id}/directReports": {
|
|
393
|
+
user: "GET:/contacts/{orgContact-id}/directReports/graph.user",
|
|
394
|
+
orgContact: "GET:/contacts/{orgContact-id}/directReports/graph.orgContact",
|
|
395
|
+
},
|
|
396
|
+
"GET:/contacts/{orgContact-id}/directReports/$count": {
|
|
397
|
+
user: "GET:/contacts/{orgContact-id}/directReports/graph.user/$count",
|
|
398
|
+
orgContact: "GET:/contacts/{orgContact-id}/directReports/graph.orgContact/$count",
|
|
399
|
+
},
|
|
400
|
+
"GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}": {
|
|
401
|
+
user: "GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}/graph.user",
|
|
402
|
+
orgContact: "GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}/graph.orgContact",
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
export function castDirectReports(base, raw, help) {
|
|
406
|
+
const value = String(raw);
|
|
407
|
+
const alternate = NAV_CASTS[base]?.[value];
|
|
408
|
+
if (!alternate) {
|
|
409
|
+
throw new AxiError(`--as ${value} is not a direct-report cast`, "VALIDATION_ERROR", [
|
|
410
|
+
help,
|
|
411
|
+
`--as takes ${NAV_CAST_VALUES.join(" or ")} on direct-report list, show and count reads only`,
|
|
412
|
+
]);
|
|
413
|
+
}
|
|
414
|
+
return alternate;
|
|
415
|
+
}
|
|
416
|
+
// Navigation projection additionally preserves @odata.type so the
|
|
417
|
+
// directory-object kind and limited-information rows survive projection.
|
|
418
|
+
function projectNav(row, fields, full) {
|
|
419
|
+
const { row: projected, truncated } = project(row, fields, full);
|
|
420
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
421
|
+
if (typeof source[NAV_TYPE_PROPERTY] === "string")
|
|
422
|
+
projected[NAV_TYPE_PROPERTY] = source[NAV_TYPE_PROPERTY];
|
|
423
|
+
const descriptive = [...new Set([...fields, ...Object.keys(source)])]
|
|
424
|
+
.filter(field => field !== "id" && !field.startsWith("@"));
|
|
425
|
+
const limitedInfo = Object.hasOwn(source, "id") && descriptive.length > 0
|
|
426
|
+
&& descriptive.every(field => source[field] === null || source[field] === undefined || !Object.hasOwn(source, field));
|
|
427
|
+
return { row: projected, truncated, limitedInfo };
|
|
428
|
+
}
|
|
429
|
+
// The directReports page documents $select only: no $filter passthrough and
|
|
430
|
+
// no ConsistencyLevel contract here, so strict validation refuses filter
|
|
431
|
+
// flags before credentials and cursor resumes restore only the $select.
|
|
432
|
+
function navCollectionCommon(session, flags, operation, help, profile) {
|
|
433
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
434
|
+
if (cursor !== undefined && !cursor.trim())
|
|
435
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
436
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
437
|
+
const defaults = saved?.["$select"] === undefined ? [...DEFAULT_NAV_SELECT] : fieldList(saved["$select"], KNOWN_NAV, KNOWN_NAV_FIELDS, "select", help);
|
|
438
|
+
const { select, fields } = navFieldsFor(flags, help, defaults);
|
|
439
|
+
return { cursor, select, fields, scopes: scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help), full: flags.full === true };
|
|
440
|
+
}
|
|
441
|
+
function navCollectArgs(profile, operation, common, flags, help) {
|
|
442
|
+
const args = { profile, operation, query: { $select: common.select.join(",") }, scopes: common.scopes };
|
|
443
|
+
if (common.cursor !== undefined)
|
|
444
|
+
args.cursor = common.cursor;
|
|
445
|
+
if (flags.all === true) {
|
|
446
|
+
if (flags.limit !== undefined)
|
|
447
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
451
|
+
}
|
|
452
|
+
return args;
|
|
453
|
+
}
|
|
454
|
+
function navSingleResult(raw, fields, full, noun) {
|
|
455
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
456
|
+
throw new AxiError(`Graph returned a malformed ${noun} body`, "GRAPH_ERROR", [
|
|
457
|
+
`${noun} reads carry one directoryObject; treat anything else as unknown, not empty`,
|
|
458
|
+
]);
|
|
459
|
+
}
|
|
460
|
+
return projectNav(raw, fields, full);
|
|
461
|
+
}
|
|
462
|
+
function navFieldsFor(flags, help, defaults = DEFAULT_NAV_SELECT) {
|
|
463
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, KNOWN_NAV, KNOWN_NAV_FIELDS, "select", help);
|
|
464
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, KNOWN_NAV, KNOWN_NAV_FIELDS, "fields", help);
|
|
465
|
+
const missing = fields.find(field => !select.includes(field));
|
|
466
|
+
if (missing) {
|
|
467
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
468
|
+
}
|
|
469
|
+
return { select, fields };
|
|
470
|
+
}
|
|
471
|
+
export async function showContactManager(session, flags, profile, operation, help, profileName) {
|
|
472
|
+
checkReadOperation(operation, help);
|
|
473
|
+
const { select, fields } = navFieldsFor(flags, help);
|
|
474
|
+
const scopes = scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help);
|
|
475
|
+
const full = flags.full === true;
|
|
476
|
+
const id = contactId(flags, help);
|
|
477
|
+
const raw = await withGuidance(CONTACT_DENIAL_HINTS, () => session.execute({
|
|
478
|
+
profile,
|
|
479
|
+
operation,
|
|
480
|
+
params: { "orgContact-id": id },
|
|
481
|
+
query: { $select: select.join(",") },
|
|
482
|
+
scopes,
|
|
483
|
+
}));
|
|
484
|
+
const { row, truncated } = navSingleResult(raw, fields, full, "manager");
|
|
485
|
+
if (truncated)
|
|
486
|
+
return { manager: row, help: [fullHint("entra contact show-manager", flags, profileName)] };
|
|
487
|
+
return { manager: row };
|
|
488
|
+
}
|
|
489
|
+
export async function listContactDirectReports(session, flags, profile, operation, help, profileName) {
|
|
490
|
+
checkReadOperation(operation, help);
|
|
491
|
+
const common = navCollectionCommon(session, flags, operation, help, profile);
|
|
492
|
+
const id = contactId(flags, help);
|
|
493
|
+
const args = navCollectArgs(profile, operation, common, flags, help);
|
|
494
|
+
args.params = { "orgContact-id": id };
|
|
495
|
+
const result = await withGuidance(CONTACT_DENIAL_HINTS, () => session.collect(args));
|
|
496
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_NAV_SELECT.join(",") };
|
|
497
|
+
const directReports = [];
|
|
498
|
+
let truncated = false;
|
|
499
|
+
let limitedInfo = 0;
|
|
500
|
+
for (const row of result.value) {
|
|
501
|
+
const projected = projectNav(row, common.fields, common.full);
|
|
502
|
+
directReports.push(projected.row);
|
|
503
|
+
truncated = truncated || projected.truncated;
|
|
504
|
+
if (projected.limitedInfo)
|
|
505
|
+
limitedInfo += 1;
|
|
506
|
+
}
|
|
507
|
+
const showHint = `mg-axi entra contact show-direct-report --id <contact-id> --report-id <report-id> ${profileHint(profileName)}`;
|
|
508
|
+
const truncationHints = truncated ? [fullHint("entra contact list-direct-reports", effectiveFlags, profileName)] : [];
|
|
509
|
+
const limitedHints = limitedInfo > 0
|
|
510
|
+
? [`${limitedInfo} of ${directReports.length} rows carry only type and id; this may reflect limited read consent or unset properties`]
|
|
511
|
+
: [];
|
|
512
|
+
if (!result.complete) {
|
|
513
|
+
return {
|
|
514
|
+
directReports,
|
|
515
|
+
count: { returned: directReports.length, complete: false, reason: result.reason },
|
|
516
|
+
cursor: result.cursor,
|
|
517
|
+
help: [...truncationHints, ...limitedHints, resumeHint(profileName), showHint],
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
const count = { returned: directReports.length, complete: true };
|
|
521
|
+
if (!directReports.length) {
|
|
522
|
+
return {
|
|
523
|
+
directReports,
|
|
524
|
+
count,
|
|
525
|
+
help: [...limitedHints, "0 direct reports matched; the absence of results is the answer, not an error", showHint],
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
return { directReports, count, help: [...truncationHints, ...limitedHints, showHint] };
|
|
529
|
+
}
|
|
530
|
+
export async function showContactDirectReport(session, flags, profile, operation, help, profileName) {
|
|
531
|
+
checkReadOperation(operation, help);
|
|
532
|
+
const { select, fields } = navFieldsFor(flags, help);
|
|
533
|
+
const scopes = scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help);
|
|
534
|
+
const full = flags.full === true;
|
|
535
|
+
const id = contactId(flags, help);
|
|
536
|
+
const reportId = String(flags["report-id"]);
|
|
537
|
+
if (!reportId.trim())
|
|
538
|
+
throw new AxiError("--report-id needs the direct-report directory-object ID", "VALIDATION_ERROR", [help]);
|
|
539
|
+
const raw = await withGuidance(CONTACT_DENIAL_HINTS, () => session.execute({
|
|
540
|
+
profile,
|
|
541
|
+
operation,
|
|
542
|
+
params: { "orgContact-id": id, "directoryObject-id": reportId },
|
|
543
|
+
query: { $select: select.join(",") },
|
|
544
|
+
scopes,
|
|
545
|
+
}));
|
|
546
|
+
const { row, truncated } = navSingleResult(raw, fields, full, "direct-report");
|
|
547
|
+
if (truncated)
|
|
548
|
+
return { directReport: row, help: [fullHint("entra contact show-direct-report", flags, profileName)] };
|
|
549
|
+
return { directReport: row };
|
|
550
|
+
}
|
|
551
|
+
// The $count routes return a text/plain integer scalar rather than a JSON
|
|
552
|
+
// collection, so the leaf reads it through session.execute and accepts only
|
|
553
|
+
// a non-negative integer. The scalars carry no operation-level
|
|
554
|
+
// documentation page and follow the contacts $count contract
|
|
555
|
+
// (ConsistencyLevel eventual). There is no --select/--limit contract on the
|
|
556
|
+
// count: strict input validation refuses those flags before credentials.
|
|
557
|
+
export async function countContactDirectReports(session, flags, profile, operation, help, profileName) {
|
|
558
|
+
void profileName;
|
|
559
|
+
checkReadOperation(operation, help);
|
|
560
|
+
const scopes = scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help);
|
|
561
|
+
const id = contactId(flags, help);
|
|
562
|
+
const raw = await withGuidance(CONTACT_DENIAL_HINTS, () => session.execute({
|
|
563
|
+
profile, operation, params: { "orgContact-id": id }, scopes, consistencyLevel: "eventual", scalar: true,
|
|
564
|
+
}));
|
|
565
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
566
|
+
throw new AxiError("Graph returned a malformed direct-report count body", "GRAPH_ERROR", [
|
|
567
|
+
"Direct-report counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
|
|
568
|
+
]);
|
|
569
|
+
}
|
|
570
|
+
const count = { returned: raw, complete: true };
|
|
571
|
+
return raw === 0
|
|
572
|
+
? { count, help: ["0 direct reports returned; a contact with no reports set as their manager carries none, so the absence of rows is the answer, not an error"] }
|
|
573
|
+
: { count };
|
|
574
|
+
}
|
|
575
|
+
// EXT-01n membership (third part): the read mapping behind
|
|
576
|
+
// `mg-axi entra contact list-member-of`, `show-member-of` and
|
|
577
|
+
// `count-member-of`. Results are directory objects of group or
|
|
578
|
+
// administrativeUnit type, so rows carry the @odata.type discriminator plus
|
|
579
|
+
// minimal fields by default (id and displayName); mail needs an explicit
|
|
580
|
+
// --select and richer per-type fields need the subtype's single-object
|
|
581
|
+
// reads. --transitive selects the transitiveMemberOf flat closure instead of
|
|
582
|
+
// direct memberOf, and --as selects one typed cast route (graph.group or
|
|
583
|
+
// graph.administrativeUnit).
|
|
584
|
+
//
|
|
585
|
+
// Reviewed against the v1.0 orgcontact-list-memberof and
|
|
586
|
+
// orgcontact-list-transitivememberof operation documentation plus the
|
|
587
|
+
// orgContact resource reference on 2026-10-05. Direct reads take delegated
|
|
588
|
+
// or application OrgContact.Read.All, the documented least privilege in each
|
|
589
|
+
// mode (Directory.Read.All, Directory.ReadWrite.All and Group.Read.All are
|
|
590
|
+
// documented only as higher-privileged alternatives). Transitive reads take
|
|
591
|
+
// delegated or application OrgContact.Read.All and Group.Read.All together,
|
|
592
|
+
// the documented least privilege in each mode (Directory.Read.All is the
|
|
593
|
+
// documented higher-privileged alternative); both scopes are already in the
|
|
594
|
+
// shared READ_SCOPES allowlist, so no deferral is needed here. Delegated
|
|
595
|
+
// callers additionally need a supported Entra role: Directory Readers reads
|
|
596
|
+
// basic properties, and Global Reader, Directory Writers, Intune
|
|
597
|
+
// Administrator or User Administrator also work. Personal Microsoft accounts
|
|
598
|
+
// are not supported and no P1/P2 prerequisite is stated for these reads.
|
|
599
|
+
// The list pages document $filter, $count, $select, $search and $top with
|
|
600
|
+
// advanced query parameters only, so --filter passes through as plain
|
|
601
|
+
// $filter with $count=true and ConsistencyLevel eventual while $search and
|
|
602
|
+
// $orderby stay unreviewed and strict input validation refuses them before
|
|
603
|
+
// credentials. The $count scalars and the single/cast routes carry no
|
|
604
|
+
// operation-level documentation page and follow the parent list contract
|
|
605
|
+
// ($select only on singles, ConsistencyLevel eventual on scalars). The typed
|
|
606
|
+
// casts (graph.group, graph.administrativeUnit) select one subtype through
|
|
607
|
+
// the documented OData-cast route with the same permission contract.
|
|
608
|
+
// Application callers with narrow consent receive limited-information rows
|
|
609
|
+
// carrying only @odata.type and id; those rows are preserved, never
|
|
610
|
+
// reinterpreted as empty.
|
|
611
|
+
// Direct reads default to OrgContact.Read.All; transitive reads additionally
|
|
612
|
+
// need Group.Read.All. Both are read scopes already allowlisted.
|
|
613
|
+
export const DEFAULT_MEMBER_OF_SCOPES = ["https://graph.microsoft.com/OrgContact.Read.All"];
|
|
614
|
+
export const DEFAULT_TRANSITIVE_MEMBER_OF_SCOPES = [
|
|
615
|
+
"https://graph.microsoft.com/OrgContact.Read.All",
|
|
616
|
+
"https://graph.microsoft.com/Group.Read.All",
|
|
617
|
+
];
|
|
618
|
+
// The --as values mirror the Graph subtype names in the cast routes.
|
|
619
|
+
export const MEMBERSHIP_CAST_VALUES = ["group", "administrativeUnit"];
|
|
620
|
+
// Direct membership operation to its transitive flat-view counterpart.
|
|
621
|
+
// --transitive selects between these two catalogued routes; anything else is
|
|
622
|
+
// refused before credentials and never falls back silently.
|
|
623
|
+
const MEMBERSHIP_TRANSITIVE = {
|
|
624
|
+
"GET:/contacts/{orgContact-id}/memberOf": "GET:/contacts/{orgContact-id}/transitiveMemberOf",
|
|
625
|
+
"GET:/contacts/{orgContact-id}/memberOf/$count": "GET:/contacts/{orgContact-id}/transitiveMemberOf/$count",
|
|
626
|
+
"GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}": "GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}",
|
|
627
|
+
};
|
|
628
|
+
export function transitMembership(base, help) {
|
|
629
|
+
const alternate = MEMBERSHIP_TRANSITIVE[base];
|
|
630
|
+
if (!alternate) {
|
|
631
|
+
throw new AxiError("--transitive is available for contact member-of reads only", "VALIDATION_ERROR", [help]);
|
|
632
|
+
}
|
|
633
|
+
return alternate;
|
|
634
|
+
}
|
|
635
|
+
// Uncast membership operation to its typed-cast counterparts. --as selects
|
|
636
|
+
// between these catalogued routes; anything else is refused before
|
|
637
|
+
// credentials and never falls back to the unfiltered route silently.
|
|
638
|
+
const MEMBERSHIP_CASTS = {
|
|
639
|
+
"GET:/contacts/{orgContact-id}/memberOf": {
|
|
640
|
+
group: "GET:/contacts/{orgContact-id}/memberOf/graph.group",
|
|
641
|
+
administrativeUnit: "GET:/contacts/{orgContact-id}/memberOf/graph.administrativeUnit",
|
|
642
|
+
},
|
|
643
|
+
"GET:/contacts/{orgContact-id}/memberOf/$count": {
|
|
644
|
+
group: "GET:/contacts/{orgContact-id}/memberOf/graph.group/$count",
|
|
645
|
+
administrativeUnit: "GET:/contacts/{orgContact-id}/memberOf/graph.administrativeUnit/$count",
|
|
646
|
+
},
|
|
647
|
+
"GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}": {
|
|
648
|
+
group: "GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}/graph.group",
|
|
649
|
+
administrativeUnit: "GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}/graph.administrativeUnit",
|
|
650
|
+
},
|
|
651
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf": {
|
|
652
|
+
group: "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.group",
|
|
653
|
+
administrativeUnit: "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.administrativeUnit",
|
|
654
|
+
},
|
|
655
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf/$count": {
|
|
656
|
+
group: "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.group/$count",
|
|
657
|
+
administrativeUnit: "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.administrativeUnit/$count",
|
|
658
|
+
},
|
|
659
|
+
"GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}": {
|
|
660
|
+
group: "GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}/graph.group",
|
|
661
|
+
administrativeUnit: "GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}/graph.administrativeUnit",
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
export function castMembership(base, raw, help) {
|
|
665
|
+
const value = String(raw);
|
|
666
|
+
const alternate = MEMBERSHIP_CASTS[base]?.[value];
|
|
667
|
+
if (!alternate) {
|
|
668
|
+
throw new AxiError(`--as ${value} is not a member-of cast`, "VALIDATION_ERROR", [
|
|
669
|
+
help,
|
|
670
|
+
`--as takes ${MEMBERSHIP_CAST_VALUES.join(" or ")} on member-of list, show and count reads only`,
|
|
671
|
+
]);
|
|
672
|
+
}
|
|
673
|
+
return alternate;
|
|
674
|
+
}
|
|
675
|
+
function membershipScopesFor(flags, profile, help) {
|
|
676
|
+
const defaults = flags.transitive === true ? DEFAULT_TRANSITIVE_MEMBER_OF_SCOPES : DEFAULT_MEMBER_OF_SCOPES;
|
|
677
|
+
return scopesFor(flags, defaults, profile, help);
|
|
678
|
+
}
|
|
679
|
+
const MEMBER_OF_DENIAL_HINTS = [
|
|
680
|
+
"Contact memberOf reads need OrgContact.Read.All for delegated or application access (Directory.Read.All, Directory.ReadWrite.All and Group.Read.All are documented higher-privileged alternatives); delegated callers pass it as --scopes",
|
|
681
|
+
"Delegated memberOf reads additionally need a supported Entra role: Directory Readers reads basic properties, and Global Reader, Directory Writers, Intune Administrator or User Administrator also work",
|
|
682
|
+
"Personal Microsoft accounts are not supported for memberOf reads",
|
|
683
|
+
"No P1/P2 prerequisite is stated for memberOf reads; never diagnose role or licence solely from HTTP 403",
|
|
684
|
+
];
|
|
685
|
+
const TRANSITIVE_MEMBER_OF_DENIAL_HINTS = [
|
|
686
|
+
"Contact transitiveMemberOf reads need OrgContact.Read.All and Group.Read.All together for delegated or application access (Directory.Read.All is the documented higher-privileged alternative); delegated callers pass both as --scopes",
|
|
687
|
+
"With only one of the two scopes, groups outside the granted type return limited-information rows carrying only type and id",
|
|
688
|
+
"Delegated transitiveMemberOf reads additionally need a supported Entra role: Directory Readers reads basic properties, and Global Reader, Directory Writers, Intune Administrator or User Administrator also work",
|
|
689
|
+
"Personal Microsoft accounts are not supported for transitiveMemberOf reads",
|
|
690
|
+
"No P1/P2 prerequisite is stated for transitiveMemberOf reads; never diagnose role or licence solely from HTTP 403",
|
|
691
|
+
];
|
|
692
|
+
function membershipDenialHints(flags) {
|
|
693
|
+
return flags.transitive === true ? TRANSITIVE_MEMBER_OF_DENIAL_HINTS : MEMBER_OF_DENIAL_HINTS;
|
|
694
|
+
}
|
|
695
|
+
// The memberOf pages document $filter alongside $select with advanced query
|
|
696
|
+
// parameters only, so --filter passes through as plain $filter with
|
|
697
|
+
// $count=true and ConsistencyLevel eventual (the groups memberOf contract);
|
|
698
|
+
// $search and $orderby stay unreviewed. Restoring the saved select and
|
|
699
|
+
// filter keeps cursor resumes lossless when --select/--filter are omitted.
|
|
700
|
+
function membershipCollectionCommon(session, flags, operation, help, profile) {
|
|
701
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
702
|
+
if (cursor !== undefined && !cursor.trim())
|
|
703
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
704
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
705
|
+
const defaults = saved?.["$select"] === undefined ? [...DEFAULT_NAV_SELECT] : fieldList(saved["$select"], KNOWN_NAV, KNOWN_NAV_FIELDS, "select", help);
|
|
706
|
+
const { select, fields } = navFieldsFor(flags, help, defaults);
|
|
707
|
+
const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
|
|
708
|
+
return { cursor, select, fields, scopes: membershipScopesFor(flags, profile, help), full: flags.full === true, filter };
|
|
709
|
+
}
|
|
710
|
+
function membershipCollectArgs(profile, operation, common, flags, help) {
|
|
711
|
+
const query = { $select: common.select.join(",") };
|
|
712
|
+
if (common.filter !== undefined) {
|
|
713
|
+
query.$filter = common.filter;
|
|
714
|
+
query.$count = "true";
|
|
715
|
+
}
|
|
716
|
+
const args = { profile, operation, query, scopes: common.scopes };
|
|
717
|
+
if (common.filter !== undefined)
|
|
718
|
+
args.consistencyLevel = "eventual";
|
|
719
|
+
if (common.cursor !== undefined)
|
|
720
|
+
args.cursor = common.cursor;
|
|
721
|
+
if (flags.all === true) {
|
|
722
|
+
if (flags.limit !== undefined)
|
|
723
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
727
|
+
}
|
|
728
|
+
return args;
|
|
729
|
+
}
|
|
730
|
+
function membershipId(flags, help) {
|
|
731
|
+
const id = flags["member-id"] === undefined ? "" : String(flags["member-id"]);
|
|
732
|
+
if (!id.trim())
|
|
733
|
+
throw new AxiError("--member-id needs the membership directory-object ID", "VALIDATION_ERROR", [help]);
|
|
734
|
+
return id;
|
|
735
|
+
}
|
|
736
|
+
export async function listContactMemberOf(session, flags, profile, operation, help, profileName) {
|
|
737
|
+
checkReadOperation(operation, help);
|
|
738
|
+
const transitive = flags.transitive === true;
|
|
739
|
+
const common = membershipCollectionCommon(session, flags, operation, help, profile);
|
|
740
|
+
const id = contactId(flags, help);
|
|
741
|
+
const args = membershipCollectArgs(profile, operation, common, flags, help);
|
|
742
|
+
args.params = { "orgContact-id": id };
|
|
743
|
+
const result = await withGuidance(membershipDenialHints(flags), () => session.collect(args));
|
|
744
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_NAV_SELECT.join(",") };
|
|
745
|
+
if (result.query.$filter !== undefined)
|
|
746
|
+
effectiveFlags.filter = result.query.$filter;
|
|
747
|
+
const memberOf = [];
|
|
748
|
+
let truncated = false;
|
|
749
|
+
let limitedInfo = 0;
|
|
750
|
+
for (const row of result.value) {
|
|
751
|
+
const projected = projectNav(row, common.fields, common.full);
|
|
752
|
+
memberOf.push(projected.row);
|
|
753
|
+
truncated = truncated || projected.truncated;
|
|
754
|
+
if (projected.limitedInfo)
|
|
755
|
+
limitedInfo += 1;
|
|
756
|
+
}
|
|
757
|
+
const noun = transitive ? "transitiveMemberOf" : "memberOf";
|
|
758
|
+
const command = "entra contact list-member-of";
|
|
759
|
+
const showHint = `mg-axi entra contact show-member-of --id <contact-id> --member-id <membership-id>${transitive ? " --transitive" : ""} ${profileHint(profileName)}`;
|
|
760
|
+
const scopeHint = transitive
|
|
761
|
+
? `Direct memberships only: mg-axi ${command} --id ${shellValue(id)} ${profileHint(profileName)}`
|
|
762
|
+
: `Flat nested view: mg-axi ${command} --id ${shellValue(id)} --transitive ${profileHint(profileName)}`;
|
|
763
|
+
const truncationHints = truncated ? [fullHint(command, effectiveFlags, profileName)] : [];
|
|
764
|
+
const limitedHints = limitedInfo > 0
|
|
765
|
+
? [`${limitedInfo} of ${memberOf.length} rows carry only type and id; this may reflect limited read consent or unset properties`]
|
|
766
|
+
: [];
|
|
767
|
+
if (!result.complete) {
|
|
768
|
+
return {
|
|
769
|
+
memberOf,
|
|
770
|
+
count: { returned: memberOf.length, complete: false, reason: result.reason },
|
|
771
|
+
cursor: result.cursor,
|
|
772
|
+
help: [...truncationHints, ...limitedHints, resumeHint(profileName), scopeHint, showHint],
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const count = { returned: memberOf.length, complete: true };
|
|
776
|
+
if (!memberOf.length) {
|
|
777
|
+
return {
|
|
778
|
+
memberOf,
|
|
779
|
+
count,
|
|
780
|
+
help: [...limitedHints, `0 ${noun} matched; the absence of results is the answer, not an error`, scopeHint, showHint],
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
return { memberOf, count, help: [...truncationHints, ...limitedHints, scopeHint, showHint] };
|
|
784
|
+
}
|
|
785
|
+
export async function showContactMemberOf(session, flags, profile, operation, help, profileName) {
|
|
786
|
+
checkReadOperation(operation, help);
|
|
787
|
+
const { select, fields } = navFieldsFor(flags, help);
|
|
788
|
+
const scopes = membershipScopesFor(flags, profile, help);
|
|
789
|
+
const full = flags.full === true;
|
|
790
|
+
const id = contactId(flags, help);
|
|
791
|
+
const memberId = membershipId(flags, help);
|
|
792
|
+
const raw = await withGuidance(membershipDenialHints(flags), () => session.execute({
|
|
793
|
+
profile,
|
|
794
|
+
operation,
|
|
795
|
+
params: { "orgContact-id": id, "directoryObject-id": memberId },
|
|
796
|
+
query: { $select: select.join(",") },
|
|
797
|
+
scopes,
|
|
798
|
+
}));
|
|
799
|
+
const { row, truncated } = navSingleResult(raw, fields, full, "member-of");
|
|
800
|
+
if (truncated)
|
|
801
|
+
return { memberOf: row, help: [fullHint("entra contact show-member-of", flags, profileName)] };
|
|
802
|
+
return { memberOf: row };
|
|
803
|
+
}
|
|
804
|
+
// The $count routes return a text/plain integer scalar rather than a JSON
|
|
805
|
+
// collection, so the leaf reads it through session.execute and accepts only
|
|
806
|
+
// a non-negative integer. The scalars carry no operation-level
|
|
807
|
+
// documentation page and follow the parent list contract (ConsistencyLevel
|
|
808
|
+
// eventual). There is no --select/--limit contract on the count: strict
|
|
809
|
+
// input validation refuses those flags before credentials.
|
|
810
|
+
export async function countContactMemberOf(session, flags, profile, operation, help, profileName) {
|
|
811
|
+
void profileName;
|
|
812
|
+
checkReadOperation(operation, help);
|
|
813
|
+
const scopes = membershipScopesFor(flags, profile, help);
|
|
814
|
+
const id = contactId(flags, help);
|
|
815
|
+
const raw = await withGuidance(membershipDenialHints(flags), () => session.execute({
|
|
816
|
+
profile, operation, params: { "orgContact-id": id }, scopes, consistencyLevel: "eventual", scalar: true,
|
|
817
|
+
}));
|
|
818
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
819
|
+
throw new AxiError("Graph returned a malformed member-of count body", "GRAPH_ERROR", [
|
|
820
|
+
"Member-of counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
|
|
821
|
+
]);
|
|
822
|
+
}
|
|
823
|
+
const count = { returned: raw, complete: true };
|
|
824
|
+
return raw === 0
|
|
825
|
+
? { count, help: ["0 memberships returned; a contact outside every group and administrative unit carries none, so the absence of rows is the answer, not an error"] }
|
|
826
|
+
: { count };
|
|
827
|
+
}
|