@knowttl/mg-axi 0.1.2 → 0.1.4

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.
@@ -1,4 +1,5 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { listTotals } from "./list-totals.js";
2
3
  // EXT-01 terms-of-use subfamily: the read mapping behind
3
4
  // `mg-axi entra agreement list/show`,
4
5
  // `mg-axi entra agreement acceptance list/show --agreement <agreement-id>`
@@ -264,20 +265,22 @@ export async function listAgreements(session, flags, profile, operation, help, p
264
265
  if (!result.complete) {
265
266
  return {
266
267
  agreements,
267
- count: { returned: agreements.length, complete: false, reason: result.reason },
268
+ ...listTotals(agreements.length, result.total, "agreements", false),
269
+ complete: false,
270
+ reason: result.reason,
268
271
  cursor: result.cursor,
269
272
  help: [...truncationHints, resumeHint(profileName), showHint],
270
273
  };
271
274
  }
272
- const count = { returned: agreements.length, complete: true };
273
275
  if (!agreements.length) {
274
276
  return {
275
277
  agreements,
276
- count,
278
+ ...listTotals(agreements.length, result.total, "agreements", true),
279
+ complete: true,
277
280
  help: ["0 agreements matched; no terms-of-use agreements are configured for the tenant, or no row passed --filter"],
278
281
  };
279
282
  }
280
- return { agreements, count, help: [...truncationHints, showHint] };
283
+ return { agreements, ...listTotals(agreements.length, result.total, "agreements", true), complete: true, help: [...truncationHints, showHint] };
281
284
  }
