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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions internal/adm/idp/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
Copyright 2026 The Butler Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package idp

import (
"context"
"fmt"
"os"

"github.com/butlerdotdev/butler/internal/common/client"
"github.com/butlerdotdev/butler/internal/common/log"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/yaml"
)

func newCreateCmd(logger *log.Logger) *cobra.Command {
var (
fromFile string
kubeconfig string
)

cmd := &cobra.Command{
Use: "create NAME",
Short: "Create an identity provider",
Long: `Create an IdentityProvider resource from a YAML file.

The YAML file should contain a complete IdentityProvider manifest including
apiVersion, kind, metadata, and spec. The OAuth2 client secret is supplied by
reference: spec.oidc.clientSecretRef must point at a Secret you create
separately (for example with kubectl create secret). This matches
'provider create --from-file'.

The file's spec can carry any IdentityProvider field, including advanced
options the console form does not expose (spec.oidc.insecureSkipVerify and
spec.oidc.googleWorkspace).

If the file carries metadata.name, it must match NAME.

Examples:
# Create from a YAML file
butleradm idp create okta --from-file okta.yaml`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
kubeContext, _ := cmd.Flags().GetString("context")
return runCreate(cmd.Context(), logger, args[0], fromFile, kubeconfig, kubeContext)
},
}

cmd.Flags().StringVar(&fromFile, "from-file", "", "path to a YAML file containing the IdentityProvider manifest (required)")
cmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "path to management cluster kubeconfig")

_ = cmd.MarkFlagRequired("from-file")

return cmd
}

