diff --git a/pkg/cli/api/client.go b/pkg/cli/api/client.go index 05a645b68..1610e3135 100644 --- a/pkg/cli/api/client.go +++ b/pkg/cli/api/client.go @@ -103,7 +103,7 @@ func (c *Client) DoRaw( host = "https://" + host } - url := fmt.Sprintf("%s%s", host, c.endpoint) + url := host + c.endpoint req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("cannot create HTTP request: %w", err) diff --git a/pkg/cli/config/config.go b/pkg/cli/config/config.go index 59b421663..60940e252 100644 --- a/pkg/cli/config/config.go +++ b/pkg/cli/config/config.go @@ -188,7 +188,7 @@ func normalizeHost(host string) string { lower := strings.ToLower(host) if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { if u, err := url.Parse(host); err == nil { - return u.Host + return strings.TrimRight(u.Scheme+"://"+u.Host, "/") } } diff --git a/pkg/cmd/access-review/accessreview.go b/pkg/cmd/access-review/accessreview.go new file mode 100644 index 000000000..41809a810 --- /dev/null +++ b/pkg/cmd/access-review/accessreview.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package accessreview + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/access-review/campaign" + "go.probo.inc/probo/pkg/cmd/access-review/entry" + "go.probo.inc/probo/pkg/cmd/access-review/source" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +func NewCmdAccessReview(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "access-review ", + Short: "Manage access reviews", + Aliases: []string{"ar"}, + } + + cmd.AddCommand(campaign.NewCmdCampaign(f)) + cmd.AddCommand(entry.NewCmdEntry(f)) + cmd.AddCommand(source.NewCmdSource(f)) + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/addsource/addsource.go b/pkg/cmd/access-review/campaign/addsource/addsource.go new file mode 100644 index 000000000..5c7ea34c9 --- /dev/null +++ b/pkg/cmd/access-review/campaign/addsource/addsource.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package addsource + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const addSourceMutation = ` +mutation($input: AddAccessReviewCampaignScopeSourceInput!) { + addAccessReviewCampaignScopeSource(input: $input) { + accessReviewCampaign { + id + name + status + } + } +} +` + +type addSourceResponse struct { + AddAccessReviewCampaignScopeSource struct { + AccessReviewCampaign struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"accessReviewCampaign"` + } `json:"addAccessReviewCampaignScopeSource"` +} + +func NewCmdAddSource(f *cmdutil.Factory) *cobra.Command { + var flagSourceID string + + cmd := &cobra.Command{ + Use: "add-source ", + Short: "Add a scope source to an access review campaign", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "accessReviewCampaignId": args[0], + "accessSourceId": flagSourceID, + } + + data, err := client.Do( + addSourceMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp addSourceResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + c := resp.AddAccessReviewCampaignScopeSource.AccessReviewCampaign + out := f.IOStreams.Out + _, _ = fmt.Fprintf(out, "Added source %s to campaign %s\n", flagSourceID, c.ID) + + return nil + }, + } + + cmd.Flags().StringVar(&flagSourceID, "source-id", "", "Access source ID to add (required)") + + _ = cmd.MarkFlagRequired("source-id") + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/campaign.go b/pkg/cmd/access-review/campaign/campaign.go new file mode 100644 index 000000000..92d384918 --- /dev/null +++ b/pkg/cmd/access-review/campaign/campaign.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package campaign + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/access-review/campaign/addsource" + "go.probo.inc/probo/pkg/cmd/access-review/campaign/cancel" + "go.probo.inc/probo/pkg/cmd/access-review/campaign/close" + "go.probo.inc/probo/pkg/cmd/access-review/campaign/create" + "go.probo.inc/probo/pkg/cmd/access-review/campaign/delete" + "go.probo.inc/probo/pkg/cmd/access-review/campaign/list" + "go.probo.inc/probo/pkg/cmd/access-review/campaign/removesource" + "go.probo.inc/probo/pkg/cmd/access-review/campaign/start" + "go.probo.inc/probo/pkg/cmd/access-review/campaign/update" + "go.probo.inc/probo/pkg/cmd/access-review/campaign/view" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +func NewCmdCampaign(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "campaign ", + Short: "Manage access review campaigns", + } + + cmd.AddCommand(list.NewCmdList(f)) + cmd.AddCommand(create.NewCmdCreate(f)) + cmd.AddCommand(view.NewCmdView(f)) + cmd.AddCommand(delete.NewCmdDelete(f)) + cmd.AddCommand(start.NewCmdStart(f)) + cmd.AddCommand(close.NewCmdClose(f)) + cmd.AddCommand(update.NewCmdUpdate(f)) + cmd.AddCommand(cancel.NewCmdCancel(f)) + cmd.AddCommand(addsource.NewCmdAddSource(f)) + cmd.AddCommand(removesource.NewCmdRemoveSource(f)) + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/cancel/cancel.go b/pkg/cmd/access-review/campaign/cancel/cancel.go new file mode 100644 index 000000000..4811943be --- /dev/null +++ b/pkg/cmd/access-review/campaign/cancel/cancel.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cancel + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const cancelMutation = ` +mutation($input: CancelAccessReviewCampaignInput!) { + cancelAccessReviewCampaign(input: $input) { + accessReviewCampaign { + id + name + status + } + } +} +` + +type cancelResponse struct { + CancelAccessReviewCampaign struct { + AccessReviewCampaign struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"accessReviewCampaign"` + } `json:"cancelAccessReviewCampaign"` +} + +func NewCmdCancel(f *cmdutil.Factory) *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel an access review campaign", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot cancel campaign: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Cancel access review campaign %s?", args[0])). + Value(&confirmed). + Run() + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + data, err := client.Do( + cancelMutation, + map[string]any{ + "input": map[string]any{ + "accessReviewCampaignId": args[0], + }, + }, + ) + if err != nil { + return err + } + + var resp cancelResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + c := resp.CancelAccessReviewCampaign.AccessReviewCampaign + out := f.IOStreams.Out + _, _ = fmt.Fprintf(out, "Cancelled access review campaign %s\n", c.ID) + _, _ = fmt.Fprintf(out, "Name: %s\n", c.Name) + _, _ = fmt.Fprintf(out, "Status: %s\n", c.Status) + + return nil + }, + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/close/close.go b/pkg/cmd/access-review/campaign/close/close.go new file mode 100644 index 000000000..3db26939b --- /dev/null +++ b/pkg/cmd/access-review/campaign/close/close.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package close + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const closeMutation = ` +mutation($input: CloseAccessReviewCampaignInput!) { + closeAccessReviewCampaign(input: $input) { + accessReviewCampaign { + id + name + status + } + } +} +` + +type closeResponse struct { + CloseAccessReviewCampaign struct { + AccessReviewCampaign struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"accessReviewCampaign"` + } `json:"closeAccessReviewCampaign"` +} + +func NewCmdClose(f *cmdutil.Factory) *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "close ", + Short: "Close an access review campaign", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot close campaign: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Close access review campaign %s?", args[0])). + Value(&confirmed). + Run() + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + data, err := client.Do( + closeMutation, + map[string]any{ + "input": map[string]any{ + "accessReviewCampaignId": args[0], + }, + }, + ) + if err != nil { + return err + } + + var resp closeResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + c := resp.CloseAccessReviewCampaign.AccessReviewCampaign + out := f.IOStreams.Out + _, _ = fmt.Fprintf(out, "Closed access review campaign %s\n", c.ID) + _, _ = fmt.Fprintf(out, "Name: %s\n", c.Name) + _, _ = fmt.Fprintf(out, "Status: %s\n", c.Status) + + return nil + }, + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/create/create.go b/pkg/cmd/access-review/campaign/create/create.go new file mode 100644 index 000000000..f20458891 --- /dev/null +++ b/pkg/cmd/access-review/campaign/create/create.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package create + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const createMutation = ` +mutation($input: CreateAccessReviewCampaignInput!) { + createAccessReviewCampaign(input: $input) { + accessReviewCampaignEdge { + node { + id + name + status + } + } + } +} +` + +type createResponse struct { + CreateAccessReviewCampaign struct { + AccessReviewCampaignEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"node"` + } `json:"accessReviewCampaignEdge"` + } `json:"createAccessReviewCampaign"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagName string + flagDescription string + flagSourceIDs []string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an access review campaign", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("cannot determine organization, use --org or 'prb auth login'") + } + + input := map[string]any{ + "organizationId": flagOrg, + "name": flagName, + } + + if flagDescription != "" { + input["description"] = flagDescription + } + + if len(flagSourceIDs) > 0 { + input["accessSourceIds"] = flagSourceIDs + } + + data, err := client.Do( + createMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp createResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + c := resp.CreateAccessReviewCampaign.AccessReviewCampaignEdge.Node + out := f.IOStreams.Out + _, _ = fmt.Fprintf(out, "Created access review campaign %s\n", c.ID) + _, _ = fmt.Fprintf(out, "Name: %s\n", c.Name) + _, _ = fmt.Fprintf(out, "Status: %s\n", c.Status) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringVar(&flagName, "name", "", "Campaign name (required)") + cmd.Flags().StringVar(&flagDescription, "description", "", "Campaign description") + cmd.Flags().StringSliceVar( + &flagSourceIDs, + "source-id", + nil, + "Access source IDs to include (can be repeated)", + ) + + _ = cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/delete/delete.go b/pkg/cmd/access-review/campaign/delete/delete.go new file mode 100644 index 000000000..04f47d50b --- /dev/null +++ b/pkg/cmd/access-review/campaign/delete/delete.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package delete + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const deleteMutation = ` +mutation($input: DeleteAccessReviewCampaignInput!) { + deleteAccessReviewCampaign(input: $input) { + deletedAccessReviewCampaignId + } +} +` + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an access review campaign", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot delete campaign: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete access review campaign %s?", args[0])). + Value(&confirmed). + Run() + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + _, err = client.Do( + deleteMutation, + map[string]any{ + "input": map[string]any{ + "accessReviewCampaignId": args[0], + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Deleted access review campaign %s\n", + args[0], + ) + + return nil + }, + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/list/list.go b/pkg/cmd/access-review/campaign/list/list.go new file mode 100644 index 000000000..309a9e660 --- /dev/null +++ b/pkg/cmd/access-review/campaign/list/list.go @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package list + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const listQuery = ` +query($id: ID!, $first: Int, $after: CursorKey, $orderBy: AccessReviewCampaignOrder) { + node(id: $id) { + __typename + ... on Organization { + accessReviewCampaigns(first: $first, after: $after, orderBy: $orderBy) { + totalCount + edges { + node { + id + name + status + startedAt + completedAt + createdAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type campaignNode struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + StartedAt *string `json:"startedAt"` + CompletedAt *string `json:"completedAt"` + CreatedAt string `json:"createdAt"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagLimit int + flagOrderBy string + flagOrderDir string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List access review campaigns", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("cannot determine organization, use --org or 'prb auth login'") + } + + variables := map[string]any{ + "id": flagOrg, + } + + if err := cmdutil.ValidateEnum("order-direction", flagOrderDir, []string{"ASC", "DESC"}); err != nil { + return err + } + + if flagOrderBy != "" { + if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT"}); err != nil { + return err + } + variables["orderBy"] = map[string]any{ + "field": flagOrderBy, + "direction": flagOrderDir, + } + } + + campaigns, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[campaignNode], error) { + var resp struct { + Node *struct { + Typename string `json:"__typename"` + AccessReviewCampaigns api.Connection[campaignNode] `json:"accessReviewCampaigns"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + if resp.Node == nil { + return nil, fmt.Errorf("organization %s not found", flagOrg) + } + if resp.Node.Typename != "Organization" { + return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename) + } + return &resp.Node.AccessReviewCampaigns, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + if campaigns == nil { + campaigns = []campaignNode{} + } + return cmdutil.PrintJSON(f.IOStreams.Out, campaigns) + } + + if len(campaigns) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No access review campaigns found.") + return nil + } + + rows := make([][]string, 0, len(campaigns)) + for _, c := range campaigns { + rows = append(rows, []string{ + c.ID, + c.Name, + c.Status, + cmdutil.FormatTime(c.CreatedAt), + }) + } + + t := cmdutil.NewTable("ID", "NAME", "STATUS", "CREATED").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(campaigns) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d campaigns\n", + len(campaigns), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of campaigns to list") + cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/removesource/removesource.go b/pkg/cmd/access-review/campaign/removesource/removesource.go new file mode 100644 index 000000000..2da120572 --- /dev/null +++ b/pkg/cmd/access-review/campaign/removesource/removesource.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package removesource + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const removeSourceMutation = ` +mutation($input: RemoveAccessReviewCampaignScopeSourceInput!) { + removeAccessReviewCampaignScopeSource(input: $input) { + accessReviewCampaign { + id + name + status + } + } +} +` + +type removeSourceResponse struct { + RemoveAccessReviewCampaignScopeSource struct { + AccessReviewCampaign struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"accessReviewCampaign"` + } `json:"removeAccessReviewCampaignScopeSource"` +} + +func NewCmdRemoveSource(f *cmdutil.Factory) *cobra.Command { + var flagSourceID string + + cmd := &cobra.Command{ + Use: "remove-source ", + Short: "Remove a scope source from an access review campaign", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "accessReviewCampaignId": args[0], + "accessSourceId": flagSourceID, + } + + data, err := client.Do( + removeSourceMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp removeSourceResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + c := resp.RemoveAccessReviewCampaignScopeSource.AccessReviewCampaign + out := f.IOStreams.Out + _, _ = fmt.Fprintf(out, "Removed source %s from campaign %s\n", flagSourceID, c.ID) + + return nil + }, + } + + cmd.Flags().StringVar(&flagSourceID, "source-id", "", "Access source ID to remove (required)") + + _ = cmd.MarkFlagRequired("source-id") + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/start/start.go b/pkg/cmd/access-review/campaign/start/start.go new file mode 100644 index 000000000..82d5c3c72 --- /dev/null +++ b/pkg/cmd/access-review/campaign/start/start.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package start + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const startMutation = ` +mutation($input: StartAccessReviewCampaignInput!) { + startAccessReviewCampaign(input: $input) { + accessReviewCampaign { + id + name + status + } + } +} +` + +type startResponse struct { + StartAccessReviewCampaign struct { + AccessReviewCampaign struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"accessReviewCampaign"` + } `json:"startAccessReviewCampaign"` +} + +func NewCmdStart(f *cmdutil.Factory) *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "start ", + Short: "Start an access review campaign", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot start campaign: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Start access review campaign %s?", args[0])). + Value(&confirmed). + Run() + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "accessReviewCampaignId": args[0], + } + + data, err := client.Do( + startMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp startResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + c := resp.StartAccessReviewCampaign.AccessReviewCampaign + out := f.IOStreams.Out + _, _ = fmt.Fprintf(out, "Started access review campaign %s\n", c.ID) + _, _ = fmt.Fprintf(out, "Name: %s\n", c.Name) + _, _ = fmt.Fprintf(out, "Status: %s\n", c.Status) + + return nil + }, + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/update/update.go b/pkg/cmd/access-review/campaign/update/update.go new file mode 100644 index 000000000..528a5539e --- /dev/null +++ b/pkg/cmd/access-review/campaign/update/update.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package update + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const updateMutation = ` +mutation($input: UpdateAccessReviewCampaignInput!) { + updateAccessReviewCampaign(input: $input) { + accessReviewCampaign { + id + name + status + } + } +} +` + +type updateResponse struct { + UpdateAccessReviewCampaign struct { + AccessReviewCampaign struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"accessReviewCampaign"` + } `json:"updateAccessReviewCampaign"` +} + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var ( + flagName string + flagDescription string + flagFrameworkControl []string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an access review campaign", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "accessReviewCampaignId": args[0], + } + + if cmd.Flags().Changed("name") { + input["name"] = flagName + } + + if cmd.Flags().Changed("description") { + input["description"] = flagDescription + } + + if cmd.Flags().Changed("framework-control") { + input["frameworkControls"] = flagFrameworkControl + } + + data, err := client.Do( + updateMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp updateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + c := resp.UpdateAccessReviewCampaign.AccessReviewCampaign + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, c) + } + + _, _ = fmt.Fprintf(f.IOStreams.Out, "Updated access review campaign %s\n", c.ID) + _, _ = fmt.Fprintf(f.IOStreams.Out, "Name: %s\n", c.Name) + _, _ = fmt.Fprintf(f.IOStreams.Out, "Status: %s\n", c.Status) + + return nil + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Campaign name") + cmd.Flags().StringVar(&flagDescription, "description", "", "Campaign description") + cmd.Flags().StringSliceVar( + &flagFrameworkControl, + "framework-control", + nil, + "Framework control IDs (can be repeated)", + ) + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/access-review/campaign/view/view.go b/pkg/cmd/access-review/campaign/view/view.go new file mode 100644 index 000000000..f9fa6e152 --- /dev/null +++ b/pkg/cmd/access-review/campaign/view/view.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package view + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const viewQuery = ` +query($id: ID!) { + node(id: $id) { + __typename + ... on AccessReviewCampaign { + id + name + status + startedAt + completedAt + createdAt + updatedAt + statistics { + totalCount + } + } + } +} +` + +type viewResponse struct { + Node *struct { + Typename string `json:"__typename"` + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + StartedAt *string `json:"startedAt"` + CompletedAt *string `json:"completedAt"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Statistics struct { + TotalCount int `json:"totalCount"` + } `json:"statistics"` + } `json:"node"` +} + +func NewCmdView(f *cmdutil.Factory) *cobra.Command { + var flagOutput *string + + cmd := &cobra.Command{ + Use: "view ", + Short: "View an access review campaign", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + data, err := client.Do( + viewQuery, + map[string]any{"id": args[0]}, + ) + if err != nil { + return err + } + + var resp viewResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + if resp.Node == nil { + return fmt.Errorf("access review campaign %s not found", args[0]) + } + + if resp.Node.Typename != "AccessReviewCampaign" { + return fmt.Errorf("expected AccessReviewCampaign node, got %s", resp.Node.Typename) + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node) + } + + c := resp.Node + out := f.IOStreams.Out + + bold := lipgloss.NewStyle().Bold(true) + label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22) + + _, _ = fmt.Fprintf(out, "%s\n\n", bold.Render("Access Review Campaign")) + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), c.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Name:"), c.Name) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Status:"), c.Status) + _, _ = fmt.Fprintf(out, "%s%d\n", label.Render("Total Entries:"), c.Statistics.TotalCount) + + if c.StartedAt != nil { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Started:"), cmdutil.FormatTime(*c.StartedAt)) + } + if c.CompletedAt != nil { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Completed:"), cmdutil.FormatTime(*c.CompletedAt)) + } + + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(c.CreatedAt)) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(c.UpdatedAt)) + + return nil + }, + } + + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/access-review/entry/decide/decide.go b/pkg/cmd/access-review/entry/decide/decide.go new file mode 100644 index 000000000..c622e2ac1 --- /dev/null +++ b/pkg/cmd/access-review/entry/decide/decide.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package decide + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const decideMutation = ` +mutation($input: RecordAccessEntryDecisionInput!) { + recordAccessEntryDecision(input: $input) { + accessEntry { + id + email + fullName + decision + decisionNote + decidedAt + } + } +} +` + +type decideResponse struct { + RecordAccessEntryDecision struct { + AccessEntry struct { + ID string `json:"id"` + Email string `json:"email"` + FullName string `json:"fullName"` + Decision string `json:"decision"` + DecisionNote *string `json:"decisionNote"` + DecidedAt *string `json:"decidedAt"` + } `json:"accessEntry"` + } `json:"recordAccessEntryDecision"` +} + +func NewCmdDecide(f *cmdutil.Factory) *cobra.Command { + var ( + flagDecision string + flagNote string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "decide ", + Short: "Record a decision on an access entry", + Args: cobra.ExactArgs(1), + Example: ` # Approve an access entry + prb access-review entry decide --decision APPROVED + + # Revoke with a note + prb access-review entry decide --decision REVOKE --note "User left the company" + + # Defer a decision + prb access-review entry decide --decision DEFER --note "Need more context"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + if err := cmdutil.ValidateEnum( + "decision", + flagDecision, + []string{"APPROVED", "REVOKE", "DEFER", "ESCALATE"}, + ); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "accessEntryId": args[0], + "decision": flagDecision, + } + if flagNote != "" { + input["decisionNote"] = flagNote + } + + data, err := client.Do( + decideMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp decideResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + e := resp.RecordAccessEntryDecision.AccessEntry + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, e) + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Recorded decision %s on entry %s\n", + e.Decision, + e.ID, + ) + + return nil + }, + } + + cmd.Flags().StringVar( + &flagDecision, + "decision", + "", + "Decision to record (APPROVED, REVOKE, DEFER, ESCALATE)", + ) + _ = cmd.MarkFlagRequired("decision") + cmd.Flags().StringVar(&flagNote, "note", "", "Decision note") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/access-review/entry/decideall/decideall.go b/pkg/cmd/access-review/entry/decideall/decideall.go new file mode 100644 index 000000000..001749ea6 --- /dev/null +++ b/pkg/cmd/access-review/entry/decideall/decideall.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package decideall + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const decideAllMutation = ` +mutation($input: RecordAccessEntryDecisionsInput!) { + recordAccessEntryDecisions(input: $input) { + accessEntries { + id + email + decision + } + } +} +` + +type decideAllResponse struct { + RecordAccessEntryDecisions struct { + AccessEntries []struct { + ID string `json:"id"` + Email string `json:"email"` + Decision string `json:"decision"` + } `json:"accessEntries"` + } `json:"recordAccessEntryDecisions"` +} + +func NewCmdDecideAll(f *cmdutil.Factory) *cobra.Command { + var ( + flagEntryIDs []string + flagDecision string + flagNote string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "decide-all", + Short: "Record decisions on multiple access entries", + Args: cobra.NoArgs, + Example: ` # Approve multiple entries + prb access-review entry decide-all --entry-id --entry-id --decision APPROVED + + # Revoke multiple entries with a note + prb access-review entry decide-all --entry-id --entry-id --decision REVOKE --note "Batch cleanup"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + if err := cmdutil.ValidateEnum( + "decision", + flagDecision, + []string{"APPROVED", "REVOKE", "DEFER", "ESCALATE"}, + ); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + decisions := make([]map[string]any, len(flagEntryIDs)) + for i, id := range flagEntryIDs { + d := map[string]any{ + "accessEntryId": id, + "decision": flagDecision, + } + if flagNote != "" { + d["decisionNote"] = flagNote + } + decisions[i] = d + } + + data, err := client.Do( + decideAllMutation, + map[string]any{"input": map[string]any{"decisions": decisions}}, + ) + if err != nil { + return err + } + + var resp decideAllResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + entries := resp.RecordAccessEntryDecisions.AccessEntries + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, entries) + } + + for _, e := range entries { + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Recorded decision %s on entry %s\n", + e.Decision, + e.ID, + ) + } + + return nil + }, + } + + cmd.Flags().StringSliceVar( + &flagEntryIDs, + "entry-id", + nil, + "Access entry IDs (can be repeated)", + ) + _ = cmd.MarkFlagRequired("entry-id") + cmd.Flags().StringVar( + &flagDecision, + "decision", + "", + "Decision to record (APPROVED, REVOKE, DEFER, ESCALATE)", + ) + _ = cmd.MarkFlagRequired("decision") + cmd.Flags().StringVar(&flagNote, "note", "", "Decision note (applied to all entries)") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/access-review/entry/entry.go b/pkg/cmd/access-review/entry/entry.go new file mode 100644 index 000000000..4f5f6730c --- /dev/null +++ b/pkg/cmd/access-review/entry/entry.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package entry + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/access-review/entry/decide" + "go.probo.inc/probo/pkg/cmd/access-review/entry/decideall" + "go.probo.inc/probo/pkg/cmd/access-review/entry/list" + "go.probo.inc/probo/pkg/cmd/access-review/entry/setflag" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +func NewCmdEntry(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "entry ", + Short: "Manage access review entries", + } + + cmd.AddCommand(list.NewCmdList(f)) + cmd.AddCommand(setflag.NewCmdFlag(f)) + cmd.AddCommand(decide.NewCmdDecide(f)) + cmd.AddCommand(decideall.NewCmdDecideAll(f)) + + return cmd +} diff --git a/pkg/cmd/access-review/entry/list/list.go b/pkg/cmd/access-review/entry/list/list.go new file mode 100644 index 000000000..d38fc1df8 --- /dev/null +++ b/pkg/cmd/access-review/entry/list/list.go @@ -0,0 +1,330 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package list + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const listQuery = ` +query( + $id: ID!, + $first: Int, + $after: CursorKey, + $orderBy: AccessEntryOrder, + $accessSourceId: ID, + $filter: AccessEntryFilter +) { + node(id: $id) { + __typename + ... on AccessReviewCampaign { + entries( + first: $first, + after: $after, + orderBy: $orderBy, + accessSourceId: $accessSourceId, + filter: $filter + ) { + totalCount + edges { + node { + id + email + fullName + role + jobTitle + isAdmin + mfaStatus + authMethod + accountType + lastLogin + externalId + incrementalTag + flags + flagReasons + decision + decisionNote + accessSource { + id + name + } + createdAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type entryNode struct { + ID string `json:"id"` + Email string `json:"email"` + FullName string `json:"fullName"` + Role string `json:"role"` + JobTitle string `json:"jobTitle"` + IsAdmin bool `json:"isAdmin"` + MfaStatus string `json:"mfaStatus"` + AuthMethod string `json:"authMethod"` + AccountType string `json:"accountType"` + LastLogin *string `json:"lastLogin"` + ExternalID string `json:"externalId"` + IncrementalTag string `json:"incrementalTag"` + Flags []string `json:"flags"` + FlagReasons []string `json:"flagReasons"` + Decision string `json:"decision"` + DecisionNote *string `json:"decisionNote"` + AccessSource struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"accessSource"` + CreatedAt string `json:"createdAt"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagLimit int + flagOrderBy string + flagOrderDir string + flagSourceID string + flagDecision string + flagFlag string + flagIncTag string + flagIsAdmin *bool + flagAuthMethod string + flagAccountType string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list ", + Short: "List access entries for a campaign", + Args: cobra.ExactArgs(1), + Example: ` # List all entries for a campaign + prb access-review entry list + + # List entries for a specific source + prb access-review entry list --source-id + + # List only pending entries + prb access-review entry list --decision PENDING + + # List flagged entries + prb access-review entry list --flag ORPHANED`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + variables := map[string]any{ + "id": args[0], + } + + if err := cmdutil.ValidateEnum("order-direction", flagOrderDir, []string{"ASC", "DESC"}); err != nil { + return err + } + + if flagOrderBy != "" { + if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT"}); err != nil { + return err + } + variables["orderBy"] = map[string]any{ + "field": flagOrderBy, + "direction": flagOrderDir, + } + } + + if flagSourceID != "" { + variables["accessSourceId"] = flagSourceID + } + + filter := map[string]any{} + if flagDecision != "" { + if err := cmdutil.ValidateEnum( + "decision", + flagDecision, + []string{"PENDING", "APPROVED", "REVOKE", "DEFER", "ESCALATE"}, + ); err != nil { + return err + } + filter["decision"] = flagDecision + } + if flagFlag != "" { + if err := cmdutil.ValidateEnum( + "flag", + flagFlag, + []string{ + "NONE", "ORPHANED", "INACTIVE", "EXCESSIVE", "ROLE_MISMATCH", + "NEW", "DORMANT", "TERMINATED_USER", "CONTRACTOR_EXPIRED", + "SOD_CONFLICT", "PRIVILEGED_ACCESS", "ROLE_CREEP", + "NO_BUSINESS_JUSTIFICATION", "OUT_OF_DEPARTMENT", "SHARED_ACCOUNT", + }, + ); err != nil { + return err + } + filter["flag"] = flagFlag + } + if flagIncTag != "" { + if err := cmdutil.ValidateEnum( + "incremental-tag", + flagIncTag, + []string{"NEW", "REMOVED", "UNCHANGED"}, + ); err != nil { + return err + } + filter["incrementalTag"] = flagIncTag + } + if cmd.Flags().Changed("is-admin") { + filter["isAdmin"] = *flagIsAdmin + } + if flagAuthMethod != "" { + if err := cmdutil.ValidateEnum( + "auth-method", + flagAuthMethod, + []string{"SSO", "PASSWORD", "API_KEY", "SERVICE_ACCOUNT", "UNKNOWN"}, + ); err != nil { + return err + } + filter["authMethod"] = flagAuthMethod + } + if flagAccountType != "" { + if err := cmdutil.ValidateEnum( + "account-type", + flagAccountType, + []string{"USER", "SERVICE_ACCOUNT"}, + ); err != nil { + return err + } + filter["accountType"] = flagAccountType + } + if len(filter) > 0 { + variables["filter"] = filter + } + + entries, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[entryNode], error) { + var resp struct { + Node *struct { + Typename string `json:"__typename"` + Entries api.Connection[entryNode] `json:"entries"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + if resp.Node == nil { + return nil, fmt.Errorf("campaign %s not found", args[0]) + } + if resp.Node.Typename != "AccessReviewCampaign" { + return nil, fmt.Errorf("expected AccessReviewCampaign node, got %s", resp.Node.Typename) + } + return &resp.Node.Entries, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + if entries == nil { + entries = []entryNode{} + } + return cmdutil.PrintJSON(f.IOStreams.Out, entries) + } + + if len(entries) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No access entries found.") + return nil + } + + rows := make([][]string, 0, len(entries)) + for _, e := range entries { + admin := "" + if e.IsAdmin { + admin = "yes" + } + rows = append(rows, []string{ + e.ID, + e.Email, + e.FullName, + e.AccessSource.Name, + e.Decision, + strings.Join(e.Flags, ","), + admin, + }) + } + + t := cmdutil.NewTable("ID", "EMAIL", "NAME", "SOURCE", "DECISION", "FLAGS", "ADMIN").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(entries) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d entries\n", + len(entries), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of entries to list") + cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + cmd.Flags().StringVar(&flagSourceID, "source-id", "", "Filter by access source ID") + cmd.Flags().StringVar(&flagDecision, "decision", "", "Filter by decision (PENDING, APPROVED, REVOKE, DEFER, ESCALATE)") + cmd.Flags().StringVar(&flagFlag, "flag", "", "Filter by flag (NONE, ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW)") + cmd.Flags().StringVar(&flagIncTag, "incremental-tag", "", "Filter by incremental tag (NEW, REMOVED, UNCHANGED)") + flagIsAdmin = cmd.Flags().Bool("is-admin", false, "Filter by admin status") + cmd.Flags().StringVar(&flagAuthMethod, "auth-method", "", "Filter by auth method (SSO, PASSWORD, API_KEY, SERVICE_ACCOUNT, UNKNOWN)") + cmd.Flags().StringVar(&flagAccountType, "account-type", "", "Filter by account type (USER, SERVICE_ACCOUNT)") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/access-review/entry/setflag/flag.go b/pkg/cmd/access-review/entry/setflag/flag.go new file mode 100644 index 000000000..97ac49c56 --- /dev/null +++ b/pkg/cmd/access-review/entry/setflag/flag.go @@ -0,0 +1,153 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package setflag + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const flagMutation = ` +mutation($input: FlagAccessEntryInput!) { + flagAccessEntry(input: $input) { + accessEntry { + id + email + fullName + flags + flagReasons + decision + } + } +} +` + +type flagResponse struct { + FlagAccessEntry struct { + AccessEntry struct { + ID string `json:"id"` + Email string `json:"email"` + FullName string `json:"fullName"` + Flags []string `json:"flags"` + FlagReasons []string `json:"flagReasons"` + Decision string `json:"decision"` + } `json:"accessEntry"` + } `json:"flagAccessEntry"` +} + +func NewCmdFlag(f *cmdutil.Factory) *cobra.Command { + var ( + flagFlags []string + flagReason string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "flag ", + Short: "Flag an access entry", + Args: cobra.ExactArgs(1), + Example: ` # Flag an entry as orphaned + prb access-review entry flag --flags ORPHANED --reason "No matching identity" + + # Flag an entry with multiple flags + prb access-review entry flag --flags ORPHANED,INACTIVE --reason "No login in 90 days" + + # Clear all flags + prb access-review entry flag --flags ""`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + validFlags := []string{ + "NONE", "ORPHANED", "INACTIVE", "EXCESSIVE", "ROLE_MISMATCH", "NEW", + "DORMANT", "TERMINATED_USER", "CONTRACTOR_EXPIRED", "SOD_CONFLICT", + "PRIVILEGED_ACCESS", "ROLE_CREEP", "NO_BUSINESS_JUSTIFICATION", + "OUT_OF_DEPARTMENT", "SHARED_ACCOUNT", + } + for _, f := range flagFlags { + if err := cmdutil.ValidateEnum("flags", f, validFlags); err != nil { + return err + } + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "accessEntryId": args[0], + "flags": flagFlags, + } + if flagReason != "" { + input["flagReasons"] = []string{flagReason} + } + + data, err := client.Do( + flagMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp flagResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + e := resp.FlagAccessEntry.AccessEntry + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, e) + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Flagged entry %s (%s) as %s\n", + e.ID, + e.Email, + strings.Join(e.Flags, ", "), + ) + + return nil + }, + } + + cmd.Flags().StringSliceVar(&flagFlags, "flags", nil, "Flags to set (ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW, etc.)") + _ = cmd.MarkFlagRequired("flags") + cmd.Flags().StringVar(&flagReason, "reason", "", "Reason for flagging") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/access-review/source/create/create.go b/pkg/cmd/access-review/source/create/create.go new file mode 100644 index 000000000..9f7f931d1 --- /dev/null +++ b/pkg/cmd/access-review/source/create/create.go @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package create + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const createMutation = ` +mutation($input: CreateAccessSourceInput!) { + createAccessSource(input: $input) { + accessSourceEdge { + node { + id + name + } + } + } +} +` + +type createResponse struct { + CreateAccessSource struct { + AccessSourceEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"accessSourceEdge"` + } `json:"createAccessSource"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagName string + flagCSVFile string + flagConnectorID string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an access source", + Example: ` # Create an access source from a CSV file + prb access-review source create --name "Okta Users" --csv-file users.csv + + # Create an access source with a connector + prb access-review source create --name "GitHub" --connector-id `, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("cannot determine organization, use --org or 'prb auth login'") + } + + if flagCSVFile != "" && flagConnectorID != "" { + return fmt.Errorf("cannot specify both --csv-file and --connector-id") + } + + input := map[string]any{ + "organizationId": flagOrg, + "name": flagName, + } + + if flagCSVFile != "" { + csvData, err := os.ReadFile(flagCSVFile) + if err != nil { + return fmt.Errorf("cannot read CSV file: %w", err) + } + input["csvData"] = string(csvData) + } + + if flagConnectorID != "" { + input["connectorId"] = flagConnectorID + } + + data, err := client.Do( + createMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp createResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + s := resp.CreateAccessSource.AccessSourceEdge.Node + out := f.IOStreams.Out + _, _ = fmt.Fprintf(out, "Created access source %s\n", s.ID) + _, _ = fmt.Fprintf(out, "Name: %s\n", s.Name) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringVar(&flagName, "name", "", "Access source name (required)") + cmd.Flags().StringVar(&flagCSVFile, "csv-file", "", "Path to CSV file with access data") + cmd.Flags().StringVar(&flagConnectorID, "connector-id", "", "Connector ID to use as data source") + + _ = cmd.MarkFlagRequired("name") + + return cmd +} diff --git a/pkg/cmd/access-review/source/delete/delete.go b/pkg/cmd/access-review/source/delete/delete.go new file mode 100644 index 000000000..5799d39f0 --- /dev/null +++ b/pkg/cmd/access-review/source/delete/delete.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package delete + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const deleteMutation = ` +mutation($input: DeleteAccessSourceInput!) { + deleteAccessSource(input: $input) { + deletedAccessSourceId + } +} +` + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var flagYes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an access source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot delete access source: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete access source %s?", args[0])). + Value(&confirmed). + Run() + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + _, err = client.Do( + deleteMutation, + map[string]any{ + "input": map[string]any{ + "accessSourceId": args[0], + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Deleted access source %s\n", + args[0], + ) + + return nil + }, + } + + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/access-review/source/list/list.go b/pkg/cmd/access-review/source/list/list.go new file mode 100644 index 000000000..00f1f8467 --- /dev/null +++ b/pkg/cmd/access-review/source/list/list.go @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package list + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const listQuery = ` +query($id: ID!, $first: Int, $after: CursorKey, $orderBy: AccessSourceOrder) { + node(id: $id) { + __typename + ... on Organization { + accessSources(first: $first, after: $after, orderBy: $orderBy) { + totalCount + edges { + node { + id + name + createdAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type sourceNode struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagLimit int + flagOrderBy string + flagOrderDir string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List access sources", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("cannot determine organization, use --org or 'prb auth login'") + } + + variables := map[string]any{ + "id": flagOrg, + } + + if err := cmdutil.ValidateEnum("order-direction", flagOrderDir, []string{"ASC", "DESC"}); err != nil { + return err + } + + if flagOrderBy != "" { + if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT"}); err != nil { + return err + } + variables["orderBy"] = map[string]any{ + "field": flagOrderBy, + "direction": flagOrderDir, + } + } + + sources, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[sourceNode], error) { + var resp struct { + Node *struct { + Typename string `json:"__typename"` + AccessSources api.Connection[sourceNode] `json:"accessSources"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + if resp.Node == nil { + return nil, fmt.Errorf("organization %s not found", flagOrg) + } + if resp.Node.Typename != "Organization" { + return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename) + } + return &resp.Node.AccessSources, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + if sources == nil { + sources = []sourceNode{} + } + return cmdutil.PrintJSON(f.IOStreams.Out, sources) + } + + if len(sources) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No access sources found.") + return nil + } + + rows := make([][]string, 0, len(sources)) + for _, s := range sources { + rows = append(rows, []string{ + s.ID, + s.Name, + cmdutil.FormatTime(s.CreatedAt), + }) + } + + t := cmdutil.NewTable("ID", "NAME", "CREATED").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(sources) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d access sources\n", + len(sources), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of access sources to list") + cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT)") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/access-review/source/source.go b/pkg/cmd/access-review/source/source.go new file mode 100644 index 000000000..5785d8c5a --- /dev/null +++ b/pkg/cmd/access-review/source/source.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package source + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/access-review/source/create" + "go.probo.inc/probo/pkg/cmd/access-review/source/delete" + "go.probo.inc/probo/pkg/cmd/access-review/source/list" + "go.probo.inc/probo/pkg/cmd/access-review/source/update" + "go.probo.inc/probo/pkg/cmd/access-review/source/view" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +func NewCmdSource(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "source ", + Short: "Manage access sources", + } + + cmd.AddCommand(list.NewCmdList(f)) + cmd.AddCommand(create.NewCmdCreate(f)) + cmd.AddCommand(view.NewCmdView(f)) + cmd.AddCommand(update.NewCmdUpdate(f)) + cmd.AddCommand(delete.NewCmdDelete(f)) + + return cmd +} diff --git a/pkg/cmd/access-review/source/update/update.go b/pkg/cmd/access-review/source/update/update.go new file mode 100644 index 000000000..0f4d6f5c5 --- /dev/null +++ b/pkg/cmd/access-review/source/update/update.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package update + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const updateMutation = ` +mutation($input: UpdateAccessSourceInput!) { + updateAccessSource(input: $input) { + accessSource { + id + name + } + } +} +` + +type updateResponse struct { + UpdateAccessSource struct { + AccessSource struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"accessSource"` + } `json:"updateAccessSource"` +} + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var ( + flagName string + flagCSVFile string + flagConnectorID string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an access source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + input := map[string]any{ + "accessSourceId": args[0], + } + + if cmd.Flags().Changed("name") { + input["name"] = flagName + } + + if cmd.Flags().Changed("csv-file") { + csvData, err := os.ReadFile(flagCSVFile) + if err != nil { + return fmt.Errorf("cannot read CSV file: %w", err) + } + input["csvData"] = string(csvData) + } + + if cmd.Flags().Changed("connector-id") { + input["connectorId"] = flagConnectorID + } + + data, err := client.Do( + updateMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp updateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + s := resp.UpdateAccessSource.AccessSource + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, s) + } + + _, _ = fmt.Fprintf(f.IOStreams.Out, "Updated access source %s\n", s.ID) + _, _ = fmt.Fprintf(f.IOStreams.Out, "Name: %s\n", s.Name) + + return nil + }, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Access source name") + cmd.Flags().StringVar(&flagCSVFile, "csv-file", "", "Path to CSV file with access data") + cmd.Flags().StringVar(&flagConnectorID, "connector-id", "", "Connector ID to use as data source") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/access-review/source/view/view.go b/pkg/cmd/access-review/source/view/view.go new file mode 100644 index 000000000..a87075dd9 --- /dev/null +++ b/pkg/cmd/access-review/source/view/view.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package view + +import ( + "encoding/json" + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const viewQuery = ` +query($id: ID!) { + node(id: $id) { + __typename + ... on AccessSource { + id + name + connectorId + createdAt + updatedAt + } + } +} +` + +type viewResponse struct { + Node *struct { + Typename string `json:"__typename"` + ID string `json:"id"` + Name string `json:"name"` + ConnectorID *string `json:"connectorId"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } `json:"node"` +} + +func NewCmdView(f *cmdutil.Factory) *cobra.Command { + var flagOutput *string + + cmd := &cobra.Command{ + Use: "view ", + Short: "View an access source", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil { + return err + } + + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + data, err := client.Do( + viewQuery, + map[string]any{"id": args[0]}, + ) + if err != nil { + return err + } + + var resp viewResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + if resp.Node == nil { + return fmt.Errorf("access source %s not found", args[0]) + } + + if resp.Node.Typename != "AccessSource" { + return fmt.Errorf("expected AccessSource node, got %s", resp.Node.Typename) + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node) + } + + s := resp.Node + out := f.IOStreams.Out + + bold := lipgloss.NewStyle().Bold(true) + label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22) + + _, _ = fmt.Fprintf(out, "%s\n\n", bold.Render("Access Source")) + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), s.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Name:"), s.Name) + + if s.ConnectorID != nil { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Connector:"), *s.ConnectorID) + } + + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(s.CreatedAt)) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(s.UpdatedAt)) + + return nil + }, + } + + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 55fe74ba6..05f646acc 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -16,6 +16,7 @@ package root import ( "github.com/spf13/cobra" + accessreview "go.probo.inc/probo/pkg/cmd/access-review" cmdapi "go.probo.inc/probo/pkg/cmd/api" "go.probo.inc/probo/pkg/cmd/auditlog" "go.probo.inc/probo/pkg/cmd/auth" @@ -66,6 +67,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command { "Disable ANSI color output (also set via NO_COLOR or TERM=dumb)", ) + cmd.AddCommand(accessreview.NewCmdAccessReview(f)) cmd.AddCommand(cmdapi.NewCmdAPI(f)) cmd.AddCommand(auditlog.NewCmdAuditLog(f)) cmd.AddCommand(auth.NewCmdAuth(f))