diff --git a/cli/apply.go b/cli/apply.go index 01a79ca1..942c46f3 100644 --- a/cli/apply.go +++ b/cli/apply.go @@ -29,6 +29,24 @@ type ResourceOperationResult struct { MetadataURL string } +// acknowledgeNotHipaaFlag is the package-level flag toggled by --accept-not-hipaa +// on bl deploy / bl apply. When true, every resource upsert routed through +// handleResourceOperation carries the X-Blaxel-Acknowledge-Not-Hipaa header, +// which the controlplane treats as one-shot consent to deploy in regions or +// products that are not HIPAA-compliant. +var acknowledgeNotHipaaFlag bool + +// SetAcknowledgeNotHipaa toggles the per-process acknowledgement flag. +// Exported so deploy / apply commands can flip it from their cobra Run funcs. +func SetAcknowledgeNotHipaa(ack bool) { + acknowledgeNotHipaaFlag = ack +} + +// AcknowledgeNotHipaa returns the current per-process acknowledgement flag. +func AcknowledgeNotHipaa() bool { + return acknowledgeNotHipaaFlag +} + type ApplyResult struct { Kind string Name string @@ -55,6 +73,7 @@ func ApplyCmd() *cobra.Command { var recursive bool var envFiles []string var commandSecrets []string + var acceptNotHipaa bool cmd := &cobra.Command{ Use: "apply", Short: "Apply a configuration to a resource by file", @@ -149,6 +168,7 @@ via -e flag for .env files or -s flag for command-line secrets.`, Run: func(cmd *cobra.Command, args []string) { core.LoadCommandSecrets(commandSecrets) core.ReadSecrets("", envFiles) + SetAcknowledgeNotHipaa(acceptNotHipaa) applyResults, err := Apply(filePath, WithRecursive(recursive)) if err != nil { core.PrintError("Apply", err) @@ -179,6 +199,7 @@ via -e flag for .env files or -s flag for command-line secrets.`, cmd.Flags().BoolVarP(&recursive, "recursive", "R", false, "Process the directory used in -f, --filename recursively. Useful when you want to manage related manifests organized within the same directory.") cmd.Flags().StringSliceVarP(&envFiles, "env-file", "e", []string{".env"}, "Environment file to load") cmd.Flags().StringSliceVarP(&commandSecrets, "secrets", "s", []string{}, "Secrets to deploy") + cmd.Flags().BoolVar(&acceptNotHipaa, "accept-not-hipaa", false, "Acknowledge that the resources in this apply may target regions/products that are NOT HIPAA-compliant. Sends X-Blaxel-Acknowledge-Not-Hipaa: true so the controlplane permits the deploy even when the workspace has not set hipaaUnsafe=true.") err := cmd.MarkFlagRequired("filename") if err != nil { core.PrintError("Apply", err) @@ -356,6 +377,9 @@ func handleResourceOperation(resource *core.Resource, name string, resourceObjec if autogeneratedInLabels { opts = append(opts, option.WithQuery("upload", "true")) } + if AcknowledgeNotHipaa() { + opts = append(opts, option.WithHeader("X-Blaxel-Acknowledge-Not-Hipaa", "true")) + } // Preserve extra runtime fields that the SDK's typed param structs // don't model (e.g. dockerConfig, skipBuild for registry image builds). diff --git a/cli/deploy.go b/cli/deploy.go index 15c4d951..43e2731d 100644 --- a/cli/deploy.go +++ b/cli/deploy.go @@ -48,6 +48,7 @@ func DeployCmd() *cobra.Command { var dockerConfigPath string var timeoutStr string var buildEnvPath string + var acceptNotHipaa bool cmd := &cobra.Command{ Use: "deploy", @@ -116,6 +117,7 @@ all projects in a monorepo (looks for blaxel.toml in subdirectories).`, Run: func(cmd *cobra.Command, args []string) { core.LoadCommandSecrets(commandSecrets) core.ReadSecrets(folder, envFiles) + SetAcknowledgeNotHipaa(acceptNotHipaa) // If the user did not explicitly set --yes, decide default based on TTY and CI if !cmd.Flags().Changed("yes") { // By default use TTY mode (noTTY=false) if terminal is interactive and not in CI @@ -341,6 +343,7 @@ all projects in a monorepo (looks for blaxel.toml in subdirectories).`, cmd.Flags().StringVar(&dockerConfigPath, "docker-config", "", "Path to a Docker config.json file with registry credentials") cmd.Flags().StringVar(&timeoutStr, "timeout", "", "Timeout for build and deployment monitoring (e.g. 30m, 1h). Defaults to 15m") cmd.Flags().StringVar(&buildEnvPath, "build-env-file", "", "Path to a build env file with Docker build args (default: auto-detect .env.build)") + cmd.Flags().BoolVar(&acceptNotHipaa, "accept-not-hipaa", false, "Acknowledge that this deploy may target regions/products that are NOT HIPAA-compliant. Sends X-Blaxel-Acknowledge-Not-Hipaa: true so the controlplane permits the deploy even when the workspace has not set hipaaUnsafe=true.") return cmd } diff --git a/cli/workspace.go b/cli/workspace.go index 6d681835..58d86f6f 100644 --- a/cli/workspace.go +++ b/cli/workspace.go @@ -1,13 +1,17 @@ package cli import ( + "bufio" "context" "fmt" + "os" + "strings" blaxel "github.com/blaxel-ai/sdk-go" "github.com/blaxel-ai/sdk-go/option" "github.com/blaxel-ai/toolkit/cli/core" "github.com/spf13/cobra" + "golang.org/x/term" ) func init() { @@ -101,9 +105,221 @@ To list all authenticated workspaces, run without arguments.`, cmd.Flags().BoolVar(¤t, "current", false, "Display only the current workspace name") + cmd.AddCommand(WorkspaceHipaaCmd()) + + return cmd +} + +// workspaceHipaaResponse mirrors the parts of the controlplane Workspace JSON +// response that this command cares about. The sdk-go Workspace struct is +// generated from an older spec and does not expose hipaaUnsafe directly. +type workspaceHipaaResponse struct { + HipaaUnsafe bool `json:"hipaaUnsafe"` + Name string `json:"name"` +} + +// WorkspaceHipaaCmd is the parent of `bl workspaces hipaa ...`. +func WorkspaceHipaaCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "hipaa", + Short: "Manage non-HIPAA-compliant deploy consent for the workspace", + Long: `Manage the workspace-level consent to deploy in non-HIPAA-compliant +regions/products (workspace.hipaaUnsafe). + +By default, deploying agents, sandboxes, functions or jobs to a region +that is not HIPAA-compliant is blocked. There are two ways to unblock +such a deploy: + - Standing consent: set workspace.hipaaUnsafe=true with 'accept'. All + future non-HIPAA-compliant deploys from this workspace will be + allowed. + - Per-deploy consent: pass '--accept-not-hipaa' to 'bl deploy' / + 'bl apply' for a single non-HIPAA-compliant deploy. + +Use 'bl workspaces hipaa accept' to grant standing consent, +'bl workspaces hipaa decline' to revoke it, and +'bl workspaces hipaa status' to inspect the current state.`, + } + + cmd.AddCommand(WorkspaceHipaaAcceptCmd()) + cmd.AddCommand(WorkspaceHipaaDeclineCmd()) + cmd.AddCommand(WorkspaceHipaaStatusCmd()) + return cmd +} + +// WorkspaceHipaaAcceptCmd flips workspace.hipaaUnsafe to true. +func WorkspaceHipaaAcceptCmd() *cobra.Command { + var assumeYes bool + + cmd := &cobra.Command{ + Use: "accept", + Short: "Allow non-HIPAA-compliant deploys for the current workspace", + Long: `Grant standing consent for the current workspace to deploy in +regions/products that are NOT HIPAA-compliant (workspace.hipaaUnsafe=true). + +By accepting, a workspace admin acknowledges that: + - Future deploys from this workspace MAY target regions and products + that are not HIPAA-compliant, with no further acknowledgement required. + - Protected health information must not be processed in those deploys. + +The '--workspace' global flag, if set, targets a different workspace. Only +workspace admins can change this setting.`, + Example: ` # Allow non-HIPAA-compliant deploys for the current workspace (prompts) + bl workspaces hipaa accept + + # Accept without an interactive prompt (useful in CI) + bl workspaces hipaa accept --yes + + # Accept for a workspace other than the current one + bl workspaces hipaa accept --workspace prod -y`, + Run: func(cmd *cobra.Command, args []string) { + runWorkspaceHipaaUpdate(cmd.Context(), true, assumeYes) + }, + } + + cmd.Flags().BoolVarP(&assumeYes, "yes", "y", false, "Skip the interactive confirmation prompt") + return cmd +} + +// WorkspaceHipaaDeclineCmd flips workspace.hipaaUnsafe back to false. +func WorkspaceHipaaDeclineCmd() *cobra.Command { + var assumeYes bool + + cmd := &cobra.Command{ + Use: "decline", + Short: "Block non-HIPAA-compliant deploys for the current workspace", + Long: `Revoke the workspace's standing consent to deploy in non-HIPAA- +compliant regions/products (workspace.hipaaUnsafe=false). + +After declining, non-HIPAA-compliant deploys will be rejected unless the +individual deploy is acknowledged with '--accept-not-hipaa'. Existing +resources are not affected. Only workspace admins can change this setting.`, + Run: func(cmd *cobra.Command, args []string) { + runWorkspaceHipaaUpdate(cmd.Context(), false, assumeYes) + }, + } + + cmd.Flags().BoolVarP(&assumeYes, "yes", "y", false, "Skip the interactive confirmation prompt") return cmd } +// WorkspaceHipaaStatusCmd prints the current value of workspace.hipaaUnsafe. +func WorkspaceHipaaStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show whether non-HIPAA-compliant deploys are allowed in the workspace", + Run: func(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + workspaceName := resolveWorkspaceName() + ws, err := fetchWorkspaceHipaa(ctx, workspaceName) + if err != nil { + core.PrintError("Workspace", err) + core.ExitWithError(err) + } + state := "blocked (workspace.hipaaUnsafe=false)" + if ws.HipaaUnsafe { + state = "allowed (workspace.hipaaUnsafe=true)" + } + fmt.Printf("Workspace %s: non-HIPAA-compliant deploys %s\n", workspaceName, state) + }, + } +} + +func runWorkspaceHipaaUpdate(ctx context.Context, hipaaUnsafe bool, assumeYes bool) { + if ctx == nil { + ctx = context.Background() + } + workspaceName := resolveWorkspaceName() + + // Run the cheap client-availability check before any interactive prompt + // so an unauthenticated user is told to `bl login` immediately instead + // of after a successful y/N dialog. + client := core.GetClient() + if client == nil { + err := fmt.Errorf("no API client available. Please run 'bl login' first") + core.PrintError("Workspace", err) + core.ExitWithError(err) + } + + if !assumeYes && !confirmHipaaChange(workspaceName, hipaaUnsafe) { + core.Print("Aborted.\n") + return + } + + body := map[string]bool{"hipaaUnsafe": hipaaUnsafe} + var res workspaceHipaaResponse + path := fmt.Sprintf("workspaces/%s/hipaa", workspaceName) + if err := client.Put(ctx, path, body, &res); err != nil { + msg := extractErrorMessage(err) + core.PrintError("Workspace", fmt.Errorf("failed to update HIPAA-unsafe consent: %s", msg)) + core.ExitWithError(err) + } + + if hipaaUnsafe { + fmt.Printf("Workspace %s: non-HIPAA-compliant deploys are now allowed.\n", workspaceName) + } else { + fmt.Printf("Workspace %s: non-HIPAA-compliant deploys are now blocked.\n", workspaceName) + } +} + +// fetchWorkspaceHipaa loads the workspace through the generic client so we can +// read fields (hipaaUnsafe) that the older sdk-go Workspace struct does not +// expose as typed members. +func fetchWorkspaceHipaa(ctx context.Context, workspaceName string) (workspaceHipaaResponse, error) { + client := core.GetClient() + if client == nil { + return workspaceHipaaResponse{}, fmt.Errorf("no API client available. Please run 'bl login' first") + } + var res workspaceHipaaResponse + path := fmt.Sprintf("workspaces/%s", workspaceName) + if err := client.Get(ctx, path, nil, &res); err != nil { + return workspaceHipaaResponse{}, fmt.Errorf("%s", extractErrorMessage(err)) + } + if res.Name == "" { + res.Name = workspaceName + } + return res, nil +} + +// resolveWorkspaceName returns the workspace targeted by the command — the +// --workspace override if provided, otherwise the current workspace from +// the local config. +func resolveWorkspaceName() string { + if ws := core.GetWorkspace(); ws != "" { + return ws + } + ctx, _ := blaxel.CurrentContext() + if ctx.Workspace == "" { + err := fmt.Errorf("no workspace selected. Run 'bl login' or pass --workspace") + core.PrintError("Workspace", err) + core.ExitWithError(err) + } + return ctx.Workspace +} + +// confirmHipaaChange shows the change and asks for y/N when stdin is a TTY. +// Non-TTY callers (CI, pipelines) must pass --yes explicitly. +func confirmHipaaChange(workspaceName string, hipaaUnsafe bool) bool { + if !term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprintln(os.Stderr, "Refusing to change HIPAA-unsafe consent without --yes (stdin is not a terminal).") + return false + } + action := "ALLOW non-HIPAA-compliant deploys" + if !hipaaUnsafe { + action = "BLOCK non-HIPAA-compliant deploys" + } + fmt.Printf("About to %s for workspace '%s'. Continue? [y/N]: ", action, workspaceName) + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return false + } + answer := strings.TrimSpace(strings.ToLower(line)) + return answer == "y" || answer == "yes" +} + func CheckWorkspaceAccess(workspaceName string, credentials blaxel.Credentials) (blaxel.Workspace, error) { // Build client options based on credentials opts := []option.RequestOption{ diff --git a/cli/workspace_test.go b/cli/workspace_test.go index 73a40883..fa92e0d7 100644 --- a/cli/workspace_test.go +++ b/cli/workspace_test.go @@ -90,3 +90,44 @@ func TestTokenCmdArguments(t *testing.T) { // Token cmd takes optional workspace argument assert.NotNil(t, cmd.Args) } + +func TestWorkspaceHipaaCmd(t *testing.T) { + cmd := WorkspaceHipaaCmd() + + assert.Equal(t, "hipaa", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + + subcommands := make(map[string]bool, len(cmd.Commands())) + for _, sub := range cmd.Commands() { + subcommands[sub.Use] = true + } + assert.True(t, subcommands["accept"], "expected accept subcommand") + assert.True(t, subcommands["decline"], "expected decline subcommand") + assert.True(t, subcommands["status"], "expected status subcommand") +} + +func TestWorkspaceHipaaSubcommandFlags(t *testing.T) { + for _, name := range []string{"accept", "decline"} { + var cmd = WorkspaceHipaaAcceptCmd() + if name == "decline" { + cmd = WorkspaceHipaaDeclineCmd() + } + flag := cmd.Flags().Lookup("yes") + assert.NotNil(t, flag, "%s should expose --yes", name) + assert.Equal(t, "y", flag.Shorthand, "%s --yes shorthand", name) + } +} + +func TestListOrSetWorkspacesCmdRegistersHipaaSubcommand(t *testing.T) { + cmd := ListOrSetWorkspacesCmd() + + var hipaa bool + for _, sub := range cmd.Commands() { + if sub.Use == "hipaa" { + hipaa = true + break + } + } + assert.True(t, hipaa, "workspaces command should attach `hipaa` subcommand") +}