diff --git a/.github/workflows/prb_linux.yml b/.github/workflows/prb_linux.yml index ea166811..210e8970 100644 --- a/.github/workflows/prb_linux.yml +++ b/.github/workflows/prb_linux.yml @@ -2,6 +2,7 @@ name: PRB_Linux permissions: contents: read env: + SAIL_AUTHTYPE: pat SAIL_CLIENT_ID: ${{ secrets.SDK_TEST_TENANT_CLIENT_ID }} SAIL_CLIENT_SECRET: ${{ secrets.SDK_TEST_TENANT_CLIENT_SECRET }} SAIL_BASE_URL: ${{ secrets.SDK_TEST_TENANT_BASE_URL }} diff --git a/.github/workflows/prb_macos.yml b/.github/workflows/prb_macos.yml index 7f810035..e7dcc21f 100644 --- a/.github/workflows/prb_macos.yml +++ b/.github/workflows/prb_macos.yml @@ -2,6 +2,7 @@ name: PRB_macOS permissions: contents: read env: + SAIL_AUTHTYPE: pat SAIL_CLIENT_ID: ${{ secrets.SDK_TEST_TENANT_CLIENT_ID }} SAIL_CLIENT_SECRET: ${{ secrets.SDK_TEST_TENANT_CLIENT_SECRET }} SAIL_BASE_URL: ${{ secrets.SDK_TEST_TENANT_BASE_URL }} diff --git a/.github/workflows/prb_windows.yml b/.github/workflows/prb_windows.yml index f8c5f24e..18e0dcb6 100644 --- a/.github/workflows/prb_windows.yml +++ b/.github/workflows/prb_windows.yml @@ -2,6 +2,7 @@ name: PRB_Windows permissions: contents: read env: + SAIL_AUTHTYPE: pat SAIL_CLIENT_ID: ${{ secrets.SDK_TEST_TENANT_CLIENT_ID }} SAIL_CLIENT_SECRET: ${{ secrets.SDK_TEST_TENANT_CLIENT_SECRET }} SAIL_BASE_URL: ${{ secrets.SDK_TEST_TENANT_BASE_URL }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..7b585f53 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +SailPoint CLI (`sail`) is a Go CLI tool for interacting with SailPoint Identity Security Cloud (ISC) tenants. Built with [cobra](https://github.com/spf13/cobra) for command structure and [viper](https://github.com/spf13/viper) for configuration management. The binary is named `sail`. + +## Build & Test Commands + +```bash +# Build and install locally +make install # builds `sail` binary and installs to /usr/local/bin/sail + +# Run all tests +make test # go test -v -count=1 ./... + +# Run a single test +go test -v -count=1 -run TestNewConnCreateCmd ./cmd/connector/ + +# Run tests with coverage report +make test-report + +# Run tests with race detection +make test-race + +# Regenerate mocks (requires mockgen) +make mocks + +# Clean build artifacts +make clean +``` + +## Architecture + +### Entry Point + +`main.go` → initializes config via `internal/config.InitConfig()`, then creates and executes the root cobra command from `cmd/root/root.go`. + +### Command Structure + +Each command lives in `cmd//` as its own package. Commands follow a consistent pattern: + +- **Parent command**: a `NewCommand()` function returns `*cobra.Command`, adds subcommands +- **Subcommands**: unexported `newCommand()` functions (e.g., `newListCommand()`, `newCreateCommand()`) +- **Help text**: many commands embed `.md` files via `//go:embed` and parse them with `util.ParseHelp()` which extracts `==Long==...====` and `==Example==...====` sections + +Two API client patterns coexist: +1. **SailPoint Go SDK** (`sailpoint-oss/golang-sdk/v2`): used by most commands (workflow, search, cluster, spconfig, etc.) via `config.InitAPIClient(experimental bool)` +2. **Internal HTTP client** (`internal/client/client.go`): used by the `connector` and `api` commands. The `connector` package injects this client via function parameters for testability. + +### Internal Packages + +- `internal/config/` — config management via viper; reads `~/.sailpoint/config.yaml`; manages environments, auth types (PAT/OAuth), token lifecycle +- `internal/auth/` — authentication logic (PAT login, OAuth flow, token caching via keyring) +- `internal/keyring/` — secrets storage abstraction (system keyring with fallback) +- `internal/client/` — low-level HTTP client (`Client` interface) with auth token injection +- `internal/tui/` — interactive prompts using [charmbracelet/huh](https://github.com/charmbracelet/huh) (confirm, input, password, list selection) +- `internal/templates/` — search, export, and report template loading (built-in + user-defined from `~/.sailpoint/`) +- `internal/mocks/` — generated gomock mocks for `Client` and `Terminal` interfaces + +### Testing Pattern + +Tests use `gomock` for mocking. The connector tests demonstrate the standard pattern: +1. Create `gomock.Controller` +2. Create `mocks.NewMockClient(ctrl)` with expected calls +3. Build the command with the mock injected +4. Execute with `cmd.Execute()` and assert output + +When adding a new root-level subcommand, update `numRootSubcommands` in `cmd/root/root_test.go`. + +### Configuration + +User config lives at `~/.sailpoint/config.yaml`. Supports multiple named environments with an `activeenvironment` key. Auth types: `pat` (Personal Access Token) or `oauth`. Environment variables prefixed with `SAIL_` are auto-bound by viper. + +### Release + +Uses GoReleaser (`.goreleaser.yaml`). Version is set in `cmd/root/root.go` and injected via ldflags at build time. Builds for Linux, macOS, and Windows. Distributed via Homebrew tap, deb/rpm packages, and zip/tar.gz archives. diff --git a/Makefile b/Makefile index 0a341c3b..a915a293 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,9 @@ test-race: .PHONY: install install: - go build -o /usr/local/bin/sail -buildvcs=false + go build -o sail -buildvcs=false + sudo install -m 755 sail /usr/local/bin/sail + rm -f sail .PHONY: vhs vhs: diff --git a/cmd/accessprofile/access_profile.go b/cmd/accessprofile/access_profile.go new file mode 100644 index 00000000..fe6f7b8b --- /dev/null +++ b/cmd/accessprofile/access_profile.go @@ -0,0 +1,184 @@ +package accessprofile + +import ( + "context" + + v2024 "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/sdkcmd" + "github.com/spf13/cobra" +) + +func NewAccessProfileCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "access-profile", + Aliases: []string{"accessprofile"}, + Short: "Inspect access profiles", + Long: "\nInspect Identity Security Cloud access profiles and their entitlements.\n\n", + Example: " sail access-profile list\n sail access-profile get ", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + cmd.AddCommand(newListCommand(), newGetCommand(), newCreateCommand(), newPatchCommand(), newDeleteCommand(), newEntitlementsCommand()) + return cmd +} + +func newListCommand() *cobra.Command { + var opts sdkcmd.ListOptions + cmd := &cobra.Command{ + Use: "list", + Short: "List access profiles", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + req := apiClient.V2024.AccessProfilesAPI.ListAccessProfiles(context.TODO()). + Limit(opts.Limit). + Offset(opts.Offset). + Count(opts.Count) + if opts.Filters != "" { + req = req.Filters(opts.Filters) + } + if opts.Sorters != "" { + req = req.Sorters(opts.Sorters) + } + profiles, resp, err := req.Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + rows := make([][]string, 0, len(profiles)) + for _, item := range profiles { + rows = append(rows, []string{item.GetName(), item.GetId(), item.GetDescription()}) + } + return sdkcmd.WriteTable(cmd, []string{"Name", "ID", "Description"}, rows, "Name", profiles) + }, + } + sdkcmd.AddListFlags(cmd, &opts) + return cmd +} + +func newGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get an access profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + profile, resp, err := apiClient.V2024.AccessProfilesAPI.GetAccessProfile(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, profile) + }, + } +} + +func newCreateCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "create --file access-profile.json", + Short: "Create an access profile from a JSON payload", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[v2024.AccessProfile](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + profile, resp, err := apiClient.V2024.AccessProfilesAPI.CreateAccessProfile(context.TODO()). + AccessProfile(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, profile) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON payload file") + cmd.MarkFlagRequired("file") + return cmd +} + +func newPatchCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "patch --file patch.json", + Aliases: []string{"update"}, + Short: "Patch an access profile from a JSON Patch payload", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[[]v2024.JsonPatchOperation](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + profile, resp, err := apiClient.V2024.AccessProfilesAPI.PatchAccessProfile(context.TODO(), args[0]). + JsonPatchOperation(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, profile) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON Patch payload file") + cmd.MarkFlagRequired("file") + return cmd +} + +func newDeleteCommand() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an access profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !force { + return clierror.Usage("access-profile delete requires --force") + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + resp, err := apiClient.V2024.AccessProfilesAPI.DeleteAccessProfile(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, map[string]string{"status": "deleted", "accessProfileId": args[0]}) + }, + } + cmd.Flags().BoolVarP(&force, "force", "f", false, "Confirm access profile deletion") + return cmd +} + +func newEntitlementsCommand() *cobra.Command { + return &cobra.Command{ + Use: "entitlements ", + Short: "List entitlements for an access profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + entitlements, resp, err := apiClient.V2024.AccessProfilesAPI.GetAccessProfileEntitlements(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, entitlements) + }, + } +} diff --git a/cmd/accessprofile/access_profile_test.go b/cmd/accessprofile/access_profile_test.go new file mode 100644 index 00000000..32bf8bcc --- /dev/null +++ b/cmd/accessprofile/access_profile_test.go @@ -0,0 +1,19 @@ +package accessprofile + +import ( + "strings" + "testing" +) + +func TestAccessProfileDeleteRequiresForce(t *testing.T) { + cmd := newDeleteCommand() + cmd.SetArgs([]string{"access-profile-id"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected access-profile delete without --force to fail") + } + if !strings.Contains(err.Error(), "access-profile delete requires --force") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/accessprofile/crud_live_test.go b/cmd/accessprofile/crud_live_test.go new file mode 100644 index 00000000..ed84df10 --- /dev/null +++ b/cmd/accessprofile/crud_live_test.go @@ -0,0 +1,120 @@ +package accessprofile + +import ( + "bytes" + "testing" + + v2024 "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" + "github.com/sailpoint-oss/sailpoint-cli/internal/testutil" +) + +func TestAccessProfileCRUD(t *testing.T) { + testutil.RequireLiveCredentials(t) + testutil.SetJSONOutput(t) + + owner := testutil.FirstIdentity(t) + source := testutil.FirstSource(t) + name := testutil.UniqueName("access-profile") + updatedDescription := "updated by sail CLI live CRUD test" + dir := t.TempDir() + + createPath := testutil.WriteJSON(t, dir, "access-profile-create.json", map[string]any{ + "name": name, + "description": "created by sail CLI live CRUD test", + "enabled": false, + "owner": map[string]any{ + "type": "IDENTITY", + "id": owner.ID, + }, + "source": map[string]any{ + "type": "SOURCE", + "id": source.ID, + "name": source.Name, + }, + "entitlements": []any{}, + }) + + createCmd := newCreateCommand() + createOut := new(bytes.Buffer) + createCmd.SetOut(createOut) + createCmd.Flags().Set("file", createPath) + + if err := createCmd.Execute(); err != nil { + testutil.SkipIfFeatureUnavailable(t, err) + t.Fatalf("access-profile create failed: %v", err) + } + + created := testutil.DecodeJSON[v2024.AccessProfile](t, createOut.String()) + if created.GetId() == "" { + t.Fatalf("expected created access profile ID, got %#v", created) + } + accessProfileID := created.GetId() + defer deleteAccessProfile(t, accessProfileID) + + getAccessProfileAndAssert(t, accessProfileID, name) + listAccessProfileAndAssert(t, accessProfileID, name) + + patchPath := testutil.WriteJSON(t, dir, "access-profile-patch.json", testutil.StringPatch("/description", updatedDescription)) + patchCmd := newPatchCommand() + patchOut := new(bytes.Buffer) + patchCmd.SetOut(patchOut) + patchCmd.SetArgs([]string{accessProfileID}) + patchCmd.Flags().Set("file", patchPath) + + if err := patchCmd.Execute(); err != nil { + t.Fatalf("access-profile patch failed: %v", err) + } + updated := testutil.DecodeJSON[v2024.AccessProfile](t, patchOut.String()) + if updated.GetDescription() != updatedDescription { + t.Fatalf("expected access profile description %q, got %q", updatedDescription, updated.GetDescription()) + } +} + +func getAccessProfileAndAssert(t *testing.T, accessProfileID string, expectedName string) { + t.Helper() + + getCmd := newGetCommand() + getOut := new(bytes.Buffer) + getCmd.SetOut(getOut) + getCmd.SetArgs([]string{accessProfileID}) + if err := getCmd.Execute(); err != nil { + t.Fatalf("access-profile get failed: %v", err) + } + profile := testutil.DecodeJSON[v2024.AccessProfile](t, getOut.String()) + if profile.GetId() != accessProfileID { + t.Fatalf("expected access profile ID %q, got %q", accessProfileID, profile.GetId()) + } + if profile.GetName() != expectedName { + t.Fatalf("expected access profile name %q, got %q", expectedName, profile.GetName()) + } +} + +func listAccessProfileAndAssert(t *testing.T, accessProfileID string, expectedName string) { + t.Helper() + + listCmd := newListCommand() + listOut := new(bytes.Buffer) + listCmd.SetOut(listOut) + listCmd.Flags().Set("filter", `id eq "`+accessProfileID+`"`) + if err := listCmd.Execute(); err != nil { + t.Fatalf("access-profile list failed: %v", err) + } + profiles := testutil.DecodeJSON[[]v2024.AccessProfile](t, listOut.String()) + if len(profiles) != 1 { + t.Fatalf("expected one access profile from filtered list, got %d", len(profiles)) + } + if profiles[0].GetName() != expectedName { + t.Fatalf("expected listed access profile name %q, got %q", expectedName, profiles[0].GetName()) + } +} + +func deleteAccessProfile(t *testing.T, accessProfileID string) { + t.Helper() + + deleteCmd := newDeleteCommand() + deleteCmd.SetArgs([]string{accessProfileID}) + deleteCmd.Flags().Set("force", "true") + if err := deleteCmd.Execute(); err != nil { + t.Logf("failed to clean up access profile %s: %v", accessProfileID, err) + } +} diff --git a/cmd/accessrequest/access_request.go b/cmd/accessrequest/access_request.go new file mode 100644 index 00000000..743a8cbe --- /dev/null +++ b/cmd/accessrequest/access_request.go @@ -0,0 +1,373 @@ +package accessrequest + +import ( + "context" + + v2024 "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/sdkcmd" + "github.com/spf13/cobra" +) + +func NewAccessRequestCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "access-request", + Aliases: []string{"accessrequest"}, + Short: "Submit and review access requests", + Long: "\nSubmit access requests, inspect request status, and act on approver work items.\n\n", + Example: " sail access-request list\n sail access-request create --file request.json\n sail access-request work-items list", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + cmd.AddCommand( + newListCommand(), + newGetCommand(), + newCreateCommand(), + newCancelCommand(), + newApproveCommand(), + newCloseCommand(), + newWorkItemsCommand(), + ) + return cmd +} + +func newListCommand() *cobra.Command { + var opts sdkcmd.ListOptions + cmd := &cobra.Command{ + Use: "list", + Short: "List access request status records", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + req := apiClient.V2024.AccessRequestsAPI.ListAccessRequestStatus(context.TODO()). + Limit(opts.Limit). + Offset(opts.Offset). + Count(opts.Count) + if opts.Filters != "" { + req = req.Filters(opts.Filters) + } + if opts.Sorters != "" { + req = req.Sorters(opts.Sorters) + } + requests, resp, err := req.Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + rows := make([][]string, 0, len(requests)) + for _, item := range requests { + rows = append(rows, []string{item.GetName(), item.GetId(), item.GetType(), string(item.GetState())}) + } + return sdkcmd.WriteTable(cmd, []string{"Name", "ID", "Type", "State"}, rows, "Name", requests) + }, + } + sdkcmd.AddListFlags(cmd, &opts) + return cmd +} + +func newGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get access request status by ID", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + requests, resp, err := apiClient.V2024.AccessRequestsAPI.ListAccessRequestStatus(context.TODO()). + Filters(`id eq "` + args[0] + `"`). + Limit(1). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, requests) + }, + } +} + +func newCreateCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "create --file request.json", + Short: "Create an access request from a JSON payload", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[v2024.AccessRequest](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + result, resp, err := apiClient.V2024.AccessRequestsAPI.CreateAccessRequest(context.TODO()). + AccessRequest(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, result) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON payload file") + cmd.MarkFlagRequired("file") + return cmd +} + +func newCancelCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "cancel --file cancel.json", + Short: "Cancel an access request from a JSON payload", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[v2024.CancelAccessRequest](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + result, resp, err := apiClient.V2024.AccessRequestsAPI.CancelAccessRequest(context.TODO()). + CancelAccessRequest(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, result) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON payload file") + cmd.MarkFlagRequired("file") + return cmd +} + +func newApproveCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "approve --file approve.json", + Short: "Approve access requests in bulk from a JSON payload", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[v2024.BulkApproveAccessRequest](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + result, resp, err := apiClient.V2024.AccessRequestsAPI.ApproveBulkAccessRequest(context.TODO()). + BulkApproveAccessRequest(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, result) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON payload file") + cmd.MarkFlagRequired("file") + return cmd +} + +func newCloseCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "close --file close.json", + Short: "Close an access request from a JSON payload", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[v2024.CloseAccessRequest](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + result, resp, err := apiClient.V2024.AccessRequestsAPI.CloseAccessRequest(context.TODO()). + CloseAccessRequest(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, result) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON payload file") + cmd.MarkFlagRequired("file") + return cmd +} + +func newWorkItemsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "work-items", + Short: "Inspect and act on access-request work items", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + cmd.AddCommand( + newWorkItemsListCommand(), + newWorkItemsGetCommand(), + newWorkItemsApproveCommand(), + newWorkItemsRejectCommand(), + newWorkItemsForwardCommand(), + newWorkItemsCompleteCommand(), + ) + return cmd +} + +func newWorkItemsListCommand() *cobra.Command { + var opts sdkcmd.ListOptions + var ownerID string + cmd := &cobra.Command{ + Use: "list", + Short: "List work items", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + req := apiClient.V2024.WorkItemsAPI.ListWorkItems(context.TODO()). + Limit(opts.Limit). + Offset(opts.Offset). + Count(opts.Count) + if ownerID != "" { + req = req.OwnerId(ownerID) + } + items, resp, err := req.Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{item.GetName(), item.GetId(), string(item.GetType()), string(item.GetState())}) + } + return sdkcmd.WriteTable(cmd, []string{"Name", "ID", "Type", "State"}, rows, "Name", items) + }, + } + sdkcmd.AddListFlags(cmd, &opts) + cmd.Flags().Lookup("filter").Hidden = true + cmd.Flags().Lookup("sort").Hidden = true + cmd.Flags().StringVar(&ownerID, "owner-id", "", "Filter work items by owner ID") + return cmd +} + +func newWorkItemsGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get a work item", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + item, resp, err := apiClient.V2024.WorkItemsAPI.GetWorkItem(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, item) + }, + } +} + +func newWorkItemsApproveCommand() *cobra.Command { + return &cobra.Command{ + Use: "approve ", + Short: "Approve an approval item", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + item, resp, err := apiClient.V2024.WorkItemsAPI.ApproveApprovalItem(context.TODO(), args[0], args[1]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, item) + }, + } +} + +func newWorkItemsRejectCommand() *cobra.Command { + return &cobra.Command{ + Use: "reject ", + Short: "Reject an approval item", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + item, resp, err := apiClient.V2024.WorkItemsAPI.RejectApprovalItem(context.TODO(), args[0], args[1]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, item) + }, + } +} + +func newWorkItemsForwardCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "forward --file forward.json", + Short: "Forward a work item from a JSON payload", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[v2024.WorkItemForward](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + resp, err := apiClient.V2024.WorkItemsAPI.ForwardWorkItem(context.TODO(), args[0]). + WorkItemForward(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, map[string]string{"status": "forwarded", "workItemId": args[0]}) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON payload file") + cmd.MarkFlagRequired("file") + return cmd +} + +func newWorkItemsCompleteCommand() *cobra.Command { + var body string + cmd := &cobra.Command{ + Use: "complete --body value", + Short: "Complete a work item", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + item, resp, err := apiClient.V2024.WorkItemsAPI.CompleteWorkItem(context.TODO(), args[0]). + Body(body). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, item) + }, + } + cmd.Flags().StringVar(&body, "body", "", "Completion body") + return cmd +} diff --git a/cmd/accessrequest/access_request_test.go b/cmd/accessrequest/access_request_test.go new file mode 100644 index 00000000..db020df8 --- /dev/null +++ b/cmd/accessrequest/access_request_test.go @@ -0,0 +1,24 @@ +package accessrequest + +import "testing" + +func TestNewAccessRequestCommandRegistersSubcommands(t *testing.T) { + cmd := NewAccessRequestCommand() + for _, name := range []string{"list", "get", "create", "cancel", "approve", "close", "work-items"} { + found, _, err := cmd.Find([]string{name}) + if err != nil || found == nil || found.Name() != name { + t.Fatalf("expected access-request subcommand %q to exist", name) + } + } + + workItems, _, err := cmd.Find([]string{"work-items"}) + if err != nil { + t.Fatalf("expected work-items command: %v", err) + } + for _, name := range []string{"list", "get", "approve", "reject", "forward", "complete"} { + found, _, err := workItems.Find([]string{name}) + if err != nil || found == nil || found.Name() != name { + t.Fatalf("expected work-items subcommand %q to exist", name) + } + } +} diff --git a/cmd/account/account.go b/cmd/account/account.go new file mode 100644 index 00000000..b45f64cd --- /dev/null +++ b/cmd/account/account.go @@ -0,0 +1,181 @@ +package account + +import ( + "context" + + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/sdkcmd" + "github.com/spf13/cobra" +) + +func NewAccountCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "account", + Short: "Inspect and operate on accounts", + Long: "\nInspect accounts and perform common account operations such as enable, disable, unlock, and reload.\n\n", + Example: " sail account list\n sail account get \n sail account disable ", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + cmd.AddCommand( + newListCommand(), + newGetCommand(), + newEntitlementsCommand(), + newEnableCommand(), + newDisableCommand(), + newUnlockCommand(), + newReloadCommand(), + ) + return cmd +} + +func newListCommand() *cobra.Command { + var opts sdkcmd.ListOptions + cmd := &cobra.Command{ + Use: "list", + Short: "List accounts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + req := apiClient.V2024.AccountsAPI.ListAccounts(context.TODO()). + Limit(opts.Limit). + Offset(opts.Offset). + Count(opts.Count) + if opts.Filters != "" { + req = req.Filters(opts.Filters) + } + if opts.Sorters != "" { + req = req.Sorters(opts.Sorters) + } + accounts, resp, err := req.Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + rows := make([][]string, 0, len(accounts)) + for _, item := range accounts { + rows = append(rows, []string{item.GetName(), item.GetId(), item.GetSourceName(), item.GetNativeIdentity()}) + } + return sdkcmd.WriteTable(cmd, []string{"Name", "ID", "Source", "Native Identity"}, rows, "Name", accounts) + }, + } + sdkcmd.AddListFlags(cmd, &opts) + return cmd +} + +func newGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get an account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + account, resp, err := apiClient.V2024.AccountsAPI.GetAccount(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, account) + }, + } +} + +func newEntitlementsCommand() *cobra.Command { + return &cobra.Command{ + Use: "entitlements ", + Short: "List entitlements for an account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + entitlements, resp, err := apiClient.V2024.AccountsAPI.GetAccountEntitlements(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, entitlements) + }, + } +} + +func newEnableCommand() *cobra.Command { + return &cobra.Command{ + Use: "enable ", + Short: "Enable an account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + result, resp, err := apiClient.V2024.AccountsAPI.EnableAccount(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, result) + }, + } +} + +func newDisableCommand() *cobra.Command { + return &cobra.Command{ + Use: "disable ", + Short: "Disable an account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + result, resp, err := apiClient.V2024.AccountsAPI.DisableAccount(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, result) + }, + } +} + +func newUnlockCommand() *cobra.Command { + return &cobra.Command{ + Use: "unlock ", + Short: "Unlock an account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + result, resp, err := apiClient.V2024.AccountsAPI.UnlockAccount(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, result) + }, + } +} + +func newReloadCommand() *cobra.Command { + return &cobra.Command{ + Use: "reload ", + Short: "Reload an account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + result, resp, err := apiClient.V2024.AccountsAPI.SubmitReloadAccount(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, result) + }, + } +} diff --git a/cmd/account/account_test.go b/cmd/account/account_test.go new file mode 100644 index 00000000..a249fa87 --- /dev/null +++ b/cmd/account/account_test.go @@ -0,0 +1,13 @@ +package account + +import "testing" + +func TestNewAccountCommandRegistersSubcommands(t *testing.T) { + cmd := NewAccountCommand() + for _, name := range []string{"list", "get", "entitlements", "enable", "disable", "unlock", "reload"} { + found, _, err := cmd.Find([]string{name}) + if err != nil || found == nil || found.Name() != name { + t.Fatalf("expected account subcommand %q to exist", name) + } + } +} diff --git a/cmd/api/README.md b/cmd/api/README.md index ba17f7c2..2c79b402 100644 --- a/cmd/api/README.md +++ b/cmd/api/README.md @@ -11,7 +11,7 @@ Similar to the GitHub CLI's `api` command, this provides a simple way to hit any ```bash sail api get /beta/accounts sail api get /beta/accounts/123 --header "Accept: application/json" --pretty -sail api get /beta/identities --query "limit=100" --query "offset=0" --output identities.json +sail api get /beta/identities --query "limit=100" --query "offset=0" sail api get /beta/accounts --jsonpath "$.items[*].id" # Extract all account IDs ``` @@ -45,10 +45,13 @@ sail api delete /beta/accounts/123 All commands support the following options: - `--header`, `-H`: Set HTTP headers (can be used multiple times, format: 'Key: Value') -- `--output`, `-o`: Output file to save the response (if not specified, prints to stdout) - `--pretty`, `-p`: Pretty print JSON response - `--jsonpath`, `-j`: JSONPath expression to evaluate on the response +### Output Option (PATCH only) + +- `--output`, `-o`: Output file to save the response (if not specified, prints to stdout) + ### Body Options (POST, PUT, PATCH) - `--body`, `-b`: Request body content as a string @@ -59,6 +62,33 @@ All commands support the following options: - `--query`, `-q`: Query parameters (can be used multiple times, format: 'key=value') +### Pagination Options (GET only) + +- `--pages`, `-n`: Number of pages to fetch (250 items per page by default) +- `--all`, `-a`: Fetch all results by paginating automatically (uses X-Total-Count header) + +These flags are mutually exclusive. When using pagination, the results from all pages are merged into a single JSON array. + +```bash +# Fetch 3 pages of accounts (up to 750 results) +sail api get /v2024/accounts --pages 3 + +# Fetch all accounts automatically +sail api get /v2024/accounts --all + +# Fetch all accounts with pretty printing +sail api get /v2024/accounts --all --pretty + +# Use a custom page size of 100 +sail api get /v2024/accounts --all --query "limit=100" + +# Start from a specific offset +sail api get /v2024/accounts --pages 5 --query "offset=500" + +# Combine with filters +sail api get /v2024/accounts --query "filters=sourceId eq \"abc\"" --all +``` + ## JSONPath Examples The `--jsonpath` option allows you to extract specific values from JSON responses using JSONPath expressions: diff --git a/cmd/api/api.go b/cmd/api/api.go index 4adc0fd5..42cb64a9 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -10,7 +10,7 @@ func NewAPICommand() *cobra.Command { Use: "api", Short: "Make API requests to SailPoint endpoints", Long: "\nMake API requests to SailPoint endpoints. Use this command to interact with SailPoint APIs directly.\n\n", - Example: "sail api get /beta/accounts", + Example: " sail api get /beta/accounts\n sail api post /beta/accounts --body '{\"name\":\"test\"}'", Aliases: []string{"a"}, Args: cobra.MinimumNArgs(0), Run: func(cmd *cobra.Command, args []string) { diff --git a/cmd/api/api_crud_test.go b/cmd/api/api_crud_test.go index 66215eb8..0f66d521 100644 --- a/cmd/api/api_crud_test.go +++ b/cmd/api/api_crud_test.go @@ -36,7 +36,6 @@ var ( } }`) - path = "test_data" createFile = "test_create.json" updateFile = "test_update.json" ) @@ -51,8 +50,8 @@ func randSeq(n int) string { return string(b) } -func SaveTransform(fileName string, transform map[string]interface{}) error { - file, err := os.OpenFile((filepath.Join(path, fileName)), os.O_RDWR|os.O_CREATE, 0666) +func SaveTransform(dir string, fileName string, transform map[string]interface{}) error { + file, err := os.OpenFile(filepath.Join(dir, fileName), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return err } @@ -74,6 +73,8 @@ func SaveTransform(fileName string, transform map[string]interface{}) error { } func TestNewCRUDCmd(t *testing.T) { + requireLiveCredentials(t) + var transform map[string]interface{} err := json.Unmarshal([]byte(createTemplate), &transform) @@ -84,13 +85,13 @@ func TestNewCRUDCmd(t *testing.T) { transformName := randSeq(16) transform["name"] = transformName - // Make sure the output dir exists first + path := t.TempDir() err = os.MkdirAll(path, os.ModePerm) if err != nil { t.Fatalf("Error Creating Folders: %v", err) } - err = SaveTransform(createFile, transform) + err = SaveTransform(path, createFile, transform) if err != nil { t.Fatalf("Unable to save test data: %v", err) } @@ -115,6 +116,14 @@ func TestNewCRUDCmd(t *testing.T) { } transformID := string(responseBytes) log.Info("Transform ID", "ID", transformID) + defer func() { + deleteCMD := newDeleteCmd() + deleteCMD.SetOut(new(bytes.Buffer)) + deleteCMD.SetArgs([]string{"/v2024/transforms/" + transformID}) + if err := deleteCMD.Execute(); err != nil { + t.Logf("failed to clean up transform %s: %v", transformID, err) + } + }() // Validate the transform was created by getting it getCMD := newGetCmd() @@ -153,7 +162,7 @@ func TestNewCRUDCmd(t *testing.T) { } attributes["attributeName"] = "UPDATED_DEPARTMENT" - err = SaveTransform(updateFile, updateTransform) + err = SaveTransform(path, updateFile, updateTransform) if err != nil { t.Fatalf("Unable to save update test data: %v", err) } @@ -199,14 +208,6 @@ func TestNewCRUDCmd(t *testing.T) { t.Fatalf("Retrieved transform name '%s' does not match original name '%s'", retrievedValue, "UPDATED_DEPARTMENT") } - // Clean up - delete the transform - deleteCMD := newDeleteCmd() - deleteBuffer := new(bytes.Buffer) - deleteCMD.SetOut(deleteBuffer) - deleteCMD.SetArgs([]string{"/v2024/transforms/" + transformID}) - - err = deleteCMD.Execute() - if err != nil { - t.Fatalf("TestNewDeleteCmd: Unable to execute the command successfully: %v", err) - } + // Cleanup is deferred immediately after create so failures in the middle of + // the test do not leave a live transform behind. } diff --git a/cmd/api/api_execution_test.go b/cmd/api/api_execution_test.go index 2c332100..e9b28574 100644 --- a/cmd/api/api_execution_test.go +++ b/cmd/api/api_execution_test.go @@ -9,6 +9,8 @@ import ( ) func TestTenantEndpoint(t *testing.T) { + requireLiveCredentials(t) + // Create the GET command cmd := newGetCmd() @@ -23,6 +25,8 @@ func TestTenantEndpoint(t *testing.T) { } func TestListTransformations(t *testing.T) { + requireLiveCredentials(t) + // Create the GET command cmd := newGetCmd() diff --git a/cmd/api/delete.go b/cmd/api/delete.go index 09d23afc..ed41763d 100644 --- a/cmd/api/delete.go +++ b/cmd/api/delete.go @@ -29,12 +29,6 @@ func newDeleteCmd() *cobra.Command { Aliases: []string{"d"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - err := config.InitConfig() - if err != nil { - return err - } - - // Get the SailPoint client configuration cfg, err := config.GetConfig() if err != nil { return err @@ -62,7 +56,7 @@ func newDeleteCmd() *cobra.Command { } ctx := context.Background() - log.Info("Making DELETE request", "endpoint", endpoint) + log.Debug("Making DELETE request", "endpoint", endpoint) // Make the request resp, err := spClient.Delete(ctx, endpoint, queryParams, headers) @@ -76,6 +70,9 @@ func newDeleteCmd() *cobra.Command { if err != nil { return fmt.Errorf("failed to read response: %w", err) } + if err := ensureSuccess(resp, responseBody); err != nil { + return err + } // If JSONPath is specified, evaluate it if jsonPath != "" { @@ -97,11 +94,8 @@ func newDeleteCmd() *cobra.Command { } } - if jsonPath != "" { - fmt.Fprint(cmd.OutOrStdout(), string(responseBody)) - } else { - cmd.Println(string(responseBody)) - fmt.Printf("Status: %s\n", resp.Status) + if err := writeResponse(cmd, responseBody, resp.Status, jsonPath); err != nil { + return err } return nil diff --git a/cmd/api/get.go b/cmd/api/get.go index d9af1eb7..0a79899b 100644 --- a/cmd/api/get.go +++ b/cmd/api/get.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/url" - "os" "strings" "github.com/charmbracelet/log" @@ -22,6 +21,8 @@ func newGetCmd() *cobra.Command { var queryParams []string var prettyPrint bool var jsonPath string + var pages int + var fetchAll bool cmd := &cobra.Command{ Use: "get [endpoint]", @@ -31,18 +32,11 @@ func newGetCmd() *cobra.Command { Aliases: []string{"g"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - err := config.InitConfig() - if err != nil { - return err - } - - // Get the SailPoint client configuration cfg, err := config.GetConfig() if err != nil { return err } - // Create a client spClient := client.NewSpClient(cfg) endpoint := args[0] @@ -50,31 +44,9 @@ func newGetCmd() *cobra.Command { endpoint = "/" + endpoint } - // Add query parameters if any - if len(queryParams) > 0 { - parsedURL, err := url.Parse(endpoint) - if err != nil { - return fmt.Errorf("invalid endpoint URL: %w", err) - } - - query := parsedURL.Query() - for _, param := range queryParams { - parts := strings.SplitN(param, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid query parameter format (use key=value): %s", param) - } - query.Add(parts[0], parts[1]) - } - - parsedURL.RawQuery = query.Encode() - endpoint = parsedURL.String() - } - // Prepare headers headers := make(map[string]string) - // Always add Accept header for JSON headers["Accept"] = "application/json" - // Add any additional headers for _, header := range headerFlags { parts := strings.SplitN(header, ":", 2) if len(parts) != 2 { @@ -84,19 +56,73 @@ func newGetCmd() *cobra.Command { } ctx := context.Background() - log.Info("Making GET request", "endpoint", endpoint) - // Make the request using the SailPoint client - resp, err := spClient.Get(ctx, endpoint, headers) - if err != nil { - return fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() + var body []byte + var status string + var paginationErr error - // Read response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) + if pages > 0 || fetchAll { + userLimit, userOffset, hasLimit, hasOffset := parseQueryParams(queryParams) + + pageCfg := PaginationConfig{ + Pages: pages, + All: fetchAll, + Limit: defaultPageSize, + Offset: 0, + } + if hasLimit { + pageCfg.Limit = userLimit + } + if hasOffset { + pageCfg.Offset = userOffset + } + + body, status, err = paginatedGet(ctx, spClient, endpoint, headers, queryParams, pageCfg) + if err != nil { + if len(body) > 0 { + log.Warn("Pagination incomplete", "error", err) + paginationErr = err + } else { + return err + } + } + } else { + // Single request (existing behavior) + if len(queryParams) > 0 { + parsedURL, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + + query := parsedURL.Query() + for _, param := range queryParams { + parts := strings.SplitN(param, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid query parameter format (use key=value): %s", param) + } + query.Add(parts[0], parts[1]) + } + + parsedURL.RawQuery = query.Encode() + endpoint = parsedURL.String() + } + + log.Debug("Making GET request", "endpoint", endpoint) + + resp, err := spClient.Get(ctx, endpoint, headers) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err = io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + if err := ensureSuccess(resp, body); err != nil { + return err + } + status = resp.Status } // If JSONPath is specified, evaluate it @@ -119,14 +145,11 @@ func newGetCmd() *cobra.Command { } } - if jsonPath != "" { - fmt.Fprint(cmd.OutOrStdout(), string(body)) - } else { - cmd.Println(string(body)) - fmt.Printf("Status: %s\n", resp.Status) + if err := writeResponse(cmd, body, status, jsonPath); err != nil { + return err } - return nil + return paginationErr }, } @@ -134,11 +157,9 @@ func newGetCmd() *cobra.Command { cmd.Flags().StringArrayVarP(&queryParams, "query", "q", []string{}, "Query parameters (can be used multiple times, format: 'key=value')") cmd.Flags().BoolVarP(&prettyPrint, "pretty", "p", false, "Pretty print JSON response") cmd.Flags().StringVarP(&jsonPath, "jsonpath", "j", "", "JSONPath expression to evaluate on the response") + cmd.Flags().IntVarP(&pages, "pages", "n", 0, "Number of pages to fetch (250 items per page by default)") + cmd.Flags().BoolVarP(&fetchAll, "all", "a", false, "Fetch all results by paginating automatically") + cmd.MarkFlagsMutuallyExclusive("pages", "all") return cmd } - -// writeToFile writes data to a file -func writeToFile(filename string, data []byte) error { - return os.WriteFile(filename, data, 0644) -} diff --git a/cmd/api/live_test.go b/cmd/api/live_test.go new file mode 100644 index 00000000..6362f867 --- /dev/null +++ b/cmd/api/live_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026, SailPoint Technologies, Inc. All rights reserved. +package api + +import ( + "fmt" + "os" + "testing" + + "github.com/sailpoint-oss/sailpoint-cli/internal/config" +) + +func TestMain(m *testing.M) { + if err := config.InitConfig(); err != nil { + fmt.Fprintf(os.Stderr, "failed to initialize CLI config: %v\n", err) + os.Exit(1) + } + + os.Exit(m.Run()) +} + +func requireLiveCredentials(t *testing.T) { + t.Helper() + + if err := config.Validate(); err != nil { + t.Skipf("skipping live API test: no usable SailPoint CLI credentials found (%v). Configure PAT credentials with SAIL_BASE_URL, SAIL_CLIENT_ID, and SAIL_CLIENT_SECRET, or run `sail env create`/`sail auth login` for OAuth, then rerun this test.", err) + } +} diff --git a/cmd/api/output.go b/cmd/api/output.go new file mode 100644 index 00000000..96a8c84d --- /dev/null +++ b/cmd/api/output.go @@ -0,0 +1,62 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/output" + "github.com/spf13/cobra" +) + +func ensureSuccess(resp *http.Response, body []byte) error { + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return nil + } + return clierror.APIStatus(resp.StatusCode, resp.Status, body) +} + +func writeResponse(cmd *cobra.Command, body []byte, status string, jsonPath string) error { + if jsonPath != "" { + _, err := fmt.Fprint(cmd.OutOrStdout(), string(body)) + return err + } + + if output.IsMachineReadable() { + var value any + if err := json.Unmarshal(body, &value); err == nil { + return output.WriteStructured(cmd.OutOrStdout(), value) + } + } + + _, err := fmt.Fprintln(cmd.OutOrStdout(), string(body)) + if err != nil { + return err + } + + return writeStatus(cmd, status) +} + +func writeResponseFile(cmd *cobra.Command, filename string, body []byte, status string) error { + if err := writeToFile(filename, body); err != nil { + return fmt.Errorf("failed to write to file: %w", err) + } + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Response saved to %s\n", filename); err != nil { + return err + } + return writeStatus(cmd, status) +} + +func writeStatus(cmd *cobra.Command, status string) error { + if output.IsMachineReadable() || status == "" { + return nil + } + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Status: %s\n", status) + return err +} + +func writeToFile(filename string, data []byte) error { + return os.WriteFile(filename, data, 0600) +} diff --git a/cmd/api/output_test.go b/cmd/api/output_test.go new file mode 100644 index 00000000..3e4c0fa0 --- /dev/null +++ b/cmd/api/output_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "bytes" + "net/http" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func resetAPIOutputTestState(t *testing.T) { + t.Helper() + viper.Reset() + t.Cleanup(viper.Reset) +} + +func TestEnsureSuccessRejectsNon2xx(t *testing.T) { + err := ensureSuccess(&http.Response{StatusCode: http.StatusNotFound, Status: "404 Not Found"}, []byte(`{"detail":"missing"}`)) + if err == nil { + t.Fatal("expected non-2xx response to fail") + } + if !strings.Contains(err.Error(), "404 Not Found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWriteResponseWritesStatusToStderrForHumanOutput(t *testing.T) { + resetAPIOutputTestState(t) + + cmd := &cobra.Command{} + out := new(bytes.Buffer) + errOut := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(errOut) + + if err := writeResponse(cmd, []byte(`{"id":"123"}`), "200 OK", ""); err != nil { + t.Fatalf("writeResponse returned error: %v", err) + } + + if !strings.Contains(out.String(), `{"id":"123"}`) { + t.Fatalf("expected body on stdout, got %q", out.String()) + } + if !strings.Contains(errOut.String(), "Status: 200 OK") { + t.Fatalf("expected status on stderr, got %q", errOut.String()) + } +} + +func TestWriteResponseOmitsStatusForMachineReadableOutput(t *testing.T) { + resetAPIOutputTestState(t) + viper.Set("output", "json") + + cmd := &cobra.Command{} + out := new(bytes.Buffer) + errOut := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetErr(errOut) + + if err := writeResponse(cmd, []byte(`{"id":"123"}`), "200 OK", ""); err != nil { + t.Fatalf("writeResponse returned error: %v", err) + } + + if !strings.Contains(out.String(), `"id": "123"`) { + t.Fatalf("expected structured JSON on stdout, got %q", out.String()) + } + if errOut.String() != "" { + t.Fatalf("expected no status on stderr for machine-readable output, got %q", errOut.String()) + } +} diff --git a/cmd/api/paginate.go b/cmd/api/paginate.go new file mode 100644 index 00000000..8d04a13f --- /dev/null +++ b/cmd/api/paginate.go @@ -0,0 +1,233 @@ +// Copyright (c) 2024, SailPoint Technologies, Inc. All rights reserved. +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/client" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" +) + +const defaultPageSize = 250 + +type PaginationConfig struct { + Pages int + All bool + Limit int + Offset int +} + +func parseQueryParams(queryParams []string) (limit, offset int, hasLimit, hasOffset bool) { + for _, param := range queryParams { + parts := strings.SplitN(param, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.ToLower(strings.TrimSpace(parts[0])) + val := strings.TrimSpace(parts[1]) + switch key { + case "limit": + if v, err := strconv.Atoi(val); err == nil { + limit = v + hasLimit = true + } + case "offset": + if v, err := strconv.Atoi(val); err == nil { + offset = v + hasOffset = true + } + } + } + return +} + +func buildPaginatedEndpoint(endpoint string, queryParams []string, offset, limit int, includeCount bool) (string, error) { + parsedURL, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("invalid endpoint URL: %w", err) + } + + query := parsedURL.Query() + + for _, param := range queryParams { + parts := strings.SplitN(param, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + keyLower := strings.ToLower(key) + if keyLower == "limit" || keyLower == "offset" || keyLower == "count" { + continue + } + query.Add(key, strings.TrimSpace(parts[1])) + } + + query.Set("limit", strconv.Itoa(limit)) + query.Set("offset", strconv.Itoa(offset)) + if includeCount { + query.Set("count", "true") + } + + parsedURL.RawQuery = query.Encode() + return parsedURL.String(), nil +} + +func getWithRetry(ctx context.Context, spClient client.Client, url string, headers map[string]string, maxRetries int) (*http.Response, error) { + var resp *http.Response + var err error + + for attempt := 0; attempt <= maxRetries; attempt++ { + resp, err = spClient.Get(ctx, url, headers) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusTooManyRequests { + return resp, nil + } + + resp.Body.Close() + + if attempt == maxRetries { + return nil, fmt.Errorf("rate limited after %d retries", maxRetries) + } + + waitTime := time.Duration(math.Pow(2, float64(attempt))) * time.Second + if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { + if seconds, err := strconv.Atoi(retryAfter); err == nil { + waitTime = time.Duration(seconds) * time.Second + } + } + + log.Debug("Rate limited, retrying", "attempt", attempt+1, "wait", waitTime) + time.Sleep(waitTime) + } + + return resp, nil +} + +func paginatedGet(ctx context.Context, spClient client.Client, endpoint string, headers map[string]string, queryParams []string, pageCfg PaginationConfig) ([]byte, string, error) { + limit := pageCfg.Limit + offset := pageCfg.Offset + if limit <= 0 { + return nil, "", clierror.Usage("pagination limit must be greater than 0") + } + if offset < 0 { + return nil, "", clierror.Usage("pagination offset must be greater than or equal to 0") + } + + reqURL, err := buildPaginatedEndpoint(endpoint, queryParams, offset, limit, true) + if err != nil { + return nil, "", err + } + + log.Debug("Paginated GET", "url", reqURL, "page", 1) + resp, err := getWithRetry(ctx, spClient, reqURL, headers, 3) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, resp.Status, clierror.APIStatus(resp.StatusCode, resp.Status, body) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("failed to read response: %w", err) + } + + var firstPage []json.RawMessage + if err := json.Unmarshal(body, &firstPage); err != nil { + return nil, "", fmt.Errorf("pagination only supported for endpoints returning JSON arrays") + } + + totalCountStr := resp.Header.Get("X-Total-Count") + totalCount := -1 + if totalCountStr != "" { + if tc, err := strconv.Atoi(totalCountStr); err == nil { + totalCount = tc + } + } + + if pageCfg.All && totalCount < 0 { + return nil, "", fmt.Errorf("--all requires X-Total-Count header but it was not present in the response") + } + + var totalPages int + if pageCfg.All { + totalPages = int(math.Ceil(float64(totalCount-offset) / float64(limit))) + } else { + totalPages = pageCfg.Pages + } + + allItems := make([]json.RawMessage, 0, len(firstPage)) + allItems = append(allItems, firstPage...) + lastStatus := resp.Status + + if !pageCfg.All && len(firstPage) < limit { + totalPages = 1 + } + + for page := 2; page <= totalPages; page++ { + offset += limit + + reqURL, err = buildPaginatedEndpoint(endpoint, queryParams, offset, limit, false) + if err != nil { + return nil, "", err + } + + log.Debug("Paginated GET", "url", reqURL, "page", page) + pageResp, err := getWithRetry(ctx, spClient, reqURL, headers, 3) + if err != nil { + merged, _ := json.Marshal(allItems) + return merged, lastStatus, fmt.Errorf("failed on page %d: %w (returning %d items collected so far)", page, err, len(allItems)) + } + + pageBody, readErr := io.ReadAll(pageResp.Body) + pageResp.Body.Close() + + if pageResp.StatusCode < 200 || pageResp.StatusCode >= 300 { + log.Warn("Non-success status on page", "page", page, "status", pageResp.Status) + merged, _ := json.Marshal(allItems) + return merged, lastStatus, fmt.Errorf("page %d returned status %s (returning %d items collected so far)", page, pageResp.Status, len(allItems)) + } + + if readErr != nil { + merged, _ := json.Marshal(allItems) + return merged, lastStatus, fmt.Errorf("failed to read page %d response: %w (returning %d items collected so far)", page, readErr, len(allItems)) + } + + var pageItems []json.RawMessage + if err := json.Unmarshal(pageBody, &pageItems); err != nil { + merged, _ := json.Marshal(allItems) + return merged, lastStatus, fmt.Errorf("page %d returned non-array response (returning %d items collected so far)", page, len(allItems)) + } + + allItems = append(allItems, pageItems...) + lastStatus = pageResp.Status + + if len(pageItems) < limit { + break + } + } + + merged, err := json.Marshal(allItems) + if err != nil { + return nil, "", fmt.Errorf("failed to marshal results: %w", err) + } + + statusMsg := fmt.Sprintf("%s (fetched %d items across pages)", lastStatus, len(allItems)) + return merged, statusMsg, nil +} diff --git a/cmd/api/paginate_test.go b/cmd/api/paginate_test.go new file mode 100644 index 00000000..a66bab53 --- /dev/null +++ b/cmd/api/paginate_test.go @@ -0,0 +1,426 @@ +// Copyright (c) 2024, SailPoint Technologies, Inc. All rights reserved. +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/sailpoint-oss/sailpoint-cli/internal/mocks" +) + +func TestParseQueryParams(t *testing.T) { + tests := []struct { + name string + params []string + wantLimit int + wantOff int + hasLimit bool + hasOffset bool + }{ + { + name: "empty", + params: nil, + }, + { + name: "limit only", + params: []string{"limit=100"}, + wantLimit: 100, + hasLimit: true, + }, + { + name: "offset only", + params: []string{"offset=500"}, + wantOff: 500, + hasOffset: true, + }, + { + name: "both", + params: []string{"limit=50", "offset=200"}, + wantLimit: 50, + wantOff: 200, + hasLimit: true, + hasOffset: true, + }, + { + name: "other params ignored", + params: []string{"filters=name eq \"test\"", "sorters=name"}, + }, + { + name: "mixed with other params", + params: []string{"filters=name eq \"test\"", "limit=10", "offset=20"}, + wantLimit: 10, + wantOff: 20, + hasLimit: true, + hasOffset: true, + }, + { + name: "invalid limit ignored", + params: []string{"limit=abc"}, + }, + { + name: "malformed param ignored", + params: []string{"noequalssign"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + limit, offset, hasLimit, hasOffset := parseQueryParams(tt.params) + if limit != tt.wantLimit { + t.Errorf("limit = %d, want %d", limit, tt.wantLimit) + } + if offset != tt.wantOff { + t.Errorf("offset = %d, want %d", offset, tt.wantOff) + } + if hasLimit != tt.hasLimit { + t.Errorf("hasLimit = %v, want %v", hasLimit, tt.hasLimit) + } + if hasOffset != tt.hasOffset { + t.Errorf("hasOffset = %v, want %v", hasOffset, tt.hasOffset) + } + }) + } +} + +func TestBuildPaginatedEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint string + queryParams []string + offset int + limit int + includeCount bool + wantContains []string + wantExcludes []string + }{ + { + name: "basic with count", + endpoint: "/v2024/accounts", + offset: 0, + limit: 250, + includeCount: true, + wantContains: []string{"limit=250", "offset=0", "count=true"}, + }, + { + name: "without count", + endpoint: "/v2024/accounts", + offset: 250, + limit: 250, + includeCount: false, + wantContains: []string{"limit=250", "offset=250"}, + wantExcludes: []string{"count="}, + }, + { + name: "user params preserved, limit/offset/count stripped", + endpoint: "/v2024/accounts", + queryParams: []string{"filters=name eq \"test\"", "limit=100", "offset=50", "count=false"}, + offset: 0, + limit: 100, + includeCount: true, + wantContains: []string{"limit=100", "offset=0", "count=true", "filters="}, + }, + { + name: "no duplicate user params", + endpoint: "/v2024/accounts", + queryParams: []string{"sorters=name"}, + offset: 500, + limit: 50, + includeCount: false, + wantContains: []string{"limit=50", "offset=500", "sorters=name"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := buildPaginatedEndpoint(tt.endpoint, tt.queryParams, tt.offset, tt.limit, tt.includeCount) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, s := range tt.wantContains { + if !strings.Contains(result, s) { + t.Errorf("result %q does not contain %q", result, s) + } + } + for _, s := range tt.wantExcludes { + if strings.Contains(result, s) { + t.Errorf("result %q should not contain %q", result, s) + } + } + }) + } +} + +func makeResponse(statusCode int, status string, body string, headers map[string]string) *http.Response { + h := http.Header{} + for k, v := range headers { + h.Set(k, v) + } + return &http.Response{ + StatusCode: statusCode, + Status: status, + Header: h, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestPaginatedGet_MergesArrays(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mocks.NewMockClient(ctrl) + + gomock.InOrder( + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `[{"id":1},{"id":2},{"id":3}]`, map[string]string{"X-Total-Count": "7"}), nil), + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `[{"id":4},{"id":5},{"id":6}]`, nil), nil), + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `[{"id":7}]`, nil), nil), + ) + + pageCfg := PaginationConfig{All: true, Limit: 3, Offset: 0} + body, status, err := paginatedGet(context.Background(), mockClient, "/v2024/accounts", map[string]string{}, nil, pageCfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var items []json.RawMessage + if err := json.Unmarshal(body, &items); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(items) != 7 { + t.Errorf("expected 7 items, got %d", len(items)) + } + + if !strings.Contains(status, "7 items") { + t.Errorf("expected status to mention 7 items, got: %s", status) + } +} + +func TestPaginatedGet_NonArrayResponse(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mocks.NewMockClient(ctrl) + + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `{"id":1,"name":"test"}`, map[string]string{"X-Total-Count": "1"}), nil) + + pageCfg := PaginationConfig{All: true, Limit: 250, Offset: 0} + _, _, err := paginatedGet(context.Background(), mockClient, "/v2024/accounts/123", map[string]string{}, nil, pageCfg) + if err == nil { + t.Fatal("expected error for non-array response") + } + if !strings.Contains(err.Error(), "pagination only supported for endpoints returning JSON arrays") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPaginatedGet_StopsOnShortPage(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mocks.NewMockClient(ctrl) + + // Page 1 returns only 2 items with limit=5, so pagination stops immediately + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `[{"id":1},{"id":2}]`, map[string]string{"X-Total-Count": "2"}), nil) + + pageCfg := PaginationConfig{Pages: 3, Limit: 5, Offset: 0} + body, _, err := paginatedGet(context.Background(), mockClient, "/v2024/accounts", map[string]string{}, nil, pageCfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var items []json.RawMessage + if err := json.Unmarshal(body, &items); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(items) != 2 { + t.Errorf("expected 2 items, got %d", len(items)) + } +} + +func TestPaginatedGet_AllMode_MissingTotalCount(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mocks.NewMockClient(ctrl) + + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `[{"id":1}]`, nil), nil) + + pageCfg := PaginationConfig{All: true, Limit: 250, Offset: 0} + _, _, err := paginatedGet(context.Background(), mockClient, "/v2024/accounts", map[string]string{}, nil, pageCfg) + if err == nil { + t.Fatal("expected error when X-Total-Count is missing with --all") + } + if !strings.Contains(err.Error(), "X-Total-Count") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPaginatedGet_PagesMode_MissingTotalCount(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mocks.NewMockClient(ctrl) + + gomock.InOrder( + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `[{"id":1},{"id":2},{"id":3}]`, nil), nil), + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `[{"id":4}]`, nil), nil), + ) + + pageCfg := PaginationConfig{Pages: 2, Limit: 3, Offset: 0} + body, _, err := paginatedGet(context.Background(), mockClient, "/v2024/accounts", map[string]string{}, nil, pageCfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var items []json.RawMessage + if err := json.Unmarshal(body, &items); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(items) != 4 { + t.Errorf("expected 4 items, got %d", len(items)) + } +} + +func TestPaginatedGet_RejectsInvalidLimitAndOffset(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mocks.NewMockClient(ctrl) + + tests := []struct { + name string + cfg PaginationConfig + want string + }{ + { + name: "zero limit", + cfg: PaginationConfig{Pages: 2, Limit: 0, Offset: 0}, + want: "limit must be greater than 0", + }, + { + name: "negative limit", + cfg: PaginationConfig{Pages: 2, Limit: -1, Offset: 0}, + want: "limit must be greater than 0", + }, + { + name: "negative offset", + cfg: PaginationConfig{Pages: 2, Limit: 250, Offset: -1}, + want: "offset must be greater than or equal to 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := paginatedGet(context.Background(), mockClient, "/v2024/accounts", map[string]string{}, nil, tt.cfg) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected %q in error, got %v", tt.want, err) + } + }) + } +} + +func TestPaginatedGet_AllModeDoesNotTrustShortFirstPageOverTotalCount(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mocks.NewMockClient(ctrl) + + gomock.InOrder( + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `[{"id":1},{"id":2}]`, map[string]string{"X-Total-Count": "7"}), nil), + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `[{"id":6},{"id":7}]`, nil), nil), + ) + + pageCfg := PaginationConfig{All: true, Limit: 5, Offset: 0} + body, _, err := paginatedGet(context.Background(), mockClient, "/v2024/accounts", map[string]string{}, nil, pageCfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var items []json.RawMessage + if err := json.Unmarshal(body, &items); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(items) != 4 { + t.Errorf("expected items from both pages, got %d", len(items)) + } +} + +func TestPaginatedGet_RespectsUserLimitOffset(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mocks.NewMockClient(ctrl) + + // With limit=2 and offset=10, the first request should use those values + var capturedURL string + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, url string, headers map[string]string) (*http.Response, error) { + capturedURL = url + return makeResponse(200, "200 OK", `[{"id":1}]`, map[string]string{"X-Total-Count": "11"}), nil + }) + + pageCfg := PaginationConfig{Pages: 1, Limit: 2, Offset: 10} + _, _, err := paginatedGet(context.Background(), mockClient, "/v2024/accounts", map[string]string{}, nil, pageCfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(capturedURL, "limit=2") { + t.Errorf("expected URL to contain limit=2, got: %s", capturedURL) + } + if !strings.Contains(capturedURL, "offset=10") { + t.Errorf("expected URL to contain offset=10, got: %s", capturedURL) + } +} + +func TestPaginatedGet_Retry429(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mocks.NewMockClient(ctrl) + + gomock.InOrder( + // First call returns 429 with Retry-After: 0 for instant retry + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(429, "429 Too Many Requests", "", map[string]string{"Retry-After": "0"}), nil), + // Second call succeeds + mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return( + makeResponse(200, "200 OK", `[{"id":1}]`, map[string]string{"X-Total-Count": "1"}), nil), + ) + + pageCfg := PaginationConfig{Pages: 1, Limit: 250, Offset: 0} + body, _, err := paginatedGet(context.Background(), mockClient, "/v2024/accounts", map[string]string{}, nil, pageCfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var items []json.RawMessage + if err := json.Unmarshal(body, &items); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(items) != 1 { + t.Errorf("expected 1 item, got %d", len(items)) + } +} diff --git a/cmd/api/patch.go b/cmd/api/patch.go index 134a7b53..0847845c 100644 --- a/cmd/api/patch.go +++ b/cmd/api/patch.go @@ -33,12 +33,6 @@ func newPatchCmd() *cobra.Command { Aliases: []string{"pa"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - err := config.InitConfig() - if err != nil { - return err - } - - // Get the SailPoint client configuration cfg, err := config.GetConfig() if err != nil { return err @@ -86,7 +80,7 @@ func newPatchCmd() *cobra.Command { } ctx := context.Background() - log.Info("Making PATCH request", "endpoint", endpoint) + log.Debug("Making PATCH request", "endpoint", endpoint) // Make the request resp, err := spClient.Patch(ctx, endpoint, body, headers) @@ -100,6 +94,9 @@ func newPatchCmd() *cobra.Command { if err != nil { return fmt.Errorf("failed to read response: %w", err) } + if err := ensureSuccess(resp, responseBody); err != nil { + return err + } // If JSONPath is specified, evaluate it if jsonPath != "" { @@ -123,15 +120,15 @@ func newPatchCmd() *cobra.Command { // Output to file or stdout if outputFile != "" { - if err := writeToFile(outputFile, responseBody); err != nil { - return fmt.Errorf("failed to write to file: %w", err) + if err := writeResponseFile(cmd, outputFile, responseBody, resp.Status); err != nil { + return err } - fmt.Printf("Response saved to %s\n", outputFile) } else { - fmt.Fprint(cmd.OutOrStdout(), string(responseBody)) + if err := writeResponse(cmd, responseBody, resp.Status, jsonPath); err != nil { + return err + } } - fmt.Printf("\nStatus: %s\n", resp.Status) return nil }, } diff --git a/cmd/api/post.go b/cmd/api/post.go index 2ac1542f..d24127a2 100644 --- a/cmd/api/post.go +++ b/cmd/api/post.go @@ -32,12 +32,6 @@ func newPostCmd() *cobra.Command { Aliases: []string{"p"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - err := config.InitConfig() - if err != nil { - return err - } - - // Get the SailPoint client configuration cfg, err := config.GetConfig() if err != nil { return err @@ -83,7 +77,7 @@ func newPostCmd() *cobra.Command { } ctx := context.Background() - log.Info("Making POST request", "endpoint", endpoint) + log.Debug("Making POST request", "endpoint", endpoint) // Make the request resp, err := spClient.Post(ctx, endpoint, contentType, body, headers) @@ -97,6 +91,9 @@ func newPostCmd() *cobra.Command { if err != nil { return fmt.Errorf("failed to read response: %w", err) } + if err := ensureSuccess(resp, responseBody); err != nil { + return err + } // If JSONPath is specified, evaluate it if jsonPath != "" { @@ -118,11 +115,8 @@ func newPostCmd() *cobra.Command { } } - if jsonPath != "" { - fmt.Fprint(cmd.OutOrStdout(), string(responseBody)) - } else { - cmd.Println(string(responseBody)) - fmt.Printf("Status: %s\n", resp.Status) + if err := writeResponse(cmd, responseBody, resp.Status, jsonPath); err != nil { + return err } return nil diff --git a/cmd/api/put.go b/cmd/api/put.go index e17b0ff0..6f386ea3 100644 --- a/cmd/api/put.go +++ b/cmd/api/put.go @@ -32,12 +32,6 @@ func newPutCmd() *cobra.Command { Aliases: []string{"u"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - err := config.InitConfig() - if err != nil { - return err - } - - // Get the SailPoint client configuration cfg, err := config.GetConfig() if err != nil { return err @@ -83,7 +77,7 @@ func newPutCmd() *cobra.Command { } ctx := context.Background() - log.Info("Making PUT request", "endpoint", endpoint) + log.Debug("Making PUT request", "endpoint", endpoint) // Make the request resp, err := spClient.Put(ctx, endpoint, contentType, body, headers) @@ -97,6 +91,9 @@ func newPutCmd() *cobra.Command { if err != nil { return fmt.Errorf("failed to read response: %w", err) } + if err := ensureSuccess(resp, responseBody); err != nil { + return err + } // If JSONPath is specified, evaluate it if jsonPath != "" { @@ -118,11 +115,8 @@ func newPutCmd() *cobra.Command { } } - if jsonPath != "" { - fmt.Fprint(cmd.OutOrStdout(), string(responseBody)) - } else { - cmd.Println(string(responseBody)) - fmt.Printf("Status: %s\n", resp.Status) + if err := writeResponse(cmd, responseBody, resp.Status, jsonPath); err != nil { + return err } return nil diff --git a/cmd/api/test_data/test_create.json b/cmd/api/test_data/test_create.json index e43c95fc..7829e289 100644 --- a/cmd/api/test_data/test_create.json +++ b/cmd/api/test_data/test_create.json @@ -1 +1 @@ -{"attributes":{"accountFilter":"!(nativeIdentity.startsWith(\"*DELETED*\"))","accountPropertyFilter":"(groups.containsAll({'Admin'}) || location == 'Austin')","accountReturnFirstLink":false,"accountSortAttribute":"created","accountSortDescending":false,"attributeName":"DEPARTMENT","input":{"attributes":{"attributeName":"first_name","sourceName":"Source"},"type":"accountAttribute"},"requiresPeriodicRefresh":false,"sourceName":"Workday"},"name":"PRithHMFRWGzvlna","type":"dateFormat"} \ No newline at end of file +{"attributes":{"accountFilter":"!(nativeIdentity.startsWith(\"*DELETED*\"))","accountPropertyFilter":"(groups.containsAll({'Admin'}) || location == 'Austin')","accountReturnFirstLink":false,"accountSortAttribute":"created","accountSortDescending":false,"attributeName":"DEPARTMENT","input":{"attributes":{"attributeName":"first_name","sourceName":"Source"},"type":"accountAttribute"},"requiresPeriodicRefresh":false,"sourceName":"Workday"},"name":"wQiIVvzkCDsUQLUE","type":"dateFormat"} diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go new file mode 100644 index 00000000..379652d6 --- /dev/null +++ b/cmd/auth/auth.go @@ -0,0 +1,29 @@ +package auth + +import ( + "github.com/spf13/cobra" +) + +func NewAuthCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Manage authentication for the active environment", + Long: "\nManage authentication sessions for the active CLI environment.\n\nUse 'sail auth login' to explicitly authenticate,\n'sail auth status' to check your current session,\nand 'sail auth logout' to clear cached tokens.\n", + Example: ` sail auth login + sail auth status + sail auth logout + printf '%s' "$SAIL_CLIENT_SECRET" | sail auth pat set --client-id "$SAIL_CLIENT_ID" --client-secret-stdin`, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + + cmd.AddCommand( + newLoginCommand(), + newLogoutCommand(), + newPATCommand(), + newStatusCommand(), + ) + + return cmd +} diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go new file mode 100644 index 00000000..5376c2d3 --- /dev/null +++ b/cmd/auth/auth_test.go @@ -0,0 +1,61 @@ +package auth + +import ( + "bytes" + "strings" + "testing" + + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/viper" +) + +func resetAuthCommandState(t *testing.T) { + t.Helper() + viper.Reset() + config.ClearActiveEnvironmentOverride() + t.Cleanup(func() { + viper.Reset() + config.ClearActiveEnvironmentOverride() + }) +} + +func TestAuthStatusNoActiveEnvironment(t *testing.T) { + resetAuthCommandState(t) + viper.Set("activeenvironment", "") + + cmd := newStatusCommand() + out := new(bytes.Buffer) + cmd.SetOut(out) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected status to succeed without active environment: %v", err) + } + if got := out.String(); !strings.Contains(got, "No active environment configured") { + t.Fatalf("expected no-active-environment message, got %q", got) + } +} + +func TestAuthLogoutNoActiveEnvironment(t *testing.T) { + resetAuthCommandState(t) + viper.Set("activeenvironment", "") + + cmd := newLogoutCommand() + + err := cmd.Execute() + if err == nil { + t.Fatal("expected logout to fail without active environment") + } + if !strings.Contains(err.Error(), "no active environment configured") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewAuthCommandRegistersSubcommands(t *testing.T) { + cmd := NewAuthCommand() + for _, name := range []string{"login", "logout", "pat", "status"} { + found, _, err := cmd.Find([]string{name}) + if err != nil || found == nil || found.Name() != name { + t.Fatalf("expected auth subcommand %q to exist", name) + } + } +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go new file mode 100644 index 00000000..f6867da9 --- /dev/null +++ b/cmd/auth/login.go @@ -0,0 +1,55 @@ +package auth + +import ( + "fmt" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/auth" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/cobra" +) + +func newLoginCommand() *cobra.Command { + return &cobra.Command{ + Use: "login", + Short: "Authenticate with the active environment", + Long: "\nExplicitly authenticate with the active environment using its configured\nauthentication method (PAT or OAuth).\n\nFor PAT environments, this uses the stored Client ID and Client Secret.\nFor OAuth environments, this opens a browser for interactive login.\n\n", + Example: ` sail auth login + sail auth login --env production`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + envName := config.GetActiveEnvironment() + if envName == "" { + return fmt.Errorf("no active environment configured. Run 'sail env create' first") + } + + authType := config.GetAuthType() + baseURL := config.GetBaseUrl() + tenantURL := config.GetTenantUrl() + tokenURL := config.GetTokenUrl() + + w := cmd.OutOrStdout() + fmt.Fprintf(w, "Logging in to '%s' (%s)...\n", envName, authType) + + token, err := auth.GetToken(authType, envName, baseURL, tenantURL, tokenURL, func(newBaseURL string) { + config.SetBaseUrl(newBaseURL) + }) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + claims, err := auth.GetTokenClaims(token) + if err != nil { + log.Warn("Could not parse token claims", "error", err) + fmt.Fprintln(w, "Login successful.") + return nil + } + + userName := claims["user_name"] + org := claims["org"] + fmt.Fprintf(w, "Authenticated as: %v (org: %v)\n", userName, org) + + return nil + }, + } +} diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go new file mode 100644 index 00000000..a3ce7f8b --- /dev/null +++ b/cmd/auth/logout.go @@ -0,0 +1,35 @@ +package auth + +import ( + "fmt" + + "github.com/sailpoint-oss/sailpoint-cli/internal/auth" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/cobra" +) + +func newLogoutCommand() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Clear cached authentication tokens", + Long: "\nClear all cached authentication tokens for the active environment.\nYou will need to re-authenticate on the next API call.\n\n", + Example: ` sail auth logout + sail auth logout --env staging`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + envName := config.GetActiveEnvironment() + if envName == "" { + return fmt.Errorf("no active environment configured") + } + + authType := config.GetAuthType() + + if err := auth.Logout(authType, envName); err != nil { + return fmt.Errorf("failed to clear tokens: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Logged out of '%s'. Cached tokens cleared.\n", envName) + return nil + }, + } +} diff --git a/cmd/auth/pat.go b/cmd/auth/pat.go new file mode 100644 index 00000000..b47af065 --- /dev/null +++ b/cmd/auth/pat.go @@ -0,0 +1,79 @@ +package auth + +import ( + "bufio" + "fmt" + "strings" + + internalauth "github.com/sailpoint-oss/sailpoint-cli/internal/auth" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/cobra" +) + +func newPATCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "pat", + Short: "Manage PAT credentials for the active environment", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + cmd.AddCommand(newPATSetCommand()) + return cmd +} + +func newPATSetCommand() *cobra.Command { + var clientID string + var readSecretFromStdin bool + cmd := &cobra.Command{ + Use: "set", + Short: "Set PAT credentials for the active environment", + Long: "\nSet PAT credentials for the active environment. Use --client-secret-stdin for non-interactive setup without placing secrets in shell history.\n\n", + Example: ` printf '%s' "$SAIL_CLIENT_SECRET" | sail auth pat set --client-id "$SAIL_CLIENT_ID" --client-secret-stdin`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + env := config.GetActiveEnvironment() + if clientID == "" { + var err error + clientID, err = internalauth.PromptForClientID() + if err != nil { + return err + } + } + + clientSecret := "" + if readSecretFromStdin { + scanner := bufio.NewScanner(cmd.InOrStdin()) + if scanner.Scan() { + clientSecret = strings.TrimSpace(scanner.Text()) + } + if err := scanner.Err(); err != nil { + return err + } + } + if clientSecret == "" { + var err error + clientSecret, err = internalauth.PromptForClientSecret() + if err != nil { + return err + } + } + + if err := internalauth.SetPatClientID(env, clientID); err != nil { + return err + } + if err := internalauth.SetPatClientSecret(env, clientSecret); err != nil { + return err + } + if err := internalauth.ResetCachePAT(env); err != nil { + return err + } + + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "PAT credentials saved for environment %q\n", env) + return err + }, + } + cmd.Flags().StringVar(&clientID, "client-id", "", "PAT client ID") + cmd.Flags().BoolVar(&readSecretFromStdin, "client-secret-stdin", false, "Read PAT client secret from stdin") + return cmd +} diff --git a/cmd/auth/status.go b/cmd/auth/status.go new file mode 100644 index 00000000..db69fd9a --- /dev/null +++ b/cmd/auth/status.go @@ -0,0 +1,243 @@ +package auth + +import ( + "fmt" + "io" + "time" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/auth" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/output" + "github.com/spf13/cobra" +) + +type authStatus struct { + Environment string `json:"environment" yaml:"environment"` + AuthType string `json:"authType" yaml:"authType"` + BaseURL string `json:"baseUrl" yaml:"baseUrl"` + TenantURL string `json:"tenantUrl" yaml:"tenantUrl"` + TokenStatus string `json:"tokenStatus" yaml:"tokenStatus"` + TokenExpiry string `json:"tokenExpiry,omitempty" yaml:"tokenExpiry,omitempty"` + RefreshStatus string `json:"refreshStatus,omitempty" yaml:"refreshStatus,omitempty"` + RefreshExpiry string `json:"refreshExpiry,omitempty" yaml:"refreshExpiry,omitempty"` + Identity string `json:"identity,omitempty" yaml:"identity,omitempty"` + Organization string `json:"organization,omitempty" yaml:"organization,omitempty"` +} + +func newStatusCommand() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show current authentication status", + Long: "\nDisplay information about the current authentication session,\nincluding the active environment, auth type, identity, and token expiry.\n\n", + Example: ` sail auth status + sail auth status --env production`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + w := cmd.OutOrStdout() + envName := config.GetActiveEnvironment() + if envName == "" { + fmt.Fprintln(w, "No active environment configured.") + return nil + } + + authType := config.GetAuthType() + baseURL := config.GetBaseUrl() + tenantURL := config.GetTenantUrl() + status := authStatus{ + Environment: envName, + AuthType: authType, + BaseURL: baseURL, + TenantURL: tenantURL, + } + + switch authType { + case "pat": + status = getPATStatus(status, envName) + case "oauth": + status = getOAuthStatus(status, envName) + default: + status.TokenStatus = "unknown auth type" + } + + if output.IsMachineReadable() { + return output.WriteStructured(w, status) + } + + fmt.Fprintf(w, "Environment: %s\n", envName) + fmt.Fprintf(w, "Auth Type: %s\n", authType) + fmt.Fprintf(w, "Base URL: %s\n", baseURL) + fmt.Fprintf(w, "Tenant URL: %s\n", tenantURL) + + if status.TokenStatus != "" { + if status.TokenExpiry != "" { + fmt.Fprintf(w, "Token: %s (%s)\n", status.TokenStatus, status.TokenExpiry) + } else { + fmt.Fprintf(w, "Token: %s\n", status.TokenStatus) + } + } + if status.RefreshStatus != "" { + fmt.Fprintf(w, "Refresh: %s (%s)\n", status.RefreshStatus, status.RefreshExpiry) + } + if status.Identity != "" { + fmt.Fprintf(w, "Identity: %v\n", status.Identity) + } + if status.Organization != "" { + fmt.Fprintf(w, "Organization: %v\n", status.Organization) + } + + return nil + }, + } +} + +func getPATStatus(status authStatus, env string) authStatus { + expiry, err := auth.GetPatTokenExpiry(env) + if err != nil { + status.TokenStatus = "not cached (will authenticate on next command)" + return status + } + + status.TokenExpiry = expiry.Format(time.RFC3339) + if expiry.After(time.Now()) { + status.TokenStatus = "valid" + } else { + status.TokenStatus = "expired" + } + + token, err := auth.GetPatToken(env) + if err != nil || token == "" { + return status + } + + claims, err := auth.GetTokenClaims(token) + if err != nil { + log.Debug("Could not parse token claims", "error", err) + return status + } + + if claims["user_name"] != nil { + status.Identity = fmt.Sprint(claims["user_name"]) + } + if claims["org"] != nil { + status.Organization = fmt.Sprint(claims["org"]) + } + return status +} + +func showPATStatus(w io.Writer, env string) { + expiry, err := auth.GetPatTokenExpiry(env) + if err != nil { + fmt.Fprintln(w, "Token: not cached (will authenticate on next command)") + return + } + + if expiry.After(time.Now()) { + fmt.Fprintf(w, "Token: valid (expires %s)\n", expiry.Format(time.RFC3339)) + } else { + fmt.Fprintf(w, "Token: expired (expired %s)\n", expiry.Format(time.RFC3339)) + } + + token, err := auth.GetPatToken(env) + if err != nil || token == "" { + return + } + + claims, err := auth.GetTokenClaims(token) + if err != nil { + log.Debug("Could not parse token claims", "error", err) + return + } + + if claims["user_name"] != nil { + fmt.Fprintf(w, "Identity: %v\n", claims["user_name"]) + } + if claims["org"] != nil { + fmt.Fprintf(w, "Organization: %v\n", claims["org"]) + } +} + +func getOAuthStatus(status authStatus, env string) authStatus { + expiry, err := auth.GetOAuthTokenExpiry(env) + if err != nil { + status.TokenStatus = "not cached (will authenticate on next command)" + return status + } + + status.TokenExpiry = expiry.Format(time.RFC3339) + if expiry.After(time.Now()) { + status.TokenStatus = "valid" + } else { + status.TokenStatus = "expired" + } + + refreshExpiry, err := auth.GetOAuthRefreshExpiry(env) + if err == nil { + status.RefreshExpiry = refreshExpiry.Format(time.RFC3339) + if refreshExpiry.After(time.Now()) { + status.RefreshStatus = "valid" + } else { + status.RefreshStatus = "expired" + } + } + + token, err := auth.GetOAuthToken(env) + if err != nil || token == "" { + return status + } + + claims, err := auth.GetTokenClaims(token) + if err != nil { + log.Debug("Could not parse token claims", "error", err) + return status + } + + if claims["user_name"] != nil { + status.Identity = fmt.Sprint(claims["user_name"]) + } + if claims["org"] != nil { + status.Organization = fmt.Sprint(claims["org"]) + } + return status +} + +func showOAuthStatus(w io.Writer, env string) { + expiry, err := auth.GetOAuthTokenExpiry(env) + if err != nil { + fmt.Fprintln(w, "Token: not cached (will authenticate on next command)") + return + } + + if expiry.After(time.Now()) { + fmt.Fprintf(w, "Token: valid (expires %s)\n", expiry.Format(time.RFC3339)) + } else { + fmt.Fprintf(w, "Token: expired (expired %s)\n", expiry.Format(time.RFC3339)) + } + + refreshExpiry, err := auth.GetOAuthRefreshExpiry(env) + if err == nil { + if refreshExpiry.After(time.Now()) { + fmt.Fprintf(w, "Refresh: valid (expires %s)\n", refreshExpiry.Format(time.RFC3339)) + } else { + fmt.Fprintf(w, "Refresh: expired (expired %s)\n", refreshExpiry.Format(time.RFC3339)) + } + } + + token, err := auth.GetOAuthToken(env) + if err != nil || token == "" { + return + } + + claims, err := auth.GetTokenClaims(token) + if err != nil { + log.Debug("Could not parse token claims", "error", err) + return + } + + if claims["user_name"] != nil { + fmt.Fprintf(w, "Identity: %v\n", claims["user_name"]) + } + if claims["org"] != nil { + fmt.Fprintf(w, "Organization: %v\n", claims["org"]) + } +} diff --git a/cmd/cluster/cluster.go b/cmd/cluster/cluster.go index 4d8fda82..db158d94 100644 --- a/cmd/cluster/cluster.go +++ b/cmd/cluster/cluster.go @@ -15,7 +15,7 @@ func NewClusterCommand() *cobra.Command { help := util.ParseHelp(clusterHelp) cmd := &cobra.Command{ Use: "cluster", - Short: "Manage clusters in Identity Security Cloud", + Short: "Manage clusters", Long: help.Long, Example: help.Example, Aliases: []string{"cl"}, diff --git a/cmd/cluster/cluster.md b/cmd/cluster/cluster.md index e1c25714..9871322e 100644 --- a/cmd/cluster/cluster.md +++ b/cmd/cluster/cluster.md @@ -3,7 +3,7 @@ Manage Identity Security Cloud VA clusters. -## API Reference: +## API Reference - https://developer.sailpoint.com/docs/api/beta/managed-clusters ==== diff --git a/cmd/cluster/get.go b/cmd/cluster/get.go index 23c685c6..d3c9dcd2 100644 --- a/cmd/cluster/get.go +++ b/cmd/cluster/get.go @@ -18,7 +18,7 @@ func newGetCommand() *cobra.Command { help := util.ParseHelp(getHelp) cmd := &cobra.Command{ Use: "get", - Short: "Get a cluster from Identity Security Cloud", + Short: "Get a cluster by ID", Long: help.Long, Example: help.Example, Aliases: []string{"get"}, diff --git a/cmd/cluster/get.md b/cmd/cluster/get.md index 034af710..53ceb939 100644 --- a/cmd/cluster/get.md +++ b/cmd/cluster/get.md @@ -3,7 +3,7 @@ Get a VA cluster's configuration from Identity Security Cloud. -## API References: +## API Reference - https://developer.sailpoint.com/docs/api/beta/get-managed-cluster ==== diff --git a/cmd/cluster/list.go b/cmd/cluster/list.go index 6a156651..d086f934 100644 --- a/cmd/cluster/list.go +++ b/cmd/cluster/list.go @@ -20,7 +20,7 @@ func newListCommand() *cobra.Command { help := util.ParseHelp(listHelp) cmd := &cobra.Command{ Use: "list", - Short: "List the clusters configured in Identity Security Cloud", + Short: "List all clusters", Long: help.Long, Example: help.Example, Aliases: []string{"ls"}, diff --git a/cmd/cluster/list.md b/cmd/cluster/list.md index c865ebb8..951d6e1c 100644 --- a/cmd/cluster/list.md +++ b/cmd/cluster/list.md @@ -3,9 +3,9 @@ List all VA clusters from Identity Security Cloud. -## API References: +## API Reference - https://developer.sailpoint.com/docs/api/beta/get-managed-clusters - + ==== ==Example== diff --git a/cmd/cluster/logConfig/logConfig.md b/cmd/cluster/logConfig/logConfig.md index 50fabeb1..7af803db 100644 --- a/cmd/cluster/logConfig/logConfig.md +++ b/cmd/cluster/logConfig/logConfig.md @@ -4,7 +4,7 @@ Get or set a VA cluster's log configuration. -## API Reference: +## API Reference - https://developer.sailpoint.com/docs/api/beta/managed-clusters ==== diff --git a/cmd/cluster/logConfig/set.go b/cmd/cluster/logConfig/set.go index 01cd32d7..022bea88 100644 --- a/cmd/cluster/logConfig/set.go +++ b/cmd/cluster/logConfig/set.go @@ -73,10 +73,16 @@ func newSetCommand() *cobra.Command { }, } - cmd.Flags().StringVarP(&level, "rootLogLevel", "r", "", "Root log level for the log configuration.") - cmd.Flags().Int32VarP(&durationInMinutes, "durationInMinutes", "d", 30, "Duration in minutes for the log configuration.\nProvided value must be above 5 and below 1440") + cmd.Flags().StringVarP(&level, "root-log-level", "r", "", "Root log level for the log configuration.") + cmd.Flags().StringVar(&level, "rootLogLevel", "", "Deprecated: use --root-log-level") + cmd.Flags().MarkDeprecated("rootLogLevel", "use --root-log-level") + cmd.Flags().MarkHidden("rootLogLevel") + cmd.Flags().Int32VarP(&durationInMinutes, "duration-minutes", "d", 30, "Duration in minutes for the log configuration.\nProvided value must be above 5 and below 1440") + cmd.Flags().Int32Var(&durationInMinutes, "durationInMinutes", 30, "Deprecated: use --duration-minutes") + cmd.Flags().MarkDeprecated("durationInMinutes", "use --duration-minutes") + cmd.Flags().MarkHidden("durationInMinutes") cmd.Flags().StringVarP(&expiration, "expiration", "e", "", "Expiration string value for the log configuration. Example: 2020-12-15T19:13:36.079Z") cmd.Flags().StringArrayVarP(&connectors, "connector", "c", []string{}, "Connectors and Log Level to configure. Example:\n-c sailpoint.connector.ADLDAPConnector=TRACE\n--connector sailpoint.connector.ADLDAPConnector=TRACE") - cmd.MarkFlagsMutuallyExclusive("expiration", "durationInMinutes") + cmd.MarkFlagsMutuallyExclusive("expiration", "duration-minutes") return cmd } diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go new file mode 100644 index 00000000..c3900a18 --- /dev/null +++ b/cmd/configure/configure.go @@ -0,0 +1,151 @@ +package configure + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/output" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var validKeys = map[string]string{ + "debug": "Enable or disable debug logging (true/false)", + "export-templates-path": "Path to custom SPConfig export templates file", + "search-templates-path": "Path to custom search templates file", + "report-templates-path": "Path to custom report templates file", +} + +var keyMapping = map[string]string{ + "debug": "debug", + "export-templates-path": "exporttemplatespath", + "search-templates-path": "searchtemplatespath", + "report-templates-path": "reporttemplatespath", +} + +type globalConfig struct { + Debug bool `json:"debug" yaml:"debug"` + ActiveEnvironment string `json:"activeEnvironment" yaml:"activeEnvironment"` + ExportTemplatesPath string `json:"exportTemplatesPath" yaml:"exportTemplatesPath"` + SearchTemplatesPath string `json:"searchTemplatesPath" yaml:"searchTemplatesPath"` + ReportTemplatesPath string `json:"reportTemplatesPath" yaml:"reportTemplatesPath"` +} + +func NewConfigureCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config [key] [value]", + Short: "View or modify global CLI settings", + Long: "\nView or modify global CLI settings such as debug mode and template paths.\n\nWith no arguments, displays all settings.\nWith one argument, displays the value of that key.\nWith two arguments, sets the key to the given value.\n", + Example: ` sail config # show all settings + sail config debug # get debug value + sail config debug true # set debug to true + sail config export-templates-path /path/to/f # set a template path`, + Args: cobra.MaximumNArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + keys := make([]string, 0, len(keyMapping)) + for key := range keyMapping { + keys = append(keys, key) + } + sort.Strings(keys) + return keys, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + w := cmd.OutOrStdout() + switch len(args) { + case 0: + return listConfig(w) + case 1: + return getConfig(w, args[0]) + case 2: + return setConfig(args[0], args[1]) + } + return nil + }, + } + + return cmd +} + +func listConfig(w io.Writer) error { + cfg := globalConfig{ + Debug: config.GetDebug(), + ActiveEnvironment: config.GetActiveEnvironment(), + ExportTemplatesPath: config.GetCustomExportTemplatePath(), + SearchTemplatesPath: config.GetCustomSearchTemplatePath(), + ReportTemplatesPath: config.GetCustomReportTemplatePath(), + } + if output.IsMachineReadable() { + return output.WriteStructured(w, cfg) + } + + fmt.Fprintln(w, "Global CLI Configuration:") + fmt.Fprintln(w) + fmt.Fprintf(w, " %-25s %v\n", "debug", cfg.Debug) + fmt.Fprintf(w, " %-25s %s\n", "active-environment", cfg.ActiveEnvironment) + fmt.Fprintf(w, " %-25s %s\n", "export-templates-path", cfg.ExportTemplatesPath) + fmt.Fprintf(w, " %-25s %s\n", "search-templates-path", cfg.SearchTemplatesPath) + fmt.Fprintf(w, " %-25s %s\n", "report-templates-path", cfg.ReportTemplatesPath) + return nil +} + +func getConfig(w io.Writer, key string) error { + viperKey, ok := keyMapping[key] + if !ok { + return unknownKey(w, key) + } + value := viper.Get(viperKey) + if output.IsMachineReadable() { + return output.WriteStructured(w, map[string]any{key: value}) + } + fmt.Fprintf(w, "%s = %v\n", key, value) + return nil +} + +func setConfig(key, value string) error { + switch key { + case "debug": + switch strings.ToLower(value) { + case "true", "enable": + config.SetDebug(true) + log.Info("Debug mode enabled") + case "false", "disable": + config.SetDebug(false) + log.Info("Debug mode disabled") + default: + return fmt.Errorf("invalid value for debug: %s (use true/false)", value) + } + + case "export-templates-path": + config.SetCustomExportTemplatePath(value) + log.Info("Export templates path updated", "path", value) + + case "search-templates-path": + config.SetCustomSearchTemplatePath(value) + log.Info("Search templates path updated", "path", value) + + case "report-templates-path": + config.SetCustomReportTemplatePath(value) + log.Info("Report templates path updated", "path", value) + + default: + return clierror.Usage("unknown config key: " + key) + } + + return nil +} + +func unknownKey(w io.Writer, key string) error { + fmt.Fprintf(w, "Unknown config key: %s\n\nValid keys:\n", key) + for k, desc := range validKeys { + fmt.Fprintf(w, " %-25s %s\n", k, desc) + } + return clierror.Usage("unknown config key: " + key) +} diff --git a/cmd/configure/configure_test.go b/cmd/configure/configure_test.go new file mode 100644 index 00000000..786fe7a9 --- /dev/null +++ b/cmd/configure/configure_test.go @@ -0,0 +1,59 @@ +package configure + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/viper" +) + +func resetConfigureTestState(t *testing.T) { + t.Helper() + viper.Reset() + t.Cleanup(viper.Reset) +} + +func TestConfigureSetAndGetDebug(t *testing.T) { + resetConfigureTestState(t) + + if err := setConfig("debug", "true"); err != nil { + t.Fatalf("setConfig returned error: %v", err) + } + if got := viper.GetBool("debug"); !got { + t.Fatal("expected debug to be true") + } + + out := new(bytes.Buffer) + if err := getConfig(out, "debug"); err != nil { + t.Fatalf("getConfig returned error: %v", err) + } + if !strings.Contains(out.String(), "debug = true") { + t.Fatalf("unexpected get output: %q", out.String()) + } +} + +func TestConfigureRejectsInvalidDebugValue(t *testing.T) { + resetConfigureTestState(t) + + err := setConfig("debug", "sometimes") + if err == nil { + t.Fatal("expected invalid debug value to fail") + } + if !strings.Contains(err.Error(), "invalid value for debug") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestConfigureUnknownKey(t *testing.T) { + resetConfigureTestState(t) + + out := new(bytes.Buffer) + err := getConfig(out, "unknown") + if err == nil { + t.Fatal("expected unknown key to fail") + } + if !strings.Contains(out.String(), "Unknown config key") { + t.Fatalf("expected unknown-key help, got %q", out.String()) + } +} diff --git a/cmd/connector/client/connector_client.go b/cmd/connector/client/connector_client.go index ab535fe4..b49667ed 100644 --- a/cmd/connector/client/connector_client.go +++ b/cmd/connector/client/connector_client.go @@ -6,12 +6,13 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" "net/url" "path" + "github.com/charmbracelet/log" "github.com/sailpoint-oss/sailpoint-cli/internal/client" + "github.com/sailpoint-oss/sailpoint-cli/internal/redact" ) const maskedPassword = "******" @@ -786,13 +787,13 @@ func newResponseError(resp *http.Response) error { var errorPayload interface{} err := json.Unmarshal(body, &errorPayload) if err != nil { - return fmt.Errorf("non-200 response: %s (body %s)", resp.Status, string(body)) + return fmt.Errorf("non-200 response: %s (body %s)", resp.Status, redact.Bytes(body)) } else { pretty, err := json.MarshalIndent(errorPayload, "", "\t") if err != nil { - return fmt.Errorf("non-200 response: %s (body %s)", resp.Status, string(body)) + return fmt.Errorf("non-200 response: %s (body %s)", resp.Status, redact.Bytes(body)) } else { - return fmt.Errorf("non-200 response: %s (body %s)", resp.Status, string(pretty)) + return fmt.Errorf("non-200 response: %s (body %s)", resp.Status, redact.Bytes(pretty)) } } } @@ -876,9 +877,9 @@ func (cc *ConnClient) rawInvokeWithConfig(cmdType string, input json.RawMessage, // if input contains sensitive information, log the masked input to console if maskedInput == nil { - log.Printf("Running %q with %q", cmdType, input) + log.Debug("running connector command", "command", cmdType, "input", redact.Bytes(input)) } else { - log.Printf("Running %q with %q", cmdType, maskedInput) + log.Debug("running connector command", "command", cmdType, "input", redact.Bytes(maskedInput)) } invokeCmd := invokeCommand{ @@ -900,7 +901,8 @@ func (cc *ConnClient) rawInvokeWithConfig(cmdType string, input json.RawMessage, func connResourceUrl(endpoint string, resourceParts ...string) string { u, err := url.Parse(endpoint) if err != nil { - log.Fatalf("invalid endpoint: %s (%q)", err, endpoint) + log.Warn("invalid endpoint", "error", err, "endpoint", endpoint) + return endpoint } u.Path = path.Join(append([]string{u.Path}, resourceParts...)...) return u.String() diff --git a/cmd/connector/client/logs_client.go b/cmd/connector/client/logs_client.go index 3bd69d0d..20a336be 100644 --- a/cmd/connector/client/logs_client.go +++ b/cmd/connector/client/logs_client.go @@ -7,14 +7,15 @@ import ( "encoding/json" "fmt" "io" - "log" "math" "net/http" "net/url" "path" "time" + "github.com/charmbracelet/log" "github.com/sailpoint-oss/sailpoint-cli/internal/client" + "github.com/sailpoint-oss/sailpoint-cli/internal/redact" ) const TimeFormatLocal = `2006-01-02T15:04:05.000-07:00` @@ -39,7 +40,8 @@ const StatsEndpoint = "/beta/platform-logs/stats" func logsResourceUrl(endpoint string, queryParms *map[string]string, resourceParts ...string) string { u, err := url.Parse(endpoint) if err != nil { - log.Fatalf("invalid endpoint: %s (%q)", err, endpoint) + log.Warn("invalid endpoint", "error", err, "endpoint", endpoint) + return endpoint } u.Path = path.Join(append([]string{u.Path}, resourceParts...)...) //set query parms @@ -130,7 +132,7 @@ func (c *LogsClient) GetLogs(ctx context.Context, logInput LogInput) (*LogEvents if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("error retrieving logs, non-200 response: %s body: %s", resp.Status, body) + return nil, fmt.Errorf("error retrieving logs, non-200 response: %s body: %s", resp.Status, redact.Bytes(body)) } raw, err := io.ReadAll(resp.Body) @@ -192,7 +194,7 @@ func (c *LogsClient) GetStats(ctx context.Context, from time.Time, connectorID s if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("error retrieving logs, non-200 response: %s. Body: %s", resp.Status, body) + return nil, fmt.Errorf("error retrieving logs, non-200 response: %s body: %s", resp.Status, redact.Bytes(body)) } raw, err := io.ReadAll(resp.Body) diff --git a/cmd/connector/conn.go b/cmd/connector/conn.go index 5f7e4406..f2aefce8 100644 --- a/cmd/connector/conn.go +++ b/cmd/connector/conn.go @@ -4,9 +4,9 @@ package connector import ( "encoding/json" "fmt" - "log" "os" + "github.com/charmbracelet/log" "github.com/spf13/cobra" "github.com/spf13/pflag" "gopkg.in/yaml.v2" @@ -78,7 +78,7 @@ func bindDevConfig(flags *pflag.FlagSet) { } err = yaml.Unmarshal(raw, cfg) if err != nil { - log.Printf("Failed to unmarshal '.dev.yaml': %s", err) + log.Warn("failed to unmarshal .dev.yaml", "error", err) return } @@ -94,7 +94,8 @@ func bindDevConfig(flags *pflag.FlagSet) { if f != nil && !f.Changed { raw, err := json.Marshal(cfg.Config) if err != nil { - panic(fmt.Sprintf("Failed to encode config as json: %s", err)) + log.Warn("failed to encode config as JSON", "error", err) + return } flags.Set("config-json", string(raw)) } diff --git a/cmd/connector/conn_create.go b/cmd/connector/conn_create.go index 4ad7580c..bfa19af9 100644 --- a/cmd/connector/conn_create.go +++ b/cmd/connector/conn_create.go @@ -22,9 +22,9 @@ func newConnCreateCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create ", - Short: "Create Connector", - Long: "Create Connector", - Example: "sail connectors create \"My-Connector\"", + Short: "Create a new connector", + Long: "Create a new connector with the given alias", + Example: " sail connectors create \"My-Connector\"", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { alias := args[0] diff --git a/cmd/connector/conn_create_version.go b/cmd/connector/conn_create_version.go index 0c295d2d..c74fd9a3 100644 --- a/cmd/connector/conn_create_version.go +++ b/cmd/connector/conn_create_version.go @@ -19,8 +19,8 @@ import ( func newConnCreateVersionCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "upload", - Short: "Upload Connector", - Long: "Upload Connector", + Short: "Upload a connector version", + Long: "Upload a connector zip archive as a new version", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { connectorRef := cmd.Flags().Lookup("id").Value.String() diff --git a/cmd/connector/conn_delete.go b/cmd/connector/conn_delete.go index f37cf9b7..8b45cf85 100644 --- a/cmd/connector/conn_delete.go +++ b/cmd/connector/conn_delete.go @@ -15,8 +15,8 @@ import ( func newConnDeleteCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "delete", - Short: "Delete Connector", - Long: "Delete Connector", + Short: "Delete a connector", + Long: "Delete a connector by ID or alias", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { connectorRef := cmd.Flags().Lookup("id").Value.String() diff --git a/cmd/connector/conn_get.go b/cmd/connector/conn_get.go index ef58a85c..22b1acb2 100644 --- a/cmd/connector/conn_get.go +++ b/cmd/connector/conn_get.go @@ -16,8 +16,8 @@ import ( func newConnGetCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "get", - Short: "Get Connector", - Long: "Get Connector", + Short: "Get a connector by ID", + Long: "Get a connector by ID or alias", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { connectorRef := cmd.Flags().Lookup("id").Value.String() diff --git a/cmd/connector/conn_init.go b/cmd/connector/conn_init.go index 81327dd0..ae3d70a3 100644 --- a/cmd/connector/conn_init.go +++ b/cmd/connector/conn_init.go @@ -35,7 +35,7 @@ func newConnInitCommand() *cobra.Command { Use: "init ", Short: "Initialize new connector project", Long: `init sets up a new TypeScript project with sample connector included for reference.`, - Example: "sail connectors init \"My Connector\"", + Example: " sail connectors init \"My Connector\"", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { projName := args[0] diff --git a/cmd/connector/conn_invoke.go b/cmd/connector/conn_invoke.go index 3c83f8f2..a08ff21a 100644 --- a/cmd/connector/conn_invoke.go +++ b/cmd/connector/conn_invoke.go @@ -29,7 +29,7 @@ const ( func newConnInvokeCmd(client client.Client, term terminal.Terminal) *cobra.Command { cmd := &cobra.Command{ Use: "invoke", - Short: "Invoke Command on a connector", + Short: "Invoke a command on a connector", Run: func(cmd *cobra.Command, args []string) { _, _ = fmt.Fprint(cmd.OutOrStdout(), cmd.UsageString()) }, diff --git a/cmd/connector/conn_invoke_account_create.go b/cmd/connector/conn_invoke_account_create.go index 2c1b09ab..37f91178 100644 --- a/cmd/connector/conn_invoke_account_create.go +++ b/cmd/connector/conn_invoke_account_create.go @@ -14,7 +14,7 @@ func newConnInvokeAccountCreateCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "account-create [identity] [--attributes ]", Short: "Invoke a std:account:create command", - Example: `sail connectors invoke account-create john.doe --attributes '{"email": "john.doe@example.com"}'`, + Example: ` sail connectors invoke account-create john.doe --attributes '{"email": "john.doe@example.com"}'`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/cmd/connector/conn_invoke_account_delete.go b/cmd/connector/conn_invoke_account_delete.go index 44187c53..09fdcc78 100644 --- a/cmd/connector/conn_invoke_account_delete.go +++ b/cmd/connector/conn_invoke_account_delete.go @@ -13,7 +13,7 @@ func newConnInvokeAccountDeleteCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "account-delete ", Short: "Invoke a std:account:delete command", - Example: `sail connectors invoke account-delete john.doe`, + Example: ` sail connectors invoke account-delete john.doe`, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/cmd/connector/conn_invoke_account_discover_schema.go b/cmd/connector/conn_invoke_account_discover_schema.go index 07091340..e1ccfceb 100644 --- a/cmd/connector/conn_invoke_account_discover_schema.go +++ b/cmd/connector/conn_invoke_account_discover_schema.go @@ -12,7 +12,7 @@ func newConnInvokeAccountDiscoverSchemaCmd(client client.Client) *cobra.Command cmd := &cobra.Command{ Use: "account-discover-schema", Short: "Invoke a std:account:discover-schema command", - Example: `sail connectors invoke account-discover-schema`, + Example: ` sail connectors invoke account-discover-schema`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cc, err := connClient(cmd, client) diff --git a/cmd/connector/conn_invoke_account_list.go b/cmd/connector/conn_invoke_account_list.go index 2c6eff08..ce3f1f2e 100644 --- a/cmd/connector/conn_invoke_account_list.go +++ b/cmd/connector/conn_invoke_account_list.go @@ -12,7 +12,7 @@ func newConnInvokeAccountListCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "account-list", Short: "Invoke a std:account:list command", - Example: `sail connectors invoke account-list`, + Example: ` sail connectors invoke account-list`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cc, err := connClient(cmd, client) diff --git a/cmd/connector/conn_invoke_account_update.go b/cmd/connector/conn_invoke_account_update.go index d8e324f7..1dd0f320 100644 --- a/cmd/connector/conn_invoke_account_update.go +++ b/cmd/connector/conn_invoke_account_update.go @@ -15,7 +15,7 @@ func newConnInvokeAccountUpdateCmd(spClient client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "account-update [id/lookupId] [uniqueId] [--changes ]", Short: "Invoke a std:account:update command", - Example: `sail connectors invoke account-update john.doe --changes '[{"op":"Add","attribute":"groups","value":["Group1","Group2"]},{"op":"Set","attribute":"phone","value":2223334444},{"op":"Remove","attribute":"location"}]'`, + Example: ` sail connectors invoke account-update john.doe --changes '[{"op":"Add","attribute":"groups","value":["Group1","Group2"]}]'`, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/cmd/connector/conn_invoke_change_password.go b/cmd/connector/conn_invoke_change_password.go index 0dedca25..90c6e1a9 100644 --- a/cmd/connector/conn_invoke_change_password.go +++ b/cmd/connector/conn_invoke_change_password.go @@ -14,7 +14,7 @@ func newConnInvokeChangePasswordCmd(spClient client.Client, term terminal.Termin cmd := &cobra.Command{ Use: "change-password", Short: "Invoke a change-password command", - Example: `sail connectors invoke change-password john.doe`, + Example: ` sail connectors invoke change-password john.doe`, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/cmd/connector/conn_invoke_entitlement_list.go b/cmd/connector/conn_invoke_entitlement_list.go index dceea95c..e9b850af 100644 --- a/cmd/connector/conn_invoke_entitlement_list.go +++ b/cmd/connector/conn_invoke_entitlement_list.go @@ -12,7 +12,7 @@ func newConnInvokeEntitlementListCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "entitlement-list [--type ]", Short: "Invoke a std:entitlement:list command", - Example: `sail connectors invoke entitlement-list --type group`, + Example: ` sail connectors invoke entitlement-list --type group`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() cc, err := connClient(cmd, client) diff --git a/cmd/connector/conn_invoke_entitlement_read.go b/cmd/connector/conn_invoke_entitlement_read.go index 83faaa95..f6cd9c03 100644 --- a/cmd/connector/conn_invoke_entitlement_read.go +++ b/cmd/connector/conn_invoke_entitlement_read.go @@ -12,7 +12,7 @@ func newConnInvokeEntitlementReadCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "entitlement-read [id/lookupId] [uniqueId]", Short: "Invoke a std:entitlement:read command", - Example: `sail connectors invoke entitlement-read john.doe --type group`, + Example: ` sail connectors invoke entitlement-read john.doe --type group`, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/cmd/connector/conn_invoke_source_data_discover.go b/cmd/connector/conn_invoke_source_data_discover.go index e2db747f..8a3c9d52 100644 --- a/cmd/connector/conn_invoke_source_data_discover.go +++ b/cmd/connector/conn_invoke_source_data_discover.go @@ -14,7 +14,7 @@ func newConnInvokeSourceDataDiscoverCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "source-data-discover [--query ]", Short: "Invoke a std:source-data:discover command", - Example: `sail connectors invoke source-data-discover --query '{"query": "", "limit": 10}'`, + Example: ` sail connectors invoke source-data-discover --query '{"query": "", "limit": 10}'`, Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/cmd/connector/conn_invoke_source_data_read.go b/cmd/connector/conn_invoke_source_data_read.go index 9070350a..853f692f 100644 --- a/cmd/connector/conn_invoke_source_data_read.go +++ b/cmd/connector/conn_invoke_source_data_read.go @@ -14,7 +14,7 @@ func newConnInvokeSourceDataReadCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "source-data-read [sourceDataKey] [--query ]", Short: "Invoke a std:source-data:read command", - Example: `sail connectors invoke source-data-read john.doe --query '{"query": "jane doe", "excludeItems": ["jane","doe"], "limit": 10}'`, + Example: ` sail connectors invoke source-data-read john.doe --query '{"query": "jane doe", "limit": 10}'`, Args: cobra.RangeArgs(1, 1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/cmd/connector/conn_list.go b/cmd/connector/conn_list.go index f4ce5853..8340457d 100644 --- a/cmd/connector/conn_list.go +++ b/cmd/connector/conn_list.go @@ -18,8 +18,8 @@ import ( func newConnListCmd(client client.Client) *cobra.Command { return &cobra.Command{ Use: "list", - Short: "List Connectors", - Long: "List Connectors For Tenant", + Short: "List all connectors", + Long: "List all connectors for the current tenant", Aliases: []string{"ls"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/connector/conn_logs.go b/cmd/connector/conn_logs.go index 1c2b34a7..c529c57e 100644 --- a/cmd/connector/conn_logs.go +++ b/cmd/connector/conn_logs.go @@ -18,8 +18,8 @@ func newConnLogsCmd(spClient client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "logs", - Short: "List Logs", - Example: "sail logs", + Short: "List connector logs", + Example: " sail conn logs\n sail conn logs --start 2h --level ERROR", RunE: func(cmd *cobra.Command, args []string) error { if err := formatDates(cmd); err != nil { return err diff --git a/cmd/connector/conn_logs_tail.go b/cmd/connector/conn_logs_tail.go index 4697a2f9..725e5f4c 100644 --- a/cmd/connector/conn_logs_tail.go +++ b/cmd/connector/conn_logs_tail.go @@ -13,8 +13,8 @@ func newConnLogsTailCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "tail", - Short: "Tail Logs", - Example: "sail logs tail", + Short: "Tail connector logs in real time", + Example: " sail conn logs tail", RunE: func(cmd *cobra.Command, args []string) error { if err := tailLogs(client, cmd); err != nil { return err diff --git a/cmd/connector/conn_stats.go b/cmd/connector/conn_stats.go index 3279f6e1..15741451 100644 --- a/cmd/connector/conn_stats.go +++ b/cmd/connector/conn_stats.go @@ -27,9 +27,9 @@ var durationMap = map[byte]int64{ func newConnStatsCmd(spClient client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "stats", - Short: "Command Stats", - Long: "Command execution stats for a tenant, default to last 24hs if duration not specified", - Example: "sail conn stats", + Short: "Show connector command statistics", + Long: "Show command execution statistics for a tenant. Defaults to the last 24 hours if no duration is specified.", + Example: " sail conn stats\n sail conn stats -d 1w\n sail conn stats -c ", RunE: func(cmd *cobra.Command, args []string) error { if err := getTenantStats(spClient, cmd); err != nil { return err diff --git a/cmd/connector/conn_tag_create.go b/cmd/connector/conn_tag_create.go index 9440ff13..4fbbb790 100644 --- a/cmd/connector/conn_tag_create.go +++ b/cmd/connector/conn_tag_create.go @@ -18,8 +18,8 @@ import ( func newConnTagCreateCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create", - Short: "Create Connector Tag", - Example: "sail conn tags create -n rc -v 10", + Short: "Create a connector tag", + Example: " sail conn tags create -c -n rc -v 10", RunE: func(cmd *cobra.Command, args []string) error { connectorRef := cmd.Flags().Lookup("id").Value.String() tagName := cmd.Flags().Lookup("name").Value.String() diff --git a/cmd/connector/conn_tag_list.go b/cmd/connector/conn_tag_list.go index b209ecf7..2fd6e9c7 100644 --- a/cmd/connector/conn_tag_list.go +++ b/cmd/connector/conn_tag_list.go @@ -17,7 +17,7 @@ func newConnTagListCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List tags for a connector", - Example: "sail conn tags list -c 1234", + Example: " sail conn tags list -c ", RunE: func(cmd *cobra.Command, args []string) error { connectorRef := cmd.Flags().Lookup("id").Value.String() diff --git a/cmd/connector/conn_tag_update.go b/cmd/connector/conn_tag_update.go index 95557e3c..23db32ca 100644 --- a/cmd/connector/conn_tag_update.go +++ b/cmd/connector/conn_tag_update.go @@ -20,8 +20,8 @@ func newConnTagUpdateCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "update", - Short: "Update Connector Tag", - Example: "sail conn tags update -n rc -v 10", + Short: "Update a connector tag", + Example: " sail conn tags update -c -n rc -v 10", RunE: func(cmd *cobra.Command, args []string) error { connectorRef := cmd.Flags().Lookup("id").Value.String() tagName := cmd.Flags().Lookup("name").Value.String() diff --git a/cmd/connector/conn_update.go b/cmd/connector/conn_update.go index c22d2ca8..b6809a55 100644 --- a/cmd/connector/conn_update.go +++ b/cmd/connector/conn_update.go @@ -17,8 +17,8 @@ import ( func newConnUpdateCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "update", - Short: "Update Connector", - Long: "Update Connector", + Short: "Update a connector", + Long: "Update a connector's alias", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { id := cmd.Flags().Lookup("id").Value.String() diff --git a/cmd/connector/conn_validate_sources.go b/cmd/connector/conn_validate_sources.go index 5ec2625b..07ad88bd 100644 --- a/cmd/connector/conn_validate_sources.go +++ b/cmd/connector/conn_validate_sources.go @@ -62,9 +62,9 @@ func (v *ValidationResults) Render() { func newConnValidateSourcesCmd(apiClient client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "validate-sources", - Short: "Validate connectors behavior", + Short: "Validate connector behavior from sources", Long: "Validate connectors behavior from a list that stores in sources.yaml", - Example: "sail conn validate-sources", + Example: " sail conn validate-sources", RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/cmd/connector/conn_versions.go b/cmd/connector/conn_versions.go index 68613e47..6c0c6423 100644 --- a/cmd/connector/conn_versions.go +++ b/cmd/connector/conn_versions.go @@ -16,8 +16,8 @@ import ( func newConnVersionsCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "versions", - Short: "Get Connector Versions", - Long: "Get Connector Versions", + Short: "List connector versions", + Long: "List all versions of a connector", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { connectorRef := cmd.Flags().Lookup("id").Value.String() diff --git a/cmd/connector/crud_live_test.go b/cmd/connector/crud_live_test.go new file mode 100644 index 00000000..e072f788 --- /dev/null +++ b/cmd/connector/crud_live_test.go @@ -0,0 +1,169 @@ +package connector + +import ( + "bytes" + "regexp" + "strings" + "testing" + + "github.com/sailpoint-oss/sailpoint-cli/internal/client" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/testutil" + "github.com/spf13/cobra" +) + +var idPattern = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`) + +func TestConnectorCustomizerCRUD(t *testing.T) { + testutil.RequireLiveCredentials(t) + + spClient := requireConnectorClient(t) + name := testutil.UniqueName("customizer") + updatedName := name + "-updated" + + createCmd := newCustomizerCreateCmd(spClient) + createOut := new(bytes.Buffer) + createCmd.SetOut(createOut) + createCmd.SetArgs([]string{name}) + + if err := createCmd.Execute(); err != nil { + testutil.SkipIfFeatureUnavailable(t, err) + t.Fatalf("customizer create failed: %v", err) + } + + customizerID := firstID(t, createOut.String()) + defer deleteCustomizer(t, spClient, customizerID) + + getCustomizerAndAssert(t, spClient, customizerID, name) + + updateCmd := newCustomizerUpdateCmd(spClient) + updateOut := new(bytes.Buffer) + updateCmd.SetOut(updateOut) + updateCmd.Flags().Set("id", customizerID) + updateCmd.Flags().Set("name", updatedName) + + if err := updateCmd.Execute(); err != nil { + t.Fatalf("customizer update failed: %v", err) + } + if !strings.Contains(updateOut.String(), updatedName) { + t.Fatalf("expected updated customizer output to contain %q, got %q", updatedName, updateOut.String()) + } + + getCustomizerAndAssert(t, spClient, customizerID, updatedName) +} + +func TestConnectorCRUD(t *testing.T) { + testutil.RequireLiveCredentials(t) + + spClient := requireConnectorClient(t) + alias := testutil.UniqueName("connector") + updatedAlias := alias + "-updated" + + createCmd := newConnCreateCmd(spClient) + createOut := new(bytes.Buffer) + createCmd.SetOut(createOut) + createCmd.SetArgs([]string{alias}) + addConnEndpointFlag(createCmd) + + if err := createCmd.Execute(); err != nil { + testutil.SkipIfFeatureUnavailable(t, err) + t.Fatalf("connector create failed: %v", err) + } + + connectorID := firstID(t, createOut.String()) + defer deleteConnector(t, spClient, connectorID) + + getConnectorAndAssert(t, spClient, connectorID, alias) + + updateCmd := newConnUpdateCmd(spClient) + updateOut := new(bytes.Buffer) + updateCmd.SetOut(updateOut) + updateCmd.Flags().Set("id", connectorID) + updateCmd.Flags().Set("alias", updatedAlias) + addConnEndpointFlag(updateCmd) + + if err := updateCmd.Execute(); err != nil { + t.Fatalf("connector update failed: %v", err) + } + if !strings.Contains(updateOut.String(), updatedAlias) { + t.Fatalf("expected updated connector output to contain %q, got %q", updatedAlias, updateOut.String()) + } + + getConnectorAndAssert(t, spClient, connectorID, updatedAlias) +} + +func requireConnectorClient(t *testing.T) client.Client { + t.Helper() + + cfg, err := config.GetConfig() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + return client.NewSpClient(cfg) +} + +func firstID(t *testing.T, output string) string { + t.Helper() + + match := idPattern.FindString(output) + if match == "" { + t.Fatalf("expected UUID-like ID in output %q", output) + } + return match +} + +func getCustomizerAndAssert(t *testing.T, spClient client.Client, id string, expectedName string) { + t.Helper() + + getCmd := newCustomizerGetCmd(spClient) + getOut := new(bytes.Buffer) + getCmd.SetOut(getOut) + getCmd.Flags().Set("id", id) + if err := getCmd.Execute(); err != nil { + t.Fatalf("customizer get failed: %v", err) + } + if !strings.Contains(getOut.String(), id) || !strings.Contains(getOut.String(), expectedName) { + t.Fatalf("expected customizer get output to contain %q and %q, got %q", id, expectedName, getOut.String()) + } +} + +func getConnectorAndAssert(t *testing.T, spClient client.Client, id string, expectedAlias string) { + t.Helper() + + getCmd := newConnGetCmd(spClient) + getOut := new(bytes.Buffer) + getCmd.SetOut(getOut) + getCmd.Flags().Set("id", id) + addConnEndpointFlag(getCmd) + if err := getCmd.Execute(); err != nil { + t.Fatalf("connector get failed: %v", err) + } + if !strings.Contains(getOut.String(), id) || !strings.Contains(getOut.String(), expectedAlias) { + t.Fatalf("expected connector get output to contain %q and %q, got %q", id, expectedAlias, getOut.String()) + } +} + +func deleteCustomizer(t *testing.T, spClient client.Client, id string) { + t.Helper() + + deleteCmd := newCustomizerDeleteCmd(spClient) + deleteCmd.Flags().Set("id", id) + if err := deleteCmd.Execute(); err != nil { + t.Logf("failed to clean up connector customizer %s: %v", id, err) + } +} + +func deleteConnector(t *testing.T, spClient client.Client, id string) { + t.Helper() + + deleteCmd := newConnDeleteCmd(spClient) + deleteCmd.Flags().Set("id", id) + addConnEndpointFlag(deleteCmd) + if err := deleteCmd.Execute(); err != nil { + t.Logf("failed to clean up connector %s: %v", id, err) + } +} + +func addConnEndpointFlag(cmd *cobra.Command) { + cmd.PersistentFlags().StringP("conn-endpoint", "e", connectorsEndpoint, "Override connectors endpoint") +} diff --git a/cmd/connector/customizer_create.go b/cmd/connector/customizer_create.go index 1740e909..b13c7bc6 100644 --- a/cmd/connector/customizer_create.go +++ b/cmd/connector/customizer_create.go @@ -18,7 +18,7 @@ func newCustomizerCreateCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "create ", Short: "Create connector customizer", - Example: "sail conn customizers create \"My Customizer\"", + Example: " sail conn customizers create \"My Customizer\"", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { raw, err := json.Marshal(customizer{Name: args[0]}) diff --git a/cmd/connector/customizer_create_version.go b/cmd/connector/customizer_create_version.go index fdea814b..0d2e9ad4 100644 --- a/cmd/connector/customizer_create_version.go +++ b/cmd/connector/customizer_create_version.go @@ -19,7 +19,7 @@ func newCustomizerCreateVersionCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "upload", Short: "Upload connector customizer", - Example: "sail conn customizers upload -c 1234 -f path/to/zip/archive.zip", + Example: " sail conn customizers upload -c -f path/to/zip/archive.zip", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { id := cmd.Flags().Lookup("id").Value.String() diff --git a/cmd/connector/customizer_delete.go b/cmd/connector/customizer_delete.go index dfb2f2f7..54925c96 100644 --- a/cmd/connector/customizer_delete.go +++ b/cmd/connector/customizer_delete.go @@ -15,7 +15,7 @@ func newCustomizerDeleteCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "delete", Short: "Delete connector customizer", - Example: "sail conn customizers delete -c 1234", + Example: " sail conn customizers delete -c ", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { id := cmd.Flags().Lookup("id").Value.String() diff --git a/cmd/connector/customizer_get.go b/cmd/connector/customizer_get.go index 6b0d7de3..c7d23072 100644 --- a/cmd/connector/customizer_get.go +++ b/cmd/connector/customizer_get.go @@ -17,7 +17,7 @@ func newCustomizerGetCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "get", Short: "Get connector customizer", - Example: "sail conn customizers update -c 1234", + Example: " sail conn customizers get -c ", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { id := cmd.Flags().Lookup("id").Value.String() diff --git a/cmd/connector/customizer_init.go b/cmd/connector/customizer_init.go index b78fcb01..28ed7bdf 100644 --- a/cmd/connector/customizer_init.go +++ b/cmd/connector/customizer_init.go @@ -26,7 +26,7 @@ func newCustomizerInitCmd() *cobra.Command { Use: "init ", Short: "Initialize new connector customizer project", Long: `init sets up a new TypeScript project with sample connector customizer included for reference.`, - Example: "sail conn customizers init \"My Customizer\"", + Example: " sail conn customizers init \"My Customizer\"", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { projName := args[0] diff --git a/cmd/connector/customizer_link.go b/cmd/connector/customizer_link.go index 6a972120..637c9836 100644 --- a/cmd/connector/customizer_link.go +++ b/cmd/connector/customizer_link.go @@ -18,7 +18,7 @@ func newCustomizerLinkCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "link", Short: "Link connector customizer to connector instance", - Example: "sail conn customizers link -c 1234 -i 5678", + Example: " sail conn customizers link -c -i ", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { customizerID := cmd.Flags().Lookup("id").Value.String() diff --git a/cmd/connector/customizer_list.go b/cmd/connector/customizer_list.go index 72df5067..fd43b41a 100644 --- a/cmd/connector/customizer_list.go +++ b/cmd/connector/customizer_list.go @@ -17,7 +17,7 @@ func newCustomizerListCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List all customizers", - Example: "sail conn customizers list", + Example: " sail conn customizers list", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { resp, err := client.Get(cmd.Context(), util.ResourceUrl(connectorCustomizersEndpoint), nil) diff --git a/cmd/connector/customizer_unlink.go b/cmd/connector/customizer_unlink.go index 456676d1..d58eca45 100644 --- a/cmd/connector/customizer_unlink.go +++ b/cmd/connector/customizer_unlink.go @@ -18,7 +18,7 @@ func newCustomizerUnlinkCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "unlink", Short: "Unlink connector customizer from connector instance", - Example: "sail conn customizers unlink -i 5678", + Example: " sail conn customizers unlink -i ", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { instanceID := cmd.Flags().Lookup("instance-id").Value.String() diff --git a/cmd/connector/customizer_update.go b/cmd/connector/customizer_update.go index 1572e84a..ef36c395 100644 --- a/cmd/connector/customizer_update.go +++ b/cmd/connector/customizer_update.go @@ -17,8 +17,8 @@ import ( func newCustomizerUpdateCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "update", - Short: "Create connector customizer", - Example: "sail conn customizers update -c 1234 -n \"My Customizer\"", + Short: "Update a connector customizer", + Example: " sail conn customizers update -c -n \"My Customizer\"", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/connector/instance_list.go b/cmd/connector/instance_list.go index 48c912db..aa104d90 100644 --- a/cmd/connector/instance_list.go +++ b/cmd/connector/instance_list.go @@ -17,7 +17,7 @@ func newInstanceListCmd(client client.Client) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List all connector instances", - Example: "sail conn instances list", + Example: " sail conn instances list", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { resp, err := client.Get(cmd.Context(), util.ResourceUrl(connectorInstancesEndpoint), nil) diff --git a/cmd/entitlement/entitlement.go b/cmd/entitlement/entitlement.go new file mode 100644 index 00000000..45729158 --- /dev/null +++ b/cmd/entitlement/entitlement.go @@ -0,0 +1,141 @@ +package entitlement + +import ( + "context" + + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/sdkcmd" + "github.com/spf13/cobra" +) + +func NewEntitlementCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "entitlement", + Short: "Inspect entitlements", + Long: "\nInspect Identity Security Cloud entitlements and entitlement hierarchy relationships.\n\n", + Example: " sail entitlement list\n sail entitlement get \n sail entitlement children ", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + cmd.AddCommand( + newListCommand(), + newGetCommand(), + newImportCommand(), + newParentsCommand(), + newChildrenCommand(), + ) + return cmd +} + +func newListCommand() *cobra.Command { + var opts sdkcmd.ListOptions + cmd := &cobra.Command{ + Use: "list", + Short: "List entitlements", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + req := apiClient.V2024.EntitlementsAPI.ListEntitlements(context.TODO()). + Limit(opts.Limit). + Offset(opts.Offset). + Count(opts.Count) + if opts.Filters != "" { + req = req.Filters(opts.Filters) + } + if opts.Sorters != "" { + req = req.Sorters(opts.Sorters) + } + entitlements, resp, err := req.Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + rows := make([][]string, 0, len(entitlements)) + for _, item := range entitlements { + rows = append(rows, []string{item.GetName(), item.GetId(), item.GetAttribute(), item.GetDescription()}) + } + return sdkcmd.WriteTable(cmd, []string{"Name", "ID", "Attribute", "Description"}, rows, "Name", entitlements) + }, + } + sdkcmd.AddListFlags(cmd, &opts) + return cmd +} + +func newGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get an entitlement", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + entitlement, resp, err := apiClient.V2024.EntitlementsAPI.GetEntitlement(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, entitlement) + }, + } +} + +func newImportCommand() *cobra.Command { + return &cobra.Command{ + Use: "import ", + Short: "Import entitlements for a source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + task, resp, err := apiClient.V2024.EntitlementsAPI.ImportEntitlementsBySource(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, task) + }, + } +} + +func newParentsCommand() *cobra.Command { + return &cobra.Command{ + Use: "parents ", + Short: "List parent entitlements", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + entitlements, resp, err := apiClient.V2024.EntitlementsAPI.ListEntitlementParents(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, entitlements) + }, + } +} + +func newChildrenCommand() *cobra.Command { + return &cobra.Command{ + Use: "children ", + Short: "List child entitlements", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + entitlements, resp, err := apiClient.V2024.EntitlementsAPI.ListEntitlementChildren(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, entitlements) + }, + } +} diff --git a/cmd/entitlement/read_live_test.go b/cmd/entitlement/read_live_test.go new file mode 100644 index 00000000..65496086 --- /dev/null +++ b/cmd/entitlement/read_live_test.go @@ -0,0 +1,48 @@ +package entitlement + +import ( + "bytes" + "testing" + + v2024 "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" + "github.com/sailpoint-oss/sailpoint-cli/internal/testutil" +) + +func TestEntitlementListAndGet(t *testing.T) { + testutil.RequireLiveCredentials(t) + testutil.SetJSONOutput(t) + + listCmd := newListCommand() + listOut := new(bytes.Buffer) + listCmd.SetOut(listOut) + listCmd.Flags().Set("limit", "5") + + if err := listCmd.Execute(); err != nil { + testutil.SkipIfFeatureUnavailable(t, err) + t.Fatalf("entitlement list failed: %v", err) + } + + entitlements := testutil.DecodeJSON[[]v2024.Entitlement](t, listOut.String()) + if len(entitlements) == 0 { + t.Skip("skipping entitlement get test: entitlement list returned no results") + } + + entitlementID := entitlements[0].GetId() + if entitlementID == "" { + t.Fatalf("expected listed entitlement to have an ID: %#v", entitlements[0]) + } + + getCmd := newGetCommand() + getOut := new(bytes.Buffer) + getCmd.SetOut(getOut) + getCmd.SetArgs([]string{entitlementID}) + + if err := getCmd.Execute(); err != nil { + t.Fatalf("entitlement get failed: %v", err) + } + + entitlement := testutil.DecodeJSON[v2024.Entitlement](t, getOut.String()) + if entitlement.GetId() != entitlementID { + t.Fatalf("expected entitlement ID %q, got %q", entitlementID, entitlement.GetId()) + } +} diff --git a/cmd/env/create.go b/cmd/env/create.go new file mode 100644 index 00000000..72c0736f --- /dev/null +++ b/cmd/env/create.go @@ -0,0 +1,204 @@ +package env + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/auth" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" + "github.com/spf13/cobra" +) + +func newCreateCommand() *cobra.Command { + return &cobra.Command{ + Use: "create [name]", + Short: "Create a new environment", + Long: "\nCreate a new CLI environment with tenant configuration and authentication.\n\nThis interactive command walks you through setting up a tenant URL,\nchoosing an authentication method (PAT or OAuth), and configuring credentials.\n\n", + Example: ` sail env create + sail env create production`, + Aliases: []string{"c"}, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return createOrUpdateEnv(args, false) + }, + } +} + +func createOrUpdateEnv(args []string, update bool) error { + environments := config.GetEnvironments() + + var envName string + if len(args) > 0 { + envName = args[0] + } + + if !update && envName != "" && environments[envName] != nil { + fmt.Printf("Environment '%s' already exists. Use 'sail env update %s' to update it.\n", envName, envName) + return clierror.Usage("environment already exists: " + envName) + } + + if update { + fmt.Print("This utility will walk you through updating an environment.\n\n") + } else { + fmt.Print("This utility will walk you through creating a new environment.\n\n") + } + fmt.Print("Press ^C at any time to quit.\n\n") + + // Prompt for tenant name + defaultTenant := envName + if update && envName == "" { + defaultTenant = config.GetActiveEnvironment() + } + + tenant, err := tui.Input("Tenant (e.g. acme)", defaultTenant) + if err != nil { + return err + } + if tenant == "" { + tenant = defaultTenant + } + + if tenant == "" { + return fmt.Errorf("tenant name is required") + } + + // Check for existing env when creating + if !update && environments[tenant] != nil && envName == "" { + fmt.Printf("Environment '%s' already exists. Use 'sail env update %s' to update it.\n", tenant, tenant) + return clierror.Usage("environment already exists: " + tenant) + } + + // Determine the environment name to use in config + effectiveName := envName + if effectiveName == "" { + effectiveName = tenant + } + + tenantURL := "https://" + tenant + ".identitynow.com" + baseURL := "https://" + tenant + ".api.identitynow.com" + + fmt.Print("\nIf the generated URLs below are correct, press Enter to accept them.\n\n") + tenantURL, err = tui.Input("Tenant URL", tenantURL) + if err != nil { + return err + } + baseURL, err = tui.Input("Base URL", baseURL) + if err != nil { + return err + } + + // Prompt for auth type + authType, err := promptAuthType() + if err != nil { + return err + } + + // Set the environment as active and configure URLs + config.SetActiveEnvironment(effectiveName) + config.SetTenantUrl(tenantURL) + config.SetBaseUrl(baseURL) + config.SetAuthType(authType) + + // Configure auth credentials inline + switch authType { + case "pat": + if err := configurePAT(effectiveName); err != nil { + return err + } + case "oauth": + if err := configureOAuth(effectiveName, baseURL); err != nil { + return err + } + } + + action := "created" + if update { + action = "updated" + } + fmt.Printf("\nEnvironment '%s' %s and set as active.\n", effectiveName, action) + + return nil +} + +func promptAuthType() (string, error) { + items := []tui.Choice{ + {Title: "PAT", Description: "Personal Access Token - authenticate with Client ID and Client Secret"}, + {Title: "OAuth", Description: "OAuth2.0 - sign in via the Identity Security Cloud web portal"}, + } + + choice, err := tui.PromptList(items, "Choose an authentication method") + if err != nil { + return "", err + } + + return strings.ToLower(choice.Title), nil +} + +func configurePAT(envName string) error { + clientID, err := auth.PromptForClientID() + if err != nil { + return err + } + + clientSecret, err := auth.PromptForClientSecret() + if err != nil { + return err + } + + if err := auth.SetPatClientID(envName, clientID); err != nil { + return err + } + if err := auth.SetPatClientSecret(envName, clientSecret); err != nil { + return err + } + if err := auth.ResetCachePAT(envName); err != nil { + return err + } + + // Verify credentials by fetching a token + fmt.Print("\nVerifying credentials... ") + tokenURL := config.GetBaseUrl() + "/oauth/token" + set, err := auth.PATLogin(tokenURL, clientID, clientSecret) + if err != nil { + fmt.Println("FAILED") + return fmt.Errorf("credential verification failed: %w", err) + } + fmt.Println("OK") + + if err := auth.CachePAT(envName, set); err != nil { + log.Warn("Failed to cache token", "error", err) + } + + claims, err := auth.GetTokenClaims(set.AccessToken) + if err == nil && claims["user_name"] != nil { + fmt.Printf("Authenticated as: %v (org: %v)\n", claims["user_name"], claims["org"]) + } + + return nil +} + +func configureOAuth(envName, baseURL string) error { + fmt.Println("\nInitiating OAuth login...") + set, err := auth.OAuthLogin(baseURL) + if err != nil { + return err + } + + if set.BaseURL != "" { + config.SetBaseUrl(set.BaseURL) + } + + if err := auth.CacheOAuth(envName, set); err != nil { + log.Warn("Failed to cache OAuth tokens", "error", err) + } + + claims, err := auth.GetTokenClaims(set.AccessToken) + if err == nil && claims["user_name"] != nil { + fmt.Printf("Authenticated as: %v (org: %v)\n", claims["user_name"], claims["org"]) + } + + return nil +} diff --git a/cmd/env/crud_test.go b/cmd/env/crud_test.go new file mode 100644 index 00000000..641d6196 --- /dev/null +++ b/cmd/env/crud_test.go @@ -0,0 +1,115 @@ +package env + +import ( + "bytes" + "testing" + + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/testutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func TestEnvLocalCRUD(t *testing.T) { + testutil.SetJSONOutput(t) + + previousEnvironments := viper.GetStringMap("environments") + previousActive := viper.GetString("activeenvironment") + t.Cleanup(func() { + viper.Set("environments", previousEnvironments) + viper.Set("activeenvironment", previousActive) + }) + + envName := "sail-cli-ci-env" + environments := map[string]any{ + envName: map[string]any{ + "tenanturl": "https://tenant.identitynow.com", + "baseurl": "https://tenant.api.identitynow.com", + "authtype": "pat", + }, + } + viper.Set("environments", environments) + config.SetActiveEnvironment(envName) + + showOut := executeEnvCommand(t, newShowCommand(), []string{envName}) + shown := testutil.DecodeJSON[map[string]any](t, showOut) + if shown["name"] != envName { + t.Fatalf("expected env show name %q, got %#v", envName, shown["name"]) + } + + listOut := executeEnvCommand(t, newListCommand(), nil) + listed := testutil.DecodeJSON[[]map[string]any](t, listOut) + if len(listed) != 1 || listed[0]["name"] != envName { + t.Fatalf("expected env list to contain %q, got %#v", envName, listed) + } + + otherEnv := "sail-cli-ci-env-other" + environments[otherEnv] = map[string]any{ + "tenanturl": "https://other.identitynow.com", + "baseurl": "https://other.api.identitynow.com", + "authtype": "oauth", + } + viper.Set("environments", environments) + + useCmd := newUseCommand() + useCmd.SetArgs([]string{otherEnv}) + if err := useCmd.Execute(); err != nil { + t.Fatalf("env use failed: %v", err) + } + if config.GetActiveEnvironment() != otherEnv { + t.Fatalf("expected active environment %q, got %q", otherEnv, config.GetActiveEnvironment()) + } + + deleteCmd := newDeleteCommand() + deleteCmd.SetArgs([]string{envName}) + deleteCmd.Flags().Set("force", "true") + if err := deleteCmd.Execute(); err != nil { + t.Fatalf("env delete failed: %v", err) + } + if config.GetEnvironments()[envName] != nil { + t.Fatalf("expected environment %q to be deleted", envName) + } +} + +func TestEnvDeleteLastActiveEnvironmentClearsActiveEnvironment(t *testing.T) { + previousEnvironments := viper.GetStringMap("environments") + previousActive := viper.GetString("activeenvironment") + t.Cleanup(func() { + viper.Set("environments", previousEnvironments) + viper.Set("activeenvironment", previousActive) + config.ClearActiveEnvironmentOverride() + }) + + envName := "sail-cli-ci-only-env" + viper.Set("environments", map[string]any{ + envName: map[string]any{ + "tenanturl": "https://tenant.identitynow.com", + "baseurl": "https://tenant.api.identitynow.com", + "authtype": "oauth", + }, + }) + config.SetActiveEnvironment(envName) + + deleteCmd := newDeleteCommand() + deleteCmd.SetArgs([]string{envName}) + deleteCmd.Flags().Set("force", "true") + if err := deleteCmd.Execute(); err != nil { + t.Fatalf("env delete failed: %v", err) + } + + if got := config.GetActiveEnvironment(); got != "" { + t.Fatalf("expected active environment to be cleared, got %q", got) + } +} + +func executeEnvCommand(t *testing.T, cmd *cobra.Command, args []string) string { + t.Helper() + + out := new(bytes.Buffer) + cmd.SetOut(out) + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("env command failed: %v", err) + } + return out.String() +} diff --git a/cmd/env/delete.go b/cmd/env/delete.go new file mode 100644 index 00000000..942a2708 --- /dev/null +++ b/cmd/env/delete.go @@ -0,0 +1,96 @@ +package env + +import ( + "fmt" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/auth" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func newDeleteCommand() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "delete [name]", + Short: "Delete an environment", + Long: "\nDelete a CLI environment and its stored credentials.\nDefaults to the active environment if no name is provided.\n\n", + Example: ` sail env delete staging + sail env delete + sail env delete production --force`, + Aliases: []string{"d", "rm"}, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completeEnvironmentNames, + RunE: func(cmd *cobra.Command, args []string) error { + environments := config.GetEnvironments() + + envName := config.GetActiveEnvironment() + if len(args) > 0 { + envName = args[0] + } + + if envName == "" { + log.Warn("No active environment configured") + return clierror.Usage("no active environment configured") + } + + if environments[envName] == nil { + log.Warn("Environment does not exist", "name", envName) + return clierror.NotFound("environment", envName, "Run 'sail env list' to see configured environments.") + } + + // Safety check: warn if deleting active env + isActive := envName == config.GetActiveEnvironment() + + if !force { + msg := fmt.Sprintf("Delete environment '%s'?", envName) + if isActive { + msg = fmt.Sprintf("Delete ACTIVE environment '%s'?", envName) + } + confirmed, err := tui.Confirm(msg) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintln(cmd.ErrOrStderr(), "Cancelled.") + return clierror.Canceled("environment delete") + } + } + + // Remove from config + delete(environments, envName) + viper.Set("environments", environments) + + // Clean up all keyring entries + auth.DeleteAllPatSecrets(envName) + auth.DeleteAllOAuthSecrets(envName) + + // If we deleted the active env, switch to another or clear + if isActive { + if len(environments) == 0 { + config.SetActiveEnvironment("") + log.Info("Environment deleted. No environments remaining.", "deleted", envName) + } else { + // Pick the first available environment + for k := range environments { + config.SetActiveEnvironment(k) + log.Info("Environment deleted. Switched active environment.", "deleted", envName, "active", k) + break + } + } + } else { + log.Info("Environment deleted", "name", envName) + } + + return nil + }, + } + + cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt") + + return cmd +} diff --git a/cmd/env/env.go b/cmd/env/env.go new file mode 100644 index 00000000..e3e35e6a --- /dev/null +++ b/cmd/env/env.go @@ -0,0 +1,45 @@ +package env + +import ( + "sort" + + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/cobra" +) + +func NewEnvCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "env", + Short: "Manage CLI environments", + Long: "\nManage SailPoint Identity Security Cloud environments for the CLI.\n\nEach environment represents a tenant with its own authentication configuration.\n", + Example: ` sail env list + sail env create production + sail env use staging + sail env show`, + // "environment" alias not used here since the deprecated cmd/environment still exists + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + + cmd.AddCommand( + newListCommand(), + newShowCommand(), + newCreateCommand(), + newUpdateCommand(), + newDeleteCommand(), + newUseCommand(), + ) + + return cmd +} + +func completeEnvironmentNames(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + environments := config.GetEnvironments() + names := make([]string, 0, len(environments)) + for name := range environments { + names = append(names, name) + } + sort.Strings(names) + return names, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/env/list.go b/cmd/env/list.go new file mode 100644 index 00000000..9d3ccb54 --- /dev/null +++ b/cmd/env/list.go @@ -0,0 +1,70 @@ +package env + +import ( + "sort" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/output" + "github.com/spf13/cobra" +) + +func newListCommand() *cobra.Command { + type environmentRow struct { + Active bool `json:"active" yaml:"active"` + Name string `json:"name" yaml:"name"` + TenantURL string `json:"tenantUrl" yaml:"tenantUrl"` + AuthType string `json:"authType" yaml:"authType"` + } + + return &cobra.Command{ + Use: "list", + Short: "List all configured environments", + Long: "\nList all configured environments with their tenant URLs and auth types.\n\n", + Example: "sail env list", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + environments := config.GetEnvironments() + + if len(environments) == 0 { + log.Warn("No environments configured. Run 'sail env create' to create one.") + return nil + } + + activeEnv := config.GetActiveEnvironment() + + headers := []string{"", "Name", "Tenant URL", "Auth Type"} + var rows [][]string + var structuredRows []environmentRow + + // Sort env names for stable output + names := make([]string, 0, len(environments)) + for name := range environments { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + active := "" + if name == activeEnv { + active = "*" + } + tenantURL := config.GetEnvTenantUrl(name) + authType := config.GetEnvAuthType(name) + if authType == "" { + authType = "pat" + } + rows = append(rows, []string{active, name, tenantURL, authType}) + structuredRows = append(structuredRows, environmentRow{ + Active: name == activeEnv, + Name: name, + TenantURL: tenantURL, + AuthType: authType, + }) + } + + return output.WriteTableOrStructured(cmd.OutOrStdout(), headers, rows, "", structuredRows) + }, + } +} diff --git a/cmd/env/show.go b/cmd/env/show.go new file mode 100644 index 00000000..916c6f1f --- /dev/null +++ b/cmd/env/show.go @@ -0,0 +1,83 @@ +package env + +import ( + "fmt" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/output" + "github.com/sailpoint-oss/sailpoint-cli/internal/redact" + "github.com/sailpoint-oss/sailpoint-cli/internal/util" + "github.com/spf13/cobra" +) + +func newShowCommand() *cobra.Command { + var showRaw bool + type environmentDetails struct { + Name string `json:"name" yaml:"name"` + Active bool `json:"active" yaml:"active"` + TenantURL string `json:"tenantUrl" yaml:"tenantUrl"` + BaseURL string `json:"baseUrl" yaml:"baseUrl"` + AuthType string `json:"authType" yaml:"authType"` + } + + cmd := &cobra.Command{ + Use: "show [name]", + Short: "Show details of an environment", + Long: "\nShow the configuration details of an environment.\nDefaults to the active environment if no name is provided.\n\n", + Example: "sail env show\nsail env show production", + Aliases: []string{"s"}, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completeEnvironmentNames, + RunE: func(cmd *cobra.Command, args []string) error { + environments := config.GetEnvironments() + + envName := config.GetActiveEnvironment() + if len(args) > 0 { + envName = args[0] + } + + if envName == "" || (envName != "" && environments[envName] == nil) { + if envName == "" { + log.Warn("No active environment configured. Run 'sail env create' to create one.") + } else { + log.Warn("Environment does not exist", "name", envName) + } + return clierror.NotFound("environment", envName, "Run 'sail env list' to see configured environments.") + } + + details := environmentDetails{ + Name: envName, + Active: envName == config.GetActiveEnvironment(), + TenantURL: config.GetEnvTenantUrl(envName), + BaseURL: config.GetEnvBaseUrl(envName), + AuthType: config.GetEnvAuthType(envName), + } + + if output.IsMachineReadable() { + return output.WriteStructured(cmd.OutOrStdout(), details) + } + + activeIndicator := "" + if details.Active { + activeIndicator = " (active)" + } + + fmt.Fprintf(cmd.OutOrStdout(), "Environment: %s%s\n", envName, activeIndicator) + fmt.Fprintf(cmd.OutOrStdout(), " Tenant URL: %s\n", details.TenantURL) + fmt.Fprintf(cmd.OutOrStdout(), " Base URL: %s\n", details.BaseURL) + fmt.Fprintf(cmd.OutOrStdout(), " Auth Type: %s\n", details.AuthType) + + if showRaw { + raw := redact.Value(environments[envName]) + fmt.Fprintf(cmd.OutOrStdout(), "\nRaw config (redacted):\n%s\n", util.PrettyPrint(raw)) + } + + return nil + }, + } + + cmd.Flags().BoolVar(&showRaw, "raw", false, "Show redacted raw environment configuration") + return cmd +} diff --git a/cmd/env/update.go b/cmd/env/update.go new file mode 100644 index 00000000..8d5dea0f --- /dev/null +++ b/cmd/env/update.go @@ -0,0 +1,42 @@ +package env + +import ( + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/cobra" +) + +func newUpdateCommand() *cobra.Command { + return &cobra.Command{ + Use: "update [name]", + Short: "Update an existing environment", + Long: "\nUpdate the configuration of an existing environment.\nDefaults to the active environment if no name is provided.\n\n", + Example: ` sail env update + sail env update production`, + Aliases: []string{"up"}, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completeEnvironmentNames, + RunE: func(cmd *cobra.Command, args []string) error { + environments := config.GetEnvironments() + + envName := config.GetActiveEnvironment() + if len(args) > 0 { + envName = args[0] + } + + if envName == "" { + log.Warn("No active environment configured. Run 'sail env create' to create one.") + return clierror.Usage("no active environment configured") + } + + if environments[envName] == nil { + log.Warn("Environment does not exist", "name", envName, + "hint", "Use 'sail env create "+envName+"' to create it.") + return clierror.NotFound("environment", envName, "Use 'sail env create "+envName+"' to create it.") + } + + return createOrUpdateEnv([]string{envName}, true) + }, + } +} diff --git a/cmd/env/use.go b/cmd/env/use.go new file mode 100644 index 00000000..be52fb7f --- /dev/null +++ b/cmd/env/use.go @@ -0,0 +1,42 @@ +package env + +import ( + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/cobra" +) + +func newUseCommand() *cobra.Command { + return &cobra.Command{ + Use: "use ", + Short: "Switch the active environment", + Long: "\nSet an environment as the active environment for CLI commands.\n\n", + Example: "sail env use production", + Aliases: []string{"u"}, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeEnvironmentNames, + RunE: func(cmd *cobra.Command, args []string) error { + envName := args[0] + environments := config.GetEnvironments() + + if environments[envName] == nil { + log.Warn("Environment does not exist", + "name", envName, + "hint", "Use 'sail env create "+envName+"' to create it.") + return clierror.NotFound("environment", envName, "Use 'sail env create "+envName+"' to create it.") + } + + config.SetActiveEnvironment(envName) + authType := config.GetEnvAuthType(envName) + tenantURL := config.GetEnvTenantUrl(envName) + + log.Info("Switched active environment", + "env", envName, + "tenant", tenantURL, + "auth", authType) + + return nil + }, + } +} diff --git a/cmd/environment/delete.go b/cmd/environment/delete.go index 62c6b5f8..c6a74d31 100644 --- a/cmd/environment/delete.go +++ b/cmd/environment/delete.go @@ -4,7 +4,7 @@ package environment import ( "github.com/charmbracelet/log" "github.com/sailpoint-oss/sailpoint-cli/internal/config" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -23,8 +23,11 @@ func newDeleteCommand() *cobra.Command { if environments[environmentName] != nil { log.Warn("You are about to Delete the Environment", "env", environmentName) - res := terminal.InputPrompt("Press Enter to continue") - if res == "" { + confirmed, err := tui.Confirm("Delete environment " + environmentName + "?") + if err != nil { + return err + } + if confirmed { delete(environments, environmentName) viper.Set("environments", environments) @@ -60,8 +63,11 @@ func newDeleteCommand() *cobra.Command { if env != "" && env != " " { log.Warn("You are about to Delete the active Environment", "env", env) - res := terminal.InputPrompt("Press Enter to continue") - if res == "" { + confirmed, err := tui.Confirm("Delete active environment " + env + "?") + if err != nil { + return err + } + if confirmed { delete(environments, env) viper.Set("environments", environments) diff --git a/cmd/environment/environment.go b/cmd/environment/environment.go index c839d5ae..6d2b389b 100644 --- a/cmd/environment/environment.go +++ b/cmd/environment/environment.go @@ -14,11 +14,12 @@ var environmentHelp string func NewEnvironmentCommand() *cobra.Command { help := util.ParseHelp(environmentHelp) cmd := &cobra.Command{ - Use: "environment", - Short: "Manage Environments for the CLI", - Long: help.Long, - Example: help.Example, - Aliases: []string{"env"}, + Use: "environment", + Short: "Manage environments for the CLI", + Long: help.Long, + Example: help.Example, + Deprecated: "use 'sail env' instead", + // "env" alias removed -- use 'sail env' instead (new command) Run: func(cmd *cobra.Command, args []string) { cmd.Help() }, diff --git a/cmd/environment/environment.md b/cmd/environment/environment.md index d23d95f1..5012628b 100644 --- a/cmd/environment/environment.md +++ b/cmd/environment/environment.md @@ -1,7 +1,20 @@ ==Long== -# Environment +# Environment (Deprecated) -Configure SailPoint Identity Security Cloud environments for the CLI +> **Deprecated:** Use `sail env` instead. + +Configure SailPoint Identity Security Cloud environments for the CLI. + +**Migration guide:** + +| Old command | New command | +|---|---| +| `sail environment create` | `sail env create` | +| `sail environment list` | `sail env list` | +| `sail environment show` | `sail env show` | +| `sail environment update` | `sail env update` | +| `sail environment delete` | `sail env delete` | +| `sail environment use` | `sail env use` | ==== ==Example== @@ -49,7 +62,7 @@ You can update an environment by calling `update` and supplying the name of an e sail environment update {environment-name} ``` -If no environment is provided this command will delete the active environment. +If no environment is provided this command will update the active environment. ```bash sail environment update diff --git a/cmd/environment/list.go b/cmd/environment/list.go index 060a5326..7b596af8 100644 --- a/cmd/environment/list.go +++ b/cmd/environment/list.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/log" "github.com/sailpoint-oss/sailpoint-cli/internal/config" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/spf13/cobra" ) @@ -24,9 +24,11 @@ func newListCommand() *cobra.Command { if len(environments) != 0 { log.Warn("You are about to Print out the list of Environments") - res := terminal.InputPrompt("Press Enter to continue") - log.Info("Response", "res", res) - if res == "" { + confirmed, err := tui.Confirm("List all configured environments?") + if err != nil { + return err + } + if confirmed { fmt.Println(util.PrettyPrint(environments)) } } else { diff --git a/cmd/environment/show.go b/cmd/environment/show.go index 2f700256..ae7546ec 100644 --- a/cmd/environment/show.go +++ b/cmd/environment/show.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/log" "github.com/sailpoint-oss/sailpoint-cli/internal/config" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/spf13/cobra" ) @@ -24,8 +24,11 @@ func newShowCommand() *cobra.Command { for _, environmentName := range args { if environments[environmentName] != nil { log.Warn("You are about to Print out the Environment", "env", environmentName) - res := terminal.InputPrompt("Press Enter to continue") - if res == "" { + confirmed, err := tui.Confirm("Show environment " + environmentName + "?") + if err != nil { + return err + } + if confirmed { fmt.Println(util.PrettyPrint(environments[environmentName])) } } else { @@ -40,8 +43,11 @@ func newShowCommand() *cobra.Command { if env != "" && env != " " { log.Warn("You are about to Print out the Environment", "env", env) - res := terminal.InputPrompt("Press Enter to continue") - if res == "" { + confirmed, err := tui.Confirm("Show environment " + env + "?") + if err != nil { + return err + } + if confirmed { fmt.Println(util.PrettyPrint(environments[env])) } } else { diff --git a/cmd/environment/update.go b/cmd/environment/update.go index 212514f1..47e61c66 100644 --- a/cmd/environment/update.go +++ b/cmd/environment/update.go @@ -4,7 +4,7 @@ package environment import ( "github.com/charmbracelet/log" "github.com/sailpoint-oss/sailpoint-cli/internal/config" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/spf13/cobra" ) @@ -37,9 +37,11 @@ func newUpdateCommand() *cobra.Command { if env != "" && env != " " { log.Warn("You are about to Update the active Environment", "env", env) - res := terminal.InputPrompt("Press Enter to continue") - if res == "" { - + confirmed, err := tui.Confirm("Update active environment " + env + "?") + if err != nil { + return err + } + if confirmed { err := util.CreateOrUpdateEnvironment(env, true) if err != nil { return err diff --git a/cmd/identity/identity.go b/cmd/identity/identity.go new file mode 100644 index 00000000..55512f72 --- /dev/null +++ b/cmd/identity/identity.go @@ -0,0 +1,192 @@ +package identity + +import ( + "context" + "fmt" + + v2024 "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/sdkcmd" + "github.com/spf13/cobra" +) + +func NewIdentityCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "identity", + Short: "Inspect and operate on identities", + Long: "\nInspect Identity Security Cloud identities and related access data.\n\n", + Example: " sail identity list\n sail identity get \n sail identity entitlements ", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + + cmd.AddCommand( + newListCommand(), + newGetCommand(), + newEntitlementsCommand(), + newSyncCommand(), + newResetCommand(), + newProcessCommand(), + ) + return cmd +} + +func newListCommand() *cobra.Command { + var opts sdkcmd.ListOptions + cmd := &cobra.Command{ + Use: "list", + Short: "List identities", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + + req := apiClient.V2024.IdentitiesAPI.ListIdentities(context.TODO()). + Limit(opts.Limit). + Offset(opts.Offset). + Count(opts.Count) + if opts.Filters != "" { + req = req.Filters(opts.Filters) + } + if opts.Sorters != "" { + req = req.Sorters(opts.Sorters) + } + + identities, resp, err := req.Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + + rows := make([][]string, 0, len(identities)) + for _, item := range identities { + rows = append(rows, []string{item.GetName(), item.GetId(), item.GetIdentityStatus()}) + } + return sdkcmd.WriteTable(cmd, []string{"Name", "ID", "Status"}, rows, "Name", identities) + }, + } + sdkcmd.AddListFlags(cmd, &opts) + return cmd +} + +func newGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get an identity", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + identity, resp, err := apiClient.V2024.IdentitiesAPI.GetIdentity(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, identity) + }, + } +} + +func newEntitlementsCommand() *cobra.Command { + var opts sdkcmd.ListOptions + cmd := &cobra.Command{ + Use: "entitlements ", + Short: "List entitlements for an identity", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + req := apiClient.V2024.IdentitiesAPI.ListEntitlementsByIdentity(context.TODO(), args[0]). + Limit(opts.Limit). + Offset(opts.Offset). + Count(opts.Count) + entitlements, resp, err := req.Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, entitlements) + }, + } + sdkcmd.AddListFlags(cmd, &opts) + cmd.Flags().Lookup("filter").Hidden = true + cmd.Flags().Lookup("sort").Hidden = true + return cmd +} + +func newSyncCommand() *cobra.Command { + return &cobra.Command{ + Use: "sync ", + Short: "Synchronize attributes for an identity", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + job, resp, err := apiClient.V2024.IdentitiesAPI.SynchronizeAttributesForIdentity(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, job) + }, + } +} + +func newResetCommand() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "reset ", + Short: "Reset an identity", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !force { + return fmt.Errorf("reset requires --force") + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + resp, err := apiClient.V2024.IdentitiesAPI.ResetIdentity(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, map[string]string{"status": "reset started", "identityId": args[0]}) + }, + } + cmd.Flags().BoolVarP(&force, "force", "f", false, "Confirm identity reset") + return cmd +} + +func newProcessCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "process --file payload.json", + Short: "Start identity processing from a JSON payload", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[v2024.ProcessIdentitiesRequest](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + task, resp, err := apiClient.V2024.IdentitiesAPI.StartIdentityProcessing(context.TODO()). + ProcessIdentitiesRequest(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, task) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON payload file") + cmd.MarkFlagRequired("file") + return cmd +} diff --git a/cmd/identity/identity_test.go b/cmd/identity/identity_test.go new file mode 100644 index 00000000..64beeee9 --- /dev/null +++ b/cmd/identity/identity_test.go @@ -0,0 +1,29 @@ +package identity + +import ( + "strings" + "testing" +) + +func TestNewIdentityCommandRegistersSubcommands(t *testing.T) { + cmd := NewIdentityCommand() + for _, name := range []string{"list", "get", "entitlements", "sync", "reset", "process"} { + found, _, err := cmd.Find([]string{name}) + if err != nil || found == nil || found.Name() != name { + t.Fatalf("expected identity subcommand %q to exist", name) + } + } +} + +func TestIdentityResetRequiresForce(t *testing.T) { + cmd := newResetCommand() + cmd.SetArgs([]string{"identity-id"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected reset without --force to fail") + } + if !strings.Contains(err.Error(), "reset requires --force") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/jsonpath/eval.go b/cmd/jsonpath/eval.go index 74c2169c..43d464c3 100644 --- a/cmd/jsonpath/eval.go +++ b/cmd/jsonpath/eval.go @@ -17,7 +17,7 @@ func newEvalCommand() *cobra.Command { cmd := &cobra.Command{ Use: "eval", - Short: "Evaluate a jsonpath against a json file", + Short: "Evaluate a JSONPath expression against a JSON file", Long: "\nEvaluate a jsonpath against a json file\n\n", Example: "sail jsonpath eval | sail jsonpath e", Aliases: []string{"e"}, diff --git a/cmd/jsonpath/jsonpath.md b/cmd/jsonpath/jsonpath.md index 3d3b0f98..927c86d8 100644 --- a/cmd/jsonpath/jsonpath.md +++ b/cmd/jsonpath/jsonpath.md @@ -2,7 +2,7 @@ # JSONPath Validator JSONPath validation for workflows and event triggers. -References: +## Reference - https://developer.sailpoint.com/docs/extensibility/event-triggers/filtering-events - https://documentation.sailpoint.com/saas/help/workflows/workflow-basics.html#trigger_filter ==== @@ -10,7 +10,7 @@ References: ==Example== ```bash -sail spconfig jsonpath +sail jsonpath ``` ==== \ No newline at end of file diff --git a/cmd/reassign/reassign.go b/cmd/reassign/reassign.go index 7a5645c3..bc10aed3 100644 --- a/cmd/reassign/reassign.go +++ b/cmd/reassign/reassign.go @@ -19,6 +19,7 @@ import ( sailpoint "github.com/sailpoint-oss/golang-sdk/v2" "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/spf13/cobra" ) @@ -84,24 +85,23 @@ func NewReassignCommand() *cobra.Command { help := util.ParseHelp(reassignHelp) cmd := &cobra.Command{ Use: "reassign", - Short: "Reassign object ownership in Identity Security Cloud", + Short: "Reassign object ownership", Long: help.Long, Example: help.Example, Args: cobra.OnlyValidArgs, RunE: func(cmd *cobra.Command, args []string) error { + w := cmd.OutOrStdout() if from == "" || to == "" { return errors.New("both --from and --to flags are required when using --object-id") } if dryRun && force { - log.Error("cannot use --dry-run and --force together") - os.Exit(1) + return errors.New("cannot use --dry-run and --force together") } if from == to { - log.Error("from and to Identities cannot be the same") - os.Exit(1) + return errors.New("--from and --to identities cannot be the same") } if objectId != "" { @@ -112,27 +112,21 @@ func NewReassignCommand() *cobra.Command { summary, err := determineObjectTypeAndCreateReassignment(objectId, from, to, dryRun) if err != nil { - log.Error("error determining object type:", "error", err) - os.Exit(1) + return fmt.Errorf("error determining object type: %w", err) } if !force { - printSummary(summary) + printSummary(w, summary) if !summary.DryRun { promptSaveReport(&summary) - fmt.Printf("Would you like to proceed with reassigning this object from '%s' to '%s': ", summary.From.Name, summary.To.Name) - var reassignResponse string - _, err = fmt.Scanln(&reassignResponse) + confirmed, err := tui.Confirm(fmt.Sprintf("Proceed with reassigning this object from '%s' to '%s'?", summary.From.Name, summary.To.Name)) if err != nil { - fmt.Println("Failed to read input:", err) return err } - response := strings.ToLower(strings.TrimSpace(reassignResponse)) - - if response == "y" { + if confirmed { m := initialModel(from, to, objectTypes, dryRun, force) m.reassignResult = &summary m.reassigning = true @@ -143,7 +137,7 @@ func NewReassignCommand() *cobra.Command { } } else { - fmt.Println("Cancelled reassignment.") + fmt.Fprintln(w, "Cancelled reassignment.") } } else { promptSaveReport(&summary) @@ -166,42 +160,35 @@ func NewReassignCommand() *cobra.Command { p := tea.NewProgram(initialModel(from, to, objectTypes, dryRun, force)) finalModel, err := p.Run() if err != nil { - fmt.Println("Error:", err) - os.Exit(1) + return fmt.Errorf("error running reassignment: %w", err) } if m, ok := finalModel.(model); ok && m.err != nil { - log.Error("An error occurred when gathering objects to reassign:", "error", m.err) - os.Exit(1) + return fmt.Errorf("error gathering objects to reassign: %w", m.err) } if m, ok := finalModel.(model); ok && m.reassignResult != nil { p.Quit() if m.reassignResult.IsEmpty() { - fmt.Println("No objects to reassign.") + fmt.Fprintln(w, "No objects to reassign.") return nil } if !m.force { - printSummary(*m.reassignResult) + printSummary(w, *m.reassignResult) // If this was not a dry run proceed with the reassignment flow if !m.reassignResult.DryRun { promptSaveReport(m.reassignResult) - fmt.Printf("Would you like to proceed with reassigning these objects from '%s' to '%s': ", m.reassignResult.From.Name, m.reassignResult.To.Name) - var reassignResponse string - _, err = fmt.Scanln(&reassignResponse) + confirmed, err := tui.Confirm(fmt.Sprintf("Proceed with reassigning these objects from '%s' to '%s'?", m.reassignResult.From.Name, m.reassignResult.To.Name)) if err != nil { - fmt.Println("Failed to read input:", err) return err } - response := strings.ToLower(strings.TrimSpace(reassignResponse)) - - if response == "y" { + if confirmed { m := initialModel(from, to, objectTypes, dryRun, force) m.reassignResult = finalModel.(model).reassignResult m.reassigning = true @@ -219,7 +206,7 @@ func NewReassignCommand() *cobra.Command { } } else { - fmt.Println("Cancelled reassignment.") + fmt.Fprintln(w, "Cancelled reassignment.") } } else { @@ -424,25 +411,17 @@ func determineObjectTypeAndCreateReassignment(objectId string, from string, to s } func promptSaveReport(summary *ReassignSummary) error { - fmt.Print("Would you like to save the full report to a file (y/n): ") - var response string - _, err := fmt.Scanln(&response) + save, err := tui.Confirm("Save the full report to a file?") if err != nil { - return fmt.Errorf("failed to read input: %w", err) + return err } - response = strings.ToLower(strings.TrimSpace(response)) - if response != "y" { + if !save { return nil } - fmt.Print("Enter the file name (without extension)(default: reassign_report): ") - var fileName string - _, err = fmt.Scanln(&fileName) - if err != nil && err.Error() != "unexpected newline" { - return fmt.Errorf("failed to read input: %w", err) - } - if strings.TrimSpace(fileName) == "" { - fileName = "reassign_report" + fileName, err := tui.Input("File name (without extension)", "reassign_report") + if err != nil { + return err } return writeReport(*summary, fmt.Sprintf("%s.json", fileName)) } @@ -524,29 +503,29 @@ func contains(slice []string, value string) bool { return false } -func printSummary(summary ReassignSummary) { - fmt.Println("Reassignment Preview") - fmt.Println("====================") - fmt.Printf("From Owner: %s (%s)\n", summary.From.ID, summary.From.Name) - fmt.Printf("To Owner: %s (%s)\n", summary.To.ID, summary.To.Name) +func printSummary(w io.Writer, summary ReassignSummary) { + fmt.Fprintln(w, "Reassignment Preview") + fmt.Fprintln(w, "====================") + fmt.Fprintf(w, "From Owner: %s (%s)\n", summary.From.ID, summary.From.Name) + fmt.Fprintf(w, "To Owner: %s (%s)\n", summary.To.ID, summary.To.Name) - fmt.Printf("Object Types: %s\n", strings.Join(summary.ObjectTypes, ", ")) - fmt.Printf("Dry Run: %t\n\n", summary.DryRun) + fmt.Fprintf(w, "Object Types: %s\n", strings.Join(summary.ObjectTypes, ", ")) + fmt.Fprintf(w, "Dry Run: %t\n\n", summary.DryRun) - fmt.Println("Objects to Reassign:") - fmt.Println("---------------------") - fmt.Printf("%-20s %s\n", "Object Type", "Count") - fmt.Printf("%-20s %s\n", "-----------", "-----") + fmt.Fprintln(w, "Objects to Reassign:") + fmt.Fprintln(w, "---------------------") + fmt.Fprintf(w, "%-20s %s\n", "Object Type", "Count") + fmt.Fprintf(w, "%-20s %s\n", "-----------", "-----") total := 0 for objectType, count := range summary.ObjectCounts { - fmt.Printf("%-20s %d\n", objectType, count) + fmt.Fprintf(w, "%-20s %d\n", objectType, count) total += count } - fmt.Printf("\nTotal: %d objects\n\n", total) + fmt.Fprintf(w, "\nTotal: %d objects\n\n", total) if summary.DryRun { - fmt.Println("No changes have been made. Run the command without --dry-run to proceed.") + fmt.Fprintln(w, "No changes have been made. Run the command without --dry-run to proceed.") } } diff --git a/cmd/report/report.go b/cmd/report/report.go index 76765153..71666fe9 100644 --- a/cmd/report/report.go +++ b/cmd/report/report.go @@ -6,7 +6,6 @@ import ( _ "embed" "encoding/json" "fmt" - "os" "path" "strings" @@ -15,7 +14,7 @@ import ( "github.com/sailpoint-oss/sailpoint-cli/internal/config" "github.com/sailpoint-oss/sailpoint-cli/internal/output" "github.com/sailpoint-oss/sailpoint-cli/internal/templates" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/types" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/spf13/cobra" @@ -31,18 +30,12 @@ func NewReportCommand() *cobra.Command { var template string cmd := &cobra.Command{ Use: "report", - Short: "Generate a report from a template using Identity Security Cloud search queries", + Short: "Generate a report from a template", Long: help.Long, Example: help.Example, Aliases: []string{"rep"}, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - - err := config.InitConfig() - if err != nil { - return err - } - apiClient, err := config.InitAPIClient(false) if err != nil { return err @@ -79,7 +72,10 @@ func NewReportCommand() *cobra.Command { if len(selectedTemplate.Variables) > 0 { for _, varEntry := range selectedTemplate.Variables { - resp := terminal.InputPrompt("Input " + varEntry.Prompt + ":") + resp, err := tui.Input(varEntry.Prompt, "") + if err != nil { + return err + } selectedTemplate.Raw = []byte(strings.ReplaceAll(string(selectedTemplate.Raw), "{{"+varEntry.Name+"}}", resp)) } err := json.Unmarshal(selectedTemplate.Raw, &selectedTemplate.Queries) @@ -97,8 +93,10 @@ func NewReportCommand() *cobra.Command { resp, err := apiClient.V3.SearchAPI.SearchCount(context.TODO()).Search(*searchQuery).Execute() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", resp) + return fmt.Errorf("failed to count results for report query %d: %w", i+1, err) + } + if resp == nil || len(resp.Header["X-Total-Count"]) == 0 { + return fmt.Errorf("missing X-Total-Count header for report query %d", i+1) } selectedTemplate.Queries[i].ResultCount = resp.Header["X-Total-Count"][0] } @@ -121,7 +119,10 @@ func NewReportCommand() *cobra.Command { } cmd.Flags().BoolVarP(&save, "save", "s", false, "save the report to a file") - cmd.Flags().StringVarP(&folderPath, "folderPath", "f", "reports", "folder path to save the reports in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)") + cmd.Flags().StringVarP(&folderPath, "folder-path", "f", "reports", "Folder path to save reports in") + cmd.Flags().StringVar(&folderPath, "folderPath", "reports", "Deprecated: use --folder-path") + cmd.Flags().MarkDeprecated("folderPath", "use --folder-path") + cmd.Flags().MarkHidden("folderPath") return cmd diff --git a/cmd/report/report.md b/cmd/report/report.md index 2bf006e6..3f552756 100644 --- a/cmd/report/report.md +++ b/cmd/report/report.md @@ -3,9 +3,9 @@ Generate a report from Identity Security Cloud. -## API References: +## API Reference - https://developer.sailpoint.com/docs/api/v3/search - + ==== ==Example== diff --git a/cmd/role/crud_live_test.go b/cmd/role/crud_live_test.go new file mode 100644 index 00000000..ece13fdc --- /dev/null +++ b/cmd/role/crud_live_test.go @@ -0,0 +1,114 @@ +package role + +import ( + "bytes" + "testing" + + v2024 "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" + "github.com/sailpoint-oss/sailpoint-cli/internal/testutil" +) + +func TestRoleCRUD(t *testing.T) { + testutil.RequireLiveCredentials(t) + testutil.SetJSONOutput(t) + + owner := testutil.FirstIdentity(t) + name := testutil.UniqueName("role") + updatedDescription := "updated by sail CLI live CRUD test" + dir := t.TempDir() + + createPath := testutil.WriteJSON(t, dir, "role-create.json", map[string]any{ + "name": name, + "description": "created by sail CLI live CRUD test", + "enabled": false, + "requestable": false, + "owner": map[string]any{ + "type": "IDENTITY", + "id": owner.ID, + }, + }) + + createCmd := newCreateCommand() + createOut := new(bytes.Buffer) + createCmd.SetOut(createOut) + createCmd.Flags().Set("file", createPath) + + if err := createCmd.Execute(); err != nil { + testutil.SkipIfFeatureUnavailable(t, err) + t.Fatalf("role create failed: %v", err) + } + + created := testutil.DecodeJSON[v2024.Role](t, createOut.String()) + if created.GetId() == "" { + t.Fatalf("expected created role ID, got %#v", created) + } + roleID := created.GetId() + defer deleteRole(t, roleID) + + getRoleAndAssert(t, roleID, name) + listRoleAndAssert(t, roleID, name) + + patchPath := testutil.WriteJSON(t, dir, "role-patch.json", testutil.StringPatch("/description", updatedDescription)) + patchCmd := newPatchCommand() + patchOut := new(bytes.Buffer) + patchCmd.SetOut(patchOut) + patchCmd.SetArgs([]string{roleID}) + patchCmd.Flags().Set("file", patchPath) + + if err := patchCmd.Execute(); err != nil { + t.Fatalf("role patch failed: %v", err) + } + updated := testutil.DecodeJSON[v2024.Role](t, patchOut.String()) + if updated.GetDescription() != updatedDescription { + t.Fatalf("expected role description %q, got %q", updatedDescription, updated.GetDescription()) + } +} + +func getRoleAndAssert(t *testing.T, roleID string, expectedName string) { + t.Helper() + + getCmd := newGetCommand() + getOut := new(bytes.Buffer) + getCmd.SetOut(getOut) + getCmd.SetArgs([]string{roleID}) + if err := getCmd.Execute(); err != nil { + t.Fatalf("role get failed: %v", err) + } + role := testutil.DecodeJSON[v2024.Role](t, getOut.String()) + if role.GetId() != roleID { + t.Fatalf("expected role ID %q, got %q", roleID, role.GetId()) + } + if role.GetName() != expectedName { + t.Fatalf("expected role name %q, got %q", expectedName, role.GetName()) + } +} + +func listRoleAndAssert(t *testing.T, roleID string, expectedName string) { + t.Helper() + + listCmd := newListCommand() + listOut := new(bytes.Buffer) + listCmd.SetOut(listOut) + listCmd.Flags().Set("filter", `id eq "`+roleID+`"`) + if err := listCmd.Execute(); err != nil { + t.Fatalf("role list failed: %v", err) + } + roles := testutil.DecodeJSON[[]v2024.Role](t, listOut.String()) + if len(roles) != 1 { + t.Fatalf("expected one role from filtered list, got %d", len(roles)) + } + if roles[0].GetName() != expectedName { + t.Fatalf("expected listed role name %q, got %q", expectedName, roles[0].GetName()) + } +} + +func deleteRole(t *testing.T, roleID string) { + t.Helper() + + deleteCmd := newDeleteCommand() + deleteCmd.SetArgs([]string{roleID}) + deleteCmd.Flags().Set("force", "true") + if err := deleteCmd.Execute(); err != nil { + t.Logf("failed to clean up role %s: %v", roleID, err) + } +} diff --git a/cmd/role/role.go b/cmd/role/role.go new file mode 100644 index 00000000..cbcae6d5 --- /dev/null +++ b/cmd/role/role.go @@ -0,0 +1,203 @@ +package role + +import ( + "context" + + v2024 "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/sdkcmd" + "github.com/spf13/cobra" +) + +func NewRoleCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "role", + Short: "Inspect roles", + Long: "\nInspect Identity Security Cloud roles, role entitlements, and role members.\n\n", + Example: " sail role list\n sail role get \n sail role entitlements ", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + cmd.AddCommand(newListCommand(), newGetCommand(), newCreateCommand(), newPatchCommand(), newDeleteCommand(), newEntitlementsCommand(), newMembersCommand()) + return cmd +} + +func newListCommand() *cobra.Command { + var opts sdkcmd.ListOptions + cmd := &cobra.Command{ + Use: "list", + Short: "List roles", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + req := apiClient.V2024.RolesAPI.ListRoles(context.TODO()). + Limit(opts.Limit). + Offset(opts.Offset). + Count(opts.Count) + if opts.Filters != "" { + req = req.Filters(opts.Filters) + } + if opts.Sorters != "" { + req = req.Sorters(opts.Sorters) + } + roles, resp, err := req.Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + rows := make([][]string, 0, len(roles)) + for _, item := range roles { + rows = append(rows, []string{item.GetName(), item.GetId(), item.GetDescription()}) + } + return sdkcmd.WriteTable(cmd, []string{"Name", "ID", "Description"}, rows, "Name", roles) + }, + } + sdkcmd.AddListFlags(cmd, &opts) + return cmd +} + +func newGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get a role", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + role, resp, err := apiClient.V2024.RolesAPI.GetRole(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, role) + }, + } +} + +func newCreateCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "create --file role.json", + Short: "Create a role from a JSON payload", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[v2024.Role](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + role, resp, err := apiClient.V2024.RolesAPI.CreateRole(context.TODO()). + Role(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, role) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON payload file") + cmd.MarkFlagRequired("file") + return cmd +} + +func newPatchCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "patch --file patch.json", + Aliases: []string{"update"}, + Short: "Patch a role from a JSON Patch payload", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[[]v2024.JsonPatchOperation](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + role, resp, err := apiClient.V2024.RolesAPI.PatchRole(context.TODO(), args[0]). + JsonPatchOperation(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, role) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON Patch payload file") + cmd.MarkFlagRequired("file") + return cmd +} + +func newDeleteCommand() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a role", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !force { + return clierror.Usage("role delete requires --force") + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + resp, err := apiClient.V2024.RolesAPI.DeleteRole(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, map[string]string{"status": "deleted", "roleId": args[0]}) + }, + } + cmd.Flags().BoolVarP(&force, "force", "f", false, "Confirm role deletion") + return cmd +} + +func newEntitlementsCommand() *cobra.Command { + return &cobra.Command{ + Use: "entitlements ", + Short: "List entitlements for a role", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + entitlements, resp, err := apiClient.V2024.RolesAPI.GetRoleEntitlements(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, entitlements) + }, + } +} + +func newMembersCommand() *cobra.Command { + return &cobra.Command{ + Use: "members ", + Aliases: []string{"identities"}, + Short: "List identities assigned to a role", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + members, resp, err := apiClient.V2024.RolesAPI.GetRoleAssignedIdentities(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, members) + }, + } +} diff --git a/cmd/role/role_test.go b/cmd/role/role_test.go new file mode 100644 index 00000000..be1dd295 --- /dev/null +++ b/cmd/role/role_test.go @@ -0,0 +1,19 @@ +package role + +import ( + "strings" + "testing" +) + +func TestRoleDeleteRequiresForce(t *testing.T) { + cmd := newDeleteCommand() + cmd.SetArgs([]string{"role-id"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected role delete without --force to fail") + } + if !strings.Contains(err.Error(), "role delete requires --force") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/root/root.go b/cmd/root/root.go index e6a6b3de..de41aa04 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -2,23 +2,38 @@ package root import ( _ "embed" + "fmt" + "sort" + "strings" + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/cmd/accessprofile" + "github.com/sailpoint-oss/sailpoint-cli/cmd/accessrequest" + "github.com/sailpoint-oss/sailpoint-cli/cmd/account" "github.com/sailpoint-oss/sailpoint-cli/cmd/api" + cmdauth "github.com/sailpoint-oss/sailpoint-cli/cmd/auth" "github.com/sailpoint-oss/sailpoint-cli/cmd/cluster" + "github.com/sailpoint-oss/sailpoint-cli/cmd/configure" "github.com/sailpoint-oss/sailpoint-cli/cmd/connector" + "github.com/sailpoint-oss/sailpoint-cli/cmd/entitlement" + "github.com/sailpoint-oss/sailpoint-cli/cmd/env" "github.com/sailpoint-oss/sailpoint-cli/cmd/environment" + "github.com/sailpoint-oss/sailpoint-cli/cmd/identity" "github.com/sailpoint-oss/sailpoint-cli/cmd/jsonpath" "github.com/sailpoint-oss/sailpoint-cli/cmd/reassign" "github.com/sailpoint-oss/sailpoint-cli/cmd/report" + "github.com/sailpoint-oss/sailpoint-cli/cmd/role" "github.com/sailpoint-oss/sailpoint-cli/cmd/rule" "github.com/sailpoint-oss/sailpoint-cli/cmd/sanitize" "github.com/sailpoint-oss/sailpoint-cli/cmd/sdk" "github.com/sailpoint-oss/sailpoint-cli/cmd/search" "github.com/sailpoint-oss/sailpoint-cli/cmd/set" + "github.com/sailpoint-oss/sailpoint-cli/cmd/source" "github.com/sailpoint-oss/sailpoint-cli/cmd/spconfig" "github.com/sailpoint-oss/sailpoint-cli/cmd/transform" "github.com/sailpoint-oss/sailpoint-cli/cmd/va" "github.com/sailpoint-oss/sailpoint-cli/cmd/workflow" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/spf13/cobra" @@ -32,8 +47,12 @@ var rootHelp string func NewRootCommand() *cobra.Command { help := util.ParseHelp(rootHelp) - var env string + var envFlag string var debug bool + var verbose bool + var jsonOutput bool + var outputFormat string + var quiet bool root := &cobra.Command{ Use: "sail", Long: help.Long, @@ -41,9 +60,41 @@ func NewRootCommand() *cobra.Command { Version: version, SilenceUsage: true, CompletionOptions: cobra.CompletionOptions{ - DisableDefaultCmd: true, - DisableNoDescFlag: true, - DisableDescriptions: true, + DisableNoDescFlag: false, + }, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + log.SetOutput(cmd.ErrOrStderr()) + log.SetReportTimestamp(false) + + if cmd.Flags().Changed("env") { + config.SetActiveEnvironmentOverride(envFlag) + } + if cmd.Flags().Changed("debug") || cmd.Flags().Changed("verbose") { + viper.Set("debug", debug || verbose) + } + if cmd.Flags().Changed("json") { + viper.Set("json", jsonOutput) + } + if cmd.Flags().Changed("output") { + format := strings.ToLower(strings.TrimSpace(outputFormat)) + switch format { + case "table", "json", "yaml": + viper.Set("output", format) + default: + return fmt.Errorf("invalid output format %q (use table, json, or yaml)", outputFormat) + } + } + if jsonOutput { + viper.Set("output", "json") + } + if quiet { + log.SetLevel(log.ErrorLevel) + } else if debug || verbose || viper.GetBool("debug") { + log.SetLevel(log.DebugLevel) + } else { + log.SetLevel(log.InfoLevel) + } + return nil }, Run: func(cmd *cobra.Command, args []string) { cmd.Help() @@ -52,29 +103,64 @@ func NewRootCommand() *cobra.Command { t := &terminal.Term{} + // New command structure + root.AddCommand( + env.NewEnvCommand(), + cmdauth.NewAuthCommand(), + configure.NewConfigureCommand(), + identity.NewIdentityCommand(), + source.NewSourceCommand(), + account.NewAccountCommand(), + role.NewRoleCommand(), + accessprofile.NewAccessProfileCommand(), + entitlement.NewEntitlementCommand(), + accessrequest.NewAccessRequestCommand(), + ) + + // Existing commands (unchanged) root.AddCommand( api.NewAPICommand(), cluster.NewClusterCommand(), connector.NewConnCmd(t), - environment.NewEnvironmentCommand(), jsonpath.NewJSONPathCmd(), report.NewReportCommand(), sdk.NewSDKCommand(), search.NewSearchCommand(), - set.NewSetCmd(t), spconfig.NewSPConfigCommand(), transform.NewTransformCommand(), rule.NewRuleCommand(), - va.NewVACommand(t), + va.NewVACommand(), workflow.NewWorkflowCommand(), sanitize.NewSanitizeCommand(), reassign.NewReassignCommand(), ) - root.PersistentFlags().StringVarP(&env, "env", "", "", "Environment to use for SailPoint CLI commands") - root.PersistentFlags().BoolVarP(&debug, "debug", "", false, "Enable debug logging") - viper.BindPFlag("activeenvironment", root.PersistentFlags().Lookup("env")) - viper.BindPFlag("debug", root.PersistentFlags().Lookup("debug")) + // Deprecated commands (kept for backward compatibility) + deprecatedSet := set.NewSetCmd(t) + deprecatedSet.Deprecated = "use 'sail config ' for settings, 'sail env create/update' for auth configuration" + deprecatedSet.Hidden = true + root.AddCommand(deprecatedSet) + + deprecatedEnv := environment.NewEnvironmentCommand() + deprecatedEnv.Deprecated = "use 'sail env' instead" + deprecatedEnv.Hidden = true + root.AddCommand(deprecatedEnv) + + root.PersistentFlags().StringVar(&envFlag, "env", "", "Environment to use for SailPoint CLI commands") + root.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging") + root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output (same as --debug)") + root.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output results in JSON format (equivalent to --output json)") + root.PersistentFlags().StringVar(&outputFormat, "output", "table", "Output format: table, json, or yaml") + root.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Suppress informational logs") + root.RegisterFlagCompletionFunc("env", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + environments := config.GetEnvironments() + names := make([]string, 0, len(environments)) + for name := range environments { + names = append(names, name) + } + sort.Strings(names) + return names, cobra.ShellCompDirectiveNoFileComp + }) return root } diff --git a/cmd/root/root.md b/cmd/root/root.md index 7acd13c4..749ed990 100644 --- a/cmd/root/root.md +++ b/cmd/root/root.md @@ -2,12 +2,43 @@ # SailPoint CLI The SailPoint CLI allows you to administer your Identity Security Cloud tenant from the command line. -Navigate to the [CLI Documentation](https://developer.sailpoint.com/docs/tools/cli) for more information. +## Common workflows + +- `sail auth login` - sign in to the active environment +- `sail env list` - show configured tenants and authentication types +- `sail env create ` - add a tenant environment +- `sail identity list` - inspect identities +- `sail source list` - inspect sources +- `sail account list` - inspect accounts +- `sail role list` - inspect roles +- `sail access-profile list` - inspect access profiles +- `sail entitlement list` - inspect entitlements +- `sail access-request list` - review access request status +- `sail api get /v2024/...` - call an Identity Security Cloud API endpoint +- `sail workflow list` - inspect workflows +- `sail transform list` - inspect transforms +- `sail search query` - run saved or ad hoc searches +- `sail spconfig` - export, import, and monitor tenant configuration +- `sail connector` - build and operate connector projects + +## Output and automation + +Use `--output json`, `--output yaml`, or `--json` for machine-readable output where commands support structured results. User-facing logs and warnings are written to stderr so stdout can be piped to tools such as `jq`. + +For CI, configure PAT credentials with environment variables instead of the local keyring: `SAIL_BASE_URL`, `SAIL_AUTHTYPE=pat`, `SAIL_CLIENT_ID`, and `SAIL_CLIENT_SECRET`. + +Navigate to the [CLI Documentation](https://developer.sailpoint.com/docs/tools/cli) for full command documentation. ==== ==Example== ```bash sail +sail --output json env list +sail env use production +printf '%s' "$SAIL_CLIENT_SECRET" | sail auth pat set --client-id "$SAIL_CLIENT_ID" --client-secret-stdin +sail identity list --filter 'name sw "a"' +sail role entitlements +sail api get /v2024/identities --query limit=10 --pretty ``` ==== \ No newline at end of file diff --git a/cmd/root/root_test.go b/cmd/root/root_test.go index a1f34e86..50b1495c 100644 --- a/cmd/root/root_test.go +++ b/cmd/root/root_test.go @@ -9,11 +9,9 @@ import ( "testing" "github.com/golang/mock/gomock" -) - -// Expected number of subcommands to `sail` root command -const ( - numRootSubcommands = 16 + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/cobra" + "github.com/spf13/viper" ) func TestNewRootCmd_noArgs(t *testing.T) { @@ -21,9 +19,29 @@ func TestNewRootCmd_noArgs(t *testing.T) { defer ctrl.Finish() cmd := NewRootCommand() - if len(cmd.Commands()) != numRootSubcommands { - t.Fatalf("expected: %d, actual: %d", numRootSubcommands, len(cmd.Commands())) - } + assertRootCommands(t, cmd, []string{ + "access-profile", + "access-request", + "account", + "api", + "auth", + "cluster", + "config", + "connectors", + "entitlement", + "env", + "identity", + "role", + "source", + }) + assertNoRootCommands(t, cmd, []string{ + "access", + "admin", + "apps", + "audit", + "lifecycle", + "users", + }) b := new(bytes.Buffer) cmd.SetOut(b) @@ -44,7 +62,39 @@ func TestNewRootCmd_noArgs(t *testing.T) { } } -func TestNewRootCmd_completionDisabled(t *testing.T) { +func assertRootCommands(t *testing.T, cmd interface{ CommandPath() string }, expected []string) { + t.Helper() + rootCmd, ok := cmd.(interface { + Find([]string) (*cobra.Command, []string, error) + }) + if !ok { + t.Fatalf("unexpected command type") + } + for _, name := range expected { + found, _, err := rootCmd.Find([]string{name}) + if err != nil || found == nil || found.Name() != name { + t.Fatalf("expected root command %q to exist", name) + } + } +} + +func assertNoRootCommands(t *testing.T, cmd interface{ CommandPath() string }, names []string) { + t.Helper() + rootCmd, ok := cmd.(interface { + Find([]string) (*cobra.Command, []string, error) + }) + if !ok { + t.Fatalf("unexpected command type") + } + for _, name := range names { + found, _, err := rootCmd.Find([]string{name}) + if err == nil && found != nil && found.Name() == name { + t.Fatalf("did not expect root command %q to exist", name) + } + } +} + +func TestNewRootCmd_completionEnabled(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -52,9 +102,36 @@ func TestNewRootCmd_completionDisabled(t *testing.T) { b := new(bytes.Buffer) cmd.SetOut(b) - cmd.SetArgs([]string{"completion"}) + cmd.SetArgs([]string{"completion", "bash"}) - if err := cmd.Execute(); err == nil { - t.Error("expected command to fail") + if err := cmd.Execute(); err != nil { + t.Fatalf("expected completion command to succeed: %v", err) + } +} + +func TestRootEnvFlagDoesNotPersistActiveEnvironment(t *testing.T) { + viper.Reset() + config.ClearActiveEnvironmentOverride() + t.Cleanup(func() { + viper.Reset() + config.ClearActiveEnvironmentOverride() + }) + + viper.Set("activeenvironment", "production") + + cmd := NewRootCommand() + cmd.SetOut(new(bytes.Buffer)) + cmd.SetErr(new(bytes.Buffer)) + cmd.SetArgs([]string{"--env", "staging", "config", "debug"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected command to succeed: %v", err) + } + + if got := config.GetActiveEnvironment(); got != "staging" { + t.Fatalf("active environment override = %q, want %q", got, "staging") + } + if got := viper.GetString("activeenvironment"); got != "production" { + t.Fatalf("persisted activeenvironment = %q, want %q", got, "production") } } diff --git a/cmd/rule/download.go b/cmd/rule/download.go index 931c8b2d..90cfa33a 100644 --- a/cmd/rule/download.go +++ b/cmd/rule/download.go @@ -75,9 +75,9 @@ func newDownloadCommand() *cobra.Command { cmd := &cobra.Command{ Use: "download", - Short: "Download all rules in Identity Security Cloud", - Long: "\nDownload all rules in Identity Security Cloud\n\n", - Example: "sail rule download", + Short: "Download all rules", + Long: "\nDownload all rules from Identity Security Cloud and save them locally.\nCloud rules are saved as XML files, connector rules as JSON.\nUse --cloud or --connector flags to filter by rule type.\n", + Example: " sail rule download\n sail rule download --cloud\n sail rule download --connector -d rule_files", Aliases: []string{"d"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/rule/list.go b/cmd/rule/list.go index 32cc6a91..146fb1e7 100644 --- a/cmd/rule/list.go +++ b/cmd/rule/list.go @@ -32,9 +32,9 @@ func newListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List all rules in Identity Security Cloud", - Long: "\nList all rules in Identity Security Cloud\n\n", - Example: "sail rule list | sail rule ls", + Short: "List all rules", + Long: "\nList all rules in Identity Security Cloud.\nUse --cloud or --connector flags to filter by rule type.\n", + Example: " sail rule list\n sail rule list --cloud\n sail rule list --connector", Aliases: []string{"ls"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/rule/rule.go b/cmd/rule/rule.go index a9af123b..93a2e093 100644 --- a/cmd/rule/rule.go +++ b/cmd/rule/rule.go @@ -8,9 +8,9 @@ import ( func NewRuleCommand() *cobra.Command { cmd := &cobra.Command{ Use: "rule", - Short: "Manage rules in Identity Security Cloud", - Long: "\nManage rules in Identity Security Cloud\n\n", - Example: "sail rule", + Short: "Manage rules", + Long: "\nManage cloud rules and connector rules in Identity Security Cloud.\nUse subcommands to list or download rules.\n", + Example: " sail rule list\n sail rule download", Run: func(cmd *cobra.Command, args []string) { cmd.Help() }, diff --git a/cmd/sdk/config.go b/cmd/sdk/config.go index bdbb505a..5919dc16 100644 --- a/cmd/sdk/config.go +++ b/cmd/sdk/config.go @@ -4,6 +4,7 @@ package sdk import ( "encoding/json" "fmt" + "io" "os" "path" @@ -18,17 +19,18 @@ type Config struct { BaseURL string } -func (c Config) printEnv() { - fmt.Println("BASE_URL=" + c.BaseURL) - fmt.Println("CLIENT_ID=" + c.ClientId) - fmt.Println("CLIENT_SECRET=" + c.ClientSecret) +func (c Config) printEnv(w io.Writer) { + fmt.Fprintln(w, "BASE_URL="+c.BaseURL) + fmt.Fprintln(w, "CLIENT_ID="+c.ClientId) + fmt.Fprintln(w, "CLIENT_SECRET="+c.ClientSecret) } func newConfigCommand() *cobra.Command { var env bool + var unsafePrintSecret bool cmd := &cobra.Command{ Use: "config", - Short: "Initialize a configuration json file for an SDK project", + Short: "Initialize a configuration JSON file for an SDK project", Long: "\nInitialize a configuration json file for an SDK project\n\nRunning with no arguments will use the currently active environment\n", Example: "sail sdk init config\nsail sdk init config ", Aliases: []string{"conf"}, @@ -55,7 +57,11 @@ func newConfigCommand() *cobra.Command { SDKConfig := Config{ClientId: clientID, ClientSecret: clientSecret, BaseURL: config.GetEnvBaseUrl(envName)} if env { - SDKConfig.printEnv() + if !unsafePrintSecret { + return fmt.Errorf("--environment prints CLIENT_SECRET and requires --unsafe-print-secret") + } + log.Warn("Printing SDK config includes CLIENT_SECRET. Do not paste this output into logs or tickets.") + SDKConfig.printEnv(cmd.OutOrStdout()) } else { workingDir, err := os.Getwd() if err != nil { @@ -64,7 +70,7 @@ func newConfigCommand() *cobra.Command { configPath := path.Join(workingDir, "config.json") - file, err := os.Create(configPath) + file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { return err } @@ -89,6 +95,7 @@ func newConfigCommand() *cobra.Command { } cmd.Flags().BoolVarP(&env, "environment", "e", false, "Print out the config values in .env format to the terminal rather than to a config file") + cmd.Flags().BoolVar(&unsafePrintSecret, "unsafe-print-secret", false, "Allow printing CLIENT_SECRET to stdout") return cmd } diff --git a/cmd/sdk/go.go b/cmd/sdk/go.go index a4465711..2e469ca5 100644 --- a/cmd/sdk/go.go +++ b/cmd/sdk/go.go @@ -16,7 +16,7 @@ const ( func newGolangCommand() *cobra.Command { cmd := &cobra.Command{ Use: "golang", - Short: "Initialize a new GO SDK project", + Short: "Initialize a new Go SDK project", Long: "\nInitialize a new GO SDK project by fetching the template from GitHub.\n\n", Example: "sail sdk init golang\nsail sdk init go example-project", Aliases: []string{"go"}, diff --git a/cmd/sdk/init.go b/cmd/sdk/init.go index dafd8f62..2923a7b6 100644 --- a/cmd/sdk/init.go +++ b/cmd/sdk/init.go @@ -10,9 +10,9 @@ func newInitCommand() *cobra.Command { cmd := &cobra.Command{ Use: "init", Short: "Initialize SDK projects", - Long: "\nInitialize SDK projects\n\n", - Example: "sail sdk init", - Aliases: []string{"temp"}, + Long: "\nInitialize a new SailPoint SDK project.\n\nChoose a language subcommand to scaffold a project with\nthe necessary dependencies and configuration.\n", + Example: " sail sdk init golang\n sail sdk init typescript my-project\n sail sdk init config", + Aliases: []string{"i"}, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { cmd.Help() diff --git a/cmd/sdk/python.go b/cmd/sdk/python.go index 9fed4aea..0f115705 100644 --- a/cmd/sdk/python.go +++ b/cmd/sdk/python.go @@ -16,7 +16,7 @@ const ( func newPythonCommand() *cobra.Command { cmd := &cobra.Command{ Use: "python", - Short: "Initialize a new python SDK project", + Short: "Initialize a new Python SDK project", Long: "\nInitialize a new Python SDK project by fetching the template from GitHub.\n\n", Example: "sail sdk init python\nsail sdk init py example-project", Aliases: []string{"py"}, diff --git a/cmd/sdk/sdk.go b/cmd/sdk/sdk.go index 2fa57df4..86fdf5ee 100644 --- a/cmd/sdk/sdk.go +++ b/cmd/sdk/sdk.go @@ -9,8 +9,8 @@ func NewSDKCommand() *cobra.Command { cmd := &cobra.Command{ Use: "sdk", Short: "Initialize or configure SDK projects", - Long: "\nInitialize or configure SDK projects\n\n", - Example: "sail sdk", + Long: "\nInitialize or configure SailPoint SDK projects.\n\nSupported languages: Go, Python, TypeScript, and PowerShell.\n", + Example: " sail sdk init\n sail sdk init config", Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { cmd.Help() diff --git a/cmd/sdk/ts.go b/cmd/sdk/ts.go index 4a80bade..21067ce5 100644 --- a/cmd/sdk/ts.go +++ b/cmd/sdk/ts.go @@ -16,7 +16,7 @@ const ( func newTypescriptCommand() *cobra.Command { cmd := &cobra.Command{ Use: "typescript", - Short: "Initialize a new typescript SDK project", + Short: "Initialize a new TypeScript SDK project", Long: "\nInitialize a new TypeScript SDK project by fetching the template from GitHub.\n\n", Example: "sail sdk init typescript\nsail sdk init ts example-project", Aliases: []string{"ts"}, diff --git a/cmd/search/query.go b/cmd/search/query.go index 34e8c9a7..ced49b30 100644 --- a/cmd/search/query.go +++ b/cmd/search/query.go @@ -16,17 +16,11 @@ func newQueryCmd() *cobra.Command { cmd := &cobra.Command{ Use: "query", Short: "Manually search using a specific query and indices", - Long: "\nRun a search query in Identity Security Cloud, using a specific query and indicies\n\n", - Example: "sail search query \"(type:provisioning AND created:[now-90d TO now])\" --indices events", + Long: "\nRun a search query in Identity Security Cloud using a specific query string\nand indices. Results are saved as JSON files in the specified folder.\n", + Example: " sail search query \"(type:provisioning AND created:[now-90d TO now])\" --indices events\n sail search query \"name:a*\" --indices identities --sort \"-created\"", Aliases: []string{"que"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - - err := config.InitConfig() - if err != nil { - return err - } - apiClient, err := config.InitAPIClient(false) if err != nil { return err @@ -56,7 +50,10 @@ func newQueryCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&folderPath, "folderPath", "f", "search_results", "Folder path to save the search results to. If the directory doesn't exist, then it will be created. (defaults to the current working directory)") + cmd.Flags().StringVarP(&folderPath, "folder-path", "f", "search_results", "Folder path to save the search results to. If the directory doesn't exist, then it will be created. (defaults to the current working directory)") + cmd.Flags().StringVar(&folderPath, "folderPath", "search_results", "Deprecated: use --folder-path") + cmd.Flags().MarkDeprecated("folderPath", "use --folder-path") + cmd.Flags().MarkHidden("folderPath") cmd.Flags().StringArrayVar(&indices, "indices", []string{}, "Indices to perform the search query on (accessprofiles, accountactivities, entitlements, events, identities, roles)") cmd.Flags().StringArrayVar(&sort, "sort", []string{}, "The sort value for the api call (displayName, +id...)") cmd.MarkFlagRequired("indices") diff --git a/cmd/search/search.go b/cmd/search/search.go index a04c83a3..d2b5fa2e 100644 --- a/cmd/search/search.go +++ b/cmd/search/search.go @@ -8,9 +8,9 @@ import ( func NewSearchCommand() *cobra.Command { cmd := &cobra.Command{ Use: "search", - Short: "Perform search operations in Identity Security Cloud, using a specific query or a template", - Long: "\nPerform search operations in Identity Security Cloud, using a specific query or a template\n\n", - Example: "sail search", + Short: "Perform search operations using a query or template", + Long: "\nPerform search operations in Identity Security Cloud.\n\nUse 'query' for ad-hoc searches with custom query strings,\nor 'template' to run predefined search templates.\n", + Example: " sail search query \"name:a*\" --indices identities\n sail search template", Aliases: []string{"se"}, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { diff --git a/cmd/search/template.go b/cmd/search/template.go index e8dd652e..905f641b 100644 --- a/cmd/search/template.go +++ b/cmd/search/template.go @@ -10,7 +10,7 @@ import ( "github.com/sailpoint-oss/sailpoint-cli/internal/config" "github.com/sailpoint-oss/sailpoint-cli/internal/search" "github.com/sailpoint-oss/sailpoint-cli/internal/templates" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/types" "github.com/spf13/cobra" ) @@ -20,24 +20,18 @@ func newTemplateCmd() *cobra.Command { var template string cmd := &cobra.Command{ Use: "template", - Short: "Perform search operations in Identity Security Cloud, using a predefined search template", - Long: "\nPerform search operations in Identity Security Cloud, using a predefined search template\n\n", - Example: "sail search template", + Short: "Search using a predefined template", + Long: "\nRun a search in Identity Security Cloud using a predefined template.\nTemplates provide pre-built queries with optional variables for common\nsearch patterns. Results are saved as JSON files.\n", + Example: " sail search template\n sail search template all-provisioning-events-90-days", Aliases: []string{"temp"}, Args: cobra.MaximumNArgs(1), PreRun: func(cmd *cobra.Command, args []string) { - folderPath, _ := cmd.Flags().GetString("folderPath") + folderPath, _ := cmd.Flags().GetString("folder-path") if folderPath == "" { cmd.MarkFlagRequired("save") } }, RunE: func(cmd *cobra.Command, args []string) error { - - err := config.InitConfig() - if err != nil { - return err - } - apiClient, err := config.InitAPIClient(false) if err != nil { return err @@ -74,7 +68,10 @@ func newTemplateCmd() *cobra.Command { if len(selectedTemplate.Variables) > 0 { for _, varEntry := range selectedTemplate.Variables { - resp := terminal.InputPrompt("Input " + varEntry.Prompt + ":") + resp, err := tui.Input(varEntry.Prompt, "") + if err != nil { + return err + } selectedTemplate.Raw = []byte(strings.ReplaceAll(string(selectedTemplate.Raw), "{{"+varEntry.Name+"}}", resp)) } err := json.Unmarshal(selectedTemplate.Raw, &selectedTemplate.SearchQuery) @@ -99,7 +96,10 @@ func newTemplateCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&folderPath, "folderPath", "f", "search_results", "Folder path to save the search results to. If the directory doesn't exist, then it will be created. (defaults to the current working directory)") + cmd.Flags().StringVarP(&folderPath, "folder-path", "f", "search_results", "Folder path to save the search results to. If the directory doesn't exist, then it will be created. (defaults to the current working directory)") + cmd.Flags().StringVar(&folderPath, "folderPath", "search_results", "Deprecated: use --folder-path") + cmd.Flags().MarkDeprecated("folderPath", "use --folder-path") + cmd.Flags().MarkHidden("folderPath") return cmd } diff --git a/cmd/set/README.md b/cmd/set/README.md index 53ee19c0..3e7f02f9 100644 --- a/cmd/set/README.md +++ b/cmd/set/README.md @@ -1,4 +1,15 @@ -# Set +# Set (Deprecated) + +> **Deprecated:** The `set` command is deprecated. Use `sail config` for global settings and `sail env` for environment management. +> +> **Migration guide:** +> | Old command | New command | +> |---|---| +> | `sail set debug enable` | `sail config debug true` | +> | `sail set auth pat` | `sail env update` (then choose PAT) | +> | `sail set pat -i -s ` | `sail env update` (then enter credentials) | +> | `sail set exportTemplates ` | `sail config export-templates-path ` | +> | `sail set searchTemplates ` | `sail config search-templates-path ` | The `set` command makes it easy to update configuration values for the SailPoint CLI. @@ -16,7 +27,7 @@ The `set` command makes it easy to update configuration values for the SailPoint Run the following command to set the current authentication method for the CLI. -> :warning: **Currently only Personal Access Token Authentication is supported**: OAuth possibly coming in the future! +> **Note:** Both Personal Access Token (PAT) and OAuth authentication methods are supported. ```shell sail set auth pat diff --git a/cmd/set/pat.go b/cmd/set/pat.go index dca14afd..58beb321 100644 --- a/cmd/set/pat.go +++ b/cmd/set/pat.go @@ -2,6 +2,10 @@ package set import ( + "bufio" + "strings" + + "github.com/charmbracelet/log" "github.com/sailpoint-oss/sailpoint-cli/internal/config" "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" "github.com/spf13/cobra" @@ -10,6 +14,7 @@ import ( func newPATCommand(term terminal.Terminal) *cobra.Command { var ClientID string var ClientSecret string + var readSecretFromStdin bool var err error cmd := &cobra.Command{ Use: "pat", @@ -18,6 +23,10 @@ func newPATCommand(term terminal.Terminal) *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("ClientSecret") { + log.Warn("Passing secrets as flags can expose them in shell history and process listings. Use --client-secret-stdin or the secure prompt instead.") + } + if ClientID == "" { ClientID, err = config.PromptForClientID() @@ -31,6 +40,16 @@ func newPATCommand(term terminal.Terminal) *cobra.Command { return err } + if readSecretFromStdin { + scanner := bufio.NewScanner(cmd.InOrStdin()) + if scanner.Scan() { + ClientSecret = strings.TrimSpace(scanner.Text()) + } + if err := scanner.Err(); err != nil { + return err + } + } + if ClientSecret == "" { ClientSecret, err = config.PromptForClientSecret() if err != nil { @@ -52,8 +71,12 @@ func newPATCommand(term terminal.Terminal) *cobra.Command { }, } - cmd.Flags().StringVarP(&ClientID, "ClientID", "i", "", "The client id to use for PAT authentication") - cmd.Flags().StringVarP(&ClientSecret, "ClientSecret", "s", "", "The client secret to use for PAT authentication") + cmd.Flags().StringVar(&ClientID, "client-id", "", "The client ID to use for PAT authentication") + cmd.Flags().BoolVar(&readSecretFromStdin, "client-secret-stdin", false, "Read the client secret from stdin") + cmd.Flags().StringVarP(&ClientID, "ClientID", "i", "", "Deprecated: use --client-id") + cmd.Flags().StringVarP(&ClientSecret, "ClientSecret", "s", "", "Deprecated: use --client-secret-stdin or the secure prompt") + cmd.Flags().MarkDeprecated("ClientID", "use --client-id") + cmd.Flags().MarkDeprecated("ClientSecret", "use --client-secret-stdin or the secure prompt") return cmd } diff --git a/cmd/set/set.go b/cmd/set/set.go index 46009ca2..ffda17df 100644 --- a/cmd/set/set.go +++ b/cmd/set/set.go @@ -8,11 +8,12 @@ import ( func NewSetCmd(term terminal.Terminal) *cobra.Command { cmd := &cobra.Command{ - Use: "set", - Short: "Configure settings for the SailPoint CLI", - Long: "\nConfigure settings for the SailPoint CLI\n\n", - Example: "sail set", - Args: cobra.MaximumNArgs(1), + Use: "set", + Short: "Configure settings for the SailPoint CLI", + Long: "\nConfigure settings for the SailPoint CLI\n\n", + Example: "sail set", + Deprecated: "use 'sail config' for global settings and 'sail env' for environment management", + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { cmd.Help() }, diff --git a/cmd/source/crud_live_test.go b/cmd/source/crud_live_test.go new file mode 100644 index 00000000..109b0566 --- /dev/null +++ b/cmd/source/crud_live_test.go @@ -0,0 +1,103 @@ +package source + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + v2024 "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" + "github.com/sailpoint-oss/sailpoint-cli/internal/testutil" +) + +func TestSourceCRUD(t *testing.T) { + testutil.RequireLiveCredentials(t) + testutil.SetJSONOutput(t) + + fixturePath := os.Getenv("SAIL_TEST_SOURCE_CREATE_PAYLOAD") + if fixturePath == "" { + t.Skip("skipping source CRUD test: SAIL_TEST_SOURCE_CREATE_PAYLOAD is required for connector-specific source creation") + } + + raw, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("failed to read source fixture %q: %v", fixturePath, err) + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("failed to decode source fixture %q: %v", fixturePath, err) + } + + dir := t.TempDir() + name := testutil.UniqueName("source") + updatedDescription := "updated by sail CLI live CRUD test" + payload["name"] = name + delete(payload, "id") + createPath := testutil.WriteJSON(t, dir, "source-create.json", payload) + + createCmd := newCreateCommand() + createOut := new(bytes.Buffer) + createCmd.SetOut(createOut) + createCmd.Flags().Set("file", createPath) + + if err := createCmd.Execute(); err != nil { + testutil.SkipIfFeatureUnavailable(t, err) + t.Fatalf("source create failed: %v", err) + } + + created := testutil.DecodeJSON[v2024.Source](t, createOut.String()) + if created.GetId() == "" { + t.Fatalf("expected created source ID, got %#v", created) + } + sourceID := created.GetId() + defer deleteSource(t, sourceID) + + getSourceAndAssert(t, sourceID, name) + + patchPath := testutil.WriteJSON(t, dir, "source-patch.json", []map[string]any{ + {"op": "replace", "path": "/description", "value": updatedDescription}, + }) + patchCmd := newPatchCommand() + patchOut := new(bytes.Buffer) + patchCmd.SetOut(patchOut) + patchCmd.SetArgs([]string{sourceID}) + patchCmd.Flags().Set("file", patchPath) + + if err := patchCmd.Execute(); err != nil { + t.Fatalf("source patch failed: %v", err) + } + updated := testutil.DecodeJSON[v2024.Source](t, patchOut.String()) + if updated.GetDescription() != updatedDescription { + t.Fatalf("expected source description %q, got %q", updatedDescription, updated.GetDescription()) + } +} + +func getSourceAndAssert(t *testing.T, sourceID string, expectedName string) { + t.Helper() + + getCmd := newGetCommand() + getOut := new(bytes.Buffer) + getCmd.SetOut(getOut) + getCmd.SetArgs([]string{sourceID}) + if err := getCmd.Execute(); err != nil { + t.Fatalf("source get failed: %v", err) + } + source := testutil.DecodeJSON[v2024.Source](t, getOut.String()) + if source.GetId() != sourceID { + t.Fatalf("expected source ID %q, got %q", sourceID, source.GetId()) + } + if source.GetName() != expectedName { + t.Fatalf("expected source name %q, got %q", expectedName, source.GetName()) + } +} + +func deleteSource(t *testing.T, sourceID string) { + t.Helper() + + deleteCmd := newDeleteCommand() + deleteCmd.SetArgs([]string{sourceID}) + deleteCmd.Flags().Set("force", "true") + if err := deleteCmd.Execute(); err != nil { + t.Logf("failed to clean up source %s: %v", sourceID, err) + } +} diff --git a/cmd/source/source.go b/cmd/source/source.go new file mode 100644 index 00000000..1fc72ad4 --- /dev/null +++ b/cmd/source/source.go @@ -0,0 +1,277 @@ +package source + +import ( + "context" + + v2024 "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/sdkcmd" + "github.com/spf13/cobra" +) + +func NewSourceCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "source", + Short: "Inspect Identity Security Cloud sources", + Long: "\nInspect sources, schemas, connector configuration, provisioning policies, schedules, and health.\n\n", + Example: " sail source list\n sail source get \n sail source schemas ", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + cmd.AddCommand( + newListCommand(), + newGetCommand(), + newCreateCommand(), + newPatchCommand(), + newDeleteCommand(), + newSchemasCommand(), + newConfigCommand(), + newHealthCommand(), + newSchedulesCommand(), + newPolicyCommand(), + ) + return cmd +} + +func newListCommand() *cobra.Command { + var opts sdkcmd.ListOptions + cmd := &cobra.Command{ + Use: "list", + Short: "List sources", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + req := apiClient.V2024.SourcesAPI.ListSources(context.TODO()). + Limit(opts.Limit). + Offset(opts.Offset). + Count(opts.Count) + if opts.Filters != "" { + req = req.Filters(opts.Filters) + } + if opts.Sorters != "" { + req = req.Sorters(opts.Sorters) + } + sources, resp, err := req.Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + rows := make([][]string, 0, len(sources)) + for _, item := range sources { + rows = append(rows, []string{item.GetName(), item.GetId(), item.GetType(), item.GetStatus()}) + } + return sdkcmd.WriteTable(cmd, []string{"Name", "ID", "Type", "Status"}, rows, "Name", sources) + }, + } + sdkcmd.AddListFlags(cmd, &opts) + return cmd +} + +func newGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get a source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + source, resp, err := apiClient.V2024.SourcesAPI.GetSource(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, source) + }, + } +} + +func newCreateCommand() *cobra.Command { + var filePath string + var provisionAsCSV bool + cmd := &cobra.Command{ + Use: "create --file source.json", + Short: "Create a source from a JSON payload", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[v2024.Source](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + req := apiClient.V2024.SourcesAPI.CreateSource(context.TODO()).Source(payload) + if provisionAsCSV { + req = req.ProvisionAsCsv(provisionAsCSV) + } + source, resp, err := req.Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, source) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON payload file") + cmd.Flags().BoolVar(&provisionAsCSV, "provision-as-csv", false, "Create a delimited file source with the provisionAsCsv query parameter") + cmd.MarkFlagRequired("file") + return cmd +} + +func newPatchCommand() *cobra.Command { + var filePath string + cmd := &cobra.Command{ + Use: "patch --file patch.json", + Aliases: []string{"update"}, + Short: "Patch a source from a JSON Patch payload", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := sdkcmd.ReadJSONFile[[]v2024.JsonPatchOperation](filePath) + if err != nil { + return err + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + source, resp, err := apiClient.V2024.SourcesAPI.UpdateSource(context.TODO(), args[0]). + JsonPatchOperation(payload). + Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, source) + }, + } + cmd.Flags().StringVarP(&filePath, "file", "f", "", "JSON Patch payload file") + cmd.MarkFlagRequired("file") + return cmd +} + +func newDeleteCommand() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !force { + return clierror.Usage("source delete requires --force") + } + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + result, resp, err := apiClient.V2024.SourcesAPI.DeleteSource(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, result) + }, + } + cmd.Flags().BoolVarP(&force, "force", "f", false, "Confirm source deletion") + return cmd +} + +func newSchemasCommand() *cobra.Command { + return &cobra.Command{ + Use: "schemas ", + Short: "List schemas for a source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + schemas, resp, err := apiClient.V2024.SourcesAPI.GetSourceSchemas(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, schemas) + }, + } +} + +func newConfigCommand() *cobra.Command { + return &cobra.Command{ + Use: "config ", + Short: "Get connector configuration for a source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + cfg, resp, err := apiClient.V2024.SourcesAPI.GetSourceConfig(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, cfg) + }, + } +} + +func newHealthCommand() *cobra.Command { + return &cobra.Command{ + Use: "health ", + Short: "Get source health", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + health, resp, err := apiClient.V2024.SourcesAPI.GetSourceHealth(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, health) + }, + } +} + +func newSchedulesCommand() *cobra.Command { + return &cobra.Command{ + Use: "schedules ", + Short: "List schedules for a source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + schedules, resp, err := apiClient.V2024.SourcesAPI.GetSourceSchedules(context.TODO(), args[0]).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, schedules) + }, + } +} + +func newPolicyCommand() *cobra.Command { + var usageType string + cmd := &cobra.Command{ + Use: "policy ", + Short: "Get a provisioning policy for a source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + apiClient, err := config.InitAPIClient(false) + if err != nil { + return err + } + policy, resp, err := apiClient.V2024.SourcesAPI.GetProvisioningPolicy(context.TODO(), args[0], v2024.UsageType(usageType)).Execute() + if err := sdkcmd.SDKError(resp, err); err != nil { + return err + } + return sdkcmd.WriteStructured(cmd, policy) + }, + } + cmd.Flags().StringVar(&usageType, "usage-type", "CREATE", "Provisioning policy usage type") + return cmd +} diff --git a/cmd/source/source_test.go b/cmd/source/source_test.go new file mode 100644 index 00000000..43b9555f --- /dev/null +++ b/cmd/source/source_test.go @@ -0,0 +1,19 @@ +package source + +import ( + "strings" + "testing" +) + +func TestSourceDeleteRequiresForce(t *testing.T) { + cmd := newDeleteCommand() + cmd.SetArgs([]string{"source-id"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected source delete without --force to fail") + } + if !strings.Contains(err.Error(), "source delete requires --force") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/spconfig/README.md b/cmd/spconfig/README.md index d15d5e3e..103c2169 100644 --- a/cmd/spconfig/README.md +++ b/cmd/spconfig/README.md @@ -81,39 +81,41 @@ Run the following command to begin an spconfig export job in Identity Security C ```shell sail spconfig export \ - -includeTypes \ - -excludeTypes \ - -description "optional description for the export job" + --include SOURCE \ + --include WORKFLOW \ + --description "optional description for the export job" ``` -Use the following command syntax to download the results from multiple import or export jobs +### Flags + +#### Include + +Specifies the object types to include in the export ```shell -sail spconfig download -export -export +sail spconfig export --include SOURCE --include WORKFLOW ``` -### Flags - -#### Import +#### Exclude -Specifies the ids of the import jobs to download +Specifies the object types to exclude from the export ```shell -sail spconfig download -import -import +sail spconfig export --exclude SOURCE --exclude WORKFLOW ``` -#### Export +#### Wait -Specifies the ids of the export jobs to download +Wait for the export job to complete and automatically download the results ```shell -sail spconfig download -export -export +sail spconfig export --include SOURCE --wait ``` #### Folder Path -Specify the folder path to save the search results in +Specify the folder path to save the export results in ```shell -sail spconfig download -export -export -folderPath ./local/folder/path +sail spconfig export --include SOURCE --wait -f ./local/folder/path ``` diff --git a/cmd/spconfig/download.go b/cmd/spconfig/download.go index 60583e7e..1866d3fa 100644 --- a/cmd/spconfig/download.go +++ b/cmd/spconfig/download.go @@ -21,7 +21,7 @@ func newDownloadCommand() *cobra.Command { var folderPath string cmd := &cobra.Command{ Use: "download {--import --export }", - Short: "Download the results of import or export jobs from Identity Security Cloud", + Short: "Download the results of import or export jobs", Long: help.Long, Example: help.Example, Aliases: []string{"down"}, @@ -55,7 +55,10 @@ func newDownloadCommand() *cobra.Command { cmd.Flags().StringArrayVarP(&importIDs, "import", "", []string{}, "Specify the IDs of the import jobs to download results for") cmd.Flags().StringArrayVarP(&exportIDs, "export", "", []string{}, "Specify the IDs of the export jobs to download results for") - cmd.Flags().StringVarP(&folderPath, "folderPath", "f", "spconfig-exports", "Folder path to save the search results in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)") + cmd.Flags().StringVarP(&folderPath, "folder-path", "f", "spconfig-exports", "Folder path to save the search results in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)") + cmd.Flags().StringVar(&folderPath, "folderPath", "spconfig-exports", "Deprecated: use --folder-path") + cmd.Flags().MarkDeprecated("folderPath", "use --folder-path") + cmd.Flags().MarkHidden("folderPath") return cmd } diff --git a/cmd/spconfig/export.go b/cmd/spconfig/export.go index 4a785f92..a8738bf8 100644 --- a/cmd/spconfig/export.go +++ b/cmd/spconfig/export.go @@ -30,7 +30,7 @@ func newExportCommand() *cobra.Command { cmd := &cobra.Command{ Use: "export", - Short: "Start an export job in Identity Security Cloud", + Short: "Start an SPConfig export job", Long: help.Long, Example: help.Example, Aliases: []string{"exp"}, @@ -70,11 +70,17 @@ func newExportCommand() *cobra.Command { }, } - cmd.Flags().StringVarP(&folderPath, "folderPath", "f", "spconfig-exports", "Folder path to save the search results in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)") + cmd.Flags().StringVarP(&folderPath, "folder-path", "f", "spconfig-exports", "Folder path to save the search results in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)") + cmd.Flags().StringVar(&folderPath, "folderPath", "spconfig-exports", "Deprecated: use --folder-path") + cmd.Flags().MarkDeprecated("folderPath", "use --folder-path") + cmd.Flags().MarkHidden("folderPath") cmd.Flags().StringVarP(&description, "description", "", "", "Optional description for the export job") cmd.Flags().StringArrayVarP(&includeTypes, "include", "i", []string{}, "Types to include in export job") cmd.Flags().StringArrayVarP(&excludeTypes, "exclude", "e", []string{}, "Types to exclude in export job") - cmd.Flags().StringVarP(&objectOptions, "objectOptions", "o", "", "Options for the object types being exported") + cmd.Flags().StringVarP(&objectOptions, "object-options", "o", "", "Options for the object types being exported") + cmd.Flags().StringVar(&objectOptions, "objectOptions", "", "Deprecated: use --object-options") + cmd.Flags().MarkDeprecated("objectOptions", "use --object-options") + cmd.Flags().MarkHidden("objectOptions") cmd.Flags().BoolVarP(&wait, "wait", "w", false, "Wait for the export job to finish, and then download the results") return cmd diff --git a/cmd/spconfig/import.go b/cmd/spconfig/import.go index daca18b9..25627d1a 100644 --- a/cmd/spconfig/import.go +++ b/cmd/spconfig/import.go @@ -18,7 +18,7 @@ func newImportCommand() *cobra.Command { cmd := &cobra.Command{ Use: "import", - Short: "Start an import job in Identity Security Cloud", + Short: "Start an SPConfig import job", Long: "\nStart an import job in Identity Security Cloud\n\n", Example: "sail spconfig import", Aliases: []string{"imp"}, @@ -57,10 +57,16 @@ func newImportCommand() *cobra.Command { }, } - cmd.Flags().StringVarP(&filePath, "filePath", "f", "", "Path to the file containing the import payload") - cmd.Flags().StringVarP(&folderPath, "folderPath", "p", "spconfig-imports", "Folder path to save the import results in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)") + cmd.Flags().StringVarP(&filePath, "file-path", "f", "", "Path to the file containing the import payload") + cmd.Flags().StringVar(&filePath, "filePath", "", "Deprecated: use --file-path") + cmd.Flags().MarkDeprecated("filePath", "use --file-path") + cmd.Flags().MarkHidden("filePath") + cmd.Flags().StringVarP(&folderPath, "folder-path", "p", "spconfig-imports", "Folder path to save the import results in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)") + cmd.Flags().StringVar(&folderPath, "folderPath", "spconfig-imports", "Deprecated: use --folder-path") + cmd.Flags().MarkDeprecated("folderPath", "use --folder-path") + cmd.Flags().MarkHidden("folderPath") cmd.Flags().BoolVarP(&wait, "wait", "w", false, "Wait for the import job to finish, and then download the results") - cmd.MarkFlagRequired("filepath") + cmd.MarkFlagRequired("file-path") return cmd } diff --git a/cmd/spconfig/job_lifecycle_live_test.go b/cmd/spconfig/job_lifecycle_live_test.go new file mode 100644 index 00000000..ef08efa6 --- /dev/null +++ b/cmd/spconfig/job_lifecycle_live_test.go @@ -0,0 +1,80 @@ +package spconfig + +import ( + "bytes" + "io" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/sailpoint-oss/sailpoint-cli/internal/testutil" +) + +var spConfigJobIDPattern = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`) + +func TestSPConfigExportStatusDownloadLifecycle(t *testing.T) { + testutil.RequireLiveCredentials(t) + + description := testutil.UniqueName("spconfig") + exportCmd := newExportCommand() + exportCmd.Flags().Set("description", description) + exportCmd.Flags().Set("include", "TRANSFORM") + + exportOut, err := captureStdout(func() error { + return exportCmd.Execute() + }) + if err != nil { + testutil.SkipIfFeatureUnavailable(t, err) + t.Fatalf("spconfig export failed: %v", err) + } + + jobID := spConfigJobIDPattern.FindString(exportOut) + if jobID == "" { + t.Fatalf("expected SPConfig export job ID in output %q", exportOut) + } + + statusCmd := newStatusCommand() + statusCmd.Flags().Set("export", jobID) + if _, err := captureStdout(func() error { + return statusCmd.Execute() + }); err != nil { + t.Fatalf("spconfig status failed: %v", err) + } + + dir := t.TempDir() + downloadCmd := newDownloadCommand() + downloadCmd.Flags().Set("export", jobID) + downloadCmd.Flags().Set("folder-path", dir) + if err := downloadCmd.Execute(); err != nil { + t.Fatalf("spconfig download failed: %v", err) + } + + expectedPath := filepath.Join(dir, "spconfig-export-"+jobID+".json") + if _, err := os.Stat(expectedPath); err != nil { + t.Fatalf("expected downloaded export at %s: %v", expectedPath, err) + } +} + +func captureStdout(fn func() error) (string, error) { + original := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + return "", err + } + os.Stdout = writer + defer func() { + os.Stdout = original + }() + + runErr := fn() + _ = writer.Close() + + var buf bytes.Buffer + _, copyErr := io.Copy(&buf, reader) + _ = reader.Close() + if runErr != nil { + return buf.String(), runErr + } + return buf.String(), copyErr +} diff --git a/cmd/spconfig/spconfig.go b/cmd/spconfig/spconfig.go index 163ea5bc..d5c07987 100644 --- a/cmd/spconfig/spconfig.go +++ b/cmd/spconfig/spconfig.go @@ -15,7 +15,7 @@ func NewSPConfigCommand() *cobra.Command { help := util.ParseHelp(spconfigHelp) cmd := &cobra.Command{ Use: "spconfig", - Short: "Perform SPConfig operations in Identity Security Cloud", + Short: "Manage SPConfig import and export operations", Long: help.Long, Example: help.Example, Aliases: []string{"spcon"}, diff --git a/cmd/spconfig/spconfig.md b/cmd/spconfig/spconfig.md index bb2f3e67..58ddc4b3 100644 --- a/cmd/spconfig/spconfig.md +++ b/cmd/spconfig/spconfig.md @@ -2,7 +2,7 @@ # SPConfig Perform SP-Config operations in Identity Security Cloud. -API References: +## API Reference - https://developer.sailpoint.com/docs/api/beta/sp-config ==== diff --git a/cmd/spconfig/status.go b/cmd/spconfig/status.go index 74a44603..463aae65 100644 --- a/cmd/spconfig/status.go +++ b/cmd/spconfig/status.go @@ -14,7 +14,7 @@ func newStatusCommand() *cobra.Command { var importJobs []string cmd := &cobra.Command{ Use: "status", - Short: "Get the status of SPConfig jobs in Identity Security Cloud", + Short: "Get the status of SPConfig jobs", Long: "\nGet the status of SPConfig jobs in Identity Security Cloud\n\n", Example: "sail spconfig status --export 2b3b68f4-cfe7-43a6-8fb0-a518c6218111", Aliases: []string{"stat"}, diff --git a/cmd/spconfig/template.go b/cmd/spconfig/template.go index 8a563829..4e3e94f1 100644 --- a/cmd/spconfig/template.go +++ b/cmd/spconfig/template.go @@ -12,7 +12,7 @@ import ( "github.com/sailpoint-oss/sailpoint-cli/internal/config" "github.com/sailpoint-oss/sailpoint-cli/internal/spconfig" "github.com/sailpoint-oss/sailpoint-cli/internal/templates" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/types" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/spf13/cobra" @@ -28,7 +28,7 @@ func newTemplateCommand() *cobra.Command { var wait bool cmd := &cobra.Command{ Use: "template", - Short: "Begin an SPConfig export task in Identity Security Cloud, using a template", + Short: "Start an SPConfig export using a template", Long: help.Long, Example: help.Example, Aliases: []string{"temp"}, @@ -71,7 +71,10 @@ func newTemplateCommand() *cobra.Command { if varCount > 0 { for i := 0; i < varCount; i++ { varEntry := selectedTemplate.Variables[i] - resp := terminal.InputPrompt("Input " + varEntry.Prompt + ":") + resp, err := tui.Input(varEntry.Prompt, "") + if err != nil { + return err + } selectedTemplate.Raw = []byte(strings.ReplaceAll(string(selectedTemplate.Raw), "{{"+varEntry.Name+"}}", resp)) } err := json.Unmarshal(selectedTemplate.Raw, &selectedTemplate.ExportBody) @@ -96,7 +99,10 @@ func newTemplateCommand() *cobra.Command { }, } - cmd.Flags().StringVarP(&folderPath, "folderPath", "f", "spconfig-exports", "Folder path to save the search results in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)") + cmd.Flags().StringVarP(&folderPath, "folder-path", "f", "spconfig-exports", "Folder path to save the search results in. If the directory doesn't exist, then it will be automatically created. (default is the current working directory)") + cmd.Flags().StringVar(&folderPath, "folderPath", "spconfig-exports", "Deprecated: use --folder-path") + cmd.Flags().MarkDeprecated("folderPath", "use --folder-path") + cmd.Flags().MarkHidden("folderPath") cmd.Flags().BoolVarP(&wait, "wait", "w", false, "Wait for the export job to finish, and then download the results") return cmd diff --git a/cmd/transform/create.go b/cmd/transform/create.go index fdcf17b0..a0127788 100644 --- a/cmd/transform/create.go +++ b/cmd/transform/create.go @@ -20,9 +20,9 @@ func newCreateCommand() *cobra.Command { var filepath string cmd := &cobra.Command{ Use: "create", - Short: "Create an Identity Security Cloud transform from a file", - Long: "\nCreate an Identity Security Cloud transform from a file\n\n", - Example: "sail transform c -f /path/to/transform.json\nsail transform c < /path/to/transform.json\necho /path/to/transform.json | sail transform c", + Short: "Create a transform from a file", + Long: "\nCreate a new transform in Identity Security Cloud from a JSON file.\nThe file can be specified with the --file flag, piped via stdin,\nor redirected from a file.\n", + Example: " sail transform create -f /path/to/transform.json\n sail transform create < /path/to/transform.json", Aliases: []string{"c"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/transform/delete.go b/cmd/transform/delete.go index 96ce5985..d7b3d2d0 100644 --- a/cmd/transform/delete.go +++ b/cmd/transform/delete.go @@ -12,9 +12,9 @@ import ( func newDeleteCommand() *cobra.Command { cmd := &cobra.Command{ Use: "delete", - Short: "Delete an Identity Security Cloud transform", - Long: "\nDelete an Identity Security Cloud transform\n\n", - Example: "sail transform delete 03d5187b-ab96-402c-b5a1-40b74285d77a", + Short: "Delete one or more transforms", + Long: "\nDelete one or more transforms from Identity Security Cloud by ID.\nMultiple transform IDs can be provided as arguments.\n", + Example: " sail transform delete 03d5187b-ab96-402c-b5a1-40b74285d77a", Aliases: []string{"d"}, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/transform/download.go b/cmd/transform/download.go index 4cfeeff5..3b2587f7 100644 --- a/cmd/transform/download.go +++ b/cmd/transform/download.go @@ -18,9 +18,9 @@ func newDownloadCommand() *cobra.Command { var destination string cmd := &cobra.Command{ Use: "download", - Short: "Download all transforms from Identity Security Cloud", - Long: "\nDownload all transforms from Identity Security Cloud\n\n", - Example: "sail transform download -d transform_files | sail transform dl", + Short: "Download all transforms", + Long: "\nDownload all transforms from Identity Security Cloud and save\nthem as individual JSON files. Use the --destination flag to\nspecify the output directory.\n", + Example: " sail transform download\n sail transform download -d transform_files", Aliases: []string{"dl"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/transform/list.go b/cmd/transform/list.go index 5016a1fc..5718463f 100644 --- a/cmd/transform/list.go +++ b/cmd/transform/list.go @@ -15,9 +15,9 @@ import ( func newListCommand() *cobra.Command { return &cobra.Command{ Use: "list", - Short: "List all transforms in Identity Security Cloud", - Long: "\nList all transforms in Identity Security Cloud\n\n", - Example: "sail transform list | sail transform ls", + Short: "List all transforms", + Long: "\nList all transforms configured in Identity Security Cloud\nand display them in a table.\n", + Example: " sail transform list\n sail transform ls", Aliases: []string{"ls"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/transform/preview.go b/cmd/transform/preview.go index 95042f7f..3986cda9 100644 --- a/cmd/transform/preview.go +++ b/cmd/transform/preview.go @@ -34,9 +34,9 @@ func newPreviewCommand() *cobra.Command { var identityPreview *v2024.IdentityPreviewResponse cmd := &cobra.Command{ Use: "preview", - Short: "Preview a transform result in Identity Security Cloud", - Long: "\nPreview a transform result in Identity Security Cloud\n\n", - Example: "sail transform preview | sail transform pre", + Short: "Preview a transform result", + Long: "\nPreview the result of a transform against an identity\nin Identity Security Cloud. The transform is temporarily\ncreated, evaluated, and then removed.\n", + Example: " sail transform preview\n sail transform preview -f /path/to/transform.json", Aliases: []string{"pre"}, Args: cobra.OnlyValidArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/transform/transform.go b/cmd/transform/transform.go index 7c78b0c0..7e7b5df1 100644 --- a/cmd/transform/transform.go +++ b/cmd/transform/transform.go @@ -13,9 +13,9 @@ const ( func NewTransformCommand() *cobra.Command { cmd := &cobra.Command{ Use: "transform", - Short: "Manage transforms in Identity Security Cloud", - Long: "\nManage transforms in Identity Security Cloud\n\n", - Example: "sail transform | sail tran", + Short: "Manage transforms", + Long: "\nManage transforms in Identity Security Cloud.\n\nTransforms allow you to manipulate attribute data during provisioning\nand aggregation. Use subcommands to list, create, update, delete,\ndownload, or preview transforms.\n", + Example: " sail transform\n sail tran", Aliases: []string{"tran"}, Run: func(cmd *cobra.Command, args []string) { cmd.Help() diff --git a/cmd/transform/update.go b/cmd/transform/update.go index 53bdf095..670f6863 100644 --- a/cmd/transform/update.go +++ b/cmd/transform/update.go @@ -17,9 +17,9 @@ import ( func newUpdateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "update", - Short: "Update a transform in Identity Security Cloud from a file", - Long: "\nUpdate a transform in Identity Security Cloud from a file\n\n", - Example: "sail transform update --file ./assets/demo_update.json\nsail transform u -f /path/to/transform.json\nsail transform u < /path/to/transform.json\necho /path/to/transform.json | sail transform u", + Short: "Update a transform from a file", + Long: "\nUpdate an existing transform in Identity Security Cloud from a JSON file.\nThe file can be specified with the --file flag, piped via stdin,\nor redirected from a file.\n", + Example: " sail transform update -f /path/to/transform.json\n sail transform update < /path/to/transform.json", Aliases: []string{"u"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/va/collect.go b/cmd/va/collect.go index b0e11862..e3ac5b82 100644 --- a/cmd/va/collect.go +++ b/cmd/va/collect.go @@ -7,7 +7,7 @@ import ( "time" "github.com/charmbracelet/log" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/sailpoint-oss/sailpoint-cli/internal/va" "github.com/spf13/cobra" @@ -17,7 +17,7 @@ import ( //go:embed collect.md var collectHelp string -func newCollectCommand(term terminal.Terminal) *cobra.Command { +func newCollectCommand() *cobra.Command { help := util.ParseHelp(collectHelp) var credentials []string var output string @@ -31,6 +31,9 @@ func newCollectCommand(term terminal.Terminal) *cobra.Command { Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var err error + if cmd.Flags().Changed("passwords") { + log.Warn("Passing passwords as flags can expose them in shell history and process listings. Omit --passwords to use the secure prompt.") + } logFiles := []string{"/home/sailpoint/log/ccg.log", "/home/sailpoint/log/charon.log"} configFiles := []string{"/home/sailpoint/proxy.yaml", "/etc/systemd/network/static.network", "/etc/resolv.conf"} @@ -67,7 +70,7 @@ func newCollectCommand(term terminal.Terminal) *cobra.Command { } if password == "" { - password, err = term.PromptPassword("Please enter the password for " + endpoint) + password, err = tui.Password("Enter password for " + endpoint) if err != nil { return err } @@ -94,6 +97,7 @@ func newCollectCommand(term terminal.Terminal) *cobra.Command { cmd.Flags().BoolVarP(&logs, "log", "l", false, "Retrieve log files") cmd.Flags().BoolVarP(&config, "config", "c", false, "Retrieve config files") cmd.Flags().StringArrayVarP(&credentials, "passwords", "p", []string{}, "Passwords for the servers in the same order that the servers are listed as arguments") + cmd.Flags().MarkDeprecated("passwords", "omit the flag to use the secure prompt") cmd.MarkFlagsMutuallyExclusive("config", "log") diff --git a/cmd/va/get.go b/cmd/va/get.go index 108b1655..99e98107 100644 --- a/cmd/va/get.go +++ b/cmd/va/get.go @@ -20,7 +20,7 @@ func newGetCommand() *cobra.Command { help := util.ParseHelp(getHelp) cmd := &cobra.Command{ Use: "get", - Short: "Get a virtual appliance configuration from Identity Security Cloud", + Short: "Get a virtual appliance configuration", Long: help.Long, Example: help.Example, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/va/list.go b/cmd/va/list.go index d02f3e3a..697e3b94 100644 --- a/cmd/va/list.go +++ b/cmd/va/list.go @@ -20,7 +20,7 @@ func newListCommand() *cobra.Command { help := util.ParseHelp(listHelp) cmd := &cobra.Command{ Use: "list", - Short: "List the virtual appliances configured in Identity Security Cloud", + Short: "List all virtual appliances", Long: help.Long, Example: help.Example, Args: cobra.NoArgs, diff --git a/cmd/va/parse.go b/cmd/va/parse.go index 5d38ff05..19d14813 100644 --- a/cmd/va/parse.go +++ b/cmd/va/parse.go @@ -101,7 +101,8 @@ func saveCanalLine(bytes []byte, dir string) { } f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - panic(err) + log.Error("failed to open log file", "file", filename, "error", err) + return } cache[filename] = f } @@ -109,7 +110,8 @@ func saveCanalLine(bytes []byte, dir string) { fileWriter := bufio.NewWriter(f) _, writeErr := fileWriter.WriteString(string(bytes)) if writeErr != nil { - panic(writeErr) + log.Error("failed to write to log file", "file", filename, "error", writeErr) + return } fileWriter.Flush() } diff --git a/cmd/va/troubleshoot.go b/cmd/va/troubleshoot.go index e926c2a3..77f17e03 100644 --- a/cmd/va/troubleshoot.go +++ b/cmd/va/troubleshoot.go @@ -10,13 +10,13 @@ import ( "path" "github.com/fatih/color" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/va" "github.com/spf13/cobra" "github.com/vbauerster/mpb/v8" ) -func NewTroubleshootCmd(term terminal.Terminal) *cobra.Command { +func NewTroubleshootCmd() *cobra.Command { var output string cmd := &cobra.Command{ Use: "troubleshoot", @@ -32,7 +32,10 @@ func NewTroubleshootCmd(term terminal.Terminal) *cobra.Command { var credentials []string for credential := 0; credential < len(args); credential++ { - password, _ := term.PromptPassword(fmt.Sprintf("Enter Password for %v:", args[credential])) + password, err := tui.Password(fmt.Sprintf("Enter password for %v", args[credential])) + if err != nil { + return err + } credentials = append(credentials, password) } @@ -71,7 +74,6 @@ func NewTroubleshootCmd(term terminal.Terminal) *cobra.Command { }} - cmd.Flags().StringP("endpoint", "e", "", "Host to troubleshoot") cmd.Flags().StringVarP(&output, "output", "o", "", "Path to save the log file") return cmd diff --git a/cmd/va/update.go b/cmd/va/update.go index 8edcec9c..626f29b2 100644 --- a/cmd/va/update.go +++ b/cmd/va/update.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/charmbracelet/log" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/sailpoint-oss/sailpoint-cli/internal/va" "github.com/spf13/cobra" @@ -33,7 +33,7 @@ func updateAndRebootVA(endpoint, password string) { fmt.Println() } -func newUpdateCommand(term terminal.Terminal) *cobra.Command { +func newUpdateCommand() *cobra.Command { help := util.ParseHelp(updateHelp) var credentials []string cmd := &cobra.Command{ @@ -43,6 +43,9 @@ func newUpdateCommand(term terminal.Terminal) *cobra.Command { Example: help.Example, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("passwords") { + log.Warn("Passing passwords as flags can expose them in shell history and process listings. Omit --passwords to use the secure prompt.") + } for i, endpoint := range args { var password string @@ -51,7 +54,11 @@ func newUpdateCommand(term terminal.Terminal) *cobra.Command { } if password == "" { - password, _ = term.PromptPassword("Enter password for " + endpoint + ":") + var err error + password, err = tui.Password("Enter password for " + endpoint) + if err != nil { + return err + } } updateAndRebootVA(endpoint, password) @@ -60,7 +67,8 @@ func newUpdateCommand(term terminal.Terminal) *cobra.Command { }, } - cmd.Flags().StringArrayVarP(&credentials, "Passwords", "p", []string{}, "You can enter the passwords for the servers in the same order that the servers are listed as arguments") + cmd.Flags().StringArrayVarP(&credentials, "passwords", "p", []string{}, "Passwords for the servers in the same order that the servers are listed as arguments") + cmd.Flags().MarkDeprecated("passwords", "omit the flag to use the secure prompt") return cmd } diff --git a/cmd/va/va.go b/cmd/va/va.go index d0f64df2..1dd30f9a 100644 --- a/cmd/va/va.go +++ b/cmd/va/va.go @@ -4,7 +4,6 @@ package va import ( _ "embed" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" "github.com/sailpoint-oss/sailpoint-cli/internal/util" "github.com/spf13/cobra" ) @@ -12,7 +11,7 @@ import ( //go:embed va.md var vaHelp string -func NewVACommand(term terminal.Terminal) *cobra.Command { +func NewVACommand() *cobra.Command { help := util.ParseHelp(vaHelp) cmd := &cobra.Command{ Use: "va", @@ -25,12 +24,12 @@ func NewVACommand(term terminal.Terminal) *cobra.Command { } cmd.AddCommand( - newCollectCommand(term), - // newTroubleshootCommand(), + newCollectCommand(), newGetCommand(), newParseCommand(), - newUpdateCommand(term), + newUpdateCommand(), newListCommand(), + NewTroubleshootCmd(), ) return cmd diff --git a/cmd/workflow/create.go b/cmd/workflow/create.go index 02a0c884..3821c128 100644 --- a/cmd/workflow/create.go +++ b/cmd/workflow/create.go @@ -24,7 +24,7 @@ func newCreateCommand() *cobra.Command { var directory bool cmd := &cobra.Command{ Use: "create [-f file1 file2 ... | -d workflowDirectory ]", - Short: "Create workflows in Identity Security Cloud", + Short: "Create workflows from files", Long: help.Long, Example: help.Example, Aliases: []string{"cr"}, diff --git a/cmd/workflow/create.md b/cmd/workflow/create.md index 832ad155..59f8d472 100644 --- a/cmd/workflow/create.md +++ b/cmd/workflow/create.md @@ -2,7 +2,7 @@ # Create Create workflows in Identity Security Cloud. -## API References: +## API Reference - https://developer.sailpoint.com/docs/api/beta/create-workflow ==== diff --git a/cmd/workflow/crud_test.go b/cmd/workflow/crud_test.go new file mode 100644 index 00000000..5409e84a --- /dev/null +++ b/cmd/workflow/crud_test.go @@ -0,0 +1,129 @@ +package workflow + +import ( + "bytes" + "context" + "testing" + + beta "github.com/sailpoint-oss/golang-sdk/v2/api_beta" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/testutil" +) + +func TestWorkflowCRUD(t *testing.T) { + testutil.RequireLiveCredentials(t) + testutil.SetJSONOutput(t) + + owner := requireWorkflowOwner(t) + name := testutil.UniqueName("workflow") + updatedDescription := "updated by sail CLI live CRUD test" + dir := t.TempDir() + + createPath := testutil.WriteJSON(t, dir, "workflow-create.json", map[string]any{ + "name": name, + "description": "created by sail CLI live CRUD test", + "enabled": false, + "owner": map[string]any{ + "type": "IDENTITY", + "id": owner.Id, + "name": owner.Name, + }, + }) + + createCmd := newCreateCommand() + createOut := new(bytes.Buffer) + createCmd.SetOut(createOut) + createCmd.SetArgs([]string{createPath}) + createCmd.Flags().Set("file", "true") + + if err := createCmd.Execute(); err != nil { + testutil.SkipIfFeatureUnavailable(t, err) + t.Fatalf("workflow create failed: %v", err) + } + + created := testutil.DecodeJSON[[]beta.Workflow](t, createOut.String()) + if len(created) != 1 || created[0].GetId() == "" { + t.Fatalf("expected one created workflow with an ID, got %#v", created) + } + workflowID := created[0].GetId() + defer deleteWorkflow(t, workflowID) + + getWorkflowAndAssert(t, workflowID, name) + + updatePath := testutil.WriteJSON(t, dir, "workflow-update.json", created[0]) + updated := created[0] + updated.SetDescription(updatedDescription) + updatePath = testutil.WriteJSON(t, dir, "workflow-update.json", updated) + + updateCmd := newUpdateCommand() + updateOut := new(bytes.Buffer) + updateCmd.SetOut(updateOut) + updateCmd.SetArgs([]string{updatePath}) + updateCmd.Flags().Set("file", "true") + + if err := updateCmd.Execute(); err != nil { + t.Fatalf("workflow update failed: %v", err) + } + + updatedWorkflow := testutil.DecodeJSON[beta.Workflow](t, updateOut.String()) + if updatedWorkflow.GetDescription() != updatedDescription { + t.Fatalf("expected updated description %q, got %q", updatedDescription, updatedWorkflow.GetDescription()) + } + + getWorkflowAndAssert(t, workflowID, name) +} + +type workflowOwner struct { + Id string + Name string +} + +func requireWorkflowOwner(t *testing.T) workflowOwner { + t.Helper() + + apiClient, err := config.InitAPIClient(false) + if err != nil { + t.Fatalf("failed to initialize API client: %v", err) + } + identities, resp, err := apiClient.V2024.IdentitiesAPI.ListIdentities(context.TODO()).Limit(1).Execute() + if err != nil { + testutil.SkipIfFeatureUnavailable(t, err) + t.Fatalf("failed to list identities for workflow owner: %v (response: %v)", err, resp) + } + if len(identities) == 0 || identities[0].GetId() == "" { + t.Skip("skipping workflow CRUD test: no identity available to use as workflow owner") + } + return workflowOwner{Id: identities[0].GetId(), Name: identities[0].GetName()} +} + +func getWorkflowAndAssert(t *testing.T, workflowID string, expectedName string) { + t.Helper() + + getCmd := newGetCommand() + getOut := new(bytes.Buffer) + getCmd.SetOut(getOut) + getCmd.SetArgs([]string{workflowID}) + if err := getCmd.Execute(); err != nil { + t.Fatalf("workflow get failed: %v", err) + } + workflows := testutil.DecodeJSON[[]beta.Workflow](t, getOut.String()) + if len(workflows) != 1 { + t.Fatalf("expected one workflow from get, got %d", len(workflows)) + } + if workflows[0].GetId() != workflowID { + t.Fatalf("expected workflow ID %q, got %q", workflowID, workflows[0].GetId()) + } + if workflows[0].GetName() != expectedName { + t.Fatalf("expected workflow name %q, got %q", expectedName, workflows[0].GetName()) + } +} + +func deleteWorkflow(t *testing.T, workflowID string) { + t.Helper() + + deleteCmd := newDeleteCommand() + deleteCmd.SetArgs([]string{workflowID}) + if err := deleteCmd.Execute(); err != nil { + t.Logf("failed to clean up workflow %s: %v", workflowID, err) + } +} diff --git a/cmd/workflow/delete.go b/cmd/workflow/delete.go index 0325ebe8..4d6238fa 100644 --- a/cmd/workflow/delete.go +++ b/cmd/workflow/delete.go @@ -19,7 +19,7 @@ func newDeleteCommand() *cobra.Command { help := util.ParseHelp(deleteHelp) cmd := &cobra.Command{ Use: "delete workflowID... ", - Short: "Delete a workflow in Identity Security Cloud", + Short: "Delete one or more workflows", Long: help.Long, Example: help.Example, Aliases: []string{"del"}, diff --git a/cmd/workflow/delete.md b/cmd/workflow/delete.md index c1de5d76..5ee780a3 100644 --- a/cmd/workflow/delete.md +++ b/cmd/workflow/delete.md @@ -2,7 +2,7 @@ # Delete Delete a workflow in Identity Security Cloud. You can delete multiple workflows at once, and you can delete a set of workflows specified in a file. -## API References: +## API Reference - https://developer.sailpoint.com/docs/api/beta/delete-workflow ==== diff --git a/cmd/workflow/download.go b/cmd/workflow/download.go index a5eeeef5..67cc5f8a 100644 --- a/cmd/workflow/download.go +++ b/cmd/workflow/download.go @@ -22,7 +22,7 @@ func newDownloadCommand() *cobra.Command { var folderPath string cmd := &cobra.Command{ Use: "download", - Short: "Download workflows from Identity Security Cloud", + Short: "Download all workflows", Long: help.Long, Example: help.Example, Aliases: []string{"down"}, diff --git a/cmd/workflow/download.md b/cmd/workflow/download.md index 7ade4462..bd83ca7f 100644 --- a/cmd/workflow/download.md +++ b/cmd/workflow/download.md @@ -2,7 +2,7 @@ # Download Downloads all workflows from Identity Security Cloud. By default, the downloaded workflows are located in the folder, "workflows". You can specify a folder to download the workflows to, as shown in the example. -## API References: +## API Reference - https://developer.sailpoint.com/docs/api/beta/list-workflows diff --git a/cmd/workflow/get.go b/cmd/workflow/get.go index 3078409e..5999dd18 100644 --- a/cmd/workflow/get.go +++ b/cmd/workflow/get.go @@ -20,7 +20,7 @@ func newGetCommand() *cobra.Command { help := util.ParseHelp(getHelp) cmd := &cobra.Command{ Use: "get", - Short: "Get workflows in Identity Security Cloud", + Short: "Get a workflow by ID", Long: help.Long, Example: help.Example, Aliases: []string{"g"}, diff --git a/cmd/workflow/get.md b/cmd/workflow/get.md index 3fe8fdfa..513955c1 100644 --- a/cmd/workflow/get.md +++ b/cmd/workflow/get.md @@ -1,17 +1,17 @@ ==Long== # Get - -Get a workflow from Identity Security Cloud by ID. -## API References +Get a workflow from Identity Security Cloud by ID. + +## API Reference - https://developer.sailpoint.com/docs/api/beta/get-workflow ==== ==Example== ```bash - sail workflow get - sail workflow get f691874a-c5a5-426d-9dd4-33129072bafa +sail workflow get +sail workflow get f691874a-c5a5-426d-9dd4-33129072bafa ``` ==== \ No newline at end of file diff --git a/cmd/workflow/list.go b/cmd/workflow/list.go index 40d61209..fa59adcc 100644 --- a/cmd/workflow/list.go +++ b/cmd/workflow/list.go @@ -19,7 +19,7 @@ func newListCommand() *cobra.Command { help := util.ParseHelp(listHelp) cmd := &cobra.Command{ Use: "list", - Short: "List all workflows in Identity Security Cloud", + Short: "List all workflows", Long: help.Long, Example: help.Example, Aliases: []string{"ls"}, diff --git a/cmd/workflow/list.md b/cmd/workflow/list.md index d838dcc9..48b661d3 100644 --- a/cmd/workflow/list.md +++ b/cmd/workflow/list.md @@ -1,16 +1,16 @@ ==Long== # List - + List workflows from Identity Security Cloud. -## API References +## API Reference - https://developer.sailpoint.com/docs/api/beta/list-workflows ==== ==Example== ```bash - sail workflow list +sail workflow list ``` ==== \ No newline at end of file diff --git a/cmd/workflow/update.go b/cmd/workflow/update.go index 8f7b6641..2883b6da 100644 --- a/cmd/workflow/update.go +++ b/cmd/workflow/update.go @@ -23,7 +23,7 @@ func newUpdateCommand() *cobra.Command { var directory bool cmd := &cobra.Command{ Use: "update", - Short: "Update a workflow in Identity Security Cloud", + Short: "Update workflows from files", Long: help.Long, Example: help.Example, Aliases: []string{"up"}, diff --git a/cmd/workflow/update.md b/cmd/workflow/update.md index 11bb2950..f7af4ffe 100644 --- a/cmd/workflow/update.md +++ b/cmd/workflow/update.md @@ -6,7 +6,7 @@ Update a workflow in Identity Security Cloud. Arguments can be a list of directories or files. You can update multiple workflows by specifying multiple file paths as arguments. If a directory is specified, all JSON files in the directory will be parsed and the workflows uploaded. -## API References: +## API Reference - https://developer.sailpoint.com/docs/api/beta/update-workflow ==== diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index f0f62887..75a422ee 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -15,7 +15,7 @@ func NewWorkflowCommand() *cobra.Command { help := util.ParseHelp(workflowHelp) cmd := &cobra.Command{ Use: "workflow", - Short: "Manage workflows in Identity Security Cloud", + Short: "Manage workflows", Long: help.Long, Example: help.Example, Aliases: []string{"work"}, diff --git a/go.mod b/go.mod index 0e21ed7f..1af1619b 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,10 @@ toolchain go1.24.1 require ( github.com/bhmj/jsonslice v1.1.3 - github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/glamour v0.10.0 + github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/log v0.4.2 github.com/fatih/color v1.18.0 @@ -51,13 +52,16 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bhmj/xpression v0.9.4 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250818131617-61d774aefe53 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect @@ -73,6 +77,7 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect diff --git a/go.sum b/go.sum index 01734ec3..e0fae35f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= @@ -16,22 +18,26 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bhmj/jsonslice v1.1.3 h1:aVYmtuQ8Gg1L3mAOFq3NMtfip8teUvC+DNArhv7IRDY= github.com/bhmj/jsonslice v1.1.3/go.mod h1:O3ZoA0zdEefdbk1dkU5aWPOA36zQhhS/HV6RQFLTlnU= github.com/bhmj/xpression v0.9.4 h1:X3vLAUX4UjXOC03B3lk65gPWVU/a1Im4Mt3rCEkEcz8= github.com/bhmj/xpression v0.9.4/go.mod h1:fA/TPgJgCLPNt9zStgpA1bdwLoYDhKHt7kee+1vTqME= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= @@ -40,14 +46,26 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7 github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.0.0-20250818131617-61d774aefe53 h1:90+RNUBLKTSUA57LjHuGDvoEbT151zEH6mCeKCIieRE= github.com/charmbracelet/x/exp/slice v0.0.0-20250818131617-61d774aefe53/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -55,6 +73,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -110,6 +130,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mrz1836/go-sanitize v1.5.2 h1:nxfEZXR6eGUBP1e1zhrUiRd0FFiaw+dduBFtgK4+GLc= @@ -128,7 +150,6 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8= github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -200,8 +221,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= @@ -222,8 +241,6 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -236,8 +253,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -256,8 +271,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -269,8 +282,6 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -282,8 +293,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 00000000..48e8c2dc --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,212 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/keyring" + "gopkg.in/square/go-jose.v2/jwt" +) + +// GetToken obtains a valid access token for the given environment. +// It checks cached tokens first, refreshes if possible, and performs a full +// login only when necessary. The setBaseURL callback is invoked if the OAuth +// flow provides an updated base URL. +func GetToken(authType, env, baseURL, tenantURL, tokenURL string, setBaseURL func(string)) (string, error) { + if err := ValidateAuth(authType, env, baseURL, tenantURL); err != nil { + return "", err + } + + var token string + + switch authType { + case "pat": + t, err := getPatToken(env, tokenURL) + if err != nil { + return "", err + } + token = t + + case "oauth": + t, err := getOAuthToken(env, baseURL, tenantURL, setBaseURL) + if err != nil { + return "", err + } + token = t + + default: + return "", fmt.Errorf("invalid authtype '%s' configured", authType) + } + + if err := CheckToken(token); err != nil { + return "", err + } + + return token, nil +} + +func getPatToken(env, tokenURL string) (string, error) { + authExpiry, _ := GetPatTokenExpiry(env) + + if authExpiry.After(time.Now()) { + return GetPatToken(env) + } + + clientID, err := GetPatClientID(env) + if err != nil { + return "", err + } + clientSecret, err := GetPatClientSecret(env) + if err != nil { + return "", err + } + + set, err := PATLogin(tokenURL, clientID, clientSecret) + if err != nil { + return "", err + } + + if err := CachePAT(env, set); err != nil { + log.Warn("Failed to cache PAT token in keyring", "error", err) + } + + return set.AccessToken, nil +} + +func getOAuthToken(env, baseURL, tenantURL string, setBaseURL func(string)) (string, error) { + authExpiry, _ := GetOAuthTokenExpiry(env) + refreshExpiry, _ := GetOAuthRefreshExpiry(env) + + if authExpiry.After(time.Now()) { + return GetOAuthToken(env) + } + + if refreshExpiry.After(time.Now()) { + set, err := RefreshOAuth(env, baseURL, tenantURL) + if err != nil { + return "", err + } + if err := CacheOAuth(env, set); err != nil { + log.Warn("Failed to cache OAuth token in keyring", "error", err) + } + return set.AccessToken, nil + } + + set, err := OAuthLogin(baseURL) + if err != nil { + return "", err + } + + if set.BaseURL != "" && setBaseURL != nil { + setBaseURL(set.BaseURL) + } + + if err := CacheOAuth(env, set); err != nil { + log.Warn("Failed to cache OAuth token in keyring", "error", err) + } + + return set.AccessToken, nil +} + +// ValidateAuth checks that the necessary configuration exists for the given auth type. +func ValidateAuth(authType, env, baseURL, tenantURL string) error { + var errors int + + supportsSecrets := keyring.IsSupported() + + switch authType { + case "pat": + if !supportsSecrets { + log.Warn("Secrets storage is not currently functional on this platform, PAT will only work with environment variables") + } + if baseURL == "" { + log.Error("configured environment is missing BaseURL") + errors++ + } + patClientID, err := GetPatClientID(env) + if err != nil { + return err + } + patClientSecret, err := GetPatClientSecret(env) + if err != nil { + return err + } + if patClientID == "" { + log.Error("configured environment is missing PAT ClientID") + errors++ + } + if patClientSecret == "" { + log.Error("configured environment is missing PAT ClientSecret") + errors++ + } + + case "oauth": + if !supportsSecrets { + log.Warn("Secrets storage is not currently functional on this platform, every command will reauthenticate with OAuth") + } + if baseURL == "" { + log.Error("configured environment is missing BaseURL") + errors++ + } + if tenantURL == "" { + log.Error("configured environment is missing TenantURL") + errors++ + } + + default: + log.Error("invalid authtype configured", "authtype", authType) + errors++ + } + + if errors > 0 { + return fmt.Errorf("configuration invalid, errors: %v", errors) + } + + return nil +} + +// CheckToken parses a JWT and logs identity info, warning if user context is missing. +func CheckToken(tokenString string) error { + var claims map[string]interface{} + + token, err := jwt.ParseSigned(tokenString) + if err != nil { + return err + } + + token.UnsafeClaimsWithoutVerification(&claims) + + if claims["user_name"] == nil { + log.Warn("It looks like the token you are using is missing a user context, this will cause many of the CLI commands to fail.") + } + + log.Debug("Token Debug Info", "user_name", claims["user_name"], "org", claims["org"], "pod", claims["pod"]) + + return nil +} + +// GetTokenClaims parses a JWT and returns the claims map (for status display). +func GetTokenClaims(tokenString string) (map[string]interface{}, error) { + var claims map[string]interface{} + + token, err := jwt.ParseSigned(tokenString) + if err != nil { + return nil, err + } + + token.UnsafeClaimsWithoutVerification(&claims) + return claims, nil +} + +// Logout clears all cached tokens for the given environment and auth type. +func Logout(authType, env string) error { + switch authType { + case "pat": + return ResetCachePAT(env) + case "oauth": + return ResetCacheOAuth(env) + default: + return fmt.Errorf("unknown auth type: %s", authType) + } +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 00000000..f9ebb3c0 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,36 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestValidateAuthRejectsUnknownAuthType(t *testing.T) { + err := ValidateAuth("saml", "default", "https://tenant.api.identitynow.com", "https://tenant.identitynow.com") + if err == nil { + t.Fatal("expected invalid auth type to fail") + } + if !strings.Contains(err.Error(), "configuration invalid") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateAuthOAuthRequiresBaseURLAndTenantURL(t *testing.T) { + err := ValidateAuth("oauth", "default", "", "") + if err == nil { + t.Fatal("expected missing OAuth URLs to fail") + } + if !strings.Contains(err.Error(), "configuration invalid") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGetTokenRejectsInvalidAuthType(t *testing.T) { + _, err := GetToken("bad", "default", "https://tenant.api.identitynow.com", "https://tenant.identitynow.com", "https://tenant.api.identitynow.com/oauth/token", nil) + if err == nil { + t.Fatal("expected invalid auth type to fail") + } + if !strings.Contains(err.Error(), "configuration invalid") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go new file mode 100644 index 00000000..e22b2c7e --- /dev/null +++ b/internal/auth/oauth.go @@ -0,0 +1,386 @@ +package auth + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/keyring" + "github.com/sailpoint-oss/sailpoint-cli/internal/redact" + "github.com/skratchdot/open-golang/open" + "gopkg.in/square/go-jose.v2/jwt" +) + +const ( + oauthAccessTokenService = "environments.oauth.accesstoken" + oauthExpiryService = "environments.oauth.expiry" + oauthRefreshTokenService = "environments.oauth.refreshtoken" + oauthRefreshExpiryService = "environments.oauth.refreshexpiry" + + OAuthClientID = "sailpoint-cli" + AuthLambdaBaseURL = "https://nug87yusrg.execute-api.us-east-1.amazonaws.com/Prod/sailapps" + AuthLambdaAuthURL = AuthLambdaBaseURL + "/auth" + AuthLambdaTokenURL = AuthLambdaBaseURL + "/auth/token" + AuthLambdaRefreshURL = AuthLambdaBaseURL + "/auth/refresh" +) + +func confirmationCodeFromID(id string) string { + id = strings.TrimSpace(id) + if len(id) < 8 { + return strings.ToUpper(id) + } + + suffix := id[len(id)-8:] + return strings.ToUpper(suffix[:4] + "-" + suffix[4:]) +} + +func newOAuthTokenRequest(tokenURL, id, pickupSecret string) (*http.Request, error) { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s", tokenURL, id), nil) + if err != nil { + return nil, err + } + if pickupSecret != "" { + req.Header.Set("Authorization", "Bearer "+pickupSecret) + } + return req, nil +} + +// GetOAuthToken retrieves the cached OAuth access token from the keyring. +func GetOAuthToken(env string) (string, error) { + return keyring.Get(oauthAccessTokenService, env) +} + +// GetOAuthTokenExpiry retrieves the cached OAuth token expiry. +func GetOAuthTokenExpiry(env string) (time.Time, error) { + return keyring.GetTime(oauthExpiryService, env) +} + +// GetOAuthRefreshExpiry retrieves the cached OAuth refresh token expiry. +func GetOAuthRefreshExpiry(env string) (time.Time, error) { + return keyring.GetTime(oauthRefreshExpiryService, env) +} + +// GetRefreshToken retrieves the cached OAuth refresh token. +func GetRefreshToken(env string) (string, error) { + return keyring.Get(oauthRefreshTokenService, env) +} + +// CacheOAuth stores the full OAuth token set in the keyring. +func CacheOAuth(env string, set TokenSet) error { + if err := keyring.Set(oauthAccessTokenService, env, set.AccessToken); err != nil { + return err + } + if err := keyring.SetTime(oauthExpiryService, env, set.AccessExpiry); err != nil { + return err + } + if err := keyring.Set(oauthRefreshTokenService, env, set.RefreshToken); err != nil { + return err + } + return keyring.SetTime(oauthRefreshExpiryService, env, set.RefreshExpiry) +} + +// ResetCacheOAuth clears all cached OAuth tokens for an environment. +func ResetCacheOAuth(env string) error { + for _, svc := range []string{ + oauthAccessTokenService, + oauthExpiryService, + oauthRefreshTokenService, + oauthRefreshExpiryService, + } { + if err := keyring.Delete(svc, env); err != nil { + log.Debug("Failed to delete keyring entry", "service", svc, "env", env, "error", err) + } + } + return nil +} + +// DeleteAllOAuthSecrets removes all OAuth-related keyring entries for an environment. +func DeleteAllOAuthSecrets(env string) { + _ = keyring.Delete(oauthAccessTokenService, env) + _ = keyring.Delete(oauthExpiryService, env) + _ = keyring.Delete(oauthRefreshTokenService, env) + _ = keyring.Delete(oauthRefreshExpiryService, env) +} + +// OAuthLogin performs the full OAuth browser-based authentication flow. +// baseURL is the SailPoint API base URL for this environment. +// Returns the token set and potentially an updated base URL. +func OAuthLogin(baseURL string) (TokenSet, error) { + var set TokenSet + + privateKey, publicKeyBase64, err := generateKeyPair() + if err != nil { + return set, fmt.Errorf("failed to generate key pair: %v", err) + } + log.Debug("Generated RSA key pair for OAuth authentication") + + authRequest := AuthRequest{ + APIBaseURL: baseURL, + PublicKey: publicKeyBase64, + } + + requestBody, err := json.Marshal(authRequest) + if err != nil { + return set, fmt.Errorf("failed to marshal auth request: %v", err) + } + + resp, err := http.Post(AuthLambdaAuthURL, "application/json", bytes.NewBuffer(requestBody)) + if err != nil { + return set, fmt.Errorf("failed to initiate auth with lambda: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return set, fmt.Errorf("auth lambda returned non-200 status: %d, body: %s", resp.StatusCode, redact.Bytes(bodyBytes)) + } + + var authResponse AuthResponse + if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil { + return set, fmt.Errorf("failed to decode auth lambda response: %v", err) + } + + log.Debug("Auth response received", "id", authResponse.ID, "baseURL", authResponse.BaseURL) + + // Track if the server redirected us to a different base URL + if authResponse.BaseURL != "" { + set.BaseURL = authResponse.BaseURL + } + + log.Info("Attempting to open browser for authentication") + if err := open.Run(authResponse.AuthURL); err != nil { + log.Warn("Cannot open automatically, Please manually open OAuth login page below") + fmt.Println(authResponse.AuthURL) + } + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + timeout := time.After(5 * time.Minute) + for { + select { + case <-timeout: + return set, fmt.Errorf("authentication timed out after 5 minutes") + case <-ticker.C: + tokenReq, err := newOAuthTokenRequest(AuthLambdaTokenURL, authResponse.ID, authResponse.PickupSecret) + if err != nil { + return set, fmt.Errorf("failed to create token polling request: %v", err) + } + + tokenResp, err := http.DefaultClient.Do(tokenReq) + if err != nil { + log.Debug("Error polling for token", "error", err) + continue + } + + if tokenResp.StatusCode == http.StatusOK { + var tokenResponse OAuthTokenResponse + if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResponse); err != nil { + tokenResp.Body.Close() + return set, fmt.Errorf("failed to decode token response: %v", err) + } + tokenResp.Body.Close() + + if tokenResponse.BaseURL != "" { + set.BaseURL = tokenResponse.BaseURL + } + + var encryptedTokenData EncryptedTokenData + if err := json.Unmarshal([]byte(tokenResponse.TokenInfo), &encryptedTokenData); err != nil { + return set, fmt.Errorf("failed to parse encrypted token data: %v", err) + } + + decryptedTokenInfo, err := decryptHybridToken(&encryptedTokenData, privateKey) + if err != nil { + return set, fmt.Errorf("failed to decrypt token info: %v", err) + } + + var response RefreshResponse + if err := json.Unmarshal([]byte(decryptedTokenInfo), &response); err != nil { + return set, fmt.Errorf("failed to parse decrypted token info: %v", err) + } + + var accessTokenClaims map[string]interface{} + accToken, err := jwt.ParseSigned(response.AccessToken) + if err != nil { + return set, fmt.Errorf("failed to parse access token: %v", err) + } + accToken.UnsafeClaimsWithoutVerification(&accessTokenClaims) + + var refreshTokenClaims map[string]interface{} + refToken, err := jwt.ParseSigned(response.RefreshToken) + if err != nil { + return set, fmt.Errorf("failed to parse refresh token: %v", err) + } + refToken.UnsafeClaimsWithoutVerification(&refreshTokenClaims) + + set.AccessToken = response.AccessToken + set.AccessExpiry = time.Unix(int64(accessTokenClaims["exp"].(float64)), 0) + set.RefreshToken = response.RefreshToken + set.RefreshExpiry = time.Unix(int64(refreshTokenClaims["exp"].(float64)), 0) + + log.Info("OAuth authentication successful") + return set, nil + } + bodyBytes, _ := io.ReadAll(tokenResp.Body) + if tokenResp.StatusCode == http.StatusUnauthorized { + tokenResp.Body.Close() + return set, fmt.Errorf("token polling unauthorized: %s", redact.Bytes(bodyBytes)) + } + log.Debug("Token not ready", "status", tokenResp.StatusCode, "body", redact.Bytes(bodyBytes)) + tokenResp.Body.Close() + } + } +} + +// RefreshOAuth uses the refresh token to obtain new access and refresh tokens. +func RefreshOAuth(env, baseURL, tenantURL string) (TokenSet, error) { + var set TokenSet + + refreshToken, err := GetRefreshToken(env) + if err != nil { + return set, err + } + + refreshRequest := RefreshRequest{ + RefreshToken: refreshToken, + APIBaseURL: baseURL, + Tenant: tenantURL, + } + + requestBody, err := json.Marshal(refreshRequest) + if err != nil { + return set, fmt.Errorf("failed to marshal refresh request: %v", err) + } + + resp, err := http.Post(AuthLambdaRefreshURL, "application/json", bytes.NewBuffer(requestBody)) + if err != nil { + return set, fmt.Errorf("failed to refresh token: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return set, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, redact.Bytes(bodyBytes)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return set, err + } + + var response RefreshResponse + if err := json.Unmarshal(body, &response); err != nil { + return set, err + } + + if response.AccessToken == "" { + return set, fmt.Errorf("no access token in refresh response") + } + if response.RefreshToken == "" { + return set, fmt.Errorf("no refresh token in refresh response") + } + + var accessTokenClaims map[string]interface{} + accToken, err := jwt.ParseSigned(response.AccessToken) + if err != nil { + return set, err + } + accToken.UnsafeClaimsWithoutVerification(&accessTokenClaims) + + var refreshTokenClaims map[string]interface{} + refToken, err := jwt.ParseSigned(response.RefreshToken) + if err != nil { + return set, err + } + refToken.UnsafeClaimsWithoutVerification(&refreshTokenClaims) + + set = TokenSet{ + AccessToken: response.AccessToken, + AccessExpiry: time.Unix(int64(accessTokenClaims["exp"].(float64)), 0), + RefreshToken: response.RefreshToken, + RefreshExpiry: time.Unix(int64(refreshTokenClaims["exp"].(float64)), 0), + } + + log.Debug("OAuth token refresh successful") + return set, nil +} + +func generateKeyPair() (*rsa.PrivateKey, string, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, "", fmt.Errorf("failed to generate RSA key pair: %v", err) + } + + publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + return nil, "", fmt.Errorf("failed to marshal public key: %v", err) + } + + publicKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyBytes, + }) + + publicKeyBase64 := base64.StdEncoding.EncodeToString(publicKeyPEM) + return privateKey, publicKeyBase64, nil +} + +func decryptHybridToken(encryptedData *EncryptedTokenData, privateKey *rsa.PrivateKey) (string, error) { + encryptedKey, err := base64.StdEncoding.DecodeString(encryptedData.Data.EncryptedKey) + if err != nil { + return "", fmt.Errorf("failed to decode encrypted key: %v", err) + } + + ciphertext, err := base64.StdEncoding.DecodeString(encryptedData.Data.Ciphertext) + if err != nil { + return "", fmt.Errorf("failed to decode ciphertext: %v", err) + } + + iv, err := base64.StdEncoding.DecodeString(encryptedData.Data.IV) + if err != nil { + return "", fmt.Errorf("failed to decode IV: %v", err) + } + + authTag, err := base64.StdEncoding.DecodeString(encryptedData.Data.AuthTag) + if err != nil { + return "", fmt.Errorf("failed to decode auth tag: %v", err) + } + + aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, encryptedKey, nil) + if err != nil { + return "", fmt.Errorf("RSA decryption failed: %v", err) + } + + block, err := aes.NewCipher(aesKey) + if err != nil { + return "", fmt.Errorf("failed to create AES cipher: %v", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %v", err) + } + + ciphertextWithTag := append(ciphertext, authTag...) + + plaintext, err := gcm.Open(nil, iv, ciphertextWithTag, nil) + if err != nil { + return "", fmt.Errorf("AES-GCM decryption failed: %v", err) + } + + return string(plaintext), nil +} diff --git a/internal/config/oauth_test.go b/internal/auth/oauth_test.go similarity index 99% rename from internal/config/oauth_test.go rename to internal/auth/oauth_test.go index a4b9e687..c887b362 100644 --- a/internal/config/oauth_test.go +++ b/internal/auth/oauth_test.go @@ -1,4 +1,4 @@ -package config +package auth import ( "encoding/json" diff --git a/internal/auth/pat.go b/internal/auth/pat.go new file mode 100644 index 00000000..450900eb --- /dev/null +++ b/internal/auth/pat.go @@ -0,0 +1,194 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/keyring" + "github.com/sailpoint-oss/sailpoint-cli/internal/tui" +) + +const ( + patClientIDService = "environments.pat.clientid" + patClientSecretService = "environments.pat.clientsecret" + patAccessTokenService = "environments.pat.accesstoken" + patExpiryService = "environments.pat.expiry" +) + +// GetPatClientID returns the PAT client ID for the given environment, +// checking the SAIL_CLIENT_ID env var first. +func GetPatClientID(env string) (string, error) { + if v := os.Getenv("SAIL_CLIENT_ID"); v != "" { + return v, nil + } + return keyring.Get(patClientIDService, env) +} + +// GetPatClientSecret returns the PAT client secret for the given environment, +// checking the SAIL_CLIENT_SECRET env var first. +func GetPatClientSecret(env string) (string, error) { + if v := os.Getenv("SAIL_CLIENT_SECRET"); v != "" { + return v, nil + } + return keyring.Get(patClientSecretService, env) +} + +// SetPatClientID stores the PAT client ID in the keyring. +func SetPatClientID(env, clientID string) error { + return keyring.Set(patClientIDService, env, clientID) +} + +// SetPatClientSecret stores the PAT client secret in the keyring. +func SetPatClientSecret(env, clientSecret string) error { + return keyring.Set(patClientSecretService, env, clientSecret) +} + +// DeletePatClientID removes the PAT client ID from the keyring. +func DeletePatClientID(env string) error { + return keyring.Delete(patClientIDService, env) +} + +// DeletePatClientSecret removes the PAT client secret from the keyring. +func DeletePatClientSecret(env string) error { + return keyring.Delete(patClientSecretService, env) +} + +// GetPatToken retrieves the cached PAT access token from the keyring. +func GetPatToken(env string) (string, error) { + return keyring.Get(patAccessTokenService, env) +} + +// GetPatTokenExpiry retrieves the cached PAT token expiry from the keyring. +func GetPatTokenExpiry(env string) (time.Time, error) { + return keyring.GetTime(patExpiryService, env) +} + +// CachePAT stores the PAT access token and expiry in the keyring. +func CachePAT(env string, set PATSet) error { + if err := keyring.Set(patAccessTokenService, env, set.AccessToken); err != nil { + return err + } + return keyring.SetTime(patExpiryService, env, set.AccessExpiry) +} + +// ResetCachePAT clears the cached PAT token and expiry from the keyring. +func ResetCachePAT(env string) error { + token, err := GetPatToken(env) + if token != "" && err == nil { + if err := keyring.Delete(patAccessTokenService, env); err != nil { + return err + } + } + expiry, err := GetPatTokenExpiry(env) + if !expiry.IsZero() && err == nil { + if err := keyring.Delete(patExpiryService, env); err != nil { + return err + } + } + return nil +} + +// DeleteAllPatSecrets removes all PAT-related keyring entries for an environment. +func DeleteAllPatSecrets(env string) { + _ = keyring.Delete(patAccessTokenService, env) + _ = keyring.Delete(patExpiryService, env) + _ = keyring.Delete(patClientIDService, env) + _ = keyring.Delete(patClientSecretService, env) +} + +// PATLogin performs a client_credentials token exchange and returns the token set. +func PATLogin(tokenURL, clientID, clientSecret string) (PATSet, error) { + var set PATSet + + uri, err := url.Parse(tokenURL) + if err != nil { + return set, err + } + + query := &url.Values{} + query.Add("grant_type", "client_credentials") + uri.RawQuery = query.Encode() + + data := &url.Values{} + data.Add("client_id", clientID) + data.Add("client_secret", clientSecret) + + ctx := context.TODO() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri.String(), strings.NewReader(data.Encode())) + if err != nil { + return set, err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, err := (&http.Client{}).Do(req) + if err != nil { + return set, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + if resp.StatusCode != http.StatusOK { + return set, fmt.Errorf("failed to retrieve access token. status %s", resp.Status) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return set, err + } + + var tResponse TokenResponse + if err := json.Unmarshal(raw, &tResponse); err != nil { + return set, err + } + + set = PATSet{ + AccessToken: tResponse.AccessToken, + AccessExpiry: time.Now().Add(time.Second * time.Duration(tResponse.ExpiresIn)), + } + return set, nil +} + +// PromptForClientID interactively prompts for a PAT Client ID with validation. +func PromptForClientID() (string, error) { + const maxAttempts = 3 + + for attempt := 1; attempt <= maxAttempts; attempt++ { + clientID, err := tui.Password("Personal Access Token Client ID") + if err != nil { + return "", err + } + if len(clientID) == 36 || len(clientID) == 32 { + log.Debug("Valid Client ID entered", "length", len(clientID)) + return clientID, nil + } + log.Warn("Invalid Client ID length", "got", len(clientID), "expected", "32 or 36", "attempt", fmt.Sprintf("%d/%d", attempt, maxAttempts)) + } + return "", fmt.Errorf("maximum attempts reached for entering Client ID") +} + +// PromptForClientSecret interactively prompts for a PAT Client Secret with validation. +func PromptForClientSecret() (string, error) { + const maxAttempts = 3 + + for attempt := 1; attempt <= maxAttempts; attempt++ { + clientSecret, err := tui.Password("Personal Access Token Client Secret") + if err != nil { + return "", err + } + if len(clientSecret) == 64 { + log.Debug("Valid Client Secret entered") + return clientSecret, nil + } + log.Warn("Invalid Client Secret length", "got", len(clientSecret), "expected", 64, "attempt", fmt.Sprintf("%d/%d", attempt, maxAttempts)) + } + return "", fmt.Errorf("maximum attempts reached for entering Client Secret") +} diff --git a/internal/auth/types.go b/internal/auth/types.go new file mode 100644 index 00000000..1769a52e --- /dev/null +++ b/internal/auth/types.go @@ -0,0 +1,95 @@ +package auth + +import "time" + +// PATSet holds the result of a PAT authentication. +type PATSet struct { + AccessToken string + AccessExpiry time.Time +} + +// TokenSet holds the result of an OAuth authentication (access + refresh). +type TokenSet struct { + AccessToken string + AccessExpiry time.Time + RefreshToken string + RefreshExpiry time.Time + BaseURL string // May be updated by the OAuth flow +} + +// TokenResponse is the response from the /oauth/token endpoint (PAT flow). +type TokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` +} + +// RefreshResponse is the response from the OAuth refresh flow. +type RefreshResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + TenantID string `json:"tenant_id"` + Internal bool `json:"internal"` + Pod string `json:"pod"` + StrongAuthSupported bool `json:"strong_auth_supported"` + Org string `json:"org"` + ClaimsSupported bool `json:"claims_supported"` + IdentityID string `json:"identity_id"` + StrongAuth bool `json:"strong_auth"` + Jti string `json:"jti"` +} + +// AuthRequest represents the request body for initiating OAuth authentication. +type AuthRequest struct { + Tenant string `json:"tenant,omitempty"` + APIBaseURL string `json:"apiBaseURL,omitempty"` + PublicKey string `json:"publicKey"` +} + +// AuthResponse represents the response from the authentication initiation endpoint. +type AuthResponse struct { + AuthURL string `json:"authURL"` + ID string `json:"id"` + BaseURL string `json:"baseURL"` + PickupSecret string `json:"pickupSecret"` + TTL int64 `json:"ttl"` +} + +// OAuthTokenResponse represents the response containing the encrypted token. +type OAuthTokenResponse struct { + ID string `json:"id"` + BaseURL string `json:"baseURL"` + TokenInfo string `json:"tokenInfo"` +} + +// EncryptedTokenData represents the structure of the encrypted token JSON. +type EncryptedTokenData struct { + Version string `json:"version"` + Algorithm struct { + Symmetric string `json:"symmetric"` + Asymmetric string `json:"asymmetric"` + } `json:"algorithm"` + Data struct { + Ciphertext string `json:"ciphertext"` + EncryptedKey string `json:"encryptedKey"` + IV string `json:"iv"` + AuthTag string `json:"authTag"` + } `json:"data"` +} + +// RefreshRequest represents the request body for refreshing OAuth tokens. +type RefreshRequest struct { + RefreshToken string `json:"refreshToken"` + APIBaseURL string `json:"apiBaseURL,omitempty"` + Tenant string `json:"tenant,omitempty"` +} + +// PatConfig holds the per-environment PAT configuration (kept for mapstructure compat). +type PatConfig struct { + ClientID string `mapstructure:"clientid"` + ClientSecret string `mapstructure:"clientsecret"` + AccessToken string `mapstructure:"accesstoken"` + Expiry time.Time `mapstructure:"expiry"` +} diff --git a/internal/client/client.go b/internal/client/client.go index fe9fbca7..5bf1ddf4 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -3,13 +3,14 @@ package client import ( "context" - "fmt" "io" "net/http" "net/http/httputil" "net/url" + "github.com/charmbracelet/log" "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/sailpoint-oss/sailpoint-cli/internal/redact" ) type Client interface { @@ -52,7 +53,7 @@ func (c *SpClient) Get(ctx context.Context, url string, headers map[string]strin if c.cfg.Debug { dbg, _ := httputil.DumpRequest(req, true) - fmt.Println(string(dbg)) + log.Debug("HTTP request", "dump", redact.Bytes(dbg)) } resp, err := c.client.Do(req) @@ -62,7 +63,7 @@ func (c *SpClient) Get(ctx context.Context, url string, headers map[string]strin if c.cfg.Debug { dbg, _ := httputil.DumpResponse(resp, true) - fmt.Println(string(dbg)) + log.Debug("HTTP response", "dump", redact.Bytes(dbg)) } return resp, nil } @@ -85,7 +86,7 @@ func (c *SpClient) Delete(ctx context.Context, url string, params map[string]str if c.cfg.Debug { dbg, _ := httputil.DumpRequest(req, true) - fmt.Println(string(dbg)) + log.Debug("HTTP request", "dump", redact.Bytes(dbg)) } if params != nil { @@ -103,7 +104,7 @@ func (c *SpClient) Delete(ctx context.Context, url string, params map[string]str if c.cfg.Debug { dbg, _ := httputil.DumpResponse(resp, true) - fmt.Println(string(dbg)) + log.Debug("HTTP response", "dump", redact.Bytes(dbg)) } return resp, nil } @@ -128,7 +129,7 @@ func (c *SpClient) Post(ctx context.Context, url string, contentType string, bod if c.cfg.Debug { dbg, _ := httputil.DumpRequest(req, true) - fmt.Println(string(dbg)) + log.Debug("HTTP request", "dump", redact.Bytes(dbg)) } resp, err := c.client.Do(req) @@ -137,7 +138,7 @@ func (c *SpClient) Post(ctx context.Context, url string, contentType string, bod } if c.cfg.Debug { dbg, _ := httputil.DumpResponse(resp, true) - fmt.Println(string(dbg)) + log.Debug("HTTP response", "dump", redact.Bytes(dbg)) } return resp, nil } @@ -161,7 +162,7 @@ func (c *SpClient) Put(ctx context.Context, url string, contentType string, body if c.cfg.Debug { dbg, _ := httputil.DumpRequest(req, true) - fmt.Println(string(dbg)) + log.Debug("HTTP request", "dump", redact.Bytes(dbg)) } resp, err := c.client.Do(req) @@ -171,7 +172,7 @@ func (c *SpClient) Put(ctx context.Context, url string, contentType string, body if c.cfg.Debug { dbg, _ := httputil.DumpResponse(resp, true) - fmt.Println(string(dbg)) + log.Debug("HTTP response", "dump", redact.Bytes(dbg)) } return resp, nil @@ -195,7 +196,7 @@ func (c *SpClient) Patch(ctx context.Context, url string, body io.Reader, header if c.cfg.Debug { dbg, _ := httputil.DumpRequest(req, true) - fmt.Println(string(dbg)) + log.Debug("HTTP request", "dump", redact.Bytes(dbg)) } resp, err := c.client.Do(req) @@ -205,7 +206,7 @@ func (c *SpClient) Patch(ctx context.Context, url string, body io.Reader, header if c.cfg.Debug { dbg, _ := httputil.DumpResponse(resp, true) - fmt.Println(string(dbg)) + log.Debug("HTTP response", "dump", redact.Bytes(dbg)) } return resp, nil diff --git a/internal/clierror/clierror.go b/internal/clierror/clierror.go new file mode 100644 index 00000000..cf7f822d --- /dev/null +++ b/internal/clierror/clierror.go @@ -0,0 +1,96 @@ +package clierror + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/sailpoint-oss/sailpoint-cli/internal/redact" +) + +const ( + ExitGeneral = 1 + ExitUsage = 2 + ExitNotFound = 3 + ExitAPI = 4 + ExitCanceled = 130 +) + +type Error struct { + Message string + Hint string + Code int + Category string +} + +func (e *Error) Error() string { + if e.Hint == "" { + return e.Message + } + return fmt.Sprintf("%s\nHint: %s", e.Message, e.Hint) +} + +func New(message string, code int) *Error { + return &Error{Message: message, Code: code} +} + +func Usage(message string) *Error { + return &Error{Message: message, Code: ExitUsage, Category: "usage"} +} + +func NotFound(resource, name, hint string) *Error { + return &Error{ + Message: fmt.Sprintf("%s not found: %s", resource, name), + Hint: hint, + Code: ExitNotFound, + Category: "not_found", + } +} + +func Canceled(action string) *Error { + if action == "" { + action = "operation" + } + return &Error{ + Message: action + " canceled", + Code: ExitCanceled, + Category: "canceled", + } +} + +func APIStatus(statusCode int, status string, body []byte) *Error { + message := status + if message == "" { + message = http.StatusText(statusCode) + } + if len(body) > 0 { + message = fmt.Sprintf("%s: %s", message, summarizeBody(body)) + } + return &Error{ + Message: "API request failed with status " + message, + Code: ExitAPI, + Category: "api", + } +} + +func ExitCode(err error) int { + if err == nil { + return 0 + } + + var cliErr *Error + if errors.As(err, &cliErr) && cliErr.Code != 0 { + return cliErr.Code + } + + return ExitGeneral +} + +func summarizeBody(body []byte) string { + summary := strings.TrimSpace(redact.Bytes(body)) + if len(summary) > 500 { + return summary[:500] + "..." + } + return summary +} diff --git a/internal/clierror/clierror_test.go b/internal/clierror/clierror_test.go new file mode 100644 index 00000000..640c384a --- /dev/null +++ b/internal/clierror/clierror_test.go @@ -0,0 +1,32 @@ +package clierror + +import ( + "errors" + "strings" + "testing" +) + +func TestExitCodeUsesTypedErrorCode(t *testing.T) { + if got := ExitCode(Usage("bad flag")); got != ExitUsage { + t.Fatalf("expected usage exit code, got %d", got) + } + if got := ExitCode(errors.New("plain")); got != ExitGeneral { + t.Fatalf("expected general exit code, got %d", got) + } + if got := ExitCode(nil); got != 0 { + t.Fatalf("expected success exit code, got %d", got) + } +} + +func TestAPIStatusRedactsBody(t *testing.T) { + err := APIStatus(401, "401 Unauthorized", []byte(`{"access_token":"secret","message":"denied"}`)) + if ExitCode(err) != ExitAPI { + t.Fatalf("expected API exit code") + } + if strings.Contains(err.Error(), "secret") { + t.Fatalf("expected secret to be redacted from %q", err.Error()) + } + if !strings.Contains(err.Error(), "denied") { + t.Fatalf("expected non-sensitive API detail to remain visible") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d12f525c..429f5b21 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,9 +9,10 @@ import ( "github.com/charmbracelet/log" sailpoint "github.com/sailpoint-oss/golang-sdk/v2" + "github.com/sailpoint-oss/sailpoint-cli/internal/auth" + "github.com/sailpoint-oss/sailpoint-cli/internal/keyring" "github.com/sailpoint-oss/sailpoint-cli/internal/types" "github.com/spf13/viper" - keyring "github.com/zalando/go-keyring" "gopkg.in/square/go-jose.v2/jwt" ) @@ -22,6 +23,7 @@ const ( configYamlFile = "config.yaml" ) +// Token holds OAuth token data (kept for mapstructure compatibility with existing config files). type Token struct { AccessToken string `mapstructure:"accesstoken"` Expiry time.Time `mapstructure:"expiry"` @@ -30,27 +32,28 @@ type Token struct { RefreshExpiry time.Time `mapstructure:"refreshexpiry"` } +// Environment represents a single CLI environment configuration. type Environment struct { - TenantURL string `mapstructure:"tenanturl"` - BaseURL string `mapstructure:"baseurl"` - Pat PatConfig `mapstructure:"pat"` - OAuth Token `mapstructure:"oauth"` + TenantURL string `mapstructure:"tenanturl"` + BaseURL string `mapstructure:"baseurl"` + AuthType string `mapstructure:"authtype"` + Pat auth.PatConfig `mapstructure:"pat"` + OAuth Token `mapstructure:"oauth"` } +// CLIConfig is the top-level CLI configuration structure. type CLIConfig struct { - - //Standard Variables + // Standard Variables ExportTemplatesPath string `mapstructure:"exporttemplatespath"` SearchTemplatesPath string `mapstructure:"searchtemplatespath"` ReportTemplatesPath string `mapstructure:"reporttemplatespath"` - // TemplatesPath string `mapstructure:"templatespath"` Debug bool `mapstructure:"debug"` AuthType string `mapstructure:"authtype"` ActiveEnvironment string `mapstructure:"activeenvironment"` Environments map[string]Environment `mapstructure:"environments"` - //Pipeline Variables + // Pipeline Variables ClientID string `mapstructure:"clientid, omitempty"` ClientSecret string `mapstructure:"clientsecret, omitempty"` BaseURL string `mapstructure:"base_url, omitempty"` @@ -58,6 +61,8 @@ type CLIConfig struct { Expiry time.Time `mapstructure:"expiry"` } +// --- Global settings --- + func GetCustomSearchTemplatePath() string { return viper.GetString("searchtemplatespath") } @@ -70,48 +75,167 @@ func GetCustomReportTemplatePath() string { return viper.GetString("reporttemplatespath") } -func SetCustomSearchTemplatePath(customsearchtemplatespath string) { - viper.Set("searchtemplatespath", customsearchtemplatespath) +func SetCustomSearchTemplatePath(path string) { + viper.Set("searchtemplatespath", path) +} + +func SetCustomExportTemplatePath(path string) { + viper.Set("exporttemplatespath", path) +} + +func SetCustomReportTemplatePath(path string) { + viper.Set("reporttemplatespath", path) +} + +func GetDebug() bool { + return viper.GetBool("debug") } -func SetCustomExportTemplatePath(customsearchtemplatespath string) { - viper.Set("exporttemplatespath", customsearchtemplatespath) +func SetDebug(debug bool) { + viper.Set("debug", debug) } -func SetCustomReportTemplatePath(customreporttemplatespath string) { - viper.Set("reporttemplatespath", customreporttemplatespath) +func GetJSONOutput() bool { + return viper.GetBool("json") } +// --- Environment management --- + +var ( + activeEnvironmentOverride string + activeEnvironmentOverrideSet bool +) + func GetEnvironments() map[string]interface{} { return viper.GetStringMap("environments") } +func GetActiveEnvironment() string { + if activeEnvironmentOverrideSet { + return activeEnvironmentOverride + } + env := strings.ToLower(viper.GetString("activeenvironment")) + if env == "" { + if viper.IsSet("activeenvironment") { + return "" + } + return "default" + } + return env +} + +func SetActiveEnvironment(activeEnv string) { + viper.Set("activeenvironment", strings.ToLower(activeEnv)) +} + +func SetActiveEnvironmentOverride(activeEnv string) { + activeEnvironmentOverride = strings.ToLower(activeEnv) + activeEnvironmentOverrideSet = true +} + +func ClearActiveEnvironmentOverride() { + activeEnvironmentOverride = "" + activeEnvironmentOverrideSet = false +} + +// GetAuthType returns the auth type for the active environment. +// Falls back to the global authtype for backward compatibility with old configs. func GetAuthType() string { - return strings.ToLower(viper.GetString("authtype")) + if authType := os.Getenv("SAIL_AUTHTYPE"); authType != "" { + return strings.ToLower(authType) + } + + env := GetActiveEnvironment() + perEnv := viper.GetString("environments." + env + ".authtype") + if perEnv != "" { + return strings.ToLower(perEnv) + } + if authType := viper.GetString("authtype"); authType != "" { + return strings.ToLower(authType) + } + return "pat" } -func SetAuthType(AuthType string) { - viper.Set("authtype", strings.ToLower(AuthType)) +// SetAuthType sets the auth type for the active environment. +func SetAuthType(authType string) { + env := GetActiveEnvironment() + viper.Set("environments."+env+".authtype", strings.ToLower(authType)) } -func GetDebug() bool { - return viper.GetBool("debug") +// GetEnvAuthType returns the auth type for a specific environment. +func GetEnvAuthType(env string) string { + if authType := os.Getenv("SAIL_AUTHTYPE"); authType != "" { + return strings.ToLower(authType) + } + + perEnv := viper.GetString("environments." + env + ".authtype") + if perEnv != "" { + return strings.ToLower(perEnv) + } + if authType := viper.GetString("authtype"); authType != "" { + return strings.ToLower(authType) + } + return "pat" } -func SetDebug(Debug bool) { - viper.Set("debug", Debug) +// SetEnvAuthType sets the auth type for a specific environment. +func SetEnvAuthType(env, authType string) { + viper.Set("environments."+env+".authtype", strings.ToLower(authType)) } -func GetActiveEnvironment() string { - return strings.ToLower(viper.GetString("activeenvironment")) +// --- URL management --- + +func GetEnvBaseUrl(env string) string { + return viper.GetString("environments." + env + ".baseurl") } -func SetActiveEnvironment(activeEnv string) { - viper.Set("activeenvironment", strings.ToLower(activeEnv)) +func GetBaseUrl() string { + envBaseUrl := os.Getenv("SAIL_BASE_URL") + if envBaseUrl != "" { + return envBaseUrl + } + return GetEnvBaseUrl(GetActiveEnvironment()) } -func InitConfig() error { +func GetTenantUrl() string { + return viper.GetString("environments." + GetActiveEnvironment() + ".tenanturl") +} + +func GetEnvTenantUrl(env string) string { + return viper.GetString("environments." + env + ".tenanturl") +} +func SetBaseUrl(baseUrl string) { + viper.Set("environments."+GetActiveEnvironment()+".baseurl", baseUrl) +} + +func SetEnvBaseUrl(env, baseUrl string) { + viper.Set("environments."+env+".baseurl", baseUrl) +} + +func SetTenantUrl(tenantUrl string) { + viper.Set("environments."+GetActiveEnvironment()+".tenanturl", tenantUrl) +} + +func SetEnvTenantUrl(env, tenantUrl string) { + viper.Set("environments."+env+".tenanturl", tenantUrl) +} + +func GetEnvTokenUrl(env string) string { + return GetEnvBaseUrl(env) + "/oauth/token" +} + +func GetTokenUrl() string { + return GetBaseUrl() + "/oauth/token" +} + +func GetAuthorizeUrl() string { + return GetTenantUrl() + "/oauth/authorize" +} + +// --- Initialization --- + +func InitConfig() error { home, err := os.UserHomeDir() if err != nil { return err @@ -133,10 +257,8 @@ func InitConfig() error { if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); ok { - // Config file not found; ignore error if desired - // IGNORE they may be using env vars + // Config file not found; ignore -- may be using env vars } else { - // Config file was found but another error was produced return err } } @@ -146,9 +268,72 @@ func InitConfig() error { log.SetReportCaller(true) } + // Pre-warm the keyring support check so it's not done on every Validate + _ = keyring.IsSupported() + + return nil +} + +func GetConfig() (CLIConfig, error) { + var cfg CLIConfig + err := viper.Unmarshal(&cfg) + if err != nil { + return cfg, err + } + return cfg, nil +} + +func SaveConfig() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + if _, err := os.Stat(filepath.Join(home, configFolder)); os.IsNotExist(err) { + err = os.Mkdir(filepath.Join(home, configFolder), 0777) + if err != nil { + log.Warn("failed to create config folder", "folder", configFolder, "error", err) + } + } + + err = viper.WriteConfig() + if err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + err = viper.SafeWriteConfig() + if err != nil { + return err + } + } else { + return err + } + } return nil } +// --- Auth wrappers (delegate to internal/auth) --- +// These maintain backward compatibility so existing callers of config.GetAuthToken() +// and config.InitAPIClient() continue to work. + +func GetAuthToken() (string, error) { + env := GetActiveEnvironment() + authType := GetAuthType() + baseURL := GetBaseUrl() + tenantURL := GetTenantUrl() + tokenURL := GetTokenUrl() + + return auth.GetToken(authType, env, baseURL, tenantURL, tokenURL, func(newBaseURL string) { + SetBaseUrl(newBaseURL) + }) +} + +func Validate() error { + return auth.ValidateAuth(GetAuthType(), GetActiveEnvironment(), GetBaseUrl(), GetTenantUrl()) +} + +func CheckToken(tokenString string) error { + return auth.CheckToken(tokenString) +} + func InitAPIClient(experimental bool) (*sailpoint.APIClient, error) { var apiClient *sailpoint.APIClient @@ -159,7 +344,7 @@ func InitAPIClient(experimental bool) (*sailpoint.APIClient, error) { token, err := GetAuthToken() if err != nil { - log.Debug("unable to retrieve accesstoken", "error", err) + return apiClient, fmt.Errorf("unable to retrieve access token: %w", err) } configuration := sailpoint.NewCLIConfiguration(sailpoint.ClientConfiguration{Token: token, BaseURL: GetBaseUrl()}) @@ -170,7 +355,7 @@ func InitAPIClient(experimental bool) (*sailpoint.APIClient, error) { apiClient = sailpoint.NewAPIClient(configuration) if GetDebug() { - logger := log.NewWithOptions(os.Stdout, log.Options{ + logger := log.NewWithOptions(os.Stderr, log.Options{ ReportCaller: true, ReportTimestamp: true, Level: log.DebugLevel, @@ -178,299 +363,181 @@ func InitAPIClient(experimental bool) (*sailpoint.APIClient, error) { debugLogger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel}) apiClient.V3.GetConfig().HTTPClient.Logger = debugLogger apiClient.Beta.GetConfig().HTTPClient.Logger = debugLogger + apiClient.V2024.GetConfig().HTTPClient.Logger = debugLogger + apiClient.V2025.GetConfig().HTTPClient.Logger = debugLogger + apiClient.V2026.GetConfig().HTTPClient.Logger = debugLogger } else { var DevNull types.DevNull apiClient.V3.GetConfig().HTTPClient.Logger = DevNull apiClient.Beta.GetConfig().HTTPClient.Logger = DevNull + apiClient.V2024.GetConfig().HTTPClient.Logger = DevNull + apiClient.V2025.GetConfig().HTTPClient.Logger = DevNull + apiClient.V2026.GetConfig().HTTPClient.Logger = DevNull } return apiClient, nil } -func CheckToken(tokenString string) error { - var claims map[string]interface{} +// --- Legacy keyring wrappers for backward compatibility --- +// These delegate to internal/auth but use GetActiveEnvironment() for the env param, +// matching the old behavior. Existing callers (e.g. cmd/set/pat.go, cmd/environment/delete.go) +// can continue to work. - token, err := jwt.ParseSigned(tokenString) - if err != nil { - return err - } - - token.UnsafeClaimsWithoutVerification(&claims) +func GetPatClientID() (string, error) { + return auth.GetPatClientID(GetActiveEnvironment()) +} - if claims["user_name"] == nil { - log.Warn("It looks like the token you are using is missing a user context, this will cause many of the CLI commands to fail.") - } +func GetPatClientSecret() (string, error) { + return auth.GetPatClientSecret(GetActiveEnvironment()) +} - log.Debug("Token Debug Info", "user_name", claims["user_name"], "org", claims["org"], "pod", claims["pod"]) +func SetPatClientID(clientID string) error { + return auth.SetPatClientID(GetActiveEnvironment(), clientID) +} - return nil +func SetPatClientSecret(clientSecret string) error { + return auth.SetPatClientSecret(GetActiveEnvironment(), clientSecret) } -func SetTime(inputTime time.Time) string { - return inputTime.Format(time.RFC3339) +func ResetCachePAT() error { + return auth.ResetCachePAT(GetActiveEnvironment()) } -func GetTime(inputString string) (time.Time, error) { - var outputTime time.Time - outputTime, err := time.Parse(time.RFC3339, inputString) - if err != nil { - return outputTime, err +func DeletePatToken(env string) error { + if env == "" { + env = GetActiveEnvironment() } - return outputTime, nil + return keyring.Delete("environments.pat.accesstoken", env) } -func GetAuthToken() (string, error) { - - var token string - - err := InitConfig() - if err != nil { - return "", err +func DeletePatTokenExpiry(env string) error { + if env == "" { + env = GetActiveEnvironment() } + return keyring.Delete("environments.pat.expiry", env) +} - err = Validate() - if err != nil { - return "", err +func DeletePatClientID(env string) error { + if env == "" { + env = GetActiveEnvironment() } + return keyring.Delete("environments.pat.clientid", env) +} - switch GetAuthType() { - - case "pat": - - authExpiry, _ := GetPatTokenExpiry() - - if authExpiry.After(time.Now()) { - - tempToken, err := GetPatToken() - if err != nil { - return token, err - } - - token = tempToken - - } else { - - set, err := PATLogin() - if err != nil { - return token, err - } - - token = set.AccessToken - - //err = - CachePAT(set) - // if err != nil { - // log.Error(err) - // } - - } - - case "oauth": - - authExpiry, _ := GetOAuthTokenExpiry() - refreshExpiry, _ := GetOAuthRefreshExpiry() - - if authExpiry.After(time.Now()) { - - tempToken, err := GetOAuthToken() - if err != nil { - return token, err - } - - token = tempToken - - } else if refreshExpiry.After(time.Now()) { - - set, err := RefreshOAuth() - if err != nil { - return token, err - } - - token = set.AccessToken - - //err = - CacheOAuth(set) - // if err != nil { - // log.Error(err) - // } - - } else { - - set, err := OAuthLogin() - if err != nil { - return "", err - } - - token = set.AccessToken - - //err = - CacheOAuth(set) - // if err != nil { - // log.Error(err) - // } - - } - - default: - return token, fmt.Errorf("invalid authtype configured") +func DeletePatClientSecret(env string) error { + if env == "" { + env = GetActiveEnvironment() } + return keyring.Delete("environments.pat.clientsecret", env) +} - err = CheckToken(token) - if err != nil { - return "", err +func DeleteOAuthToken(env string) error { + if env == "" { + env = GetActiveEnvironment() } - - return token, nil + return keyring.Delete("environments.oauth.accesstoken", env) } -func GetEnvBaseUrl(env string) string { - return viper.GetString("environments." + env + ".baseurl") +func DeleteOAuthTokenExpiry(env string) error { + if env == "" { + env = GetActiveEnvironment() + } + return keyring.Delete("environments.oauth.expiry", env) } -func GetBaseUrl() string { - envBaseUrl := os.Getenv("SAIL_BASE_URL") - if envBaseUrl != "" { - return envBaseUrl - } else { - return GetEnvBaseUrl(GetActiveEnvironment()) +func DeleteRefreshToken(env string) error { + if env == "" { + env = GetActiveEnvironment() } + return keyring.Delete("environments.oauth.refreshtoken", env) } -func GetTenantUrl() string { - return viper.GetString("environments." + GetActiveEnvironment() + ".tenanturl") +func DeleteRefreshTokenExpiry(env string) error { + if env == "" { + env = GetActiveEnvironment() + } + return keyring.Delete("environments.oauth.refreshexpiry", env) } -func SetBaseUrl(baseUrl string) { - viper.Set("environments."+GetActiveEnvironment()+".baseurl", baseUrl) +func PromptForClientID() (string, error) { + return auth.PromptForClientID() } -func SetTenantUrl(tenantUrl string) { - viper.Set("environments."+GetActiveEnvironment()+".tenanturl", tenantUrl) +func PromptForClientSecret() (string, error) { + return auth.PromptForClientSecret() } -func GetEnvTokenUrl(env string) string { - return GetEnvBaseUrl(env) + "/oauth/token" +// TestSecretsStorage delegates to the cached keyring check. +func TestSecretsStorage() bool { + return keyring.IsSupported() } -func GetTokenUrl() string { - return GetBaseUrl() + "/oauth/token" +// SetTime formats a time.Time as RFC3339. +func SetTime(inputTime time.Time) string { + return inputTime.Format(time.RFC3339) } -func GetAuthorizeUrl() string { - return GetTenantUrl() + "/oauth/authorize" +// GetTime parses an RFC3339 string into a time.Time. +func GetTime(inputString string) (time.Time, error) { + return time.Parse(time.RFC3339, inputString) } -func GetConfig() (CLIConfig, error) { - var Config CLIConfig +// --- Legacy functions that are no longer used by internal/auth but may be used elsewhere --- - err := viper.Unmarshal(&Config) - if err != nil { - return Config, err - } - return Config, nil +func GetClientID(env string) (string, error) { + return auth.GetPatClientID(env) } -func SaveConfig() error { - home, err := os.UserHomeDir() - if err != nil { - return err - } +func GetClientSecret(env string) (string, error) { + return auth.GetPatClientSecret(env) +} - if _, err := os.Stat(filepath.Join(home, configFolder)); os.IsNotExist(err) { - err = os.Mkdir(filepath.Join(home, configFolder), 0777) - if err != nil { - log.Warn("failed to create %s folder for config. %v", configFolder, err) - } +// PATLogin delegates to internal/auth. +func PATLogin() (auth.PATSet, error) { + clientID, err := GetPatClientID() + if err != nil { + return auth.PATSet{}, err } - - err = viper.WriteConfig() + clientSecret, err := GetPatClientSecret() if err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - err = viper.SafeWriteConfig() - if err != nil { - return err - } - } else { - return err - } + return auth.PATSet{}, err } - return nil + return auth.PATLogin(GetTokenUrl(), clientID, clientSecret) } -func TestSecretsStorage() bool { - keyring.Set("test.service", "test.user", "test.secret") - secret, err := keyring.Get("test.service", "test.user") - if err != nil || secret != "test.secret" { - return false - } else { - return true - } +// CachePAT delegates to internal/auth. +func CachePAT(set auth.PATSet) error { + return auth.CachePAT(GetActiveEnvironment(), set) } -func Validate() error { - var errors int - authType := GetAuthType() - - supportsSecrets := TestSecretsStorage() - - switch authType { - - case "pat": - - if !supportsSecrets { - log.Warn("Secrets storage is not currently functional on this platform, PAT will only work with environment variables", "additional information", "URL") - } - - if GetBaseUrl() == "" { - log.Error("configured environment is missing BaseURL") - errors++ - } - - patClientID, err := GetPatClientID() - if err != nil { - return err - } - patClientSecret, err := GetPatClientSecret() - if err != nil { - return err - } - - if patClientID == "" { - log.Error("configured environment is missing PAT ClientID") - errors++ - } - - if patClientSecret == "" { - log.Error("configured environment is missing PAT ClientSecret") - errors++ - } - - case "oauth": - - if !supportsSecrets { - log.Warn("Secrets storage is not currently functional on this platform, every command will reauthenticate with OAuth") - } - - if GetBaseUrl() == "" { - log.Error("configured environment is missing BaseURL") - errors++ - } - - if GetTenantUrl() == "" { - log.Error("configured environment is missing TenantURL") - errors++ - } +// OAuthLogin delegates to internal/auth. +func OAuthLogin() (auth.TokenSet, error) { + return auth.OAuthLogin(GetBaseUrl()) +} - default: +// RefreshOAuth delegates to internal/auth. +func RefreshOAuth() (auth.TokenSet, error) { + env := GetActiveEnvironment() + return auth.RefreshOAuth(env, GetBaseUrl(), GetTenantUrl()) +} - log.Error("invalid authtype '%s' configured", authType) - errors++ +// CacheOAuth delegates to internal/auth. +func CacheOAuth(set auth.TokenSet) error { + return auth.CacheOAuth(GetActiveEnvironment(), set) +} - } +// ResetCacheOAuth delegates to internal/auth. +func ResetCacheOAuth() error { + return auth.ResetCacheOAuth(GetActiveEnvironment()) +} - if errors > 0 { - return fmt.Errorf("configuration invalid, errors: %v", errors) +// CheckTokenForClaims returns JWT claims for display purposes. +func CheckTokenForClaims(tokenString string) (map[string]interface{}, error) { + var claims map[string]interface{} + token, err := jwt.ParseSigned(tokenString) + if err != nil { + return nil, err } - - return nil + token.UnsafeClaimsWithoutVerification(&claims) + return claims, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..bb906df9 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,73 @@ +package config + +import ( + "testing" + + "github.com/spf13/viper" +) + +func resetViperForTest(t *testing.T) { + t.Helper() + viper.Reset() + ClearActiveEnvironmentOverride() + t.Cleanup(func() { + viper.Reset() + ClearActiveEnvironmentOverride() + }) +} + +func TestGetActiveEnvironmentDefaultsWithoutInitConfig(t *testing.T) { + resetViperForTest(t) + + if got := GetActiveEnvironment(); got != "default" { + t.Fatalf("GetActiveEnvironment() = %q, want %q", got, "default") + } +} + +func TestGetActiveEnvironmentReturnsEmptyWhenExplicitlyCleared(t *testing.T) { + resetViperForTest(t) + viper.Set("activeenvironment", "") + + if got := GetActiveEnvironment(); got != "" { + t.Fatalf("GetActiveEnvironment() = %q, want empty string", got) + } +} + +func TestGetActiveEnvironmentOverrideDoesNotMutateConfiguredValue(t *testing.T) { + resetViperForTest(t) + viper.Set("activeenvironment", "production") + + SetActiveEnvironmentOverride("staging") + + if got := GetActiveEnvironment(); got != "staging" { + t.Fatalf("GetActiveEnvironment() = %q, want %q", got, "staging") + } + if got := viper.GetString("activeenvironment"); got != "production" { + t.Fatalf("configured activeenvironment = %q, want %q", got, "production") + } +} + +func TestGetAuthTypeDefaultsWithoutInitConfig(t *testing.T) { + resetViperForTest(t) + + if got := GetAuthType(); got != "pat" { + t.Fatalf("GetAuthType() = %q, want %q", got, "pat") + } +} + +func TestGetAuthTypeReadsEnvironmentWithoutInitConfig(t *testing.T) { + resetViperForTest(t) + t.Setenv("SAIL_AUTHTYPE", "OAUTH") + + if got := GetAuthType(); got != "oauth" { + t.Fatalf("GetAuthType() = %q, want %q", got, "oauth") + } +} + +func TestGetEnvAuthTypeDefaultsWithoutInitConfig(t *testing.T) { + resetViperForTest(t) + + if got := GetEnvAuthType("default"); got != "pat" { + t.Fatalf("GetEnvAuthType() = %q, want %q", got, "pat") + } +} diff --git a/internal/config/oauth.go b/internal/config/oauth.go deleted file mode 100644 index e02af2fe..00000000 --- a/internal/config/oauth.go +++ /dev/null @@ -1,620 +0,0 @@ -package config - -import ( - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/json" - "encoding/pem" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/charmbracelet/log" - "github.com/skratchdot/open-golang/open" - keyring "github.com/zalando/go-keyring" - "gopkg.in/square/go-jose.v2/jwt" -) - -type RefreshResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` - TenantID string `json:"tenant_id"` - Internal bool `json:"internal"` - Pod string `json:"pod"` - StrongAuthSupported bool `json:"strong_auth_supported"` - Org string `json:"org"` - ClaimsSupported bool `json:"claims_supported"` - IdentityID string `json:"identity_id"` - StrongAuth bool `json:"strong_auth"` - Jti string `json:"jti"` -} - -type TokenSet struct { - AccessToken string - AccessExpiry time.Time - RefreshToken string - RefreshExpiry time.Time -} - -// AuthRequest represents the request body for initiating OAuth authentication -type AuthRequest struct { - Tenant string `json:"tenant,omitempty"` - APIBaseURL string `json:"apiBaseURL,omitempty"` - PublicKey string `json:"publicKey"` -} - -// AuthResponse represents the response from the authentication initiation endpoint -type AuthResponse struct { - AuthURL string `json:"authURL"` - ID string `json:"id"` - BaseURL string `json:"baseURL"` - PickupSecret string `json:"pickupSecret"` - TTL int64 `json:"ttl"` -} - -func confirmationCodeFromID(id string) string { - id = strings.TrimSpace(id) - if len(id) < 8 { - return strings.ToUpper(id) - } - - suffix := id[len(id)-8:] - return strings.ToUpper(suffix[:4] + "-" + suffix[4:]) -} - -func newOAuthTokenRequest(tokenURL, id, pickupSecret string) (*http.Request, error) { - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s", tokenURL, id), nil) - if err != nil { - return nil, err - } - if pickupSecret != "" { - req.Header.Set("Authorization", "Bearer "+pickupSecret) - } - return req, nil -} - -// OAuthTokenResponse represents the response containing the encrypted token from OAuth flow -type OAuthTokenResponse struct { - ID string `json:"id"` - BaseURL string `json:"baseURL"` - TokenInfo string `json:"tokenInfo"` -} - -// EncryptedTokenData represents the structure of the encrypted token JSON -type EncryptedTokenData struct { - Version string `json:"version"` - Algorithm struct { - Symmetric string `json:"symmetric"` - Asymmetric string `json:"asymmetric"` - } `json:"algorithm"` - Data struct { - Ciphertext string `json:"ciphertext"` - EncryptedKey string `json:"encryptedKey"` - IV string `json:"iv"` - AuthTag string `json:"authTag"` - } `json:"data"` -} - -// RefreshRequest represents the request body for refreshing OAuth tokens -type RefreshRequest struct { - RefreshToken string `json:"refreshToken"` - APIBaseURL string `json:"apiBaseURL,omitempty"` - Tenant string `json:"tenant,omitempty"` -} - -func DeleteOAuthToken(env string) error { - if env != "" { - err := keyring.Delete("environments.oauth.accesstoken", env) - if err != nil { - return err - } - return nil - } else { - err := keyring.Delete("environments.oauth.accesstoken", GetActiveEnvironment()) - if err != nil { - return err - } - return nil - } -} - -func GetOAuthToken() (string, error) { - value, err := keyring.Get("environments.oauth.accesstoken", GetActiveEnvironment()) - if err != nil { - return value, err - } - return value, nil -} - -func SetOAuthToken(token string) error { - err := keyring.Set("environments.oauth.accesstoken", GetActiveEnvironment(), token) - if err != nil { - return err - } - return nil -} - -func DeleteOAuthTokenExpiry(env string) error { - if env != "" { - err := keyring.Delete("environments.oauth.expiry", env) - if err != nil { - return err - } - return nil - } else { - err := keyring.Delete("environments.oauth.expiry", GetActiveEnvironment()) - if err != nil { - return err - } - return nil - } -} - -func GetOAuthTokenExpiry() (time.Time, error) { - var valueTime time.Time - valueString, err := keyring.Get("environments.oauth.expiry", GetActiveEnvironment()) - if err != nil { - return valueTime, err - } - - valueTime, err = GetTime(valueString) - if err != nil { - return valueTime, err - } - - return valueTime, nil -} - -func SetOAuthTokenExpiry(expiry time.Time) error { - err := keyring.Set("environments.oauth.expiry", GetActiveEnvironment(), SetTime(expiry)) - if err != nil { - return err - } - return nil -} - -func DeleteRefreshToken(env string) error { - if env != "" { - err := keyring.Delete("environments.oauth.refreshtoken", env) - if err != nil { - return err - } - return nil - } else { - err := keyring.Delete("environments.oauth.refreshtoken", GetActiveEnvironment()) - if err != nil { - return err - } - return nil - } -} - -func GetRefreshToken() (string, error) { - value, err := keyring.Get("environments.oauth.refreshtoken", GetActiveEnvironment()) - - if err != nil { - return value, err - } - - return value, nil -} - -func SetRefreshToken(token string) error { - - err := keyring.Set("environments.oauth.refreshtoken", GetActiveEnvironment(), token) - if err != nil { - return err - } - - return nil - -} - -func DeleteRefreshTokenExpiry(env string) error { - if env != "" { - err := keyring.Delete("environments.oauth.refreshexpiry", env) - if err != nil { - return err - } - return nil - } else { - err := keyring.Delete("environments.oauth.refreshexpiry", GetActiveEnvironment()) - if err != nil { - return err - } - return nil - } -} - -func GetOAuthRefreshExpiry() (time.Time, error) { - - var valueTime time.Time - valueString, err := keyring.Get("environments.oauth.refreshexpiry", GetActiveEnvironment()) - if err != nil { - return valueTime, err - } - - valueTime, err = GetTime(valueString) - if err != nil { - return valueTime, err - } - - return valueTime, nil - -} - -func SetOAuthRefreshExpiry(expiry time.Time) error { - - err := keyring.Set("environments.oauth.refreshexpiry", GetActiveEnvironment(), SetTime(expiry)) - if err != nil { - return err - } - - return nil - -} - -var ( - tokenSet TokenSet -) - -const ( - ClientID = "sailpoint-cli" - AuthLambdaBaseURL = "https://nug87yusrg.execute-api.us-east-1.amazonaws.com/Prod/sailapps" - AuthLambdaAuthURL = AuthLambdaBaseURL + "/auth" - AuthLambdaTokenURL = AuthLambdaBaseURL + "/auth/token" - AuthLambdaRefreshURL = AuthLambdaBaseURL + "/auth/refresh" -) - -func ResetCacheOAuth() error { - err := DeleteOAuthToken("") - if err != nil { - return err - } - - err = DeleteOAuthTokenExpiry("") - if err != nil { - return err - } - - err = DeleteRefreshToken("") - if err != nil { - return err - } - - err = DeleteRefreshTokenExpiry("") - if err != nil { - return err - } - - return nil -} - -func CacheOAuth(set TokenSet) error { - var err error - - err = SetOAuthToken(set.AccessToken) - if err != nil { - return err - } - - err = SetOAuthTokenExpiry(set.AccessExpiry) - if err != nil { - return err - } - - err = SetRefreshToken(set.RefreshToken) - if err != nil { - return err - } - - err = SetOAuthRefreshExpiry(set.RefreshExpiry) - if err != nil { - return err - } - - return nil -} - -// generateKeyPair creates a new 2048-bit RSA key pair for OAuth authentication -// Returns the private key, the public key as base64-encoded PEM, and any error -func generateKeyPair() (*rsa.PrivateKey, string, error) { - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, "", fmt.Errorf("failed to generate RSA key pair: %v", err) - } - - publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) - if err != nil { - return nil, "", fmt.Errorf("failed to marshal public key: %v", err) - } - - publicKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: publicKeyBytes, - }) - - publicKeyBase64 := base64.StdEncoding.EncodeToString(publicKeyPEM) - return privateKey, publicKeyBase64, nil -} - -// decryptHybridToken decrypts a token encrypted with hybrid RSA-OAEP + AES-256-GCM encryption -func decryptHybridToken(encryptedData *EncryptedTokenData, privateKey *rsa.PrivateKey) (string, error) { - // 1. Decode base64 components - encryptedKey, err := base64.StdEncoding.DecodeString(encryptedData.Data.EncryptedKey) - if err != nil { - return "", fmt.Errorf("failed to decode encrypted key: %v", err) - } - - ciphertext, err := base64.StdEncoding.DecodeString(encryptedData.Data.Ciphertext) - if err != nil { - return "", fmt.Errorf("failed to decode ciphertext: %v", err) - } - - iv, err := base64.StdEncoding.DecodeString(encryptedData.Data.IV) - if err != nil { - return "", fmt.Errorf("failed to decode IV: %v", err) - } - - authTag, err := base64.StdEncoding.DecodeString(encryptedData.Data.AuthTag) - if err != nil { - return "", fmt.Errorf("failed to decode auth tag: %v", err) - } - - // 2. Decrypt AES key using RSA-OAEP-SHA256 - aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, encryptedKey, nil) - if err != nil { - return "", fmt.Errorf("RSA decryption failed: %v", err) - } - - // 3. Decrypt token using AES-256-GCM - block, err := aes.NewCipher(aesKey) - if err != nil { - return "", fmt.Errorf("failed to create AES cipher: %v", err) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", fmt.Errorf("failed to create GCM: %v", err) - } - - // Append auth tag to ciphertext (GCM expects it this way) - ciphertextWithTag := append(ciphertext, authTag...) - - plaintext, err := gcm.Open(nil, iv, ciphertextWithTag, nil) - if err != nil { - return "", fmt.Errorf("AES-GCM decryption failed: %v", err) - } - - return string(plaintext), nil -} - -func OAuthLogin() (TokenSet, error) { - var set TokenSet - - // Step 1: Generate RSA key pair for this authentication session - privateKey, publicKeyBase64, err := generateKeyPair() - if err != nil { - return set, fmt.Errorf("failed to generate key pair: %v", err) - } - log.Debug("Generated RSA key pair for OAuth authentication") - - // Step 2: Initiate authentication flow with the public key - authRequest := AuthRequest{ - APIBaseURL: GetBaseUrl(), - PublicKey: publicKeyBase64, - } - - requestBody, err := json.Marshal(authRequest) - if err != nil { - return set, fmt.Errorf("failed to marshal auth request: %v", err) - } - - resp, err := http.Post(AuthLambdaAuthURL, "application/json", bytes.NewBuffer(requestBody)) - if err != nil { - return set, fmt.Errorf("failed to initiate auth with lambda: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - return set, fmt.Errorf("auth lambda returned non-200 status: %d, body: %s", resp.StatusCode, string(bodyBytes)) - } - - var authResponse AuthResponse - if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil { - return set, fmt.Errorf("failed to decode auth lambda response: %v", err) - } - - log.Debug("Auth response received", "id", authResponse.ID, "baseURL", authResponse.BaseURL) - if confirmationCode := confirmationCodeFromID(authResponse.ID); confirmationCode != "" { - fmt.Printf("SailApps confirmation code: %s\n", confirmationCode) - } - - // Update the base URL for this session - if authResponse.BaseURL != "" { - SetBaseUrl(authResponse.BaseURL) - } - - // Step 3: Present Auth URL to user - log.Info("Attempting to open browser for authentication") - err = open.Run(authResponse.AuthURL) - if err != nil { - log.Warn("Cannot open automatically, Please manually open OAuth login page below") - fmt.Println(authResponse.AuthURL) - } - - // Step 4: Poll Auth-Lambda for encrypted token using UUID - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - timeout := time.After(5 * time.Minute) - for { - select { - case <-timeout: - return set, fmt.Errorf("authentication timed out after 5 minutes") - case <-ticker.C: - // Query Auth-Lambda for token using UUID - tokenReq, err := newOAuthTokenRequest(AuthLambdaTokenURL, authResponse.ID, authResponse.PickupSecret) - if err != nil { - return set, fmt.Errorf("failed to create token polling request: %v", err) - } - - tokenResp, err := http.DefaultClient.Do(tokenReq) - if err != nil { - log.Debug("Error polling for token", "error", err) - continue - } - - if tokenResp.StatusCode == http.StatusOK { - var tokenResponse OAuthTokenResponse - if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResponse); err != nil { - tokenResp.Body.Close() - return set, fmt.Errorf("failed to decode token response: %v", err) - } - tokenResp.Body.Close() - - // Update base URL if provided in token response - if tokenResponse.BaseURL != "" { - SetBaseUrl(tokenResponse.BaseURL) - } - - // Parse the encrypted token data - var encryptedTokenData EncryptedTokenData - if err := json.Unmarshal([]byte(tokenResponse.TokenInfo), &encryptedTokenData); err != nil { - return set, fmt.Errorf("failed to parse encrypted token data: %v", err) - } - - // Decrypt the token using our private key - decryptedTokenInfo, err := decryptHybridToken(&encryptedTokenData, privateKey) - if err != nil { - return set, fmt.Errorf("failed to decrypt token info: %v", err) - } - - // Parse the decrypted token info into RefreshResponse - var response RefreshResponse - if err := json.Unmarshal([]byte(decryptedTokenInfo), &response); err != nil { - return set, fmt.Errorf("failed to parse decrypted token info: %v", err) - } - - // Parse tokens to get expiry - var accessTokenClaims map[string]interface{} - accToken, err := jwt.ParseSigned(response.AccessToken) - if err != nil { - return set, fmt.Errorf("failed to parse access token: %v", err) - } - accToken.UnsafeClaimsWithoutVerification(&accessTokenClaims) - - var refreshTokenClaims map[string]interface{} - refToken, err := jwt.ParseSigned(response.RefreshToken) - if err != nil { - return set, fmt.Errorf("failed to parse refresh token: %v", err) - } - refToken.UnsafeClaimsWithoutVerification(&refreshTokenClaims) - - set = TokenSet{ - AccessToken: response.AccessToken, - AccessExpiry: time.Unix(int64(accessTokenClaims["exp"].(float64)), 0), - RefreshToken: response.RefreshToken, - RefreshExpiry: time.Unix(int64(refreshTokenClaims["exp"].(float64)), 0), - } - - log.Info("OAuth authentication successful") - return set, nil - } - bodyBytes, _ := io.ReadAll(tokenResp.Body) - if tokenResp.StatusCode == http.StatusUnauthorized { - tokenResp.Body.Close() - return set, fmt.Errorf("token polling unauthorized: %s", string(bodyBytes)) - } - log.Debug("Token not ready", "status", tokenResp.StatusCode, "body", string(bodyBytes)) - tokenResp.Body.Close() - } - } -} - -func RefreshOAuth() (TokenSet, error) { - var response RefreshResponse - var set TokenSet - - tempRefreshToken, err := GetRefreshToken() - if err != nil { - return set, err - } - - // Prepare the refresh request body - refreshRequest := RefreshRequest{ - RefreshToken: tempRefreshToken, - APIBaseURL: GetBaseUrl(), - Tenant: GetTenantUrl(), - } - - requestBody, err := json.Marshal(refreshRequest) - if err != nil { - return set, fmt.Errorf("failed to marshal refresh request: %v", err) - } - - resp, err := http.Post(AuthLambdaRefreshURL, "application/json", bytes.NewBuffer(requestBody)) - if err != nil { - return set, fmt.Errorf("failed to refresh token: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - return set, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(bodyBytes)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return set, err - } - - err = json.Unmarshal(body, &response) - if err != nil { - return set, err - } - - if response.AccessToken == "" { - return set, fmt.Errorf("no access token in refresh response") - } - - if response.RefreshToken == "" { - return set, fmt.Errorf("no refresh token in refresh response") - } - - var accessToken map[string]interface{} - accToken, err := jwt.ParseSigned(response.AccessToken) - if err != nil { - return set, err - } - accToken.UnsafeClaimsWithoutVerification(&accessToken) - - var refreshToken map[string]interface{} - refToken, err := jwt.ParseSigned(response.RefreshToken) - if err != nil { - return set, err - } - refToken.UnsafeClaimsWithoutVerification(&refreshToken) - - set = TokenSet{ - AccessToken: response.AccessToken, - AccessExpiry: time.Unix(int64(accessToken["exp"].(float64)), 0), - RefreshToken: response.RefreshToken, - RefreshExpiry: time.Unix(int64(refreshToken["exp"].(float64)), 0), - } - - log.Debug("OAuth token refresh successful") - return set, nil -} diff --git a/internal/config/pat.go b/internal/config/pat.go deleted file mode 100644 index 2ac2be23..00000000 --- a/internal/config/pat.go +++ /dev/null @@ -1,350 +0,0 @@ -package config - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/charmbracelet/log" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" - keyring "github.com/zalando/go-keyring" -) - -type TokenResponse struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` -} - -type PatConfig struct { - ClientID string `mapstructure:"clientid"` - ClientSecret string `mapstructure:"clientsecret"` - AccessToken string `mapstructure:"accesstoken"` - Expiry time.Time `mapstructure:"expiry"` -} - -type PATSet struct { - AccessToken string - AccessExpiry time.Time -} - -func ResetCachePAT() error { - - token, err := GetPatToken() - if token != "" && err == nil { - - err = DeletePatToken("") - if err != nil { - return err - } - } - - expiry, err := GetPatTokenExpiry() - if !expiry.IsZero() && err == nil { - err = DeletePatTokenExpiry("") - if err != nil { - return err - } - } - - return nil -} - -func CachePAT(set PATSet) error { - var err error - - err = SetPatToken(set.AccessToken) - if err != nil { - return err - } - - err = SetPatTokenExpiry(set.AccessExpiry) - if err != nil { - return err - } - - return nil -} - -func DeletePatToken(env string) error { - if env != "" { - err := keyring.Delete("environments.pat.accesstoken", env) - if err != nil { - return err - } - return nil - } else { - err := keyring.Delete("environments.pat.accesstoken", GetActiveEnvironment()) - if err != nil { - return err - } - return nil - } -} - -func GetPatToken() (string, error) { - value, err := keyring.Get("environments.pat.accesstoken", GetActiveEnvironment()) - if err != nil { - return "", err - } - return value, nil -} - -func SetPatToken(token string) error { - err := keyring.Set("environments.pat.accesstoken", GetActiveEnvironment(), token) - if err != nil { - return err - } - return nil -} - -func DeletePatTokenExpiry(env string) error { - if env != "" { - err := keyring.Delete("environments.pat.expiry", env) - if err != nil { - return err - } - return nil - } else { - err := keyring.Delete("environments.pat.expiry", GetActiveEnvironment()) - if err != nil { - return err - } - return nil - } -} - -func GetPatTokenExpiry() (time.Time, error) { - valueString, err := keyring.Get("environments.pat.expiry", GetActiveEnvironment()) - if err != nil { - return time.Time{}, err - } - - valueTime, err := GetTime(valueString) - if err != nil { - return valueTime, err - } - - return valueTime, nil -} - -func SetPatTokenExpiry(expiry time.Time) error { - err := keyring.Set("environments.pat.expiry", GetActiveEnvironment(), SetTime(expiry)) - if err != nil { - return err - } - return nil -} - -func GetClientID(env string) (string, error) { - value, err := keyring.Get("environments.pat.clientid", env) - if err != nil { - log.Error("issue retrieving clientID", "env", env) - return value, err - } - return value, nil -} - -func GetPatClientID() (string, error) { - envSecret := os.Getenv("SAIL_CLIENT_ID") - if envSecret != "" { - return envSecret, nil - } else { - value, err := GetClientID(GetActiveEnvironment()) - if err != nil { - return value, err - } - return value, nil - } -} - -func DeletePatClientID(env string) error { - if env != "" { - err := keyring.Delete("environments.pat.clientid", env) - if err != nil { - return err - } - return nil - } else { - err := keyring.Delete("environments.pat.clientid", GetActiveEnvironment()) - if err != nil { - return err - } - return nil - } -} - -func GetClientSecret(env string) (string, error) { - value, err := keyring.Get("environments.pat.clientsecret", env) - if err != nil { - log.Error("issue retrieving clientSecret", "env", env) - return value, err - } - return value, nil -} - -func DeletePatClientSecret(env string) error { - if env != "" { - err := keyring.Delete("environments.pat.clientsecret", env) - if err != nil { - return err - } - return nil - } else { - err := keyring.Delete("environments.pat.clientsecret", GetActiveEnvironment()) - if err != nil { - return err - } - return nil - } -} - -func GetPatClientSecret() (string, error) { - envSecret := os.Getenv("SAIL_CLIENT_SECRET") - if envSecret != "" { - return envSecret, nil - } else { - value, err := GetClientSecret(GetActiveEnvironment()) - if err != nil { - return value, err - } - return value, nil - } -} - -func SetPatClientID(ClientID string) error { - err := keyring.Set("environments.pat.clientid", GetActiveEnvironment(), ClientID) - if err != nil { - return err - } - return nil -} - -func SetPatClientSecret(ClientSecret string) error { - err := keyring.Set("environments.pat.clientsecret", GetActiveEnvironment(), ClientSecret) - if err != nil { - return err - } - return nil -} - -func PATLogin() (PATSet, error) { - var set PATSet - - uri, err := url.Parse(GetTokenUrl()) - if err != nil { - return set, err - } - - query := &url.Values{} - query.Add("grant_type", "client_credentials") - uri.RawQuery = query.Encode() - - data := &url.Values{} - - patClientID, err := GetPatClientID() - if err != nil { - return set, err - } - patClientSecret, err := GetPatClientSecret() - if err != nil { - return set, err - } - - data.Add("client_id", patClientID) - data.Add("client_secret", patClientSecret) - - ctx := context.TODO() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, uri.String(), strings.NewReader(data.Encode())) - if err != nil { - return set, err - } - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - - client := http.Client{} - - resp, err := client.Do(req) - if err != nil { - return set, err - } - defer func(Body io.ReadCloser) { - _ = Body.Close() - }(resp.Body) - - if resp.StatusCode != http.StatusOK { - return set, fmt.Errorf("failed to retrieve access token. status %s", resp.Status) - } - - raw, err := io.ReadAll(resp.Body) - if err != nil { - return set, err - } - - var tResponse TokenResponse - - err = json.Unmarshal(raw, &tResponse) - if err != nil { - return set, err - } - - now := time.Now() - - set = PATSet{AccessToken: tResponse.AccessToken, AccessExpiry: now.Add(time.Second * time.Duration(tResponse.ExpiresIn))} - - return set, nil -} - -func PromptForClientID() (string, error) { - const maxAttempts = 3 - var ClientID string - var err error - - for attempt := 1; attempt <= maxAttempts; attempt++ { - // Prompt for the Client ID - ClientID, err = terminal.PromptPassword("Personal Access Token Client ID:") - if err != nil { - return "", err - } - - // Check length - if len(ClientID) == 36 || len(ClientID) == 32 { - log.Info("ClientID", "length", len(ClientID)) - fmt.Println("Valid Client ID provided.") - return ClientID, nil - } else { - log.Warn("ClientID", "length", len(ClientID)) - fmt.Printf("Invalid Client ID. Please ensure it is either 32 or 36 characters long. Attempt %d/%d.\n", attempt, maxAttempts) - } - } - - return "", fmt.Errorf("maximum attempts reached for entering Client ID") -} - -func PromptForClientSecret() (string, error) { - const maxAttempts = 3 - var ClientSecret string - var err error - - for attempt := 1; attempt <= maxAttempts; attempt++ { - // Prompt for the Client Secret - ClientSecret, err = terminal.PromptPassword("Personal Access Token Client Secret:") - if err != nil { - return "", err - } - - // Check length - if len(ClientSecret) == 64 { - fmt.Println("Valid Client Secret provided.") - return ClientSecret, nil - } else { - fmt.Printf("Invalid Client Secret. Please ensure it is 64 characters long. Attempt %d/%d.\n", attempt, maxAttempts) - } - } - - return "", fmt.Errorf("maximum attempts reached for entering Client Secret") -} diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go new file mode 100644 index 00000000..fe014619 --- /dev/null +++ b/internal/keyring/keyring.go @@ -0,0 +1,68 @@ +package keyring + +import ( + "sync" + "time" + + gokeyring "github.com/zalando/go-keyring" +) + +// Get retrieves a value from the system keyring for the given service and environment. +func Get(service, env string) (string, error) { + return gokeyring.Get(service, env) +} + +// Set stores a value in the system keyring for the given service and environment. +func Set(service, env, value string) error { + return gokeyring.Set(service, env, value) +} + +// Delete removes a value from the system keyring for the given service and environment. +func Delete(service, env string) error { + return gokeyring.Delete(service, env) +} + +// GetTime retrieves a time.Time value from the keyring (stored as RFC3339 string). +func GetTime(service, env string) (time.Time, error) { + s, err := Get(service, env) + if err != nil { + return time.Time{}, err + } + return time.Parse(time.RFC3339, s) +} + +// SetTime stores a time.Time value in the keyring as an RFC3339 string. +func SetTime(service, env string, t time.Time) error { + return Set(service, env, t.Format(time.RFC3339)) +} + +var ( + secretsSupported *bool + secretsSupportedOnce sync.Once +) + +// IsSupported checks whether the system keyring is functional. +// The result is cached after the first call. +func IsSupported() bool { + secretsSupportedOnce.Do(func() { + supported := testKeyring() + secretsSupported = &supported + }) + return *secretsSupported +} + +func testKeyring() bool { + const svc = "sailpoint-cli.test" + const user = "test" + const secret = "test-secret" + + if err := gokeyring.Set(svc, user, secret); err != nil { + return false + } + val, err := gokeyring.Get(svc, user) + if err != nil || val != secret { + return false + } + _ = gokeyring.Delete(svc, user) + return true +} diff --git a/internal/output/output.go b/internal/output/output.go index 98ce2be4..54f01fd7 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -3,14 +3,19 @@ package output import ( "bufio" "encoding/json" + "fmt" "io" "os" "path" "sort" + "strings" "github.com/charmbracelet/log" "github.com/mrz1836/go-sanitize" "github.com/olekukonko/tablewriter" + "github.com/sailpoint-oss/sailpoint-cli/internal/redact" + "github.com/spf13/viper" + "gopkg.in/yaml.v2" ) func SaveJSONFile[T any](formattedResponse T, fileName string, folderPath string) error { @@ -23,7 +28,7 @@ func SaveJSONFile[T any](formattedResponse T, fileName string, folderPath string return err } - log.Debug("Formatted Data", "data", string(dataToSave)) + log.Debug("Formatted data", "bytes", len(dataToSave), "redactedPreview", string(redact.JSONBytes(dataToSave))) saveErr := WriteFile(folderPath, saveName, dataToSave) if saveErr != nil { @@ -37,13 +42,13 @@ func WriteFile(folderPath string, filePath string, data []byte) error { // Create the folder if it doesn't exist if _, err := os.Stat(folderPath); os.IsNotExist(err) { - err = os.MkdirAll(folderPath, 0777) + err = os.MkdirAll(folderPath, 0755) if err != nil { return err } } - file, err := os.OpenFile(path.Join(folderPath, filePath), os.O_CREATE|os.O_RDWR, 0777) + file, err := os.OpenFile(path.Join(folderPath, filePath), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0600) if err != nil { return err } @@ -74,6 +79,11 @@ func GetSanitizedPath(fileName string, extension string) string { } func WriteTable(writer io.Writer, headers []string, entries [][]string, sortKey string) { + if IsMachineReadable() { + _ = WriteStructured(writer, tableRecords(headers, entries)) + return + } + table := tablewriter.NewWriter(writer) // Convert []string to []any for the Header method headerAny := make([]any, len(headers)) @@ -105,3 +115,91 @@ func WriteTable(writer io.Writer, headers []string, entries [][]string, sortKey table.Render() } + +const ( + FormatTable = "table" + FormatJSON = "json" + FormatYAML = "yaml" +) + +func CurrentFormat() string { + if viper.GetBool("json") { + return FormatJSON + } + + format := strings.ToLower(strings.TrimSpace(viper.GetString("output"))) + switch format { + case "", FormatTable: + return FormatTable + case FormatJSON, FormatYAML: + return format + default: + return FormatTable + } +} + +func IsMachineReadable() bool { + format := CurrentFormat() + return format == FormatJSON || format == FormatYAML +} + +func WriteStructured(writer io.Writer, value any) error { + switch CurrentFormat() { + case FormatJSON: + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(value) + case FormatYAML: + data, err := yaml.Marshal(value) + if err != nil { + return err + } + _, err = writer.Write(data) + return err + default: + _, err := fmt.Fprintln(writer, Pretty(value)) + return err + } +} + +func WriteTableOrStructured(writer io.Writer, headers []string, entries [][]string, sortKey string, structuredValue any) error { + if IsMachineReadable() { + return WriteStructured(writer, structuredValue) + } + + WriteTable(writer, headers, entries, sortKey) + return nil +} + +func Pretty(value any) string { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(data) +} + +func tableRecords(headers []string, entries [][]string) []map[string]string { + records := make([]map[string]string, 0, len(entries)) + for _, entry := range entries { + record := make(map[string]string, len(headers)) + for i, header := range headers { + if i >= len(entry) { + continue + } + record[normalizeHeader(header)] = entry[i] + } + records = append(records, record) + } + return records +} + +func normalizeHeader(header string) string { + normalized := strings.TrimSpace(strings.ToLower(header)) + if normalized == "" { + return "marker" + } + normalized = strings.ReplaceAll(normalized, " ", "_") + normalized = strings.ReplaceAll(normalized, "-", "_") + return normalized +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 00000000..a5cb71b6 --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,50 @@ +package output + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/viper" +) + +func TestWriteStructuredJSON(t *testing.T) { + t.Cleanup(viper.Reset) + viper.Set("output", "json") + + var buf bytes.Buffer + if err := WriteStructured(&buf, map[string]any{"name": "demo"}); err != nil { + t.Fatalf("WriteStructured returned error: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("expected valid JSON, got %q: %v", buf.String(), err) + } + if decoded["name"] != "demo" { + t.Fatalf("unexpected JSON payload: %#v", decoded) + } +} + +func TestWriteStructuredYAML(t *testing.T) { + t.Cleanup(viper.Reset) + viper.Set("output", "yaml") + + var buf bytes.Buffer + if err := WriteStructured(&buf, map[string]any{"name": "demo"}); err != nil { + t.Fatalf("WriteStructured returned error: %v", err) + } + if !strings.Contains(buf.String(), "name: demo") { + t.Fatalf("expected YAML payload, got %q", buf.String()) + } +} + +func TestCurrentFormatFallsBackToTable(t *testing.T) { + t.Cleanup(viper.Reset) + viper.Set("output", "text") + + if got := CurrentFormat(); got != FormatTable { + t.Fatalf("expected unsupported output format to fall back to table, got %q", got) + } +} diff --git a/internal/pretty/pretty.go b/internal/pretty/pretty.go index 0606fb9d..f8e87bf7 100644 --- a/internal/pretty/pretty.go +++ b/internal/pretty/pretty.go @@ -16,7 +16,7 @@ func init() { glamour.WithAutoStyle(), ) if err != nil { - panic(err) + log.Warn("Markdown renderer unavailable; falling back to JSON-only formatting", "error", err) } } diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 00000000..bdc46a76 --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,100 @@ +package redact + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +const replacement = "[REDACTED]" + +var sensitiveKeyFragments = []string{ + "authorization", + "access_token", + "accesstoken", + "refresh_token", + "refreshtoken", + "pickupsecret", + "clientsecret", + "client_secret", + "secret", + "password", + "token", + "credential", +} + +var sensitivePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?im)^(Authorization:\s*Bearer\s+).+$`), + regexp.MustCompile(`(?im)^(Authorization:\s*Basic\s+).+$`), + regexp.MustCompile(`(?i)("?[A-Za-z0-9_.-]*(?:token|secret|password|credential)[A-Za-z0-9_.-]*"?\s*[:=]\s*")([^"]+)(")`), + regexp.MustCompile(`(?i)((?:access_token|refresh_token|client_secret|pickupSecret|password|secret|token)=)([^&\s]+)`), +} + +func String(value string) string { + redacted := value + for _, pattern := range sensitivePatterns { + switch pattern.NumSubexp() { + case 1: + redacted = pattern.ReplaceAllString(redacted, `${1}`+replacement) + case 3: + redacted = pattern.ReplaceAllString(redacted, `${1}`+replacement+`${3}`) + default: + redacted = pattern.ReplaceAllString(redacted, `${1}`+replacement) + } + } + return redacted +} + +func Bytes(value []byte) string { + return String(string(value)) +} + +func JSONBytes(value []byte) []byte { + var decoded any + if err := json.Unmarshal(value, &decoded); err != nil { + return []byte(String(string(value))) + } + + redacted := Value(decoded) + data, err := json.MarshalIndent(redacted, "", " ") + if err != nil { + return []byte(fmt.Sprintf("%v", redacted)) + } + return data +} + +func Value(value any) any { + switch typed := value.(type) { + case map[string]any: + out := make(map[string]any, len(typed)) + for key, val := range typed { + if IsSensitiveKey(key) { + out[key] = replacement + continue + } + out[key] = Value(val) + } + return out + case []any: + out := make([]any, len(typed)) + for i, val := range typed { + out[i] = Value(val) + } + return out + case string: + return String(typed) + default: + return typed + } +} + +func IsSensitiveKey(key string) bool { + normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_")) + for _, fragment := range sensitiveKeyFragments { + if strings.Contains(normalized, fragment) { + return true + } + } + return false +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 00000000..07a2ae83 --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,46 @@ +package redact + +import ( + "strings" + "testing" +) + +func TestStringRedactsHeadersAndKeyValueSecrets(t *testing.T) { + input := "Authorization: Bearer secret-token\nclient_secret=abc123&next=true\npassword=\"hunter2\"" + output := String(input) + + for _, secret := range []string{"secret-token", "abc123", "hunter2"} { + if strings.Contains(output, secret) { + t.Fatalf("expected %q to be redacted from %q", secret, output) + } + } + if strings.Count(output, replacement) < 3 { + t.Fatalf("expected redacted output, got %q", output) + } +} + +func TestValueRedactsNestedSensitiveKeys(t *testing.T) { + value := map[string]any{ + "name": "visible", + "nested": map[string]any{ + "accessToken": "secret", + }, + "items": []any{ + map[string]any{"clientSecret": "secret2"}, + }, + } + + redacted := Value(value).(map[string]any) + if redacted["name"] != "visible" { + t.Fatalf("expected non-sensitive value to remain visible") + } + nested := redacted["nested"].(map[string]any) + if nested["accessToken"] != replacement { + t.Fatalf("expected nested access token to be redacted") + } + items := redacted["items"].([]any) + item := items[0].(map[string]any) + if item["clientSecret"] != replacement { + t.Fatalf("expected nested client secret to be redacted") + } +} diff --git a/internal/sdk/sdk.go b/internal/sdk/sdk.go index be719380..60aa0174 100644 --- a/internal/sdk/sdk.go +++ b/internal/sdk/sdk.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/charmbracelet/log" + "github.com/sailpoint-oss/sailpoint-cli/internal/redact" "github.com/sailpoint-oss/sailpoint-cli/internal/util" ) @@ -35,7 +36,7 @@ func HandleSDKError(resp *http.Response, sdkErr error) error { log.Error(err) } - return errors.New(util.RenderMarkdown(sdkErrParts[0] + util.PrettyPrint(resp.Header) + sdkErrParts[1] + util.PrettyPrint(data) + sdkErrParts[2])) + return errors.New(util.RenderMarkdown(sdkErrParts[0] + util.PrettyPrint(redact.Value(resp.Header)) + sdkErrParts[1] + util.PrettyPrint(redact.Value(data)) + sdkErrParts[2])) } func PrintSDKResult(resp *http.Response, field string) string { diff --git a/internal/sdkcmd/sdkcmd.go b/internal/sdkcmd/sdkcmd.go new file mode 100644 index 00000000..2ecf0edd --- /dev/null +++ b/internal/sdkcmd/sdkcmd.go @@ -0,0 +1,69 @@ +package sdkcmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" + "github.com/sailpoint-oss/sailpoint-cli/internal/output" + "github.com/spf13/cobra" +) + +type ListOptions struct { + Limit int32 + Offset int32 + Count bool + Filters string + Sorters string +} + +func AddListFlags(cmd *cobra.Command, opts *ListOptions) { + opts.Limit = 250 + cmd.Flags().Int32VarP(&opts.Limit, "limit", "l", opts.Limit, "Maximum number of results to return") + cmd.Flags().Int32Var(&opts.Offset, "offset", 0, "Offset of the first result to return") + cmd.Flags().BoolVar(&opts.Count, "count", false, "Request total count metadata from the API") + cmd.Flags().StringVar(&opts.Filters, "filter", "", "API filter expression") + cmd.Flags().StringVar(&opts.Sorters, "sort", "", "API sort expression") +} + +func WriteTable(cmd *cobra.Command, headers []string, rows [][]string, sortKey string, structuredValue any) error { + return output.WriteTableOrStructured(cmd.OutOrStdout(), headers, rows, sortKey, structuredValue) +} + +func WriteStructured(cmd *cobra.Command, value any) error { + return output.WriteStructured(cmd.OutOrStdout(), value) +} + +func SDKError(resp *http.Response, err error) error { + if err == nil { + return nil + } + if resp == nil { + return err + } + + var body []byte + if resp.Body != nil { + body, _ = io.ReadAll(resp.Body) + } + return clierror.APIStatus(resp.StatusCode, resp.Status, body) +} + +func ReadJSONFile[T any](filePath string) (T, error) { + var value T + if filePath == "" { + return value, clierror.Usage("a JSON payload file is required") + } + + data, err := os.ReadFile(filePath) + if err != nil { + return value, fmt.Errorf("failed to read JSON payload file: %w", err) + } + if err := json.Unmarshal(data, &value); err != nil { + return value, fmt.Errorf("failed to parse JSON payload file: %w", err) + } + return value, nil +} diff --git a/internal/sdkcmd/sdkcmd_test.go b/internal/sdkcmd/sdkcmd_test.go new file mode 100644 index 00000000..01a9d7f6 --- /dev/null +++ b/internal/sdkcmd/sdkcmd_test.go @@ -0,0 +1,50 @@ +package sdkcmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadJSONFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "payload.json") + if err := os.WriteFile(path, []byte(`{"name":"demo"}`), 0600); err != nil { + t.Fatalf("failed to write payload: %v", err) + } + + got, err := ReadJSONFile[map[string]string](path) + if err != nil { + t.Fatalf("ReadJSONFile returned error: %v", err) + } + if got["name"] != "demo" { + t.Fatalf("decoded name = %q, want %q", got["name"], "demo") + } +} + +func TestReadJSONFileRequiresPath(t *testing.T) { + _, err := ReadJSONFile[map[string]any]("") + if err == nil { + t.Fatal("expected missing path to fail") + } + if !strings.Contains(err.Error(), "JSON payload file is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestReadJSONFileRejectsInvalidJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "payload.json") + if err := os.WriteFile(path, []byte(`{"name":`), 0600); err != nil { + t.Fatalf("failed to write payload: %v", err) + } + + _, err := ReadJSONFile[map[string]any](path) + if err == nil { + t.Fatal("expected invalid JSON to fail") + } + if !strings.Contains(err.Error(), "failed to parse JSON payload file") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/search/search.go b/internal/search/search.go index 5e15e31c..43537de7 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -3,12 +3,14 @@ package search import ( "context" "fmt" - "os" + "io" + "net/http" "github.com/charmbracelet/log" "github.com/mitchellh/mapstructure" sailpoint "github.com/sailpoint-oss/golang-sdk/v2" sailpointsdk "github.com/sailpoint-oss/golang-sdk/v2/api_v3" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" "github.com/sailpoint-oss/sailpoint-cli/internal/output" ) @@ -59,8 +61,10 @@ func PerformSearch(apiClient sailpoint.APIClient, search sailpointsdk.Search) (S ctx := context.TODO() resp, r, err := sailpoint.PaginateWithDefaults[map[string]interface{}](apiClient.V3.SearchAPI.SearchPost(ctx).Search(search)) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + if r != nil { + return SearchResults, searchAPIError(r, err) + } + return SearchResults, fmt.Errorf("search request failed: %w", err) } log.Debug("Search complete") @@ -131,8 +135,10 @@ func PerformSearchWithLimit(apiClient sailpoint.APIClient, search sailpointsdk.S ctx := context.TODO() resp, r, err := apiClient.V3.SearchAPI.SearchPost(ctx).Search(search).Limit(limit).Execute() if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintf(os.Stderr, "Full HTTP response: %v\n", r) + if r != nil { + return SearchResults, searchAPIError(r, err) + } + return SearchResults, fmt.Errorf("search request failed: %w", err) } log.Debug("Search complete") @@ -193,6 +199,17 @@ func PerformSearchWithLimit(apiClient sailpoint.APIClient, search sailpointsdk.S return SearchResults, nil } +func searchAPIError(resp *http.Response, err error) error { + var body []byte + if resp.Body != nil { + body, _ = io.ReadAll(resp.Body) + } + if len(body) == 0 && err != nil { + body = []byte(err.Error()) + } + return clierror.APIStatus(resp.StatusCode, resp.Status, body) +} + func IterateIndices(SearchResults SearchResults, searchQuery string, folderPath string, outputTypes []string) error { if len(SearchResults.AccountActivities) > 0 { fileName := "query=" + searchQuery + "&indices=AccountActivities" diff --git a/internal/templates/templates.go b/internal/templates/templates.go index c578b110..13e86d55 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -7,7 +7,6 @@ import ( "path/filepath" "github.com/charmbracelet/log" - "github.com/fatih/color" "github.com/sailpoint-oss/sailpoint-cli/internal/config" "github.com/sailpoint-oss/sailpoint-cli/internal/tui" ) @@ -50,7 +49,7 @@ func GetSearchTemplates() ([]SearchTemplate, error) { err = json.Unmarshal(raw, &templates) if err != nil { - log.Error("an error occurred while parsing the file: %s", templateFile) + log.Error("failed to parse template file", "file", templateFile) return nil, err } @@ -60,7 +59,7 @@ func GetSearchTemplates() ([]SearchTemplate, error) { err = json.Unmarshal([]byte(builtInSearchTemplates), &builtInTemplates) if err != nil { - color.Red("an error occurred while parsing the built in templates") + log.Error("failed to parse built-in search templates") return nil, err } @@ -105,7 +104,7 @@ func GetExportTemplates() ([]ExportTemplate, error) { file, err := os.OpenFile(templateFile, os.O_RDWR, 0777) if err != nil { - log.Debug("error opening file %s", templateFile) + log.Debug("error opening file", "file", templateFile) } else { @@ -116,7 +115,7 @@ func GetExportTemplates() ([]ExportTemplate, error) { err = json.Unmarshal(raw, &templates) if err != nil { - log.Debug("an error occurred while parsing the file: %s", templateFile) + log.Error("failed to parse template file", "file", templateFile) return nil, err } @@ -126,7 +125,7 @@ func GetExportTemplates() ([]ExportTemplate, error) { err = json.Unmarshal([]byte(builtInExportTemplates), &templates) if err != nil { - log.Error("an error occurred while parsing the built in templates") + log.Error("failed to parse built-in export templates") return nil, err } @@ -176,7 +175,7 @@ func GetReportTemplates() ([]ReportTemplate, error) { file, err := os.OpenFile(templateFile, os.O_RDWR, 0777) if err != nil { - log.Debug("error opening file %s", templateFile) + log.Debug("error opening file", "file", templateFile) } else { @@ -187,7 +186,7 @@ func GetReportTemplates() ([]ReportTemplate, error) { err = json.Unmarshal(raw, &templates) if err != nil { - log.Error("an error occured while parsing the file: %s", templateFile) + log.Error("failed to parse template file", "file", templateFile) return nil, err } @@ -197,7 +196,7 @@ func GetReportTemplates() ([]ReportTemplate, error) { err = json.Unmarshal([]byte(builtInReportTemplates), &buildInTemplates) if err != nil { - color.Red("an error occured while parsing the built in templates") + log.Error("failed to parse built-in report templates") return nil, err } diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 258b0b6b..8c5f0f66 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -2,9 +2,7 @@ package terminal import ( - "bufio" "fmt" - "os" "strings" "syscall" @@ -17,7 +15,6 @@ type Terminal interface { PromptPassword(promptMsg string) (string, error) } -// PromptPassword prompts user to enter password and then returns it func (c *Term) PromptPassword(promptMsg string) (string, error) { fmt.Print(promptMsg) bytePassword, err := term.ReadPassword(int(syscall.Stdin)) @@ -27,28 +24,3 @@ func (c *Term) PromptPassword(promptMsg string) (string, error) { fmt.Println() return strings.TrimSpace(string(bytePassword)), nil } - -// PromptPassword prompts user to enter password and then returns it -func PromptPassword(promptMsg string) (string, error) { - fmt.Print(promptMsg) - bytePassword, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return "", err - } - fmt.Println() - return strings.TrimSpace(string(bytePassword)), nil -} - -// InputPrompt receives a string value using the label -func InputPrompt(label string) string { - var s string - r := bufio.NewReader(os.Stdin) - for { - fmt.Fprint(os.Stderr, label+" ") - s, _ = r.ReadString('\n') - if s != "" { - break - } - } - return strings.TrimSpace(s) -} diff --git a/internal/testutil/live.go b/internal/testutil/live.go new file mode 100644 index 00000000..b8d7aff7 --- /dev/null +++ b/internal/testutil/live.go @@ -0,0 +1,142 @@ +package testutil + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + v2024 "github.com/sailpoint-oss/golang-sdk/v2/api_v2024" + "github.com/sailpoint-oss/sailpoint-cli/internal/config" + "github.com/spf13/viper" +) + +func RequireLiveCredentials(t *testing.T) { + t.Helper() + + if err := config.InitConfig(); err != nil { + t.Fatalf("failed to initialize CLI config: %v", err) + } + if err := config.Validate(); err != nil { + t.Skipf("skipping live CLI test: no usable SailPoint CLI credentials found (%v). Configure PAT credentials with SAIL_BASE_URL, SAIL_CLIENT_ID, and SAIL_CLIENT_SECRET, or run `sail env create`/`sail auth login` for OAuth, then rerun this test.", err) + } +} + +func SetJSONOutput(t *testing.T) { + t.Helper() + + previousJSON := viper.GetBool("json") + previousOutput := viper.GetString("output") + viper.Set("json", false) + viper.Set("output", "json") + t.Cleanup(func() { + viper.Set("json", previousJSON) + viper.Set("output", previousOutput) + }) +} + +func UniqueName(prefix string) string { + sanitized := strings.Trim(strings.ToLower(prefix), "-") + if sanitized == "" { + sanitized = "resource" + } + return fmt.Sprintf("sail-cli-ci-%s-%d", sanitized, time.Now().UnixNano()) +} + +func WriteJSON(t *testing.T, dir string, name string, value any) string { + t.Helper() + + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("failed to marshal %s: %v", name, err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } + return path +} + +func DecodeJSON[T any](t *testing.T, raw string) T { + t.Helper() + + var value T + if err := json.Unmarshal([]byte(raw), &value); err != nil { + t.Fatalf("failed to decode JSON output %q: %v", raw, err) + } + return value +} + +func SkipIfFeatureUnavailable(t *testing.T, err error) { + t.Helper() + + if err == nil { + return + } + message := err.Error() + for _, status := range []string{"401", "403", "404", "not found", "forbidden", "unauthorized"} { + if strings.Contains(strings.ToLower(message), status) { + t.Skipf("skipping live CLI test: required tenant feature or permission unavailable: %v", err) + } + } +} + +type IdentityRef struct { + ID string + Name string +} + +type SourceRef struct { + ID string + Name string +} + +func FirstIdentity(t *testing.T) IdentityRef { + t.Helper() + + apiClient, err := config.InitAPIClient(false) + if err != nil { + t.Fatalf("failed to initialize API client: %v", err) + } + identities, resp, err := apiClient.V2024.IdentitiesAPI.ListIdentities(context.TODO()).Limit(1).Execute() + if err != nil { + SkipIfFeatureUnavailable(t, err) + t.Fatalf("failed to list identities for live test owner: %v (response: %v)", err, resp) + } + if len(identities) == 0 || identities[0].GetId() == "" { + t.Skip("skipping live CLI test: no identity available to use as owner") + } + return IdentityRef{ID: identities[0].GetId(), Name: identities[0].GetName()} +} + +func FirstSource(t *testing.T) SourceRef { + t.Helper() + + apiClient, err := config.InitAPIClient(false) + if err != nil { + t.Fatalf("failed to initialize API client: %v", err) + } + sources, resp, err := apiClient.V2024.SourcesAPI.ListSources(context.TODO()).Limit(1).Execute() + if err != nil { + SkipIfFeatureUnavailable(t, err) + t.Fatalf("failed to list sources for live test fixture: %v (response: %v)", err, resp) + } + if len(sources) == 0 || sources[0].GetId() == "" { + t.Skip("skipping live CLI test: no source available to use as fixture") + } + return SourceRef{ID: sources[0].GetId(), Name: sources[0].GetName()} +} + +func StringPatch(path string, value string) []v2024.JsonPatchOperation { + return []v2024.JsonPatchOperation{ + { + Op: "replace", + Path: path, + Value: &v2024.UpdateMultiHostSourcesRequestInnerValue{String: &value}, + }, + } +} diff --git a/internal/tui/list/main.go b/internal/tui/list/main.go deleted file mode 100644 index ee2e0daf..00000000 --- a/internal/tui/list/main.go +++ /dev/null @@ -1,103 +0,0 @@ -package tuilist - -import ( - "fmt" - "io" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -const ListHeight = 14 - -var ( - TitleStyle = lipgloss.NewStyle().MarginLeft(2) - itemStyle = lipgloss.NewStyle().PaddingLeft(4) - selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) - PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) - HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) - quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) -) - -type Item string - -func (i Item) FilterValue() string { return "" } - -type ItemDelegate struct{} - -func (d ItemDelegate) Height() int { return 1 } -func (d ItemDelegate) Spacing() int { return 0 } -func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(Item) - if !ok { - return - } - - str := fmt.Sprintf("%d. %s", index+1, i) - - fn := itemStyle.Render - if index == m.Index() { - fn = func(s ...string) string { - var fullString string - for _, v := range s { - fullString = fullString + v - } - return selectedItemStyle.Render("> " + fullString) - } - } - - fmt.Fprint(w, fn(str)) -} - -type Model struct { - List list.Model - Quitting bool -} - -func (m Model) Init() tea.Cmd { - return nil -} - -var choice string - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.List.SetWidth(msg.Width) - return m, nil - - case tea.KeyMsg: - switch keypress := msg.String(); keypress { - case "ctrl+c": - m.Quitting = true - return m, tea.Quit - - case "enter": - i, ok := m.List.SelectedItem().(Item) - if ok { - choice = string(i) - } - return m, tea.Quit - } - } - - var cmd tea.Cmd - m.List, cmd = m.List.Update(msg) - return m, cmd -} - -func (m Model) View() string { - if choice != "" { - return quitTextStyle.Render(fmt.Sprintf("Begining %s Configuration.", choice)) - } - if m.Quitting { - return quitTextStyle.Render("Aborting Configuration.") - } - return "\n" + m.List.View() -} - -func (m Model) Retrieve() string { - return choice -} diff --git a/internal/tui/prompts.go b/internal/tui/prompts.go new file mode 100644 index 00000000..aa7623e8 --- /dev/null +++ b/internal/tui/prompts.go @@ -0,0 +1,57 @@ +package tui + +import ( + "github.com/charmbracelet/huh" +) + +// Confirm displays an interactive yes/no prompt and returns the user's choice. +func Confirm(message string) (bool, error) { + var confirmed bool + err := huh.NewConfirm(). + Title(message). + Affirmative("Yes"). + Negative("No"). + Value(&confirmed). + Run() + if err != nil { + return false, err + } + return confirmed, nil +} + +// Input displays an interactive text input prompt and returns the entered value. +// If the user leaves the input empty, defaultValue is returned. +func Input(label string, defaultValue string) (string, error) { + var value string + input := huh.NewInput(). + Title(label). + Value(&value) + + if defaultValue != "" { + input = input.Placeholder(defaultValue) + } + + err := input.Run() + if err != nil { + return "", err + } + + if value == "" { + return defaultValue, nil + } + return value, nil +} + +// Password displays an interactive password input (masked) and returns the entered value. +func Password(label string) (string, error) { + var value string + err := huh.NewInput(). + Title(label). + EchoMode(huh.EchoModePassword). + Value(&value). + Run() + if err != nil { + return "", err + } + return value, nil +} diff --git a/internal/tui/table/main.go b/internal/tui/table/main.go deleted file mode 100644 index 46d6313b..00000000 --- a/internal/tui/table/main.go +++ /dev/null @@ -1,49 +0,0 @@ -package tuitable - -import ( - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var baseStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) - -var choice table.Row - -type Model struct { - Table table.Model -} - -func (m Model) Init() tea.Cmd { return nil } - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "esc": - if m.Table.Focused() { - m.Table.Blur() - } else { - m.Table.Focus() - } - case "q", "ctrl+c": - return m, tea.Quit - case "enter": - choice = m.Table.SelectedRow() - return m, tea.Quit - } - } - m.Table, cmd = m.Table.Update(msg) - return m, cmd -} - -func (m Model) View() string { - return baseStyle.Render(m.Table.View()) + "\n" -} - -func (m Model) Retrieve() table.Row { - return choice -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index eea4ff71..c6c18b4b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" ) var docStyle = lipgloss.NewStyle().Margin(1, 2) @@ -22,8 +23,6 @@ type Choice struct { Id string } -var choice ListItem - func (i ListItem) Title() string { return i.title } func (i ListItem) Description() string { return i.description } func (i ListItem) FilterValue() string { return i.title } @@ -31,6 +30,7 @@ func (i ListItem) FilterValue() string { return i.title } type model struct { List list.Model Quitting bool + Selected *ListItem } func (m model) Init() tea.Cmd { @@ -48,7 +48,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": i, ok := m.List.SelectedItem().(ListItem) if ok { - choice = i + m.Selected = &i } return m, tea.Quit } @@ -67,12 +67,11 @@ func (m model) View() string { return docStyle.Render(m.List.View()) } -func (m model) Retrieve() ListItem { - if choice.title != "" { - return choice - } else { - return ListItem{} +func (m model) Retrieve() (ListItem, bool) { + if m.Selected == nil { + return ListItem{}, false } + return *m.Selected, true } func PromptList(choices []Choice, Title string) (Choice, error) { @@ -89,11 +88,20 @@ func PromptList(choices []Choice, Title string) (Choice, error) { p := tea.NewProgram(m, tea.WithAltScreen()) - if _, err := p.Run(); err != nil { + finalModel, err := p.Run() + if err != nil { return Choice{}, fmt.Errorf("error running program: %s", err) } - choice := m.Retrieve() + final, ok := finalModel.(model) + if !ok { + return Choice{}, fmt.Errorf("unexpected list prompt result") + } + + choice, ok := final.Retrieve() + if !ok { + return Choice{}, clierror.Canceled("selection") + } return Choice{Title: choice.title, Description: choice.description, Id: choice.id}, nil } diff --git a/internal/util/url.go b/internal/util/url.go index f71bcc4e..16b6b9cf 100644 --- a/internal/util/url.go +++ b/internal/util/url.go @@ -1,15 +1,17 @@ package util import ( - "log" "net/url" "path" + + "github.com/charmbracelet/log" ) func ResourceUrl(endpoint string, resourceParts ...string) string { u, err := url.Parse(endpoint) if err != nil { - log.Fatalf("invalid endpoint: %s (%q)", err, endpoint) + log.Error("invalid endpoint", "endpoint", endpoint, "error", err) + return "" } u.Path = path.Join(append([]string{u.Path}, resourceParts...)...) return u.String() diff --git a/internal/util/util.go b/internal/util/util.go index 4cf40741..6181d31d 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -11,7 +11,6 @@ import ( "github.com/mrz1836/go-sanitize" "github.com/sailpoint-oss/sailpoint-cli/internal/config" "github.com/sailpoint-oss/sailpoint-cli/internal/search" - "github.com/sailpoint-oss/sailpoint-cli/internal/terminal" "github.com/sailpoint-oss/sailpoint-cli/internal/tui" "github.com/spf13/viper" ) @@ -25,7 +24,7 @@ func init() { glamour.WithAutoStyle(), ) if err != nil { - panic(err) + log.Warn("Markdown renderer unavailable; falling back to plain help text", "error", err) } } @@ -43,9 +42,13 @@ func SanitizeFileName(fileName string) string { } func RenderMarkdown(markdown string) string { + if renderer == nil { + return markdown + } out, err := renderer.Render(markdown) if err != nil { - panic(err) + log.Warn("Failed to render markdown; falling back to plain text", "error", err) + return markdown } return out @@ -59,7 +62,8 @@ type Help struct { func ParseHelp(help string) Help { helpParser, err := regexp.Compile(`==([A-Za-z]+)==([\s\S]*?)====`) if err != nil { - panic(err) + log.Warn("Failed to compile help parser", "error", err) + return Help{} } matches := helpParser.FindAllStringSubmatch(help, -1) @@ -107,12 +111,18 @@ func CreateOrUpdateEnvironment(environmentName string, update bool) error { tenant := "" + var defaultTenant string if update && environmentName == "" { - tenant = terminal.InputPrompt("Tenant Name (ie: https://{tenant}.identitynow.com): (" + config.GetActiveEnvironment() + ")") + defaultTenant = config.GetActiveEnvironment() } else if update { - tenant = terminal.InputPrompt("Tenant Name (ie: https://{tenant}.identitynow.com): (" + getTextBetween(viper.GetString("environments."+environmentName+".tenanturl"), "//", ".") + ")") + defaultTenant = getTextBetween(viper.GetString("environments."+environmentName+".tenanturl"), "//", ".") } else { - tenant = terminal.InputPrompt("Tenant Name (ie: https://{tenant}.identitynow.com): (" + environmentName + ")") + defaultTenant = environmentName + } + var err error + tenant, err = tui.Input("Tenant Name (e.g. acme)", defaultTenant) + if err != nil { + return err } if !update { @@ -129,18 +139,19 @@ func CreateOrUpdateEnvironment(environmentName string, update bool) error { tenantUrl := "https://" + tenant + ".identitynow.com" baseUrl := "https://" + tenant + ".api.identitynow.com" - fmt.Print("\nThe following two prompts will allow you to set a custom base and tenant url if the generated URL\ndoes not apply. If the generated URL is correct simply press enter to proceed\n\n") - confirmTenantUrl := terminal.InputPrompt("Tenant URL (ie: https://{tenant}.identitynow.com): (" + tenantUrl + ")") - confirmBaseURL := terminal.InputPrompt("Base URL (ie: https://{tenant}.api.identitynow.com): (" + baseUrl + ")") - - authType := terminal.InputPrompt("Authentication Type (oauth, pat):") - - if confirmTenantUrl != "" { - tenantUrl = confirmTenantUrl + fmt.Print("\nIf the generated URLs are correct, press Enter to accept them.\n\n") + tenantUrl, err = tui.Input("Tenant URL", tenantUrl) + if err != nil { + return err + } + baseUrl, err = tui.Input("Base URL", baseUrl) + if err != nil { + return err } - if confirmBaseURL != "" { - baseUrl = confirmBaseURL + authType, err := tui.Input("Authentication Type (oauth, pat)", "") + if err != nil { + return err } if authType == "pat" { diff --git a/main.go b/main.go index 03a56978..27dc2ecb 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/log" "github.com/sailpoint-oss/sailpoint-cli/cmd/root" + "github.com/sailpoint-oss/sailpoint-cli/internal/clierror" "github.com/sailpoint-oss/sailpoint-cli/internal/config" "github.com/spf13/cobra" ) @@ -34,6 +35,6 @@ func main() { // When error occurs, we need to make sure we exit the program with an error code. We // don't need to log it here because the sub commands already log it to the console. if err != nil { - os.Exit(1) + os.Exit(clierror.ExitCode(err)) } }