@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.
@@ -0,0 +1,65 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "mg-axi credential profile",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["mode", "tenantId", "clientId"],
7
+ "properties": {
8
+ "mode": { "enum": ["delegated", "application"] },
9
+ "tenantId": { "$ref": "#/$defs/uuid" },
10
+ "clientId": { "$ref": "#/$defs/uuid" },
11
+ "cloud": { "const": "commercial", "default": "commercial" },
12
+ "enabledPacks": { "type": "array", "items": { "const": "entra" }, "uniqueItems": true, "default": ["entra"] },
13
+ "preview": { "type": "boolean", "default": false },
14
+ "sensitiveAreas": { "type": "array", "maxItems": 0, "default": [] },
15
+ "allowDeviceCode": { "type": "boolean", "default": false },
16
+ "auth": { "enum": ["certificate", "client-secret", "federated"] },
17
+ "thumbprintSha256": { "type": "string", "pattern": "^[0-9a-fA-F]{64}$" },
18
+ "privateKey": { "$ref": "#/$defs/externalSecret", "x-secret": true },
19
+ "clientSecret": { "$ref": "#/$defs/externalSecret", "x-secret": true },
20
+ "token": { "$ref": "#/$defs/secret", "x-secret": true },
21
+ "writes": {
22
+ "type": "object", "additionalProperties": false, "required": ["allowWrites", "operations"],
23
+ "properties": {
24
+ "allowWrites": { "type": "boolean" },
25
+ "operations": { "type": "array", "minItems": 1, "maxItems": 64, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }
26
+ }
27
+ }
28
+ },
29
+ "oneOf": [
30
+ {
31
+ "properties": { "mode": { "const": "delegated" } },
32
+ "not": { "anyOf": [{ "required": ["auth"] }, { "required": ["privateKey"] }, { "required": ["clientSecret"] }, { "required": ["thumbprintSha256"] }] }
33
+ },
34
+ {
35
+ "required": ["auth", "thumbprintSha256"],
36
+ "properties": { "mode": { "const": "application" }, "auth": { "const": "certificate" }, "allowDeviceCode": { "const": false } },
37
+ "not": { "anyOf": [{ "required": ["token"] }, { "required": ["clientSecret"] }] }
38
+ },
39
+ {
40
+ "required": ["auth"],
41
+ "properties": { "mode": { "const": "application" }, "auth": { "const": "client-secret" }, "allowDeviceCode": { "const": false } },
42
+ "not": { "anyOf": [{ "required": ["token"] }, { "required": ["privateKey"] }, { "required": ["thumbprintSha256"] }] }
43
+ },
44
+ {
45
+ "required": ["auth"],
46
+ "properties": { "mode": { "const": "application" }, "auth": { "const": "federated" }, "token": { "$ref": "#/$defs/externalSecret" }, "allowDeviceCode": { "const": false } },
47
+ "not": { "anyOf": [{ "required": ["privateKey"] }, { "required": ["clientSecret"] }, { "required": ["thumbprintSha256"] }] }
48
+ }
49
+ ],
50
+ "$defs": {
51
+ "uuid": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" },
52
+ "externalSecret": {
53
+ "oneOf": [
54
+ { "type": "object", "additionalProperties": false, "required": ["env"], "properties": { "env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } } },
55
+ { "type": "object", "additionalProperties": false, "required": ["file"], "properties": { "file": { "type": "string", "pattern": "^(?:/|[A-Za-z]:[\\\\/])" } } }
56
+ ]
57
+ },
58
+ "secret": {
59
+ "oneOf": [
60
+ { "$ref": "#/$defs/externalSecret" },
61
+ { "type": "object", "additionalProperties": false, "required": ["stored"], "properties": { "stored": { "const": true } } }
62
+ ]
63
+ }
64
+ }
65
+ }
package/dist/api.js CHANGED
@@ -61,7 +61,7 @@ const AUTH_METHOD_FIELDS = ["id", "createdDateTime", "displayName", "phoneNumber
61
61
  const REGISTRATION_FIELDS = ["id", "userPrincipalName", "userDisplayName", "userType", "isAdmin", "isMfaRegistered", "isMfaCapable", "isPasswordlessCapable", "isSsprRegistered", "isSsprEnabled", "isSsprCapable", "userPreferredMethodForSecondaryAuthentication", "lastUpdatedDateTime"];
62
62
  const SIGNIN_FIELDS = ["id", "createdDateTime", "userId", "userPrincipalName", "userDisplayName", "appDisplayName", "appId", "ipAddress", "location", "status", "conditionalAccessStatus", "riskDetail", "riskLevelAggregated", "riskLevelDuringSignIn", "riskState", "resourceDisplayName", "resourceId", "clientAppUsed"];
63
63
  const AUDIT_FIELDS = ["id", "activityDateTime", "activityDisplayName", "category", "loggedByService", "operationType", "result", "resultReason", "correlationId", "initiatedBy", "targetResources"];
64
- const PROVISIONING_FIELDS = ["id", "activityDateTime", "tenantId", "jobId", "cycleId", "changeId", "action", "durationInMilliseconds", "sourceSystem", "sourceIdentity", "targetSystem", "targetIdentity", "provisioningStatusInfo", "provisioningSteps", "modifiedProperties", "servicePrincipal", "initiatedBy"];
64
+ const PROVISIONING_FIELDS = ["id", "activityDateTime", "tenantId", "jobId", "cycleId", "changeId", "provisioningAction", "durationInMilliseconds", "sourceSystem", "sourceIdentity", "targetSystem", "targetIdentity", "provisioningStatusInfo", "provisioningSteps", "modifiedProperties", "servicePrincipal", "initiatedBy"];
65
65
  const RISKY_USER_FIELDS = ["id", "userPrincipalName", "userDisplayName", "riskDetail", "riskLastUpdatedDateTime", "riskLevel", "riskState"];
66
66
  const RISK_DETECTION_FIELDS = ["id", "detectedDateTime", "activityDateTime", "userId", "userPrincipalName", "userDisplayName", "ipAddress", "location", "riskDetail", "riskLevel", "riskState", "riskEventType", "detectionTimingType", "lastUpdatedDateTime", "source"];
67
67
  const RISKY_SP_FIELDS = ["id", "displayName", "appId", "servicePrincipalType", "riskDetail", "riskLastUpdatedDateTime", "riskLevel", "riskState", "isEnabled", "isProcessing"];
@@ -117,7 +117,7 @@ export const REVIEWED_ROUTES = [
117
117
  access: "D/A Policy.Read.AuthenticationMethod. Delegated callers pass it as --scopes; a supported administrator role (Conditional Access Administrator, Security Administrator or Security Reader) is also required.",
118
118
  note: "Conditional Access needs P1.",
119
119
  sources: ["https://learn.microsoft.com/en-us/graph/api/authenticationstrengthroot-list-policies?view=graph-rest-1.0"] },
120
- { id: "v1.0:GET:/identity/conditionalAccess/authenticationStrength/policies/{authenticationStrengthPolicy-id}", kind: "single", query: SINGLE_QUERY, fields: KNOWN_STRENGTH_FIELDS,
120
+ { id: "v1.0:GET:/identity/conditionalAccess/authenticationStrength/policies/{authenticationStrengthPolicy-id}", kind: "single", query: [], fields: KNOWN_STRENGTH_FIELDS,
121
121
  access: "D/A Policy.Read.AuthenticationMethod. Delegated callers pass it as --scopes; a supported administrator role (Conditional Access Administrator, Security Administrator or Security Reader) is also required.",
122
122
  note: "Conditional Access needs P1.",
123
123
  sources: ["https://learn.microsoft.com/en-us/graph/api/authenticationstrengthpolicy-get?view=graph-rest-1.0"] },
@@ -145,7 +145,7 @@ export const REVIEWED_ROUTES = [
145
145
  access: "D/A Policy.Read.All. Delegated callers pass it as --scopes; a supported administrator role (for example Security Reader) is also required.",
146
146
  note: "Conditional Access needs P1.",
147
147
  sources: ["https://learn.microsoft.com/en-us/graph/api/conditionalaccesstemplate-get?view=graph-rest-1.0"] },
148
- { id: "v1.0:GET:/identity/conditionalAccess/authenticationContextClassReferences", kind: "collection", query: COLLECTION_QUERY, fields: KNOWN_AUTH_CONTEXT_FIELDS,
148
+ { id: "v1.0:GET:/identity/conditionalAccess/authenticationContextClassReferences", kind: "collection", query: ["$filter", "$orderby", "$top"], fields: KNOWN_AUTH_CONTEXT_FIELDS,
149
149
  access: "D/A AuthenticationContext.Read.All. Delegated callers pass it as --scopes; a supported administrator role (Conditional Access Administrator, Global Reader, Global Secure Access Administrator, Security Administrator or Security Reader) is also required.",
150
150
  note: "Conditional Access needs P1.",
151
151
  sources: ["https://learn.microsoft.com/graph/api/conditionalaccessroot-list-authenticationcontextclassreferences?view=graph-rest-1.0"] },
@@ -187,7 +187,7 @@ export const REVIEWED_ROUTES = [
187
187
  { id: "v1.0:GET:/auditLogs/directoryAudits", kind: "collection", query: COLLECTION_QUERY, fields: AUDIT_FIELDS,
188
188
  access: "D/A AuditLog.Read.All. Delegated callers pass it as --scopes.",
189
189
  sources: ["https://learn.microsoft.com/graph/api/directoryaudit-list?view=graph-rest-1.0"] },
190
- { id: "v1.0:GET:/auditLogs/provisioning", kind: "collection", query: COLLECTION_QUERY, fields: PROVISIONING_FIELDS,
190
+ { id: "v1.0:GET:/auditLogs/provisioning", kind: "collection", query: ["$filter", "$orderby", "$top"], fields: PROVISIONING_FIELDS,
191
191
  access: "D/A AuditLog.Read.All and Directory.Read.All. Delegated callers pass both as --scopes; Reports Reader is the least privileged directory role.",
192
192
  note: "Entra ID P1/P2 licence prerequisite; retention is 30 days on premium and 7 days on free.",
193
193
  sources: ["https://learn.microsoft.com/graph/api/provisioningobjectsummary-list?view=graph-rest-1.0"] },
@@ -313,14 +313,6 @@ export const REVIEWED_ROUTES = [
313
313
  access: "D/A RoleManagement.Read.Directory. Delegated callers pass it as --scopes.",
314
314
  note: "Administrative-unit scopes need P1.",
315
315
  sources: ["https://learn.microsoft.com/graph/api/scopedrolemembership-get?view=graph-rest-1.0"] },
316
- { id: "v1.0:GET:/scopedRoleMemberships", kind: "collection", query: COLLECTION_QUERY, fields: KNOWN_SCOPED_MEMBER_FIELDS,
317
- access: "D/A RoleManagement.Read.Directory. Delegated callers pass it as --scopes.",
318
- note: "Administrative-unit-scoped admin assignments; directory-wide assignments live on roleAssignments. Administrative-unit scopes need P1.",
319
- sources: ["https://learn.microsoft.com/graph/api/scopedrolemembership-list?view=graph-rest-1.0"] },
320
- { id: "v1.0:GET:/scopedRoleMemberships/{scopedRoleMembership-id}", kind: "single", query: SINGLE_QUERY, fields: KNOWN_SCOPED_MEMBER_FIELDS,
321
- access: "D/A RoleManagement.Read.Directory. Delegated callers pass it as --scopes.",
322
- note: "Administrative-unit scopes need P1.",
323
- sources: ["https://learn.microsoft.com/graph/api/scopedrolemembership-get?view=graph-rest-1.0"] },
324
316
  { id: "v1.0:GET:/identityGovernance/privilegedAccess/group/assignmentSchedules", kind: "collection", query: COLLECTION_QUERY, fields: KNOWN_ASSIGNMENT_SCHEDULE_FIELDS,
325
317
  access: "D/A PrivilegedAssignmentSchedule.Read.AzureADGroup. Delegated callers pass it as --scopes.",
326
318
  note: "Assignment schedules govern group membership or ownership over time; PIM for Groups needs P2 or ID Governance; lists require $filter on groupId or principalId. AssignmentScheduleRequests stay out: their least privilege is the write scope PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup.",
@@ -415,10 +407,6 @@ export const REVIEWED_ROUTES = [
415
407
  access: "D/A Domain.Read.All. Delegated callers pass it as --scopes; Domain Name Administrator or Global Reader are the least-privileged delegated roles. No P1/P2 prerequisite is stated for DNS record reads.",
416
408
  note: "No operation-level documentation page; access follows the parent service-configuration contract and the domainDnsRecord resource reference.",
417
409
  sources: ["https://learn.microsoft.com/en-us/graph/api/domain-list-serviceconfigurationrecords?view=graph-rest-1.0", "https://learn.microsoft.com/en-us/graph/api/resources/domaindnsrecord?view=graph-rest-1.0"] },
418
- { id: "v1.0:GET:/domainDnsRecords", kind: "collection", query: COLLECTION_QUERY, fields: DNS_RECORD_FIELDS,
419
- access: "D/A Domain.Read.All. Delegated callers pass it as --scopes; Domain Name Administrator or Global Reader are the least-privileged delegated roles. No P1/P2 prerequisite is stated for DNS record reads.",
420
- note: "No operation-level documentation page; access follows the documented domain/DNS-read contract and the domainDnsRecord resource reference.",
421
- sources: ["https://learn.microsoft.com/en-us/graph/api/domain-list?view=graph-rest-1.0", "https://learn.microsoft.com/en-us/graph/api/resources/domaindnsrecord?view=graph-rest-1.0"] },
422
410
  { id: "v1.0:GET:/domainDnsRecords/{domainDnsRecord-id}", kind: "single", query: SINGLE_QUERY, fields: DNS_RECORD_FIELDS,
423
411
  access: "D/A Domain.Read.All. Delegated callers pass it as --scopes; Domain Name Administrator or Global Reader are the least-privileged delegated roles. No P1/P2 prerequisite is stated for DNS record reads.",
424
412
  note: "No operation-level documentation page; access follows the documented domain/DNS-read contract and the domainDnsRecord resource reference.",
@@ -489,10 +477,6 @@ export const REVIEWED_ROUTES = [
489
477
  delegatedOnly: true,
490
478
  note: "No single-acceptance operation page; access follows the parent acceptance-list contract and the agreementAcceptance resource reference. Acceptance records are personal data: default rows carry id, agreementId, state and recordedDateTime only.",
491
479
  sources: ["https://learn.microsoft.com/en-us/graph/api/agreement-list-acceptances?view=graph-rest-1.0", "https://learn.microsoft.com/en-us/graph/api/resources/agreementacceptance?view=graph-rest-1.0"] },
492
- { id: "v1.0:GET:/directoryObjects", kind: "collection", query: ["$select"], fields: KNOWN_DIRECTORY_OBJECT_FIELDS, defaultFields: ["id"], keepODataType: true,
493
- access: "D/A Directory.Read.All. Delegated callers pass it as --scopes; no delegated role prerequisite is stated for directory-object reads. Personal Microsoft accounts are not supported.",
494
- note: "No List operation page exists; access follows the sibling directoryobject-get contract and the directoryObject resource reference. Graph documents no collection filter contract here, so only $select is reviewed. Rows are polymorphic: the @odata.type discriminator rides along automatically and subtype properties need the subtype's named reads, never raw $select of unreviewed fields.",
495
- sources: ["https://learn.microsoft.com/en-us/graph/api/directoryobject-get?view=graph-rest-1.0", "https://learn.microsoft.com/en-us/graph/api/resources/directoryobject?view=graph-rest-1.0"] },
496
480
  { id: "v1.0:GET:/directoryObjects/{directoryObject-id}", kind: "single", query: SINGLE_QUERY, fields: KNOWN_DIRECTORY_OBJECT_FIELDS, defaultFields: ["id", "deletedDateTime"], keepODataType: true,
497
481
  access: "D/A Directory.Read.All. Delegated callers pass it as --scopes; no delegated role prerequisite is stated for this read. Personal Microsoft accounts are not supported.",
498
482
  note: "Single reads return the base-type properties plus the @odata.type discriminator; subtype detail needs the subtype's named reads. Only base properties are projected, so subtype secrets or credentials can never appear.",
@@ -605,7 +589,7 @@ export const REVIEWED_ROUTES = [
605
589
  access: "D/A OrgContact.Read.All and Group.Read.All together least-privileged (Directory.Read.All is the documented higher-privileged alternative). Delegated callers pass both as --scopes; delegated access additionally needs a supported Entra role (Directory Readers reads basic properties; Global Reader, Directory Writers, Intune Administrator or User Administrator also work). Personal Microsoft accounts are not supported.",
606
590
  note: "No operation-level documentation page; OData cast selects the administrativeUnit subtype and access follows the parent transitiveMemberOf contract and the administrativeUnit resource reference.",
607
591
  sources: ["https://learn.microsoft.com/en-us/graph/api/orgcontact-list-transitivememberof?view=graph-rest-1.0", "https://learn.microsoft.com/en-us/graph/api/resources/administrativeunit?view=graph-rest-1.0"] },
608
- { id: "v1.0:GET:/groupLifecyclePolicies", kind: "collection", query: COLLECTION_QUERY, fields: KNOWN_LIFECYCLE_FIELDS,
592
+ { id: "v1.0:GET:/groupLifecyclePolicies", kind: "collection", query: ["$filter", "$orderby", "$top"], fields: KNOWN_LIFECYCLE_FIELDS,
609
593
  access: "D/A Directory.Read.All. Delegated callers pass it as --scopes; no delegated role prerequisite is stated for lifecycle-policy reads. Personal Microsoft accounts are not supported.",
610
594
  note: "Filtering uses plain $filter with no $count or ConsistencyLevel contract; no P1/P2 prerequisite is stated for lifecycle-policy reads.",
611
595
  sources: ["https://learn.microsoft.com/graph/api/grouplifecyclepolicy-list?view=graph-rest-1.0", "https://learn.microsoft.com/graph/api/resources/grouplifecyclepolicy?view=graph-rest-1.0"] },
package/dist/app-auth.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
+ import { PrivateFiles } from "./private-file.js";
2
3
  import { validateApplicationProfile } from "./profiles.js";
4
+ import { registerSecret } from "./redact.js";
3
5
  // The configured Graph audience is the app's consented application grants, never a per-command subset.
4
6
  export const graphAudience = (profile) => `https://graph.microsoft.com/.default`;
5
7
  // This service is the application credential boundary for CORE-01; tokens never reach CLI output.
@@ -7,13 +9,15 @@ export const graphAudience = (profile) => `https://graph.microsoft.com/.default`
7
9
  export class ApplicationAuth {
8
10
  provider;
9
11
  now;
12
+ files;
10
13
  cache = new Map();
11
- constructor(provider, now = Date.now) {
14
+ constructor(provider, now = Date.now, files = new PrivateFiles()) {
12
15
  this.provider = provider;
13
16
  this.now = now;
17
+ this.files = files;
14
18
  }
15
19
  async credential(profile, scopes = [graphAudience(profile)]) {
16
- profile = validateApplicationProfile(profile);
20
+ profile = validateApplicationProfile(profile, this.files);
17
21
  const requested = this.audience(profile, scopes);
18
22
  const key = this.key(profile, requested);
19
23
  const hit = this.cache.get(key);
@@ -30,7 +34,7 @@ export class ApplicationAuth {
30
34
  catch (error) {
31
35
  // Provider AUTH_REQUIRED failures already name only the credential
32
36
  // reference, never its value; everything else stays fully generic.
33
- if (error instanceof AxiError && error.code === "AUTH_REQUIRED")
37
+ if (error instanceof AxiError && ["AUTH_REQUIRED", "CONFIG_INVALID", "CONFIG_INSECURE"].includes(error.code))
34
38
  throw error;
35
39
  throw this.failure();
36
40
  }
@@ -41,10 +45,11 @@ export class ApplicationAuth {
41
45
  throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", ["mg-axi profile show --profile <name>", "Application permissions apply to the app registration, not to individual commands"]);
42
46
  return configured;
43
47
  }
44
- key(profile, scopes) { return JSON.stringify([profile.credentialRef, profile.tenantId, profile.clientId, profile.cloud, scopes]); }
48
+ key(profile, scopes) { return JSON.stringify([profile.auth, profile.auth === "certificate" ? [profile.privateKey, profile.thumbprintSha256] : profile.auth === "client-secret" ? profile.clientSecret : profile.token, profile.tenantId, profile.clientId, profile.cloud, scopes]); }
45
49
  check(profile, credential) {
46
50
  if (!credential.token || credential.tenantId.toLowerCase() !== profile.tenantId.toLowerCase() || credential.clientId.toLowerCase() !== profile.clientId.toLowerCase() || !Number.isFinite(credential.expiresAt) || credential.expiresAt <= this.now() + 60_000)
47
51
  throw new Error("Invalid credential context");
52
+ registerSecret(credential.token);
48
53
  return { ...credential };
49
54
  }
50
55
  failure() { return new AxiError("Application authentication failed for the configured identity; no user or device-code fallback was attempted", "AUTH_REQUIRED", ["mg-axi profile show --profile <name>", "Ask an administrator to grant application consent for the Graph .default audience on this app registration", "Confirm the certificate thumbprint or federated credential mapping on the app registration"]); }
@@ -0,0 +1,20 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { Credentials } from "./credentials.js";
3
+ import { resolveSecret, secretSource } from "./secrets.js";
4
+ export function authStatus(name, profile, credentials = new Credentials()) {
5
+ const fields = profile.mode === "delegated" ? { token: profile.token }
6
+ : profile.auth === "certificate" ? { privateKey: profile.privateKey }
7
+ : profile.auth === "client-secret" ? { clientSecret: profile.clientSecret } : { token: profile.token };
8
+ const sources = Object.entries(fields).map(([field, ref]) => {
9
+ let available = false;
10
+ try {
11
+ available = "stored" in ref && profile.mode === "delegated" ? credentials.read(profile, true) !== undefined : !!resolveSecret(ref, credentials.files, true);
12
+ }
13
+ catch (error) {
14
+ if (!(error instanceof AxiError) || error.code !== "AUTH_REQUIRED")
15
+ throw error;
16
+ }
17
+ return { field, source: "stored" in ref ? `stored:${credentials.path}` : secretSource(ref), available };
18
+ });
19
+ return { profile: name, mode: profile.mode, endpoint: "https://graph.microsoft.com", tenantId: profile.tenantId, clientId: profile.clientId, sources };
20
+ }
package/dist/auth.js CHANGED
@@ -1,18 +1,22 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
- import { validateDelegatedProfile } from "./profiles.js";
2
+ import { PrivateFiles } from "./private-file.js";
3
+ import { profileName, validateDelegatedProfile } from "./profiles.js";
4
+ import { registerSecret } from "./redact.js";
3
5
  // This service is the credential boundary for CORE-01; tokens never reach CLI output.
4
6
  export class DelegatedAuth {
5
7
  provider;
6
8
  now;
9
+ files;
7
10
  cache = new Map();
8
- constructor(provider, now = Date.now) {
11
+ constructor(provider, now = Date.now, files = new PrivateFiles()) {
9
12
  this.provider = provider;
10
13
  this.now = now;
14
+ this.files = files;
11
15
  }
12
16
  async login(profile, method = "browser", scopes = []) {
13
- profile = validateDelegatedProfile(profile);
17
+ profile = validateDelegatedProfile(profile, this.files);
14
18
  if (method !== "browser" && method !== "device-code")
15
- throw new AxiError("Login method must be browser or device-code", "VALIDATION_ERROR", ["mg-axi login --help"]);
19
+ throw new AxiError("Login method must be browser or device-code", "VALIDATION_ERROR", ["mg-axi auth login --help"]);
16
20
  if (method === "device-code" && !profile.allowDeviceCode)
17
21
  throw new AxiError("Device code is disabled for this profile", "VALIDATION_ERROR", ["Enable device code only when organization policy permits it; browser login is the default"]);
18
22
  const requested = this.scopes(scopes);
@@ -23,12 +27,14 @@ export class DelegatedAuth {
23
27
  this.cache.set(key, credential);
24
28
  return { status: "authenticated", mode: "delegated", storage: this.provider.storage };
25
29
  }
26
- catch {
30
+ catch (error) {
31
+ if (error instanceof AxiError && ["CONFIG_INVALID", "CONFIG_INSECURE"].includes(error.code))
32
+ throw error;
27
33
  throw this.failure("LOGIN_FAILED");
28
34
  }
29
35
  }
30
36
  async credential(profile, scopes) {
31
- profile = validateDelegatedProfile(profile);
37
+ profile = validateDelegatedProfile(profile, this.files);
32
38
  const requested = this.scopes(scopes);
33
39
  const key = this.key(profile, requested);
34
40
  const hit = this.cache.get(key);
@@ -40,20 +46,28 @@ export class DelegatedAuth {
40
46
  this.cache.set(key, credential);
41
47
  return { ...credential };
42
48
  }
43
- catch {
49
+ catch (error) {
50
+ if (error instanceof AxiError && ["CONFIG_INVALID", "CONFIG_INSECURE"].includes(error.code))
51
+ throw error;
44
52
  throw this.failure("AUTH_REQUIRED");
45
53
  }
46
54
  }
55
+ async logout(profile) {
56
+ this.cache.clear();
57
+ await this.provider.logout(validateDelegatedProfile(profile, this.files));
58
+ return { status: "signed out", revocation: "MSAL provides no token revocation API; local credentials deleted" };
59
+ }
47
60
  scopes(scopes) {
48
61
  if (!scopes.length || scopes.some(scope => !/^https:\/\/graph\.microsoft\.com\/[A-Za-z][A-Za-z.-]+$/.test(scope) || scope.endsWith("/.default")))
49
- throw new AxiError("Explicit delegated Graph scopes are required", "VALIDATION_ERROR", ["mg-axi login --profile <name> --scopes <comma-separated-Graph-scopes>"]);
62
+ throw new AxiError("Explicit delegated Graph scopes are required", "VALIDATION_ERROR", ["mg-axi auth login --profile <name> --scopes <comma-separated-Graph-scopes>"]);
50
63
  return [...new Set(scopes)].sort();
51
64
  }
52
- key(profile, scopes) { return JSON.stringify([profile.credentialRef, profile.tenantId, profile.clientId, profile.cloud, scopes]); }
65
+ key(profile, scopes) { return JSON.stringify([profileName(profile), profile.token, profile.tenantId, profile.clientId, profile.cloud, scopes]); }
53
66
  check(profile, credential) {
54
67
  if (!credential.token || !credential.accountId || credential.tenantId.toLowerCase() !== profile.tenantId.toLowerCase() || credential.clientId.toLowerCase() !== profile.clientId.toLowerCase() || !Number.isFinite(credential.expiresAt) || credential.expiresAt <= this.now() + 60_000)
55
68
  throw new Error("Invalid credential context");
69
+ registerSecret(credential.token);
56
70
  return { ...credential };
57
71
  }
58
- failure(code) { return new AxiError("Authentication unavailable for the configured identity; no interactive fallback was attempted", code, ["mg-axi login --profile <name> --scopes <comma-separated-Graph-scopes>", "Check dedicated app consent and organization sign-in policy", "Restore OS credential store access before retrying login; prior stored accounts must be invalidated before replacement"]); }
72
+ failure(code) { return new AxiError("Authentication unavailable for the configured identity; no interactive fallback was attempted", code, ["mg-axi auth login --profile <name> --scopes <comma-separated-Graph-scopes>", "Check dedicated app consent and organization sign-in policy", "Restore access to the protected credentials.json before retrying login"]); }
59
73
  }
package/dist/catalogue.js CHANGED
@@ -506,23 +506,23 @@ export const LEAVES = [
506
506
  cloud: { value: "commercial", required: true, description: "Explicit cloud; only commercial is supported" },
507
507
  mode: { value: "delegated|application", default: "delegated", description: "Delegated analyst sign-in or application client credentials" },
508
508
  "allow-device-code": { description: "Delegated only: opt in only when permitted by organization policy; never enables fallback" },
509
- "certificate-thumbprint": { value: "40-hex-digits", description: "Application only: certificate thumbprint; the private key stays in referenced storage" },
510
- "certificate-key-file": { value: "path", description: "Application only: certificate PEM private key from this 0600 file; omit both key flags for the OS keychain" },
511
- "certificate-key-env": { value: "env-var-name", description: "Application only: certificate PEM private key from this environment variable; omit both key flags for the OS keychain" },
509
+ "certificate-thumbprint-sha256": { value: "64-hex-digits", description: "Application only: SHA-256 certificate thumbprint" },
510
+ "certificate-key-file": { value: "absolute-path", description: "Application only: certificate PEM private key from this protected file" },
511
+ "certificate-key-env": { value: "env-var-name", description: "Application only: certificate PEM private key from this environment variable; defaults to MG_AXI_PRIVATE_KEY" },
512
512
  "client-secret-env": { value: "env-var-name", description: "Application only: client secret from this environment variable; exactly one of this or --client-secret-file" },
513
- "client-secret-file": { value: "path", description: "Application only: client secret from this 0600 file; exactly one of this or --client-secret-env" },
514
- federated: { description: "Application only: workload federation via the assertion file named by --federated-token-file-env" },
515
- "federated-token-file-env": { value: "env-var-name", description: "Application only: environment variable naming the workload assertion file; defaults to AZURE_FEDERATED_TOKEN_FILE" },
516
- }, examples: ["mg-axi profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial", "mg-axi profile create --name batch --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --federated", "mg-axi profile create --name daemon --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --certificate-thumbprint <40-hex-digit-thumbprint> --certificate-key-file /run/secrets/mg-axi-key.pem", "mg-axi profile create --name svc --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --client-secret-env MG_AXI_CLIENT_SECRET", "mg-axi profile create --help"] },
513
+ "client-secret-file": { value: "absolute-path", description: "Application only: client secret from this protected file; exactly one of this or --client-secret-env" },
514
+ federated: { description: "Application only: workload federation; token defaults to env MG_AXI_TOKEN; AZURE_FEDERATED_TOKEN_FILE stays accepted as a deprecated alias" },
515
+ "federated-token-file": { value: "absolute-path", description: "Application only: workload assertion content from this protected file" },
516
+ }, examples: ["mg-axi profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial", "mg-axi profile create --name batch --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --federated", "mg-axi profile create --name daemon --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --certificate-thumbprint-sha256 <64-hex-digit-thumbprint> --certificate-key-file /run/secrets/mg-axi-key.pem", "mg-axi profile create --name svc --tenant <tenant-id> --client <client-id> --cloud commercial --mode application --client-secret-env MG_AXI_CLIENT_SECRET", "mg-axi profile create --help"] },
517
517
  { path: "profile list", description: "List configured profiles without accessing credentials", flags: {}, examples: ["mg-axi profile list", "mg-axi profile list --help"] },
518
518
  { path: "profile show", description: "Show profile policy and credential reference, never credentials", flags: {
519
519
  profile: common.profile,
520
520
  }, examples: ["mg-axi profile show --profile soc", "mg-axi profile show"] },
521
- { path: "login", description: "Explicit delegated login; browser by default, no automatic device-code fallback", flags: {
521
+ { path: "auth login", description: "Explicit delegated login; browser by default, no automatic device-code fallback", flags: {
522
522
  profile: common.profile,
523
523
  method: { value: "browser|device-code", default: "browser", description: "Device code additionally requires profile opt-in" },
524
524
  scopes: { value: "comma-separated-Graph-scopes", required: true, description: "Explicit delegated permissions using full https://graph.microsoft.com/ scope names" },
525
- }, examples: ["mg-axi login --profile soc --scopes https://graph.microsoft.com/User.Read", "mg-axi login --profile soc --method device-code --scopes https://graph.microsoft.com/User.Read"] },
525
+ }, examples: ["mg-axi auth login --profile soc --scopes https://graph.microsoft.com/User.Read", "mg-axi auth login --profile soc --method device-code --scopes https://graph.microsoft.com/User.Read"] },
526
526
  { path: "setup", description: "Show installation, configuration and capability guidance without signing in or writing anything", flags: {}, examples: ["mg-axi setup", "mg-axi setup --help"] },
527
527
  { path: "setup hooks", description: "Install or repair agent SessionStart hooks for mg-axi ambient context", flags: {}, examples: ["mg-axi setup hooks", "mg-axi setup hooks --help"] },
528
528
  { path: "doctor", description: "Check each selected profile with one bounded user-list read; never signs in interactively, installs nothing, enables no writes", flags: {
@@ -677,7 +677,7 @@ export const LEAVES = [
677
677
  cursor: roleRead.cursor,
678
678
  scopes: { value: "comma-separated-Graph-scopes", description: "Delegated only: explicit Graph scopes using full https://graph.microsoft.com/ names; defaults to RoleManagement.Read.Directory; delegated callers also need a supported directory role" },
679
679
  }, examples: ["mg-axi entra directory-role member list --role <role-id> --profile soc", "mg-axi entra directory-role member list --role <role-id> --profile soc --filter \"displayName eq 'Adele Vance'\""] },
680
- { path: "entra directory-role scoped-member list", description: "List the scoped members of one activated directory role (id, principalId, roleId, directoryScopeId); administrative-unit scopes need P1", operation: "GET:/directoryRoles/{directoryRole-id}/scopedMembers", flags: {
680
+ { path: "entra directory-role scoped-member list", description: "List the scoped members of one activated directory role (id, roleMemberInfo, roleId, administrativeUnitId); administrative-unit scopes need P1", operation: "GET:/directoryRoles/{directoryRole-id}/scopedMembers", flags: {
681
681
  ...common, role: { value: "role-id", required: true, description: "Activated directory-role object ID whose scoped members are listed" },
682
682
  limit: { value: "positive-integer", default: "100", description: "Cap returned rows; the remainder is buffered into an opaque cursor, never discarded; incompatible with --all" },
683
683
  all: { description: "Follow pages within request, byte and deadline budgets" },
@@ -687,7 +687,7 @@ export const LEAVES = [
687
687
  full: roleRead.full,
688
688
  cursor: roleRead.cursor,
689
689
  scopes: { value: "comma-separated-Graph-scopes", description: "Delegated only: explicit Graph scopes using full https://graph.microsoft.com/ names; defaults to RoleManagement.Read.Directory; delegated callers also need a supported directory role; administrative-unit scopes need P1" },
690
- }, examples: ["mg-axi entra directory-role scoped-member list --role <role-id> --profile soc", "mg-axi entra directory-role scoped-member list --role <role-id> --profile soc --filter \"principalId eq '<principal-id>'\""] },
690
+ }, examples: ["mg-axi entra directory-role scoped-member list --role <role-id> --profile soc", "mg-axi entra directory-role scoped-member list --role <role-id> --profile soc --filter \"administrativeUnitId eq '<unit-id>'\""] },
691
691
  { path: "entra directory-role scoped-member show", description: "Show one scoped member of an activated directory role with richer default properties", operation: "GET:/directoryRoles/{directoryRole-id}/scopedMembers/{scopedRoleMembership-id}", flags: {
692
692
  ...common, role: { value: "role-id", required: true, description: "Activated directory-role object ID owning the scoped member" },
693
693
  id: { value: "scoped-membership-id", required: true, description: "Scoped membership object ID" },
@@ -696,7 +696,7 @@ export const LEAVES = [
696
696
  full: roleRead.full,
697
697
  scopes: { value: "comma-separated-Graph-scopes", description: "Delegated only: explicit Graph scopes using full https://graph.microsoft.com/ names; defaults to RoleManagement.Read.Directory; delegated callers also need a supported directory role; administrative-unit scopes need P1" },
698
698
  }, examples: ["mg-axi entra directory-role scoped-member show --role <role-id> --id <scoped-membership-id> --profile soc", "mg-axi entra directory-role scoped-member show --role <role-id> --id <scoped-membership-id> --profile soc --full"] },
699
- { path: "entra scoped-role-membership list", description: "List administrative-unit-scoped role memberships (id, principalId, roleId, directoryScopeId); directory-wide assignments live on roleAssignments", operation: "GET:/scopedRoleMemberships", flags: {
699
+ { path: "entra scoped-role-membership list", description: "Unsupported by Graph v1.0; use directory-role scoped-member list --role <role-id>", operation: "GET:/scopedRoleMemberships", flags: {
700
700
  ...common,
701
701
  limit: { value: "positive-integer", default: "100", description: "Cap returned rows; the remainder is buffered into an opaque cursor, never discarded; incompatible with --all" },
702
702
  all: { description: "Follow pages within request, byte and deadline budgets" },
@@ -707,7 +707,7 @@ export const LEAVES = [
707
707
  cursor: roleRead.cursor,
708
708
  scopes: { value: "comma-separated-Graph-scopes", description: "Delegated only: explicit Graph scopes using full https://graph.microsoft.com/ names; defaults to RoleManagement.Read.Directory; delegated callers also need a supported directory role; administrative-unit scopes need P1" },
709
709
  }, examples: ["mg-axi entra scoped-role-membership list --profile soc", "mg-axi entra scoped-role-membership list --profile soc --filter \"roleId eq '<role-id>'\""] },
710
- { path: "entra scoped-role-membership show", description: "Show one administrative-unit-scoped role membership with richer default properties", operation: "GET:/scopedRoleMemberships/{scopedRoleMembership-id}", flags: {
710
+ { path: "entra scoped-role-membership show", description: "Unsupported by Graph v1.0; use directory-role scoped-member show --role <role-id> --id <scoped-membership-id>", operation: "GET:/scopedRoleMemberships/{scopedRoleMembership-id}", flags: {
711
711
  ...common, id: { value: "scoped-membership-id", required: true, description: "Scoped membership object ID" },
712
712
  select: roleRead.select,
713
713
  fields: roleRead.fields,
@@ -981,7 +981,7 @@ export const LEAVES = [
981
981
  full: dnsRead.full,
982
982
  scopes: dnsRead.scopes,
983
983
  }, examples: ["mg-axi entra domain service-configuration-record show --domain contoso.com --id <record-id> --profile soc"] },
984
- { path: "entra domain-dns-record list", description: "List domain DNS records from the top-level record set; rows carry @odata.type naming the derived record kind", operation: "GET:/domainDnsRecords", flags: {
984
+ { path: "entra domain-dns-record list", description: "Unsupported by Graph v1.0; use domain verification-dns-record list or domain service-configuration-record list with --domain <domain-id>", operation: "GET:/domainDnsRecords", flags: {
985
985
  ...common,
986
986
  limit: { value: "positive-integer", default: "100", description: "Cap returned rows; the remainder is buffered into an opaque cursor, never discarded; incompatible with --all" },
987
987
  all: { description: "Follow pages within request, byte and deadline budgets" },
@@ -1145,7 +1145,7 @@ export const LEAVES = [
1145
1145
  full: acceptanceRead.full,
1146
1146
  scopes: acceptanceRead.scopes,
1147
1147
  }, examples: ["mg-axi entra agreement-acceptance show --id <acceptance-id> --profile soc"] },
1148
- { path: "entra directory-object list", description: "List directory objects with compact rows (id plus the @odata.type subtype discriminator); Graph documents no List operation page, so no --filter", operation: "GET:/directoryObjects", flags: {
1148
+ { path: "entra directory-object list", description: "Unsupported by Graph v1.0; use directory-object show --id <object-id> or a named user, group, device, application or service-principal list", operation: "GET:/directoryObjects", flags: {
1149
1149
  ...common,
1150
1150
  limit: { value: "positive-integer", default: "100", description: "Cap returned rows; the remainder is buffered into an opaque cursor, never discarded; incompatible with --all" },
1151
1151
  all: { description: "Follow pages within request, byte and deadline budgets" },
@@ -1254,7 +1254,7 @@ export const LEAVES = [
1254
1254
  limit: { value: "positive-integer", default: "100", description: "Cap returned rows; the remainder is buffered into an opaque cursor, never discarded; incompatible with --all" },
1255
1255
  all: { description: "Follow pages within request, byte and deadline budgets" },
1256
1256
  filter: lifecycleRead.filter,
1257
- select: lifecycleRead.select,
1257
+ select: { value: "comma-separated-properties", description: "Select output properties locally; no $select is sent to Graph" },
1258
1258
  fields: lifecycleRead.fields,
1259
1259
  full: lifecycleRead.full,
1260
1260
  cursor: lifecycleRead.cursor,
@@ -1599,7 +1599,7 @@ export const LEAVES = [
1599
1599
  since: { ...logRead.since, description: "Required for a new query (resume with --cursor instead): earliest activityDateTime bounding the server time range" },
1600
1600
  until: logRead.until,
1601
1601
  filter: logRead.filter,
1602
- select: { value: "comma-separated-properties", description: "Request server properties from the supported provisioning-log property set" },
1602
+ select: { value: "comma-separated-properties", description: "Select output properties locally from the provisioning-log set, including provisioningAction; no $select is sent to Graph" },
1603
1603
  fields: logRead.fields,
1604
1604
  full: logRead.full,
1605
1605
  cursor: logRead.cursor,
@@ -1921,7 +1921,7 @@ export const LEAVES = [
1921
1921
  }, examples: ["mg-axi entra conditional-access auth-strength-policy list --profile soc", "mg-axi entra conditional-access auth-strength-policy list --profile soc --limit 10", "mg-axi entra conditional-access auth-strength-policy list --profile soc --filter \"policyType eq 'builtIn'\" --select id,displayName,policyType"] },
1922
1922
  { path: "entra conditional-access auth-strength-policy show", description: "Show one authentication-strength policy with the full reviewed property set", operation: "GET:/identity/conditionalAccess/authenticationStrength/policies/{authenticationStrengthPolicy-id}", flags: {
1923
1923
  ...common, id: { value: "auth-strength-policy-id", required: true, description: "Authentication-strength policy object ID" },
1924
- select: caStrengthRead.select,
1924
+ select: { value: "comma-separated-properties", description: "Select output properties locally; no $select is sent to Graph" },
1925
1925
  fields: caStrengthRead.fields,
1926
1926
  full: caStrengthRead.full,
1927
1927
  scopes: caStrengthRead.scopes,
@@ -1987,7 +1987,7 @@ export const LEAVES = [
1987
1987
  limit: { value: "positive-integer", default: "100", description: "Cap returned rows; the remainder is buffered into an opaque cursor, never discarded; incompatible with --all" },
1988
1988
  all: { description: "Follow pages within request, byte and deadline budgets" },
1989
1989
  filter: caAuthContextRead.filter,
1990
- select: caAuthContextRead.select,
1990
+ select: { value: "comma-separated-properties", description: "Select output properties locally; no $select is sent to Graph" },
1991
1991
  fields: caAuthContextRead.fields,
1992
1992
  full: caAuthContextRead.full,
1993
1993
  cursor: caAuthContextRead.cursor,
@@ -2790,6 +2790,12 @@ export const LEAVES = [
2790
2790
  full: { description: "Disable 500-character string truncation; never disables redaction or row caps" },
2791
2791
  }, examples: ["mg-axi api get /users --scopes https://graph.microsoft.com/User.Read.All", "mg-axi api get /groups --odata '$filter=securityEnabled eq true&$top=5' --scopes https://graph.microsoft.com/GroupMember.Read.All", "mg-axi api get /users --cursor - --all --scopes https://graph.microsoft.com/User.Read.All < cursor.txt"] },
2792
2792
  ];
2793
+ LEAVES.push({ ...LEAVES.find(leaf => leaf.path === "auth login"), path: "login", description: "Deprecated alias for auth login (one minor release)" });
2794
+ LEAVES.push({ path: "config path", description: "Show the selected configuration path without reading credentials", flags: {}, examples: ["mg-axi config path", "mg-axi config path --config /absolute/path/config.json"] });
2795
+ LEAVES.push({ path: "auth status", description: "Show credential sources and verify local file permissions without a live request", flags: { profile: common.profile }, examples: ["mg-axi auth status", "mg-axi auth status --profile soc"] });
2796
+ LEAVES.push({ path: "auth logout", description: "Delete the selected delegated login cache without prompting", flags: { profile: common.profile }, examples: ["mg-axi auth logout", "mg-axi auth logout --profile soc"] });
2797
+ for (const leaf of LEAVES)
2798
+ leaf.flags.config = { value: "path", description: "Configuration file override; takes precedence over MG_AXI_CONFIG" };
2793
2799
  export function leafHelp(leaf) {
2794
2800
  return [
2795
2801
  `mg-axi ${leaf.path}${leaf.positional ? ` <${leaf.positional.name}>` : ""}`, leaf.description,
@@ -2828,7 +2834,7 @@ export function resolveCommand(argv) {
2828
2834
  const flags = Object.create(null);
2829
2835
  for (let i = flagStart; i < argv.length; i++) {
2830
2836
  const arg = argv[i];
2831
- const match = /^--([a-z-]+)(?:=(.*))?$/.exec(arg);
2837
+ const match = /^--([a-z][a-z0-9-]*)(?:=(.*))?$/.exec(arg);
2832
2838
  if (!match)
2833
2839
  fail("unexpected argument or short flag");
2834
2840
  const name = match[1];