@aderis-erp/cli 1.1.0 → 1.1.1
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/dist/commands/access-groups.js +2 -2
- package/dist/commands/business-partners.js +2 -2
- package/dist/commands/files.js +1 -1
- package/dist/commands/ledgers.js +5 -4
- package/dist/commands/support/end.d.ts +9 -0
- package/dist/commands/support/end.js +29 -0
- package/dist/commands/support/start.d.ts +14 -0
- package/dist/commands/support/start.js +50 -0
- package/dist/commands/support/status.d.ts +10 -0
- package/dist/commands/support/status.js +45 -0
- package/dist/commands/support/tenants.d.ts +10 -0
- package/dist/commands/support/tenants.js +39 -0
- package/dist/commands/tags.js +4 -3
- package/dist/lib/auth/constants.d.ts +12 -0
- package/dist/lib/auth/constants.js +12 -0
- package/dist/lib/auth/index.d.ts +11 -1
- package/dist/lib/auth/index.js +90 -2
- package/dist/lib/auth/keycloak-provider.d.ts +22 -0
- package/dist/lib/auth/keycloak-provider.js +108 -3
- package/dist/lib/config.d.ts +18 -0
- package/dist/lib/config.js +10 -0
- package/oclif.manifest.json +174 -173
- package/package.json +4 -1
- package/dist/commands/tag-configurations.d.ts +0 -39
- package/dist/commands/tag-configurations.js +0 -1155
|
@@ -9,9 +9,9 @@ const NESTED_IMPORTS = [];
|
|
|
9
9
|
const SUBCOMMANDS = [...["list", "create", "get", "update", "delete"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
|
|
10
10
|
const ENDPOINTS = {
|
|
11
11
|
'list': { method: 'GET', path: '/v1/access-groups', summary: 'List access groups (paginated)', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
12
|
-
'create': { method: 'POST', path: '/v1/access-groups', summary: 'Create a new access group', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['types', '
|
|
12
|
+
'create': { method: 'POST', path: '/v1/access-groups', summary: 'Create a new access group', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['types', 'userIds'] },
|
|
13
13
|
'get': { method: 'GET', path: '/v1/access-groups/{id}', summary: 'Get a single access group by ID', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
14
|
-
'update': { method: 'PATCH', path: '/v1/access-groups/{id}', summary: 'Update an existing access group', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['types', '
|
|
14
|
+
'update': { method: 'PATCH', path: '/v1/access-groups/{id}', summary: 'Update an existing access group', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['types', 'userIds'] },
|
|
15
15
|
'delete': { method: 'DELETE', path: '/v1/access-groups/{id}', summary: 'Delete an access group', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
16
16
|
};
|
|
17
17
|
export default class AccessGroups extends Command {
|
|
@@ -9,10 +9,10 @@ const NESTED_IMPORTS = ["notes", "files", "contacts", "contacts-notes"];
|
|
|
9
9
|
const SUBCOMMANDS = [...["list", "create", "match-candidates", "get", "update", "delete", "merge", "activate", "get-favicon", "update-favicon", "trigger-favicon-backfill", "list-notes", "create-notes", "get-notes", "update-notes", "delete-notes", "list-files", "create-files", "get-files", "update-files", "delete-files", "files-download", "list-contacts", "create-contacts", "get-contacts", "update-contacts", "delete-contacts", "list-contacts-notes", "create-contacts-notes", "get-contacts-notes", "update-contacts-notes", "delete-contacts-notes"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
|
|
10
10
|
const ENDPOINTS = {
|
|
11
11
|
'list': { method: 'GET', path: '/v1/business-partners', summary: 'Get all business partners with pagination', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
12
|
-
'create': { method: 'POST', path: '/v1/business-partners', summary: 'Create a new business partner', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['salesOrderRecipientCc', 'salesOrderRecipientBcc', 'salesInvoiceRecipientCc', 'salesInvoiceRecipientBcc', 'deliveryNoteRecipientCc', 'deliveryNoteRecipientBcc', 'contacts', 'tagIds', 'tagCodes', 'salesEarlyPaymentDiscounts', 'purchaseEarlyPaymentDiscounts', 'notes'] },
|
|
12
|
+
'create': { method: 'POST', path: '/v1/business-partners', summary: 'Create a new business partner', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['salesOrderRecipientCc', 'salesOrderRecipientBcc', 'salesInvoiceRecipientCc', 'salesInvoiceRecipientBcc', 'deliveryNoteRecipientCc', 'deliveryNoteRecipientBcc', 'dunningRecipientCc', 'dunningRecipientBcc', 'contacts', 'tagIds', 'tagCodes', 'salesEarlyPaymentDiscounts', 'purchaseEarlyPaymentDiscounts', 'notes'] },
|
|
13
13
|
'match-candidates': { method: 'GET', path: '/v1/business-partners/match-candidates', summary: 'Rank the business partners whose names resemble a bank counterparty text', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
14
14
|
'get': { method: 'GET', path: '/v1/business-partners/{id}', summary: 'Get a business partner by ID', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
15
|
-
'update': { method: 'PATCH', path: '/v1/business-partners/{id}', summary: 'Update a business partner', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['salesOrderRecipientCc', 'salesOrderRecipientBcc', 'salesInvoiceRecipientCc', 'salesInvoiceRecipientBcc', 'deliveryNoteRecipientCc', 'deliveryNoteRecipientBcc', 'contacts', 'tagIds', 'tagCodes', 'salesEarlyPaymentDiscounts', 'purchaseEarlyPaymentDiscounts', 'notes'] },
|
|
15
|
+
'update': { method: 'PATCH', path: '/v1/business-partners/{id}', summary: 'Update a business partner', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['salesOrderRecipientCc', 'salesOrderRecipientBcc', 'salesInvoiceRecipientCc', 'salesInvoiceRecipientBcc', 'deliveryNoteRecipientCc', 'deliveryNoteRecipientBcc', 'dunningRecipientCc', 'dunningRecipientBcc', 'contacts', 'tagIds', 'tagCodes', 'salesEarlyPaymentDiscounts', 'purchaseEarlyPaymentDiscounts', 'notes'] },
|
|
16
16
|
'delete': { method: 'DELETE', path: '/v1/business-partners/{id}', summary: 'Delete a business partner', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
17
17
|
'merge': { method: 'POST', path: '/v1/business-partners/{id}/merge', summary: 'Merge a business partner into another', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
18
18
|
'activate': { method: 'POST', path: '/v1/business-partners/{id}/activate', summary: 'Activate a business partner', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
package/dist/commands/files.js
CHANGED
|
@@ -14,7 +14,7 @@ const ENDPOINTS = {
|
|
|
14
14
|
'download': { method: 'GET', path: '/v1/files/{id}/download', summary: 'Get a signed download URL for a file', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
15
15
|
'get': { method: 'GET', path: '/v1/files/{id}', summary: 'Get a file by id (permission-scoped)', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
16
16
|
'references': { method: 'POST', path: '/v1/files/references', summary: 'Register a file reference to an existing storage object', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
17
|
-
'update': { method: 'PATCH', path: '/v1/files/{fileId}', summary: 'Update file metadata (type, parent, sortOrder)', pathParams: ['fileId'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['
|
|
17
|
+
'update': { method: 'PATCH', path: '/v1/files/{fileId}', summary: 'Update file metadata (type, parent, sortOrder)', pathParams: ['fileId'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['tagIds', 'tagCodes', 'references'] },
|
|
18
18
|
'discard': { method: 'POST', path: '/v1/files/{fileId}/discard', summary: 'Discard a file (sets status DISCARDED; stays readable by id)', pathParams: ['fileId'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
19
19
|
'move': { method: 'POST', path: '/v1/files/{fileId}/move', summary: 'Move file between storage zones', pathParams: ['fileId'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
20
20
|
'retention': { method: 'POST', path: '/v1/files/{fileId}/retention', summary: 'Set or override retention period on a file', pathParams: ['fileId'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
package/dist/commands/ledgers.js
CHANGED
|
@@ -6,12 +6,12 @@ const HAS_IMPORT = true;
|
|
|
6
6
|
const HAS_EXPORT = true;
|
|
7
7
|
const HAS_CAMT053 = false;
|
|
8
8
|
const NESTED_IMPORTS = ["month-end-closings", "month-end-closings-accruals", "month-end-closings-notes", "notes", "closings"];
|
|
9
|
-
const SUBCOMMANDS = [...["list", "create", "get", "update", "delete", "list-month-end-closings", "create-month-end-closings", "get-month-end-closings", "update-month-end-closings", "delete-month-end-closings", "month-end-closings-complete", "create-month-end-closings-provisional-booking", "delete-month-end-closings-provisional-booking", "month-end-closings-reopen", "list-month-end-closings-accruals", "create-month-end-closings-accruals", "update-month-end-closings-accruals", "delete-month-end-closings-accruals", "month-end-closings-accruals-accept", "month-end-closings-accruals-dismiss", "list-month-end-closings-accrual-accounts", "list-month-end-closings-findings", "update-month-end-closings-findings", "list-month-end-closings-notes", "create-month-end-closings-notes", "get-month-end-closings-notes", "update-month-end-closings-notes", "delete-month-end-closings-notes", "update-month-end-closings-agent-results", "month-end-closings-agent-run", "get-fx-rate", "replace-fx-rate-providers", "list-notes", "create-notes", "get-notes", "update-notes", "delete-notes", "list-closings", "create-closings", "get-closings", "update-closings", "closings-close", "closings-reopen", "list-posting-runs", "create-posting-runs", "posting-runs"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
|
|
9
|
+
const SUBCOMMANDS = [...["list", "create", "get", "update", "delete", "list-month-end-closings", "create-month-end-closings", "get-month-end-closings", "update-month-end-closings", "delete-month-end-closings", "month-end-closings-complete", "create-month-end-closings-provisional-booking", "delete-month-end-closings-provisional-booking", "month-end-closings-reopen", "list-month-end-closings-accruals", "create-month-end-closings-accruals", "update-month-end-closings-accruals", "delete-month-end-closings-accruals", "month-end-closings-accruals-accept", "month-end-closings-accruals-dismiss", "list-month-end-closings-accrual-accounts", "list-month-end-closings-findings", "update-month-end-closings-findings", "list-month-end-closings-notes", "create-month-end-closings-notes", "get-month-end-closings-notes", "update-month-end-closings-notes", "delete-month-end-closings-notes", "update-month-end-closings-agent-results", "month-end-closings-agent-run", "get-fx-rate", "replace-fx-rate-providers", "replace-closing-fx-rate-providers", "list-notes", "create-notes", "get-notes", "update-notes", "delete-notes", "list-closings", "create-closings", "get-closings", "update-closings", "closings-close", "closings-reopen", "list-posting-runs", "create-posting-runs", "posting-runs"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
|
|
10
10
|
const ENDPOINTS = {
|
|
11
11
|
'list': { method: 'GET', path: '/v1/ledgers', summary: 'List all ledgers', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
12
|
-
'create': { method: 'POST', path: '/v1/ledgers', summary: 'Create a new ledger', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['notes', 'fxRateProviders'] },
|
|
12
|
+
'create': { method: 'POST', path: '/v1/ledgers', summary: 'Create a new ledger', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['notes', 'fxRateProviders', 'closingFxRateProviders'] },
|
|
13
13
|
'get': { method: 'GET', path: '/v1/ledgers/{id}', summary: 'Get a ledger by ID', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
14
|
-
'update': { method: 'PATCH', path: '/v1/ledgers/{id}', summary: 'Update a ledger', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['fxRateProviders'] },
|
|
14
|
+
'update': { method: 'PATCH', path: '/v1/ledgers/{id}', summary: 'Update a ledger', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['fxRateProviders', 'closingFxRateProviders'] },
|
|
15
15
|
'delete': { method: 'DELETE', path: '/v1/ledgers/{id}', summary: 'Delete a ledger', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
16
16
|
'list-month-end-closings': { method: 'GET', path: '/v1/ledgers/{ledgerId}/month-end-closings', summary: 'List the month-end closings of a ledger, newest month first', pathParams: ['ledgerId'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
17
17
|
'create-month-end-closings': { method: 'POST', path: '/v1/ledgers/{ledgerId}/month-end-closings', summary: 'Create a DRAFT month-end closing for one calendar month', pathParams: ['ledgerId'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
@@ -38,8 +38,9 @@ const ENDPOINTS = {
|
|
|
38
38
|
'delete-month-end-closings-notes': { method: 'DELETE', path: '/v1/ledgers/{ledgerId}/month-end-closings/{id}/notes/{noteId}', summary: 'Delete a note of a month-end closing', pathParams: ['ledgerId', 'id', 'noteId'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
39
39
|
'update-month-end-closings-agent-results': { method: 'PUT', path: '/v1/ledgers/{ledgerId}/month-end-closings/{id}/agent-results', summary: 'Write back a month-end agent run\'s explanations and accrual proposals', pathParams: ['ledgerId', 'id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['accounts', 'accruals'] },
|
|
40
40
|
'month-end-closings-agent-run': { method: 'POST', path: '/v1/ledgers/{ledgerId}/month-end-closings/{id}/agent-run', summary: 'Compute the month\'s findings and start a month-end agent run', pathParams: ['ledgerId', 'id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
41
|
-
'get-fx-rate': { method: 'GET', path: '/v1/ledgers/{id}/fx-rate', summary: 'Resolve the FX rate this ledger would book for a currency on a day', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
41
|
+
'get-fx-rate': { method: 'GET', path: '/v1/ledgers/{id}/fx-rate', summary: 'Resolve the FX rate this ledger would book for a currency on a day, or with purpose=CLOSING the rate its month-end close values a balance at', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
42
42
|
'replace-fx-rate-providers': { method: 'PUT', path: '/v1/ledgers/{id}/fx-rate-providers', summary: 'Replace the list of FX rate providers this ledger asks, leaving the rest of the ledger untouched', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['fxRateProviders'] },
|
|
43
|
+
'replace-closing-fx-rate-providers': { method: 'PUT', path: '/v1/ledgers/{id}/closing-fx-rate-providers', summary: 'Replace the list of FX rate providers the month-end close asks, leaving the rest of the ledger untouched', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['closingFxRateProviders'] },
|
|
43
44
|
'list-notes': { method: 'GET', path: '/v1/ledgers/{ledgerId}/notes', summary: 'Get all notes for a ledger', pathParams: ['ledgerId'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
44
45
|
'create-notes': { method: 'POST', path: '/v1/ledgers/{ledgerId}/notes', summary: 'Create a note for a ledger', pathParams: ['ledgerId'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
45
46
|
'get-notes': { method: 'GET', path: '/v1/ledgers/{ledgerId}/notes/{noteId}', summary: 'Get a specific note for a ledger', pathParams: ['ledgerId', 'noteId'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class SupportEnd extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
context: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
};
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { endSupportSession } from '../../lib/auth/index.js';
|
|
3
|
+
import { getCurrentContext } from '../../lib/config.js';
|
|
4
|
+
export default class SupportEnd extends Command {
|
|
5
|
+
static description = 'Stop acting as Aderis Support and return to your own account';
|
|
6
|
+
static examples = ['<%= config.bin %> support end', '<%= config.bin %> support end --context prod'];
|
|
7
|
+
static flags = {
|
|
8
|
+
context: Flags.string({
|
|
9
|
+
aliases: ['config'],
|
|
10
|
+
char: 'c',
|
|
11
|
+
description: 'Context to use, overriding the current context (alias: --config)',
|
|
12
|
+
env: 'ADERIS_CONTEXT',
|
|
13
|
+
}),
|
|
14
|
+
};
|
|
15
|
+
async run() {
|
|
16
|
+
const { flags } = await this.parse(SupportEnd);
|
|
17
|
+
const contextName = flags.context ?? getCurrentContext()?.name;
|
|
18
|
+
if (!contextName) {
|
|
19
|
+
this.error('No context configured. Run: aderis auth login --new');
|
|
20
|
+
}
|
|
21
|
+
const ended = await endSupportSession(contextName);
|
|
22
|
+
if (!ended) {
|
|
23
|
+
this.log(`Context ${contextName} was not acting as Aderis Support.`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this.log(`Stopped acting as Aderis Support in ${ended.tenant_name}.`);
|
|
27
|
+
this.log(`Context ${contextName} is your own account again; no new login was needed.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class SupportStart extends Command {
|
|
3
|
+
static args: {
|
|
4
|
+
tenantId: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
context: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
};
|
|
11
|
+
run(): Promise<void>;
|
|
12
|
+
/** Without a tenant id, offer the customers Keycloak says this operator may act for. */
|
|
13
|
+
private chooseTenant;
|
|
14
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts';
|
|
2
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
3
|
+
import { listSupportTenants, startSupportSession, tenantLabel } from '../../lib/auth/index.js';
|
|
4
|
+
import { getCurrentContext } from '../../lib/config.js';
|
|
5
|
+
export default class SupportStart extends Command {
|
|
6
|
+
static args = {
|
|
7
|
+
tenantId: Args.string({ description: 'Customer to act for; prompted for if omitted', required: false }),
|
|
8
|
+
};
|
|
9
|
+
static description = 'Act as Aderis Support in a customer tenant';
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> support start',
|
|
12
|
+
'<%= config.bin %> support start 11839c8b-7ec3-4cc1-9dae-396ced09b928',
|
|
13
|
+
'<%= config.bin %> support start <tenant-id> --context prod',
|
|
14
|
+
];
|
|
15
|
+
static flags = {
|
|
16
|
+
context: Flags.string({
|
|
17
|
+
aliases: ['config'],
|
|
18
|
+
char: 'c',
|
|
19
|
+
description: 'Context to use, overriding the current context (alias: --config)',
|
|
20
|
+
env: 'ADERIS_CONTEXT',
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
async run() {
|
|
24
|
+
const { args, flags } = await this.parse(SupportStart);
|
|
25
|
+
const contextName = flags.context ?? getCurrentContext()?.name;
|
|
26
|
+
if (!contextName) {
|
|
27
|
+
this.error('No context configured. Run: aderis auth login --new');
|
|
28
|
+
}
|
|
29
|
+
const tenantId = args.tenantId ?? (await this.chooseTenant(contextName));
|
|
30
|
+
const support = await startSupportSession(contextName, tenantId);
|
|
31
|
+
this.log(`Acting as Aderis Support in ${support.tenant_name} (${support.tenant_id})`);
|
|
32
|
+
this.log(`Context: ${contextName}`);
|
|
33
|
+
this.warn('Every call from this context now goes out as Aderis Support, and every write is recorded against you. ' +
|
|
34
|
+
'End with: aderis support end');
|
|
35
|
+
}
|
|
36
|
+
/** Without a tenant id, offer the customers Keycloak says this operator may act for. */
|
|
37
|
+
async chooseTenant(contextName) {
|
|
38
|
+
const tenants = await listSupportTenants(contextName);
|
|
39
|
+
if (tenants.length === 0) {
|
|
40
|
+
this.error('There are no customers you may act for.');
|
|
41
|
+
}
|
|
42
|
+
if (!process.stdin.isTTY) {
|
|
43
|
+
this.error(`Name the customer to act for. List them with: aderis support tenants --context ${contextName}`);
|
|
44
|
+
}
|
|
45
|
+
return select({
|
|
46
|
+
choices: tenants.map((t) => ({ name: tenantLabel(t), value: t.tenantId })),
|
|
47
|
+
message: 'Act as Aderis Support in which customer?',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class SupportStatus extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
context: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
};
|
|
9
|
+
run(): Promise<void>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { getContext, getCurrentContext, getSupportSession } from '../../lib/config.js';
|
|
3
|
+
export default class SupportStatus extends Command {
|
|
4
|
+
static description = 'Show whether this context is acting as Aderis Support';
|
|
5
|
+
static examples = ['<%= config.bin %> support status', '<%= config.bin %> support status --context prod'];
|
|
6
|
+
static flags = {
|
|
7
|
+
context: Flags.string({
|
|
8
|
+
aliases: ['config'],
|
|
9
|
+
char: 'c',
|
|
10
|
+
description: 'Context to check, overriding the current context (alias: --config)',
|
|
11
|
+
env: 'ADERIS_CONTEXT',
|
|
12
|
+
}),
|
|
13
|
+
json: Flags.boolean({ default: false, description: 'Output as JSON' }),
|
|
14
|
+
};
|
|
15
|
+
async run() {
|
|
16
|
+
const { flags } = await this.parse(SupportStatus);
|
|
17
|
+
const contextName = flags.context ?? getCurrentContext()?.name;
|
|
18
|
+
if (!contextName) {
|
|
19
|
+
this.error('No context configured. Run: aderis auth login --new');
|
|
20
|
+
}
|
|
21
|
+
const context = getContext(contextName);
|
|
22
|
+
if (!context) {
|
|
23
|
+
this.error(`Context "${contextName}" not found.`);
|
|
24
|
+
}
|
|
25
|
+
const support = getSupportSession(context);
|
|
26
|
+
if (flags.json) {
|
|
27
|
+
this.log(JSON.stringify({ context: contextName, supportSession: support ? supportJson(support) : null }, null, 2));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!support) {
|
|
31
|
+
this.log(`Context ${contextName} is acting as yourself.`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.log(`Acting as Aderis Support in ${support.tenant_name} (${support.tenant_id})`);
|
|
35
|
+
this.log(`Context: ${contextName}`);
|
|
36
|
+
if (support.tokens.expires_at) {
|
|
37
|
+
const expires = new Date(support.tokens.expires_at);
|
|
38
|
+
this.log(`Token expires: ${expires.toLocaleString()}${expires < new Date() ? ' (refreshes on next call)' : ''}`);
|
|
39
|
+
}
|
|
40
|
+
this.log('\nEverything you do is recorded against you. End with: aderis support end');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function supportJson(support) {
|
|
44
|
+
return { expiresAt: support.tokens.expires_at ?? null, tenantId: support.tenant_id, tenantName: support.tenant_name };
|
|
45
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class SupportTenants extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
context: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
};
|
|
9
|
+
run(): Promise<void>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import { listSupportTenants, tenantLabel } from '../../lib/auth/index.js';
|
|
3
|
+
import { getCurrentContext } from '../../lib/config.js';
|
|
4
|
+
export default class SupportTenants extends Command {
|
|
5
|
+
static description = 'List the customers you may act for as Aderis Support';
|
|
6
|
+
static examples = [
|
|
7
|
+
'<%= config.bin %> support tenants',
|
|
8
|
+
'<%= config.bin %> support tenants --context prod --json',
|
|
9
|
+
];
|
|
10
|
+
static flags = {
|
|
11
|
+
context: Flags.string({
|
|
12
|
+
aliases: ['config'],
|
|
13
|
+
char: 'c',
|
|
14
|
+
description: 'Context to use, overriding the current context (alias: --config)',
|
|
15
|
+
env: 'ADERIS_CONTEXT',
|
|
16
|
+
}),
|
|
17
|
+
json: Flags.boolean({ default: false, description: 'Output as JSON' }),
|
|
18
|
+
};
|
|
19
|
+
async run() {
|
|
20
|
+
const { flags } = await this.parse(SupportTenants);
|
|
21
|
+
const contextName = flags.context ?? getCurrentContext()?.name;
|
|
22
|
+
if (!contextName) {
|
|
23
|
+
this.error('No context configured. Run: aderis auth login --new');
|
|
24
|
+
}
|
|
25
|
+
const tenants = await listSupportTenants(contextName);
|
|
26
|
+
if (flags.json) {
|
|
27
|
+
this.log(JSON.stringify(tenants, null, 2));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (tenants.length === 0) {
|
|
31
|
+
this.log('No customers available to act for.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.log('Customers you may act for:\n');
|
|
35
|
+
for (const tenant of tenants)
|
|
36
|
+
this.log(` ${tenantLabel(tenant)}`);
|
|
37
|
+
this.log(`\nStart with: aderis support start <tenant-id> --context ${contextName}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/commands/tags.js
CHANGED
|
@@ -6,13 +6,14 @@ const HAS_IMPORT = true;
|
|
|
6
6
|
const HAS_EXPORT = true;
|
|
7
7
|
const HAS_CAMT053 = false;
|
|
8
8
|
const NESTED_IMPORTS = [];
|
|
9
|
-
const SUBCOMMANDS = [...["list", "create", "get", "update", "delete"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
|
|
9
|
+
const SUBCOMMANDS = [...["list", "create", "get", "update", "delete", "journal-coverage"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
|
|
10
10
|
const ENDPOINTS = {
|
|
11
11
|
'list': { method: 'GET', path: '/v1/tags', summary: 'Get all tags with pagination', pathParams: [], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
12
|
-
'create': { method: 'POST', path: '/v1/tags', summary: 'Create a new tag', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
12
|
+
'create': { method: 'POST', path: '/v1/tags', summary: 'Create a new tag', pathParams: [], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['rules'] },
|
|
13
13
|
'get': { method: 'GET', path: '/v1/tags/{id}', summary: 'Get a tag by ID', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
14
|
-
'update': { method: 'PATCH', path: '/v1/tags/{id}', summary: 'Update a tag', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
14
|
+
'update': { method: 'PATCH', path: '/v1/tags/{id}', summary: 'Update a tag', pathParams: ['id'], hasBody: true, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: ['rules'] },
|
|
15
15
|
'delete': { method: 'DELETE', path: '/v1/tags/{id}', summary: 'Delete a tag', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
16
|
+
'journal-coverage': { method: 'GET', path: '/v1/tags/{id}/journal-coverage', summary: 'How much of the journal-line amount in scope carries one of the tags below this tag', pathParams: ['id'], hasBody: false, isMultipart: false, isBinaryResponse: false, hasBinaryVariant: false, bodyArrayFields: [] },
|
|
16
17
|
};
|
|
17
18
|
export default class Tags extends Command {
|
|
18
19
|
static description = 'Manage tags';
|
|
@@ -9,3 +9,15 @@ export declare const FORM_POST_HEADERS: {
|
|
|
9
9
|
readonly Accept: "application/json";
|
|
10
10
|
readonly 'Content-Type': "application/x-www-form-urlencoded";
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Acting as Aderis Support. The realm mints a second login for the shared
|
|
14
|
+
* support account through a custom grant, taking the operator's own access
|
|
15
|
+
* token as the Bearer. The support token names the customer in `tenantId` and
|
|
16
|
+
* the operator in `impersonatorId`; the backend records that pair on every
|
|
17
|
+
* write (backend/docs/concepts/2026-09-23-support-impersonator-audit-attribution.md).
|
|
18
|
+
*/
|
|
19
|
+
export declare const SUPPORT_SESSION_GRANT_TYPE = "urn:aderis:params:oauth:grant-type:support-session";
|
|
20
|
+
/** Public client of the support session; like the CLI client it holds no secret. */
|
|
21
|
+
export declare const SUPPORT_CLIENT_ID = "aderis-support-session";
|
|
22
|
+
/** Keycloak ends a support session after this long however active it is. */
|
|
23
|
+
export declare const SUPPORT_SESSION_MAX_LIFESPAN_S: number;
|
|
@@ -9,3 +9,15 @@ export const FORM_POST_HEADERS = {
|
|
|
9
9
|
Accept: 'application/json',
|
|
10
10
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Acting as Aderis Support. The realm mints a second login for the shared
|
|
14
|
+
* support account through a custom grant, taking the operator's own access
|
|
15
|
+
* token as the Bearer. The support token names the customer in `tenantId` and
|
|
16
|
+
* the operator in `impersonatorId`; the backend records that pair on every
|
|
17
|
+
* write (backend/docs/concepts/2026-09-23-support-impersonator-audit-attribution.md).
|
|
18
|
+
*/
|
|
19
|
+
export const SUPPORT_SESSION_GRANT_TYPE = 'urn:aderis:params:oauth:grant-type:support-session';
|
|
20
|
+
/** Public client of the support session; like the CLI client it holds no secret. */
|
|
21
|
+
export const SUPPORT_CLIENT_ID = 'aderis-support-session';
|
|
22
|
+
/** Keycloak ends a support session after this long however active it is. */
|
|
23
|
+
export const SUPPORT_SESSION_MAX_LIFESPAN_S = 4 * 60 * 60;
|
package/dist/lib/auth/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type Context, type TokenData } from '../config.js';
|
|
1
|
+
import { type Context, type SupportSession, type TokenData } from '../config.js';
|
|
2
2
|
import { type FlowHooks } from './flow.js';
|
|
3
3
|
import { type Tenant } from './types.js';
|
|
4
|
+
export { type SupportSession } from '../config.js';
|
|
4
5
|
export { CALLBACK_PORT, REDIRECT_URI } from './constants.js';
|
|
5
6
|
export { AuthError, TenantMismatchError } from './errors.js';
|
|
6
7
|
export { type FlowHooks } from './flow.js';
|
|
@@ -17,6 +18,15 @@ export declare function getValidToken(contextName: string): Promise<string>;
|
|
|
17
18
|
* needed) or `x-api-key` for `api_key`. The one place credentials become headers.
|
|
18
19
|
*/
|
|
19
20
|
export declare function getAuthHeaders(contextName: string): Promise<Record<string, string>>;
|
|
21
|
+
/** Customers the operator may act for, as Keycloak lists them. */
|
|
22
|
+
export declare function listSupportTenants(contextName: string): Promise<Tenant[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Starts a support session on the context and stores it beside the operator's
|
|
25
|
+
* own login. Subsequent API calls go out as Aderis Support until it is ended.
|
|
26
|
+
*/
|
|
27
|
+
export declare function startSupportSession(contextName: string, tenantId: string): Promise<SupportSession>;
|
|
28
|
+
/** Ends the running support session; the operator's own login is untouched. */
|
|
29
|
+
export declare function endSupportSession(contextName: string): Promise<null | SupportSession>;
|
|
20
30
|
/** The user `/v1/auth/me` resolved for the session; the fields the CLI shows. */
|
|
21
31
|
export interface AuthenticatedUser {
|
|
22
32
|
email?: string;
|
package/dist/lib/auth/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keycloak; the protocol details live in `keycloak-provider.ts`.
|
|
4
4
|
*/
|
|
5
5
|
import axios from 'axios';
|
|
6
|
-
import { getAuthType, getBaseUrls, getContext, missingCredentialsMessage, saveCachedTenants, saveTokens, SESSION_EXPIRED_MESSAGE, unauthorizedMessage, } from '../config.js';
|
|
6
|
+
import { clearSupportSession, getAuthType, getBaseUrls, getContext, getSupportSession, missingCredentialsMessage, saveCachedTenants, saveSupportSession, saveTokens, SESSION_EXPIRED_MESSAGE, unauthorizedMessage, } from '../config.js';
|
|
7
7
|
import { AuthError, TenantMismatchError } from './errors.js';
|
|
8
8
|
import { runBrowserFlow } from './flow.js';
|
|
9
9
|
import { requireOAuthContext } from './guards.js';
|
|
@@ -15,6 +15,8 @@ export { CALLBACK_PORT, REDIRECT_URI } from './constants.js';
|
|
|
15
15
|
export { AuthError, TenantMismatchError } from './errors.js';
|
|
16
16
|
export { keycloakBaseUrlOf, keycloakProvider, parseKeycloakBaseUrl } from './keycloak-provider.js';
|
|
17
17
|
export { buildValidatedUrl } from './validated-url.js';
|
|
18
|
+
/** A token this close to expiry is refreshed rather than sent. */
|
|
19
|
+
const EXPIRY_BUFFER_MS = 60_000;
|
|
18
20
|
function requireContext(contextName) {
|
|
19
21
|
const context = getContext(contextName);
|
|
20
22
|
if (!context) {
|
|
@@ -82,7 +84,7 @@ export async function getValidToken(contextName) {
|
|
|
82
84
|
if (!context.tokens?.access_token) {
|
|
83
85
|
throw new Error('Not logged in. Please run: aderis auth login');
|
|
84
86
|
}
|
|
85
|
-
if (context.tokens.expires_at && context.tokens.expires_at < Date.now() +
|
|
87
|
+
if (context.tokens.expires_at && context.tokens.expires_at < Date.now() + EXPIRY_BUFFER_MS) {
|
|
86
88
|
if (context.tokens.refresh_token) {
|
|
87
89
|
const newTokens = await refreshTokens(contextName);
|
|
88
90
|
return newTokens.access_token;
|
|
@@ -103,8 +105,94 @@ export async function getAuthHeaders(contextName) {
|
|
|
103
105
|
}
|
|
104
106
|
return { 'x-api-key': context.api_key };
|
|
105
107
|
}
|
|
108
|
+
// While acting as Aderis Support every call goes out as the support account,
|
|
109
|
+
// and the operator is told so — a write made here is attributed to them.
|
|
110
|
+
const support = getSupportSession(context);
|
|
111
|
+
if (support) {
|
|
112
|
+
const token = await validSupportToken(contextName, support);
|
|
113
|
+
warnActingAsSupport(context.name, support);
|
|
114
|
+
return { Authorization: `Bearer ${token}` };
|
|
115
|
+
}
|
|
106
116
|
return { Authorization: `Bearer ${await getValidToken(contextName)}` };
|
|
107
117
|
}
|
|
118
|
+
/** Printed once per process, to stderr so it never lands in piped output. */
|
|
119
|
+
let warnedAboutSupport = false;
|
|
120
|
+
function warnActingAsSupport(contextName, support) {
|
|
121
|
+
if (warnedAboutSupport)
|
|
122
|
+
return;
|
|
123
|
+
warnedAboutSupport = true;
|
|
124
|
+
process.stderr.write(`! Acting as Aderis Support in ${support.tenant_name} (context ${contextName}). ` +
|
|
125
|
+
`Everything you do is recorded against you. End with: aderis support end\n`);
|
|
126
|
+
}
|
|
127
|
+
/** The support access token, refreshed when it is within the expiry buffer. */
|
|
128
|
+
async function validSupportToken(contextName, support) {
|
|
129
|
+
if (support.tokens.expires_at && support.tokens.expires_at < Date.now() + EXPIRY_BUFFER_MS) {
|
|
130
|
+
if (!support.tokens.refresh_token) {
|
|
131
|
+
throw new AuthError('The support session has ended. Start a new one with: aderis support start <tenant-id>');
|
|
132
|
+
}
|
|
133
|
+
const context = requireContext(contextName);
|
|
134
|
+
const refreshed = await keycloakProvider.refreshSupportSession(context, support.tokens.refresh_token);
|
|
135
|
+
const tokens = { ...refreshed, refresh_token: refreshed.refresh_token || support.tokens.refresh_token };
|
|
136
|
+
saveSupportSession(contextName, { ...support, tokens });
|
|
137
|
+
return tokens.access_token;
|
|
138
|
+
}
|
|
139
|
+
return support.tokens.access_token;
|
|
140
|
+
}
|
|
141
|
+
/** Customers the operator may act for, as Keycloak lists them. */
|
|
142
|
+
export async function listSupportTenants(contextName) {
|
|
143
|
+
const context = requireContext(contextName);
|
|
144
|
+
requireOAuthContext(context, 'Support sessions');
|
|
145
|
+
if (getSupportSession(context)) {
|
|
146
|
+
throw new AuthError('A support session is already running. End it first with: aderis support end');
|
|
147
|
+
}
|
|
148
|
+
return keycloakProvider.listSupportTenants(context, await getValidToken(contextName));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Starts a support session on the context and stores it beside the operator's
|
|
152
|
+
* own login. Subsequent API calls go out as Aderis Support until it is ended.
|
|
153
|
+
*/
|
|
154
|
+
export async function startSupportSession(contextName, tenantId) {
|
|
155
|
+
const context = requireContext(contextName);
|
|
156
|
+
requireOAuthContext(context, 'Support sessions');
|
|
157
|
+
if (getSupportSession(context)) {
|
|
158
|
+
throw new AuthError('A support session is already running. End it first with: aderis support end');
|
|
159
|
+
}
|
|
160
|
+
const operatorToken = await getValidToken(contextName);
|
|
161
|
+
const operatorSub = decodedSub(operatorToken);
|
|
162
|
+
const tenants = await keycloakProvider.listSupportTenants(context, operatorToken);
|
|
163
|
+
const chosen = tenants.find((t) => t.tenantId === tenantId);
|
|
164
|
+
if (!chosen) {
|
|
165
|
+
throw new AuthError(`Tenant ${tenantId} is not one you may act for. List them with: aderis support tenants --context ${contextName}`);
|
|
166
|
+
}
|
|
167
|
+
const tokens = await keycloakProvider.startSupportSession(context, operatorToken, tenantId);
|
|
168
|
+
const support = {
|
|
169
|
+
operator_sub: operatorSub,
|
|
170
|
+
tenant_id: tenantId,
|
|
171
|
+
tenant_name: chosen.name,
|
|
172
|
+
tokens,
|
|
173
|
+
};
|
|
174
|
+
saveSupportSession(contextName, support);
|
|
175
|
+
return support;
|
|
176
|
+
}
|
|
177
|
+
/** Ends the running support session; the operator's own login is untouched. */
|
|
178
|
+
export async function endSupportSession(contextName) {
|
|
179
|
+
const context = requireContext(contextName);
|
|
180
|
+
const support = getSupportSession(context);
|
|
181
|
+
if (!support)
|
|
182
|
+
return null;
|
|
183
|
+
if (support.tokens.refresh_token) {
|
|
184
|
+
// Keycloak may have ended it already; the local session goes either way.
|
|
185
|
+
await keycloakProvider.endSupportSession(context, support.tokens.refresh_token).catch(() => { });
|
|
186
|
+
}
|
|
187
|
+
clearSupportSession(contextName);
|
|
188
|
+
return support;
|
|
189
|
+
}
|
|
190
|
+
/** The operator's own `sub`, read from a token the realm signed. */
|
|
191
|
+
function decodedSub(accessToken) {
|
|
192
|
+
const [, payload] = accessToken.split('.');
|
|
193
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
194
|
+
return claims.sub ?? '';
|
|
195
|
+
}
|
|
108
196
|
export async function testAuth(contextName) {
|
|
109
197
|
const context = requireContext(contextName);
|
|
110
198
|
const { api: apiUrl } = getBaseUrls(context.environment);
|
|
@@ -16,6 +16,8 @@ export declare const KEYCLOAK_SCOPE = "openid profile email offline_access";
|
|
|
16
16
|
/** The subset of the OIDC discovery document (RFC 8414 / OIDC Discovery 1.0) the CLI uses. */
|
|
17
17
|
export interface OidcDiscovery {
|
|
18
18
|
authorization_endpoint: string;
|
|
19
|
+
/** Ends one session without touching the operator's own login. */
|
|
20
|
+
end_session_endpoint: string;
|
|
19
21
|
issuer: string;
|
|
20
22
|
/** Where the realm publishes the signing keys every issued token is checked against. */
|
|
21
23
|
jwks_uri: string;
|
|
@@ -45,7 +47,18 @@ export declare class KeycloakProvider implements AuthProvider {
|
|
|
45
47
|
* validated against the host allowlist; a failed fetch is not cached.
|
|
46
48
|
*/
|
|
47
49
|
discover(target: KeycloakTarget): Promise<OidcDiscovery>;
|
|
50
|
+
/**
|
|
51
|
+
* Ends exactly the support session. The operator's own login is a different
|
|
52
|
+
* Keycloak session and is left running.
|
|
53
|
+
*/
|
|
54
|
+
endSupportSession(context: Context, refreshToken: string): Promise<void>;
|
|
48
55
|
exchangeCode(context: Context, { code, codeVerifier, nonce }: ExchangeParams): Promise<TokenData>;
|
|
56
|
+
/**
|
|
57
|
+
* Customers this operator may act for. Served by the realm's tenant-claims
|
|
58
|
+
* mapper against the operator's own Bearer, so Keycloak — not the CLI —
|
|
59
|
+
* decides who is an operator and which customers they may pick.
|
|
60
|
+
*/
|
|
61
|
+
listSupportTenants(context: Context, operatorAccessToken: string): Promise<Tenant[]>;
|
|
49
62
|
/** The access token's `availableTenants` claim lists every tenant the user may act in, read once its signature checks out. */
|
|
50
63
|
listTenants(context: Context, accessToken: string): Promise<Tenant[]>;
|
|
51
64
|
/**
|
|
@@ -56,6 +69,15 @@ export declare class KeycloakProvider implements AuthProvider {
|
|
|
56
69
|
*/
|
|
57
70
|
readClaims(context: Context, accessToken: string): Promise<DecodedToken>;
|
|
58
71
|
refresh(context: Context, refreshToken: string): Promise<TokenData>;
|
|
72
|
+
/** Refreshes a support session. No tenant is named: the session never moves customer. */
|
|
73
|
+
refreshSupportSession(context: Context, refreshToken: string): Promise<TokenData>;
|
|
74
|
+
/**
|
|
75
|
+
* Starts a support session for `tenantId`, authorised by the operator's own
|
|
76
|
+
* access token. The returned tokens belong to the shared support account and
|
|
77
|
+
* name the operator in `impersonatorId`; they are a second login, so the
|
|
78
|
+
* operator's own tokens stay valid and untouched.
|
|
79
|
+
*/
|
|
80
|
+
startSupportSession(context: Context, operatorAccessToken: string, tenantId: string): Promise<TokenData>;
|
|
59
81
|
/**
|
|
60
82
|
* One refresh-token grant with the `tenant_id` form field, read by the
|
|
61
83
|
* realm's tenant-claims mapper: the minted token's `tenantId` becomes this
|