diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index ead64a157..a0584946d 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -44,6 +44,7 @@ import ( processingactivity "go.probo.inc/probo/pkg/cmd/processing-activity" rightsrequest "go.probo.inc/probo/pkg/cmd/rights-request" "go.probo.inc/probo/pkg/cmd/risk" + "go.probo.inc/probo/pkg/cmd/scim" "go.probo.inc/probo/pkg/cmd/soa" "go.probo.inc/probo/pkg/cmd/task" "go.probo.inc/probo/pkg/cmd/tia" @@ -111,6 +112,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(processingactivity.NewCmdProcessingActivity(f)) cmd.AddCommand(rightsrequest.NewCmdRightsRequest(f)) cmd.AddCommand(risk.NewCmdRisk(f)) + cmd.AddCommand(scim.NewCmdScim(f)) cmd.AddCommand(soa.NewCmdSoa(f)) cmd.AddCommand(task.NewCmdTask(f)) cmd.AddCommand(tia.NewCmdTIA(f)) diff --git a/pkg/cmd/scim/bridge/bridge.go b/pkg/cmd/scim/bridge/bridge.go new file mode 100644 index 000000000..2da20e31f --- /dev/null +++ b/pkg/cmd/scim/bridge/bridge.go @@ -0,0 +1,34 @@ +// 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 bridge + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/scim/bridge/update" + "go.probo.inc/probo/pkg/cmd/scim/bridge/view" +) + +func NewCmdBridge(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "bridge ", + Short: "Manage SCIM bridges", + } + + cmd.AddCommand(view.NewCmdView(f)) + cmd.AddCommand(update.NewCmdUpdate(f)) + + return cmd +} diff --git a/pkg/cmd/scim/bridge/update/update.go b/pkg/cmd/scim/bridge/update/update.go new file mode 100644 index 000000000..2f9699418 --- /dev/null +++ b/pkg/cmd/scim/bridge/update/update.go @@ -0,0 +1,134 @@ +// 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: UpdateSCIMBridgeInput!) { + updateSCIMBridge(input: $input) { + scimBridge { + id + state + excludedUserNames + } + } +} +` + +type updateResponse struct { + UpdateSCIMBridge struct { + ScimBridge struct { + ID string `json:"id"` + State string `json:"state"` + ExcludedUserNames []string `json:"excludedUserNames"` + } `json:"scimBridge"` + } `json:"updateSCIMBridge"` +} + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagExcludedUserNames []string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a SCIM bridge", + Example: ` # Set excluded user names + prb scim bridge update --excluded-user-names admin@example.com --excluded-user-names bot@example.com + + # Clear excluded user names + prb scim bridge update --excluded-user-names ""`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !cmd.Flags().Changed("excluded-user-names") { + return fmt.Errorf("at least one field must be specified for update; use --excluded-user-names") + } + + 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/connect/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'") + } + + excluded := make([]string, 0, len(flagExcludedUserNames)) + for _, name := range flagExcludedUserNames { + if name != "" { + excluded = append(excluded, name) + } + } + + data, err := client.Do( + updateMutation, + map[string]any{ + "input": map[string]any{ + "organizationId": flagOrg, + "scimBridgeId": args[0], + "excludedUserNames": excluded, + }, + }, + ) + if err != nil { + return err + } + + var resp updateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Updated SCIM bridge %s\n", + resp.UpdateSCIMBridge.ScimBridge.ID, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringSliceVar(&flagExcludedUserNames, "excluded-user-names", nil, "User names to exclude from SCIM sync (repeatable)") + + return cmd +} diff --git a/pkg/cmd/scim/bridge/view/view.go b/pkg/cmd/scim/bridge/view/view.go new file mode 100644 index 000000000..42b28a6e5 --- /dev/null +++ b/pkg/cmd/scim/bridge/view/view.go @@ -0,0 +1,164 @@ +// 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" + "strings" + + "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 SCIMBridge { + id + state + type + excludedUserNames + scimConfiguration { + id + } + connector { + id + provider + } + createdAt + updatedAt + } + } +} +` + +type ( + connector struct { + ID string `json:"id"` + Provider string `json:"provider"` + } + + viewResponse struct { + Node *struct { + Typename string `json:"__typename"` + ID string `json:"id"` + State string `json:"state"` + Type string `json:"type"` + ExcludedUserNames []string `json:"excludedUserNames"` + ScimConfiguration *struct { + ID string `json:"id"` + } `json:"scimConfiguration"` + Connector *connector `json:"connector"` + 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 a SCIM bridge", + 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/connect/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + 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("SCIM bridge %s not found", args[0]) + } + + if resp.Node.Typename != "SCIMBridge" { + return fmt.Errorf("expected SCIMBridge node, got %s", resp.Node.Typename) + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node) + } + + b := 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("SCIM Bridge")) + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), b.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("State:"), b.State) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Type:"), b.Type) + + if b.ScimConfiguration != nil { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Configuration ID:"), b.ScimConfiguration.ID) + } + + if b.Connector != nil { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Connector ID:"), b.Connector.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Connector Provider:"), b.Connector.Provider) + } + + if len(b.ExcludedUserNames) > 0 { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Excluded Users:"), strings.Join(b.ExcludedUserNames, ", ")) + } + + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(b.CreatedAt)) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(b.UpdatedAt)) + + return nil + }, + } + + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/scim/create/create.go b/pkg/cmd/scim/create/create.go new file mode 100644 index 000000000..41f36358d --- /dev/null +++ b/pkg/cmd/scim/create/create.go @@ -0,0 +1,141 @@ +// 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: CreateSCIMConfigurationInput!) { + createSCIMConfiguration(input: $input) { + scimConfiguration { + id + endpointUrl + } + scimBridge { + id + state + type + } + token + } +} +` + +type createResponse struct { + CreateSCIMConfiguration struct { + ScimConfiguration struct { + ID string `json:"id"` + EndpointURL string `json:"endpointUrl"` + } `json:"scimConfiguration"` + ScimBridge *struct { + ID string `json:"id"` + State string `json:"state"` + Type string `json:"type"` + } `json:"scimBridge"` + Token string `json:"token"` + } `json:"createSCIMConfiguration"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagConnectorID string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a SCIM configuration", + Example: ` # Create a SCIM configuration + prb scim create + + # Create with a connector to also set up a SCIM bridge + prb scim create --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/connect/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'") + } + + input := map[string]any{ + "organizationId": flagOrg, + } + + 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) + } + + out := f.IOStreams.Out + sc := resp.CreateSCIMConfiguration + + _, _ = fmt.Fprintf(out, "Created SCIM configuration %s\n", sc.ScimConfiguration.ID) + _, _ = fmt.Fprintf(out, "Endpoint URL: %s\n", sc.ScimConfiguration.EndpointURL) + + if sc.ScimBridge != nil { + _, _ = fmt.Fprintf(out, "Bridge: %s (%s, %s)\n", sc.ScimBridge.ID, sc.ScimBridge.Type, sc.ScimBridge.State) + } + + _, _ = fmt.Fprintf(out, "\nSCIM Bearer Token (save this — it will not be shown again):\n%s\n", sc.Token) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().StringVar(&flagConnectorID, "connector-id", "", "Connector ID to create a SCIM bridge") + + return cmd +} diff --git a/pkg/cmd/scim/delete/delete.go b/pkg/cmd/scim/delete/delete.go new file mode 100644 index 000000000..0745e795c --- /dev/null +++ b/pkg/cmd/scim/delete/delete.go @@ -0,0 +1,116 @@ +// 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: DeleteSCIMConfigurationInput!) { + deleteSCIMConfiguration(input: $input) { + deletedScimConfigurationId + } +} +` + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagYes bool + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a SCIM configuration", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot delete SCIM configuration: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title(fmt.Sprintf("Delete SCIM configuration %s? This will also remove the associated bridge.", 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/connect/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'") + } + + _, err = client.Do( + deleteMutation, + map[string]any{ + "input": map[string]any{ + "organizationId": flagOrg, + "scimConfigurationId": args[0], + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Deleted SCIM configuration %s\n", + args[0], + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/scim/event/event.go b/pkg/cmd/scim/event/event.go new file mode 100644 index 000000000..799067746 --- /dev/null +++ b/pkg/cmd/scim/event/event.go @@ -0,0 +1,32 @@ +// 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 event + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/scim/event/list" +) + +func NewCmdEvent(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "event ", + Short: "Manage SCIM events", + } + + cmd.AddCommand(list.NewCmdList(f)) + + return cmd +} diff --git a/pkg/cmd/scim/event/list/list.go b/pkg/cmd/scim/event/list/list.go new file mode 100644 index 000000000..a62e966d1 --- /dev/null +++ b/pkg/cmd/scim/event/list/list.go @@ -0,0 +1,184 @@ +// 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" + "strconv" + + "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: SCIMEventOrder) { + node(id: $id) { + __typename + ... on SCIMConfiguration { + events(first: $first, after: $after, orderBy: $orderBy) { + totalCount + edges { + node { + id + method + path + statusCode + userName + ipAddress + createdAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +` + +type scimEvent struct { + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int `json:"statusCode"` + UserName string `json:"userName"` + IPAddress string `json:"ipAddress"` + CreatedAt string `json:"createdAt"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + flagLimit int + flagOrderDir string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "list ", + Short: "List SCIM events for a configuration", + Example: ` # List recent SCIM events + prb scim event list + + # List oldest first + prb scim event list --order-direction ASC`, + Aliases: []string{"ls"}, + 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/connect/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + variables := map[string]any{ + "id": args[0], + "orderBy": map[string]any{ + "field": "CREATED_AT", + "direction": flagOrderDir, + }, + } + + events, totalCount, err := api.Paginate( + client, + listQuery, + variables, + flagLimit, + func(data json.RawMessage) (*api.Connection[scimEvent], error) { + var resp struct { + Node *struct { + Typename string `json:"__typename"` + Events api.Connection[scimEvent] `json:"events"` + } `json:"node"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + if resp.Node == nil { + return nil, fmt.Errorf("SCIM configuration %s not found", args[0]) + } + if resp.Node.Typename != "SCIMConfiguration" { + return nil, fmt.Errorf("expected SCIMConfiguration node, got %s", resp.Node.Typename) + } + return &resp.Node.Events, nil + }, + ) + if err != nil { + return err + } + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, events) + } + + if len(events) == 0 { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No SCIM events found.") + return nil + } + + rows := make([][]string, 0, len(events)) + for _, e := range events { + rows = append(rows, []string{ + e.ID, + e.Method, + e.Path, + strconv.Itoa(e.StatusCode), + e.UserName, + cmdutil.FormatTime(e.CreatedAt), + }) + } + + t := cmdutil.NewTable("ID", "METHOD", "PATH", "STATUS", "USER", "CREATED").Rows(rows...) + + _, _ = fmt.Fprintln(f.IOStreams.Out, t) + + if totalCount > len(events) { + _, _ = fmt.Fprintf( + f.IOStreams.ErrOut, + "\nShowing %d of %d events\n", + len(events), + totalCount, + ) + } + + return nil + }, + } + + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of events to list") + cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +} diff --git a/pkg/cmd/scim/regenerate-token/regenerate_token.go b/pkg/cmd/scim/regenerate-token/regenerate_token.go new file mode 100644 index 000000000..9d85943c2 --- /dev/null +++ b/pkg/cmd/scim/regenerate-token/regenerate_token.go @@ -0,0 +1,132 @@ +// 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 regeneratetoken + +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 regenerateMutation = ` +mutation($input: RegenerateSCIMTokenInput!) { + regenerateSCIMToken(input: $input) { + scimConfiguration { + id + } + token + } +} +` + +type regenerateResponse struct { + RegenerateSCIMToken struct { + ScimConfiguration struct { + ID string `json:"id"` + } `json:"scimConfiguration"` + Token string `json:"token"` + } `json:"regenerateSCIMToken"` +} + +func NewCmdRegenerateToken(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagYes bool + ) + + cmd := &cobra.Command{ + Use: "regenerate-token ", + Short: "Regenerate the SCIM bearer token", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !flagYes { + if !f.IOStreams.IsInteractive() { + return fmt.Errorf("cannot regenerate SCIM token: confirmation required, use --yes to confirm") + } + + var confirmed bool + err := huh.NewConfirm(). + Title("Regenerate SCIM token? The current token will be invalidated."). + 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/connect/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'") + } + + data, err := client.Do( + regenerateMutation, + map[string]any{ + "input": map[string]any{ + "organizationId": flagOrg, + "scimConfigurationId": args[0], + }, + }, + ) + if err != nil { + return err + } + + var resp regenerateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + out := f.IOStreams.Out + _, _ = fmt.Fprintf(out, "Regenerated SCIM token for configuration %s\n", resp.RegenerateSCIMToken.ScimConfiguration.ID) + _, _ = fmt.Fprintf(out, "\nSCIM Bearer Token (save this — it will not be shown again):\n%s\n", resp.RegenerateSCIMToken.Token) + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/pkg/cmd/scim/scim.go b/pkg/cmd/scim/scim.go new file mode 100644 index 000000000..6660c79de --- /dev/null +++ b/pkg/cmd/scim/scim.go @@ -0,0 +1,42 @@ +// 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 scim + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/scim/bridge" + "go.probo.inc/probo/pkg/cmd/scim/create" + "go.probo.inc/probo/pkg/cmd/scim/delete" + "go.probo.inc/probo/pkg/cmd/scim/event" + regeneratetoken "go.probo.inc/probo/pkg/cmd/scim/regenerate-token" + "go.probo.inc/probo/pkg/cmd/scim/view" +) + +func NewCmdScim(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "scim ", + Short: "Manage SCIM provisioning", + } + + cmd.AddCommand(view.NewCmdView(f)) + cmd.AddCommand(create.NewCmdCreate(f)) + cmd.AddCommand(delete.NewCmdDelete(f)) + cmd.AddCommand(regeneratetoken.NewCmdRegenerateToken(f)) + cmd.AddCommand(bridge.NewCmdBridge(f)) + cmd.AddCommand(event.NewCmdEvent(f)) + + return cmd +} diff --git a/pkg/cmd/scim/view/view.go b/pkg/cmd/scim/view/view.go new file mode 100644 index 000000000..0c07c422b --- /dev/null +++ b/pkg/cmd/scim/view/view.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 view + +import ( + "encoding/json" + "fmt" + "strings" + + "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 Organization { + scimConfiguration { + id + endpointUrl + bridge { + id + state + type + excludedUserNames + connector { + id + provider + } + } + createdAt + updatedAt + } + } + } +} +` + +type ( + connector struct { + ID string `json:"id"` + Provider string `json:"provider"` + } + + scimBridge struct { + ID string `json:"id"` + State string `json:"state"` + Type string `json:"type"` + ExcludedUserNames []string `json:"excludedUserNames"` + Connector *connector `json:"connector"` + } + + scimConfiguration struct { + ID string `json:"id"` + EndpointURL string `json:"endpointUrl"` + Bridge *scimBridge `json:"bridge"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + } + + viewResponse struct { + Node *struct { + Typename string `json:"__typename"` + ScimConfiguration *scimConfiguration `json:"scimConfiguration"` + } `json:"node"` + } +) + +func NewCmdView(f *cmdutil.Factory) *cobra.Command { + var ( + flagOrg string + flagOutput *string + ) + + cmd := &cobra.Command{ + Use: "view", + Short: "View SCIM configuration for an organization", + Example: ` # View SCIM configuration for the default organization + prb scim view + + # View as JSON + prb scim view --json`, + 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/connect/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'") + } + + data, err := client.Do( + viewQuery, + map[string]any{"id": flagOrg}, + ) + 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("organization %s not found", flagOrg) + } + + if resp.Node.Typename != "Organization" { + return fmt.Errorf("expected Organization node, got %s", resp.Node.Typename) + } + + if resp.Node.ScimConfiguration == nil { + _, _ = fmt.Fprintln(f.IOStreams.Out, "No SCIM configuration found.") + return nil + } + + sc := resp.Node.ScimConfiguration + + if *flagOutput == cmdutil.OutputJSON { + return cmdutil.PrintJSON(f.IOStreams.Out, sc) + } + + 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("SCIM Configuration")) + + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), sc.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Endpoint URL:"), sc.EndpointURL) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(sc.CreatedAt)) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(sc.UpdatedAt)) + + if sc.Bridge != nil { + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintf(out, "%s\n\n", bold.Render("Bridge")) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Bridge ID:"), sc.Bridge.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("State:"), sc.Bridge.State) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Type:"), sc.Bridge.Type) + + if sc.Bridge.Connector != nil { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Connector ID:"), sc.Bridge.Connector.ID) + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Connector Provider:"), sc.Bridge.Connector.Provider) + } + + if len(sc.Bridge.ExcludedUserNames) > 0 { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Excluded Users:"), strings.Join(sc.Bridge.ExcludedUserNames, ", ")) + } + } + + return nil + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + flagOutput = cmdutil.AddOutputFlag(cmd) + + return cmd +}