@heroku/skynet 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/commands/allowlist/add.d.ts +2 -2
- package/dist/commands/allowlist/remove.d.ts +1 -1
- package/dist/commands/categories/add.d.ts +2 -2
- package/dist/commands/categories/remove.d.ts +1 -1
- package/dist/commands/deprovision.d.ts +6 -6
- package/dist/commands/deprovision.js +1 -1
- package/dist/commands/legal-hold/add.d.ts +11 -0
- package/dist/commands/legal-hold/add.js +28 -0
- package/dist/commands/legal-hold/remove.d.ts +11 -0
- package/dist/commands/legal-hold/remove.js +28 -0
- package/dist/commands/suspend/app-owner.d.ts +7 -7
- package/dist/commands/suspend/app-owner.js +2 -2
- package/dist/commands/suspend/apps.d.ts +5 -5
- package/dist/commands/suspend/user/direct.d.ts +24 -0
- package/dist/commands/suspend/user/direct.js +286 -0
- package/dist/commands/suspend/user.d.ts +10 -9
- package/dist/commands/suspend/user.js +17 -3
- package/dist/commands/suspensions/upload.d.ts +9 -0
- package/dist/commands/suspensions/upload.js +111 -0
- package/dist/commands/suspensions.d.ts +1 -1
- package/dist/commands/suspensions.js +2 -2
- package/dist/commands/unsuspend/apps.d.ts +3 -3
- package/dist/commands/unsuspend/user.d.ts +3 -3
- package/dist/commands/userpass/add.d.ts +2 -2
- package/dist/commands/userpass/remove.d.ts +2 -2
- package/dist/lib/local-storage.d.ts +31 -0
- package/dist/lib/local-storage.js +88 -0
- package/dist/lib/skynet.d.ts +16 -2
- package/dist/lib/skynet.js +24 -2
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.js +64 -7
- package/oclif.manifest.json +560 -176
- package/package.json +13 -13
|
@@ -2,7 +2,7 @@ import SkynetAPI from '../../lib/skynet.js';
|
|
|
2
2
|
import { requireSudo } from '../../lib/sudo.js';
|
|
3
3
|
import { Command } from '@heroku-cli/command';
|
|
4
4
|
import { Flags, ux } from '@oclif/core';
|
|
5
|
-
import { processSuspensionResult, parseResponse, handleApiError } from '../../lib/utils.js';
|
|
5
|
+
import { processSuspensionResult, parseResponse, handleApiError, confirmLegalHold } from '../../lib/utils.js';
|
|
6
6
|
import { color } from '@heroku-cli/color';
|
|
7
7
|
export default class SuspendUser extends Command {
|
|
8
8
|
static id = 'skynet:suspend:user';
|
|
@@ -63,6 +63,11 @@ export default class SuspendUser extends Command {
|
|
|
63
63
|
name: 'async',
|
|
64
64
|
description: 'do not wait for suspension to complete',
|
|
65
65
|
required: false
|
|
66
|
+
}),
|
|
67
|
+
'legal-hold': Flags.boolean({
|
|
68
|
+
name: 'legal-hold',
|
|
69
|
+
description: 'mark suspension with legal hold status (prevents unsuspension until cleared)',
|
|
70
|
+
required: false
|
|
66
71
|
})
|
|
67
72
|
};
|
|
68
73
|
async run() {
|
|
@@ -75,6 +80,7 @@ export default class SuspendUser extends Command {
|
|
|
75
80
|
const category = flags.category;
|
|
76
81
|
const force = flags.bypass || process.env.HEROKU_FORCE === '1';
|
|
77
82
|
const deprovision = flags.deprovision;
|
|
83
|
+
const legalHold = flags['legal-hold'] || false;
|
|
78
84
|
if (flags.notify && flags.no_notify) {
|
|
79
85
|
ux.error('Flag --notify and --no-notify cannot be used together');
|
|
80
86
|
}
|
|
@@ -90,10 +96,18 @@ export default class SuspendUser extends Command {
|
|
|
90
96
|
if ((user && file) || (!user && !file)) {
|
|
91
97
|
ux.error('Either --user USER or --infile must be passed, but not both');
|
|
92
98
|
}
|
|
99
|
+
// Legal hold confirmation
|
|
100
|
+
if (legalHold) {
|
|
101
|
+
const confirmed = await confirmLegalHold();
|
|
102
|
+
if (!confirmed) {
|
|
103
|
+
this.log(color.yellow('Legal hold suspension cancelled'));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
93
107
|
if (file) {
|
|
94
108
|
ux.action.start(color.blue.bold('Starting bulk suspend...'));
|
|
95
109
|
try {
|
|
96
|
-
let response = await skynet.bulkSuspendUsers(file, notes, category, notify, force, deprovision);
|
|
110
|
+
let response = await skynet.bulkSuspendUsers(file, notes, category, notify, force, deprovision, legalHold);
|
|
97
111
|
response = parseResponse(response);
|
|
98
112
|
ux.action.stop();
|
|
99
113
|
// For bulk operations, use a custom message if no message is in response
|
|
@@ -110,7 +124,7 @@ export default class SuspendUser extends Command {
|
|
|
110
124
|
else {
|
|
111
125
|
ux.action.start(color.blue.bold(`Suspending user ${color.cyan(user)}...`));
|
|
112
126
|
try {
|
|
113
|
-
let response = await skynet.suspendUser(user, notes, category, notify, force, deprovision, flags.async);
|
|
127
|
+
let response = await skynet.suspendUser(user, notes, category, notify, force, deprovision, flags.async, legalHold);
|
|
114
128
|
response = parseResponse(response);
|
|
115
129
|
ux.action.stop();
|
|
116
130
|
processSuspensionResult(user, response);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Command } from '@heroku-cli/command';
|
|
2
|
+
export default class SuspensionsUpload extends Command {
|
|
3
|
+
static id: string;
|
|
4
|
+
static aliases: string[];
|
|
5
|
+
static description: string;
|
|
6
|
+
static hidden: boolean;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import SkynetAPI from '../../lib/skynet.js';
|
|
2
|
+
import { requireSudo } from '../../lib/sudo.js';
|
|
3
|
+
import { Command } from '@heroku-cli/command';
|
|
4
|
+
import { color } from '@heroku-cli/color';
|
|
5
|
+
import { ux } from '@oclif/core';
|
|
6
|
+
import { confirm } from '../../lib/utils.js';
|
|
7
|
+
import { getPendingSuspensionRecords, deleteSuspensionRecord, getPendingSuspensionCount } from '../../lib/local-storage.js';
|
|
8
|
+
export default class SuspensionsUpload extends Command {
|
|
9
|
+
static id = 'skynet:suspensions:upload';
|
|
10
|
+
static aliases = ['skynet:suspensions:upload'];
|
|
11
|
+
static description = '(requires sudo) uploads locally stored suspension records to Skynet';
|
|
12
|
+
static hidden = true;
|
|
13
|
+
static examples = [
|
|
14
|
+
'$ heroku sudo skynet:suspensions:upload'
|
|
15
|
+
];
|
|
16
|
+
async run() {
|
|
17
|
+
requireSudo();
|
|
18
|
+
await this.parse(SuspensionsUpload);
|
|
19
|
+
// Get count first
|
|
20
|
+
const count = await getPendingSuspensionCount();
|
|
21
|
+
if (count === 0) {
|
|
22
|
+
this.log(color.yellow('No pending suspension records found to upload'));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
this.log(color.blue(`Found ${color.bold(String(count))} pending suspension record(s)`));
|
|
26
|
+
// Confirm before uploading
|
|
27
|
+
const confirmed = await confirm(`Upload ${count} suspension record(s) to Skynet? (y/n)`);
|
|
28
|
+
if (!confirmed) {
|
|
29
|
+
this.log(color.yellow('Upload cancelled'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// Get all pending records
|
|
33
|
+
const records = await getPendingSuspensionRecords();
|
|
34
|
+
if (records.length === 0) {
|
|
35
|
+
this.log(color.yellow('No pending suspension records found to upload'));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const skynet = new SkynetAPI(this.heroku.auth);
|
|
39
|
+
ux.action.start(color.blue.bold(`Uploading ${records.length} suspension record(s) to Skynet...`));
|
|
40
|
+
try {
|
|
41
|
+
// Upload records to Skynet
|
|
42
|
+
const response = await skynet.uploadSuspensionRecords(records.map(r => r.record));
|
|
43
|
+
ux.action.stop();
|
|
44
|
+
// Parse response
|
|
45
|
+
let parsedResponse = response;
|
|
46
|
+
if (typeof response === 'string') {
|
|
47
|
+
try {
|
|
48
|
+
parsedResponse = JSON.parse(response);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Keep as string
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Check if upload was successful
|
|
55
|
+
const success = parsedResponse?.status === 'OK' ||
|
|
56
|
+
parsedResponse?.statusCode === 200 ||
|
|
57
|
+
parsedResponse?.status_code === 200;
|
|
58
|
+
if (success) {
|
|
59
|
+
this.log(color.green(`✓ Successfully uploaded ${records.length} suspension record(s) to Skynet`));
|
|
60
|
+
// Show details if available
|
|
61
|
+
if (parsedResponse.uploaded) {
|
|
62
|
+
this.log(color.dim(` Uploaded: ${parsedResponse.uploaded}`));
|
|
63
|
+
}
|
|
64
|
+
if (parsedResponse.failed && parsedResponse.failed > 0) {
|
|
65
|
+
this.log(color.yellow(` Failed: ${parsedResponse.failed}`));
|
|
66
|
+
}
|
|
67
|
+
if (parsedResponse.message) {
|
|
68
|
+
this.log(color.dim(` ${parsedResponse.message}`));
|
|
69
|
+
}
|
|
70
|
+
// Delete local files
|
|
71
|
+
ux.action.start(color.blue('Cleaning up local files...'));
|
|
72
|
+
let deletedCount = 0;
|
|
73
|
+
for (const { filepath } of records) {
|
|
74
|
+
try {
|
|
75
|
+
await deleteSuspensionRecord(filepath);
|
|
76
|
+
deletedCount++;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
console.error(`Failed to delete ${filepath}:`, error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
ux.action.stop();
|
|
83
|
+
this.log(color.green(`✓ Deleted ${deletedCount} local file(s)`));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
ux.error(`Upload failed: ${parsedResponse?.message || parsedResponse?.error || 'Unknown error'}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
ux.action.stop();
|
|
91
|
+
if (error.response) {
|
|
92
|
+
const status = error.response.statusCode;
|
|
93
|
+
const body = error.response.body;
|
|
94
|
+
let errorMessage = 'Failed to upload suspension records';
|
|
95
|
+
if (typeof body === 'string') {
|
|
96
|
+
errorMessage += `: ${body}`;
|
|
97
|
+
}
|
|
98
|
+
else if (body && body.message) {
|
|
99
|
+
errorMessage += `: ${body.message}`;
|
|
100
|
+
}
|
|
101
|
+
else if (body && body.error) {
|
|
102
|
+
errorMessage += `: ${body.error}`;
|
|
103
|
+
}
|
|
104
|
+
ux.error(`${errorMessage} (HTTP ${status})`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
ux.error(`Failed to upload suspension records: ${error.message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -5,7 +5,7 @@ export default class Suspensions extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
account: import("@oclif/core/
|
|
8
|
+
account: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
9
|
};
|
|
10
10
|
run(): Promise<void>;
|
|
11
11
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import SkynetAPI from '../lib/skynet.js';
|
|
2
2
|
import { Command } from '@heroku-cli/command';
|
|
3
|
-
import { Flags
|
|
3
|
+
import { Flags } from '@oclif/core';
|
|
4
4
|
import { parseResponse } from '../lib/utils.js';
|
|
5
5
|
export default class Suspensions extends Command {
|
|
6
6
|
static id = 'skynet:suspensions';
|
|
@@ -23,6 +23,6 @@ export default class Suspensions extends Command {
|
|
|
23
23
|
const skynet = new SkynetAPI(this.heroku.auth);
|
|
24
24
|
let response = await skynet.suspensions(flags.account);
|
|
25
25
|
response = parseResponse(response);
|
|
26
|
-
|
|
26
|
+
this.log(response);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -5,9 +5,9 @@ export default class UnsuspendApp extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
app: import("@oclif/core/
|
|
9
|
-
category: import("@oclif/core/
|
|
10
|
-
notes: import("@oclif/core/
|
|
8
|
+
app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
category: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
notes: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
11
|
};
|
|
12
12
|
run(): Promise<void>;
|
|
13
13
|
}
|
|
@@ -5,9 +5,9 @@ export default class UnsuspendUser extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
user: import("@oclif/core/
|
|
9
|
-
category: import("@oclif/core/
|
|
10
|
-
notes: import("@oclif/core/
|
|
8
|
+
user: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
category: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
notes: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
11
|
};
|
|
12
12
|
run(): Promise<void>;
|
|
13
13
|
}
|
|
@@ -5,8 +5,8 @@ export default class AddUserpass extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
user: import("@oclif/core/
|
|
9
|
-
flag: import("@oclif/core/
|
|
8
|
+
user: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
flag: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
10
|
};
|
|
11
11
|
run(): Promise<void>;
|
|
12
12
|
}
|
|
@@ -5,8 +5,8 @@ export default class RemoveUserpass extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
user: import("@oclif/core/
|
|
9
|
-
flag: import("@oclif/core/
|
|
8
|
+
user: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
flag: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
10
|
};
|
|
11
11
|
run(): Promise<void>;
|
|
12
12
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface LocalSuspensionRecord {
|
|
2
|
+
resourceId: string;
|
|
3
|
+
isApp: boolean;
|
|
4
|
+
reason: string;
|
|
5
|
+
category: string;
|
|
6
|
+
action: string;
|
|
7
|
+
createdAt: string;
|
|
8
|
+
method: string;
|
|
9
|
+
notify?: boolean;
|
|
10
|
+
deprovision?: boolean;
|
|
11
|
+
legalHold?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Save a suspension record to local storage
|
|
15
|
+
*/
|
|
16
|
+
export declare function saveSuspensionRecord(record: LocalSuspensionRecord): Promise<string>;
|
|
17
|
+
/**
|
|
18
|
+
* Get all pending suspension records from local storage
|
|
19
|
+
*/
|
|
20
|
+
export declare function getPendingSuspensionRecords(): Promise<Array<{
|
|
21
|
+
filepath: string;
|
|
22
|
+
record: LocalSuspensionRecord;
|
|
23
|
+
}>>;
|
|
24
|
+
/**
|
|
25
|
+
* Delete a suspension record file
|
|
26
|
+
*/
|
|
27
|
+
export declare function deleteSuspensionRecord(filepath: string): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Get count of pending suspension records
|
|
30
|
+
*/
|
|
31
|
+
export declare function getPendingSuspensionCount(): Promise<number>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
const readdir = promisify(fs.readdir);
|
|
6
|
+
const readFile = promisify(fs.readFile);
|
|
7
|
+
const writeFile = promisify(fs.writeFile);
|
|
8
|
+
const unlink = promisify(fs.unlink);
|
|
9
|
+
const mkdir = promisify(fs.mkdir);
|
|
10
|
+
const STORAGE_DIR_NAME = 'heroku-skynet-suspensions';
|
|
11
|
+
/**
|
|
12
|
+
* Get the temp directory path for storing suspension records
|
|
13
|
+
*/
|
|
14
|
+
function getStorageDir() {
|
|
15
|
+
return path.join(os.tmpdir(), STORAGE_DIR_NAME);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Ensure the storage directory exists
|
|
19
|
+
*/
|
|
20
|
+
async function ensureStorageDir() {
|
|
21
|
+
const dir = getStorageDir();
|
|
22
|
+
try {
|
|
23
|
+
await mkdir(dir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== 'EEXIST') {
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Generate a unique filename for a suspension record
|
|
34
|
+
*/
|
|
35
|
+
function generateFilename(resourceId) {
|
|
36
|
+
const timestamp = Date.now();
|
|
37
|
+
const sanitized = resourceId.replace(/[^a-zA-Z0-9]/g, '_');
|
|
38
|
+
return `suspension_${sanitized}_${timestamp}.json`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Save a suspension record to local storage
|
|
42
|
+
*/
|
|
43
|
+
export async function saveSuspensionRecord(record) {
|
|
44
|
+
const dir = await ensureStorageDir();
|
|
45
|
+
const filename = generateFilename(record.resourceId);
|
|
46
|
+
const filepath = path.join(dir, filename);
|
|
47
|
+
await writeFile(filepath, JSON.stringify(record, null, 2), 'utf8');
|
|
48
|
+
return filepath;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get all pending suspension records from local storage
|
|
52
|
+
*/
|
|
53
|
+
export async function getPendingSuspensionRecords() {
|
|
54
|
+
const dir = getStorageDir();
|
|
55
|
+
// Check if directory exists
|
|
56
|
+
if (!fs.existsSync(dir)) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const files = await readdir(dir);
|
|
60
|
+
const suspensionFiles = files.filter(f => f.startsWith('suspension_') && f.endsWith('.json'));
|
|
61
|
+
const records = [];
|
|
62
|
+
for (const file of suspensionFiles) {
|
|
63
|
+
const filepath = path.join(dir, file);
|
|
64
|
+
try {
|
|
65
|
+
const content = await readFile(filepath, 'utf8');
|
|
66
|
+
const record = JSON.parse(content);
|
|
67
|
+
records.push({ filepath, record });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
// Skip files that can't be read or parsed
|
|
71
|
+
console.error(`Error reading ${filepath}:`, error);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return records;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Delete a suspension record file
|
|
78
|
+
*/
|
|
79
|
+
export async function deleteSuspensionRecord(filepath) {
|
|
80
|
+
await unlink(filepath);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get count of pending suspension records
|
|
84
|
+
*/
|
|
85
|
+
export async function getPendingSuspensionCount() {
|
|
86
|
+
const records = await getPendingSuspensionRecords();
|
|
87
|
+
return records.length;
|
|
88
|
+
}
|
package/dist/lib/skynet.d.ts
CHANGED
|
@@ -6,6 +6,17 @@ interface RequestOptions {
|
|
|
6
6
|
json?: any;
|
|
7
7
|
form?: any;
|
|
8
8
|
}
|
|
9
|
+
interface SuspensionRecord {
|
|
10
|
+
resourceId: string;
|
|
11
|
+
isApp: boolean;
|
|
12
|
+
reason: string;
|
|
13
|
+
category: string;
|
|
14
|
+
action: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
method: string;
|
|
17
|
+
notify?: boolean;
|
|
18
|
+
deprovision?: boolean;
|
|
19
|
+
}
|
|
9
20
|
export default class SkynetAPI {
|
|
10
21
|
private version;
|
|
11
22
|
private token;
|
|
@@ -22,11 +33,14 @@ export default class SkynetAPI {
|
|
|
22
33
|
suspendAppOwner(app: string, notes: string, category: string, force?: boolean, deprovision?: boolean, async?: boolean): Promise<any>;
|
|
23
34
|
suspendApp(app: string, notes: string, category: string, force?: boolean, async?: boolean): Promise<any>;
|
|
24
35
|
unsuspendApp(app: string, category: string, notes: string): Promise<any>;
|
|
25
|
-
suspendUser(user: string, notes: string, category: string, notify: boolean, force?: boolean, deprovision?: boolean, async?: boolean): Promise<any>;
|
|
36
|
+
suspendUser(user: string, notes: string, category: string, notify: boolean, force?: boolean, deprovision?: boolean, async?: boolean, legalHold?: boolean): Promise<any>;
|
|
26
37
|
unsuspendUser(user: string, category: string, notes: string): Promise<any>;
|
|
27
|
-
bulkSuspendUsers(file: string, notes: string, category: string, notify: boolean, force?: boolean, deprovision?: boolean): Promise<any>;
|
|
38
|
+
bulkSuspendUsers(file: string, notes: string, category: string, notify: boolean, force?: boolean, deprovision?: boolean, legalHold?: boolean): Promise<any>;
|
|
28
39
|
bulkSuspendAppOwner(apps: string, notes: string, category: string, deprovision?: boolean): Promise<any>;
|
|
29
40
|
deprovision(user: string, notes: string, category: string, notify?: boolean, force?: boolean): Promise<any>;
|
|
30
41
|
suspensions(account: string): Promise<any>;
|
|
42
|
+
uploadSuspensionRecords(records: SuspensionRecord[]): Promise<any>;
|
|
43
|
+
addLegalHold(user: string): Promise<any>;
|
|
44
|
+
removeLegalHold(user: string): Promise<any>;
|
|
31
45
|
}
|
|
32
46
|
export {};
|
package/dist/lib/skynet.js
CHANGED
|
@@ -140,7 +140,7 @@ export default class SkynetAPI {
|
|
|
140
140
|
json: {}
|
|
141
141
|
});
|
|
142
142
|
}
|
|
143
|
-
suspendUser(user, notes, category, notify, force = false, deprovision = false, async = false) {
|
|
143
|
+
suspendUser(user, notes, category, notify, force = false, deprovision = false, async = false, legalHold = false) {
|
|
144
144
|
const body = {
|
|
145
145
|
value: user,
|
|
146
146
|
reason: notes,
|
|
@@ -150,6 +150,7 @@ export default class SkynetAPI {
|
|
|
150
150
|
notify,
|
|
151
151
|
deprovision,
|
|
152
152
|
sync: !async,
|
|
153
|
+
legal_hold: legalHold,
|
|
153
154
|
};
|
|
154
155
|
return this.request('/suspend/user', {
|
|
155
156
|
method: 'POST',
|
|
@@ -162,7 +163,7 @@ export default class SkynetAPI {
|
|
|
162
163
|
json: {}
|
|
163
164
|
});
|
|
164
165
|
}
|
|
165
|
-
bulkSuspendUsers(file, notes, category, notify, force = false, deprovision = false) {
|
|
166
|
+
bulkSuspendUsers(file, notes, category, notify, force = false, deprovision = false, legalHold = false) {
|
|
166
167
|
const fileStream = fs.createReadStream(file);
|
|
167
168
|
const form = new FormData();
|
|
168
169
|
form.append('file', fileStream);
|
|
@@ -173,6 +174,7 @@ export default class SkynetAPI {
|
|
|
173
174
|
form.append('force', String(force));
|
|
174
175
|
form.append('notify', String(notify));
|
|
175
176
|
form.append('deprovision', String(deprovision));
|
|
177
|
+
form.append('legal_hold', String(legalHold));
|
|
176
178
|
return this.request('/bulk-suspend/user', {
|
|
177
179
|
method: 'POST',
|
|
178
180
|
body: form
|
|
@@ -211,5 +213,25 @@ export default class SkynetAPI {
|
|
|
211
213
|
method: 'GET'
|
|
212
214
|
});
|
|
213
215
|
}
|
|
216
|
+
uploadSuspensionRecords(records) {
|
|
217
|
+
return this.request('/suspensions/upload', {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
json: {
|
|
220
|
+
records
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
addLegalHold(user) {
|
|
225
|
+
return this.request(`/legal_hold/${user}`, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
json: {}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
removeLegalHold(user) {
|
|
231
|
+
return this.request(`/legal_hold/${user}`, {
|
|
232
|
+
method: 'DELETE',
|
|
233
|
+
json: {}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
214
236
|
}
|
|
215
237
|
;
|
package/dist/lib/utils.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare function confirm(message: string): Promise<boolean>;
|
|
2
|
+
export declare function promptHidden(message: string): Promise<string>;
|
|
1
3
|
export declare function readlines(file: string): Promise<string[]>;
|
|
2
4
|
export declare function arrayChunks<T>(array: T[], chunkSize: number): T[][];
|
|
3
5
|
/**
|
|
@@ -15,3 +17,4 @@ export declare function handleApiError(error: any, operation: string, throwError
|
|
|
15
17
|
export declare function processSuspensionResult(suspendObject: any, response: any): void;
|
|
16
18
|
export declare function processUnsuspendResult(unsuspendObject: any, response: any): void;
|
|
17
19
|
export declare function logError(response: any): void;
|
|
20
|
+
export declare function confirmLegalHold(): Promise<boolean>;
|
package/dist/lib/utils.js
CHANGED
|
@@ -1,7 +1,52 @@
|
|
|
1
1
|
import split from 'split';
|
|
2
2
|
import fs from 'fs';
|
|
3
|
+
import * as readline from 'readline';
|
|
3
4
|
import { ux } from '@oclif/core';
|
|
4
5
|
import { color } from '@heroku-cli/color';
|
|
6
|
+
// Helper to write to stdout consistently
|
|
7
|
+
function log(message) {
|
|
8
|
+
process.stdout.write(message + '\n');
|
|
9
|
+
}
|
|
10
|
+
// Helper for confirm prompts - exported for use in commands
|
|
11
|
+
export async function confirm(message) {
|
|
12
|
+
const rl = readline.createInterface({
|
|
13
|
+
input: process.stdin,
|
|
14
|
+
output: process.stdout
|
|
15
|
+
});
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
rl.question(`${message} `, (answer) => {
|
|
18
|
+
rl.close();
|
|
19
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// Helper for password/hidden input prompts - exported for use in commands
|
|
24
|
+
export async function promptHidden(message) {
|
|
25
|
+
const rl = readline.createInterface({
|
|
26
|
+
input: process.stdin,
|
|
27
|
+
output: process.stdout
|
|
28
|
+
});
|
|
29
|
+
// Hide the input
|
|
30
|
+
const stdin = process.stdin;
|
|
31
|
+
const originalSetRawMode = stdin.setRawMode ? stdin.setRawMode.bind(stdin) : null;
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
rl.question(`${message}: `, (answer) => {
|
|
34
|
+
rl.close();
|
|
35
|
+
console.log(); // New line after hidden input
|
|
36
|
+
resolve(answer);
|
|
37
|
+
});
|
|
38
|
+
// Attempt to hide input (may not work in all terminals)
|
|
39
|
+
if (originalSetRawMode) {
|
|
40
|
+
stdin.setRawMode(true);
|
|
41
|
+
stdin.on('data', (char) => {
|
|
42
|
+
const c = char.toString();
|
|
43
|
+
if (c === '\r' || c === '\n') {
|
|
44
|
+
stdin.setRawMode(false);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
5
50
|
export function readlines(file) {
|
|
6
51
|
return new Promise(function (resolve, reject) {
|
|
7
52
|
const entries = [];
|
|
@@ -69,9 +114,9 @@ export function handleApiError(error, operation, throwError = true) {
|
|
|
69
114
|
}
|
|
70
115
|
else if (!error?.response?.body) {
|
|
71
116
|
// Only log error message if we don't have a response body (already logged by logError)
|
|
72
|
-
|
|
117
|
+
log(color.red(`Error: ${error?.message || error}`));
|
|
73
118
|
}
|
|
74
|
-
|
|
119
|
+
log(color.bgRed(`Failed to ${operation}`));
|
|
75
120
|
if (throwError) {
|
|
76
121
|
throw error;
|
|
77
122
|
}
|
|
@@ -80,15 +125,15 @@ export function processSuspensionResult(suspendObject, response) {
|
|
|
80
125
|
if (response?.success === 'ok' || response?.status === 'OK' || response?.statusCode === 200) {
|
|
81
126
|
const message = response?.message || response?.Message;
|
|
82
127
|
if (message) {
|
|
83
|
-
|
|
128
|
+
log(color.green(message));
|
|
84
129
|
}
|
|
85
130
|
else {
|
|
86
|
-
|
|
131
|
+
log(color.green(`Suspended ${suspendObject}`));
|
|
87
132
|
}
|
|
88
133
|
}
|
|
89
134
|
else {
|
|
90
135
|
logError(response);
|
|
91
|
-
|
|
136
|
+
log(color.bgRed(`Failed to suspend ${suspendObject}`));
|
|
92
137
|
}
|
|
93
138
|
}
|
|
94
139
|
export function processUnsuspendResult(unsuspendObject, response) {
|
|
@@ -129,10 +174,22 @@ export function logError(response) {
|
|
|
129
174
|
}
|
|
130
175
|
// If we have parts, combine them into a single line
|
|
131
176
|
if (parts.length > 0) {
|
|
132
|
-
|
|
177
|
+
log(color.red(parts.join(' - ')));
|
|
133
178
|
}
|
|
134
179
|
else {
|
|
135
180
|
// If response doesn't have expected error format, log the whole thing
|
|
136
|
-
|
|
181
|
+
log(color.red(`Error: ${JSON.stringify(response)}`));
|
|
137
182
|
}
|
|
138
183
|
}
|
|
184
|
+
export async function confirmLegalHold() {
|
|
185
|
+
log('');
|
|
186
|
+
log(color.yellow.bold('⚠ LEGAL HOLD WARNING'));
|
|
187
|
+
log(color.yellow('This suspension will be marked with legal hold status.'));
|
|
188
|
+
log(color.yellow('Users under legal hold:'));
|
|
189
|
+
log(color.yellow(' • Cannot be unsuspended until legal hold is cleared'));
|
|
190
|
+
log(color.yellow(' • Are tracked for legal/compliance purposes'));
|
|
191
|
+
log(color.yellow(' • May have data retention requirements'));
|
|
192
|
+
log('');
|
|
193
|
+
const confirmed = await confirm('Proceed with legal hold suspension? (y/n)');
|
|
194
|
+
return confirmed;
|
|
195
|
+
}
|