[AC-2631] Add device-approval command scaffolding (#9351)

* Add device-approval scaffolding

* Refactor: move helpers to BaseProgram

* Update CODEOWNERS
This commit is contained in:
Thomas Rittson 2024-05-27 11:03:23 +10:00 committed by GitHub
parent 89d7e96b25
commit c0bb7b9edf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 333 additions and 160 deletions

1
.github/CODEOWNERS vendored
View File

@ -52,6 +52,7 @@ apps/cli/src/admin-console @bitwarden/team-admin-console-dev
apps/desktop/src/admin-console @bitwarden/team-admin-console-dev
apps/web/src/app/admin-console @bitwarden/team-admin-console-dev
bitwarden_license/bit-web/src/app/admin-console @bitwarden/team-admin-console-dev
bitwarden_license/bit-cli/src/admin-console @bitwarden/team-admin-console-dev
libs/angular/src/admin-console @bitwarden/team-admin-console-dev
libs/common/src/admin-console @bitwarden/team-admin-console-dev
libs/admin-console @bitwarden/team-admin-console-dev

View File

@ -0,0 +1,173 @@
import * as chalk from "chalk";
import { firstValueFrom, map } from "rxjs";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UnlockCommand } from "./auth/commands/unlock.command";
import { Response } from "./models/response";
import { ListResponse } from "./models/response/list.response";
import { MessageResponse } from "./models/response/message.response";
import { StringResponse } from "./models/response/string.response";
import { TemplateResponse } from "./models/response/template.response";
import { ServiceContainer } from "./service-container";
import { CliUtils } from "./utils";
const writeLn = CliUtils.writeLn;
export abstract class BaseProgram {
constructor(protected serviceContainer: ServiceContainer) {}
protected processResponse(response: Response, exitImmediately = false) {
if (!response.success) {
if (process.env.BW_QUIET !== "true") {
if (process.env.BW_RESPONSE === "true") {
writeLn(this.getJson(response), true, false);
} else {
writeLn(chalk.redBright(response.message), true, true);
}
}
const exitCode = process.env.BW_CLEANEXIT ? 0 : 1;
if (exitImmediately) {
process.exit(exitCode);
} else {
process.exitCode = exitCode;
}
return;
}
if (process.env.BW_RESPONSE === "true") {
writeLn(this.getJson(response), true, false);
} else if (response.data != null) {
let out: string = null;
if (response.data.object === "template") {
out = this.getJson((response.data as TemplateResponse).template);
}
if (out == null) {
if (response.data.object === "string") {
const data = (response.data as StringResponse).data;
if (data != null) {
out = data;
}
} else if (response.data.object === "list") {
out = this.getJson((response.data as ListResponse).data);
} else if (response.data.object === "message") {
out = this.getMessage(response);
} else {
out = this.getJson(response.data);
}
}
if (out != null && process.env.BW_QUIET !== "true") {
writeLn(out, true, false);
}
}
if (exitImmediately) {
process.exit(0);
} else {
process.exitCode = 0;
}
}
private getJson(obj: any): string {
if (process.env.BW_PRETTY === "true") {
return JSON.stringify(obj, null, " ");
} else {
return JSON.stringify(obj);
}
}
protected getMessage(response: Response): string {
const message = response.data as MessageResponse;
if (process.env.BW_RAW === "true") {
return message.raw;
}
let out = "";
if (message.title != null) {
if (message.noColor) {
out = message.title;
} else {
out = chalk.greenBright(message.title);
}
}
if (message.message != null) {
if (message.title != null) {
out += "\n";
}
out += message.message;
}
return out.trim() === "" ? null : out;
}
protected async exitIfAuthed() {
const authed = await firstValueFrom(
this.serviceContainer.authService.activeAccountStatus$.pipe(
map((status) => status > AuthenticationStatus.LoggedOut),
),
);
if (authed) {
const email = await firstValueFrom(
this.serviceContainer.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.processResponse(Response.error("You are already logged in as " + email + "."), true);
}
}
protected async exitIfNotAuthed() {
const authed = await this.serviceContainer.stateService.getIsAuthenticated();
if (!authed) {
this.processResponse(Response.error("You are not logged in."), true);
}
}
protected async exitIfLocked() {
await this.exitIfNotAuthed();
if (await this.serviceContainer.cryptoService.hasUserKey()) {
return;
} else if (process.env.BW_NOINTERACTION !== "true") {
// must unlock
if (await this.serviceContainer.keyConnectorService.getUsesKeyConnector()) {
const response = Response.error(
"Your vault is locked. You must unlock your vault using your session key.\n" +
"If you do not have your session key, you can get a new one by logging out and logging in again.",
);
this.processResponse(response, true);
} else {
const command = new UnlockCommand(
this.serviceContainer.accountService,
this.serviceContainer.masterPasswordService,
this.serviceContainer.cryptoService,
this.serviceContainer.stateService,
this.serviceContainer.cryptoFunctionService,
this.serviceContainer.apiService,
this.serviceContainer.logService,
this.serviceContainer.keyConnectorService,
this.serviceContainer.environmentService,
this.serviceContainer.syncService,
this.serviceContainer.organizationApiService,
this.serviceContainer.logout,
this.serviceContainer.kdfConfigService,
);
const response = await command.run(null, null);
if (!response.success) {
this.processResponse(response, true);
}
}
} else {
this.processResponse(Response.error("Vault is locked."), true);
}
}
protected async exitIfFeatureFlagDisabled(featureFlag: FeatureFlag) {
const enabled = await firstValueFrom(
this.serviceContainer.configService.getFeatureFlag$(featureFlag),
);
if (!enabled) {
this.processResponse(Response.error("This command is temporarily unavailable."), true);
}
}
}

View File

@ -1,6 +1,6 @@
import * as chalk from "chalk";
import { program, Command, OptionValues } from "commander";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@ -8,6 +8,7 @@ import { LockCommand } from "./auth/commands/lock.command";
import { LoginCommand } from "./auth/commands/login.command";
import { LogoutCommand } from "./auth/commands/logout.command";
import { UnlockCommand } from "./auth/commands/unlock.command";
import { BaseProgram } from "./base-program";
import { CompletionCommand } from "./commands/completion.command";
import { ConfigCommand } from "./commands/config.command";
import { EncodeCommand } from "./commands/encode.command";
@ -15,20 +16,14 @@ import { ServeCommand } from "./commands/serve.command";
import { StatusCommand } from "./commands/status.command";
import { UpdateCommand } from "./commands/update.command";
import { Response } from "./models/response";
import { ListResponse } from "./models/response/list.response";
import { MessageResponse } from "./models/response/message.response";
import { StringResponse } from "./models/response/string.response";
import { TemplateResponse } from "./models/response/template.response";
import { ServiceContainer } from "./service-container";
import { GenerateCommand } from "./tools/generate.command";
import { CliUtils } from "./utils";
import { SyncCommand } from "./vault/sync.command";
const writeLn = CliUtils.writeLn;
export class Program {
constructor(protected serviceContainer: ServiceContainer) {}
export class Program extends BaseProgram {
async register() {
program
.option("--pretty", "Format output. JSON is tabbed with two spaces.")
@ -517,147 +512,4 @@ export class Program {
await command.run(cmd);
});
}
protected processResponse(response: Response, exitImmediately = false) {
if (!response.success) {
if (process.env.BW_QUIET !== "true") {
if (process.env.BW_RESPONSE === "true") {
writeLn(this.getJson(response), true, false);
} else {
writeLn(chalk.redBright(response.message), true, true);
}
}
const exitCode = process.env.BW_CLEANEXIT ? 0 : 1;
if (exitImmediately) {
process.exit(exitCode);
} else {
process.exitCode = exitCode;
}
return;
}
if (process.env.BW_RESPONSE === "true") {
writeLn(this.getJson(response), true, false);
} else if (response.data != null) {
let out: string = null;
if (response.data.object === "template") {
out = this.getJson((response.data as TemplateResponse).template);
}
if (out == null) {
if (response.data.object === "string") {
const data = (response.data as StringResponse).data;
if (data != null) {
out = data;
}
} else if (response.data.object === "list") {
out = this.getJson((response.data as ListResponse).data);
} else if (response.data.object === "message") {
out = this.getMessage(response);
} else {
out = this.getJson(response.data);
}
}
if (out != null && process.env.BW_QUIET !== "true") {
writeLn(out, true, false);
}
}
if (exitImmediately) {
process.exit(0);
} else {
process.exitCode = 0;
}
}
private getJson(obj: any): string {
if (process.env.BW_PRETTY === "true") {
return JSON.stringify(obj, null, " ");
} else {
return JSON.stringify(obj);
}
}
private getMessage(response: Response): string {
const message = response.data as MessageResponse;
if (process.env.BW_RAW === "true") {
return message.raw;
}
let out = "";
if (message.title != null) {
if (message.noColor) {
out = message.title;
} else {
out = chalk.greenBright(message.title);
}
}
if (message.message != null) {
if (message.title != null) {
out += "\n";
}
out += message.message;
}
return out.trim() === "" ? null : out;
}
private async exitIfAuthed() {
const authed = await firstValueFrom(
this.serviceContainer.authService.activeAccountStatus$.pipe(
map((status) => status > AuthenticationStatus.LoggedOut),
),
);
if (authed) {
const email = await firstValueFrom(
this.serviceContainer.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.processResponse(Response.error("You are already logged in as " + email + "."), true);
}
}
private async exitIfNotAuthed() {
const authed = await this.serviceContainer.stateService.getIsAuthenticated();
if (!authed) {
this.processResponse(Response.error("You are not logged in."), true);
}
}
protected async exitIfLocked() {
await this.exitIfNotAuthed();
if (await this.serviceContainer.cryptoService.hasUserKey()) {
return;
} else if (process.env.BW_NOINTERACTION !== "true") {
// must unlock
if (await this.serviceContainer.keyConnectorService.getUsesKeyConnector()) {
const response = Response.error(
"Your vault is locked. You must unlock your vault using your session key.\n" +
"If you do not have your session key, you can get a new one by logging out and logging in again.",
);
this.processResponse(response, true);
} else {
const command = new UnlockCommand(
this.serviceContainer.accountService,
this.serviceContainer.masterPasswordService,
this.serviceContainer.cryptoService,
this.serviceContainer.stateService,
this.serviceContainer.cryptoFunctionService,
this.serviceContainer.apiService,
this.serviceContainer.logService,
this.serviceContainer.keyConnectorService,
this.serviceContainer.environmentService,
this.serviceContainer.syncService,
this.serviceContainer.organizationApiService,
this.serviceContainer.logout,
this.serviceContainer.kdfConfigService,
);
const response = await command.run(null, null);
if (!response.success) {
this.processResponse(response, true);
}
}
} else {
this.processResponse(Response.error("Vault is locked."), true);
}
}
}

View File

@ -15,8 +15,8 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) {
await program.register();
const vaultProgram = new VaultProgram(serviceContainer);
await vaultProgram.register();
vaultProgram.register();
const sendProgram = new SendProgram(serviceContainer);
await sendProgram.register();
sendProgram.register();
}

View File

@ -7,9 +7,9 @@ import { program, Command, OptionValues } from "commander";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { BaseProgram } from "../../base-program";
import { GetCommand } from "../../commands/get.command";
import { Response } from "../../models/response";
import { Program } from "../../program";
import { CliUtils } from "../../utils";
import {
@ -27,8 +27,8 @@ import { SendResponse } from "./models/send.response";
const writeLn = CliUtils.writeLn;
export class SendProgram extends Program {
async register() {
export class SendProgram extends BaseProgram {
register() {
program.addCommand(this.sendCommand());
// receive is accessible both at `bw receive` and `bw send receive`
program.addCommand(this.receiveCommand());

View File

@ -2,12 +2,12 @@ import { program, Command } from "commander";
import { ConfirmCommand } from "./admin-console/commands/confirm.command";
import { ShareCommand } from "./admin-console/commands/share.command";
import { BaseProgram } from "./base-program";
import { EditCommand } from "./commands/edit.command";
import { GetCommand } from "./commands/get.command";
import { ListCommand } from "./commands/list.command";
import { RestoreCommand } from "./commands/restore.command";
import { Response } from "./models/response";
import { Program } from "./program";
import { ExportCommand } from "./tools/export.command";
import { ImportCommand } from "./tools/import.command";
import { CliUtils } from "./utils";
@ -16,8 +16,8 @@ import { DeleteCommand } from "./vault/delete.command";
const writeLn = CliUtils.writeLn;
export class VaultProgram extends Program {
async register() {
export class VaultProgram extends BaseProgram {
register() {
program
.addCommand(this.listCommand())
.addCommand(this.getCommand())

View File

@ -0,0 +1,9 @@
import { Response } from "@bitwarden/cli/models/response";
export class ApproveAllCommand {
constructor() {}
async run(organizationId: string): Promise<Response> {
throw new Error("Not implemented");
}
}

View File

@ -0,0 +1,9 @@
import { Response } from "@bitwarden/cli/models/response";
export class ApproveCommand {
constructor() {}
async run(id: string): Promise<Response> {
throw new Error("Not implemented");
}
}

View File

@ -0,0 +1,9 @@
import { Response } from "@bitwarden/cli/models/response";
export class DenyAllCommand {
constructor() {}
async run(organizationId: string): Promise<Response> {
throw new Error("Not implemented");
}
}

View File

@ -0,0 +1,9 @@
import { Response } from "@bitwarden/cli/models/response";
export class DenyCommand {
constructor() {}
async run(id: string): Promise<Response> {
throw new Error("Not implemented");
}
}

View File

@ -0,0 +1,96 @@
import { program, Command } from "commander";
import { BaseProgram } from "@bitwarden/cli/base-program";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ApproveAllCommand } from "./approve-all.command";
import { ApproveCommand } from "./approve.command";
import { DenyAllCommand } from "./deny-all.command";
import { DenyCommand } from "./deny.command";
import { ListCommand } from "./list.command";
export class DeviceApprovalProgram extends BaseProgram {
register() {
program.addCommand(this.deviceApprovalCommand());
}
private deviceApprovalCommand() {
return new Command("device-approval")
.description("Manage device approvals")
.addCommand(this.listCommand())
.addCommand(this.approveCommand())
.addCommand(this.approveAllCommand())
.addCommand(this.denyCommand())
.addCommand(this.denyAllCommand());
}
private listCommand(): Command {
return new Command("list")
.description("List all pending requests for an organization")
.argument("<organizationId>")
.action(async (organizationId: string) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfNotAuthed();
const cmd = new ListCommand();
const response = await cmd.run(organizationId);
this.processResponse(response);
});
}
private approveCommand(): Command {
return new Command("approve")
.argument("<id>")
.description("Approve a pending request")
.action(async (id: string) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
const cmd = new ApproveCommand();
const response = await cmd.run(id);
this.processResponse(response);
});
}
private approveAllCommand(): Command {
return new Command("approveAll")
.description("Approve all pending requests for an organization")
.argument("<organizationId>")
.action(async (organizationId: string) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
const cmd = new ApproveAllCommand();
const response = await cmd.run(organizationId);
this.processResponse(response);
});
}
private denyCommand(): Command {
return new Command("deny")
.argument("<id>")
.description("Deny a pending request")
.action(async (id: string) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
const cmd = new DenyCommand();
const response = await cmd.run(id);
this.processResponse(response);
});
}
private denyAllCommand(): Command {
return new Command("denyAll")
.description("Deny all pending requests for an organization")
.argument("<organizationId>")
.action(async (organizationId: string) => {
await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval);
await this.exitIfLocked();
const cmd = new DenyAllCommand();
const response = await cmd.run(organizationId);
this.processResponse(response);
});
}
}

View File

@ -0,0 +1 @@
export { DeviceApprovalProgram } from "./device-approval.program";

View File

@ -0,0 +1,9 @@
import { Response } from "@bitwarden/cli/models/response";
export class ListCommand {
constructor() {}
async run(organizationId: string): Promise<Response> {
throw new Error("Not implemented");
}
}

View File

@ -1,3 +1,4 @@
import { DeviceApprovalProgram } from "./admin-console/device-approval";
import { ServiceContainer } from "./service-container";
/**
@ -7,4 +8,6 @@ import { ServiceContainer } from "./service-container";
* myProgram.register();
* @param serviceContainer A class that instantiates services and makes them available for dependency injection
*/
export async function registerBitPrograms(serviceContainer: ServiceContainer) {}
export async function registerBitPrograms(serviceContainer: ServiceContainer) {
new DeviceApprovalProgram(serviceContainer).register();
}

View File

@ -16,6 +16,7 @@ export enum FeatureFlag {
ExtensionRefresh = "extension-refresh",
RestrictProviderAccess = "restrict-provider-access",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
BulkDeviceApproval = "bulk-device-approval",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@ -42,6 +43,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ExtensionRefresh]: FALSE,
[FeatureFlag.RestrictProviderAccess]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
[FeatureFlag.BulkDeviceApproval]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;