@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,252 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// EXT-04 partner contracts subfamily: the read mapping behind `mg-axi entra
|
|
3
|
+
// contract list/show/count`. Operation construction stays beside its command;
|
|
4
|
+
// the shared session owns URLs, credentials, paging, retries and error
|
|
5
|
+
// translation, and the SDK owns TOON rendering. This module only maps flags
|
|
6
|
+
// to session calls and projects rows for compact output.
|
|
7
|
+
//
|
|
8
|
+
// Reviewed against the v1.0 contract-list, contract-get and contract
|
|
9
|
+
// resource documentation on 2026-10-04. Every read takes D/A
|
|
10
|
+
// Directory.Read.All; delegated callers additionally need a supported Entra
|
|
11
|
+
// role (Directory Readers is the only least-privileged role supported for
|
|
12
|
+
// these operations). Personal Microsoft accounts are not supported. No
|
|
13
|
+
// P1/P2 prerequisite is stated for these reads. Contracts exist in partner
|
|
14
|
+
// tenants only (Cloud Solution Provider, Office 365 Syndication or Advisor
|
|
15
|
+
// programs); a non-partner tenant lists zero contracts, which is an answer
|
|
16
|
+
// rather than an error. The list documents OData query parameters with
|
|
17
|
+
// filtering for customerId, defaultDomainName and displayName, so --filter
|
|
18
|
+
// passes through as plain $filter with no $count or ConsistencyLevel
|
|
19
|
+
// contract. The $count route returns a text/plain integer scalar; the leaf
|
|
20
|
+
// reads it through session.execute and accepts only a non-negative integer,
|
|
21
|
+
// so no --filter/--select/--limit/--cursor contract exists on the count.
|
|
22
|
+
// contracts/delta() stays out: delta-token sync needs its own paging and
|
|
23
|
+
// change-tracking contract. Beta contracts and the POST lookup actions
|
|
24
|
+
// belong to later EXT-04 subfamilies. No contract mutation exists in this
|
|
25
|
+
// slice.
|
|
26
|
+
// Every contract property this slice may request or display, matching the
|
|
27
|
+
// reviewed resource. Anything else fails before credentials.
|
|
28
|
+
export const KNOWN_CONTRACT_FIELDS = [
|
|
29
|
+
"contractType",
|
|
30
|
+
"customerId",
|
|
31
|
+
"defaultDomainName",
|
|
32
|
+
"displayName",
|
|
33
|
+
"id",
|
|
34
|
+
];
|
|
35
|
+
const KNOWN_CONTRACTS = new Set(KNOWN_CONTRACT_FIELDS);
|
|
36
|
+
// Compact contract rows: the partnership id, the customer name and domain
|
|
37
|
+
// copies that name the customer, and the contract kind that decides the
|
|
38
|
+
// partner relationship.
|
|
39
|
+
const DEFAULT_CONTRACT_LIST_SELECT = ["id", "displayName", "contractType", "defaultDomainName"];
|
|
40
|
+
// Show rows: the full reviewed contract set.
|
|
41
|
+
const DEFAULT_CONTRACT_SHOW_SELECT = [...KNOWN_CONTRACT_FIELDS];
|
|
42
|
+
// Delegated defaults are operation-specific; application profiles use their
|
|
43
|
+
// configured .default audience and reject --scopes.
|
|
44
|
+
export const DEFAULT_CONTRACT_SCOPES = ["https://graph.microsoft.com/Directory.Read.All"];
|
|
45
|
+
const TRUNCATE_AT = 500;
|
|
46
|
+
function fieldList(raw, known, knownList, flag, help) {
|
|
47
|
+
const parts = String(raw)
|
|
48
|
+
.split(",")
|
|
49
|
+
.map(part => part.trim())
|
|
50
|
+
.filter(part => part.length > 0);
|
|
51
|
+
if (!parts.length)
|
|
52
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
53
|
+
const fields = [];
|
|
54
|
+
for (const part of parts) {
|
|
55
|
+
if (!known.has(part)) {
|
|
56
|
+
throw new AxiError(`Unknown contract property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
57
|
+
help,
|
|
58
|
+
`Known properties: ${knownList.join(", ")}`,
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
if (!fields.includes(part))
|
|
62
|
+
fields.push(part);
|
|
63
|
+
}
|
|
64
|
+
return fields;
|
|
65
|
+
}
|
|
66
|
+
function scopesFor(flags, defaults, profile, help) {
|
|
67
|
+
if (profile.mode === "application") {
|
|
68
|
+
if (flags.scopes !== undefined) {
|
|
69
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
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, known, knownList, defaults, help) {
|
|
109
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, knownList, "select", help);
|
|
110
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, knownList, "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 partner prerequisite that actually
|
|
130
|
+
// unlock it, 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 CONTRACT_DENIAL_HINTS = [
|
|
140
|
+
"Contract reads need Directory.Read.All plus a supported Entra role for delegated access (Directory Readers is the least-privileged role), or admin-consented Directory.Read.All for application access",
|
|
141
|
+
"Contracts exist in partner tenants only (Cloud Solution Provider, Office 365 Syndication or Advisor programs); a non-partner tenant reads empty, which is an answer rather than an error",
|
|
142
|
+
"Personal Microsoft accounts are not supported for contract reads",
|
|
143
|
+
"No P1/P2 prerequisite is stated for contract reads; never diagnose licence solely from HTTP 403",
|
|
144
|
+
];
|
|
145
|
+
const PARTNER_NOTE = "Contracts exist in partner tenants only; a non-partner tenant lists zero contracts, which is an answer rather than an error";
|
|
146
|
+
// Restoring the saved select/filter keeps cursor resumes lossless when
|
|
147
|
+
// --select or --filter is omitted. The contract list passes --filter through
|
|
148
|
+
// as plain $filter with no $count or ConsistencyLevel contract.
|
|
149
|
+
function collectionCommon(session, flags, known, knownList, defaults, defaultScopes, 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, known, knownList, saved?.["$select"] === undefined ? defaults : fieldList(saved["$select"], known, knownList, "select", help), help);
|
|
155
|
+
const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
|
|
156
|
+
return { cursor, select, fields, scopes: scopesFor(flags, defaultScopes, profile, help), full: flags.full === true, filter };
|
|
157
|
+
}
|
|
158
|
+
function collectArgs(profile, operation, common, flags, help) {
|
|
159
|
+
const query = { $select: common.select.join(",") };
|
|
160
|
+
if (common.filter !== undefined)
|
|
161
|
+
query.$filter = common.filter;
|
|
162
|
+
const args = { profile, operation, query, scopes: common.scopes };
|
|
163
|
+
if (common.cursor !== undefined)
|
|
164
|
+
args.cursor = common.cursor;
|
|
165
|
+
if (flags.all === true) {
|
|
166
|
+
if (flags.limit !== undefined)
|
|
167
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
171
|
+
}
|
|
172
|
+
return args;
|
|
173
|
+
}
|
|
174
|
+
function resumeHint(profileName) {
|
|
175
|
+
return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
|
|
176
|
+
}
|
|
177
|
+
function contractId(flags, help) {
|
|
178
|
+
const id = String(flags.id);
|
|
179
|
+
if (!id.trim())
|
|
180
|
+
throw new AxiError("--id needs the contract partnership identifier", "VALIDATION_ERROR", [help]);
|
|
181
|
+
return id;
|
|
182
|
+
}
|
|
183
|
+
export async function listContracts(session, flags, profile, operation, help, profileName) {
|
|
184
|
+
const common = collectionCommon(session, flags, KNOWN_CONTRACTS, KNOWN_CONTRACT_FIELDS, DEFAULT_CONTRACT_LIST_SELECT, DEFAULT_CONTRACT_SCOPES, operation, help, profile);
|
|
185
|
+
const result = await withGuidance(CONTRACT_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
|
|
186
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_CONTRACT_LIST_SELECT.join(",") };
|
|
187
|
+
if (result.query.$filter !== undefined)
|
|
188
|
+
effectiveFlags.filter = result.query.$filter;
|
|
189
|
+
const contracts = [];
|
|
190
|
+
let truncated = false;
|
|
191
|
+
for (const row of result.value) {
|
|
192
|
+
const projected = project(row, common.fields, common.full);
|
|
193
|
+
contracts.push(projected.row);
|
|
194
|
+
truncated = truncated || projected.truncated;
|
|
195
|
+
}
|
|
196
|
+
const showHint = `mg-axi entra contract show --id <contract-id> ${profileHint(profileName)}`;
|
|
197
|
+
const truncationHints = truncated ? [fullHint("entra contract list", effectiveFlags, profileName)] : [];
|
|
198
|
+
if (!result.complete) {
|
|
199
|
+
return {
|
|
200
|
+
contracts,
|
|
201
|
+
count: { returned: contracts.length, complete: false, reason: result.reason },
|
|
202
|
+
cursor: result.cursor,
|
|
203
|
+
help: [...truncationHints, resumeHint(profileName), showHint, PARTNER_NOTE],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const count = { returned: contracts.length, complete: true };
|
|
207
|
+
if (!contracts.length) {
|
|
208
|
+
return {
|
|
209
|
+
contracts,
|
|
210
|
+
count,
|
|
211
|
+
help: ["0 contracts matched; the absence of results is the answer, not an error", PARTNER_NOTE],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return { contracts, count, help: [...truncationHints, showHint, PARTNER_NOTE] };
|
|
215
|
+
}
|
|
216
|
+
export async function showContract(session, flags, profile, operation, help, profileName) {
|
|
217
|
+
const { select, fields } = selectedFields(flags, KNOWN_CONTRACTS, KNOWN_CONTRACT_FIELDS, DEFAULT_CONTRACT_SHOW_SELECT, help);
|
|
218
|
+
const scopes = scopesFor(flags, DEFAULT_CONTRACT_SCOPES, profile, help);
|
|
219
|
+
const full = flags.full === true;
|
|
220
|
+
const raw = await withGuidance(CONTRACT_DENIAL_HINTS, () => session.execute({
|
|
221
|
+
profile,
|
|
222
|
+
operation,
|
|
223
|
+
params: { "contract-id": contractId(flags, help) },
|
|
224
|
+
query: { $select: select.join(",") },
|
|
225
|
+
scopes,
|
|
226
|
+
}));
|
|
227
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
228
|
+
throw new AxiError("Graph returned a malformed contract body", "GRAPH_ERROR", [
|
|
229
|
+
"Single-contract reads carry one contract object; treat anything else as unknown, not empty",
|
|
230
|
+
]);
|
|
231
|
+
}
|
|
232
|
+
const { row, truncated } = project(raw, fields, full);
|
|
233
|
+
if (truncated)
|
|
234
|
+
return { contract: row, help: [fullHint("entra contract show", flags, profileName)] };
|
|
235
|
+
return { contract: row };
|
|
236
|
+
}
|
|
237
|
+
// The $count route returns a text/plain integer scalar rather than a JSON
|
|
238
|
+
// collection, so the leaf reads it through session.execute and accepts only
|
|
239
|
+
// a non-negative integer. There is no --filter/--select/--limit contract on
|
|
240
|
+
// the count: the catalogue declares no such flags and strict input
|
|
241
|
+
// validation refuses them before credentials.
|
|
242
|
+
export async function countContracts(session, flags, profile, operation, help, profileName) {
|
|
243
|
+
void profileName;
|
|
244
|
+
const scopes = scopesFor(flags, DEFAULT_CONTRACT_SCOPES, profile, help);
|
|
245
|
+
const raw = await withGuidance(CONTRACT_DENIAL_HINTS, () => session.execute({ profile, operation, scopes }));
|
|
246
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
247
|
+
throw new AxiError("Graph returned a malformed contract count body", "GRAPH_ERROR", [
|
|
248
|
+
"Contract counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
|
|
249
|
+
]);
|
|
250
|
+
}
|
|
251
|
+
return { contractCount: raw };
|
|
252
|
+
}
|