@knowttl/mg-axi 0.1.4 → 0.1.6
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/app-auth.js +9 -4
- package/dist/auth-status.js +20 -0
- package/dist/auth.js +24 -10
- package/dist/catalogue.js +16 -10
- package/dist/cli.js +57 -26
- package/dist/credentials.js +78 -0
- package/dist/docs.js +10 -11
- package/dist/doctor.js +10 -8
- package/dist/graph-session.js +15 -11
- 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 +1 -1
- package/package.json +4 -6
- package/skills/mg-axi/SKILL.md +14 -11
package/dist/profiles.js
CHANGED
|
@@ -1,104 +1,161 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
4
|
import { AxiError } from "axi-sdk-js";
|
|
5
|
+
import { configPath, toolDirectory } from "./paths.js";
|
|
6
|
+
import { PrivateFiles } from "./private-file.js";
|
|
7
|
+
import { validateSecretRef } from "./secrets.js";
|
|
8
|
+
const names = new WeakMap();
|
|
6
9
|
const guid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
|
-
const namePattern = /^[
|
|
10
|
+
const namePattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
8
11
|
const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
9
12
|
const exact = (value, keys) => Object.keys(value).every(key => keys.includes(key));
|
|
10
13
|
function invalid(message) {
|
|
11
|
-
throw new AxiError(message, "
|
|
14
|
+
throw new AxiError(message, "CONFIG_INVALID", ["mg-axi profile create --help", "Correct the version-1 profile configuration; no automatic migration is performed"]);
|
|
12
15
|
}
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
function validateWrites(value) {
|
|
21
|
-
if (value === undefined)
|
|
22
|
-
return undefined;
|
|
23
|
-
if (!object(value) || !exact(value, ["allowWrites", "operations"]) || typeof value.allowWrites !== "boolean" ||
|
|
24
|
-
!Array.isArray(value.operations) || value.operations.length === 0 || value.operations.length > MAX_WRITE_OPERATIONS ||
|
|
25
|
-
value.operations.some(entry => typeof entry !== "string" || !entry.length || entry.length > MAX_WRITE_OPERATION_LENGTH)) {
|
|
26
|
-
invalid("Invalid write policy; hand-edit a writes object with boolean allowWrites and a nonempty operations array of nonempty operation names");
|
|
27
|
-
}
|
|
28
|
-
return Object.freeze({ allowWrites: value.allowWrites, operations: Object.freeze([...value.operations]) });
|
|
16
|
+
export function booleanEnv(name, env = process.env) {
|
|
17
|
+
const value = env[name]?.toLowerCase();
|
|
18
|
+
if (value === undefined || value === "0" || value === "false")
|
|
19
|
+
return false;
|
|
20
|
+
if (value === "1" || value === "true")
|
|
21
|
+
return true;
|
|
22
|
+
return invalid(`Invalid boolean for ${name}; use 1, true, 0 or false`);
|
|
29
23
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
typeof value.credentialRef.key !== "string" || !guid.test(value.credentialRef.key))
|
|
38
|
-
invalid("Invalid delegated profile; explicit tenant/client IDs and commercial cloud are required, with credentials only by reference");
|
|
39
|
-
const profile = value;
|
|
40
|
-
const writes = validateWrites(value.writes);
|
|
41
|
-
return Object.freeze({ mode: "delegated", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial", preview: profile.preview,
|
|
42
|
-
allowDeviceCode: profile.allowDeviceCode, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
|
|
43
|
-
credentialRef: Object.freeze({ provider: "os-or-session", key: profile.credentialRef.key }), ...(writes === undefined ? {} : { writes }) });
|
|
24
|
+
// Names belong to the selected configuration, never to its serialized profile schema.
|
|
25
|
+
export function profileName(profile) { return names.get(profile); }
|
|
26
|
+
function validated(source, profile) {
|
|
27
|
+
const name = names.get(source);
|
|
28
|
+
if (name !== undefined)
|
|
29
|
+
names.set(profile, name);
|
|
30
|
+
return Object.freeze(profile);
|
|
44
31
|
}
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
32
|
+
function shared(value) {
|
|
33
|
+
if (typeof value.tenantId !== "string" || !guid.test(value.tenantId) || typeof value.clientId !== "string" || !guid.test(value.clientId))
|
|
34
|
+
invalid("Explicit tenant and client UUIDs are required");
|
|
35
|
+
if (value.cloud !== undefined && value.cloud !== "commercial")
|
|
36
|
+
invalid("Only commercial cloud is supported");
|
|
37
|
+
const packs = value.enabledPacks === undefined ? ["entra"] : value.enabledPacks;
|
|
38
|
+
const sensitive = value.sensitiveAreas === undefined ? [] : value.sensitiveAreas;
|
|
39
|
+
const preview = value.preview === undefined ? false : value.preview;
|
|
40
|
+
if (!Array.isArray(packs) || packs.some(pack => pack !== "entra") || new Set(packs).size !== packs.length ||
|
|
41
|
+
!Array.isArray(sensitive) || sensitive.length || typeof preview !== "boolean")
|
|
42
|
+
invalid("Invalid profile policy");
|
|
43
|
+
let writes;
|
|
44
|
+
if (value.writes !== undefined) {
|
|
45
|
+
const policy = value.writes;
|
|
46
|
+
if (!object(policy) || !exact(policy, ["allowWrites", "operations"]) || typeof policy.allowWrites !== "boolean" ||
|
|
47
|
+
!Array.isArray(policy.operations) || !policy.operations.length || policy.operations.length > 64 ||
|
|
48
|
+
policy.operations.some(entry => typeof entry !== "string" || !entry.length || entry.length > 256))
|
|
49
|
+
invalid("Invalid write policy; use boolean allowWrites and a nonempty operations array");
|
|
50
|
+
writes = Object.freeze({ allowWrites: policy.allowWrites, operations: Object.freeze([...policy.operations]) });
|
|
58
51
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
52
|
+
return { tenantId: value.tenantId, clientId: value.clientId, cloud: "commercial", enabledPacks: Object.freeze([...packs]),
|
|
53
|
+
preview: preview, sensitiveAreas: Object.freeze([]), ...(writes === undefined ? {} : { writes }) };
|
|
54
|
+
}
|
|
55
|
+
const sharedKeys = ["mode", "tenantId", "clientId", "cloud", "enabledPacks", "preview", "sensitiveAreas", "allowDeviceCode", "writes"];
|
|
56
|
+
export function validateDelegatedProfile(value, files) {
|
|
57
|
+
if (!object(value) || value.mode !== "delegated" || !exact(value, [...sharedKeys, "token"]) ||
|
|
58
|
+
(value.allowDeviceCode !== undefined && typeof value.allowDeviceCode !== "boolean"))
|
|
59
|
+
invalid("Invalid delegated profile; token must be a SecretRef");
|
|
60
|
+
return validated(value, { ...shared(value), mode: "delegated", allowDeviceCode: value.allowDeviceCode === true, token: validateSecretRef(value.token, "token", true, files) });
|
|
61
|
+
}
|
|
62
|
+
export function validateApplicationProfile(value, files) {
|
|
63
|
+
if (!object(value) || value.mode !== "application" || (value.allowDeviceCode !== undefined && value.allowDeviceCode !== false))
|
|
64
|
+
invalid("Invalid application profile; device code is unavailable");
|
|
65
|
+
const keys = value.auth === "certificate" ? ["privateKey", "thumbprintSha256"] : value.auth === "client-secret" ? ["clientSecret"] : value.auth === "federated" ? ["token"] : [];
|
|
66
|
+
if (!keys.length || !exact(value, [...sharedKeys, "auth", ...keys]))
|
|
67
|
+
invalid("Invalid application profile; auth must be certificate, client-secret or federated with only its credential fields");
|
|
68
|
+
const base = { ...shared(value), mode: "application", allowDeviceCode: false };
|
|
69
|
+
if (value.auth === "certificate") {
|
|
70
|
+
if (typeof value.thumbprintSha256 !== "string" || !/^[0-9a-f]{64}$/i.test(value.thumbprintSha256))
|
|
71
|
+
invalid("thumbprintSha256 must contain 64 hex digits");
|
|
72
|
+
return validated(value, { ...base, auth: "certificate", thumbprintSha256: value.thumbprintSha256.toLowerCase(), privateKey: validateSecretRef(value.privateKey, "privateKey", false, files) });
|
|
74
73
|
}
|
|
75
|
-
if (
|
|
76
|
-
(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
if (value.auth === "client-secret")
|
|
75
|
+
return validated(value, { ...base, auth: "client-secret", clientSecret: validateSecretRef(value.clientSecret, "clientSecret", false, files) });
|
|
76
|
+
return validated(value, { ...base, auth: "federated", token: validateSecretRef(value.token, "token", false, files) });
|
|
77
|
+
}
|
|
78
|
+
export function validateProfile(value, files) {
|
|
79
|
+
return object(value) && value.mode === "application" ? validateApplicationProfile(value, files) : validateDelegatedProfile(value, files);
|
|
80
|
+
}
|
|
81
|
+
function envProfile(env) {
|
|
82
|
+
const fields = ["MODE", "TENANT_ID", "CLIENT_ID", "AUTH", "CLOUD", "PREVIEW", "ALLOW_DEVICE_CODE", "ENABLED_PACKS", "SENSITIVE_AREAS", "WRITES"];
|
|
83
|
+
if (!fields.some(field => env[`MG_AXI_${field}`] !== undefined))
|
|
84
|
+
return undefined;
|
|
85
|
+
if (!env.MG_AXI_MODE || !env.MG_AXI_TENANT_ID || !env.MG_AXI_CLIENT_ID)
|
|
86
|
+
invalid("Partial env profile; MG_AXI_MODE, MG_AXI_TENANT_ID and MG_AXI_CLIENT_ID are required");
|
|
87
|
+
const json = (name) => {
|
|
88
|
+
if (env[name] === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(env[name]);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return invalid(`Invalid JSON field ${name}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const base = { mode: env.MG_AXI_MODE, tenantId: env.MG_AXI_TENANT_ID, clientId: env.MG_AXI_CLIENT_ID, cloud: env.MG_AXI_CLOUD,
|
|
98
|
+
preview: booleanEnv("MG_AXI_PREVIEW", env), allowDeviceCode: booleanEnv("MG_AXI_ALLOW_DEVICE_CODE", env),
|
|
99
|
+
enabledPacks: json("MG_AXI_ENABLED_PACKS"), sensitiveAreas: json("MG_AXI_SENSITIVE_AREAS"), writes: json("MG_AXI_WRITES"),
|
|
100
|
+
...(env.MG_AXI_AUTH === undefined ? {} : { auth: env.MG_AXI_AUTH }),
|
|
101
|
+
...(env.MG_AXI_THUMBPRINT_SHA256 === undefined ? {} : { thumbprintSha256: env.MG_AXI_THUMBPRINT_SHA256 }),
|
|
102
|
+
...(env.MG_AXI_CLIENT_SECRET === undefined ? {} : { clientSecret: { env: "MG_AXI_CLIENT_SECRET" } }),
|
|
103
|
+
...(env.MG_AXI_PRIVATE_KEY === undefined ? {} : { privateKey: { env: "MG_AXI_PRIVATE_KEY" } }),
|
|
104
|
+
...(env.MG_AXI_TOKEN === undefined ? {} : { token: { env: "MG_AXI_TOKEN" } }) };
|
|
105
|
+
if (base.mode === "delegated") {
|
|
106
|
+
if (!env.MG_AXI_TOKEN)
|
|
107
|
+
invalid("Partial env profile; MG_AXI_TOKEN must supply a delegated cache");
|
|
108
|
+
return validateDelegatedProfile(base);
|
|
80
109
|
}
|
|
81
|
-
|
|
110
|
+
const auth = env.MG_AXI_AUTH;
|
|
111
|
+
const required = auth === "certificate" ? ["PRIVATE_KEY", "THUMBPRINT_SHA256"] : auth === "client-secret" ? ["CLIENT_SECRET"] : auth === "federated" ? ["TOKEN"] : [];
|
|
112
|
+
if (!required.length || required.some(field => !env[`MG_AXI_${field}`]))
|
|
113
|
+
invalid("Partial env profile; MG_AXI_AUTH and its required credential fields must be set");
|
|
114
|
+
return validateApplicationProfile(base);
|
|
82
115
|
}
|
|
83
|
-
export function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
116
|
+
export function movedError(kind, legacy, target, guidance) {
|
|
117
|
+
const quote = (path) => `'${path.replace(/'/g, "'\\''")}'`;
|
|
118
|
+
const move = process.platform === "win32"
|
|
119
|
+
? `New-Item -ItemType Directory -Force -Path '${dirname(target).replace(/'/g, "''")}'; Move-Item -LiteralPath '${legacy.replace(/'/g, "''")}' -Destination '${target.replace(/'/g, "''")}'`
|
|
120
|
+
: `mkdir -p ${quote(dirname(target))} && mv ${quote(legacy)} ${quote(target)}`;
|
|
121
|
+
throw new AxiError(`${kind} moved from ${legacy} to ${target}`, "CONFIG_MOVED", [move, guidance]);
|
|
87
122
|
}
|
|
88
123
|
export class Profiles {
|
|
124
|
+
files;
|
|
89
125
|
path;
|
|
90
|
-
constructor(path =
|
|
91
|
-
this.
|
|
126
|
+
constructor(path, files = new PrivateFiles()) {
|
|
127
|
+
this.files = files;
|
|
128
|
+
this.path = path?.trim() ? path.trim() : configPath();
|
|
129
|
+
}
|
|
130
|
+
moved() {
|
|
131
|
+
const legacy = join(homedir(), ".mg-axi", "config.json");
|
|
132
|
+
const target = resolve(this.path) === resolve(legacy) ? join(toolDirectory("config"), "config.json") : this.path;
|
|
133
|
+
movedError("Configuration", legacy, target, "Update credentialRef to the flattened SecretRef format described in the AXI credential standard");
|
|
92
134
|
}
|
|
93
135
|
load() {
|
|
94
|
-
|
|
136
|
+
const legacy = join(homedir(), ".mg-axi", "config.json");
|
|
137
|
+
if (resolve(this.path) === resolve(legacy) && existsSync(legacy))
|
|
138
|
+
this.moved();
|
|
139
|
+
let text;
|
|
95
140
|
try {
|
|
96
|
-
|
|
141
|
+
text = this.files.read(this.path, false);
|
|
97
142
|
}
|
|
98
143
|
catch (error) {
|
|
99
|
-
if (error
|
|
144
|
+
if (error instanceof AxiError)
|
|
145
|
+
throw error;
|
|
146
|
+
if (error.code === "ENOENT") {
|
|
147
|
+
if (existsSync(legacy))
|
|
148
|
+
this.moved();
|
|
100
149
|
return { version: 1, profiles: {} };
|
|
101
|
-
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
let value;
|
|
154
|
+
try {
|
|
155
|
+
value = JSON.parse(text);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
invalid("Unable to read profile configuration; check the version-1 JSON schema");
|
|
102
159
|
}
|
|
103
160
|
if (!object(value) || value.version !== 1 || !exact(value, ["version", "defaultProfile", "profiles"]) || !object(value.profiles))
|
|
104
161
|
invalid("Unsupported profile configuration; explicitly migrate to version 1");
|
|
@@ -106,80 +163,69 @@ export class Profiles {
|
|
|
106
163
|
for (const [name, profile] of Object.entries(value.profiles)) {
|
|
107
164
|
if (!namePattern.test(name))
|
|
108
165
|
invalid("Invalid profile name");
|
|
109
|
-
profiles[name] = validateProfile(profile);
|
|
166
|
+
profiles[name] = validateProfile(profile, this.files);
|
|
167
|
+
names.set(profiles[name], name);
|
|
110
168
|
}
|
|
111
169
|
if (value.defaultProfile !== undefined && (typeof value.defaultProfile !== "string" || !Object.hasOwn(profiles, value.defaultProfile)))
|
|
112
170
|
invalid("Default profile does not exist");
|
|
113
|
-
if (new Set(Object.values(profiles).map(profile => profile.credentialRef.key)).size !== Object.keys(profiles).length)
|
|
114
|
-
invalid("Each profile must have a separate credential reference");
|
|
115
171
|
return { version: 1, profiles, defaultProfile: value.defaultProfile };
|
|
116
172
|
}
|
|
117
173
|
list() { return Object.entries(this.load().profiles).map(([name, profile]) => ({ name, mode: profile.mode, tenant: profile.tenantId, cloud: profile.cloud })); }
|
|
118
174
|
resolve(name) {
|
|
175
|
+
const flag = name !== undefined && name.trim() !== "" ? name.trim() : undefined;
|
|
176
|
+
const envName = process.env.MG_AXI_PROFILE !== undefined && process.env.MG_AXI_PROFILE.trim() !== "" ? process.env.MG_AXI_PROFILE.trim() : undefined;
|
|
177
|
+
const explicit = flag ?? envName;
|
|
178
|
+
if (explicit === undefined) {
|
|
179
|
+
const profile = envProfile(process.env);
|
|
180
|
+
if (profile) {
|
|
181
|
+
names.set(profile, "env");
|
|
182
|
+
return { name: "env", profile };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
119
185
|
const config = this.load();
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
186
|
+
const configured = Object.keys(config.profiles);
|
|
187
|
+
const selected = explicit ?? config.defaultProfile ?? (configured.length === 1 ? configured[0] : undefined);
|
|
188
|
+
if (!selected)
|
|
189
|
+
throw new AxiError("No configured profile selected", configured.length ? "PROFILE_AMBIGUOUS" : "PROFILE_REQUIRED", ["mg-axi profile list", "mg-axi profile create --help"]);
|
|
190
|
+
if (!Object.hasOwn(config.profiles, selected))
|
|
191
|
+
throw new AxiError(`Profile ${selected} does not exist`, "PROFILE_NOT_FOUND", ["mg-axi profile list"]);
|
|
123
192
|
return { name: selected, profile: config.profiles[selected] };
|
|
124
193
|
}
|
|
194
|
+
verifyPermissions() {
|
|
195
|
+
try {
|
|
196
|
+
this.files.check(this.path, false, true);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error.code !== "ENOENT")
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
125
203
|
create(name, tenantId, clientId, cloud, allowDeviceCode, application) {
|
|
126
204
|
if (!namePattern.test(name))
|
|
127
205
|
invalid("Profile name must contain only letters, digits, underscores or hyphens");
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
invalid(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
206
|
+
const base = { tenantId, clientId, cloud, allowDeviceCode };
|
|
207
|
+
let profile;
|
|
208
|
+
if (!application)
|
|
209
|
+
profile = validateDelegatedProfile({ ...base, mode: "delegated", token: { stored: true } }, this.files);
|
|
210
|
+
else {
|
|
211
|
+
const { certificateThumbprint, federated, clientSecretEnv, clientSecretFile, certificateKeyFile, certificateKeyEnv, federatedTokenFile } = application;
|
|
212
|
+
if (clientSecretEnv !== undefined && clientSecretFile !== undefined || certificateKeyEnv !== undefined && certificateKeyFile !== undefined)
|
|
213
|
+
invalid("Use exactly one env or file reference for each credential");
|
|
214
|
+
if ([certificateThumbprint !== undefined, federated === true, clientSecretEnv !== undefined || clientSecretFile !== undefined].filter(Boolean).length !== 1)
|
|
215
|
+
invalid("Select exactly one application credential kind");
|
|
216
|
+
if ((certificateKeyEnv !== undefined || certificateKeyFile !== undefined) && certificateThumbprint === undefined || federatedTokenFile !== undefined && !federated)
|
|
217
|
+
invalid("Credential holder does not match the selected application auth kind");
|
|
218
|
+
profile = validateApplicationProfile({ ...base, mode: "application",
|
|
219
|
+
...(certificateThumbprint !== undefined ? { auth: "certificate", thumbprintSha256: certificateThumbprint, privateKey: certificateKeyFile !== undefined ? { file: certificateKeyFile } : { env: certificateKeyEnv ?? "MG_AXI_PRIVATE_KEY" } }
|
|
220
|
+
: federated ? { auth: "federated", ...(federatedTokenFile === undefined ? {} : { token: { file: federatedTokenFile } }) }
|
|
221
|
+
: { auth: "client-secret", clientSecret: clientSecretFile !== undefined ? { file: clientSecretFile } : { env: clientSecretEnv } }) }, this.files);
|
|
144
222
|
}
|
|
145
|
-
if (application && secretEnv !== undefined && secretFile !== undefined)
|
|
146
|
-
invalid("Application client-secret profiles use exactly one holder: --client-secret-env or --client-secret-file");
|
|
147
|
-
if (application && keyFile !== undefined && keyEnv !== undefined)
|
|
148
|
-
invalid("Application certificate profiles use exactly one key holder: --certificate-key-file or --certificate-key-env; omit both for the OS keychain");
|
|
149
|
-
if (application && (keyFile !== undefined || keyEnv !== undefined) && certificate === undefined)
|
|
150
|
-
invalid("Certificate key holders need --certificate-thumbprint on the same profile");
|
|
151
|
-
if (application && tokenFileEnv !== undefined && !federated)
|
|
152
|
-
invalid("Federated token-file holders need --federated on the same profile");
|
|
153
|
-
if (application && [certificate !== undefined, secretEnv !== undefined || secretFile !== undefined, federated].filter(Boolean).length !== 1)
|
|
154
|
-
invalid("Application profiles use exactly one credential: --certificate-thumbprint, --client-secret-env/--client-secret-file or --federated");
|
|
155
|
-
if (application && allowDeviceCode)
|
|
156
|
-
invalid("Application profiles never use device code; it is a delegated login method");
|
|
157
|
-
const credentialRef = certificate === undefined
|
|
158
|
-
? secretEnv === undefined && secretFile === undefined
|
|
159
|
-
? { provider: "federated", key: randomUUID(), ...(tokenFileEnv === undefined ? {} : { tokenFileEnv }) }
|
|
160
|
-
: { provider: "client-secret", key: randomUUID(), ...(secretEnv === undefined ? { secretFile: secretFile } : { secretEnv }) }
|
|
161
|
-
: { provider: "certificate", key: randomUUID(), thumbprint: certificate, ...(keyFile === undefined && keyEnv === undefined ? {} : keyFile === undefined ? { keyEnv: keyEnv } : { keyFile }) };
|
|
162
|
-
const profile = application
|
|
163
|
-
? validateApplicationProfile({ mode: "application", tenantId, clientId, cloud, enabledPacks: ["entra"], preview: false, sensitiveAreas: [],
|
|
164
|
-
credentialRef, allowDeviceCode: false })
|
|
165
|
-
: validateDelegatedProfile({ mode: "delegated", tenantId, clientId, cloud, enabledPacks: ["entra"], preview: false, sensitiveAreas: [], credentialRef: { provider: "os-or-session", key: randomUUID() }, allowDeviceCode });
|
|
166
223
|
const config = this.load();
|
|
167
224
|
if (Object.hasOwn(config.profiles, name))
|
|
168
225
|
invalid("Profile already exists; creation never replaces identity");
|
|
169
226
|
config.profiles[name] = profile;
|
|
170
227
|
config.defaultProfile ??= name;
|
|
171
|
-
|
|
172
|
-
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
173
|
-
try {
|
|
174
|
-
writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
175
|
-
renameSync(temporary, this.path);
|
|
176
|
-
}
|
|
177
|
-
catch {
|
|
178
|
-
throw new AxiError("Unable to save profile configuration", "CONFIG_ERROR", ["Check configuration directory permissions"]);
|
|
179
|
-
}
|
|
180
|
-
finally {
|
|
181
|
-
rmSync(temporary, { force: true });
|
|
182
|
-
}
|
|
228
|
+
this.files.write(this.path, `${JSON.stringify(config, null, 2)}\n`);
|
|
183
229
|
return { name, mode: profile.mode, tenant: profile.tenantId, cloud: profile.cloud };
|
|
184
230
|
}
|
|
185
231
|
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const secrets = new Set();
|
|
2
|
+
export function registerSecret(value) {
|
|
3
|
+
if (!value)
|
|
4
|
+
return value;
|
|
5
|
+
for (const form of [value, JSON.stringify(value).slice(1, -1), encodeURIComponent(value)])
|
|
6
|
+
secrets.add(form);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
export function redactSecrets(value) {
|
|
10
|
+
for (const secret of [...secrets].sort((a, b) => b.length - a.length))
|
|
11
|
+
value = value.split(secret).join("***redacted***");
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
// MSAL caches contain opaque refresh, access and ID tokens under secret fields.
|
|
15
|
+
export function registerCache(value) {
|
|
16
|
+
registerSecret(value);
|
|
17
|
+
const visit = (entry, key = "") => {
|
|
18
|
+
if (typeof entry === "string" && /^(secret|accessToken|refreshToken|idToken)$/i.test(key))
|
|
19
|
+
registerSecret(entry);
|
|
20
|
+
else if (entry && typeof entry === "object")
|
|
21
|
+
for (const [name, child] of Object.entries(entry))
|
|
22
|
+
visit(child, name);
|
|
23
|
+
};
|
|
24
|
+
visit(JSON.parse(value));
|
|
25
|
+
return value;
|
|
26
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { PrivateFiles } from "./private-file.js";
|
|
3
|
+
import { registerSecret } from "./redact.js";
|
|
4
|
+
export function validateSecretRef(value, field, stored = true, files = new PrivateFiles()) {
|
|
5
|
+
if (value === undefined)
|
|
6
|
+
return Object.freeze({ env: `MG_AXI_${field.replace(/[A-Z]/g, letter => `_${letter}`).toUpperCase()}` });
|
|
7
|
+
if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 1) {
|
|
8
|
+
const ref = value;
|
|
9
|
+
if (typeof ref.env === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(ref.env))
|
|
10
|
+
return Object.freeze({ env: ref.env });
|
|
11
|
+
if (typeof ref.file === "string" && files.absolute(ref.file))
|
|
12
|
+
return Object.freeze({ file: ref.file });
|
|
13
|
+
if (stored && ref.stored === true)
|
|
14
|
+
return Object.freeze({ stored: true });
|
|
15
|
+
}
|
|
16
|
+
throw new AxiError(`Invalid ${field}; use a SecretRef, never an inline secret`, "CONFIG_INVALID", ["Use exactly one validated env name, absolute local file path, or stored: true for a delegated login"]);
|
|
17
|
+
}
|
|
18
|
+
export function secretSource(ref) { return "env" in ref ? `env:${ref.env}` : "file" in ref ? `file:${ref.file}` : "stored"; }
|
|
19
|
+
export function resolveSecret(ref, files = new PrivateFiles(), verifyAcl = false) {
|
|
20
|
+
let value;
|
|
21
|
+
if ("env" in ref)
|
|
22
|
+
value = process.env[ref.env];
|
|
23
|
+
else if ("file" in ref) {
|
|
24
|
+
try {
|
|
25
|
+
value = files.read(ref.file, true, verifyAcl).trimEnd();
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof AxiError)
|
|
29
|
+
throw error;
|
|
30
|
+
if (error?.code !== "ENOENT")
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!value?.trim())
|
|
35
|
+
throw new AxiError(`Credential unavailable from ${secretSource(ref)}`, "AUTH_REQUIRED", ["Supply the named credential reference; no interactive fallback was attempted"]);
|
|
36
|
+
return registerSecret(value);
|
|
37
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -105,9 +105,9 @@ export function setupView(store) {
|
|
|
105
105
|
"node dist/bin/mg-axi.js --version",
|
|
106
106
|
],
|
|
107
107
|
guidance: [
|
|
108
|
-
`Hand-edit ${store.path} or set MG_AXI_CONFIG to a separate configuration file; a profile stores only a
|
|
108
|
+
`Hand-edit ${store.path} or set MG_AXI_CONFIG to a separate configuration file; a profile stores only a SecretRef - an environment variable name, an absolute file path, or stored: true for delegated login - and never the secret value, never in argv or config`,
|
|
109
109
|
"node dist/bin/mg-axi.js profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial",
|
|
110
|
-
"node dist/bin/mg-axi.js login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
|
|
110
|
+
"node dist/bin/mg-axi.js auth login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
|
|
111
111
|
"node dist/bin/mg-axi.js doctor",
|
|
112
112
|
],
|
|
113
113
|
capabilities: {
|
package/docs/coverage.md
CHANGED
|
@@ -14,7 +14,7 @@ scheduled work as complete.
|
|
|
14
14
|
|
|
15
15
|
- implemented read leaves: 263 (261 named Entra reads plus reviewed raw api get and doctor health check)
|
|
16
16
|
- implemented write leaves: 5 (named gated mutations below)
|
|
17
|
-
- local leaves:
|
|
17
|
+
- local leaves: 11 (home, profile, config, auth, login alias and setup views)
|
|
18
18
|
- inventory named-command: 289
|
|
19
19
|
- inventory reviewed-raw-read: 5
|
|
20
20
|
- inventory scheduled: 3717
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knowttl/mg-axi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Agent-facing Microsoft Graph CLI, read-only by default",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"inventory/operations.json",
|
|
21
21
|
"skills/mg-axi",
|
|
22
22
|
"docs/coverage.md",
|
|
23
|
-
"README.md"
|
|
23
|
+
"README.md",
|
|
24
|
+
"axi-credential.schema.json"
|
|
24
25
|
],
|
|
25
26
|
"engines": {
|
|
26
27
|
"node": ">=22.12.0"
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
"packageManager": "pnpm@10.34.6",
|
|
29
30
|
"scripts": {
|
|
30
31
|
"build": "tsc -p tsconfig.json",
|
|
31
|
-
"test": "node --test --test-concurrency=4 test/*.test.mjs",
|
|
32
|
+
"test": "node tools/isolated-home.mjs node --test --test-concurrency=4 test/*.test.mjs",
|
|
32
33
|
"lint": "tsc --noEmit -p tsconfig.json",
|
|
33
34
|
"docs:generate": "node tools/generate-docs.mjs",
|
|
34
35
|
"docs:check": "node tools/generate-docs.mjs --check"
|
|
@@ -42,8 +43,5 @@
|
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@types/node": "^22.20.5",
|
|
44
45
|
"typescript": "^7.0.2"
|
|
45
|
-
},
|
|
46
|
-
"optionalDependencies": {
|
|
47
|
-
"keytar": "^7.9.0"
|
|
48
46
|
}
|
|
49
47
|
}
|
package/skills/mg-axi/SKILL.md
CHANGED
|
@@ -17,13 +17,13 @@ See [README.md](../../README.md) for write usage, execution gates, identity pinn
|
|
|
17
17
|
Run commands non-interactively as `npx -y @knowttl/mg-axi ...`: no global install needed and no interactive prompts.
|
|
18
18
|
Version pinning is the installer's choice: use `npx -y @knowttl/mg-axi@<version> ...` to select a specific release.
|
|
19
19
|
Run `npx -y @knowttl/mg-axi doctor` first.
|
|
20
|
-
See [README.md](../../README.md#
|
|
20
|
+
See [README.md](../../README.md#usage) for doctor profile selection, checks and failure behavior.
|
|
21
21
|
|
|
22
22
|
## Orientation
|
|
23
23
|
|
|
24
24
|
The exact current leaf registry is `src/catalogue.ts`.
|
|
25
25
|
Its capability label is `native` (implemented by an mg-axi handler).
|
|
26
|
-
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.
|
|
26
|
+
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.
|
|
27
27
|
Doctor acquires credentials silently and contacts Graph; only its help view stays offline.
|
|
28
28
|
The list below records current executable leaves; it makes no coverage claim for other Graph operations.
|
|
29
29
|
See `docs/coverage.md` for the per-operation disposition records.
|
|
@@ -36,7 +36,7 @@ See `docs/coverage.md` for the per-operation disposition records.
|
|
|
36
36
|
| `mg-axi profile create` | native | local |
|
|
37
37
|
| `mg-axi profile list` | native | local |
|
|
38
38
|
| `mg-axi profile show` | native | local |
|
|
39
|
-
| `mg-axi login` | native | local |
|
|
39
|
+
| `mg-axi auth login` | native | local |
|
|
40
40
|
| `mg-axi setup` | native | local |
|
|
41
41
|
| `mg-axi setup hooks` | native | local |
|
|
42
42
|
| `mg-axi doctor` | native | read |
|
|
@@ -307,6 +307,10 @@ See `docs/coverage.md` for the per-operation disposition records.
|
|
|
307
307
|
| `mg-axi entra lifecycle run task-processing-result show` | native | read |
|
|
308
308
|
| `mg-axi entra lifecycle run task-processing-result count` | native | read |
|
|
309
309
|
| `mg-axi api get` | native | read |
|
|
310
|
+
| `mg-axi login` | native | local |
|
|
311
|
+
| `mg-axi config path` | native | local |
|
|
312
|
+
| `mg-axi auth status` | native | local |
|
|
313
|
+
| `mg-axi auth logout` | native | local |
|
|
310
314
|
<!-- command-registry:end -->
|
|
311
315
|
|
|
312
316
|
Run `npx -y @knowttl/mg-axi <leaf-path> --help` for that leaf's accepted flags and reference.
|
|
@@ -318,7 +322,7 @@ The no-args home view prints these hints alongside live profile status:
|
|
|
318
322
|
|
|
319
323
|
```sh
|
|
320
324
|
npx -y @knowttl/mg-axi profile list
|
|
321
|
-
npx -y @knowttl/mg-axi login --help
|
|
325
|
+
npx -y @knowttl/mg-axi auth login --help
|
|
322
326
|
npx -y @knowttl/mg-axi setup
|
|
323
327
|
npx -y @knowttl/mg-axi doctor
|
|
324
328
|
npx -y @knowttl/mg-axi entra user list --help
|
|
@@ -333,18 +337,18 @@ npx -y @knowttl/mg-axi api get --help
|
|
|
333
337
|
|
|
334
338
|
## Setup (explicit only)
|
|
335
339
|
|
|
336
|
-
Follow [
|
|
340
|
+
Follow [installation guidance](../../README.md#quick-start) for npm installation and explicit skill installation; [README.md](../../README.md#development--testing) also documents checkout builds.
|
|
337
341
|
Create profiles explicitly and keep secrets out of argv and config files:
|
|
338
342
|
|
|
339
343
|
```sh
|
|
340
344
|
npx -y @knowttl/mg-axi setup # build steps, config path and capabilities; writes nothing
|
|
341
345
|
npx -y @knowttl/mg-axi profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial
|
|
342
|
-
npx -y @knowttl/mg-axi login --profile soc --scopes https://graph.microsoft.com/User.Read.All
|
|
346
|
+
npx -y @knowttl/mg-axi auth login --profile soc --scopes https://graph.microsoft.com/User.Read.All
|
|
343
347
|
npx -y @knowttl/mg-axi doctor # one bounded read per selected profile
|
|
344
348
|
npx -y @knowttl/mg-axi entra user list --profile soc --limit 10
|
|
345
349
|
```
|
|
346
350
|
|
|
347
|
-
Configuration
|
|
351
|
+
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`.
|
|
348
352
|
`mg-axi setup` shows the selected path and writes nothing.
|
|
349
353
|
Application profiles authenticate headlessly through credential references: the profile names an env var or file and the environment supplies the value at acquisition time.
|
|
350
354
|
Container examples for client secrets, certificate keys and workload federation live in [README.md](../../README.md#mg-axi).
|
|
@@ -366,14 +370,13 @@ No other ordinary command installs hooks, plugins or configuration.
|
|
|
366
370
|
## Selecting a profile
|
|
367
371
|
|
|
368
372
|
Read leaves accept `--profile <name>`.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
Doctor selection is documented in [README.md](../../README.md#release).
|
|
373
|
+
Profile selection order and env-profile rules live in [README Configuration](../../README.md#configuration); an explicitly selected file profile ignores env fields.
|
|
374
|
+
Doctor selection is documented in [README.md](../../README.md#usage).
|
|
372
375
|
Without profiles, home, setup and profile list show unconfigured state; Graph read leaves report `AUTH_REQUIRED`.
|
|
373
376
|
|
|
374
377
|
## Safety
|
|
375
378
|
|
|
376
|
-
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.
|
|
379
|
+
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.
|
|
377
380
|
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.
|
|
378
381
|
Output is TOON on stdout; diagnostics use stderr.
|
|
379
382
|
Text truncates at 500 characters (`--full` restores text, never redaction); row caps resume through opaque cursors.
|