Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/prb_linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/prb_macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/prb_windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
78 changes: 78 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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/<command>/` as its own package. Commands follow a consistent pattern:

- **Parent command**: a `New<Name>Command()` function returns `*cobra.Command`, adds subcommands
- **Subcommands**: unexported `new<Action>Command()` 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.
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
184 changes: 184 additions & 0 deletions cmd/accessprofile/access_profile.go
Original file line number Diff line number Diff line change
@@ -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 <access-profile-id>",
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 <access-profile-id>",
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 <access-profile-id> --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 <access-profile-id>",
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 <access-profile-id>",
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)
},
}
}
19 changes: 19 additions & 0 deletions cmd/accessprofile/access_profile_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading