@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,152 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { resolveSessionOperation } from "./graph-session.js";
|
|
3
|
+
// EXT-04e tenant-lookup subfamily: the read mapping behind
|
|
4
|
+
// `mg-axi entra tenant-information show --domain-name <domain>` and
|
|
5
|
+
// `mg-axi entra tenant-information show --tenant-id <tenant-guid>`.
|
|
6
|
+
// Operation construction stays beside its command; the shared session owns
|
|
7
|
+
// URLs, credentials, retries and error translation, and the SDK owns TOON
|
|
8
|
+
// rendering. This module only maps flags to session calls and projects rows
|
|
9
|
+
// for compact output.
|
|
10
|
+
//
|
|
11
|
+
// Reviewed against the v1.0 findTenantInformationByDomainName,
|
|
12
|
+
// findTenantInformationByTenantId and tenantInformation resource
|
|
13
|
+
// documentation on 2026-10-05. Every read takes D/A
|
|
14
|
+
// CrossTenantInformation.ReadBasic.All as least privilege; delegated
|
|
15
|
+
// callers pass it as --scopes while application callers need it
|
|
16
|
+
// admin-consented on the configured .default audience. The operation pages
|
|
17
|
+
// state no Entra role prerequisite and personal Microsoft accounts are not
|
|
18
|
+
// supported. No P1/P2 prerequisite is stated for these reads. The pages
|
|
19
|
+
// document no query parameters, so the leaf offers no --select/--fields/--
|
|
20
|
+
// filter/--limit/--cursor and always returns the full reviewed
|
|
21
|
+
// tenantInformation set. The addresses are function segments, so the key
|
|
22
|
+
// reaches the session only as an explicit CLI flag and the allowlisted
|
|
23
|
+
// session binding validates, OData-quotes and encodes it; any other value
|
|
24
|
+
// fails before credentials. Beta stays out. No mutation lives here.
|
|
25
|
+
// Every tenant-information property this slice may display, matching the
|
|
26
|
+
// reviewed tenantInformation resource. Anything else fails before
|
|
27
|
+
// credentials.
|
|
28
|
+
export const KNOWN_TENANT_INFORMATION_FIELDS = [
|
|
29
|
+
"defaultDomainName",
|
|
30
|
+
"displayName",
|
|
31
|
+
"federationBrandName",
|
|
32
|
+
"tenantId",
|
|
33
|
+
];
|
|
34
|
+
// Delegated defaults are operation-specific; application profiles use their
|
|
35
|
+
// configured .default audience and reject --scopes.
|
|
36
|
+
export const DEFAULT_TENANT_INFORMATION_SCOPES = ["https://graph.microsoft.com/CrossTenantInformation.ReadBasic.All"];
|
|
37
|
+
const TRUNCATE_AT = 500;
|
|
38
|
+
function scopesFor(flags, defaults, profile, help) {
|
|
39
|
+
if (profile.mode === "application") {
|
|
40
|
+
if (flags.scopes !== undefined) {
|
|
41
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (flags.scopes === undefined)
|
|
46
|
+
return [...defaults];
|
|
47
|
+
const scopes = String(flags.scopes)
|
|
48
|
+
.split(",")
|
|
49
|
+
.map(scope => scope.trim())
|
|
50
|
+
.filter(scope => scope.length > 0);
|
|
51
|
+
return scopes;
|
|
52
|
+
}
|
|
53
|
+
function truncateValue(value, full) {
|
|
54
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
55
|
+
? { value: text, truncated: false }
|
|
56
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
57
|
+
if (typeof value === "string")
|
|
58
|
+
return truncate(value);
|
|
59
|
+
if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
|
|
60
|
+
const projected = value.map(truncate);
|
|
61
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
62
|
+
}
|
|
63
|
+
return { value, truncated: false };
|
|
64
|
+
}
|
|
65
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
66
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
67
|
+
function project(row, fields, full) {
|
|
68
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
69
|
+
const projected = {};
|
|
70
|
+
let truncated = false;
|
|
71
|
+
for (const field of fields) {
|
|
72
|
+
if (!Object.hasOwn(source, field))
|
|
73
|
+
continue;
|
|
74
|
+
const result = truncateValue(source[field], full);
|
|
75
|
+
projected[field] = result.value;
|
|
76
|
+
truncated = truncated || result.truncated;
|
|
77
|
+
}
|
|
78
|
+
return { row: projected, truncated };
|
|
79
|
+
}
|
|
80
|
+
function profileHint(profileName) {
|
|
81
|
+
return `--profile ${shellValue(profileName)}`;
|
|
82
|
+
}
|
|
83
|
+
function shellValue(value) {
|
|
84
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
85
|
+
}
|
|
86
|
+
function fullHint(command, flags, profileName) {
|
|
87
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
88
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
89
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
90
|
+
}
|
|
91
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
92
|
+
// operation adds the scope and prerequisites that actually unlock it,
|
|
93
|
+
// because a 403 alone never says which prerequisite is missing.
|
|
94
|
+
function withGuidance(hints, run) {
|
|
95
|
+
return run().catch(error => {
|
|
96
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
97
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const TENANT_INFORMATION_DENIAL_HINTS = [
|
|
103
|
+
"Tenant-information lookups need CrossTenantInformation.ReadBasic.All, passed as --scopes for delegated access or admin-consented for application access",
|
|
104
|
+
"No Entra role is required for tenant-information lookups; a 403 names a missing grant, not a missing role",
|
|
105
|
+
"Personal Microsoft accounts are not supported for tenant-information lookups",
|
|
106
|
+
"No P1/P2 prerequisite is stated for tenant-information lookups; never diagnose licence solely from HTTP 403",
|
|
107
|
+
];
|
|
108
|
+
// Exactly one lookup key selects the function route; the value itself is
|
|
109
|
+
// validated by the allowlisted session binding before credentials. The
|
|
110
|
+
// domain operation arrives catalogue-resolved; the tenant-ID route resolves
|
|
111
|
+
// from the same inventory authority beside its command.
|
|
112
|
+
function lookupTarget(flags, domainOperation, help) {
|
|
113
|
+
const domain = flags["domain-name"];
|
|
114
|
+
const tenantId = flags["tenant-id"];
|
|
115
|
+
if (domain !== undefined && tenantId !== undefined) {
|
|
116
|
+
throw new AxiError("--domain-name and --tenant-id cannot be combined", "VALIDATION_ERROR", [help]);
|
|
117
|
+
}
|
|
118
|
+
if (domain !== undefined) {
|
|
119
|
+
if (!String(domain).trim())
|
|
120
|
+
throw new AxiError("--domain-name needs the tenant domain name", "VALIDATION_ERROR", [help]);
|
|
121
|
+
return { operation: domainOperation, params: { domainName: String(domain) } };
|
|
122
|
+
}
|
|
123
|
+
if (tenantId !== undefined) {
|
|
124
|
+
if (!String(tenantId).trim())
|
|
125
|
+
throw new AxiError("--tenant-id needs the tenant GUID", "VALIDATION_ERROR", [help]);
|
|
126
|
+
return {
|
|
127
|
+
operation: resolveSessionOperation("v1.0", "GET", "/tenantRelationships/findTenantInformationByTenantId(tenantId='{tenantId}')"),
|
|
128
|
+
params: { tenantId: String(tenantId) },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
throw new AxiError("Tenant-information show needs exactly one of --domain-name or --tenant-id", "VALIDATION_ERROR", [help]);
|
|
132
|
+
}
|
|
133
|
+
export async function showTenantInformation(session, flags, profile, operation, help, profileName) {
|
|
134
|
+
const target = lookupTarget(flags, operation, help);
|
|
135
|
+
const scopes = scopesFor(flags, DEFAULT_TENANT_INFORMATION_SCOPES, profile, help);
|
|
136
|
+
const full = flags.full === true;
|
|
137
|
+
const raw = await withGuidance(TENANT_INFORMATION_DENIAL_HINTS, () => session.execute({
|
|
138
|
+
profile,
|
|
139
|
+
operation: target.operation,
|
|
140
|
+
params: target.params,
|
|
141
|
+
scopes,
|
|
142
|
+
}));
|
|
143
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
144
|
+
throw new AxiError("Graph returned a malformed tenant-information body", "GRAPH_ERROR", [
|
|
145
|
+
"Tenant-information lookups carry one tenantInformation object; treat anything else as unknown, not empty",
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
const { row, truncated } = project(raw, KNOWN_TENANT_INFORMATION_FIELDS, full);
|
|
149
|
+
if (truncated)
|
|
150
|
+
return { tenantInformation: row, help: [fullHint("entra tenant-information show", flags, profileName)] };
|
|
151
|
+
return { tenantInformation: row };
|
|
152
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
// EXT-01 terms-of-use subfamily: the read mapping behind
|
|
3
|
+
// `mg-axi entra agreement list/show`,
|
|
4
|
+
// `mg-axi entra agreement acceptance list/show --agreement <agreement-id>`
|
|
5
|
+
// and `mg-axi entra agreement-acceptance list/show`. Operation construction
|
|
6
|
+
// stays beside its command; the shared session owns URLs, credentials,
|
|
7
|
+
// paging, retries and error translation, and the SDK owns TOON rendering.
|
|
8
|
+
// This module only maps flags to session calls and projects rows for compact
|
|
9
|
+
// output.
|
|
10
|
+
//
|
|
11
|
+
// Reviewed against the v1.0 termsofusecontainer-list-agreements,
|
|
12
|
+
// agreement-get and agreement-list-acceptances operation documentation and
|
|
13
|
+
// the agreement and agreementAcceptance resource references on 2026-10-05,
|
|
14
|
+
// corroborated by the Microsoft Entra terms-of-use feature documentation
|
|
15
|
+
// (same P1 prerequisite, same Security Reader read role). The legacy
|
|
16
|
+
// /agreements and /agreementAcceptances roots share the termsOfUse
|
|
17
|
+
// operation contract documented on the /identityGovernance/termsOfUse
|
|
18
|
+
// routes; the root acceptance list and get carry no dedicated operation
|
|
19
|
+
// page, so they follow the reviewed acceptance-list contract and the
|
|
20
|
+
// agreementAcceptance resource reference. Agreement reads take delegated
|
|
21
|
+
// Agreement.Read.All; acceptance reads take delegated
|
|
22
|
+
// AgreementAcceptance.Read least-privileged (AgreementAcceptance.Read.All
|
|
23
|
+
// is the documented higher-privileged alternative). Graph documents no
|
|
24
|
+
// supported application permission for any of these operations, so
|
|
25
|
+
// application profiles are refused before credentials rather than sent to
|
|
26
|
+
// a certain denial. Delegated callers additionally need Security Reader,
|
|
27
|
+
// the least-privileged supported role (Global Reader, Conditional Access
|
|
28
|
+
// Administrator and Security Administrator also work). Personal Microsoft
|
|
29
|
+
// accounts are not supported. Terms of use needs Microsoft Entra ID P1.
|
|
30
|
+
// The agreement list documents $select, $filter and $top; the agreement
|
|
31
|
+
// get documents $select only, so it offers no --filter. The nested
|
|
32
|
+
// acceptance list documents $select and $filter; the root acceptance list
|
|
33
|
+
// follows the same reviewed contract. --filter passes through as plain
|
|
34
|
+
// $filter with no $count or ConsistencyLevel contract. The acceptances
|
|
35
|
+
// $count scalar, every agreement file/localization/files/versions
|
|
36
|
+
// sub-read, beta and every mutation stay out. No mutation lives here.
|
|
37
|
+
//
|
|
38
|
+
// Secrecy by construction: agreement file and files navigation properties
|
|
39
|
+
// are never requested ($expand is not reviewed here) and file bytes are
|
|
40
|
+
// never downloaded or printed; only agreement metadata is projected.
|
|
41
|
+
// Acceptance records are personal data, so default rows stay minimal (id,
|
|
42
|
+
// agreementId, state, recordedDateTime) and the identifying fields
|
|
43
|
+
// (userDisplayName, userEmail, userPrincipalName, userId and device
|
|
44
|
+
// detail) need an explicit --select naming them.
|
|
45
|
+
// Every agreement property this slice may request or display, matching the
|
|
46
|
+
// reviewed resource. The acceptances, file and files relationships are
|
|
47
|
+
// never projected here. Anything else fails before credentials.
|
|
48
|
+
export const KNOWN_AGREEMENT_FIELDS = [
|
|
49
|
+
"displayName",
|
|
50
|
+
"id",
|
|
51
|
+
"isPerDeviceAcceptanceRequired",
|
|
52
|
+
"isViewingBeforeAcceptanceRequired",
|
|
53
|
+
"termsExpiration",
|
|
54
|
+
"userReacceptRequiredFrequency",
|
|
55
|
+
];
|
|
56
|
+
const KNOWN_AGREEMENTS = new Set(KNOWN_AGREEMENT_FIELDS);
|
|
57
|
+
// Every acceptance property this slice may request or display, matching
|
|
58
|
+
// the reviewed resource. Anything else fails before credentials.
|
|
59
|
+
export const KNOWN_ACCEPTANCE_FIELDS = [
|
|
60
|
+
"agreementFileId",
|
|
61
|
+
"agreementId",
|
|
62
|
+
"deviceDisplayName",
|
|
63
|
+
"deviceId",
|
|
64
|
+
"deviceOSType",
|
|
65
|
+
"deviceOSVersion",
|
|
66
|
+
"expirationDateTime",
|
|
67
|
+
"id",
|
|
68
|
+
"recordedDateTime",
|
|
69
|
+
"state",
|
|
70
|
+
"userDisplayName",
|
|
71
|
+
"userEmail",
|
|
72
|
+
"userId",
|
|
73
|
+
"userPrincipalName",
|
|
74
|
+
];
|
|
75
|
+
const KNOWN_ACCEPTANCES = new Set(KNOWN_ACCEPTANCE_FIELDS);
|
|
76
|
+
// Compact agreement rows: the identifier plus the internal tracking name
|
|
77
|
+
// agents need next.
|
|
78
|
+
const DEFAULT_AGREEMENT_LIST_SELECT = ["id", "displayName"];
|
|
79
|
+
// Show rows: the full reviewed agreement metadata set.
|
|
80
|
+
const DEFAULT_AGREEMENT_SHOW_SELECT = [...KNOWN_AGREEMENT_FIELDS];
|
|
81
|
+
// Acceptance rows stay minimal by default because they are personal data:
|
|
82
|
+
// the identifier, the owning agreement, the current state and when it was
|
|
83
|
+
// recorded. Identifying fields need an explicit --select.
|
|
84
|
+
const DEFAULT_ACCEPTANCE_SELECT = ["id", "agreementId", "state", "recordedDateTime"];
|
|
85
|
+
// Delegated defaults are operation-specific; application profiles are
|
|
86
|
+
// refused before credentials because Graph documents no supported
|
|
87
|
+
// application permission here.
|
|
88
|
+
export const DEFAULT_AGREEMENT_SCOPES = ["https://graph.microsoft.com/Agreement.Read.All"];
|
|
89
|
+
export const DEFAULT_ACCEPTANCE_SCOPES = ["https://graph.microsoft.com/AgreementAcceptance.Read"];
|
|
90
|
+
const TRUNCATE_AT = 500;
|
|
91
|
+
function fieldList(raw, known, knownList, flag, help, noun) {
|
|
92
|
+
const parts = String(raw)
|
|
93
|
+
.split(",")
|
|
94
|
+
.map(part => part.trim())
|
|
95
|
+
.filter(part => part.length > 0);
|
|
96
|
+
if (!parts.length)
|
|
97
|
+
throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
|
|
98
|
+
const fields = [];
|
|
99
|
+
for (const part of parts) {
|
|
100
|
+
if (!known.has(part)) {
|
|
101
|
+
throw new AxiError(`Unknown terms-of-use property ${part} in --${flag}`, "VALIDATION_ERROR", [
|
|
102
|
+
help,
|
|
103
|
+
`Known ${noun} properties: ${knownList.join(", ")}`,
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
if (!fields.includes(part))
|
|
107
|
+
fields.push(part);
|
|
108
|
+
}
|
|
109
|
+
return fields;
|
|
110
|
+
}
|
|
111
|
+
function scopesFor(flags, defaults, profile, help, noun) {
|
|
112
|
+
if (profile.mode === "application") {
|
|
113
|
+
throw new AxiError(`${noun} reads need a delegated profile; Graph documents no supported application permission for this operation`, "VALIDATION_ERROR", [help, "mg-axi profile create --name <name> --tenant <tenant-id> --client <client-id> --cloud commercial"]);
|
|
114
|
+
}
|
|
115
|
+
if (flags.scopes === undefined)
|
|
116
|
+
return [...defaults];
|
|
117
|
+
const scopes = String(flags.scopes)
|
|
118
|
+
.split(",")
|
|
119
|
+
.map(scope => scope.trim())
|
|
120
|
+
.filter(scope => scope.length > 0);
|
|
121
|
+
return scopes;
|
|
122
|
+
}
|
|
123
|
+
function truncateValue(value, full) {
|
|
124
|
+
const truncate = (text) => full || text.length <= TRUNCATE_AT
|
|
125
|
+
? { value: text, truncated: false }
|
|
126
|
+
: { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
|
|
127
|
+
if (typeof value === "string")
|
|
128
|
+
return truncate(value);
|
|
129
|
+
if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
|
|
130
|
+
const projected = value.map(truncate);
|
|
131
|
+
return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
|
|
132
|
+
}
|
|
133
|
+
return { value, truncated: false };
|
|
134
|
+
}
|
|
135
|
+
// Local projection preserves Graph's null/missing distinction: an explicit
|
|
136
|
+
// null stays null, an absent property stays absent and is never synthesized.
|
|
137
|
+
function project(row, fields, full) {
|
|
138
|
+
const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
|
|
139
|
+
const projected = {};
|
|
140
|
+
let truncated = false;
|
|
141
|
+
for (const field of fields) {
|
|
142
|
+
if (!Object.hasOwn(source, field))
|
|
143
|
+
continue;
|
|
144
|
+
const result = truncateValue(source[field], full);
|
|
145
|
+
projected[field] = result.value;
|
|
146
|
+
truncated = truncated || result.truncated;
|
|
147
|
+
}
|
|
148
|
+
return { row: projected, truncated };
|
|
149
|
+
}
|
|
150
|
+
function selectedFields(flags, known, knownList, defaults, help, noun) {
|
|
151
|
+
const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, knownList, "select", help, noun);
|
|
152
|
+
const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, knownList, "fields", help, noun);
|
|
153
|
+
const missing = fields.find(field => !select.includes(field));
|
|
154
|
+
if (missing) {
|
|
155
|
+
throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
|
|
156
|
+
}
|
|
157
|
+
return { select, fields };
|
|
158
|
+
}
|
|
159
|
+
function profileHint(profileName) {
|
|
160
|
+
return `--profile ${shellValue(profileName)}`;
|
|
161
|
+
}
|
|
162
|
+
function shellValue(value) {
|
|
163
|
+
return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
164
|
+
}
|
|
165
|
+
function fullHint(command, flags, profileName) {
|
|
166
|
+
const args = Object.entries({ ...flags, profile: profileName, full: true })
|
|
167
|
+
.map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
|
|
168
|
+
return `mg-axi ${command} ${args.join(" ")}`;
|
|
169
|
+
}
|
|
170
|
+
// Denials carry the session's generic grant/role/licence cause; each
|
|
171
|
+
// operation adds the scope, roles and licensing that actually unlock it,
|
|
172
|
+
// because a 403 alone never says which prerequisite is missing.
|
|
173
|
+
function withGuidance(hints, run) {
|
|
174
|
+
return run().catch(error => {
|
|
175
|
+
if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
|
|
176
|
+
throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const AGREEMENT_DENIAL_HINTS = [
|
|
182
|
+
"Agreement reads need delegated Agreement.Read.All; delegated callers pass it as --scopes",
|
|
183
|
+
"Delegated reads additionally need Security Reader, the least-privileged supported Entra role for this operation (Global Reader, Conditional Access Administrator or Security Administrator also work); Graph documents no supported application permission, so application profiles are refused before credentials",
|
|
184
|
+
"Personal Microsoft accounts are not supported for agreement reads",
|
|
185
|
+
"Terms of use needs Microsoft Entra ID P1; never diagnose licence solely from HTTP 403",
|
|
186
|
+
];
|
|
187
|
+
const ACCEPTANCE_DENIAL_HINTS = [
|
|
188
|
+
"Acceptance reads need delegated AgreementAcceptance.Read least-privileged (AgreementAcceptance.Read.All is the documented higher-privileged alternative); delegated callers pass one as --scopes",
|
|
189
|
+
"Delegated reads additionally need Security Reader, the least-privileged supported Entra role for this operation (Global Reader, Conditional Access Administrator or Security Administrator also work); Graph documents no supported application permission, so application profiles are refused before credentials",
|
|
190
|
+
"Personal Microsoft accounts are not supported for acceptance reads",
|
|
191
|
+
"Terms of use needs Microsoft Entra ID P1; never diagnose licence solely from HTTP 403",
|
|
192
|
+
];
|
|
193
|
+
// Restoring the saved select/filter keeps cursor resumes lossless when
|
|
194
|
+
// --select or --filter is omitted. Both lists document $filter, so
|
|
195
|
+
// --filter passes through as plain $filter with no $count or
|
|
196
|
+
// ConsistencyLevel contract; strict input validation refuses the flag on
|
|
197
|
+
// the show leaves before credentials instead of forwarding a misleading
|
|
198
|
+
// request.
|
|
199
|
+
function collectionCommon(session, flags, known, knownList, defaults, noun, defaultScopes, denialNoun, operation, help, profile) {
|
|
200
|
+
const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
|
|
201
|
+
if (cursor !== undefined && !cursor.trim())
|
|
202
|
+
throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
|
|
203
|
+
const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
|
|
204
|
+
const { select, fields } = selectedFields(flags, known, knownList, saved?.["$select"] === undefined ? defaults : fieldList(saved["$select"], known, knownList, "select", help, noun), help, noun);
|
|
205
|
+
const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
|
|
206
|
+
return { cursor, select, fields, scopes: scopesFor(flags, defaultScopes, profile, help, denialNoun), full: flags.full === true, filter };
|
|
207
|
+
}
|
|
208
|
+
function collectArgs(profile, operation, common, flags, help, params) {
|
|
209
|
+
const query = { $select: common.select.join(",") };
|
|
210
|
+
if (common.filter !== undefined)
|
|
211
|
+
query.$filter = common.filter;
|
|
212
|
+
const args = { profile, operation, query, scopes: common.scopes };
|
|
213
|
+
if (params !== undefined)
|
|
214
|
+
args.params = params;
|
|
215
|
+
if (common.cursor !== undefined)
|
|
216
|
+
args.cursor = common.cursor;
|
|
217
|
+
if (flags.all === true) {
|
|
218
|
+
if (flags.limit !== undefined)
|
|
219
|
+
throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
223
|
+
}
|
|
224
|
+
return args;
|
|
225
|
+
}
|
|
226
|
+
function resumeHint(profileName) {
|
|
227
|
+
return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
|
|
228
|
+
}
|
|
229
|
+
function agreementId(flags, help, flag = "id") {
|
|
230
|
+
const id = String(flags[flag]);
|
|
231
|
+
if (!id.trim())
|
|
232
|
+
throw new AxiError(`--${flag} needs the agreement identifier`, "VALIDATION_ERROR", [help]);
|
|
233
|
+
return id;
|
|
234
|
+
}
|
|
235
|
+
function acceptanceId(flags, help) {
|
|
236
|
+
const id = String(flags.id);
|
|
237
|
+
if (!id.trim())
|
|
238
|
+
throw new AxiError("--id needs the agreement-acceptance identifier", "VALIDATION_ERROR", [help]);
|
|
239
|
+
return id;
|
|
240
|
+
}
|
|
241
|
+
function singleResult(raw, fields, full, kind) {
|
|
242
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
243
|
+
throw new AxiError(`Graph returned a malformed ${kind} body`, "GRAPH_ERROR", [
|
|
244
|
+
`Single-${kind} reads carry one ${kind} object; treat anything else as unknown, not empty`,
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
return project(raw, fields, full);
|
|
248
|
+
}
|
|
249
|
+
export async function listAgreements(session, flags, profile, operation, help, profileName) {
|
|
250
|
+
const common = collectionCommon(session, flags, KNOWN_AGREEMENTS, KNOWN_AGREEMENT_FIELDS, DEFAULT_AGREEMENT_LIST_SELECT, "agreement", DEFAULT_AGREEMENT_SCOPES, "Agreement", operation, help, profile);
|
|
251
|
+
const result = await withGuidance(AGREEMENT_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
|
|
252
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_AGREEMENT_LIST_SELECT.join(",") };
|
|
253
|
+
if (result.query.$filter !== undefined)
|
|
254
|
+
effectiveFlags.filter = result.query.$filter;
|
|
255
|
+
const agreements = [];
|
|
256
|
+
let truncated = false;
|
|
257
|
+
for (const row of result.value) {
|
|
258
|
+
const projected = project(row, common.fields, common.full);
|
|
259
|
+
agreements.push(projected.row);
|
|
260
|
+
truncated = truncated || projected.truncated;
|
|
261
|
+
}
|
|
262
|
+
const showHint = `mg-axi entra agreement show --id <agreement-id> ${profileHint(profileName)}`;
|
|
263
|
+
const truncationHints = truncated ? [fullHint("entra agreement list", effectiveFlags, profileName)] : [];
|
|
264
|
+
if (!result.complete) {
|
|
265
|
+
return {
|
|
266
|
+
agreements,
|
|
267
|
+
count: { returned: agreements.length, complete: false, reason: result.reason },
|
|
268
|
+
cursor: result.cursor,
|
|
269
|
+
help: [...truncationHints, resumeHint(profileName), showHint],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
const count = { returned: agreements.length, complete: true };
|
|
273
|
+
if (!agreements.length) {
|
|
274
|
+
return {
|
|
275
|
+
agreements,
|
|
276
|
+
count,
|
|
277
|
+
help: ["0 agreements matched; no terms-of-use agreements are configured for the tenant, or no row passed --filter"],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return { agreements, count, help: [...truncationHints, showHint] };
|
|
281
|
+
}
|
|
282
|
+
export async function showAgreement(session, flags, profile, operation, help, profileName) {
|
|
283
|
+
const { select, fields } = selectedFields(flags, KNOWN_AGREEMENTS, KNOWN_AGREEMENT_FIELDS, DEFAULT_AGREEMENT_SHOW_SELECT, help, "agreement");
|
|
284
|
+
const scopes = scopesFor(flags, DEFAULT_AGREEMENT_SCOPES, profile, help, "Agreement");
|
|
285
|
+
const full = flags.full === true;
|
|
286
|
+
const id = agreementId(flags, help);
|
|
287
|
+
const raw = await withGuidance(AGREEMENT_DENIAL_HINTS, () => session.execute({
|
|
288
|
+
profile,
|
|
289
|
+
operation,
|
|
290
|
+
params: { "agreement-id": id },
|
|
291
|
+
query: { $select: select.join(",") },
|
|
292
|
+
scopes,
|
|
293
|
+
}));
|
|
294
|
+
const { row, truncated } = singleResult(raw, fields, full, "agreement");
|
|
295
|
+
const helpHints = [
|
|
296
|
+
...(truncated ? [fullHint("entra agreement show", flags, profileName)] : []),
|
|
297
|
+
`mg-axi entra agreement acceptance list --agreement ${shellValue(id)} ${profileHint(profileName)}`,
|
|
298
|
+
];
|
|
299
|
+
return { agreement: row, help: helpHints };
|
|
300
|
+
}
|
|
301
|
+
function acceptanceCollection(session, flags, operation, help, profile, agreement) {
|
|
302
|
+
const common = collectionCommon(session, flags, KNOWN_ACCEPTANCES, KNOWN_ACCEPTANCE_FIELDS, DEFAULT_ACCEPTANCE_SELECT, "agreement-acceptance", DEFAULT_ACCEPTANCE_SCOPES, "Acceptance", operation, help, profile);
|
|
303
|
+
if (agreement === undefined)
|
|
304
|
+
return { common, params: undefined };
|
|
305
|
+
if (!agreement.trim())
|
|
306
|
+
throw new AxiError("--agreement needs the agreement identifier", "VALIDATION_ERROR", [help]);
|
|
307
|
+
return { common, params: { "agreement-id": agreement } };
|
|
308
|
+
}
|
|
309
|
+
async function collectAcceptances(session, flags, profile, operation, help, profileName, command, showCommand, agreement) {
|
|
310
|
+
const { common, params } = acceptanceCollection(session, flags, operation, help, profile, agreement);
|
|
311
|
+
const result = await withGuidance(ACCEPTANCE_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help, params)));
|
|
312
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_ACCEPTANCE_SELECT.join(",") };
|
|
313
|
+
if (result.query.$filter !== undefined)
|
|
314
|
+
effectiveFlags.filter = result.query.$filter;
|
|
315
|
+
const acceptances = [];
|
|
316
|
+
let truncated = false;
|
|
317
|
+
for (const row of result.value) {
|
|
318
|
+
const projected = project(row, common.fields, common.full);
|
|
319
|
+
acceptances.push(projected.row);
|
|
320
|
+
truncated = truncated || projected.truncated;
|
|
321
|
+
}
|
|
322
|
+
const scope = agreement === undefined ? "" : `--agreement ${shellValue(agreement)} `;
|
|
323
|
+
const showHint = `mg-axi ${showCommand} ${scope}--id <acceptance-id> ${profileHint(profileName)}`;
|
|
324
|
+
const truncationHints = truncated ? [fullHint(command, effectiveFlags, profileName)] : [];
|
|
325
|
+
if (!result.complete) {
|
|
326
|
+
return {
|
|
327
|
+
agreementAcceptances: acceptances,
|
|
328
|
+
count: { returned: acceptances.length, complete: false, reason: result.reason },
|
|
329
|
+
cursor: result.cursor,
|
|
330
|
+
help: [...truncationHints, resumeHint(profileName), showHint],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const count = { returned: acceptances.length, complete: true };
|
|
334
|
+
if (!acceptances.length) {
|
|
335
|
+
return {
|
|
336
|
+
agreementAcceptances: acceptances,
|
|
337
|
+
count,
|
|
338
|
+
help: ["0 agreement acceptances matched; nobody recorded an acceptance in scope yet, or no row passed --filter"],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return { agreementAcceptances: acceptances, count, help: [...truncationHints, showHint] };
|
|
342
|
+
}
|
|
343
|
+
async function showAcceptanceById(session, flags, profile, operation, help, profileName, command, agreement) {
|
|
344
|
+
const { select, fields } = selectedFields(flags, KNOWN_ACCEPTANCES, KNOWN_ACCEPTANCE_FIELDS, DEFAULT_ACCEPTANCE_SELECT, help, "agreement-acceptance");
|
|
345
|
+
const scopes = scopesFor(flags, DEFAULT_ACCEPTANCE_SCOPES, profile, help, "Acceptance");
|
|
346
|
+
const full = flags.full === true;
|
|
347
|
+
const id = acceptanceId(flags, help);
|
|
348
|
+
const params = agreement === undefined
|
|
349
|
+
? { "agreementAcceptance-id": id }
|
|
350
|
+
: { "agreement-id": agreement, "agreementAcceptance-id": id };
|
|
351
|
+
const raw = await withGuidance(ACCEPTANCE_DENIAL_HINTS, () => session.execute({
|
|
352
|
+
profile,
|
|
353
|
+
operation,
|
|
354
|
+
params,
|
|
355
|
+
query: { $select: select.join(",") },
|
|
356
|
+
scopes,
|
|
357
|
+
}));
|
|
358
|
+
const { row, truncated } = singleResult(raw, fields, full, "agreement acceptance");
|
|
359
|
+
if (truncated)
|
|
360
|
+
return { agreementAcceptance: row, help: [fullHint(command, flags, profileName)] };
|
|
361
|
+
return { agreementAcceptance: row };
|
|
362
|
+
}
|
|
363
|
+
export async function listAgreementAcceptances(session, flags, profile, operation, help, profileName) {
|
|
364
|
+
const agreement = String(flags.agreement);
|
|
365
|
+
if (!agreement.trim())
|
|
366
|
+
throw new AxiError("--agreement needs the agreement identifier", "VALIDATION_ERROR", [help]);
|
|
367
|
+
return collectAcceptances(session, flags, profile, operation, help, profileName, "entra agreement acceptance list", "entra agreement acceptance show", agreement);
|
|
368
|
+
}
|
|
369
|
+
export async function showAgreementAcceptance(session, flags, profile, operation, help, profileName) {
|
|
370
|
+
const agreement = String(flags.agreement);
|
|
371
|
+
if (!agreement.trim())
|
|
372
|
+
throw new AxiError("--agreement needs the agreement identifier", "VALIDATION_ERROR", [help]);
|
|
373
|
+
return showAcceptanceById(session, flags, profile, operation, help, profileName, "entra agreement acceptance show", agreement);
|
|
374
|
+
}
|
|
375
|
+
export async function listAcceptances(session, flags, profile, operation, help, profileName) {
|
|
376
|
+
return collectAcceptances(session, flags, profile, operation, help, profileName, "entra agreement-acceptance list", "entra agreement-acceptance show", undefined);
|
|
377
|
+
}
|
|
378
|
+
export async function showAcceptance(session, flags, profile, operation, help, profileName) {
|
|
379
|
+
return showAcceptanceById(session, flags, profile, operation, help, profileName, "entra agreement-acceptance show", undefined);
|
|
380
|
+
}
|