func runCreate(ctx context.Context, logger *log.Logger, name, fromFile, kubeconfigPath, kubeContext string) error {
data, err := os.ReadFile(fromFile)
if err != nil {
return fmt.Errorf("reading file %s: %w", fromFile, err)
}

obj := &unstructured.Unstructured{}
if err := yaml.Unmarshal(data, &obj.Object); err != nil {
return fmt.Errorf("parsing YAML from %s: %w", fromFile, err)
}

if kind := obj.GetKind(); kind != "" && kind != "IdentityProvider" {
return fmt.Errorf("expected kind IdentityProvider, got %s", kind)
}
if fileName := obj.GetName(); fileName != "" && fileName != name {
return fmt.Errorf("metadata.name %q in %s does not match NAME %q", fileName, fromFile, name)
}

if obj.GetAPIVersion() == "" {
obj.SetAPIVersion("butler.butlerlabs.dev/v1alpha1")
}
obj.SetKind("IdentityProvider")
obj.SetName(name)

c, err := client.New(kubeconfigPath, kubeContext)
if err != nil {
return fmt.Errorf("connecting to management cluster: %w", err)
}

// IdentityProvider is cluster-scoped: no namespace.
_, err = c.Dynamic.Resource(client.IdentityProviderGVR).Create(ctx, obj, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("creating IdentityProvider: %w", err)
}

logger.Success("identity provider created", "name", name)
return nil
}
197 changes: 197 additions & 0 deletions internal/adm/idp/discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/*
Copyright 2026 The Butler Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package idp

import (
"context"
"errors"
"fmt"
"io"
"os"

"github.com/butlerdotdev/butler/internal/common/log"
"github.com/butlerdotdev/butler/internal/common/output"
"github.com/butlerdotdev/butler/internal/common/serverhttp"
"github.com/spf13/cobra"
)

// testDiscoveryRequest mirrors butler-server's TestDiscoveryRequest
// (POST /api/admin/identity-providers/test).
type testDiscoveryRequest struct {
IssuerURL string `json:"issuerURL"`
}

// testDiscoveryResponse mirrors butler-server's TestDiscoveryResponse. Both
// the test (issuer URL) and validate (existing IdP) endpoints return it. The
// underlying probe is a read-only OIDC discovery fetch, so neither verb
// mutates anything.
type testDiscoveryResponse struct {
Valid bool `json:"valid"`
Message string `json:"message"`
AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"`
TokenEndpoint string `json:"tokenEndpoint,omitempty"`
UserInfoEndpoint string `json:"userInfoEndpoint,omitempty"`
JWKSURI string `json:"jwksURI,omitempty"`
}

// --- test ---

func newTestCmd(_ *log.Logger) *cobra.Command {
var (
issuerURL string
outputFormat string
)

cmd := &cobra.Command{
Use: "test",
Short: "Test OIDC discovery for an issuer URL",
Long: `Probe an OIDC issuer's discovery document before creating an identity
provider. The server fetches the issuer's .well-known/openid-configuration and
reports whether discovery succeeds and which endpoints it found.

This is a read-only probe: it does not create or change any resource.

Examples:
butleradm idp test --issuer-url https://accounts.google.com
butleradm idp test --issuer-url https://example.okta.com -o json`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runTest(cmd.Context(), os.Stdout, issuerURL, outputFormat)
},
}

cmd.Flags().StringVar(&issuerURL, "issuer-url", "", "OIDC issuer URL to probe (required)")
cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "output format (table, json, yaml)")
_ = cmd.MarkFlagRequired("issuer-url")

return cmd
}

func runTest(ctx context.Context, out io.Writer, issuerURL, outputFormat string) error {
sh, err := serverhttp.New()
if err != nil {
return err
}
var res testDiscoveryResponse
if err := sh.Post(ctx, "/api/admin/identity-providers/test", testDiscoveryRequest{IssuerURL: issuerURL}, &res); err != nil {
return translateDiscoveryError(err)
}
return renderDiscovery(out, res, outputFormat)
}

// --- validate ---

func newValidateCmd(_ *log.Logger) *cobra.Command {
var outputFormat string

cmd := &cobra.Command{
Use: "validate NAME",
Short: "Test OIDC discovery for an existing identity provider",
Long: `Probe the OIDC discovery document of an existing identity provider, using
the issuer URL already configured on it. Reports whether discovery succeeds and
which endpoints it found.

This is a read-only probe: it does not change the identity provider.

Examples:
butleradm idp validate google-workspace
butleradm idp validate okta -o json`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runValidate(cmd.Context(), os.Stdout, args[0], outputFormat)
},
}

cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "output format (table, json, yaml)")

return cmd
}

func runValidate(ctx context.Context, out io.Writer, name, outputFormat string) error {
sh, err := serverhttp.New()
if err != nil {
return err
}
path := fmt.Sprintf("/api/admin/identity-providers/%s/validate", name)
var res testDiscoveryResponse
if err := sh.Post(ctx, path, struct{}{}, &res); err != nil {
return translateDiscoveryError(err)
}
return renderDiscovery(out, res, outputFormat)
}

// --- shared rendering + error translation ---

func renderDiscovery(out io.Writer, res testDiscoveryResponse, outputFormat string) error {
switch outputFormat {
case "json":
return output.PrintJSON(out, res)
case "yaml":
return output.PrintYAML(out, res)
case "", "table":
return printDiscovery(out, res)
default:
return fmt.Errorf("unsupported output format %q (use table, json or yaml)", outputFormat)
}
}

func printDiscovery(out io.Writer, res testDiscoveryResponse) error {
status := "INVALID"
if res.Valid {
status = "VALID"
}
fmt.Fprintf(out, "Discovery: %s\n", status)
if res.Message != "" {
fmt.Fprintf(out, "Message: %s\n", res.Message)
}
if !res.Valid {
return nil
}
t := output.NewTable(out, "ENDPOINT", "URL")
if res.AuthorizationEndpoint != "" {
t.AddRow("authorization", res.AuthorizationEndpoint)
}
if res.TokenEndpoint != "" {
t.AddRow("token", res.TokenEndpoint)
}
if res.UserInfoEndpoint != "" {
t.AddRow("userinfo", res.UserInfoEndpoint)
}
if res.JWKSURI != "" {
t.AddRow("jwks", res.JWKSURI)
}
return t.Flush()
}

func translateDiscoveryError(err error) error {
if errors.Is(err, serverhttp.ErrSessionExpired) {
fmt.Fprintln(os.Stderr, "Butler session expired. Run 'butleradm login' to re-authenticate.")
return err
}
var se *serverhttp.ServerError
if errors.As(err, &se) {
switch {
case se.IsForbidden():
return fmt.Errorf("forbidden (identity provider operations require platform admin): %s", se.Message)
case se.IsNotFound():
return fmt.Errorf("not found: %s", se.Message)
case se.IsBadRequest():
return fmt.Errorf("invalid request: %s", se.Message)
}
}
return err
}
20 changes: 14 additions & 6 deletions internal/adm/idp/idp.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Google Workspace, Microsoft Entra ID, Okta, or generic OIDC providers.
Examples:
butleradm idp list
butleradm idp get google-workspace
butleradm idp create okta --from-file okta.yaml
butleradm idp test --issuer-url https://accounts.google.com
butleradm idp delete old-provider`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
auth.WarnIfUnauthenticated()
Expand All @@ -55,8 +57,14 @@ Examples:

cmd.AddCommand(newListCmd(logger))
cmd.AddCommand(newGetCmd(logger))
cmd.AddCommand(newCreateCmd(logger))
cmd.AddCommand(newUpdateCmd(logger))
cmd.AddCommand(newDeleteCmd(logger))

// OIDC discovery probes (P1 #6): non-mutating pre-create and existing-IdP checks.
cmd.AddCommand(newTestCmd(logger))
cmd.AddCommand(newValidateCmd(logger))

return cmd
}

Expand Down Expand Up @@ -113,7 +121,7 @@ func runList(ctx context.Context, logger *log.Logger, kubeconfigPath, kubeContex
for _, idp := range list.Items {
name := idp.GetName()
idpType := client.GetNestedString(idp.Object, "spec", "type")
issuer := client.GetNestedString(idp.Object, "spec", "issuerURL")
issuer := client.GetNestedString(idp.Object, "spec", "oidc", "issuerURL")
phase := client.GetNestedString(idp.Object, "status", "phase")
age := output.FormatAge(idp.GetCreationTimestamp().Time)

Expand Down Expand Up @@ -187,14 +195,14 @@ func runGet(ctx context.Context, logger *log.Logger, name, kubeconfigPath, kubeC

// Human-readable detail view
idpType := client.GetNestedString(idp.Object, "spec", "type")
issuer := client.GetNestedString(idp.Object, "spec", "issuerURL")
clientID := client.GetNestedString(idp.Object, "spec", "clientID")
issuer := client.GetNestedString(idp.Object, "spec", "oidc", "issuerURL")
clientID := client.GetNestedString(idp.Object, "spec", "oidc", "clientID")
phase := client.GetNestedString(idp.Object, "status", "phase")
message := client.GetNestedString(idp.Object, "status", "message")
age := output.FormatAge(idp.GetCreationTimestamp().Time)

// Extract scopes
scopesRaw, _, _ := unstructuredNestedSlice(idp.Object, "spec", "scopes")
scopesRaw, _, _ := unstructuredNestedSlice(idp.Object, "spec", "oidc", "scopes")
var scopes []string
for _, s := range scopesRaw {
if str, ok := s.(string); ok {
Expand All @@ -203,8 +211,8 @@ func runGet(ctx context.Context, logger *log.Logger, name, kubeconfigPath, kubeC
}

// Extract claim mappings
emailClaim := client.GetNestedString(idp.Object, "spec", "claims", "email")
groupsClaim := client.GetNestedString(idp.Object, "spec", "claims", "groups")
emailClaim := client.GetNestedString(idp.Object, "spec", "oidc", "emailClaim")
groupsClaim := client.GetNestedString(idp.Object, "spec", "oidc", "groupsClaim")

fmt.Printf("Name: %s\n", idp.GetName())
fmt.Printf("Type: %s\n", idpType)
Expand Down
Loading
Loading