282
285
  export async function showAgreement(session, flags, profile, operation, help, profileName) {
283
286
  const { select, fields } = selectedFields(flags, KNOWN_AGREEMENTS, KNOWN_AGREEMENT_FIELDS, DEFAULT_AGREEMENT_SHOW_SELECT, help, "agreement");
@@ -325,20 +328,22 @@ async function collectAcceptances(session, flags, profile, operation, help, prof
325
328
  if (!result.complete) {
326
329
  return {
327
330
  agreementAcceptances: acceptances,
328
- count: { returned: acceptances.length, complete: false, reason: result.reason },
331
+ ...listTotals(acceptances.length, result.total, "agreement acceptances", false),
332
+ complete: false,
333
+ reason: result.reason,
329
334
  cursor: result.cursor,
330
335
  help: [...truncationHints, resumeHint(profileName), showHint],
331
336
  };
332
337
  }
333
- const count = { returned: acceptances.length, complete: true };
334
338
  if (!acceptances.length) {
335
339
  return {
336
340
  agreementAcceptances: acceptances,
337
- count,
341
+ ...listTotals(acceptances.length, result.total, "agreement acceptances", true),
342
+ complete: true,
338
343
  help: ["0 agreement acceptances matched; nobody recorded an acceptance in scope yet, or no row passed --filter"],
339
344
  };
340
345
  }
341
- return { agreementAcceptances: acceptances, count, help: [...truncationHints, showHint] };
346
+ return { agreementAcceptances: acceptances, ...listTotals(acceptances.length, result.total, "agreement acceptances", true), complete: true, help: [...truncationHints, showHint] };
342
347
  }
343
348
  async function showAcceptanceById(session, flags, profile, operation, help, profileName, command, agreement) {
344
349
  const { select, fields } = selectedFields(flags, KNOWN_ACCEPTANCES, KNOWN_ACCEPTANCE_FIELDS, DEFAULT_ACCEPTANCE_SELECT, help, "agreement-acceptance");
@@ -25,6 +25,7 @@ const READ_SCOPES = new Set([
25
25
  "AdministrativeUnit.Read.All",
26
26
  "Application.Read.All",
27
27
  "AuditLog.Read.All",
28
+ "AuthenticationContext.Read.All",
28
29
  "CustomSecAttributeDefinition.Read.All",
29
30
  "CrossTenantInformation.ReadBasic.All",
30
31
  "DelegatedAdminRelationship.Read.All",
@@ -1,15 +1,19 @@
1
- import { readFileSync } from "node:fs";
1
+ import { lstatSync, readFileSync } from "node:fs";
2
2
  import { ConfidentialClientApplication } from "@azure/msal-node";
3
+ import { AxiError } from "axi-sdk-js";
3
4
  import { graphAudience } from "./app-auth.js";
4
5
  // Only this adapter touches application credentials; keys and assertions are referenced, never logged.
5
6
  export class MsalApplicationProvider {
6
7
  storage = "session-only";
7
8
  async acquire(profile, scopes) {
9
+ const ref = profile.credentialRef;
8
10
  const configuration = {
9
11
  auth: { clientId: profile.clientId, authority: `https://login.microsoftonline.com/${profile.tenantId}`,
10
- ...(profile.credentialRef.provider === "certificate"
11
- ? { clientCertificate: { thumbprint: profile.credentialRef.thumbprint, privateKey: await this.privateKey(profile.credentialRef.key) } }
12
- : { clientAssertion: () => this.assertion() }) },
12
+ ...(ref.provider === "client-secret"
13
+ ? { clientSecret: this.clientSecret(ref) }
14
+ : ref.provider === "certificate"
15
+ ? { clientCertificate: { thumbprint: ref.thumbprint, privateKey: await this.privateKey(ref) } }
16
+ : { clientAssertion: () => this.assertion(ref) }) },
13
17
  system: { loggerOptions: { loggerCallback: () => { }, piiLoggingEnabled: false } },
14
18
  };
15
19
  const result = await new ConfidentialClientApplication(configuration)
@@ -20,23 +24,102 @@ export class MsalApplicationProvider {
20
24
  throw new Error("Configured application authority mismatch");
21
25
  return { token: result.accessToken, expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: profile.tenantId, clientId: profile.clientId };
22
26
  }
23
- async privateKey(key) {
27
+ async privateKey(ref) {
28
+ if (ref.keyFile !== undefined) {
29
+ this.storage = "session-only";
30
+ return this.normalizeKey(this.readProtectedFile(ref.keyFile), ref.keyFile);
31
+ }
32
+ if (ref.keyEnv !== undefined) {
33
+ this.storage = "session-only";
34
+ const material = process.env[ref.keyEnv];
35
+ if (typeof material !== "string" || !material.trim())
36
+ throw this.missing(ref.keyEnv);
37
+ return this.normalizeKey(material, ref.keyEnv);
38
+ }
24
39
  const store = await import("keytar").then(module => module.default).catch(() => undefined);
25
40
  this.storage = store ? "os-protected" : "session-only";
26
- const material = await store?.getPassword("mg-axi", key).catch(() => null);
41
+ const material = await store?.getPassword("mg-axi", ref.key).catch(() => null);
27
42
  if (typeof material !== "string" || !material.trim())
28
43
  throw new Error("Missing referenced certificate private key");
29
44
  return material;
30
45
  }
31
- async assertion() {
46
+ clientSecret(ref) {
32
47
  this.storage = "session-only";
33
- const file = process.env.AZURE_FEDERATED_TOKEN_FILE;
48
+ if (ref.secretEnv !== undefined) {
49
+ const secret = process.env[ref.secretEnv];
50
+ if (typeof secret !== "string" || !secret.trim())
51
+ throw this.missing(ref.secretEnv);
52
+ return secret.trim();
53
+ }
54
+ if (ref.secretFile !== undefined)
55
+ return this.readProtectedFile(ref.secretFile);
56
+ throw new Error("Missing client-secret credential holder");
57
+ }
58
+ async assertion(ref) {
59
+ this.storage = "session-only";
60
+ if (ref.tokenFileEnv === undefined) {
61
+ const file = process.env.AZURE_FEDERATED_TOKEN_FILE;
62
+ if (!file?.trim())
63
+ throw new Error("Missing federated token source");
64
+ // Read on every invocation: projected federated tokens rotate on disk.
65
+ const assertion = readFileSync(file, "utf8").trim();
66
+ if (!assertion)
67
+ throw new Error("Missing federated token source");
68
+ return assertion;
69
+ }
70
+ const file = process.env[ref.tokenFileEnv];
34
71
  if (!file?.trim())
35
- throw new Error("Missing federated token source");
72
+ throw this.missing(ref.tokenFileEnv);
36
73
  // Read on every invocation: projected federated tokens rotate on disk.
37
- const assertion = readFileSync(file, "utf8").trim();
38
- if (!assertion)
39
- throw new Error("Missing federated token source");
40
- return assertion;
74
+ try {
75
+ const assertion = readFileSync(file, "utf8").trim();
76
+ if (!assertion)
77
+ throw this.missing(ref.tokenFileEnv);
78
+ return assertion;
79
+ }
80
+ catch (error) {
81
+ if (error instanceof AxiError)
82
+ throw error;
83
+ throw this.missing(ref.tokenFileEnv);
84
+ }
85
+ }
86
+ // Key and secret files hold long-lived material: regular file, owned by the
87
+ // current user, mode 0600, never a symlink. Relative paths resolve against
88
+ // the process working directory at acquire time. Windows skips the
89
+ // ownership and mode checks. Every failure names only the path, never content.
90
+ readProtectedFile(path) {
91
+ try {
92
+ const stat = lstatSync(path);
93
+ if (stat.isSymbolicLink() || !stat.isFile())
94
+ throw this.missing(path);
95
+ if (process.platform !== "win32") {
96
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
97
+ throw this.missing(path);
98
+ if ((stat.mode & 0o777) !== 0o600)
99
+ throw this.missing(path);
100
+ }
101
+ const content = readFileSync(path, "utf8").trim();
102
+ if (!content)
103
+ throw this.missing(path);
104
+ return content;
105
+ }
106
+ catch (error) {
107
+ if (error instanceof AxiError)
108
+ throw error;
109
+ throw this.missing(path);
110
+ }
111
+ }
112
+ // PEM material from files and env vars; env vars may carry escaped "\n".
113
+ normalizeKey(material, reference) {
114
+ const normalized = material.includes("\n") ? material : material.replace(/\\n/g, "\n");
115
+ if (/ENCRYPTED PRIVATE KEY/.test(normalized) || /Proc-Type:\s*4,ENCRYPTED/.test(normalized))
116
+ throw new AxiError(`Application credential reference ${reference} is passphrase-protected; passphrase-protected keys are unsupported`, "AUTH_REQUIRED", ["mg-axi profile show --profile <name>", "Provision an unencrypted PEM through the referenced environment variable or file"]);
117
+ if (!normalized.trim())
118
+ throw this.missing(reference);
119
+ return normalized;
120
+ }
121
+ // Fail-closed holder error: names only the env var name or file path.
122
+ missing(reference) {
123
+ return new AxiError(`Application credential unavailable for the configured reference ${reference}; no user or device-code fallback was attempted`, "AUTH_REQUIRED", ["mg-axi profile show --profile <name>", "Supply the referenced credential through the named environment variable or file"]);
41
124
  }
42
125
  }
package/dist/profiles.js CHANGED
@@ -11,6 +11,9 @@ function invalid(message) {
11
11
  throw new AxiError(message, "VALIDATION_ERROR", ["mg-axi profile create --help", "Correct the version-1 profile configuration; no automatic migration is performed"]);
12
12
  }
13
13
  const thumbprint = /^[0-9a-f]{40}$/i;
14
+ const envName = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+ const credentialPath = (value) => typeof value === "string" && !!value.trim();
16
+ const exactlyOne = (values) => values.filter(value => value !== undefined).length === 1;
14
17
  const sharedKeys = ["mode", "tenantId", "clientId", "cloud", "enabledPacks", "preview", "sensitiveAreas", "credentialRef", "allowDeviceCode", "writes"];
15
18
  const MAX_WRITE_OPERATIONS = 64;
16
19
  const MAX_WRITE_OPERATION_LENGTH = 256;
@@ -47,18 +50,35 @@ export function validateApplicationProfile(value) {
47
50
  const ref = profile.credentialRef;
48
51
  const writes = validateWrites(value.writes);
49
52
  const writeFields = writes === undefined ? {} : { writes };
50
- if (ref.provider === "certificate" && exact(ref, ["provider", "key", "thumbprint"]) && typeof ref.key === "string" && guid.test(ref.key) &&
51
- typeof ref.thumbprint === "string" && thumbprint.test(ref.thumbprint)) {
53
+ if (ref.provider === "certificate" && exact(ref, ["provider", "key", "thumbprint", "keyFile", "keyEnv"]) && typeof ref.key === "string" && guid.test(ref.key) &&
54
+ typeof ref.thumbprint === "string" && thumbprint.test(ref.thumbprint) && ref.keyFile === undefined && ref.keyEnv === undefined) {
52
55
  return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
53
56
  preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
54
57
  credentialRef: Object.freeze({ provider: "certificate", key: ref.key, thumbprint: ref.thumbprint.toLowerCase() }), ...writeFields });
55
58
  }
56
- if (ref.provider === "federated" && exact(ref, ["provider", "key"]) && typeof ref.key === "string" && guid.test(ref.key)) {
59
+ if (ref.provider === "certificate" && exact(ref, ["provider", "key", "thumbprint", "keyFile", "keyEnv"]) && typeof ref.key === "string" && guid.test(ref.key) &&
60
+ typeof ref.thumbprint === "string" && thumbprint.test(ref.thumbprint) && exactlyOne([ref.keyFile, ref.keyEnv]) &&
61
+ (ref.keyFile === undefined || credentialPath(ref.keyFile)) && (ref.keyEnv === undefined || (typeof ref.keyEnv === "string" && envName.test(ref.keyEnv)))) {
62
+ const keySource = ref.keyFile === undefined ? { keyEnv: ref.keyEnv } : { keyFile: ref.keyFile };
57
63
  return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
58
64
  preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
59
- credentialRef: Object.freeze({ provider: "federated", key: ref.key }), ...writeFields });
65
+ credentialRef: Object.freeze({ provider: "certificate", key: ref.key, thumbprint: ref.thumbprint.toLowerCase(), ...keySource }), ...writeFields });
60
66
  }
61
- invalid("Invalid application profile; credentialRef must be a certificate or federated reference, never inlined key material");
67
+ if (ref.provider === "client-secret" && exact(ref, ["provider", "key", "secretEnv", "secretFile"]) && typeof ref.key === "string" && guid.test(ref.key) &&
68
+ exactlyOne([ref.secretEnv, ref.secretFile]) &&
69
+ (ref.secretEnv === undefined || (typeof ref.secretEnv === "string" && envName.test(ref.secretEnv))) && (ref.secretFile === undefined || credentialPath(ref.secretFile))) {
70
+ const secretSource = ref.secretEnv === undefined ? { secretFile: ref.secretFile } : { secretEnv: ref.secretEnv };
71
+ return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
72
+ preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
73
+ credentialRef: Object.freeze({ provider: "client-secret", key: ref.key, ...secretSource }), ...writeFields });
74
+ }
75
+ if (ref.provider === "federated" && exact(ref, ["provider", "key", "tokenFileEnv"]) && typeof ref.key === "string" && guid.test(ref.key) &&
76
+ (ref.tokenFileEnv === undefined || (typeof ref.tokenFileEnv === "string" && envName.test(ref.tokenFileEnv)))) {
77
+ return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
78
+ preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
79
+ credentialRef: Object.freeze({ provider: "federated", key: ref.key, ...(ref.tokenFileEnv === undefined ? {} : { tokenFileEnv: ref.tokenFileEnv }) }), ...writeFields });
80
+ }
81
+ invalid("Invalid application profile; credentialRef must be a certificate, client-secret or federated reference, never inlined key material");
62
82
  }
