@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,461 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// EXT-01 deleted-items subfamily: the read mapping behind
|
|
3
|
+
// `mg-axi entra deleted-user|deleted-group|deleted-application|
|
|
4
|
+
// deleted-service-principal|deleted-administrative-unit list/count` and
|
|
5
|
+
// `mg-axi entra deleted-item show`. Operation construction stays beside its
|
|
6
|
+
// command; the shared session owns URLs, credentials, paging, retries and
|
|
7
|
+
// error translation, and the SDK owns TOON rendering. This module only maps
|
|
8
|
+
// flags to session calls and projects rows for compact output.
|
|
9
|
+
//
|
|
10
|
+
// Reviewed against the v1.0 directory-deleteditems-list and
|
|
11
|
+
// directory-deleteditems-get operation documentation on 2026-10-05. The list
|
|
12
|
+
// page documents per-type least-privilege permissions (delegated and
|
|
13
|
+
// application share the same scope per type; personal Microsoft accounts are
|
|
14
|
+
// not supported): user takes User.Read.All, group takes Group.Read.All,
|
|
15
|
+
// application and servicePrincipal take Application.Read.All, and
|
|
16
|
+
// administrativeUnit takes AdministrativeUnit.Read.All. No delegated role
|
|
17
|
+
// and no P1/P2 prerequisite are stated for these reads. The OData cast is a
|
|
18
|
+
// required part of the list URI, so untyped list/count commands do not exist
|
|
19
|
+
// and device casts carry no documented v1.0 permission contract and stay
|
|
20
|
+
// out. The $count scalar follows its typed list's permission contract.
|
|
21
|
+
// Deleted users are personal data and deleted applications carry credential
|
|
22
|
+
// metadata, so defaults stay minimal and key/password credential collections
|
|
23
|
+
// are never selectable: GET never returns secret values and success
|
|
24
|
+
// redaction still applies. Restore, permanent delete, the POST
|
|
25
|
+
// lookup/validation actions and beta stay out. No mutation lives here.
|
|
26
|
+
//
|
|
27
|
+
// Show reads the single documented untyped get
|
|
28
|
+
// (GET /directory/deletedItems/{directoryObject-id}), which can return any
|
|
29
|
+
// deletable type. Its $select is therefore limited to the cross-type safe
|
|
30
|
+
// properties valid on every deletable type; per-type detail comes from the
|
|
31
|
+
// typed lists. The object's type is unknown before the read, so show takes
|
|
32
|
+
// no default scope: delegated callers pass the scope matching the object's
|
|
33
|
+
// type as --scopes. The @odata.type discriminator rides along automatically
|
|
34
|
+
// (envelope metadata, never selectable) so rows still name their kind.
|
|
35
|
+
// Every deleted-user property this slice may request or display. Phones,
|
|
36
|
+
// photos and other PII beyond what identifies the restore target stay out;
|
|
37
|
+
// anything else fails before credentials.
|
|
38
|
+
export const KNOWN_DELETED_USER_FIELDS = [
|
|
39
|
+
"id",
|
|
40
|
+
"displayName",
|
|
41
|
+
"userPrincipalName",
|
|
42
|
+
"mail",
|
|
43
|
+
"userType",
|
|
44
|
+
"deletedDateTime",
|
|
45
|
+
];
|
|
46
|
+
// Every deleted-group property this slice may request or display. Soft
|
|
47
|
+
// deleted security groups report securityEnabled false through a known
|
|
48
|
+
// upstream limitation, so groupTypes names the real kind.
|
|
49
|
+
export const KNOWN_DELETED_GROUP_FIELDS = [
|
|
50
|
+
"id",
|
|
51
|
+
"displayName",
|
|
52
|
+
"mail",
|
|
53
|
+
"mailNickname",
|
|
54
|
+
"groupTypes",
|
|
55
|
+
"visibility",
|
|
56
|
+
"securityEnabled",
|
|
57
|
+
"deletedDateTime",
|
|
58
|
+
];
|
|
59
|
+
// Every deleted-application property this slice may request or display.
|
|
60
|
+
// appId (client ID) is distinct from the object id. keyCredentials and
|
|
61
|
+
// passwordCredentials are never selectable: credential metadata stays
|
|
62
|
+
// minimal and secret values can never appear.
|
|
63
|
+
export const KNOWN_DELETED_APPLICATION_FIELDS = [
|
|
64
|
+
"id",
|
|
65
|
+
"appId",
|
|
66
|
+
"displayName",
|
|
67
|
+
"publisherDomain",
|
|
68
|
+
"signInAudience",
|
|
69
|
+
"deletedDateTime",
|
|
70
|
+
];
|
|
71
|
+
// Every deleted-service-principal property this slice may request or
|
|
72
|
+
// display. appId (client ID) is distinct from the object id; secret-bearing
|
|
73
|
+
// fields are never selectable.
|
|
74
|
+
export const KNOWN_DELETED_SERVICE_PRINCIPAL_FIELDS = [
|
|
75
|
+
"id",
|
|
76
|
+
"appId",
|
|
77
|
+
"displayName",
|
|
78
|
+
"servicePrincipalType",
|
|
79
|
+
"accountEnabled",
|
|
80
|
+
"deletedDateTime",
|
|
81
|
+
];
|
|
82
|
+
// Every deleted-administrative-unit property this slice may request or
|
|
83
|
+
// display.
|
|
84
|
+
export const KNOWN_DELETED_ADMINISTRATIVE_UNIT_FIELDS = [
|
|
85
|
+
"id",
|
|
86
|
+
"displayName",
|
|
87
|
+
"description",
|
|
88
|
+
"visibility",
|
|
89
|
+
"membershipType",
|
|
90
|
+
"deletedDateTime",
|
|
91
|
+
];
|
|
92
|
+
// Every deleted-item show property this slice may request or display: the
|
|
93
|
+
// cross-type safe intersection, valid on whatever deletable type the
|
|
94
|
+
// untyped get returns. Per-type detail comes from the typed lists.
|
|
95
|
+
export const KNOWN_DELETED_SHOW_FIELDS = [
|
|
96
|
+
"id",
|
|
97
|
+
"displayName",
|
|
98
|
+
"deletedDateTime",
|
|
99
|
+
];
|
|
100
|
+
// The subtype discriminator rides on every row without being selectable: it
|
|
101
|
+
// names the concrete kind (user, group, application and the rest).
|
|
102
|
+
const DELETED_TYPE_PROPERTY = "@odata.type";
|
|
103
|
+
// Compact list rows carry the identifier, the display name and the deletion
|
|
104
|
+
// timestamp; applications and service principals additionally carry appId so
|
|
105
|
+
// the client ID is never conflated with the object id.
|
|
106
|
+
export const DEFAULT_DELETED_USER_LIST_SELECT = ["id", "displayName", "deletedDateTime"];
|
|
107
|
+
export const DEFAULT_DELETED_GROUP_LIST_SELECT = ["id", "displayName", "deletedDateTime"];
|
|
108
|
+
export const DEFAULT_DELETED_APPLICATION_LIST_SELECT = ["id", "appId", "displayName", "deletedDateTime"];
|
|
109
|
+
export const DEFAULT_DELETED_SERVICE_PRINCIPAL_LIST_SELECT = ["id", "appId", "displayName", "deletedDateTime"];
|
|
110
|
+
export const DEFAULT_DELETED_ADMINISTRATIVE_UNIT_LIST_SELECT = ["id", "displayName", "deletedDateTime"];
|
|
111
|
+
// Show rows carry the full cross-type safe set.
|
|
112
|
+
const DEFAULT_SHOW_SELECT = [...KNOWN_DELETED_SHOW_FIELDS];
|
|
113
|
+
// Delegated list/count defaults are per-type least privilege; application
|
|
114
|
+
// profiles use their configured .default audience and reject --scopes. All
|
|
115
|
+
// four scopes are already in the shared READ_SCOPES allowlist, and every
|
|
116
|
+
// one is a read scope, so no new scope and no deferral are needed here.
|
|
117
|
+
export const DELETED_ITEM_SCOPES = {
|
|
118
|
+
user: ["https://graph.microsoft.com/User.Read.All"],
|
|
119
|
+
group: ["https://graph.microsoft.com/Group.Read.All"],
|
|
120
|
+
application: ["https://graph.microsoft.com/Application.Read.All"],
|
|
121
|
+
"service-principal": ["https://graph.microsoft.com/Application.Read.All"],
|
|
122
|
+
"administrative-unit": ["https://graph.microsoft.com/AdministrativeUnit.Read.All"],
|
|
123
|
+
};
|
|
124
|
+
// Every scope a deleted-item show caller may need to pass, one per deletable
|
|
125
|
+
// type. Show takes no default because the object's type is unknown before
|
|
126
|
+
// the read.
|
|
127
|
+
export const DELETED_SHOW_SCOPE_CHOICES = [
|
|
128
|
+
"https://graph.microsoft.com/User.Read.All",
|
|
129
|
+
"https://graph.microsoft.com/Group.Read.All",
|
|
130
|
+
"https://graph.microsoft.com/Application.Read.All",
|
|
131
|
+
"https://graph.microsoft.com/AdministrativeUnit.Read.All",
|
|
132
|
+
];
|
|
133
|
+
const TRUNCATE_AT = 500;
|
|
134
|
+
const TYPE_FIELDS = {
|
|
135
|
+
user: KNOWN_DELETED_USER_FIELDS,
|
|
136
|
+
group: KNOWN_DELETED_GROUP_FIELDS,
|
|
137
|
+
application: KNOWN_DELETED_APPLICATION_FIELDS,
|
|
138
|
+
"service-principal": KNOWN_DELETED_SERVICE_PRINCIPAL_FIELDS,
|
|
139
|
+
"administrative-unit": KNOWN_DELETED_ADMINISTRATIVE_UNIT_FIELDS,
|
|
140
|
+
};
|
|
141
|
+
const TYPE_LIST_DEFAULTS = {
|
|
142
|
+
user: DEFAULT_DELETED_USER_LIST_SELECT,
|
|
143
|
+
group: DEFAULT_DELETED_GROUP_LIST_SELECT,
|
|
144
|
+
application: DEFAULT_DELETED_APPLICATION_LIST_SELECT,
|
|
145
|
+
"service-principal": DEFAULT_DELETED_SERVICE_PRINCIPAL_LIST_SELECT,
|
|
146
|
+
"administrative-unit": DEFAULT_DELETED_ADMINISTRATIVE_UNIT_LIST_SELECT,
|
|
147
|
+
};
|
|
148
|
+
// The only operations this slice ever binds. Anything else - the untyped
|
|
149
|
+
// list/count (upstream requires the cast), device casts (no documented v1.0
|
|
150
|
+
// permission contract), typed-cast singles (covered by the untyped show),
|
|
151
|
+
// POST lookup/validation actions, restore, permanent delete, beta - is
|
|
152
|
+
// refused before credentials.
|
|
153
|
+
const LIST_ROUTES = {
|
|
154
|
+
"GET:/directory/deletedItems/graph.user": "user",
|
|
155
|
+
"GET:/directory/deletedItems/graph.group": "group",
|
|
156
|
+
"GET:/directory/deletedItems/graph.application": "application",
|
|
157
|
+
"GET:/directory/deletedItems/graph.servicePrincipal": "service-principal",
|
|
158
|
+
"GET:/directory/deletedItems/graph.administrativeUnit": "administrative-unit",
|
|
159
|
+
};
|
|
160
|
+
const COUNT_ROUTES = {
|
|
161
|
+
"GET:/directory/deletedItems/graph.user/$count": "user",
|
|
162
|
+
"GET:/directory/deletedItems/graph.group/$count": "group",
|
|
163
|
+
"GET:/directory/deletedItems/graph.application/$count": "application",
|
|
164
|
+
"GET:/directory/deletedItems/graph.servicePrincipal/$count": "service-principal",
|
|
165
|
+
"GET:/directory/deletedItems/graph.administrativeUnit/$count": "administrative-unit",
|
|
166
|
+
};
|
|
167
|
+
const SHOW_ROUTE = "GET:/directory/deletedItems/{directoryObject-id}";
|
|
168
|
+
function routeOf(operation) {
|
|
169
|
+
return `${operation.method}:${operation.path}`;
|
|
170
|
+
}
|
|
171
|
+
function fieldList(raw, known, flag, help) {
|
|
172
|
+
const knownSet = new Set(known);
|
|
173
|
+
const parts = String(raw)
|
|
174
|
+
.split(",")
|
|
175
|
+
.map(part => part.trim())
|
|
176
|
+
.filter(part => part.length > 0);
|
|
177
|
+
if (!parts.length)
|
|
178
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
179
|
+
const fields = [];
|
|
180
|
+
for (const part of parts) {
|
|
181
|
+
if (!knownSet.has(part)) {
|
|
182
|
+
throw new AxiError(`Unknown deleted-item property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
183
|
+
help,
|
|
184
|
+
`Known deleted-item properties: ${known.join(", ")}`,
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
if (!fields.includes(part))
|
|
188
|
+
fields.push(part);
|
|
189
|
+
}
|
|
190
|
+
return fields;
|
|
191
|
+
}
|
|
192
|
+
function listScopesFor(flags, type, profile, help) {
|
|
193
|
+
if (profile.mode === "application") {
|
|
194
|
+
if (flags.scopes !== undefined) {
|
|
195
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
if (flags.scopes === undefined)
|
|
200
|
+
return [...DELETED_ITEM_SCOPES[type]];
|
|
201
|
+
const scopes = String(flags.scopes)
|
|
202
|
+
.split(",")
|
|
203
|
+
.map(scope => scope.trim())
|
|
204
|
+
.filter(scope => scope.length > 0);
|
|
205
|
+
return scopes;
|
|
206
|
+
}
|
|
207
|
+
// Show takes no default scope: the untyped get can return any deletable
|
|
208
|
+
// type, so the caller passes the scope matching the object's type.
|
|
209
|
+
function showScopesFor(flags, profile, help) {
|
|
210
|
+
if (profile.mode === "application") {
|
|
211
|
+
if (flags.scopes !== undefined) {
|
|
212
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
if (flags.scopes === undefined) {
|
|
217
|
+
throw new AxiError("Deleted-item show needs an explicit --scopes matching the object's type", "VALIDATION_ERROR", [
|
|
218
|
+
help,
|
|
219
|
+
`Pass one of: ${DELETED_SHOW_SCOPE_CHOICES.join(", ")}`,
|
|
220
|
+
"List the recycle bin first: the @odata.type discriminator names each row's kind",
|
|
221
|
+
]);
|
|
222
|
+
}
|
|
223
|
+
const scopes = String(flags.scopes)
|
|
224
|
+
.split(",")
|
|
225
|
+
.map(scope => scope.trim())
|
|
226
|
+
.filter(scope => scope.length > 0);
|
|
227
|
+
if (!scopes.length)
|
|
228
|
+
throw new AxiError("--scopes needs at least one scope", "VALIDATION_ERROR", [help]);
|
|
229
|
+
return scopes;
|
|
230
|
+
}
|
|
231
|
+
function truncateValue(value, full) {
|
|
232
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
233
|
+
? { value: text, truncated: false }
|
|
234
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
235
|
+
if (typeof value === "string")
|
|
236
|
+
return truncate(value);
|
|
237
|
+
if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
|
|
238
|
+
const projected = value.map(truncate);
|
|
239
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
240
|
+
}
|
|
241
|
+
return { value, truncated: false };
|
|
242
|
+
}
|
|
243
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
244
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
245
|
+
// The subtype discriminator rides along automatically when the server sent
|
|
246
|
+
// it; unreviewed properties never do, so secrets or key material any
|
|
247
|
+
// subtype carries stay out.
|
|
248
|
+
function project(row, fields, full) {
|
|
249
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
250
|
+
const projected = {};
|
|
251
|
+
let truncated = false;
|
|
252
|
+
for (const field of fields) {
|
|
253
|
+
if (!Object.hasOwn(source, field))
|
|
254
|
+
continue;
|
|
255
|
+
const result = truncateValue(source[field], full);
|
|
256
|
+
projected[field] = result.value;
|
|
257
|
+
truncated = truncated || result.truncated;
|
|
258
|
+
}
|
|
259
|
+
if (typeof source[DELETED_TYPE_PROPERTY] === "string")
|
|
260
|
+
projected[DELETED_TYPE_PROPERTY] = source[DELETED_TYPE_PROPERTY];
|
|
261
|
+
return { row: projected, truncated };
|
|
262
|
+
}
|
|
263
|
+
function selectedFields(flags, known, defaults, help) {
|
|
264
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, "select", help);
|
|
265
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, "fields", help);
|
|
266
|
+
const missing = fields.find(field => !select.includes(field));
|
|
267
|
+
if (missing) {
|
|
268
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
269
|
+
}
|
|
270
|
+
return { select, fields };
|
|
271
|
+
}
|
|
272
|
+
function profileHint(profileName) {
|
|
273
|
+
return `--profile ${shellValue(profileName)}`;
|
|
274
|
+
}
|
|
275
|
+
function shellValue(value) {
|
|
276
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
277
|
+
}
|
|
278
|
+
function fullHint(command, flags, profileName) {
|
|
279
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
280
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
281
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
282
|
+
}
|
|
283
|
+
// Denials carry the session's generic grant/role/licence cause; each type
|
|
284
|
+
// adds the scope that actually unlocks it, because a 403 alone never says
|
|
285
|
+
// which prerequisite is missing. No delegated role and no P1/P2
|
|
286
|
+
// prerequisite are stated for these reads.
|
|
287
|
+
function withGuidance(hints, run) {
|
|
288
|
+
return run().catch(error => {
|
|
289
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
290
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
291
|
+
}
|
|
292
|
+
throw error;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function denialHints(type) {
|
|
296
|
+
const scope = DELETED_ITEM_SCOPES[type][0];
|
|
297
|
+
return [
|
|
298
|
+
`Deleted-${type} reads need ${scope} for delegated or application access; delegated callers pass it as --scopes`,
|
|
299
|
+
"Personal Microsoft accounts are not supported for deleted-item reads",
|
|
300
|
+
"No delegated role or P1/P2 prerequisite is stated for deleted-item reads; never diagnose role or licence solely from HTTP 403",
|
|
301
|
+
];
|
|
302
|
+
}
|
|
303
|
+
const SHOW_DENIAL_HINTS = [
|
|
304
|
+
"Deleted-item show needs the scope matching the object's type (User.Read.All, Group.Read.All, Application.Read.All or AdministrativeUnit.Read.All): delegated callers pass it as --scopes, application access needs the admin-consented scope on the configured .default audience",
|
|
305
|
+
"Personal Microsoft accounts are not supported for deleted-item reads",
|
|
306
|
+
"No delegated role or P1/P2 prerequisite is stated for this read; never diagnose role or licence solely from HTTP 403",
|
|
307
|
+
];
|
|
308
|
+
const SHOW_SCOPE_NOTE = "Show takes no default scope: pass the scope matching the object's type as --scopes; list the recycle bin first and read each row's @odata.type kind";
|
|
309
|
+
function typeCommand(type, verb) {
|
|
310
|
+
return `mg-axi entra deleted-${type} ${verb}`;
|
|
311
|
+
}
|
|
312
|
+
function showCommand(profileName) {
|
|
313
|
+
return `mg-axi entra deleted-item show --id <object-id> ${profileHint(profileName)}`;
|
|
314
|
+
}
|
|
315
|
+
// Restoring the saved select keeps cursor resumes lossless when --select is
|
|
316
|
+
// omitted. The typed lists document no reviewed $filter/$orderby contract
|
|
317
|
+
// here, so the catalogue declares no such flags and strict input validation
|
|
318
|
+
// refuses them before credentials instead of forwarding an unreviewed query.
|
|
319
|
+
function collectionCommon(session, flags, type, operation, help, profile) {
|
|
320
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
321
|
+
if (cursor !== undefined && !cursor.trim())
|
|
322
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
323
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
324
|
+
const known = TYPE_FIELDS[type];
|
|
325
|
+
const defaults = TYPE_LIST_DEFAULTS[type];
|
|
326
|
+
const { select, fields } = selectedFields(flags, known, saved?.["$select"] === undefined ? [...defaults] : fieldList(saved["$select"], known, "select", help), help);
|
|
327
|
+
return { cursor, select, fields, scopes: listScopesFor(flags, type, profile, help), full: flags.full === true };
|
|
328
|
+
}
|
|
329
|
+
function collectArgs(profile, operation, common, flags, help) {
|
|
330
|
+
const query = { $select: common.select.join(",") };
|
|
331
|
+
const args = { profile, operation, query, scopes: common.scopes };
|
|
332
|
+
if (common.cursor !== undefined)
|
|
333
|
+
args.cursor = common.cursor;
|
|
334
|
+
if (flags.all === true) {
|
|
335
|
+
if (flags.limit !== undefined)
|
|
336
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
340
|
+
}
|
|
341
|
+
return args;
|
|
342
|
+
}
|
|
343
|
+
function resumeHint(profileName) {
|
|
344
|
+
return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
|
|
345
|
+
}
|
|
346
|
+
function deletedId(flags, help) {
|
|
347
|
+
const id = String(flags.id);
|
|
348
|
+
if (!id.trim())
|
|
349
|
+
throw new AxiError("--id needs the deleted object ID", "VALIDATION_ERROR", [help]);
|
|
350
|
+
return id;
|
|
351
|
+
}
|
|
352
|
+
function listType(operation, help) {
|
|
353
|
+
const type = LIST_ROUTES[routeOf(operation)];
|
|
354
|
+
if (operation.method !== "GET" || type === undefined) {
|
|
355
|
+
throw new AxiError(`Refused non-read route ${operation.id}: deleted-item lists serve only the five catalogued v1.0 typed deletedItems GETs`, "VALIDATION_ERROR", [
|
|
356
|
+
help,
|
|
357
|
+
"Untyped lists need the OData cast upstream requires; device casts, POST lookups, restore and beta need their own review",
|
|
358
|
+
]);
|
|
359
|
+
}
|
|
360
|
+
return type;
|
|
361
|
+
}
|
|
362
|
+
function countType(operation, help) {
|
|
363
|
+
const type = COUNT_ROUTES[routeOf(operation)];
|
|
364
|
+
if (operation.method !== "GET" || type === undefined) {
|
|
365
|
+
throw new AxiError(`Refused non-read route ${operation.id}: deleted-item counts serve only the five catalogued v1.0 typed deletedItems $count GETs`, "VALIDATION_ERROR", [
|
|
366
|
+
help,
|
|
367
|
+
"Untyped counts need the OData cast upstream requires; device casts and beta need their own review",
|
|
368
|
+
]);
|
|
369
|
+
}
|
|
370
|
+
return type;
|
|
371
|
+
}
|
|
372
|
+
function checkShowOperation(operation, help) {
|
|
373
|
+
if (operation.method !== "GET" || routeOf(operation) !== SHOW_ROUTE) {
|
|
374
|
+
throw new AxiError(`Refused non-read route ${operation.id}: deleted-item show serves only the catalogued v1.0 untyped deletedItems get`, "VALIDATION_ERROR", [
|
|
375
|
+
help,
|
|
376
|
+
"Typed-cast singles are covered by this same untyped get; restore, permanent delete and beta need their own review",
|
|
377
|
+
]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
export async function listDeletedItems(session, flags, profile, operation, help, profileName) {
|
|
381
|
+
const type = listType(operation, help);
|
|
382
|
+
const common = collectionCommon(session, flags, type, operation, help, profile);
|
|
383
|
+
const result = await withGuidance(denialHints(type), () => session.collect(collectArgs(profile, operation, common, flags, help)));
|
|
384
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? TYPE_LIST_DEFAULTS[type].join(",") };
|
|
385
|
+
const rows = [];
|
|
386
|
+
let truncated = false;
|
|
387
|
+
for (const row of result.value) {
|
|
388
|
+
const projected = project(row, common.fields, common.full);
|
|
389
|
+
rows.push(projected.row);
|
|
390
|
+
truncated = truncated || projected.truncated;
|
|
391
|
+
}
|
|
392
|
+
const command = typeCommand(type, "list");
|
|
393
|
+
const showHint = showCommand(profileName);
|
|
394
|
+
const groupNote = type === "group"
|
|
395
|
+
? ["Soft-deleted security groups report securityEnabled false through a known upstream limitation; read groupTypes to name the real kind"]
|
|
396
|
+
: [];
|
|
397
|
+
const appNote = type === "application" || type === "service-principal"
|
|
398
|
+
? ["appId (client ID) is distinct from the object id; credential collections are never selectable and GET never returns secret values"]
|
|
399
|
+
: [];
|
|
400
|
+
const emptyNote = `0 deleted ${type === "service-principal" ? "service principals" : `${type}s`} matched; the absence of results is the answer, not an error`;
|
|
401
|
+
const truncationHints = truncated ? [fullHint(command, effectiveFlags, profileName)] : [];
|
|
402
|
+
if (!result.complete) {
|
|
403
|
+
return {
|
|
404
|
+
deletedItems: rows,
|
|
405
|
+
count: { returned: rows.length, complete: false, reason: result.reason },
|
|
406
|
+
cursor: result.cursor,
|
|
407
|
+
help: [...truncationHints, resumeHint(profileName), showHint, ...groupNote, ...appNote],
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
const count = { returned: rows.length, complete: true };
|
|
411
|
+
if (!rows.length) {
|
|
412
|
+
return { deletedItems: rows, count, help: [emptyNote, ...groupNote, ...appNote] };
|
|
413
|
+
}
|
|
414
|
+
return { deletedItems: rows, count, help: [...truncationHints, showHint, ...groupNote, ...appNote] };
|
|
415
|
+
}
|
|
416
|
+
export async function showDeletedItem(session, flags, profile, operation, help, profileName) {
|
|
417
|
+
checkShowOperation(operation, help);
|
|
418
|
+
const { select, fields } = selectedFields(flags, KNOWN_DELETED_SHOW_FIELDS, DEFAULT_SHOW_SELECT, help);
|
|
419
|
+
const scopes = showScopesFor(flags, profile, help);
|
|
420
|
+
const full = flags.full === true;
|
|
421
|
+
const id = deletedId(flags, help);
|
|
422
|
+
const raw = await withGuidance(SHOW_DENIAL_HINTS, () => session.execute({
|
|
423
|
+
profile,
|
|
424
|
+
operation,
|
|
425
|
+
params: { "directoryObject-id": id },
|
|
426
|
+
query: { $select: select.join(",") },
|
|
427
|
+
scopes,
|
|
428
|
+
}));
|
|
429
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
430
|
+
throw new AxiError("Graph returned a malformed deleted-item body", "GRAPH_ERROR", [
|
|
431
|
+
"Single-object reads carry one directoryObject; treat anything else as unknown, not empty",
|
|
432
|
+
]);
|
|
433
|
+
}
|
|
434
|
+
const { row, truncated } = project(raw, fields, full);
|
|
435
|
+
const helpHints = [...(truncated ? [fullHint("entra deleted-item show", flags, profileName)] : []), SHOW_SCOPE_NOTE];
|
|
436
|
+
if (typeof row["@odata.type"] === "string" && row["@odata.type"].includes("group")) {
|
|
437
|
+
helpHints.push("Soft-deleted security groups report securityEnabled false through a known upstream limitation; read groupTypes to name the real kind");
|
|
438
|
+
}
|
|
439
|
+
return { deletedItem: row, help: helpHints };
|
|
440
|
+
}
|
|
441
|
+
// The $count route returns a text/plain integer scalar rather than a JSON
|
|
442
|
+
// collection, so the leaf reads it through session.execute and accepts only
|
|
443
|
+
// a non-negative integer. There is no --filter/--select/--limit contract on
|
|
444
|
+
// the count: the catalogue declares no such flags and strict input
|
|
445
|
+
// validation refuses them before credentials.
|
|
446
|
+
export async function countDeletedItems(session, flags, profile, operation, help, profileName) {
|
|
447
|
+
void profileName;
|
|
448
|
+
const type = countType(operation, help);
|
|
449
|
+
const scopes = listScopesFor(flags, type, profile, help);
|
|
450
|
+
const raw = await withGuidance(denialHints(type), () => session.execute({ profile, operation, scopes, scalar: true }));
|
|
451
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
452
|
+
throw new AxiError("Graph returned a malformed deleted-item count body", "GRAPH_ERROR", [
|
|
453
|
+
"Deleted-item counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
|
|
454
|
+
]);
|
|
455
|
+
}
|
|
456
|
+
const label = type === "service-principal" ? "service principals" : `${type}s`;
|
|
457
|
+
const count = { returned: raw, complete: true };
|
|
458
|
+
return raw === 0
|
|
459
|
+
? { count, help: [`0 deleted ${label} in the recycle bin; the absence of results is the answer, not an error`] }
|
|
460
|
+
: { count };
|
|
461
|
+
}
|