@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,309 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// EXT-04 multi-tenant-organization subfamily: the read mapping behind
|
|
3
|
+
// `mg-axi entra multi-tenant-organization show`,
|
|
4
|
+
// `mg-axi entra multi-tenant-organization join-request show`,
|
|
5
|
+
// `mg-axi entra multi-tenant-organization tenant list` and
|
|
6
|
+
// `mg-axi entra multi-tenant-organization tenant count`. Operation
|
|
7
|
+
// construction stays beside its command; the shared session owns URLs,
|
|
8
|
+
// credentials, paging, retries and error translation, and the SDK owns TOON
|
|
9
|
+
// rendering. This module only maps flags to session calls and projects rows
|
|
10
|
+
// for compact output.
|
|
11
|
+
//
|
|
12
|
+
// Reviewed against the v1.0 multiTenantOrganization get, the
|
|
13
|
+
// joinRequestRecord get, the tenant list and the multiTenantOrganization,
|
|
14
|
+
// joinRequestRecord and member resource pages on 2026-10-05. Every read
|
|
15
|
+
// takes D/A MultiTenantOrganization.Read.All as the full-property scope;
|
|
16
|
+
// delegated callers pass it as --scopes while the lower-privileged delegated
|
|
17
|
+
// MultiTenantOrganization.ReadBasic.All returns displayName and tenantId
|
|
18
|
+
// only; application callers need Read.All admin-consented on the configured
|
|
19
|
+
// .default audience. Delegated callers additionally need a supported Entra
|
|
20
|
+
// role (Security Reader or Global Reader are least-privileged). Personal
|
|
21
|
+
// Microsoft accounts are not supported. These reads run in the commercial
|
|
22
|
+
// Global service only; sovereign clouds are not supported. The list
|
|
23
|
+
// documents $select and $filter only, so --filter passes through as plain
|
|
24
|
+
// $filter with no $count or ConsistencyLevel contract. The $count route
|
|
25
|
+
// returns a text/plain integer scalar with no --filter/--select/--limit
|
|
26
|
+
// contract. A tenant outside any multitenant organization reads the
|
|
27
|
+
// container as state inactive with null properties, which is an answer
|
|
28
|
+
// rather than an error; at most one multitenant organization exists per
|
|
29
|
+
// tenant. The single-member read stays scheduled: its documented least
|
|
30
|
+
// privilege is the write scope MultiTenantOrganization.ReadWrite.All in
|
|
31
|
+
// both modes. The tenant-lookup functions ship as tenant-information show
|
|
32
|
+
// under the mg-ext-04e function-argument contract. Beta stays out. No multi-tenant-organization mutation exists
|
|
33
|
+
// in this slice: creation, update, member add/remove and join acceptance
|
|
34
|
+
// are writes.
|
|
35
|
+
// Every organization property this slice may request or display, matching
|
|
36
|
+
// the reviewed multiTenantOrganization resource. Anything else fails before
|
|
37
|
+
// credentials.
|
|
38
|
+
export const KNOWN_MTO_FIELDS = [
|
|
39
|
+
"createdDateTime",
|
|
40
|
+
"description",
|
|
41
|
+
"displayName",
|
|
42
|
+
"id",
|
|
43
|
+
"state",
|
|
44
|
+
];
|
|
45
|
+
// Every join-request property this slice may request or display, matching
|
|
46
|
+
// the reviewed joinRequestRecord resource.
|
|
47
|
+
export const KNOWN_MTO_JOIN_REQUEST_FIELDS = [
|
|
48
|
+
"addedByTenantId",
|
|
49
|
+
"id",
|
|
50
|
+
"memberState",
|
|
51
|
+
"role",
|
|
52
|
+
"transitionDetails",
|
|
53
|
+
];
|
|
54
|
+
// Every member property this slice may request or display, matching the
|
|
55
|
+
// reviewed member resource.
|
|
56
|
+
export const KNOWN_MTO_TENANT_FIELDS = [
|
|
57
|
+
"addedByTenantId",
|
|
58
|
+
"addedDateTime",
|
|
59
|
+
"displayName",
|
|
60
|
+
"joinedDateTime",
|
|
61
|
+
"role",
|
|
62
|
+
"state",
|
|
63
|
+
"tenantId",
|
|
64
|
+
"transitionDetails",
|
|
65
|
+
];
|
|
66
|
+
const KNOWN_MTO = new Set(KNOWN_MTO_FIELDS);
|
|
67
|
+
const KNOWN_JOIN_REQUEST = new Set(KNOWN_MTO_JOIN_REQUEST_FIELDS);
|
|
68
|
+
const KNOWN_TENANTS = new Set(KNOWN_MTO_TENANT_FIELDS);
|
|
69
|
+
// Singleton rows: the full reviewed set; the container carries only five
|
|
70
|
+
// scalar properties and the join request five.
|
|
71
|
+
const DEFAULT_MTO_SHOW_SELECT = [...KNOWN_MTO_FIELDS];
|
|
72
|
+
const DEFAULT_JOIN_REQUEST_SHOW_SELECT = [...KNOWN_MTO_JOIN_REQUEST_FIELDS];
|
|
73
|
+
// Compact member rows: the member tenant identifier and name, the role that
|
|
74
|
+
// decides management attention and the state that decides participation.
|
|
75
|
+
const DEFAULT_TENANT_LIST_SELECT = ["tenantId", "displayName", "role", "state"];
|
|
76
|
+
// Delegated defaults are operation-specific; application profiles use their
|
|
77
|
+
// configured .default audience and reject --scopes.
|
|
78
|
+
export const DEFAULT_MTO_SCOPES = ["https://graph.microsoft.com/MultiTenantOrganization.Read.All"];
|
|
79
|
+
const TRUNCATE_AT = 500;
|
|
80
|
+
function fieldList(raw, known, knownList, flag, help) {
|
|
81
|
+
const parts = String(raw)
|
|
82
|
+
.split(",")
|
|
83
|
+
.map(part => part.trim())
|
|
84
|
+
.filter(part => part.length > 0);
|
|
85
|
+
if (!parts.length)
|
|
86
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
87
|
+
const fields = [];
|
|
88
|
+
for (const part of parts) {
|
|
89
|
+
if (!known.has(part)) {
|
|
90
|
+
throw new AxiError(`Unknown multi-tenant-organization property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
91
|
+
help,
|
|
92
|
+
`Known properties: ${knownList.join(", ")}`,
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
if (!fields.includes(part))
|
|
96
|
+
fields.push(part);
|
|
97
|
+
}
|
|
98
|
+
return fields;
|
|
99
|
+
}
|
|
100
|
+
function scopesFor(flags, defaults, profile, help) {
|
|
101
|
+
if (profile.mode === "application") {
|
|
102
|
+
if (flags.scopes !== undefined) {
|
|
103
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (flags.scopes === undefined)
|
|
108
|
+
return [...defaults];
|
|
109
|
+
const scopes = String(flags.scopes)
|
|
110
|
+
.split(",")
|
|
111
|
+
.map(scope => scope.trim())
|
|
112
|
+
.filter(scope => scope.length > 0);
|
|
113
|
+
return scopes;
|
|
114
|
+
}
|
|
115
|
+
function truncateValue(value, full) {
|
|
116
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
117
|
+
? { value: text, truncated: false }
|
|
118
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
119
|
+
if (typeof value === "string")
|
|
120
|
+
return truncate(value);
|
|
121
|
+
if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
|
|
122
|
+
const projected = value.map(truncate);
|
|
123
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
124
|
+
}
|
|
125
|
+
return { value, truncated: false };
|
|
126
|
+
}
|
|
127
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
128
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
129
|
+
function project(row, fields, full) {
|
|
130
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
131
|
+
const projected = {};
|
|
132
|
+
let truncated = false;
|
|
133
|
+
for (const field of fields) {
|
|
134
|
+
if (!Object.hasOwn(source, field))
|
|
135
|
+
continue;
|
|
136
|
+
const result = truncateValue(source[field], full);
|
|
137
|
+
projected[field] = result.value;
|
|
138
|
+
truncated = truncated || result.truncated;
|
|
139
|
+
}
|
|
140
|
+
return { row: projected, truncated };
|
|
141
|
+
}
|
|
142
|
+
function selectedFields(flags, known, knownList, defaults, help) {
|
|
143
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, knownList, "select", help);
|
|
144
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, knownList, "fields", help);
|
|
145
|
+
const missing = fields.find(field => !select.includes(field));
|
|
146
|
+
if (missing) {
|
|
147
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
148
|
+
}
|
|
149
|
+
return { select, fields };
|
|
150
|
+
}
|
|
151
|
+
function profileHint(profileName) {
|
|
152
|
+
return `--profile ${shellValue(profileName)}`;
|
|
153
|
+
}
|
|
154
|
+
function shellValue(value) {
|
|
155
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
156
|
+
}
|
|
157
|
+
function fullHint(command, flags, profileName) {
|
|
158
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
159
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
160
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
161
|
+
}
|
|
162
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
163
|
+
// operation adds the scope, roles and prerequisites that actually unlock it,
|
|
164
|
+
// because a 403 alone never says which prerequisite is missing.
|
|
165
|
+
function withGuidance(hints, run) {
|
|
166
|
+
return run().catch(error => {
|
|
167
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
168
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const MTO_DENIAL_HINTS = [
|
|
174
|
+
"Multi-tenant-organization reads need MultiTenantOrganization.Read.All, passed as --scopes for delegated access or admin-consented for application access; the lower-privileged delegated MultiTenantOrganization.ReadBasic.All returns displayName and tenantId only",
|
|
175
|
+
"Delegated multi-tenant-organization reads additionally need Security Reader or Global Reader, the least-privileged supported Entra roles",
|
|
176
|
+
"Multi-tenant-organization reads run in the commercial Global service; sovereign clouds are not supported for these reads",
|
|
177
|
+
"Personal Microsoft accounts are not supported for multi-tenant-organization reads",
|
|
178
|
+
"Multi-tenant-organization participation needs Entra ID P1; never diagnose licence solely from HTTP 403",
|
|
179
|
+
];
|
|
180
|
+
const MTO_NOTE = "At most one multitenant organization exists per tenant; a tenant outside any multitenant organization reads empty or inactive, which is an answer rather than an error";
|
|
181
|
+
const ORGANIZATION = {
|
|
182
|
+
command: "entra multi-tenant-organization show",
|
|
183
|
+
known: KNOWN_MTO,
|
|
184
|
+
knownList: KNOWN_MTO_FIELDS,
|
|
185
|
+
showSelect: DEFAULT_MTO_SHOW_SELECT,
|
|
186
|
+
rowKey: "multiTenantOrganization",
|
|
187
|
+
noun: "multitenant organization",
|
|
188
|
+
};
|
|
189
|
+
const JOIN_REQUEST = {
|
|
190
|
+
command: "entra multi-tenant-organization join-request show",
|
|
191
|
+
known: KNOWN_JOIN_REQUEST,
|
|
192
|
+
knownList: KNOWN_MTO_JOIN_REQUEST_FIELDS,
|
|
193
|
+
showSelect: DEFAULT_JOIN_REQUEST_SHOW_SELECT,
|
|
194
|
+
rowKey: "multiTenantOrganizationJoinRequest",
|
|
195
|
+
noun: "join request",
|
|
196
|
+
};
|
|
197
|
+
// Restoring the saved select/filter keeps cursor resumes lossless when
|
|
198
|
+
// --select or --filter is omitted. The tenant list passes --filter through
|
|
199
|
+
// as plain $filter with no $count or ConsistencyLevel contract.
|
|
200
|
+
function collectionCommon(session, flags, operation, help, profile) {
|
|
201
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
202
|
+
if (cursor !== undefined && !cursor.trim())
|
|
203
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
204
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
205
|
+
const { select, fields } = selectedFields(flags, KNOWN_TENANTS, KNOWN_MTO_TENANT_FIELDS, saved?.["$select"] === undefined ? DEFAULT_TENANT_LIST_SELECT : fieldList(saved["$select"], KNOWN_TENANTS, KNOWN_MTO_TENANT_FIELDS, "select", help), help);
|
|
206
|
+
const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
|
|
207
|
+
return { cursor, select, fields, scopes: scopesFor(flags, DEFAULT_MTO_SCOPES, profile, help), full: flags.full === true, filter };
|
|
208
|
+
}
|
|
209
|
+
function collectArgs(profile, operation, common, flags, help) {
|
|
210
|
+
const query = { $select: common.select.join(",") };
|
|
211
|
+
if (common.filter !== undefined)
|
|
212
|
+
query.$filter = common.filter;
|
|
213
|
+
const args = { profile, operation, query, scopes: common.scopes };
|
|
214
|
+
if (common.cursor !== undefined)
|
|
215
|
+
args.cursor = common.cursor;
|
|
216
|
+
if (flags.all === true) {
|
|
217
|
+
if (flags.limit !== undefined)
|
|
218
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
222
|
+
}
|
|
223
|
+
return args;
|
|
224
|
+
}
|
|
225
|
+
function resumeHint(profileName) {
|
|
226
|
+
return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
|
|
227
|
+
}
|
|
228
|
+
async function showSingleton(session, flags, profile, operation, help, profileName, resource) {
|
|
229
|
+
const { select, fields } = selectedFields(flags, resource.known, resource.knownList, resource.showSelect, help);
|
|
230
|
+
const scopes = scopesFor(flags, DEFAULT_MTO_SCOPES, profile, help);
|
|
231
|
+
const full = flags.full === true;
|
|
232
|
+
const raw = await withGuidance(MTO_DENIAL_HINTS, () => session.execute({
|
|
233
|
+
profile,
|
|
234
|
+
operation,
|
|
235
|
+
query: { $select: select.join(",") },
|
|
236
|
+
scopes,
|
|
237
|
+
}));
|
|
238
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
239
|
+
throw new AxiError(`Graph returned a malformed multi-tenant-organization ${resource.noun} body`, "GRAPH_ERROR", [
|
|
240
|
+
`Single-${resource.noun} reads carry one ${resource.noun} object; treat anything else as unknown, not empty`,
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
const { row, truncated } = project(raw, fields, full);
|
|
244
|
+
// The container answers state inactive with null properties when the
|
|
245
|
+
// tenant joined no multitenant organization; that absence is the answer.
|
|
246
|
+
const inactive = resource === ORGANIZATION && row.state === "inactive";
|
|
247
|
+
const hints = [
|
|
248
|
+
...(truncated ? [fullHint(resource.command, flags, profileName)] : []),
|
|
249
|
+
...(inactive ? ["This tenant is not part of any multitenant organization (state inactive); the absence of membership is the answer, not an error"] : []),
|
|
250
|
+
];
|
|
251
|
+
if (hints.length)
|
|
252
|
+
return { [resource.rowKey]: row, help: [...hints, MTO_NOTE] };
|
|
253
|
+
return { [resource.rowKey]: row };
|
|
254
|
+
}
|
|
255
|
+
export async function showMultiTenantOrganization(session, flags, profile, operation, help, profileName) {
|
|
256
|
+
return showSingleton(session, flags, profile, operation, help, profileName, ORGANIZATION);
|
|
257
|
+
}
|
|
258
|
+
export async function showMultiTenantOrganizationJoinRequest(session, flags, profile, operation, help, profileName) {
|
|
259
|
+
return showSingleton(session, flags, profile, operation, help, profileName, JOIN_REQUEST);
|
|
260
|
+
}
|
|
261
|
+
export async function listMultiTenantOrganizationTenants(session, flags, profile, operation, help, profileName) {
|
|
262
|
+
const common = collectionCommon(session, flags, operation, help, profile);
|
|
263
|
+
const result = await withGuidance(MTO_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
|
|
264
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_TENANT_LIST_SELECT.join(",") };
|
|
265
|
+
if (result.query.$filter !== undefined)
|
|
266
|
+
effectiveFlags.filter = result.query.$filter;
|
|
267
|
+
const rows = [];
|
|
268
|
+
let truncated = false;
|
|
269
|
+
for (const row of result.value) {
|
|
270
|
+
const projected = project(row, common.fields, common.full);
|
|
271
|
+
rows.push(projected.row);
|
|
272
|
+
truncated = truncated || projected.truncated;
|
|
273
|
+
}
|
|
274
|
+
const countHint = `mg-axi entra multi-tenant-organization tenant count ${profileHint(profileName)}`;
|
|
275
|
+
const truncationHints = truncated ? [fullHint("entra multi-tenant-organization tenant list", effectiveFlags, profileName)] : [];
|
|
276
|
+
if (!result.complete) {
|
|
277
|
+
return {
|
|
278
|
+
multiTenantOrganizationTenants: rows,
|
|
279
|
+
count: { returned: rows.length, complete: false, reason: result.reason },
|
|
280
|
+
cursor: result.cursor,
|
|
281
|
+
help: [...truncationHints, resumeHint(profileName), countHint, MTO_NOTE],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const count = { returned: rows.length, complete: true };
|
|
285
|
+
if (!rows.length) {
|
|
286
|
+
return {
|
|
287
|
+
multiTenantOrganizationTenants: rows,
|
|
288
|
+
count,
|
|
289
|
+
help: ["0 tenants matched; the absence of results is the answer, not an error", MTO_NOTE],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { multiTenantOrganizationTenants: rows, count, help: [...truncationHints, countHint, MTO_NOTE] };
|
|
293
|
+
}
|
|
294
|
+
// The $count route returns a text/plain integer scalar rather than a JSON
|
|
295
|
+
// collection, so the leaf reads it through session.execute and accepts only
|
|
296
|
+
// a non-negative integer. There is no --filter/--select/--limit contract on
|
|
297
|
+
// the count: the catalogue declares no such flags and strict input
|
|
298
|
+
// validation refuses them before credentials.
|
|
299
|
+
export async function countMultiTenantOrganizationTenants(session, flags, profile, operation, help, profileName) {
|
|
300
|
+
void profileName;
|
|
301
|
+
const scopes = scopesFor(flags, DEFAULT_MTO_SCOPES, profile, help);
|
|
302
|
+
const raw = await withGuidance(MTO_DENIAL_HINTS, () => session.execute({ profile, operation, scopes }));
|
|
303
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
304
|
+
throw new AxiError("Graph returned a malformed multi-tenant-organization tenant count body", "GRAPH_ERROR", [
|
|
305
|
+
"Tenant counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
|
|
306
|
+
]);
|
|
307
|
+
}
|
|
308
|
+
return { multiTenantOrganizationTenantCount: raw };
|
|
309
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// EXT-01 on-premises-synchronization subfamily: the read mapping behind
|
|
3
|
+
// `mg-axi entra on-premises-synchronization list/show`. Operation
|
|
4
|
+
// construction stays beside its command; the shared session owns URLs,
|
|
5
|
+
// credentials, paging, retries and error translation, and the SDK owns TOON
|
|
6
|
+
// rendering. This module only maps flags to session calls and projects rows
|
|
7
|
+
// for compact output.
|
|
8
|
+
//
|
|
9
|
+
// Reviewed against the v1.0 onpremisesdirectorysynchronization-get
|
|
10
|
+
// operation documentation and the onPremisesDirectorySynchronization
|
|
11
|
+
// resource reference on 2026-10-05, corroborated by the sibling
|
|
12
|
+
// onpremisesdirectorysynchronization-update page (same permission table,
|
|
13
|
+
// same Global Administrator-only role, same unsupported application and
|
|
14
|
+
// personal-account rows). Both reads take delegated
|
|
15
|
+
// OnPremDirectorySynchronization.Read.All; Graph documents no supported
|
|
16
|
+
// application permission for this operation, so application profiles are
|
|
17
|
+
// refused before credentials rather than sent to a certain denial.
|
|
18
|
+
// Delegated callers additionally need Global Administrator, the only
|
|
19
|
+
// supported role (a custom role with a supported permission also works).
|
|
20
|
+
// Personal Microsoft accounts are not supported. No P1/P2 prerequisite is
|
|
21
|
+
// stated for these reads. Every reviewed read documents $select only, so
|
|
22
|
+
// neither leaf offers --filter. The $count scalar stays scheduled for a
|
|
23
|
+
// later counts subfamily, beta stays out ("beta needs its own review") and
|
|
24
|
+
// no mutation lives here.
|
|
25
|
+
//
|
|
26
|
+
// Secrecy by construction: the reviewed set carries the tenant id plus the
|
|
27
|
+
// configuration and features objects only, and the shared session still
|
|
28
|
+
// redacts secret-shaped values (credential, secret, key and token shapes)
|
|
29
|
+
// before buffering or output, so sync configuration never leaks keys or
|
|
30
|
+
// passwords through these reads.
|
|
31
|
+
// Every on-premises-synchronization property this slice may request or
|
|
32
|
+
// display, matching the reviewed resource. Anything else fails before
|
|
33
|
+
// credentials.
|
|
34
|
+
export const KNOWN_SYNC_FIELDS = [
|
|
35
|
+
"id",
|
|
36
|
+
"configuration",
|
|
37
|
+
"features",
|
|
38
|
+
];
|
|
39
|
+
const KNOWN_SYNCS = new Set(KNOWN_SYNC_FIELDS);
|
|
40
|
+
// The resource carries only three properties, so compact list rows and show
|
|
41
|
+
// rows share the full reviewed set: the tenant id plus the sync
|
|
42
|
+
// configuration and feature flags agents need next.
|
|
43
|
+
const DEFAULT_LIST_SELECT = [...KNOWN_SYNC_FIELDS];
|
|
44
|
+
const DEFAULT_SHOW_SELECT = [...KNOWN_SYNC_FIELDS];
|
|
45
|
+
// Delegated default; application profiles are refused before credentials
|
|
46
|
+
// because Graph documents no supported application permission here.
|
|
47
|
+
export const DEFAULT_SYNC_SCOPES = ["https://graph.microsoft.com/OnPremDirectorySynchronization.Read.All"];
|
|
48
|
+
const TRUNCATE_AT = 500;
|
|
49
|
+
function fieldList(raw, known, knownList, flag, help) {
|
|
50
|
+
const parts = String(raw)
|
|
51
|
+
.split(",")
|
|
52
|
+
.map(part => part.trim())
|
|
53
|
+
.filter(part => part.length > 0);
|
|
54
|
+
if (!parts.length)
|
|
55
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
56
|
+
const fields = [];
|
|
57
|
+
for (const part of parts) {
|
|
58
|
+
if (!known.has(part)) {
|
|
59
|
+
throw new AxiError(`Unknown on-premises-synchronization property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
60
|
+
help,
|
|
61
|
+
`Known on-premises-synchronization properties: ${knownList.join(", ")}`,
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
if (!fields.includes(part))
|
|
65
|
+
fields.push(part);
|
|
66
|
+
}
|
|
67
|
+
return fields;
|
|
68
|
+
}
|
|
69
|
+
function scopesFor(flags, defaults, profile, help) {
|
|
70
|
+
if (profile.mode === "application") {
|
|
71
|
+
throw new AxiError("On-premises-synchronization reads need a delegated profile; Graph documents no supported application permission for this operation", "VALIDATION_ERROR", [help, "mg-axi profile create --name <name> --tenant <tenant-id> --client <client-id> --cloud commercial"]);
|
|
72
|
+
}
|
|
73
|
+
if (flags.scopes === undefined)
|
|
74
|
+
return [...defaults];
|
|
75
|
+
const scopes = String(flags.scopes)
|
|
76
|
+
.split(",")
|
|
77
|
+
.map(scope => scope.trim())
|
|
78
|
+
.filter(scope => scope.length > 0);
|
|
79
|
+
return scopes;
|
|
80
|
+
}
|
|
81
|
+
function truncateValue(value, full) {
|
|
82
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
83
|
+
? { value: text, truncated: false }
|
|
84
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
85
|
+
if (typeof value === "string")
|
|
86
|
+
return truncate(value);
|
|
87
|
+
if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
|
|
88
|
+
const projected = value.map(truncate);
|
|
89
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
90
|
+
}
|
|
91
|
+
return { value, truncated: false };
|
|
92
|
+
}
|
|
93
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
94
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
95
|
+
function project(row, fields, full) {
|
|
96
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
97
|
+
const projected = {};
|
|
98
|
+
let truncated = false;
|
|
99
|
+
for (const field of fields) {
|
|
100
|
+
if (!Object.hasOwn(source, field))
|
|
101
|
+
continue;
|
|
102
|
+
const result = truncateValue(source[field], full);
|
|
103
|
+
projected[field] = result.value;
|
|
104
|
+
truncated = truncated || result.truncated;
|
|
105
|
+
}
|
|
106
|
+
return { row: projected, truncated };
|
|
107
|
+
}
|
|
108
|
+
function selectedFields(flags, defaults, help) {
|
|
109
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, KNOWN_SYNCS, KNOWN_SYNC_FIELDS, "select", help);
|
|
110
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, KNOWN_SYNCS, KNOWN_SYNC_FIELDS, "fields", help);
|
|
111
|
+
const missing = fields.find(field => !select.includes(field));
|
|
112
|
+
if (missing) {
|
|
113
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
114
|
+
}
|
|
115
|
+
return { select, fields };
|
|
116
|
+
}
|
|
117
|
+
function profileHint(profileName) {
|
|
118
|
+
return `--profile ${shellValue(profileName)}`;
|
|
119
|
+
}
|
|
120
|
+
function shellValue(value) {
|
|
121
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
122
|
+
}
|
|
123
|
+
function fullHint(command, flags, profileName) {
|
|
124
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
125
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
126
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
127
|
+
}
|
|
128
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
129
|
+
// operation adds the scope, roles and licensing that actually unlock it,
|
|
130
|
+
// because a 403 alone never says which prerequisite is missing.
|
|
131
|
+
function withGuidance(hints, run) {
|
|
132
|
+
return run().catch(error => {
|
|
133
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
134
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const SYNC_DENIAL_HINTS = [
|
|
140
|
+
"On-premises-synchronization reads need delegated OnPremDirectorySynchronization.Read.All plus Global Administrator, the only supported Entra role for this operation (a custom role with a supported permission also works); Graph documents no supported application permission, so application profiles are refused before credentials",
|
|
141
|
+
"Personal Microsoft accounts are not supported for on-premises-synchronization reads",
|
|
142
|
+
"No P1/P2 prerequisite is stated for on-premises-synchronization reads; never diagnose licence solely from HTTP 403",
|
|
143
|
+
];
|
|
144
|
+
const EMPTY_NOTE = "An empty on-premises-synchronization list may mean on-premises directory sync is not configured for the tenant; the absence of rows is the answer, not an error";
|
|
145
|
+
// Restoring the saved select keeps cursor resumes lossless when --select is
|
|
146
|
+
// omitted. The list documents $select only, so it offers no --filter:
|
|
147
|
+
// strict input validation refuses the flag before credentials instead of
|
|
148
|
+
// forwarding a misleading request.
|
|
149
|
+
function collectionCommon(session, flags, operation, help, profile) {
|
|
150
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
151
|
+
if (cursor !== undefined && !cursor.trim())
|
|
152
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
153
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
154
|
+
const { select, fields } = selectedFields(flags, saved?.["$select"] === undefined ? DEFAULT_LIST_SELECT : fieldList(saved["$select"], KNOWN_SYNCS, KNOWN_SYNC_FIELDS, "select", help), help);
|
|
155
|
+
return { cursor, select, fields, scopes: scopesFor(flags, DEFAULT_SYNC_SCOPES, profile, help), full: flags.full === true };
|
|
156
|
+
}
|
|
157
|
+
function collectArgs(profile, operation, common, flags, help) {
|
|
158
|
+
const query = { $select: common.select.join(",") };
|
|
159
|
+
const args = { profile, operation, query, scopes: common.scopes };
|
|
160
|
+
if (common.cursor !== undefined)
|
|
161
|
+
args.cursor = common.cursor;
|
|
162
|
+
if (flags.all === true) {
|
|
163
|
+
if (flags.limit !== undefined)
|
|
164
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
168
|
+
}
|
|
169
|
+
return args;
|
|
170
|
+
}
|
|
171
|
+
function resumeHint(profileName) {
|
|
172
|
+
return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
|
|
173
|
+
}
|
|
174
|
+
function syncId(flags, help) {
|
|
175
|
+
const id = String(flags.id);
|
|
176
|
+
if (!id.trim())
|
|
177
|
+
throw new AxiError("--id needs the on-premises-synchronization object ID (the Entra tenant ID)", "VALIDATION_ERROR", [help]);
|
|
178
|
+
return id;
|
|
179
|
+
}
|
|
180
|
+
function singleResult(raw, fields, full) {
|
|
181
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
182
|
+
throw new AxiError("Graph returned a malformed on-premises-synchronization body", "GRAPH_ERROR", [
|
|
183
|
+
"Single-synchronization reads carry one onPremisesDirectorySynchronization object; treat anything else as unknown, not empty",
|
|
184
|
+
]);
|
|
185
|
+
}
|
|
186
|
+
return project(raw, fields, full);
|
|
187
|
+
}
|
|
188
|
+
export async function listSynchronizations(session, flags, profile, operation, help, profileName) {
|
|
189
|
+
const common = collectionCommon(session, flags, operation, help, profile);
|
|
190
|
+
const result = await withGuidance(SYNC_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
|
|
191
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIST_SELECT.join(",") };
|
|
192
|
+
const synchronizations = [];
|
|
193
|
+
let truncated = false;
|
|
194
|
+
for (const row of result.value) {
|
|
195
|
+
const projected = project(row, common.fields, common.full);
|
|
196
|
+
synchronizations.push(projected.row);
|
|
197
|
+
truncated = truncated || projected.truncated;
|
|
198
|
+
}
|
|
199
|
+
const showHint = `mg-axi entra on-premises-synchronization show --id <synchronization-id> ${profileHint(profileName)}`;
|
|
200
|
+
const truncationHints = truncated ? [fullHint("entra on-premises-synchronization list", effectiveFlags, profileName)] : [];
|
|
201
|
+
if (!result.complete) {
|
|
202
|
+
return {
|
|
203
|
+
synchronizations,
|
|
204
|
+
count: { returned: synchronizations.length, complete: false, reason: result.reason },
|
|
205
|
+
cursor: result.cursor,
|
|
206
|
+
help: [...truncationHints, resumeHint(profileName), showHint],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const count = { returned: synchronizations.length, complete: true };
|
|
210
|
+
if (!synchronizations.length) {
|
|
211
|
+
return { synchronizations, count, help: [EMPTY_NOTE] };
|
|
212
|
+
}
|
|
213
|
+
return { synchronizations, count, help: [...truncationHints, showHint] };
|
|
214
|
+
}
|
|
215
|
+
export async function showSynchronization(session, flags, profile, operation, help, profileName) {
|
|
216
|
+
const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
|
|
217
|
+
const scopes = scopesFor(flags, DEFAULT_SYNC_SCOPES, profile, help);
|
|
218
|
+
const full = flags.full === true;
|
|
219
|
+
const id = syncId(flags, help);
|
|
220
|
+
const raw = await withGuidance(SYNC_DENIAL_HINTS, () => session.execute({
|
|
221
|
+
profile,
|
|
222
|
+
operation,
|
|
223
|
+
params: { "onPremisesDirectorySynchronization-id": id },
|
|
224
|
+
query: { $select: select.join(",") },
|
|
225
|
+
scopes,
|
|
226
|
+
}));
|
|
227
|
+
const { row, truncated } = singleResult(raw, fields, full);
|
|
228
|
+
if (truncated)
|
|
229
|
+
return { synchronization: row, help: [fullHint("entra on-premises-synchronization show", flags, profileName)] };
|
|
230
|
+
return { synchronization: row };
|
|
231
|
+
}
|