@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.
Files changed (34) hide show
  1. package/README.md +47 -0
  2. package/dist/commands/allowlist/add.d.ts +2 -2
  3. package/dist/commands/allowlist/remove.d.ts +1 -1
  4. package/dist/commands/categories/add.d.ts +2 -2
  5. package/dist/commands/categories/remove.d.ts +1 -1
  6. package/dist/commands/deprovision.d.ts +6 -6
  7. package/dist/commands/deprovision.js +1 -1
  8. package/dist/commands/legal-hold/add.d.ts +11 -0
  9. package/dist/commands/legal-hold/add.js +28 -0
  10. package/dist/commands/legal-hold/remove.d.ts +11 -0
  11. package/dist/commands/legal-hold/remove.js +28 -0
  12. package/dist/commands/suspend/app-owner.d.ts +7 -7
  13. package/dist/commands/suspend/app-owner.js +2 -2
  14. package/dist/commands/suspend/apps.d.ts +5 -5
  15. package/dist/commands/suspend/user/direct.d.ts +24 -0
  16. package/dist/commands/suspend/user/direct.js +286 -0
  17. package/dist/commands/suspend/user.d.ts +10 -9
  18. package/dist/commands/suspend/user.js +17 -3
  19. package/dist/commands/suspensions/upload.d.ts +9 -0
  20. package/dist/commands/suspensions/upload.js +111 -0
  21. package/dist/commands/suspensions.d.ts +1 -1
  22. package/dist/commands/suspensions.js +2 -2
  23. package/dist/commands/unsuspend/apps.d.ts +3 -3
  24. package/dist/commands/unsuspend/user.d.ts +3 -3
  25. package/dist/commands/userpass/add.d.ts +2 -2
  26. package/dist/commands/userpass/remove.d.ts +2 -2
  27. package/dist/lib/local-storage.d.ts +31 -0
  28. package/dist/lib/local-storage.js +88 -0
  29. package/dist/lib/skynet.d.ts +16 -2
  30. package/dist/lib/skynet.js +24 -2
  31. package/dist/lib/utils.d.ts +3 -0
  32. package/dist/lib/utils.js +64 -7
  33. package/oclif.manifest.json +560 -176
  34. package/package.json +13 -13
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/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
- notes: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
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/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
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/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
- description: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
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/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
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/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
- category: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
- notes: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
- bypass: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
12
- notify: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
13
- 'no-notify': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
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
- ux.log(`${color.cyan(response.status)}. ${response.message}. Notification ${notificationStatus}`);
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/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
- infile: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
- category: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
- notes: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
- bypass: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
13
- deprovision: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
14
- async: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
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
- ux.log('Suspending app owners: ' + chunk.join());
76
+ this.log('Suspending app owners: ' + chunk.join());
77
77
  const response = await skynet.bulkSuspendAppOwner(apps.join(), notes, category, deprovision);
78
- ux.log(`${color.cyan(response.status)}. ${response.message}`);
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/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
- notes: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
- category: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
- bypass: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
12
- async: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
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/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
9
- infile: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
10
- category: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
- notes: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
- bypass: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
13
- 'no-notify': import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
14
- notify: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
15
- deprovision: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
16
- async: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
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
  }