63
83
  export function validateProfile(value) {
64
84
  if (!!value && typeof value === "object" && !Array.isArray(value) && value.mode === "application")
@@ -107,15 +127,41 @@ export class Profiles {
107
127
  invalid("Profile name must contain only letters, digits, underscores or hyphens");
108
128
  const certificate = application?.certificateThumbprint;
109
129
  const federated = application?.federated ?? false;
130
+ const secretEnv = application?.clientSecretEnv;
131
+ const secretFile = application?.clientSecretFile;
132
+ const keyFile = application?.certificateKeyFile;
133
+ const keyEnv = application?.certificateKeyEnv;
134
+ const tokenFileEnv = application?.federatedTokenFileEnv;
110
135
  if (application && (certificate !== undefined && (typeof certificate !== "string" || !/^[0-9a-f]{40}$/i.test(certificate))))
111
- invalid("Application certificate profiles name a 40-hex-digit thumbprint; the private key stays in protected storage");
112
- if (application && ((certificate === undefined) === !federated))
113
- invalid("Application profiles use exactly one credential: --certificate-thumbprint or --federated");
136
+ invalid("Application certificate profiles name a 40-hex-digit thumbprint; the private key stays in referenced storage");
137
+ for (const [label, value] of [["--client-secret-env", secretEnv], ["--certificate-key-env", keyEnv], ["--federated-token-file-env", tokenFileEnv]]) {
138
+ if (application && value !== undefined && (typeof value !== "string" || !envName.test(value)))
139
+ invalid(`Application profiles name the credential holder with ${label}; names match /^[A-Za-z_][A-Za-z0-9_]*$/`);
140
+ }
141
+ for (const [label, value] of [["--client-secret-file", secretFile], ["--certificate-key-file", keyFile]]) {
142
+ if (application && value !== undefined && !credentialPath(value))
143
+ invalid(`Application profiles name the credential holder with ${label}; paths must be nonempty`);
144
+ }
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");
114
155
  if (application && allowDeviceCode)
115
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 }) };
116
162
  const profile = application
