@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
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { LEAVES, operationFor } from "./catalogue.js";
|
|
3
|
+
import { listUsers } from "./entra-users.js";
|
|
4
|
+
// PACK-01: explicit release health check over the reviewed read-only session.
|
|
5
|
+
// Doctor performs one documented bounded read per profile (entra user list
|
|
6
|
+
// with a one-row window) and reports configuration, connectivity,
|
|
7
|
+
// authentication and access failures. Credential acquisition stays silent:
|
|
8
|
+
// DelegatedAuth.credential and ApplicationAuth.credential never open a
|
|
9
|
+
// browser or device-code flow, so doctor never signs in interactively.
|
|
10
|
+
// Doctor never auto-installs, changes profiles or enables Graph writes;
|
|
11
|
+
// silent token refresh may update the protected credential cache.
|
|
12
|
+
// See README.md for usage.
|
|
13
|
+
export const DOCTOR_OPERATION = "GET:/users";
|
|
14
|
+
export const DOCTOR_VERSION = "v1.0";
|
|
15
|
+
export const DOCTOR_CHECK = `entra user list --limit 1 (${DOCTOR_VERSION}:${DOCTOR_OPERATION})`;
|
|
16
|
+
export function doctorTargets(store, flag) {
|
|
17
|
+
if (flag !== undefined)
|
|
18
|
+
return [store.resolve(flag).name];
|
|
19
|
+
const items = store.list();
|
|
20
|
+
if (!items.length)
|
|
21
|
+
return [];
|
|
22
|
+
try {
|
|
23
|
+
return [store.resolve(undefined).name];
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return items.map(item => item.name);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function runDoctor(args) {
|
|
30
|
+
if (!args.names.length) {
|
|
31
|
+
throw new AxiError("No profiles are configured", "AUTH_REQUIRED", ["npx -y @knowttl/mg-axi profile create --help", "npx -y @knowttl/mg-axi setup"]);
|
|
32
|
+
}
|
|
33
|
+
const leaf = LEAVES.find(item => item.path === "entra user list");
|
|
34
|
+
const operation = leaf === undefined ? undefined : operationFor(leaf, DOCTOR_VERSION);
|
|
35
|
+
if (!operation || operation.method !== "GET") {
|
|
36
|
+
throw new AxiError(`Unknown catalogued Graph operation for ${DOCTOR_CHECK}`, "VALIDATION_ERROR", ["npx -y @knowttl/mg-axi entra user list --help"]);
|
|
37
|
+
}
|
|
38
|
+
const profiles = [];
|
|
39
|
+
for (const name of args.names) {
|
|
40
|
+
const rerun = `npx -y @knowttl/mg-axi doctor --profile ${name}`;
|
|
41
|
+
try {
|
|
42
|
+
const selected = args.store.resolve(name);
|
|
43
|
+
// The same bounded read the named leaf serves: one fetched page, one
|
|
44
|
+
// surfaced row, remainder buffered in a discarded cursor. Doctor keeps
|
|
45
|
+
// no cursor and accepts none; recovery reruns doctor, not the read.
|
|
46
|
+
const result = (await listUsers(args.session, { limit: "1" }, selected.profile, operation, `npx -y @knowttl/mg-axi ${DOCTOR_CHECK} --help`, name));
|
|
47
|
+
if (!result.count.complete && result.count.reason !== "row limit reached; buffered remainder is preserved in the cursor") {
|
|
48
|
+
throw new AxiError(`Doctor read incomplete: ${result.count.reason}`, "GRAPH_ERROR", [rerun]);
|
|
49
|
+
}
|
|
50
|
+
profiles.push({ name, mode: selected.profile.mode, check: DOCTOR_CHECK, status: "ok",
|
|
51
|
+
detail: result.users.length ? "1 user row returned" : "0 users matched; the absence of results is the answer" });
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const failure = error instanceof AxiError
|
|
55
|
+
? error
|
|
56
|
+
: new AxiError("Unable to run the doctor check", "GRAPH_ERROR", ["npx -y @knowttl/mg-axi entra user list --help"]);
|
|
57
|
+
profiles.push({ name, check: DOCTOR_CHECK, status: "failed", code: failure.code, error: failure.message });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const ok = profiles.filter(row => row.status === "ok");
|
|
61
|
+
const failed = profiles.filter(row => row.status !== "ok");
|
|
62
|
+
return {
|
|
63
|
+
output: {
|
|
64
|
+
config: args.store.path,
|
|
65
|
+
check: `${DOCTOR_CHECK} per profile; silent credential acquisition only, no interactive sign-in, no writes`,
|
|
66
|
+
count: `${ok.length} of ${profiles.length} profiles ok`,
|
|
67
|
+
profiles,
|
|
68
|
+
complete: failed.length === 0,
|
|
69
|
+
help: failed.length
|
|
70
|
+
? failed.map(row => `[${String(row.name)}] Check the reported failure, then rerun \`npx -y @knowttl/mg-axi doctor --profile ${String(row.name)}\``)
|
|
71
|
+
: [`Run \`npx -y @knowttl/mg-axi entra user list --profile ${String(ok[0].name)} --limit 1\` to start reading`],
|
|
72
|
+
},
|
|
73
|
+
failed: failed.length > 0,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// EXT-02 access-reviews subfamily: the read-only access-review mapping.
|
|
3
|
+
// See README.md for the supported commands and usage. Operation construction
|
|
4
|
+
// stays beside its command; the shared session owns URLs, credentials, paging,
|
|
5
|
+
// retries and error translation, and the SDK owns TOON rendering. This
|
|
6
|
+
// module only maps flags to session calls and projects rows for compact
|
|
7
|
+
// output.
|
|
8
|
+
//
|
|
9
|
+
// Reviewed against the v1.0 accessreviewset-list-definitions,
|
|
10
|
+
// accessreviewscheduledefinition-get,
|
|
11
|
+
// accessreviewscheduledefinition-list-instances, accessreviewinstance-get,
|
|
12
|
+
// accessreviewinstance-list-decisions,
|
|
13
|
+
// accessreviewinstancedecisionitem-get,
|
|
14
|
+
// accessreviewinstance-list-contactedreviewers,
|
|
15
|
+
// accessreviewinstance-list-stages and accessreviewstage-get operation
|
|
16
|
+
// documentation, plus the accessReviewReviewer and accessReviewStage
|
|
17
|
+
// resource references, on 2026-10-04. All ten reads take D/A
|
|
18
|
+
// AccessReview.Read.All as the least privileged choice; delegated personal
|
|
19
|
+
// Microsoft accounts are not supported. Delegated callers additionally need
|
|
20
|
+
// a supported administrator role: group or app reviews need the review
|
|
21
|
+
// creator, Global Reader, Security Reader, User Administrator, Identity
|
|
22
|
+
// Governance Administrator or Security Administrator, while Entra-role
|
|
23
|
+
// reviews need Security Reader, Identity Governance Administrator,
|
|
24
|
+
// Privileged Role Administrator or Security Administrator. Definition,
|
|
25
|
+
// instance and decision collections document $select with plain $filter
|
|
26
|
+
// ($orderby/$skip/$top stay server-side paging concerns); contacted-reviewer
|
|
27
|
+
// lists add $orderby to the documented set and stage lists document $filter
|
|
28
|
+
// (eq only), both still passed through as plain $filter with no $count or
|
|
29
|
+
// ConsistencyLevel attached. Singles document $select only; the
|
|
30
|
+
// contacted-reviewer single reuses its list's resource contract because the
|
|
31
|
+
// resource carries no relationships and the list returns all nested
|
|
32
|
+
// properties. None of these collections documents an advanced-query
|
|
33
|
+
// contract.
|
|
34
|
+
//
|
|
35
|
+
// Definitions are review schedules (a series: one recurrence creates one
|
|
36
|
+
// instance per reviewed resource, and a one-time review creates one
|
|
37
|
+
// instance per resource); instances are the occurrences under review; each
|
|
38
|
+
// reviewed principal or resource in an instance carries one decision item.
|
|
39
|
+
// Listing decisions never approves, denies or applies anything: submitting,
|
|
40
|
+
// stopping and every other access-review mutation belongs to later slices,
|
|
41
|
+
// never to these reads. Access reviews need P2 or ID Governance depending
|
|
42
|
+
// on capability, never one uniform licence.
|
|
43
|
+
// Definitions expose the series schedule; the deprecated backupReviewers
|
|
44
|
+
// alias, createdBy, stageSettings and additionalNotificationRecipients carry
|
|
45
|
+
// no documented $select contract and stay out of the reviewed set.
|
|
46
|
+
export const KNOWN_DEFINITION_FIELDS = [
|
|
47
|
+
"id",
|
|
48
|
+
"displayName",
|
|
49
|
+
"status",
|
|
50
|
+
"createdDateTime",
|
|
51
|
+
"lastModifiedDateTime",
|
|
52
|
+
"descriptionForAdmins",
|
|
53
|
+
"descriptionForReviewers",
|
|
54
|
+
"scope",
|
|
55
|
+
"instanceEnumerationScope",
|
|
56
|
+
"reviewers",
|
|
57
|
+
"fallbackReviewers",
|
|
58
|
+
"settings",
|
|
59
|
+
];
|
|
60
|
+
// Instances expose occurrence state; errors carry no documented $select
|
|
61
|
+
// contract and stay out of the reviewed set.
|
|
62
|
+
export const KNOWN_INSTANCE_FIELDS = [
|
|
63
|
+
"id",
|
|
64
|
+
"startDateTime",
|
|
65
|
+
"endDateTime",
|
|
66
|
+
"status",
|
|
67
|
+
"scope",
|
|
68
|
+
"reviewers",
|
|
69
|
+
"fallbackReviewers",
|
|
70
|
+
];
|
|
71
|
+
// Decisions expose review outcomes; target is the reviewed
|
|
72
|
+
// principal-or-resource item shown in the documented list response, while
|
|
73
|
+
// appliedBy, applyDescription, justification, permission, principalLink and
|
|
74
|
+
// resource carry no documented $select contract and stay out.
|
|
75
|
+
export const KNOWN_DECISION_FIELDS = [
|
|
76
|
+
"id",
|
|
77
|
+
"accessReviewId",
|
|
78
|
+
"decision",
|
|
79
|
+
"recommendation",
|
|
80
|
+
"reviewedDateTime",
|
|
81
|
+
"reviewedBy",
|
|
82
|
+
"appliedDateTime",
|
|
83
|
+
"applyResult",
|
|
84
|
+
"principal",
|
|
85
|
+
"resourceLink",
|
|
86
|
+
"target",
|
|
87
|
+
];
|
|
88
|
+
// Contacted reviewers are the reviewer identities recorded on one instance,
|
|
89
|
+
// whether or not they were notified; the resource carries no relationships
|
|
90
|
+
// and the list returns all nested properties, so list and show share one
|
|
91
|
+
// reviewed set.
|
|
92
|
+
export const KNOWN_CONTACTED_REVIEWER_FIELDS = [
|
|
93
|
+
"id",
|
|
94
|
+
"displayName",
|
|
95
|
+
"userPrincipalName",
|
|
96
|
+
"createdDateTime",
|
|
97
|
+
];
|
|
98
|
+
// Stages are the sequential phases of one instance (up to three when the
|
|
99
|
+
// definition sets stageSettings); each stage carries its own reviewer
|
|
100
|
+
// scopes, and durationInDays is not a stage property (it folds into
|
|
101
|
+
// endDateTime). Decisions hang off each stage but belong to a later slice.
|
|
102
|
+
export const KNOWN_STAGE_FIELDS = [
|
|
103
|
+
"id",
|
|
104
|
+
"startDateTime",
|
|
105
|
+
"endDateTime",
|
|
106
|
+
"status",
|
|
107
|
+
"reviewers",
|
|
108
|
+
"fallbackReviewers",
|
|
109
|
+
];
|
|
110
|
+
const DEFINITION_KNOWN = new Set(KNOWN_DEFINITION_FIELDS);
|
|
111
|
+
const INSTANCE_KNOWN = new Set(KNOWN_INSTANCE_FIELDS);
|
|
112
|
+
const DECISION_KNOWN = new Set(KNOWN_DECISION_FIELDS);
|
|
113
|
+
const CONTACTED_REVIEWER_KNOWN = new Set(KNOWN_CONTACTED_REVIEWER_FIELDS);
|
|
114
|
+
const STAGE_KNOWN = new Set(KNOWN_STAGE_FIELDS);
|
|
115
|
+
// Compact rows: identifiers plus schedule state, occurrence or stage window,
|
|
116
|
+
// reviewer identity or review outcome. The definition id is the schedule;
|
|
117
|
+
// accessReviewId on a decision names its parent instance.
|
|
118
|
+
const DEFAULT_DEFINITION_LIST_SELECT = ["id", "displayName", "status"];
|
|
119
|
+
const DEFAULT_DEFINITION_SHOW_SELECT = [...KNOWN_DEFINITION_FIELDS];
|
|
120
|
+
const DEFAULT_INSTANCE_LIST_SELECT = ["id", "status", "startDateTime", "endDateTime"];
|
|
121
|
+
const DEFAULT_INSTANCE_SHOW_SELECT = [...KNOWN_INSTANCE_FIELDS];
|
|
122
|
+
const DEFAULT_DECISION_SELECT = ["id", "accessReviewId", "decision", "recommendation"];
|
|
123
|
+
const DEFAULT_DECISION_SHOW_SELECT = [...KNOWN_DECISION_FIELDS];
|
|
124
|
+
const DEFAULT_CONTACTED_REVIEWER_LIST_SELECT = ["id", "displayName", "userPrincipalName"];
|
|
125
|
+
const DEFAULT_CONTACTED_REVIEWER_SHOW_SELECT = [...KNOWN_CONTACTED_REVIEWER_FIELDS];
|
|
126
|
+
const DEFAULT_STAGE_LIST_SELECT = ["id", "status", "startDateTime", "endDateTime"];
|
|
127
|
+
const DEFAULT_STAGE_SHOW_SELECT = [...KNOWN_STAGE_FIELDS];
|
|
128
|
+
export const DEFAULT_ACCESS_REVIEW_SCOPES = ["https://graph.microsoft.com/AccessReview.Read.All"];
|
|
129
|
+
const TRUNCATE_AT = 500;
|
|
130
|
+
function fieldList(raw, flag, known, knownList, help) {
|
|
131
|
+
const parts = String(raw)
|
|
132
|
+
.split(",")
|
|
133
|
+
.map(part => part.trim())
|
|
134
|
+
.filter(part => part.length > 0);
|
|
135
|
+
if (!parts.length)
|
|
136
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
137
|
+
const fields = [];
|
|
138
|
+
for (const part of parts) {
|
|
139
|
+
if (!known.has(part)) {
|
|
140
|
+
throw new AxiError(`Unknown property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
141
|
+
help,
|
|
142
|
+
`Known properties: ${knownList.join(", ")}`,
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
if (!fields.includes(part))
|
|
146
|
+
fields.push(part);
|
|
147
|
+
}
|
|
148
|
+
return fields;
|
|
149
|
+
}
|
|
150
|
+
function scopesFor(flags, profile, defaults, help) {
|
|
151
|
+
if (profile.mode === "application") {
|
|
152
|
+
if (flags.scopes !== undefined) {
|
|
153
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
if (flags.scopes === undefined)
|
|
158
|
+
return [...defaults];
|
|
159
|
+
const scopes = String(flags.scopes)
|
|
160
|
+
.split(",")
|
|
161
|
+
.map(scope => scope.trim())
|
|
162
|
+
.filter(scope => scope.length > 0);
|
|
163
|
+
return scopes;
|
|
164
|
+
}
|
|
165
|
+
function truncateValue(value, full) {
|
|
166
|
+
if (full)
|
|
167
|
+
return { value, truncated: false };
|
|
168
|
+
if (typeof value === "string" && value.length > TRUNCATE_AT) {
|
|
169
|
+
return { value: `${value.slice(0, TRUNCATE_AT)}... (truncated, ${value.length} chars total)`, truncated: true };
|
|
170
|
+
}
|
|
171
|
+
if (Array.isArray(value)) {
|
|
172
|
+
const results = value.map(item => truncateValue(item, full));
|
|
173
|
+
return { value: results.map(result => result.value), truncated: results.some(result => result.truncated) };
|
|
174
|
+
}
|
|
175
|
+
if (value !== null && typeof value === "object") {
|
|
176
|
+
let truncated = false;
|
|
177
|
+
const entries = Object.entries(value).map(([key, item]) => {
|
|
178
|
+
const result = truncateValue(item, full);
|
|
179
|
+
truncated = truncated || result.truncated;
|
|
180
|
+
return [key, result.value];
|
|
181
|
+
});
|
|
182
|
+
return { value: Object.fromEntries(entries), truncated };
|
|
183
|
+
}
|
|
184
|
+
return { value, truncated: false };
|
|
185
|
+
}
|
|
186
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
187
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
188
|
+
function project(row, fields, full) {
|
|
189
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
190
|
+
const projected = {};
|
|
191
|
+
let truncated = false;
|
|
192
|
+
for (const field of fields) {
|
|
193
|
+
if (!Object.hasOwn(source, field))
|
|
194
|
+
continue;
|
|
195
|
+
const result = truncateValue(source[field], full);
|
|
196
|
+
projected[field] = result.value;
|
|
197
|
+
truncated = truncated || result.truncated;
|
|
198
|
+
}
|
|
199
|
+
return { row: projected, truncated };
|
|
200
|
+
}
|
|
201
|
+
function selectedFields(flags, defaults, known, knownList, help) {
|
|
202
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, "select", known, knownList, help);
|
|
203
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, "fields", known, knownList, help);
|
|
204
|
+
const missing = fields.find(field => !select.includes(field));
|
|
205
|
+
if (missing) {
|
|
206
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
207
|
+
}
|
|
208
|
+
return { select, fields };
|
|
209
|
+
}
|
|
210
|
+
function profileHint(profileName) {
|
|
211
|
+
return `--profile ${shellValue(profileName)}`;
|
|
212
|
+
}
|
|
213
|
+
function shellValue(value) {
|
|
214
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
215
|
+
}
|
|
216
|
+
function fullHint(command, flags, profileName) {
|
|
217
|
+
const args = Object.entries({ ...flags, ...(flags.cursor === undefined ? {} : { cursor: "-" }), profile: profileName, full: true })
|
|
218
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
219
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
220
|
+
}
|
|
221
|
+
// Denials carry the session's generic grant/role/licence cause; access
|
|
222
|
+
// reviews add the roles and licensing that actually unlock them, because a
|
|
223
|
+
// 403 alone never says which prerequisite is missing.
|
|
224
|
+
function withGuidance(run) {
|
|
225
|
+
return run().catch(error => {
|
|
226
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
227
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...ACCESS_REVIEW_DENIAL_HINTS, ...error.suggestions]);
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const ACCESS_REVIEW_DENIAL_HINTS = [
|
|
233
|
+
"Access-review reads need AccessReview.Read.All plus a supported Microsoft Entra role for delegated access (group or app reviews: the review creator, Global Reader, Security Reader, User Administrator, Identity Governance Administrator or Security Administrator; Entra-role reviews: Security Reader, Identity Governance Administrator, Privileged Role Administrator or Security Administrator), or admin-consented AccessReview.Read.All for application access; delegated personal Microsoft accounts are not supported",
|
|
234
|
+
"Access reviews need P2 or ID Governance depending on capability, not one uniform licence",
|
|
235
|
+
];
|
|
236
|
+
const DEFINITION_LIST = {
|
|
237
|
+
command: "entra access-review definition list",
|
|
238
|
+
key: "definitions",
|
|
239
|
+
known: DEFINITION_KNOWN,
|
|
240
|
+
knownList: KNOWN_DEFINITION_FIELDS,
|
|
241
|
+
defaultSelect: DEFAULT_DEFINITION_LIST_SELECT,
|
|
242
|
+
denialScopeNote: "Definitions are review schedules (a series), never their occurrences; instances hang off each definition",
|
|
243
|
+
emptyNote: "0 access-review definitions matched; the absence of results is the answer, not an error",
|
|
244
|
+
standing: profileName => [
|
|
245
|
+
"Definitions are review schedules (a series): each recurrence creates one instance per reviewed resource, and a one-time review creates one instance per resource",
|
|
246
|
+
`Show one schedule: mg-axi entra access-review definition show --id <definition-id> ${profileHint(profileName)}`,
|
|
247
|
+
],
|
|
248
|
+
};
|
|
249
|
+
const INSTANCE_LIST = {
|
|
250
|
+
command: "entra access-review instance list",
|
|
251
|
+
key: "instances",
|
|
252
|
+
known: INSTANCE_KNOWN,
|
|
253
|
+
knownList: KNOWN_INSTANCE_FIELDS,
|
|
254
|
+
defaultSelect: DEFAULT_INSTANCE_LIST_SELECT,
|
|
255
|
+
denialScopeNote: "Instances are occurrences of one definition schedule, never schedules themselves",
|
|
256
|
+
emptyNote: "0 access-review instances matched; the absence of results is the answer, not an error",
|
|
257
|
+
standing: () => [
|
|
258
|
+
"Instances are occurrences of one definition schedule: each recurrence and each reviewed resource gets its own instance",
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
const DECISION_LIST = {
|
|
262
|
+
command: "entra access-review decision list",
|
|
263
|
+
key: "decisions",
|
|
264
|
+
known: DECISION_KNOWN,
|
|
265
|
+
knownList: KNOWN_DECISION_FIELDS,
|
|
266
|
+
defaultSelect: DEFAULT_DECISION_SELECT,
|
|
267
|
+
denialScopeNote: "Decisions are read-only here: listing never approves, denies or applies anything",
|
|
268
|
+
emptyNote: "0 access-review decisions matched; an instance with no reviewed principals has no decision items, which is the answer, not an error",
|
|
269
|
+
standing: () => [
|
|
270
|
+
"Decisions are read-only here: listing never approves, denies or applies anything; submitting decisions belongs to a later slice",
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
const CONTACTED_REVIEWER_LIST = {
|
|
274
|
+
command: "entra access-review contacted-reviewer list",
|
|
275
|
+
key: "contactedReviewers",
|
|
276
|
+
known: CONTACTED_REVIEWER_KNOWN,
|
|
277
|
+
knownList: KNOWN_CONTACTED_REVIEWER_FIELDS,
|
|
278
|
+
defaultSelect: DEFAULT_CONTACTED_REVIEWER_LIST_SELECT,
|
|
279
|
+
denialScopeNote: "Contacted reviewers are the reviewer identities recorded on one instance, whether or not they were notified; they never carry review outcomes",
|
|
280
|
+
emptyNote: "0 contacted reviewers matched; the absence of results is the answer, not an error",
|
|
281
|
+
standing: profileName => [
|
|
282
|
+
"Contacted reviewers are identities recorded on one instance, never review outcomes or notification receipts",
|
|
283
|
+
`Show one contacted reviewer: mg-axi entra access-review contacted-reviewer show --definition <definition-id> --instance <instance-id> --id <reviewer-id> ${profileHint(profileName)}`,
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
const STAGE_LIST = {
|
|
287
|
+
command: "entra access-review stage list",
|
|
288
|
+
key: "stages",
|
|
289
|
+
known: STAGE_KNOWN,
|
|
290
|
+
knownList: KNOWN_STAGE_FIELDS,
|
|
291
|
+
defaultSelect: DEFAULT_STAGE_LIST_SELECT,
|
|
292
|
+
denialScopeNote: "Stages are sequential phases of one instance (up to three when stageSettings is defined), never definitions or decisions",
|
|
293
|
+
emptyNote: "0 access-review stages matched; an instance without stageSettings has no stages, which is the answer, not an error",
|
|
294
|
+
standing: profileName => [
|
|
295
|
+
"Stages are sequential phases of one instance: each stage carries its own reviewer scopes, and per-stage decisions belong to a later slice",
|
|
296
|
+
`Show one stage: mg-axi entra access-review stage show --definition <definition-id> --instance <instance-id> --id <stage-id> ${profileHint(profileName)}`,
|
|
297
|
+
],
|
|
298
|
+
};
|
|
299
|
+
function parentId(flags, flag, noun, help) {
|
|
300
|
+
const value = String(flags[flag] ?? "");
|
|
301
|
+
if (!value.trim())
|
|
302
|
+
throw new AxiError(`--${flag} needs the parent ${noun} ID`, "VALIDATION_ERROR", [help]);
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
async function listCollection(shape, session, flags, profile, operation, help, profileName, params, resumeHint) {
|
|
306
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
307
|
+
if (cursor !== undefined && !cursor.trim())
|
|
308
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
309
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
310
|
+
const savedSelect = saved?.$select;
|
|
311
|
+
const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, help), shape.known, shape.knownList, help);
|
|
312
|
+
const savedFilter = saved?.$filter;
|
|
313
|
+
const filter = flags.filter === undefined ? savedFilter : String(flags.filter);
|
|
314
|
+
const scopes = scopesFor(flags, profile, DEFAULT_ACCESS_REVIEW_SCOPES, help);
|
|
315
|
+
const full = flags.full === true;
|
|
316
|
+
// These collections document $select/$filter only (plus server-side
|
|
317
|
+
// $orderby/$skip/$top paging concerns), so --filter passes through as
|
|
318
|
+
// plain $filter with no $count or ConsistencyLevel.
|
|
319
|
+
const query = { $select: select.join(",") };
|
|
320
|
+
if (filter !== undefined)
|
|
321
|
+
query.$filter = filter;
|
|
322
|
+
const args = { profile, operation, params, query, scopes };
|
|
323
|
+
if (cursor !== undefined)
|
|
324
|
+
args.cursor = cursor;
|
|
325
|
+
if (flags.all === true) {
|
|
326
|
+
if (flags.limit !== undefined)
|
|
327
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
331
|
+
}
|
|
332
|
+
const result = await withGuidance(() => session.collect(args));
|
|
333
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? shape.defaultSelect.join(",") };
|
|
334
|
+
if (result.query.$filter !== undefined)
|
|
335
|
+
effectiveFlags.filter = result.query.$filter;
|
|
336
|
+
const rows = [];
|
|
337
|
+
let truncated = false;
|
|
338
|
+
for (const row of result.value) {
|
|
339
|
+
const projected = project(row, fields, full);
|
|
340
|
+
rows.push(projected.row);
|
|
341
|
+
truncated = truncated || projected.truncated;
|
|
342
|
+
}
|
|
343
|
+
const truncationHints = truncated ? [fullHint(shape.command, effectiveFlags, profileName)] : [];
|
|
344
|
+
if (truncated && cursor !== undefined)
|
|
345
|
+
truncationHints.push("Supply the original input cursor on stdin to replay this result with --full");
|
|
346
|
+
const standing = [shape.denialScopeNote, ...shape.standing(profileName)];
|
|
347
|
+
if (!result.complete) {
|
|
348
|
+
return {
|
|
349
|
+
[shape.key]: rows,
|
|
350
|
+
count: { returned: rows.length, complete: false, reason: result.reason },
|
|
351
|
+
cursor: result.cursor,
|
|
352
|
+
help: [...truncationHints, resumeHint, ...standing],
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const count = { returned: rows.length, complete: true };
|
|
356
|
+
if (!rows.length) {
|
|
357
|
+
return { [shape.key]: rows, count, help: [shape.emptyNote, ...standing] };
|
|
358
|
+
}
|
|
359
|
+
return { [shape.key]: rows, count, help: [...truncationHints, ...standing] };
|
|
360
|
+
}
|
|
361
|
+
async function showOne(key, command, known, knownList, defaultSelect, session, flags, profile, operation, help, profileName, params, malformed) {
|
|
362
|
+
const { select, fields } = selectedFields(flags, defaultSelect, known, knownList, help);
|
|
363
|
+
const scopes = scopesFor(flags, profile, DEFAULT_ACCESS_REVIEW_SCOPES, help);
|
|
364
|
+
const full = flags.full === true;
|
|
365
|
+
const raw = await withGuidance(() => session.execute({
|
|
366
|
+
profile,
|
|
367
|
+
operation,
|
|
368
|
+
params,
|
|
369
|
+
query: { $select: select.join(",") },
|
|
370
|
+
scopes,
|
|
371
|
+
}));
|
|
372
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
373
|
+
throw new AxiError(malformed, "GRAPH_ERROR", [
|
|
374
|
+
"Single-object reads carry one object; treat anything else as unknown, not empty",
|
|
375
|
+
]);
|
|
376
|
+
}
|
|
377
|
+
const { row, truncated } = project(raw, fields, full);
|
|
378
|
+
if (truncated)
|
|
379
|
+
return { [key]: row, help: [fullHint(command, flags, profileName)] };
|
|
380
|
+
return { [key]: row };
|
|
381
|
+
}
|
|
382
|
+
export async function listDefinitions(session, flags, profile, operation, help, profileName) {
|
|
383
|
+
return listCollection(DEFINITION_LIST, session, flags, profile, operation, help, profileName, {}, `Resume losslessly with the same flags plus --cursor - ${profileHint(profileName)} and supply the returned cursor on stdin`);
|
|
384
|
+
}
|
|
385
|
+
export async function showDefinition(session, flags, profile, operation, help, profileName) {
|
|
386
|
+
return showOne("definition", "entra access-review definition show", DEFINITION_KNOWN, KNOWN_DEFINITION_FIELDS, DEFAULT_DEFINITION_SHOW_SELECT, session, flags, profile, operation, help, profileName, { "accessReviewScheduleDefinition-id": String(flags.id) }, "Graph returned a malformed access-review definition body");
|
|
387
|
+
}
|
|
388
|
+
export async function listInstances(session, flags, profile, operation, help, profileName) {
|
|
389
|
+
const definition = parentId(flags, "definition", "access-review definition", help);
|
|
390
|
+
return listCollection(INSTANCE_LIST, session, flags, profile, operation, help, profileName, { "accessReviewScheduleDefinition-id": definition }, `Resume losslessly with the same --definition and flags plus --cursor - ${profileHint(profileName)} and supply the returned cursor on stdin`);
|
|
391
|
+
}
|
|
392
|
+
export async function showInstance(session, flags, profile, operation, help, profileName) {
|
|
393
|
+
const definition = parentId(flags, "definition", "access-review definition", help);
|
|
394
|
+
return showOne("instance", "entra access-review instance show", INSTANCE_KNOWN, KNOWN_INSTANCE_FIELDS, DEFAULT_INSTANCE_SHOW_SELECT, session, flags, profile, operation, help, profileName, { "accessReviewScheduleDefinition-id": definition, "accessReviewInstance-id": String(flags.id) }, "Graph returned a malformed access-review instance body");
|
|
395
|
+
}
|
|
396
|
+
export async function listDecisions(session, flags, profile, operation, help, profileName) {
|
|
397
|
+
const definition = parentId(flags, "definition", "access-review definition", help);
|
|
398
|
+
const instance = parentId(flags, "instance", "access-review instance", help);
|
|
399
|
+
return listCollection(DECISION_LIST, session, flags, profile, operation, help, profileName, { "accessReviewScheduleDefinition-id": definition, "accessReviewInstance-id": instance }, `Resume losslessly with the same --definition, --instance and flags plus --cursor - ${profileHint(profileName)} and supply the returned cursor on stdin`);
|
|
400
|
+
}
|
|
401
|
+
export async function showDecision(session, flags, profile, operation, help, profileName) {
|
|
402
|
+
const definition = parentId(flags, "definition", "access-review definition", help);
|
|
403
|
+
const instance = parentId(flags, "instance", "access-review instance", help);
|
|
404
|
+
return showOne("decision", "entra access-review decision show", DECISION_KNOWN, KNOWN_DECISION_FIELDS, DEFAULT_DECISION_SHOW_SELECT, session, flags, profile, operation, help, profileName, { "accessReviewScheduleDefinition-id": definition, "accessReviewInstance-id": instance, "accessReviewInstanceDecisionItem-id": String(flags.id) }, "Graph returned a malformed access-review decision body");
|
|
405
|
+
}
|
|
406
|
+
export async function listContactedReviewers(session, flags, profile, operation, help, profileName) {
|
|
407
|
+
const definition = parentId(flags, "definition", "access-review definition", help);
|
|
408
|
+
const instance = parentId(flags, "instance", "access-review instance", help);
|
|
409
|
+
return listCollection(CONTACTED_REVIEWER_LIST, session, flags, profile, operation, help, profileName, { "accessReviewScheduleDefinition-id": definition, "accessReviewInstance-id": instance }, `Resume losslessly with the same --definition, --instance and flags plus --cursor - ${profileHint(profileName)} and supply the returned cursor on stdin`);
|
|
410
|
+
}
|
|
411
|
+
export async function showContactedReviewer(session, flags, profile, operation, help, profileName) {
|
|
412
|
+
const definition = parentId(flags, "definition", "access-review definition", help);
|
|
413
|
+
const instance = parentId(flags, "instance", "access-review instance", help);
|
|
414
|
+
return showOne("contactedReviewer", "entra access-review contacted-reviewer show", CONTACTED_REVIEWER_KNOWN, KNOWN_CONTACTED_REVIEWER_FIELDS, DEFAULT_CONTACTED_REVIEWER_SHOW_SELECT, session, flags, profile, operation, help, profileName, { "accessReviewScheduleDefinition-id": definition, "accessReviewInstance-id": instance, "accessReviewReviewer-id": String(flags.id) }, "Graph returned a malformed contacted-reviewer body");
|
|
415
|
+
}
|
|
416
|
+
export async function listStages(session, flags, profile, operation, help, profileName) {
|
|
417
|
+
const definition = parentId(flags, "definition", "access-review definition", help);
|
|
418
|
+
const instance = parentId(flags, "instance", "access-review instance", help);
|
|
419
|
+
return listCollection(STAGE_LIST, session, flags, profile, operation, help, profileName, { "accessReviewScheduleDefinition-id": definition, "accessReviewInstance-id": instance }, `Resume losslessly with the same --definition, --instance and flags plus --cursor - ${profileHint(profileName)} and supply the returned cursor on stdin`);
|
|
420
|
+
}
|
|
421
|
+
export async function showStage(session, flags, profile, operation, help, profileName) {
|
|
422
|
+
const definition = parentId(flags, "definition", "access-review definition", help);
|
|
423
|
+
const instance = parentId(flags, "instance", "access-review instance", help);
|
|
424
|
+
return showOne("stage", "entra access-review stage show", STAGE_KNOWN, KNOWN_STAGE_FIELDS, DEFAULT_STAGE_SHOW_SELECT, session, flags, profile, operation, help, profileName, { "accessReviewScheduleDefinition-id": definition, "accessReviewInstance-id": instance, "accessReviewStage-id": String(flags.id) }, "Graph returned a malformed access-review stage body");
|
|
425
|
+
}
|