@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
|
+
import { REDACTED } from "./graph-session.js";
|
|
3
|
+
// EXT-03 data-policy-operations subfamily: the read mapping behind
|
|
4
|
+
// `mg-axi entra data-policy-operation list/show/count`. Operation
|
|
5
|
+
// construction stays beside its command; the shared session owns URLs,
|
|
6
|
+
// credentials, paging, retries and error translation, and the SDK owns TOON
|
|
7
|
+
// rendering. This module only maps flags to session calls and projects rows
|
|
8
|
+
// for compact output.
|
|
9
|
+
//
|
|
10
|
+
// Reviewed against the v1.0 datapolicyoperation-get operation documentation
|
|
11
|
+
// and the dataPolicyOperation resource reference on 2026-10-05,
|
|
12
|
+
// corroborated by the sibling user-exportpersonaldata page (same
|
|
13
|
+
// User.Export.All least-privileged scope, same Company Administrator-only
|
|
14
|
+
// delegated role, same unsupported personal-account row). Every read takes
|
|
15
|
+
// D/A User.Export.All plus User.Read.All; delegated callers additionally
|
|
16
|
+
// need Company Administrator, the privileged role documented for export
|
|
17
|
+
// reads. Personal Microsoft accounts are not supported. No P1/P2
|
|
18
|
+
// prerequisite is stated for these reads. The single GET documents $select
|
|
19
|
+
// only (plus unreviewed $expand, which stays out); no list operation
|
|
20
|
+
// documentation page exists, so the list shares the single-get permission
|
|
21
|
+
// contract and resource shape and offers $select only with no --filter.
|
|
22
|
+
// The $count scalar takes no --filter/--select/--limit/--cursor. Beta stays
|
|
23
|
+
// out ("beta needs its own review") and no mutation lives here: export
|
|
24
|
+
// submission (POST /users/{id}/exportPersonalData) belongs to no read slice.
|
|
25
|
+
//
|
|
26
|
+
// Secrecy by construction: storageLocation carries the Azure Storage SAS URL
|
|
27
|
+
// where export data lands. It stays selectable so agents can confirm an
|
|
28
|
+
// export completed, but its value is always replaced with the redaction
|
|
29
|
+
// marker before projection, so blob URLs and signed links can never reach
|
|
30
|
+
// stdout, errors, previews, logs or cursors through this slice. The shared
|
|
31
|
+
// session still redacts secret-shaped values before buffering or output.
|
|
32
|
+
// Every data-policy-operation property this slice may request or display,
|
|
33
|
+
// matching the reviewed resource. Anything else fails before credentials.
|
|
34
|
+
export const KNOWN_DATA_POLICY_FIELDS = [
|
|
35
|
+
"id",
|
|
36
|
+
"completedDateTime",
|
|
37
|
+
"status",
|
|
38
|
+
"storageLocation",
|
|
39
|
+
"userId",
|
|
40
|
+
"submittedDateTime",
|
|
41
|
+
"progress",
|
|
42
|
+
];
|
|
43
|
+
const KNOWN_DATA_POLICIES = new Set(KNOWN_DATA_POLICY_FIELDS);
|
|
44
|
+
// Compact list rows: identifier, lifecycle status, subject and submission
|
|
45
|
+
// time agents need to decide what to inspect next. storageLocation stays out
|
|
46
|
+
// of the default selection: it is always redacted and only confirms a
|
|
47
|
+
// completed export on show.
|
|
48
|
+
const DEFAULT_LIST_SELECT = ["id", "status", "userId", "submittedDateTime"];
|
|
49
|
+
// Show rows: the full reviewed set, with storageLocation always redacted.
|
|
50
|
+
const DEFAULT_SHOW_SELECT = [...KNOWN_DATA_POLICY_FIELDS];
|
|
51
|
+
// Both scopes are documented least-privileged for the single GET in both
|
|
52
|
+
// modes; delegated callers pass them as --scopes, application access needs
|
|
53
|
+
// both admin-consented on the configured .default audience.
|
|
54
|
+
export const DEFAULT_DATA_POLICY_SCOPES = [
|
|
55
|
+
"https://graph.microsoft.com/User.Export.All",
|
|
56
|
+
"https://graph.microsoft.com/User.Read.All",
|
|
57
|
+
];
|
|
58
|
+
const TRUNCATE_AT = 500;
|
|
59
|
+
function fieldList(raw, known, knownList, flag, help) {
|
|
60
|
+
const parts = String(raw)
|
|
61
|
+
.split(",")
|
|
62
|
+
.map(part => part.trim())
|
|
63
|
+
.filter(part => part.length > 0);
|
|
64
|
+
if (!parts.length)
|
|
65
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
66
|
+
const fields = [];
|
|
67
|
+
for (const part of parts) {
|
|
68
|
+
if (!known.has(part)) {
|
|
69
|
+
throw new AxiError(`Unknown data-policy-operation property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
70
|
+
help,
|
|
71
|
+
`Known data-policy-operation properties: ${knownList.join(", ")}`,
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
if (!fields.includes(part))
|
|
75
|
+
fields.push(part);
|
|
76
|
+
}
|
|
77
|
+
return fields;
|
|
78
|
+
}
|
|
79
|
+
function scopesFor(flags, defaults, profile, help) {
|
|
80
|
+
if (profile.mode === "application") {
|
|
81
|
+
if (flags.scopes !== undefined) {
|
|
82
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
if (flags.scopes === undefined)
|
|
87
|
+
return [...defaults];
|
|
88
|
+
const scopes = String(flags.scopes)
|
|
89
|
+
.split(",")
|
|
90
|
+
.map(scope => scope.trim())
|
|
91
|
+
.filter(scope => scope.length > 0);
|
|
92
|
+
return scopes;
|
|
93
|
+
}
|
|
94
|
+
function truncateValue(value, full) {
|
|
95
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
96
|
+
? { value: text, truncated: false }
|
|
97
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
98
|
+
if (typeof value === "string")
|
|
99
|
+
return truncate(value);
|
|
100
|
+
if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
|
|
101
|
+
const projected = value.map(truncate);
|
|
102
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
103
|
+
}
|
|
104
|
+
return { value, truncated: false };
|
|
105
|
+
}
|
|
106
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
107
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
108
|
+
// storageLocation is always replaced with the redaction marker when present
|
|
109
|
+
// as a string, so export blob URLs and signed links never reach output even
|
|
110
|
+
// when the server URL carries no sig= sentinel. Truncation never hides the
|
|
111
|
+
// marker.
|
|
112
|
+
function project(row, fields, full) {
|
|
113
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
114
|
+
const projected = {};
|
|
115
|
+
let truncated = false;
|
|
116
|
+
for (const field of fields) {
|
|
117
|
+
if (!Object.hasOwn(source, field))
|
|
118
|
+
continue;
|
|
119
|
+
if (field === "storageLocation" && typeof source[field] === "string") {
|
|
120
|
+
projected[field] = REDACTED;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const result = truncateValue(source[field], full);
|
|
124
|
+
projected[field] = result.value;
|
|
125
|
+
truncated = truncated || result.truncated;
|
|
126
|
+
}
|
|
127
|
+
return { row: projected, truncated };
|
|
128
|
+
}
|
|
129
|
+
function selectedFields(flags, defaults, help) {
|
|
130
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, KNOWN_DATA_POLICIES, KNOWN_DATA_POLICY_FIELDS, "select", help);
|
|
131
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, KNOWN_DATA_POLICIES, KNOWN_DATA_POLICY_FIELDS, "fields", help);
|
|
132
|
+
const missing = fields.find(field => !select.includes(field));
|
|
133
|
+
if (missing) {
|
|
134
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
135
|
+
}
|
|
136
|
+
return { select, fields };
|
|
137
|
+
}
|
|
138
|
+
function profileHint(profileName) {
|
|
139
|
+
return `--profile ${shellValue(profileName)}`;
|
|
140
|
+
}
|
|
141
|
+
function shellValue(value) {
|
|
142
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
143
|
+
}
|
|
144
|
+
function fullHint(command, flags, profileName) {
|
|
145
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
146
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
147
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
148
|
+
}
|
|
149
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
150
|
+
// operation adds the scopes, roles and licensing that actually unlock it,
|
|
151
|
+
// because a 403 alone never says which prerequisite is missing.
|
|
152
|
+
function withGuidance(hints, run) {
|
|
153
|
+
return run().catch(error => {
|
|
154
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
155
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const DATA_POLICY_DENIAL_HINTS = [
|
|
161
|
+
"Data-policy-operation reads need User.Export.All plus User.Read.All: delegated callers pass both as --scopes, application access needs both admin-consented on the configured .default audience",
|
|
162
|
+
"Delegated callers additionally need Company Administrator, the privileged role documented for export reads",
|
|
163
|
+
"Personal Microsoft accounts are not supported for data-policy-operation reads",
|
|
164
|
+
"No P1/P2 prerequisite is stated for data-policy-operation reads; never diagnose licence solely from HTTP 403",
|
|
165
|
+
];
|
|
166
|
+
const WORKFORCE_NOTE = "Workforce tenant context only; export submission (POST /users/{id}/exportPersonalData) belongs to no read slice and is never sent here";
|
|
167
|
+
// Restoring the saved select keeps cursor resumes lossless when --select is
|
|
168
|
+
// omitted. The list offers $select only with no --filter: no list operation
|
|
169
|
+
// documentation page exists and the single GET documents $select only, so
|
|
170
|
+
// strict input validation refuses --filter before credentials instead of
|
|
171
|
+
// forwarding a misleading request.
|
|
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_DATA_POLICIES, KNOWN_DATA_POLICY_FIELDS, "select", help), help);
|
|
178
|
+
return { cursor, select, fields, scopes: scopesFor(flags, DEFAULT_DATA_POLICY_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 operationId(flags, help) {
|
|
198
|
+
const id = String(flags.id);
|
|
199
|
+
if (!id.trim())
|
|
200
|
+
throw new AxiError("--id needs the data-policy-operation 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 data-policy-operation body", "GRAPH_ERROR", [
|
|
206
|
+
"Single-operation reads carry one dataPolicyOperation object; treat anything else as unknown, not empty",
|
|
207
|
+
]);
|
|
208
|
+
}
|
|
209
|
+
return project(raw, fields, full);
|
|
210
|
+
}
|
|
211
|
+
export async function listDataPolicyOperations(session, flags, profile, operation, help, profileName) {
|
|
212
|
+
const common = collectionCommon(session, flags, operation, help, profile);
|
|
213
|
+
const result = await withGuidance(DATA_POLICY_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
|
|
214
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIST_SELECT.join(",") };
|
|
215
|
+
const operations = [];
|
|
216
|
+
let truncated = false;
|
|
217
|
+
for (const row of result.value) {
|
|
218
|
+
const projected = project(row, common.fields, common.full);
|
|
219
|
+
operations.push(projected.row);
|
|
220
|
+
truncated = truncated || projected.truncated;
|
|
221
|
+
}
|
|
222
|
+
const showHint = `mg-axi entra data-policy-operation show --id <operation-id> ${profileHint(profileName)}`;
|
|
223
|
+
const truncationHints = truncated ? [fullHint("entra data-policy-operation list", effectiveFlags, profileName)] : [];
|
|
224
|
+
if (!result.complete) {
|
|
225
|
+
return {
|
|
226
|
+
dataPolicyOperations: operations,
|
|
227
|
+
count: { returned: operations.length, complete: false, reason: result.reason },
|
|
228
|
+
cursor: result.cursor,
|
|
229
|
+
help: [...truncationHints, resumeHint(profileName), showHint, WORKFORCE_NOTE],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const count = { returned: operations.length, complete: true };
|
|
233
|
+
if (!operations.length) {
|
|
234
|
+
return {
|
|
235
|
+
dataPolicyOperations: operations,
|
|
236
|
+
count,
|
|
237
|
+
help: ["0 data-policy operations matched; the absence of results is the answer, not an error", WORKFORCE_NOTE],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { dataPolicyOperations: operations, count, help: [...truncationHints, showHint, WORKFORCE_NOTE] };
|
|
241
|
+
}
|
|
242
|
+
export async function showDataPolicyOperation(session, flags, profile, operation, help, profileName) {
|
|
243
|
+
const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
|
|
244
|
+
const scopes = scopesFor(flags, DEFAULT_DATA_POLICY_SCOPES, profile, help);
|
|
245
|
+
const full = flags.full === true;
|
|
246
|
+
const id = operationId(flags, help);
|
|
247
|
+
const raw = await withGuidance(DATA_POLICY_DENIAL_HINTS, () => session.execute({
|
|
248
|
+
profile,
|
|
249
|
+
operation,
|
|
250
|
+
params: { "dataPolicyOperation-id": id },
|
|
251
|
+
query: { $select: select.join(",") },
|
|
252
|
+
scopes,
|
|
253
|
+
}));
|
|
254
|
+
const { row, truncated } = singleResult(raw, fields, full);
|
|
255
|
+
if (truncated)
|
|
256
|
+
return { dataPolicyOperation: row, help: [fullHint("entra data-policy-operation show", flags, profileName), WORKFORCE_NOTE] };
|
|
257
|
+
return { dataPolicyOperation: row, help: [WORKFORCE_NOTE] };
|
|
258
|
+
}
|
|
259
|
+
// The $count route returns a text/plain integer scalar rather than a JSON
|
|
260
|
+
// collection, so the leaf reads it through session.execute and accepts only
|
|
261
|
+
// a non-negative integer. There is no --filter/--select/--limit/--cursor
|
|
262
|
+
// contract on the count: the catalogue declares no such flags and strict
|
|
263
|
+
// input validation refuses them before credentials.
|
|
264
|
+
export async function countDataPolicyOperations(session, flags, profile, operation, help, profileName) {
|
|
265
|
+
void profileName;
|
|
266
|
+
const scopes = scopesFor(flags, DEFAULT_DATA_POLICY_SCOPES, profile, help);
|
|
267
|
+
const raw = await withGuidance(DATA_POLICY_DENIAL_HINTS, () => session.execute({ profile, operation, scopes, scalar: true }));
|
|
268
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
269
|
+
throw new AxiError("Graph returned a malformed data-policy-operation count body", "GRAPH_ERROR", [
|
|
270
|
+
"Data-policy-operation counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
|
|
271
|
+
]);
|
|
272
|
+
}
|
|
273
|
+
return { count: { returned: raw, complete: true }, help: [WORKFORCE_NOTE] };
|
|
274
|
+
}
|