117
163
  ? validateApplicationProfile({ mode: "application", tenantId, clientId, cloud, enabledPacks: ["entra"], preview: false, sensitiveAreas: [],
118
- credentialRef: certificate === undefined ? { provider: "federated", key: randomUUID() } : { provider: "certificate", key: randomUUID(), thumbprint: certificate }, allowDeviceCode: false })
164
+ credentialRef, allowDeviceCode: false })
119
165
  : validateDelegatedProfile({ mode: "delegated", tenantId, clientId, cloud, enabledPacks: ["entra"], preview: false, sensitiveAreas: [], credentialRef: { provider: "os-or-session", key: randomUUID() }, allowDeviceCode });
120
166
  const config = this.load();
121
167
  if (Object.hasOwn(config.profiles, name))
package/dist/setup.js CHANGED
@@ -15,7 +15,9 @@ const READ_FAMILY_PREFIXES = [
15
15
  ["entra user", "user"],
16
16
  ["entra registration", "registration"],
17
17
  ["entra group", "group"],
18
+ ["entra directory-role-template", "directory-role/PIM"],
18
19
  ["entra directory-role", "directory-role/PIM"],
20
+ ["entra scoped-role-membership", "directory-role/PIM"],
19
21
  ["entra role-assignment", "directory-role/PIM"],
20
22
  ["entra pim", "directory-role/PIM"],
21
23
  ["entra device", "device"],
@@ -49,6 +51,7 @@ const READ_FAMILY_PREFIXES = [
49
51
  ["entra tenant-information", "tenant information"],
50
52
  ["entra sign-in", "sign-in/directory-audit"],
51
53
  ["entra directory-audit", "sign-in/directory-audit"],
54
+ ["entra provisioning", "sign-in/directory-audit"],
52
55
  ["entra application", "application/service-principal and consent grants"],
53
56
  ["entra service-principal", "application/service-principal and consent grants"],
54
57
  ["entra risky-user", "risk"],
@@ -102,7 +105,7 @@ export function setupView(store) {
102
105
  "node dist/bin/mg-axi.js --version",
103
106
  ],
104
107
  guidance: [
105
- `Hand-edit ${store.path} or set MG_AXI_CONFIG to a separate configuration file; secrets stay in protected storage references, never in argv or config`,
108
+ `Hand-edit ${store.path} or set MG_AXI_CONFIG to a separate configuration file; a profile stores only a credential reference - an OS keychain entry, an environment variable name, or a file path - and never the secret value, never in argv or config`,
106
109
  "node dist/bin/mg-axi.js profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial",
107
110
  "node dist/bin/mg-axi.js login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
108
111
  "node dist/bin/mg-axi.js doctor",
@@ -112,7 +115,7 @@ export function setupView(store) {
112
115
  reads,
113
116
  writes,
114
117
  local: implemented.length - reads - writes,
115
- api: `Entra ${families.slice(0, -1).join(", ")} and ${families.at(-1)} reads plus reviewed raw api get, one gated group-membership write, one gated account enable/disable write, one gated session-revocation write and one gated risky-user dismissal write; every other operation is scheduled, blocked, deprecated or excluded`,
118
+ api: `Entra ${families.slice(0, -1).join(", ")} and ${families.at(-1)} reads plus reviewed raw api get, one gated group-membership write, one gated account enable/disable write, one gated session-revocation write and one gated risky-user dismissal write; every other v1.0 operation is deferred out of v1, and every other beta operation remains scheduled (never agreed for v1), blocked, deprecated, unavailable or excluded`,
116
119
  report: "docs/coverage.md",
117
120
  },
118
121
  integration: "Install skills/mg-axi/SKILL.md explicitly through your agent's skill installation mechanism; setup only shows guidance and installs no skills or hooks",