@knowttl/mg-axi 0.1.4 → 0.1.7
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 +303 -1268
- package/axi-credential.schema.json +65 -0
- package/dist/api.js +5 -21
- package/dist/app-auth.js +9 -4
- package/dist/auth-status.js +20 -0
- package/dist/auth.js +24 -10
- package/dist/catalogue.js +26 -20
- package/dist/cli.js +57 -26
- package/dist/credentials.js +78 -0
- package/dist/docs.js +17 -13
- package/dist/doctor.js +10 -8
- package/dist/entra-audit-logs.js +5 -4
- package/dist/entra-conditional-access.js +4 -3
- package/dist/entra-directory-objects.js +4 -5
- package/dist/entra-domains.js +4 -2
- package/dist/entra-group-lifecycle.js +3 -2
- package/dist/entra-roles.js +6 -5
- package/dist/graph-session.js +30 -14
- package/dist/hook.js +9 -32
- package/dist/msal-app-provider.js +27 -101
- package/dist/msal-provider.js +67 -54
- package/dist/mutations.js +42 -53
- package/dist/paths.js +17 -0
- package/dist/private-file.js +227 -0
- package/dist/profiles.js +180 -134
- package/dist/redact.js +26 -0
- package/dist/secrets.js +37 -0
- package/dist/setup.js +2 -2
- package/docs/coverage.md +6 -3
- package/package.json +4 -6
- package/skills/mg-axi/SKILL.md +15 -11
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { encode } from "@toon-format/toon";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, relative, isAbsolute, sep } from "node:path";
|
|
4
5
|
import { AxiError, installSessionStartHooks, runAxiCli } from "axi-sdk-js";
|
|
5
6
|
import { LEAVES, home, leafHelp, operationFor, resolveCommand, DESCRIPTION, TOP_LEVEL_HELP } from "./catalogue.js";
|
|
6
7
|
import { VERSION } from "./version.js";
|
|
7
8
|
import { Profiles } from "./profiles.js";
|
|
9
|
+
import { authStatus } from "./auth-status.js";
|
|
10
|
+
import { redactSecrets } from "./redact.js";
|
|
8
11
|
import { GraphSession, MAX_CURSOR_BYTES } from "./graph-session.js";
|
|
9
12
|
import { listUsers, showUser } from "./entra-users.js";
|
|
10
13
|
import { updateUserAccount } from "./entra-user-update.js";
|
|
@@ -48,22 +51,21 @@ import { addGroupMember } from "./entra-group-member-add.js";
|
|
|
48
51
|
import { fetchTransport } from "./api.js";
|
|
49
52
|
import { doctorTargets, runDoctor } from "./doctor.js";
|
|
50
53
|
import { setupView } from "./setup.js";
|
|
51
|
-
function localHome(name) {
|
|
54
|
+
function localHome(name, config) {
|
|
52
55
|
const output = home();
|
|
53
|
-
const store = new Profiles();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
const store = new Profiles(config);
|
|
57
|
+
try {
|
|
58
|
+
const selected = store.resolve(name);
|
|
59
|
+
output.profile = selected.name;
|
|
60
|
+
output.tenant = selected.profile.tenantId;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if ((name?.trim() ? name : undefined) || !(error instanceof AxiError) || !["PROFILE_REQUIRED", "PROFILE_AMBIGUOUS"].includes(error.code))
|
|
64
|
+
throw error;
|
|
65
|
+
if (error.code === "PROFILE_AMBIGUOUS")
|
|
63
66
|
output.profile = "unavailable: no default profile selected";
|
|
64
|
-
}
|
|
65
67
|
}
|
|
66
|
-
output.help.unshift("npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi login --help");
|
|
68
|
+
output.help.unshift("npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi auth login --help");
|
|
67
69
|
return output;
|
|
68
70
|
}
|
|
69
71
|
// One dispatch for the hook installer: resolve the sibling hook entry point
|
|
@@ -73,17 +75,34 @@ function localHome(name) {
|
|
|
73
75
|
// summary from src/hook.ts. Install failures throw loudly; hook failures
|
|
74
76
|
// never do.
|
|
75
77
|
function runSetupHooks() {
|
|
76
|
-
const hookPath = join(dirname(process.argv[1] ?? ""), "mg-axi-hook.js");
|
|
78
|
+
const hookPath = join(dirname(realpathSync(process.argv[1] ?? "")), "mg-axi-hook.js");
|
|
77
79
|
if (!existsSync(hookPath)) {
|
|
78
80
|
throw new AxiError("Cannot locate the mg-axi-hook entry point", "HOOK_INSTALL_FAILED", [
|
|
79
81
|
"Reinstall @knowttl/mg-axi so dist/bin/mg-axi-hook.js sits next to the main entry point",
|
|
80
82
|
"Run mg-axi setup hooks again after reinstalling",
|
|
81
83
|
]);
|
|
82
84
|
}
|
|
85
|
+
const target = realpathSync(hookPath);
|
|
86
|
+
const temporaryRoots = [tmpdir(), "/tmp", "/var/tmp"].map(path => existsSync(path) ? realpathSync(path) : path);
|
|
87
|
+
const inTemporaryDirectory = temporaryRoots.some(root => {
|
|
88
|
+
const path = relative(root, target);
|
|
89
|
+
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
90
|
+
});
|
|
91
|
+
let inWorktree = false;
|
|
92
|
+
for (let directory = dirname(target); directory !== dirname(directory); directory = dirname(directory)) {
|
|
93
|
+
const gitPath = join(directory, ".git");
|
|
94
|
+
if (existsSync(gitPath) && statSync(gitPath).isFile())
|
|
95
|
+
inWorktree = true;
|
|
96
|
+
}
|
|
97
|
+
if (inTemporaryDirectory || inWorktree || /(?:^|[/\\])(?:\.no-mistakes|\.treehouse|worktrees)(?:[/\\]|$)/.test(target)) {
|
|
98
|
+
throw new AxiError("Cannot install hooks from a temporary directory or worktree", "HOOK_INSTALL_FAILED", [
|
|
99
|
+
"Install @knowttl/mg-axi globally, then run mg-axi setup hooks from that installation",
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
83
102
|
const failures = [];
|
|
84
103
|
installSessionStartHooks({
|
|
85
104
|
marker: "mg-axi-hook",
|
|
86
|
-
execPath:
|
|
105
|
+
execPath: target,
|
|
87
106
|
binaryNames: ["mg-axi-hook"],
|
|
88
107
|
distEntrypoints: ["dist/bin/mg-axi-hook.js"],
|
|
89
108
|
onError: (message) => { failures.push(message); },
|
|
@@ -115,23 +134,30 @@ export async function executeArgv(argv, overrides = {}) {
|
|
|
115
134
|
if (flags.help)
|
|
116
135
|
return leafHelp(leaf);
|
|
117
136
|
if (leaf.path === "home")
|
|
118
|
-
return localHome(flags.profile);
|
|
119
|
-
const profiles = new Profiles();
|
|
137
|
+
return localHome(flags.profile, flags.config);
|
|
138
|
+
const profiles = new Profiles(flags.config);
|
|
139
|
+
if (leaf.path === "config path")
|
|
140
|
+
return { config: profiles.path };
|
|
120
141
|
if (leaf.path === "profile create") {
|
|
121
|
-
const credentialFlags = ["certificate-thumbprint", "client-secret-env", "client-secret-file", "certificate-key-file", "certificate-key-env", "federated-token-file
|
|
142
|
+
const credentialFlags = ["certificate-thumbprint-sha256", "client-secret-env", "client-secret-file", "certificate-key-file", "certificate-key-env", "federated-token-file"];
|
|
122
143
|
if (String(flags.mode ?? "delegated") !== "application" && (flags.federated || credentialFlags.some(name => flags[name] !== undefined)))
|
|
123
144
|
throw new AxiError("Certificate, client-secret and federated credentials belong to application profiles; pass --mode application", "VALIDATION_ERROR", [leafHelp(leaf)]);
|
|
124
145
|
const optional = (name) => flags[name] === undefined ? undefined : String(flags[name]);
|
|
125
146
|
return profiles.create(String(flags.name), String(flags.tenant), String(flags.client), String(flags.cloud), !!flags["allow-device-code"], String(flags.mode ?? "delegated") === "application"
|
|
126
|
-
? { certificateThumbprint: optional("certificate-thumbprint"), federated: !!flags.federated, clientSecretEnv: optional("client-secret-env"), clientSecretFile: optional("client-secret-file"), certificateKeyFile: optional("certificate-key-file"), certificateKeyEnv: optional("certificate-key-env"),
|
|
147
|
+
? { certificateThumbprint: optional("certificate-thumbprint-sha256"), federated: !!flags.federated, clientSecretEnv: optional("client-secret-env"), clientSecretFile: optional("client-secret-file"), certificateKeyFile: optional("certificate-key-file"), certificateKeyEnv: optional("certificate-key-env"), federatedTokenFile: optional("federated-token-file") }
|
|
127
148
|
: undefined);
|
|
128
149
|
}
|
|
129
150
|
if (leaf.path === "profile list") {
|
|
130
151
|
const items = profiles.list();
|
|
131
|
-
return items.length ? { profiles: items, help: ["npx -y @knowttl/mg-axi profile show --profile <name>", "npx -y @knowttl/mg-axi login --help"] } : { profiles: "0 profiles configured", help: ["npx -y @knowttl/mg-axi profile create --help"] };
|
|
152
|
+
return items.length ? { profiles: items, help: ["npx -y @knowttl/mg-axi profile show --profile <name>", "npx -y @knowttl/mg-axi auth login --help"] } : { profiles: "0 profiles configured", help: ["npx -y @knowttl/mg-axi profile create --help"] };
|
|
132
153
|
}
|
|
133
154
|
if (leaf.path === "profile show")
|
|
134
155
|
return profiles.resolve(flags.profile);
|
|
156
|
+
if (leaf.path === "auth status") {
|
|
157
|
+
const selected = profiles.resolve(flags.profile);
|
|
158
|
+
profiles.verifyPermissions();
|
|
159
|
+
return authStatus(selected.name, selected.profile);
|
|
160
|
+
}
|
|
135
161
|
if (leaf.path === "setup")
|
|
136
162
|
return setupView(profiles);
|
|
137
163
|
if (leaf.path === "setup hooks")
|
|
@@ -153,18 +179,20 @@ export async function executeArgv(argv, overrides = {}) {
|
|
|
153
179
|
const session = new GraphSession({ delegated, application, transport: overrides.transport ?? fetchTransport });
|
|
154
180
|
// A failing profile still reports its bounded read on stdout; the
|
|
155
181
|
// nonzero exit travels through process state, never the payload.
|
|
156
|
-
const
|
|
182
|
+
const selected = names.length === 1 ? profiles.resolve(flags.profile) : undefined;
|
|
183
|
+
const result = await runDoctor({ store: profiles, names, session, selected });
|
|
157
184
|
if (result.failed)
|
|
158
185
|
process.exitCode = 1;
|
|
159
186
|
return result.output;
|
|
160
187
|
}
|
|
161
|
-
if (leaf.path === "login") {
|
|
188
|
+
if (leaf.path === "login" || leaf.path === "auth login" || leaf.path === "auth logout") {
|
|
162
189
|
const selected = profiles.resolve(flags.profile);
|
|
163
190
|
if (selected.profile.mode !== "delegated")
|
|
164
191
|
throw new AxiError("Application profiles authenticate with client credentials; interactive login is unavailable", "VALIDATION_ERROR", ["mg-axi profile show --profile <name>", "Application tokens are acquired silently with the configured Graph .default audience"]);
|
|
165
192
|
const { DelegatedAuth } = await import("./auth.js");
|
|
166
193
|
const { MsalProvider } = await import("./msal-provider.js");
|
|
167
|
-
|
|
194
|
+
const service = overrides.delegated ?? new DelegatedAuth(new MsalProvider());
|
|
195
|
+
return { profile: selected.name, ...await (leaf.path === "auth logout" ? service.logout(selected.profile) : service.login(selected.profile, String(flags.method ?? "browser"), String(flags.scopes).split(","))) };
|
|
168
196
|
}
|
|
169
197
|
if (leaf.path === "entra conditional-access policy list" || leaf.path === "entra conditional-access policy show" || leaf.path === "entra conditional-access named-location list" || leaf.path === "entra conditional-access named-location show" || leaf.path === "entra conditional-access auth-strength-policy list" || leaf.path === "entra conditional-access auth-strength-policy show" || leaf.path === "entra conditional-access combination-configuration list" || leaf.path === "entra conditional-access combination-configuration show" || leaf.path === "entra conditional-access auth-method-mode list" || leaf.path === "entra conditional-access auth-method-mode show" || leaf.path === "entra conditional-access template list" || leaf.path === "entra conditional-access template show" || leaf.path === "entra conditional-access auth-context list" || leaf.path === "entra conditional-access auth-context show" || leaf.path === "entra conditional-access deleted-policy list" || leaf.path === "entra conditional-access deleted-policy show" || leaf.path === "entra conditional-access deleted-named-location list" || leaf.path === "entra conditional-access deleted-named-location show") {
|
|
170
198
|
const selected = profiles.resolve(flags.profile);
|
|
@@ -1432,12 +1460,15 @@ export async function executeArgv(argv, overrides = {}) {
|
|
|
1432
1460
|
throw new AxiError(`Command is not executable: ${operation?.disposition ?? "unavailable"} (${operation?.owningSlice ?? "no inventory mapping"})`, "NOT_IMPLEMENTED", [leafHelp(leaf)]);
|
|
1433
1461
|
}
|
|
1434
1462
|
export async function main() {
|
|
1463
|
+
const stderrWrite = process.stderr.write.bind(process.stderr);
|
|
1464
|
+
process.stderr.write = ((chunk, ...args) => stderrWrite(redactSecrets(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")), ...args));
|
|
1435
1465
|
await runAxiCli({
|
|
1436
1466
|
description: DESCRIPTION,
|
|
1437
1467
|
version: VERSION,
|
|
1438
1468
|
topLevelHelp: TOP_LEVEL_HELP,
|
|
1439
1469
|
// Strict resolution owns leaf help, so SDK help cannot bypass validation.
|
|
1440
1470
|
argv: ["dispatch"],
|
|
1471
|
+
stdout: { write: (value) => process.stdout.write(redactSecrets(value)) },
|
|
1441
1472
|
home: () => localHome(),
|
|
1442
1473
|
commands: {
|
|
1443
1474
|
dispatch: async () => {
|
|
@@ -1447,7 +1478,7 @@ export async function main() {
|
|
|
1447
1478
|
},
|
|
1448
1479
|
},
|
|
1449
1480
|
formatError: error => ({
|
|
1450
|
-
output: `${encode(error instanceof AxiError ? { error: error.message, code: error.code, help: error.suggestions } : { error: "Unable to run mg-axi", help: ["mg-axi --help"] })}\n`,
|
|
1481
|
+
output: `${redactSecrets(encode(error instanceof AxiError ? { error: error.message, code: error.code, help: error.suggestions } : { error: "Unable to run mg-axi", help: ["mg-axi --help"] }))}\n`,
|
|
1451
1482
|
exitCode: error instanceof AxiError && error.code === "VALIDATION_ERROR" ? 2 : 1,
|
|
1452
1483
|
}),
|
|
1453
1484
|
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { AxiError } from "axi-sdk-js";
|
|
3
|
+
import { statePath } from "./paths.js";
|
|
4
|
+
import { PrivateFiles } from "./private-file.js";
|
|
5
|
+
import { profileName } from "./profiles.js";
|
|
6
|
+
import { registerCache } from "./redact.js";
|
|
7
|
+
const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
// The only persistent credential backend. Access tokens never leave memory.
|
|
9
|
+
export class Credentials {
|
|
10
|
+
path;
|
|
11
|
+
files;
|
|
12
|
+
constructor(path = statePath("credentials.json"), files = new PrivateFiles()) {
|
|
13
|
+
this.path = path;
|
|
14
|
+
this.files = files;
|
|
15
|
+
}
|
|
16
|
+
load(verifyAcl = false) {
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = this.files.read(this.path, true, verifyAcl);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === "ENOENT")
|
|
23
|
+
return { version: 1, profiles: Object.create(null) };
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const value = JSON.parse(text);
|
|
28
|
+
if (!object(value) || value.version !== 1 || !object(value.profiles) || Object.keys(value).some(key => !["version", "profiles"].includes(key)))
|
|
29
|
+
throw new Error("Invalid store");
|
|
30
|
+
for (const [name, entry] of Object.entries(value.profiles)) {
|
|
31
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) || !object(entry) || typeof entry.tenantId !== "string" || typeof entry.clientId !== "string" || typeof entry.cache !== "string" ||
|
|
32
|
+
Object.keys(entry).some(key => !["tenantId", "clientId", "cache"].includes(key)) || !object(JSON.parse(entry.cache)))
|
|
33
|
+
throw new Error("Invalid cache");
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof AxiError)
|
|
39
|
+
throw error;
|
|
40
|
+
throw new AxiError(`Invalid credential store ${this.path}`, "CONFIG_INVALID", ["Restore a valid protected credentials.json before signing in again"]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
name(profile) {
|
|
44
|
+
const name = profileName(profile);
|
|
45
|
+
if (!name)
|
|
46
|
+
throw new AxiError("Stored credentials require a named profile", "CONFIG_INVALID");
|
|
47
|
+
return name;
|
|
48
|
+
}
|
|
49
|
+
read(profile, verifyAcl = false) {
|
|
50
|
+
const records = this.load(verifyAcl).profiles;
|
|
51
|
+
const name = this.name(profile);
|
|
52
|
+
const entry = Object.hasOwn(records, name) ? records[name] : undefined;
|
|
53
|
+
if (!entry)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (entry.tenantId.toLowerCase() !== profile.tenantId.toLowerCase() || entry.clientId.toLowerCase() !== profile.clientId.toLowerCase())
|
|
56
|
+
throw new AxiError("Stored credential binding does not match the selected tenant and client", "AUTH_REQUIRED", ["mg-axi auth login --profile <name> --scopes <comma-separated-Graph-scopes>"]);
|
|
57
|
+
return registerCache(entry.cache);
|
|
58
|
+
}
|
|
59
|
+
write(profile, serialized) {
|
|
60
|
+
registerCache(serialized);
|
|
61
|
+
const cache = JSON.parse(serialized);
|
|
62
|
+
delete cache.AccessToken;
|
|
63
|
+
const store = this.load();
|
|
64
|
+
Object.defineProperty(store.profiles, this.name(profile), { value: { tenantId: profile.tenantId, clientId: profile.clientId, cache: JSON.stringify(cache) }, enumerable: true, configurable: true, writable: true });
|
|
65
|
+
this.files.write(this.path, `${JSON.stringify(store, null, 2)}\n`);
|
|
66
|
+
}
|
|
67
|
+
remove(profile) {
|
|
68
|
+
const store = this.load();
|
|
69
|
+
const name = this.name(profile);
|
|
70
|
+
if (!Object.hasOwn(store.profiles, name))
|
|
71
|
+
return;
|
|
72
|
+
delete store.profiles[name];
|
|
73
|
+
if (Object.keys(store.profiles).length)
|
|
74
|
+
this.files.write(this.path, `${JSON.stringify(store, null, 2)}\n`);
|
|
75
|
+
else
|
|
76
|
+
rmSync(this.path);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/docs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { DESCRIPTION, HOME_HELP, LEAVES } from "./catalogue.js";
|
|
3
|
+
const UNSUPPORTED_READ_NOTE = "Graph v1.0 does not support the legacy `entra scoped-role-membership list/show`, `entra domain-dns-record list` or `entra directory-object list` routes; those commands fail locally with supported alternatives and their root routes are not available through `api get`.";
|
|
3
4
|
// PACK-01: single source for generated release records. The committed
|
|
4
5
|
// skills/mg-axi/SKILL.md and docs/coverage.md are generated from the templates
|
|
5
6
|
// below, the executable catalogue and the discovery inventory, so help, skill
|
|
@@ -138,7 +139,7 @@ export function skillDescription() {
|
|
|
138
139
|
// profile/login hints localHome() in cli.ts prepends to HOME_HELP; the
|
|
139
140
|
// rest is HOME_HELP verbatim, so skill and home view cannot drift apart.
|
|
140
141
|
export function skillHomeHints() {
|
|
141
|
-
return ["npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi login --help", ...HOME_HELP];
|
|
142
|
+
return ["npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi auth login --help", ...HOME_HELP];
|
|
142
143
|
}
|
|
143
144
|
// The full installable skill. Durable guidance only: read-only by default,
|
|
144
145
|
// writes need preview plus typed confirmation, no secrets in output, the
|
|
@@ -165,15 +166,16 @@ export function skillDocument() {
|
|
|
165
166
|
"Run commands non-interactively as `npx -y @knowttl/mg-axi ...`: no global install needed and no interactive prompts.",
|
|
166
167
|
"Version pinning is the installer's choice: use `npx -y @knowttl/mg-axi@<version> ...` to select a specific release.",
|
|
167
168
|
"Run `npx -y @knowttl/mg-axi doctor` first.",
|
|
168
|
-
"See [README.md](../../README.md#
|
|
169
|
+
"See [README.md](../../README.md#usage) for doctor profile selection, checks and failure behavior.",
|
|
169
170
|
"",
|
|
170
171
|
"## Orientation",
|
|
171
172
|
"",
|
|
172
173
|
"The exact current leaf registry is `src/catalogue.ts`.",
|
|
173
174
|
"Its capability label is `native` (implemented by an mg-axi handler).",
|
|
174
|
-
"Its effect is `read` for Graph read leaves and the doctor health check, `write` for the gated named writes, or `local` for home, profile, login and setup views.",
|
|
175
|
+
"Its effect is `read` for Graph read leaves and the doctor health check, `write` for the gated named writes, or `local` for home, profile, auth, config path, login-alias and setup views.",
|
|
175
176
|
"Doctor acquires credentials silently and contacts Graph; only its help view stays offline.",
|
|
176
177
|
"The list below records current executable leaves; it makes no coverage claim for other Graph operations.",
|
|
178
|
+
UNSUPPORTED_READ_NOTE,
|
|
177
179
|
"See `docs/coverage.md` for the per-operation disposition records.",
|
|
178
180
|
"`docs/coverage.md` and this file are generated from the same catalogue and inventory sources; `node tools/generate-docs.mjs --check` fails when they are stale.",
|
|
179
181
|
"",
|
|
@@ -194,18 +196,18 @@ export function skillDocument() {
|
|
|
194
196
|
"",
|
|
195
197
|
"## Setup (explicit only)",
|
|
196
198
|
"",
|
|
197
|
-
"Follow [
|
|
199
|
+
"Follow [installation guidance](../../README.md#quick-start) for npm installation and explicit skill installation; [README.md](../../README.md#development--testing) also documents checkout builds.",
|
|
198
200
|
"Create profiles explicitly and keep secrets out of argv and config files:",
|
|
199
201
|
"",
|
|
200
202
|
"```sh",
|
|
201
203
|
"npx -y @knowttl/mg-axi setup # build steps, config path and capabilities; writes nothing",
|
|
202
204
|
"npx -y @knowttl/mg-axi profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial",
|
|
203
|
-
"npx -y @knowttl/mg-axi login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
|
|
205
|
+
"npx -y @knowttl/mg-axi auth login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
|
|
204
206
|
"npx -y @knowttl/mg-axi doctor # one bounded read per selected profile",
|
|
205
207
|
"npx -y @knowttl/mg-axi entra user list --profile soc --limit 10",
|
|
206
208
|
"```",
|
|
207
209
|
"",
|
|
208
|
-
"Configuration
|
|
210
|
+
"Configuration uses the XDG config directory (default `~/.config/mg-axi/config.json`), or `%APPDATA%\\mg-axi\\config.json` on Windows; `--config` overrides `MG_AXI_CONFIG`.",
|
|
209
211
|
"`mg-axi setup` shows the selected path and writes nothing.",
|
|
210
212
|
"Application profiles authenticate headlessly through credential references: the profile names an env var or file and the environment supplies the value at acquisition time.",
|
|
211
213
|
"Container examples for client secrets, certificate keys and workload federation live in [README.md](../../README.md#mg-axi).",
|
|
@@ -227,14 +229,13 @@ export function skillDocument() {
|
|
|
227
229
|
"## Selecting a profile",
|
|
228
230
|
"",
|
|
229
231
|
"Read leaves accept `--profile <name>`.",
|
|
230
|
-
"
|
|
231
|
-
"
|
|
232
|
-
"Doctor selection is documented in [README.md](../../README.md#release).",
|
|
232
|
+
"Profile selection order and env-profile rules live in [README Configuration](../../README.md#configuration); an explicitly selected file profile ignores env fields.",
|
|
233
|
+
"Doctor selection is documented in [README.md](../../README.md#usage).",
|
|
233
234
|
"Without profiles, home, setup and profile list show unconfigured state; Graph read leaves report `AUTH_REQUIRED`.",
|
|
234
235
|
"",
|
|
235
236
|
"## Safety",
|
|
236
237
|
"",
|
|
237
|
-
"Reads run through a session that authorizes only catalogued operations before silent credential acquisition; only explicit `login` opens a browser or prints a device-code challenge.",
|
|
238
|
+
"Reads run through a session that authorizes only catalogued operations before silent credential acquisition; only explicit `auth login` opens a browser or prints a device-code challenge.",
|
|
238
239
|
"Writes preview before sending and need `--execute` plus a typed `--confirm` repeating the target; an already-desired value is a no-op with exit 0, never an error.",
|
|
239
240
|
"Output is TOON on stdout; diagnostics use stderr.",
|
|
240
241
|
"Text truncates at 500 characters (`--full` restores text, never redaction); row caps resume through opaque cursors.",
|
|
@@ -407,7 +408,7 @@ export function capabilityDocument() {
|
|
|
407
408
|
"",
|
|
408
409
|
`- implemented read leaves: ${readCount} (${readLeaves.length} named Entra reads plus reviewed raw api get and doctor health check)`,
|
|
409
410
|
`- implemented write leaves: ${writeLeaves.length} (named gated mutations below)`,
|
|
410
|
-
`- local leaves: ${localCount} (home, profile, login and setup views)`,
|
|
411
|
+
`- local leaves: ${localCount} (home, profile, config, auth, login alias and setup views)`,
|
|
411
412
|
...DISPOSITIONS.map(disposition => `- inventory ${disposition}: ${count(disposition)}`),
|
|
412
413
|
"",
|
|
413
414
|
...auditRecord(rows),
|
|
@@ -420,9 +421,12 @@ export function capabilityDocument() {
|
|
|
420
421
|
"| `mg-axi api get` | reviewed raw reads (see src/api.ts) | reviewed-raw-read catalogue | API-01 |",
|
|
421
422
|
"| `mg-axi doctor` | bounded `GET:/users` health check | uses the named user-list read | PACK-01 |",
|
|
422
423
|
"",
|
|
424
|
+
UNSUPPORTED_READ_NOTE,
|
|
425
|
+
"",
|
|
423
426
|
"## EXT-01 domain scope decisions",
|
|
424
427
|
"",
|
|
425
428
|
"Firstmate decision R1: approve narrowing this change to the eight v1.0 domain reads above.",
|
|
429
|
+
"The root domain-dns-record list is now explicitly unsupported; use verification-dns-record or service-configuration-record list with a parent domain.",
|
|
426
430
|
"The v1.0 operations below carry the deferred disposition (out of v1) and the beta operations remain scheduled (never agreed for v1); no new commands or raw access are approved.",
|
|
427
431
|
"Federation stays out because it can carry signing-certificate material.",
|
|
428
432
|
"Firstmate decision R2: keep the write-family status section as the coverage fix added to scope (resolved-kept).",
|
|
@@ -561,10 +565,10 @@ export function capabilityDocument() {
|
|
|
561
565
|
"",
|
|
562
566
|
"## EXT-01 directory-objects scope decisions",
|
|
563
567
|
"",
|
|
564
|
-
"
|
|
568
|
+
"Directory-object show and count are available. Graph v1.0 rejects directory-object list; the legacy command fails locally and points to show or a typed list command.",
|
|
565
569
|
"The v1.0 operations below carry the deferred disposition (out of v1) and the beta operations remain scheduled (never agreed for v1); no new commands or raw access are approved.",
|
|
566
570
|
"Directory-object reads support v1.0 only; beta needs its own review.",
|
|
567
|
-
"Delta-token sync needs its own change-tracking contract beyond the approved
|
|
571
|
+
"Delta-token sync needs its own change-tracking contract beyond the approved show/count reads; the POST lookup and validation actions need their own request and projection review.",
|
|
568
572
|
"Rows are polymorphic: only the base-type properties plus the @odata.type discriminator are projected, so subtype secrets or credentials can never appear.",
|
|
569
573
|
"",
|
|
570
574
|
"| Inventory operation | Disposition | Owning slice | Deferral reason |",
|
package/dist/doctor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AxiError } from "axi-sdk-js";
|
|
2
2
|
import { LEAVES, operationFor } from "./catalogue.js";
|
|
3
3
|
import { listUsers } from "./entra-users.js";
|
|
4
|
+
import { authStatus } from "./auth-status.js";
|
|
4
5
|
// PACK-01: explicit release health check over the reviewed read-only session.
|
|
5
6
|
// Doctor performs one documented bounded read per profile (entra user list
|
|
6
7
|
// with a one-row window) and reports configuration, connectivity,
|
|
@@ -8,7 +9,7 @@ import { listUsers } from "./entra-users.js";
|
|
|
8
9
|
// DelegatedAuth.credential and ApplicationAuth.credential never open a
|
|
9
10
|
// browser or device-code flow, so doctor never signs in interactively.
|
|
10
11
|
// Doctor never auto-installs, changes profiles or enables Graph writes;
|
|
11
|
-
// silent token refresh
|
|
12
|
+
// silent token refresh stays in memory.
|
|
12
13
|
// See README.md for usage.
|
|
13
14
|
export const DOCTOR_OPERATION = "GET:/users";
|
|
14
15
|
export const DOCTOR_VERSION = "v1.0";
|
|
@@ -16,19 +17,18 @@ export const DOCTOR_CHECK = `entra user list --limit 1 (${DOCTOR_VERSION}:${DOCT
|
|
|
16
17
|
export function doctorTargets(store, flag) {
|
|
17
18
|
if (flag !== undefined)
|
|
18
19
|
return [store.resolve(flag).name];
|
|
19
|
-
const items = store.list();
|
|
20
|
-
if (!items.length)
|
|
21
|
-
return [];
|
|
22
20
|
try {
|
|
23
21
|
return [store.resolve(undefined).name];
|
|
24
22
|
}
|
|
25
|
-
catch {
|
|
26
|
-
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (!(error instanceof AxiError) || !["PROFILE_REQUIRED", "PROFILE_AMBIGUOUS"].includes(error.code))
|
|
25
|
+
throw error;
|
|
26
|
+
return store.list().map(item => item.name);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
export async function runDoctor(args) {
|
|
30
30
|
if (!args.names.length) {
|
|
31
|
-
throw new AxiError("No profiles are configured", "
|
|
31
|
+
throw new AxiError("No profiles are configured", "PROFILE_REQUIRED", ["npx -y @knowttl/mg-axi profile create --help", "npx -y @knowttl/mg-axi setup"]);
|
|
32
32
|
}
|
|
33
33
|
const leaf = LEAVES.find(item => item.path === "entra user list");
|
|
34
34
|
const operation = leaf === undefined ? undefined : operationFor(leaf, DOCTOR_VERSION);
|
|
@@ -39,7 +39,9 @@ export async function runDoctor(args) {
|
|
|
39
39
|
for (const name of args.names) {
|
|
40
40
|
const rerun = `npx -y @knowttl/mg-axi doctor --profile ${name}`;
|
|
41
41
|
try {
|
|
42
|
-
|
|
42
|
+
args.store.verifyPermissions();
|
|
43
|
+
const selected = args.selected ?? args.store.resolve(name);
|
|
44
|
+
authStatus(name, selected.profile);
|
|
43
45
|
// The same bounded read the named leaf serves: one fetched page, one
|
|
44
46
|
// surfaced row, remainder buffered in a discarded cursor. Doctor keeps
|
|
45
47
|
// no cursor and accepts none; recovery reruns doctor, not the read.
|
package/dist/entra-audit-logs.js
CHANGED
|
@@ -72,7 +72,7 @@ export const KNOWN_PROVISIONING_FIELDS = [
|
|
|
72
72
|
"jobId",
|
|
73
73
|
"cycleId",
|
|
74
74
|
"changeId",
|
|
75
|
-
"
|
|
75
|
+
"provisioningAction",
|
|
76
76
|
"durationInMilliseconds",
|
|
77
77
|
"sourceSystem",
|
|
78
78
|
"sourceIdentity",
|
|
@@ -88,7 +88,7 @@ const PROVISIONING_KNOWN = new Set(KNOWN_PROVISIONING_FIELDS);
|
|
|
88
88
|
// Compact list rows: identifier, time, who/what happened, and outcome.
|
|
89
89
|
const DEFAULT_SIGNIN_LIST_SELECT = ["id", "createdDateTime", "userPrincipalName", "appDisplayName"];
|
|
90
90
|
const DEFAULT_AUDIT_LIST_SELECT = ["id", "activityDateTime", "activityDisplayName", "result"];
|
|
91
|
-
const DEFAULT_PROVISIONING_LIST_SELECT = ["id", "activityDateTime", "
|
|
91
|
+
const DEFAULT_PROVISIONING_LIST_SELECT = ["id", "activityDateTime", "provisioningAction", "provisioningStatusInfo"];
|
|
92
92
|
// Show rows: the full reviewed server set for one object.
|
|
93
93
|
const DEFAULT_SIGNIN_SHOW_SELECT = [...KNOWN_SIGNIN_FIELDS];
|
|
94
94
|
const DEFAULT_AUDIT_SHOW_SELECT = [...KNOWN_AUDIT_FIELDS];
|
|
@@ -344,7 +344,8 @@ async function listLogs(shape, session, flags, profile, operation, help, profile
|
|
|
344
344
|
const scopes = scopesFor(flags, profile, help, shape.defaultScopes);
|
|
345
345
|
const full = flags.full === true;
|
|
346
346
|
const filter = boundedFilter(shape.dateField, flags, help);
|
|
347
|
-
|
|
347
|
+
// Provisioning lists reject $select; selection remains a local projection.
|
|
348
|
+
const query = shape.noun === "provisioning" ? {} : { $select: select.join(",") };
|
|
348
349
|
const explicitFilter = filter !== undefined;
|
|
349
350
|
if (explicitFilter)
|
|
350
351
|
query.$filter = filter;
|
|
@@ -362,7 +363,7 @@ async function listLogs(shape, session, flags, profile, operation, help, profile
|
|
|
362
363
|
// The hint replays the caller's own time flags (or the cursor alone, which
|
|
363
364
|
// restores the saved query); copying back the composed $filter would wrap
|
|
364
365
|
// it a second time and conflict with the cursor context.
|
|
365
|
-
const effectiveFlags = { ...flags, select: result.query.$select ??
|
|
366
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? select.join(",") };
|
|
366
367
|
const rows = [];
|
|
367
368
|
let truncated = false;
|
|
368
369
|
for (const row of result.value) {
|
|
@@ -376,7 +376,8 @@ async function listCollection(shape, session, flags, profile, operation, help, p
|
|
|
376
376
|
const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, shape.noun, help), shape.known, shape.knownList, shape.noun, help);
|
|
377
377
|
const scopes = scopesFor(flags, profile, help, shape.defaultScopes);
|
|
378
378
|
const full = flags.full === true;
|
|
379
|
-
|
|
379
|
+
// Authentication contexts reject $select; selection remains a local projection.
|
|
380
|
+
const query = shape.noun === "auth-context" ? {} : { $select: select.join(",") };
|
|
380
381
|
if (flags.filter !== undefined)
|
|
381
382
|
query.$filter = String(flags.filter);
|
|
382
383
|
const args = { profile, operation, query, scopes };
|
|
@@ -397,7 +398,7 @@ async function listCollection(shape, session, flags, profile, operation, help, p
|
|
|
397
398
|
args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
|
|
398
399
|
}
|
|
399
400
|
const result = await withGuidance(shape.denialHints, () => session.collect(args));
|
|
400
|
-
const effectiveFlags = { ...flags, select: result.query.$select ??
|
|
401
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? select.join(",") };
|
|
401
402
|
if (result.query.$filter !== undefined)
|
|
402
403
|
effectiveFlags.filter = result.query.$filter;
|
|
403
404
|
const rows = [];
|
|
@@ -474,7 +475,7 @@ async function showSingle(shape, session, flags, profile, operation, help, profi
|
|
|
474
475
|
profile,
|
|
475
476
|
operation,
|
|
476
477
|
params,
|
|
477
|
-
query: { $select: select.join(",") },
|
|
478
|
+
query: shape.noun === "auth-strength-policy" ? {} : { $select: select.join(",") },
|
|
478
479
|
scopes,
|
|
479
480
|
}));
|
|
480
481
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AxiError } from "axi-sdk-js";
|
|
2
2
|
import { listTotals } from "./list-totals.js";
|
|
3
3
|
// EXT-01 directory-objects subfamily: the read mapping behind
|
|
4
|
-
// `mg-axi entra directory-object
|
|
4
|
+
// `mg-axi entra directory-object show/count`. Operation construction
|
|
5
5
|
// stays beside its command; the shared session owns URLs, credentials,
|
|
6
6
|
// paging, retries and error translation, and the SDK owns TOON rendering.
|
|
7
7
|
// This module only maps flags to session calls and projects rows for compact
|
|
@@ -10,10 +10,9 @@ import { listTotals } from "./list-totals.js";
|
|
|
10
10
|
// Reviewed against the v1.0 directoryobject-get operation documentation and
|
|
11
11
|
// the directoryObject resource reference on 2026-10-05. There is no dedicated
|
|
12
12
|
// List operation page: the resource Methods table documents Get, delta,
|
|
13
|
-
// Delete and the lookup/validation functions only
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// support is unreviewed. Both reads take delegated or application
|
|
13
|
+
// Delete and the lookup/validation functions only. Graph rejects the root
|
|
14
|
+
// collection GET, so the session refuses the legacy list command with
|
|
15
|
+
// supported alternatives. Detail and count reads take delegated or application
|
|
17
16
|
// Directory.Read.All, the documented least privilege in each mode; personal
|
|
18
17
|
// Microsoft accounts are not supported. No delegated role and no P1/P2
|
|
19
18
|
// prerequisite are stated for these reads. The $count scalar carries no
|
package/dist/entra-domains.js
CHANGED
|
@@ -2,7 +2,7 @@ import { AxiError } from "axi-sdk-js";
|
|
|
2
2
|
import { listTotals } from "./list-totals.js";
|
|
3
3
|
// EXT-01 domains subfamily: the read mapping behind `mg-axi entra domain
|
|
4
4
|
// list/show`, the per-domain verification and service-configuration DNS
|
|
5
|
-
// record reads, and the top-level domain DNS record
|
|
5
|
+
// record reads, and the top-level domain DNS record show. Operation
|
|
6
6
|
// construction stays beside its command; the shared session owns URLs,
|
|
7
7
|
// credentials, paging, retries and error translation, and the SDK owns TOON
|
|
8
8
|
// rendering. This module only maps flags to session calls and projects rows
|
|
@@ -17,7 +17,7 @@ import { listTotals } from "./list-totals.js";
|
|
|
17
17
|
// domain list/get support those plus Directory Readers and other directory
|
|
18
18
|
// roles). Personal Microsoft accounts are not supported. No P1/P2
|
|
19
19
|
// prerequisite is stated for these reads. The single-record GETs and the
|
|
20
|
-
// top-level /domainDnsRecords
|
|
20
|
+
// top-level /domainDnsRecords/{id} GET carries no operation-level documentation
|
|
21
21
|
// page; they read the same domainDnsRecord resource through the same
|
|
22
22
|
// permission contract, so the review cites the parent list documentation
|
|
23
23
|
// alongside the domainDnsRecord resource reference. Domain ids are fully
|
|
@@ -28,6 +28,8 @@ import { listTotals } from "./list-totals.js";
|
|
|
28
28
|
// no $count or ConsistencyLevel contract. No domain mutation exists in this
|
|
29
29
|
// slice: verify, forceDelete, promote, federation and shared-email surfaces
|
|
30
30
|
// belong to later pieces.
|
|
31
|
+
// Graph rejects the root /domainDnsRecords collection. The session refuses
|
|
32
|
+
// its legacy list command and points to the parent-domain DNS collections.
|
|
31
33
|
// Every domain property this slice may request or display, matching the
|
|
32
34
|
// reviewed raw surface. Anything else fails before credentials.
|
|
33
35
|
export const KNOWN_DOMAIN_FIELDS = [
|
|
@@ -186,7 +186,8 @@ function collectionCommon(session, flags, known, knownList, defaults, defaultSco
|
|
|
186
186
|
return { cursor, select, fields, scopes: scopesFor(flags, defaultScopes, profile, help), full: flags.full === true, filter };
|
|
187
187
|
}
|
|
188
188
|
function collectArgs(profile, operation, common, flags, help) {
|
|
189
|
-
|
|
189
|
+
// Lifecycle-policy lists reject $select; keep the selected output fields local.
|
|
190
|
+
const query = operation.path === "/groupLifecyclePolicies" ? {} : { $select: common.select.join(",") };
|
|
190
191
|
if (common.filter !== undefined)
|
|
191
192
|
query.$filter = common.filter;
|
|
192
193
|
const args = { profile, operation, query, scopes: common.scopes };
|
|
@@ -227,7 +228,7 @@ function singleResult(raw, fields, full, kind) {
|
|
|
227
228
|
export async function listLifecyclePolicies(session, flags, profile, operation, help, profileName) {
|
|
228
229
|
const common = collectionCommon(session, flags, KNOWN_LIFECYCLE, KNOWN_LIFECYCLE_FIELDS, DEFAULT_LIFECYCLE_LIST_SELECT, DEFAULT_LIFECYCLE_SCOPES, operation, help, profile, true);
|
|
229
230
|
const result = await withGuidance(LIFECYCLE_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
|
|
230
|
-
const effectiveFlags = { ...flags, select: result.query.$select ??
|
|
231
|
+
const effectiveFlags = { ...flags, select: result.query.$select ?? common.select.join(",") };
|
|
231
232
|
if (result.query.$filter !== undefined)
|
|
232
233
|
effectiveFlags.filter = result.query.$filter;
|
|
233
234
|
const policies = [];
|
package/dist/entra-roles.js
CHANGED
|
@@ -3,8 +3,8 @@ import { listTotals } from "./list-totals.js";
|
|
|
3
3
|
// READ-09: the Entra directory-role and PIM read mapping behind
|
|
4
4
|
// `mg-axi entra directory-role list/show`, `mg-axi entra directory-role-template
|
|
5
5
|
// list/show`, `mg-axi entra directory-role member list`, `mg-axi entra
|
|
6
|
-
// directory-role scoped-member list/show`, `mg-axi entra
|
|
7
|
-
// list
|
|
6
|
+
// directory-role scoped-member list/show`, `mg-axi entra role-assignment
|
|
7
|
+
// list`, `mg-axi entra pim eligible
|
|
8
8
|
// list` and `mg-axi entra pim active list`.
|
|
9
9
|
// Operation construction stays beside its command; the shared session owns
|
|
10
10
|
// URLs, credentials, paging, retries and error translation, and the SDK owns
|
|
@@ -44,6 +44,8 @@ import { listTotals } from "./list-totals.js";
|
|
|
44
44
|
// every other PIM mutation belongs to later write slices, never to these
|
|
45
45
|
// reads. Built-in roles are base inventory; custom role assignments need P1;
|
|
46
46
|
// PIM reads need P2 or ID Governance.
|
|
47
|
+
// Direct /scopedRoleMemberships queries are unsupported by Graph v1.0;
|
|
48
|
+
// the session refuses the legacy commands with parent-role alternatives.
|
|
47
49
|
// Eligible and active instances need separate reviewed property sets:
|
|
48
50
|
// only active instances expose assignmentType.
|
|
49
51
|
export const KNOWN_ROLE_FIELDS = [
|
|
@@ -84,9 +86,8 @@ export const KNOWN_ROLE_MEMBER_FIELDS = [
|
|
|
84
86
|
];
|
|
85
87
|
export const KNOWN_SCOPED_MEMBER_FIELDS = [
|
|
86
88
|
"id",
|
|
87
|
-
"
|
|
89
|
+
"roleMemberInfo",
|
|
88
90
|
"roleId",
|
|
89
|
-
"directoryScopeId",
|
|
90
91
|
"administrativeUnitId",
|
|
91
92
|
];
|
|
92
93
|
const ROLE_KNOWN = new Set(KNOWN_ROLE_FIELDS);
|
|
@@ -106,7 +107,7 @@ const DEFAULT_ACTIVE_SELECT = ["id", "principalId", "roleDefinitionId", "assignm
|
|
|
106
107
|
const DEFAULT_TEMPLATE_LIST_SELECT = ["id", "displayName", "description"];
|
|
107
108
|
const DEFAULT_TEMPLATE_SHOW_SELECT = [...KNOWN_TEMPLATE_FIELDS];
|
|
108
109
|
const DEFAULT_MEMBER_SELECT = ["id", "displayName"];
|
|
109
|
-
const DEFAULT_SCOPED_LIST_SELECT = ["id", "
|
|
110
|
+
const DEFAULT_SCOPED_LIST_SELECT = ["id", "roleMemberInfo", "roleId", "administrativeUnitId"];
|
|
110
111
|
const DEFAULT_SCOPED_SHOW_SELECT = [...KNOWN_SCOPED_MEMBER_FIELDS];
|
|
111
112
|
export const DEFAULT_ROLE_SCOPES = ["https://graph.microsoft.com/RoleManagement.Read.Directory"];
|
|
112
113
|
export const DEFAULT_ELIGIBLE_SCOPES = ["https://graph.microsoft.com/RoleEligibilitySchedule.Read.Directory"];
|