@heroku/skynet 2.2.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 +17 -17
package/README.md
CHANGED
|
@@ -10,6 +10,53 @@ Most Skynet CLI commands require sudo and only the folowing teams are authrozied
|
|
|
10
10
|
* heroku-cedar
|
|
11
11
|
* heroku-dogwood
|
|
12
12
|
|
|
13
|
+
### Break-glass direct suspend (`skynet:suspend:user:direct`)
|
|
14
|
+
|
|
15
|
+
When Skynet is unavailable, you can suspend users or apps directly via the Heroku API using the hidden command `heroku sudo skynet:suspend:user:direct`. The command will prompt for a **Heroku Platform API key** (it is not stored).
|
|
16
|
+
|
|
17
|
+
**Getting the platform user API token**
|
|
18
|
+
|
|
19
|
+
Use an account that is authorized for suspension (e.g. a platform/ops user or your own account if you are on an authorized team). Then obtain an API key in one of these ways:
|
|
20
|
+
|
|
21
|
+
1. **From the Heroku Dashboard**
|
|
22
|
+
Log in as the platform user → [Account settings](https://dashboard.heroku.com/account) → **API Key** section → click "Reveal" or "Regenerate API Key" and copy the key.
|
|
23
|
+
|
|
24
|
+
2. **From the CLI**
|
|
25
|
+
Log in as the platform user (`heroku login`) and run:
|
|
26
|
+
```bash
|
|
27
|
+
heroku auth:token
|
|
28
|
+
```
|
|
29
|
+
Use the printed token when the command prompts for "Heroku Platform API Key".
|
|
30
|
+
|
|
31
|
+
The token is only used for the duration of the command and is not saved. After running a direct suspend, upload the local suspension record to Skynet once it is back up: `heroku skynet:suspensions:upload`.
|
|
32
|
+
|
|
33
|
+
### Legal Hold Suspensions
|
|
34
|
+
|
|
35
|
+
User suspensions can be marked with legal hold status using the `--legal-hold` flag. Legal hold suspensions:
|
|
36
|
+
- Cannot be unsuspended until the hold is cleared by authorized personnel
|
|
37
|
+
- Are tracked for legal/compliance purposes
|
|
38
|
+
- May have data retention requirements
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
heroku sudo skynet:suspend:user -u user@example.com -c "legal" -n "legal hold required" --legal-hold
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Legal hold is also supported for break-glass direct suspensions.
|
|
45
|
+
|
|
46
|
+
### Managing Legal Hold Flags
|
|
47
|
+
|
|
48
|
+
You can add or remove legal hold flags independently of suspension status:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Add legal hold to a user
|
|
52
|
+
heroku skynet:legal-hold:add -u user@example.com
|
|
53
|
+
|
|
54
|
+
# Remove legal hold from a user
|
|
55
|
+
heroku skynet:legal-hold:remove -u user@example.com
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
These commands manage legal hold as a standalone flag/marker on user accounts.
|
|
59
|
+
|
|
13
60
|
### Installation
|
|
14
61
|
```
|
|
15
62
|
heroku plugins:install heroku-skynet-cli
|
|
@@ -5,8 +5,8 @@ export default class AddAllowlist extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
value: import("@oclif/core/
|
|
9
|
-
notes: import("@oclif/core/
|
|
8
|
+
value: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
notes: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
10
|
};
|
|
11
11
|
static hiddenAliases: string[];
|
|
12
12
|
run(): Promise<void>;
|
|
@@ -5,7 +5,7 @@ export default class RemoveAllowlist extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
value: import("@oclif/core/
|
|
8
|
+
value: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
9
|
};
|
|
10
10
|
static hiddenAliases: string[];
|
|
11
11
|
run(): Promise<void>;
|
|
@@ -5,8 +5,8 @@ export default class AddCategory extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
name: import("@oclif/core/
|
|
9
|
-
description: import("@oclif/core/
|
|
8
|
+
name: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
description: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
10
|
};
|
|
11
11
|
run(): Promise<void>;
|
|
12
12
|
}
|
|
@@ -5,7 +5,7 @@ export default class RemoveCategory extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
name: import("@oclif/core/
|
|
8
|
+
name: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
9
|
};
|
|
10
10
|
run(): Promise<void>;
|
|
11
11
|
}
|
|
@@ -5,12 +5,12 @@ export default class Deprovision 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/
|
|
11
|
-
bypass: import("@oclif/core/
|
|
12
|
-
notify: import("@oclif/core/
|
|
13
|
-
'no-notify': 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
|
+
bypass: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
notify: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
'no-notify': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
14
|
};
|
|
15
15
|
run(): Promise<void>;
|
|
16
16
|
}
|
|
@@ -78,6 +78,6 @@ export default class Deprovision extends Command {
|
|
|
78
78
|
let response = await skynet.deprovision(user, notes, category, notify, force);
|
|
79
79
|
ux.action.stop();
|
|
80
80
|
response = parseResponse(response);
|
|
81
|
-
|
|
81
|
+
this.log(`${color.cyan(response.status)}. ${response.message}. Notification ${notificationStatus}`);
|
|
82
82
|
}
|
|
83
83
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from '@heroku-cli/command';
|
|
2
|
+
export default class AddLegalHold extends Command {
|
|
3
|
+
static id: string;
|
|
4
|
+
static aliases: string[];
|
|
5
|
+
static description: string;
|
|
6
|
+
static examples: string[];
|
|
7
|
+
static flags: {
|
|
8
|
+
user: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import SkynetAPI from '../../lib/skynet.js';
|
|
2
|
+
import { Command } from '@heroku-cli/command';
|
|
3
|
+
import { color } from '@heroku-cli/color';
|
|
4
|
+
import { Flags, ux } from '@oclif/core';
|
|
5
|
+
export default class AddLegalHold extends Command {
|
|
6
|
+
static id = 'skynet:legal-hold:add';
|
|
7
|
+
static aliases = ['skynet:legal-hold:add'];
|
|
8
|
+
static description = 'add legal hold flag to a user';
|
|
9
|
+
static examples = [
|
|
10
|
+
'$ heroku skynet:legal-hold:add -u foo@bar.com'
|
|
11
|
+
];
|
|
12
|
+
static flags = {
|
|
13
|
+
user: Flags.string({
|
|
14
|
+
name: 'user',
|
|
15
|
+
char: 'u',
|
|
16
|
+
description: 'user to add legal hold to',
|
|
17
|
+
hasValue: true,
|
|
18
|
+
required: true
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
async run() {
|
|
22
|
+
const { flags } = await this.parse(AddLegalHold);
|
|
23
|
+
const skynet = new SkynetAPI(this.heroku.auth);
|
|
24
|
+
ux.action.start(`adding legal hold to ${color.cyan(flags.user)}`);
|
|
25
|
+
await skynet.addLegalHold(flags.user);
|
|
26
|
+
ux.action.stop();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from '@heroku-cli/command';
|
|
2
|
+
export default class RemoveLegalHold extends Command {
|
|
3
|
+
static id: string;
|
|
4
|
+
static aliases: string[];
|
|
5
|
+
static description: string;
|
|
6
|
+
static examples: string[];
|
|
7
|
+
static flags: {
|
|
8
|
+
user: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import SkynetAPI from '../../lib/skynet.js';
|
|
2
|
+
import { Command } from '@heroku-cli/command';
|
|
3
|
+
import { color } from '@heroku-cli/color';
|
|
4
|
+
import { Flags, ux } from '@oclif/core';
|
|
5
|
+
export default class RemoveLegalHold extends Command {
|
|
6
|
+
static id = 'skynet:legal-hold:remove';
|
|
7
|
+
static aliases = ['skynet:legal-hold:remove'];
|
|
8
|
+
static description = 'remove legal hold flag from a user';
|
|
9
|
+
static examples = [
|
|
10
|
+
'$ heroku skynet:legal-hold:remove -u foo@bar.com'
|
|
11
|
+
];
|
|
12
|
+
static flags = {
|
|
13
|
+
user: Flags.string({
|
|
14
|
+
name: 'user',
|
|
15
|
+
char: 'u',
|
|
16
|
+
description: 'user to remove legal hold from',
|
|
17
|
+
hasValue: true,
|
|
18
|
+
required: true
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
async run() {
|
|
22
|
+
const { flags } = await this.parse(RemoveLegalHold);
|
|
23
|
+
const skynet = new SkynetAPI(this.heroku.auth);
|
|
24
|
+
ux.action.start(`removing legal hold from ${color.cyan(flags.user)}`);
|
|
25
|
+
await skynet.removeLegalHold(flags.user);
|
|
26
|
+
ux.action.stop();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -5,13 +5,13 @@ export default class SuspendAppOwner extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
app: import("@oclif/core/
|
|
9
|
-
infile: import("@oclif/core/
|
|
10
|
-
category: import("@oclif/core/
|
|
11
|
-
notes: import("@oclif/core/
|
|
12
|
-
bypass: import("@oclif/core/
|
|
13
|
-
deprovision: import("@oclif/core/
|
|
14
|
-
async: import("@oclif/core/
|
|
8
|
+
app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
infile: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
category: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
notes: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
bypass: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
deprovision: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
async: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
15
|
};
|
|
16
16
|
run(): Promise<void>;
|
|
17
17
|
}
|
|
@@ -73,9 +73,9 @@ export default class SuspendAppOwner extends Command {
|
|
|
73
73
|
const apps = await readlines(file);
|
|
74
74
|
const chunks = arrayChunks(apps, chunkSize);
|
|
75
75
|
for (const chunk of chunks) {
|
|
76
|
-
|
|
76
|
+
this.log('Suspending app owners: ' + chunk.join());
|
|
77
77
|
const response = await skynet.bulkSuspendAppOwner(apps.join(), notes, category, deprovision);
|
|
78
|
-
|
|
78
|
+
this.log(`${color.cyan(response.status)}. ${response.message}`);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
else {
|
|
@@ -5,11 +5,11 @@ export default class SuspendApp extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
app: import("@oclif/core/
|
|
9
|
-
notes: import("@oclif/core/
|
|
10
|
-
category: import("@oclif/core/
|
|
11
|
-
bypass: import("@oclif/core/
|
|
12
|
-
async: import("@oclif/core/
|
|
8
|
+
app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
notes: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
category: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
bypass: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
async: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
13
|
};
|
|
14
14
|
run(): Promise<void>;
|
|
15
15
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Command } from '@heroku-cli/command';
|
|
2
|
+
export default class SuspendDirect extends Command {
|
|
3
|
+
static id: string;
|
|
4
|
+
static aliases: string[];
|
|
5
|
+
static description: string;
|
|
6
|
+
static hidden: boolean;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
user: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'app-owner': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
category: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
notes: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
bypass: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
'no-notify': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
notify: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
deprovision: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
|
+
'legal-hold': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
|
+
};
|
|
20
|
+
run(): Promise<void>;
|
|
21
|
+
private suspendUserDirect;
|
|
22
|
+
private suspendAppDirect;
|
|
23
|
+
private getAppOwnerDirect;
|
|
24
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { Command } from '@heroku-cli/command';
|
|
2
|
+
import { color } from '@heroku-cli/color';
|
|
3
|
+
import { Flags, ux } from '@oclif/core';
|
|
4
|
+
import { requireSudo } from '../../../lib/sudo.js';
|
|
5
|
+
import { saveSuspensionRecord } from '../../../lib/local-storage.js';
|
|
6
|
+
import { confirmLegalHold, confirm, promptHidden } from '../../../lib/utils.js';
|
|
7
|
+
import got from 'got';
|
|
8
|
+
export default class SuspendDirect extends Command {
|
|
9
|
+
static id = 'skynet:suspend:user:direct';
|
|
10
|
+
static aliases = ['skynet:suspend:user:direct'];
|
|
11
|
+
static description = '(requires sudo) suspends a user, app, or app-owner directly via Heroku API';
|
|
12
|
+
static hidden = true;
|
|
13
|
+
static examples = [
|
|
14
|
+
'$ heroku sudo skynet:suspend:user:direct -u foo@bar.com -n "helpful suspend message" -c "ddos"',
|
|
15
|
+
'$ heroku sudo skynet:suspend:user:direct -a myapp -n "helpful suspend message" -c "malware"',
|
|
16
|
+
'$ heroku sudo skynet:suspend:user:direct --app-owner myapp -n "helpful suspend message" -c "cryptocurrency"'
|
|
17
|
+
];
|
|
18
|
+
static flags = {
|
|
19
|
+
user: Flags.string({
|
|
20
|
+
name: 'user',
|
|
21
|
+
char: 'u',
|
|
22
|
+
description: 'user to suspend',
|
|
23
|
+
hasValue: true
|
|
24
|
+
}),
|
|
25
|
+
app: Flags.string({
|
|
26
|
+
name: 'app',
|
|
27
|
+
char: 'a',
|
|
28
|
+
description: 'app to suspend',
|
|
29
|
+
hasValue: true
|
|
30
|
+
}),
|
|
31
|
+
'app-owner': Flags.string({
|
|
32
|
+
name: 'app-owner',
|
|
33
|
+
description: 'app whose owner should be suspended',
|
|
34
|
+
hasValue: true
|
|
35
|
+
}),
|
|
36
|
+
category: Flags.string({
|
|
37
|
+
name: 'category',
|
|
38
|
+
char: 'c',
|
|
39
|
+
description: 'suspension category',
|
|
40
|
+
hasValue: true,
|
|
41
|
+
required: true
|
|
42
|
+
}),
|
|
43
|
+
notes: Flags.string({
|
|
44
|
+
name: 'notes',
|
|
45
|
+
char: 'n',
|
|
46
|
+
description: 'suspend notes',
|
|
47
|
+
hasValue: true,
|
|
48
|
+
required: true
|
|
49
|
+
}),
|
|
50
|
+
bypass: Flags.boolean({
|
|
51
|
+
name: 'bypass',
|
|
52
|
+
description: 'force suspension, bypassing skynet safety checks (N/A for direct suspension)',
|
|
53
|
+
required: false
|
|
54
|
+
}),
|
|
55
|
+
'no-notify': Flags.boolean({
|
|
56
|
+
name: 'no-notify',
|
|
57
|
+
description: 'skip user suspension email notification (user suspensions only)',
|
|
58
|
+
required: false
|
|
59
|
+
}),
|
|
60
|
+
notify: Flags.boolean({
|
|
61
|
+
name: 'notify',
|
|
62
|
+
description: 'send user suspension email notification (user suspensions only)',
|
|
63
|
+
required: false
|
|
64
|
+
}),
|
|
65
|
+
deprovision: Flags.boolean({
|
|
66
|
+
name: 'deprovision',
|
|
67
|
+
char: 'd',
|
|
68
|
+
description: 'put user into the fast resource deletion flow (user/app-owner only)',
|
|
69
|
+
required: false
|
|
70
|
+
}),
|
|
71
|
+
'legal-hold': Flags.boolean({
|
|
72
|
+
name: 'legal-hold',
|
|
73
|
+
description: 'mark suspension with legal hold status (prevents unsuspension until cleared)',
|
|
74
|
+
required: false
|
|
75
|
+
})
|
|
76
|
+
};
|
|
77
|
+
async run() {
|
|
78
|
+
requireSudo();
|
|
79
|
+
const { flags } = await this.parse(SuspendDirect);
|
|
80
|
+
// Validate that exactly one of user/app/app-owner is specified
|
|
81
|
+
const targetCount = [flags.user, flags.app, flags['app-owner']].filter(Boolean).length;
|
|
82
|
+
if (targetCount === 0) {
|
|
83
|
+
ux.error('Must specify one of: --user, --app, or --app-owner');
|
|
84
|
+
}
|
|
85
|
+
if (targetCount > 1) {
|
|
86
|
+
ux.error('Cannot specify more than one of: --user, --app, or --app-owner');
|
|
87
|
+
}
|
|
88
|
+
if (flags.notify && flags['no-notify']) {
|
|
89
|
+
ux.error('Flag --notify and --no-notify cannot be used together');
|
|
90
|
+
}
|
|
91
|
+
// Determine suspension level and target
|
|
92
|
+
let level;
|
|
93
|
+
let target;
|
|
94
|
+
let isApp;
|
|
95
|
+
if (flags.user) {
|
|
96
|
+
level = 'user';
|
|
97
|
+
target = flags.user;
|
|
98
|
+
isApp = false;
|
|
99
|
+
}
|
|
100
|
+
else if (flags.app) {
|
|
101
|
+
level = 'app';
|
|
102
|
+
target = flags.app;
|
|
103
|
+
isApp = true;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
level = 'app-owner';
|
|
107
|
+
target = flags['app-owner'];
|
|
108
|
+
isApp = false; // We'll get the user email from the app
|
|
109
|
+
}
|
|
110
|
+
// Determine notification setting (only for user suspensions)
|
|
111
|
+
let notify = process.env.SKYNET_NOTIFICATION !== 'false';
|
|
112
|
+
if (flags['no-notify']) {
|
|
113
|
+
notify = false;
|
|
114
|
+
}
|
|
115
|
+
else if (flags.notify) {
|
|
116
|
+
notify = true;
|
|
117
|
+
}
|
|
118
|
+
const notes = flags.notes;
|
|
119
|
+
const category = flags.category;
|
|
120
|
+
const deprovision = flags.deprovision || false;
|
|
121
|
+
const legalHold = flags['legal-hold'] || false;
|
|
122
|
+
// Show warning and get confirmation
|
|
123
|
+
this.log(color.yellow.bold('⚠ WARNING: Direct suspension bypasses ALL Skynet safety checks!'));
|
|
124
|
+
this.log(color.yellow('This command directly calls the Heroku API without any whitelist or classification checks.'));
|
|
125
|
+
this.log('');
|
|
126
|
+
this.log('Suspension details:');
|
|
127
|
+
this.log(` Level: ${color.cyan(level)}`);
|
|
128
|
+
this.log(` Target: ${color.cyan(target)}`);
|
|
129
|
+
this.log(` Category: ${color.cyan(category)}`);
|
|
130
|
+
this.log(` Notes: ${notes}`);
|
|
131
|
+
if (level === 'user') {
|
|
132
|
+
this.log(` Notify: ${notify ? color.green('yes') : color.red('no')}`);
|
|
133
|
+
}
|
|
134
|
+
if (deprovision) {
|
|
135
|
+
this.log(` ${color.red.bold('Deprovision: yes (fast resource deletion)')}`);
|
|
136
|
+
}
|
|
137
|
+
if (legalHold) {
|
|
138
|
+
this.log(` ${color.yellow.bold('Legal Hold: yes (prevents unsuspension)')}`);
|
|
139
|
+
}
|
|
140
|
+
this.log('');
|
|
141
|
+
const confirmed = await confirm(`Are you sure you want to suspend ${color.cyan(target)} WITHOUT Skynet safety checks? (y/n)`);
|
|
142
|
+
if (!confirmed) {
|
|
143
|
+
this.log(color.yellow('Suspension cancelled'));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Legal hold confirmation (only for user and app-owner suspensions)
|
|
147
|
+
if (legalHold && (level === 'user' || level === 'app-owner')) {
|
|
148
|
+
const legalHoldConfirmed = await confirmLegalHold();
|
|
149
|
+
if (!legalHoldConfirmed) {
|
|
150
|
+
this.log(color.yellow('Legal hold suspension cancelled'));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Prompt for Heroku Platform API Key
|
|
155
|
+
const apiKey = await promptHidden('Enter Heroku Platform API Key');
|
|
156
|
+
if (!apiKey || apiKey.trim().length === 0) {
|
|
157
|
+
ux.error('API Key is required');
|
|
158
|
+
}
|
|
159
|
+
ux.action.start(color.blue.bold(`Suspending ${level} ${color.cyan(target)} directly via Heroku API...`));
|
|
160
|
+
try {
|
|
161
|
+
let response;
|
|
162
|
+
let actualResourceId = target;
|
|
163
|
+
let actualIsApp = isApp;
|
|
164
|
+
// Handle different suspension levels
|
|
165
|
+
if (level === 'user') {
|
|
166
|
+
response = await this.suspendUserDirect(apiKey, target, notes, notify, legalHold);
|
|
167
|
+
}
|
|
168
|
+
else if (level === 'app') {
|
|
169
|
+
response = await this.suspendAppDirect(apiKey, target, notes);
|
|
170
|
+
}
|
|
171
|
+
else if (level === 'app-owner') {
|
|
172
|
+
// First, get the app owner
|
|
173
|
+
const ownerEmail = await this.getAppOwnerDirect(apiKey, target);
|
|
174
|
+
actualResourceId = ownerEmail;
|
|
175
|
+
actualIsApp = false;
|
|
176
|
+
this.log(color.dim(`App owner: ${color.cyan(ownerEmail)}`));
|
|
177
|
+
// Then suspend the owner
|
|
178
|
+
response = await this.suspendUserDirect(apiKey, ownerEmail, notes, notify, legalHold);
|
|
179
|
+
}
|
|
180
|
+
ux.action.stop();
|
|
181
|
+
// Store result locally for later upload to Skynet
|
|
182
|
+
const record = {
|
|
183
|
+
resourceId: actualResourceId,
|
|
184
|
+
isApp: actualIsApp,
|
|
185
|
+
reason: notes,
|
|
186
|
+
category,
|
|
187
|
+
action: 'suspend',
|
|
188
|
+
createdAt: new Date().toISOString(),
|
|
189
|
+
method: 'skynet-cli-direct',
|
|
190
|
+
notify: level === 'user' || level === 'app-owner' ? notify : undefined,
|
|
191
|
+
deprovision: level === 'user' || level === 'app-owner' ? deprovision : undefined,
|
|
192
|
+
legalHold: level === 'user' || level === 'app-owner' ? legalHold : undefined
|
|
193
|
+
};
|
|
194
|
+
const filepath = await saveSuspensionRecord(record);
|
|
195
|
+
this.log(color.green(`✓ ${level === 'user' ? 'User' : level === 'app' ? 'App' : 'App owner'} ${color.cyan(actualResourceId)} suspended successfully`));
|
|
196
|
+
this.log(color.dim(`Suspension record saved locally: ${filepath}`));
|
|
197
|
+
this.log(color.yellow('⚠ Remember to upload suspension records to Skynet using: heroku skynet:suspensions:upload'));
|
|
198
|
+
if (response && response.message) {
|
|
199
|
+
this.log(color.dim(`API Response: ${response.message}`));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
ux.action.stop();
|
|
204
|
+
if (error.response) {
|
|
205
|
+
const status = error.response.statusCode;
|
|
206
|
+
const body = error.response.body;
|
|
207
|
+
let errorMessage = `Failed to suspend ${target}`;
|
|
208
|
+
if (typeof body === 'string') {
|
|
209
|
+
errorMessage += `: ${body}`;
|
|
210
|
+
}
|
|
211
|
+
else if (body && body.message) {
|
|
212
|
+
errorMessage += `: ${body.message}`;
|
|
213
|
+
}
|
|
214
|
+
else if (body && body.error) {
|
|
215
|
+
errorMessage += `: ${body.error}`;
|
|
216
|
+
}
|
|
217
|
+
ux.error(`${errorMessage} (HTTP ${status})`);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
ux.error(`Failed to suspend ${target}: ${error.message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async suspendUserDirect(apiKey, email, notes, notify, legalHold) {
|
|
225
|
+
const herokuApiUrl = 'https://api.heroku.com/abuse/bulk-suspend';
|
|
226
|
+
const requestBody = {
|
|
227
|
+
notes,
|
|
228
|
+
users: [email],
|
|
229
|
+
notify,
|
|
230
|
+
legal_hold: legalHold
|
|
231
|
+
};
|
|
232
|
+
const response = await got.put(herokuApiUrl, {
|
|
233
|
+
headers: {
|
|
234
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
235
|
+
'X-Heroku-Sudo': 'true',
|
|
236
|
+
'Accept': 'application/vnd.heroku+json; version=3',
|
|
237
|
+
'Content-Type': 'application/json'
|
|
238
|
+
},
|
|
239
|
+
json: requestBody,
|
|
240
|
+
responseType: 'json',
|
|
241
|
+
timeout: {
|
|
242
|
+
request: 60000 // 60 second timeout
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
return response.body;
|
|
246
|
+
}
|
|
247
|
+
async suspendAppDirect(apiKey, appName, notes) {
|
|
248
|
+
const herokuApiUrl = `https://api.heroku.com/apps/${appName}`;
|
|
249
|
+
const formData = new URLSearchParams();
|
|
250
|
+
formData.append('suspended', 'true');
|
|
251
|
+
formData.append('suspended_notes', notes);
|
|
252
|
+
const response = await got.patch(herokuApiUrl, {
|
|
253
|
+
headers: {
|
|
254
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
255
|
+
'X-Heroku-Sudo': 'true',
|
|
256
|
+
'Accept': 'application/vnd.heroku+json; version=3',
|
|
257
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
258
|
+
},
|
|
259
|
+
body: formData.toString(),
|
|
260
|
+
responseType: 'json',
|
|
261
|
+
timeout: {
|
|
262
|
+
request: 60000 // 60 second timeout
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
return response.body;
|
|
266
|
+
}
|
|
267
|
+
async getAppOwnerDirect(apiKey, appName) {
|
|
268
|
+
const herokuApiUrl = `https://api.heroku.com/apps/${appName}`;
|
|
269
|
+
const response = await got.get(herokuApiUrl, {
|
|
270
|
+
headers: {
|
|
271
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
272
|
+
'X-Heroku-Sudo': 'true',
|
|
273
|
+
'Accept': 'application/vnd.heroku+json; version=3'
|
|
274
|
+
},
|
|
275
|
+
responseType: 'json',
|
|
276
|
+
timeout: {
|
|
277
|
+
request: 60000 // 60 second timeout
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
const body = response.body;
|
|
281
|
+
if (!body.owner || !body.owner.email) {
|
|
282
|
+
throw new Error('Unable to determine app owner from API response');
|
|
283
|
+
}
|
|
284
|
+
return body.owner.email;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -5,15 +5,16 @@ export default class SuspendUser extends Command {
|
|
|
5
5
|
static description: string;
|
|
6
6
|
static examples: string[];
|
|
7
7
|
static flags: {
|
|
8
|
-
user: import("@oclif/core/
|
|
9
|
-
infile: import("@oclif/core/
|
|
10
|
-
category: import("@oclif/core/
|
|
11
|
-
notes: import("@oclif/core/
|
|
12
|
-
bypass: import("@oclif/core/
|
|
13
|
-
'no-notify': import("@oclif/core/
|
|
14
|
-
notify: import("@oclif/core/
|
|
15
|
-
deprovision: import("@oclif/core/
|
|
16
|
-
async: import("@oclif/core/
|
|
8
|
+
user: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
infile: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
category: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
notes: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
bypass: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
'no-notify': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
notify: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
deprovision: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
async: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
'legal-hold': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
18
|
};
|
|
18
19
|
run(): Promise<void>;
|
|
19
20
|
}
|