@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/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
|
|
@@ -316,9 +316,12 @@ The declined EXT-02c access-review history reads (ReadWrite least privilege, SAS
|
|
|
316
316
|
| `mg-axi api get` | reviewed raw reads (see src/api.ts) | reviewed-raw-read catalogue | API-01 |
|
|
317
317
|
| `mg-axi doctor` | bounded `GET:/users` health check | uses the named user-list read | PACK-01 |
|
|
318
318
|
|
|
319
|
+
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`.
|
|
320
|
+
|
|
319
321
|
## EXT-01 domain scope decisions
|
|
320
322
|
|
|
321
323
|
Firstmate decision R1: approve narrowing this change to the eight v1.0 domain reads above.
|
|
324
|
+
The root domain-dns-record list is now explicitly unsupported; use verification-dns-record or service-configuration-record list with a parent domain.
|
|
322
325
|
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.
|
|
323
326
|
Federation stays out because it can carry signing-certificate material.
|
|
324
327
|
Firstmate decision R2: keep the write-family status section as the coverage fix added to scope (resolved-kept).
|
|
@@ -644,10 +647,10 @@ Scalar counts need a separate query and response contract from the approved list
|
|
|
644
647
|
|
|
645
648
|
## EXT-01 directory-objects scope decisions
|
|
646
649
|
|
|
647
|
-
|
|
650
|
+
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.
|
|
648
651
|
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.
|
|
649
652
|
Directory-object reads support v1.0 only; beta needs its own review.
|
|
650
|
-
Delta-token sync needs its own change-tracking contract beyond the approved
|
|
653
|
+
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.
|
|
651
654
|
Rows are polymorphic: only the base-type properties plus the @odata.type discriminator are projected, so subtype secrets or credentials can never appear.
|
|
652
655
|
|
|
653
656
|
| Inventory operation | Disposition | Owning slice | Deferral reason |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knowttl/mg-axi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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
|
}
|