@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,274 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// EXT-01 directory-objects subfamily: the read mapping behind
|
|
3
|
+
// `mg-axi entra directory-object list/show/count`. Operation construction
|
|
4
|
+
// stays beside its command; the shared session owns URLs, credentials,
|
|
5
|
+
// paging, retries and error translation, and the SDK owns TOON rendering.
|
|
6
|
+
// This module only maps flags to session calls and projects rows for compact
|
|
7
|
+
// output.
|
|
8
|
+
//
|
|
9
|
+
// Reviewed against the v1.0 directoryobject-get operation documentation and
|
|
10
|
+
// the directoryObject resource reference on 2026-10-05. There is no dedicated
|
|
11
|
+
// List operation page: the resource Methods table documents Get, delta,
|
|
12
|
+
// Delete and the lookup/validation functions only, so the collection GET
|
|
13
|
+
// follows the sibling get contract and the resource reference (both name
|
|
14
|
+
// Directory.Read.All), with $select-only queries because collection filter
|
|
15
|
+
// support is unreviewed. Both reads take delegated or application
|
|
16
|
+
// Directory.Read.All, the documented least privilege in each mode; personal
|
|
17
|
+
// Microsoft accounts are not supported. No delegated role and no P1/P2
|
|
18
|
+
// prerequisite are stated for these reads. The $count scalar carries no
|
|
19
|
+
// operation-level documentation page and follows the same Directory.Read.All
|
|
20
|
+
// contract. The delta() sync, every POST lookup/validation action, beta and
|
|
21
|
+
// every mutation stay out. No mutation lives here.
|
|
22
|
+
//
|
|
23
|
+
// Secrecy by construction: directory objects are polymorphic, so only the
|
|
24
|
+
// base-type properties (id, deletedDateTime) are ever requested or
|
|
25
|
+
// projected; $expand is not offered and subtype secrets or credentials any
|
|
26
|
+
// subtype carries can never appear. @odata.type rides along automatically
|
|
27
|
+
// (envelope metadata, never selectable) so rows still name their subtype.
|
|
28
|
+
// Every directory-object property this slice may request or display,
|
|
29
|
+
// matching the reviewed base resource. Anything else fails before
|
|
30
|
+
// credentials. @odata.type is preserved separately and is never selectable.
|
|
31
|
+
export const KNOWN_DIRECTORY_OBJECT_FIELDS = [
|
|
32
|
+
"id",
|
|
33
|
+
"deletedDateTime",
|
|
34
|
+
];
|
|
35
|
+
const KNOWN_OBJECTS = new Set(KNOWN_DIRECTORY_OBJECT_FIELDS);
|
|
36
|
+
// The subtype discriminator rides on every row without being selectable: it
|
|
37
|
+
// names the concrete kind (user, group, servicePrincipal and the rest).
|
|
38
|
+
const OBJECT_TYPE_PROPERTY = "@odata.type";
|
|
39
|
+
// Compact list rows carry the identifier only; show rows carry the full
|
|
40
|
+
// reviewed base set. The discriminator is attached to both automatically.
|
|
41
|
+
const DEFAULT_LIST_SELECT = ["id"];
|
|
42
|
+
const DEFAULT_SHOW_SELECT = [...KNOWN_DIRECTORY_OBJECT_FIELDS];
|
|
43
|
+
// Directory.Read.All covers list, show and count in both modes; delegated
|
|
44
|
+
// reads default to it unless --scopes overrides it. It is already in the
|
|
45
|
+
// shared READ_SCOPES allowlist, and it is a read scope, so no new scope and
|
|
46
|
+
// no deferral are needed here.
|
|
47
|
+
export const DEFAULT_DIRECTORY_OBJECT_SCOPES = ["https://graph.microsoft.com/Directory.Read.All"];
|
|
48
|
+
const TRUNCATE_AT = 500;
|
|
49
|
+
// The only operations this slice ever binds. Anything else - delta sync,
|
|
50
|
+
// lookup/validation actions, writes - is refused before credentials.
|
|
51
|
+
const READ_OPERATIONS = {
|
|
52
|
+
"GET:/directoryObjects": "directory-object",
|
|
53
|
+
"GET:/directoryObjects/{directoryObject-id}": "directory-object",
|
|
54
|
+
"GET:/directoryObjects/$count": "directory-object",
|
|
55
|
+
};
|
|
56
|
+
function checkReadOperation(operation, help) {
|
|
57
|
+
const route = `${operation.method}:${operation.path}`;
|
|
58
|
+
if (operation.method !== "GET" || !Object.hasOwn(READ_OPERATIONS, route)) {
|
|
59
|
+
throw new AxiError(`Refused non-read route ${operation.id}: directory-object serves only the three catalogued v1.0 directoryObjects GETs`, "VALIDATION_ERROR", [
|
|
60
|
+
help,
|
|
61
|
+
"Delta sync, POST lookup/validation actions and writes are never constructed here; beta needs its own review",
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function fieldList(raw, known, knownList, flag, help) {
|
|
66
|
+
const parts = String(raw)
|
|
67
|
+
.split(",")
|
|
68
|
+
.map(part => part.trim())
|
|
69
|
+
.filter(part => part.length > 0);
|
|
70
|
+
if (!parts.length)
|
|
71
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
72
|
+
const fields = [];
|
|
73
|
+
for (const part of parts) {
|
|
74
|
+
if (!known.has(part)) {
|
|
75
|
+
throw new AxiError(`Unknown directory-object property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
76
|
+
help,
|
|
77
|
+
`Known directory-object properties: ${knownList.join(", ")}`,
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
if (!fields.includes(part))
|
|
81
|
+
fields.push(part);
|
|
82
|
+
}
|
|
83
|
+
return fields;
|
|
84
|
+
}
|
|
85
|
+
function scopesFor(flags, defaults, profile, help) {
|
|
86
|
+
if (profile.mode === "application") {
|
|
87
|
+
if (flags.scopes !== undefined) {
|
|
88
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (flags.scopes === undefined)
|
|
93
|
+
return [...defaults];
|
|
94
|
+
const scopes = String(flags.scopes)
|
|
95
|
+
.split(",")
|
|
96
|
+
.map(scope => scope.trim())
|
|
97
|
+
.filter(scope => scope.length > 0);
|
|
98
|
+
return scopes;
|
|
99
|
+
}
|
|
100
|
+
function truncateValue(value, full) {
|
|
101
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
102
|
+
? { value: text, truncated: false }
|
|
103
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
104
|
+
if (typeof value === "string")
|
|
105
|
+
return truncate(value);
|
|
106
|
+
if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
|
|
107
|
+
const projected = value.map(truncate);
|
|
108
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
109
|
+
}
|
|
110
|
+
return { value, truncated: false };
|
|
111
|
+
}
|
|
112
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
113
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
114
|
+
// The subtype discriminator rides along automatically when the server sent
|
|
115
|
+
// it; subtype properties never do, so secrets any subtype carries stay out.
|
|
116
|
+
function project(row, fields, full) {
|
|
117
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
118
|
+
const projected = {};
|
|
119
|
+
let truncated = false;
|
|
120
|
+
for (const field of fields) {
|
|
121
|
+
if (!Object.hasOwn(source, field))
|
|
122
|
+
continue;
|
|
123
|
+
const result = truncateValue(source[field], full);
|
|
124
|
+
projected[field] = result.value;
|
|
125
|
+
truncated = truncated || result.truncated;
|
|
126
|
+
}
|
|
127
|
+
if (typeof source[OBJECT_TYPE_PROPERTY] === "string")
|
|
128
|
+
projected[OBJECT_TYPE_PROPERTY] = source[OBJECT_TYPE_PROPERTY];
|
|
129
|
+
return { row: projected, truncated };
|
|
130
|
+
}
|
|
131
|
+
function selectedFields(flags, defaults, help) {
|
|
132
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, KNOWN_OBJECTS, KNOWN_DIRECTORY_OBJECT_FIELDS, "select", help);
|
|
133
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, KNOWN_OBJECTS, KNOWN_DIRECTORY_OBJECT_FIELDS, "fields", help);
|
|
134
|
+
const missing = fields.find(field => !select.includes(field));
|
|
135
|
+
if (missing) {
|
|
136
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
137
|
+
}
|
|
138
|
+
return { select, fields };
|
|
139
|
+
}
|
|
140
|
+
function profileHint(profileName) {
|
|
141
|
+
return `--profile ${shellValue(profileName)}`;
|
|
142
|
+
}
|
|
143
|
+
function shellValue(value) {
|
|
144
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
145
|
+
}
|
|
146
|
+
function fullHint(command, flags, profileName) {
|
|
147
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
148
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
149
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
150
|
+
}
|
|
151
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
152
|
+
// operation adds the scope, roles and licensing that actually unlock it,
|
|
153
|
+
// because a 403 alone never says which prerequisite is missing.
|
|
154
|
+
function withGuidance(hints, run) {
|
|
155
|
+
return run().catch(error => {
|
|
156
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
157
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const DIRECTORY_OBJECT_DENIAL_HINTS = [
|
|
163
|
+
"Directory-object reads need Directory.Read.All for delegated or application access; delegated callers pass it as --scopes",
|
|
164
|
+
"Personal Microsoft accounts are not supported for directory-object reads",
|
|
165
|
+
"No delegated role or P1/P2 prerequisite is stated for directory-object reads; never diagnose role or licence solely from HTTP 403",
|
|
166
|
+
];
|
|
167
|
+
const EMPTY_NOTE = "0 directory objects returned; every tenant directory carries objects, so verify the profile tenant before treating this as an empty directory";
|
|
168
|
+
// Restoring the saved select keeps cursor resumes lossless when --select is
|
|
169
|
+
// omitted. The collection documents no List operation page, so it offers no
|
|
170
|
+
// --filter: strict input validation refuses the flag before credentials
|
|
171
|
+
// instead of forwarding an unreviewed query.
|
|
172
|
+
function collectionCommon(session, flags, operation, help, profile) {
|
|
173
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
174
|
+
if (cursor !== undefined && !cursor.trim())
|
|
175
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
176
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
177
|
+
const { select, fields } = selectedFields(flags, saved?.["$select"] === undefined ? DEFAULT_LIST_SELECT : fieldList(saved["$select"], KNOWN_OBJECTS, KNOWN_DIRECTORY_OBJECT_FIELDS, "select", help), help);
|
|
178
|
+
return { cursor, select, fields, scopes: scopesFor(flags, DEFAULT_DIRECTORY_OBJECT_SCOPES, profile, help), full: flags.full === true };
|
|
179
|
+
}
|
|
180
|
+
function collectArgs(profile, operation, common, flags, help) {
|
|
181
|
+
const query = { $select: common.select.join(",") };
|
|
182
|
+
const args = { profile, operation, query, scopes: common.scopes };
|
|
183
|
+
if (common.cursor !== undefined)
|
|
184
|
+
args.cursor = common.cursor;
|
|
185
|
+
if (flags.all === true) {
|
|
186
|
+
if (flags.limit !== undefined)
|
|
187
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
191
|
+
}
|
|
192
|
+
return args;
|
|
193
|
+
}
|
|
194
|
+
function resumeHint(profileName) {
|
|
195
|
+
return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
|
|
196
|
+
}
|
|
197
|
+
function objectId(flags, help) {
|
|
198
|
+
const id = String(flags.id);
|
|
199
|
+
if (!id.trim())
|
|
200
|
+
throw new AxiError("--id needs the directory-object object ID", "VALIDATION_ERROR", [help]);
|
|
201
|
+
return id;
|
|
202
|
+
}
|
|
203
|
+
function singleResult(raw, fields, full) {
|
|
204
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
205
|
+
throw new AxiError("Graph returned a malformed directory-object body", "GRAPH_ERROR", [
|
|
206
|
+
"Single-object reads carry one directoryObject; treat anything else as unknown, not empty",
|
|
207
|
+
]);
|
|
208
|
+
}
|
|
209
|
+
return project(raw, fields, full);
|
|
210
|
+
}
|
|
211
|
+
export async function listDirectoryObjects(session, flags, profile, operation, help, profileName) {
|
|
212
|
+
checkReadOperation(operation, help);
|
|
213
|
+
const common = collectionCommon(session, flags, operation, help, profile);
|
|
214
|
+
const result = await withGuidance(DIRECTORY_OBJECT_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
|
|
215
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIST_SELECT.join(",") };
|
|
216
|
+
const directoryObjects = [];
|
|
217
|
+
let truncated = false;
|
|
218
|
+
for (const row of result.value) {
|
|
219
|
+
const projected = project(row, common.fields, common.full);
|
|
220
|
+
directoryObjects.push(projected.row);
|
|
221
|
+
truncated = truncated || projected.truncated;
|
|
222
|
+
}
|
|
223
|
+
const showHint = `mg-axi entra directory-object show --id <object-id> ${profileHint(profileName)}`;
|
|
224
|
+
const truncationHints = truncated ? [fullHint("entra directory-object list", effectiveFlags, profileName)] : [];
|
|
225
|
+
if (!result.complete) {
|
|
226
|
+
return {
|
|
227
|
+
directoryObjects,
|
|
228
|
+
count: { returned: directoryObjects.length, complete: false, reason: result.reason },
|
|
229
|
+
cursor: result.cursor,
|
|
230
|
+
help: [...truncationHints, resumeHint(profileName), showHint],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const count = { returned: directoryObjects.length, complete: true };
|
|
234
|
+
if (!directoryObjects.length) {
|
|
235
|
+
return { directoryObjects, count, help: [EMPTY_NOTE] };
|
|
236
|
+
}
|
|
237
|
+
return { directoryObjects, count, help: [...truncationHints, showHint] };
|
|
238
|
+
}
|
|
239
|
+
export async function showDirectoryObject(session, flags, profile, operation, help, profileName) {
|
|
240
|
+
checkReadOperation(operation, help);
|
|
241
|
+
const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
|
|
242
|
+
const scopes = scopesFor(flags, DEFAULT_DIRECTORY_OBJECT_SCOPES, profile, help);
|
|
243
|
+
const full = flags.full === true;
|
|
244
|
+
const id = objectId(flags, help);
|
|
245
|
+
const raw = await withGuidance(DIRECTORY_OBJECT_DENIAL_HINTS, () => session.execute({
|
|
246
|
+
profile,
|
|
247
|
+
operation,
|
|
248
|
+
params: { "directoryObject-id": id },
|
|
249
|
+
query: { $select: select.join(",") },
|
|
250
|
+
scopes,
|
|
251
|
+
}));
|
|
252
|
+
const { row, truncated } = singleResult(raw, fields, full);
|
|
253
|
+
if (truncated)
|
|
254
|
+
return { directoryObject: row, help: [fullHint("entra directory-object show", flags, profileName)] };
|
|
255
|
+
return { directoryObject: row };
|
|
256
|
+
}
|
|
257
|
+
// The $count route returns a text/plain integer scalar rather than a JSON
|
|
258
|
+
// collection, so the leaf reads it through session.execute and accepts only
|
|
259
|
+
// a non-negative integer. There is no --filter/--select/--limit contract on
|
|
260
|
+
// the count: the catalogue declares no such flags and strict input
|
|
261
|
+
// validation refuses them before credentials.
|
|
262
|
+
export async function countDirectoryObjects(session, flags, profile, operation, help, profileName) {
|
|
263
|
+
void profileName;
|
|
264
|
+
checkReadOperation(operation, help);
|
|
265
|
+
const scopes = scopesFor(flags, DEFAULT_DIRECTORY_OBJECT_SCOPES, profile, help);
|
|
266
|
+
const raw = await withGuidance(DIRECTORY_OBJECT_DENIAL_HINTS, () => session.execute({ profile, operation, scopes, scalar: true }));
|
|
267
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
268
|
+
throw new AxiError("Graph returned a malformed directory-object count body", "GRAPH_ERROR", [
|
|
269
|
+
"Directory-object counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
const count = { returned: raw, complete: true };
|
|
273
|
+
return raw === 0 ? { count, help: [EMPTY_NOTE] } : { count };
|
|
274
|
+
}
|