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
14 changes: 11 additions & 3 deletions internal/abac/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,24 @@ import (

// Client provides methods for ABAC API endpoints. The caller obtains
// httpClient from client.NewBearerHTTPClient (which handles token
// refresh) and projectUUID from config.GetProjectUUID.
// refresh), projectUUID from config.GetProjectUUID, and accountUUID from
// config.GetAccountUUID.
type Client struct {
HTTP *http.Client
BaseURL string
ProjectUUID string
AccountUUID string
}

// NewClient creates an ABAC client.
func NewClient(httpClient *http.Client, baseURL, projectUUID string) *Client {
// NewClient creates an ABAC client. accountUUID may be empty, in which case
// no X-Zep-Account-UUID header is sent and the server resolves the caller's
// default membership.
func NewClient(httpClient *http.Client, baseURL, projectUUID, accountUUID string) *Client {
return &Client{
HTTP: httpClient,
BaseURL: strings.TrimRight(baseURL, "/"),
ProjectUUID: projectUUID,
AccountUUID: accountUUID,
}
}

Expand Down Expand Up @@ -210,6 +215,9 @@ func (c *Client) doRequestRaw(ctx context.Context, method, path string, body any
if c.ProjectUUID != "" {
req.Header.Set("X-Zep-Project", c.ProjectUUID)
}
if c.AccountUUID != "" {
req.Header.Set("X-Zep-Account-UUID", c.AccountUUID)
}

resp, err := c.HTTP.Do(req)
if err != nil {
Expand Down
25 changes: 24 additions & 1 deletion internal/abac/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (

// newTestClient creates a Client pointing at the given test server.
func newTestClient(srv *httptest.Server) *Client {
return NewClient(srv.Client(), srv.URL, "test-project-uuid")
return NewClient(srv.Client(), srv.URL, "test-project-uuid", "test-account-uuid")
}

// --- Policy Set CRUD ---
Expand Down Expand Up @@ -42,6 +42,29 @@ func TestClient_ListPolicySets(t *testing.T) {
assert.Equal(t, "test-project-uuid", gotReq.Header.Get("X-Zep-Project"))
}

func TestClient_SendsAccountHeader(t *testing.T) {
var gotReq *http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotReq = r
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(PolicySetList{})
}))
defer srv.Close()

// A configured account UUID is sent as X-Zep-Account-UUID.
_, err := NewClient(srv.Client(), srv.URL, "test-project", "acct-123").
ListPolicySets(context.Background())
require.NoError(t, err)
assert.Equal(t, "acct-123", gotReq.Header.Get("X-Zep-Account-UUID"))

// An empty account UUID omits the header, letting the server resolve the
// caller's default membership.
_, err = NewClient(srv.Client(), srv.URL, "test-project", "").
ListPolicySets(context.Background())
require.NoError(t, err)
assert.Empty(t, gotReq.Header.Get("X-Zep-Account-UUID"))
}

func TestClient_GetPolicySet(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v2/abac/policy-sets/ps-uuid-1", r.URL.Path)
Expand Down
24 changes: 12 additions & 12 deletions internal/auth/config.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
package auth

// Default OAuth configuration values. These can be overridden at build
// time via -ldflags for development/testing:
// Default OAuth configuration values. These are the production tenant's
// settings, so a bare `auth login` with no --env authenticates against
// production out of the box.
//
// go build -ldflags "-X github.com/getzep/zepctl/internal/auth.defaultIssuer=https://dev.kinde.com \
// -X github.com/getzep/zepctl/internal/auth.defaultClientID=dev-client-id"
//
// They can also be overridden per-profile via the `oauth-issuer` and
// `oauth-client-id` config fields, so a single binary can authenticate
// against multiple OAuth tenants. See OAuthConfigFor.
// They can be overridden per-profile via the `oauth-issuer`,
// `oauth-client-id`, and `oauth-audience` config fields, so a single binary
// can authenticate against multiple OAuth tenants. See OAuthConfigFor.
var (
defaultIssuer = "https://getzep.kinde.com"
defaultIssuer = "https://auth.getzep.com"
defaultClientID = "8b0f41c63c6141c282c3b4dfd740708d"
defaultAudience = "urn:zep:zepctl"
)

// DefaultOAuthConfig returns the OAuth configuration for zepctl using
Expand All @@ -21,13 +20,14 @@ var (
// for a "Front-end" application, which has no client secret. It is safe to
// commit to source control and compile into the binary.
//
// Audience is intentionally empty in the build-time defaults. Profiles that
// target a backend which enforces the aud claim must provide one explicitly
// via the OAuth audience profile field (or via an environment preset).
// The audience is a public token-audience identifier (it rides in every
// issued token), not a secret. It must be requested at login because the
// production API enforces the aud claim; without it every bearer request 401s.
func DefaultOAuthConfig() *OAuthConfig {
return &OAuthConfig{
Issuer: defaultIssuer,
ClientID: defaultClientID,
Audience: defaultAudience,
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/auth/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ func TestOAuthConfigFor_FallsBackToDefaults(t *testing.T) {
if cfg.ClientID != defaultClientID {
t.Errorf("ClientID = %q, want default %q", cfg.ClientID, defaultClientID)
}
if cfg.Audience != "" {
t.Errorf("Audience = %q, want empty default", cfg.Audience)
if cfg.Audience != defaultAudience {
t.Errorf("Audience = %q, want default %q", cfg.Audience, defaultAudience)
}
}

Expand Down
11 changes: 7 additions & 4 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,15 @@ var configAddProfileCmd = &cobra.Command{
}
}

var target *config.Profile
if existing == nil {
newProfile := config.Profile{Name: name}
applyEnvAndOverrides(&newProfile, env, cmd)
cfg.Profiles = append(cfg.Profiles, newProfile)
cfg.Profiles = append(cfg.Profiles, config.Profile{Name: name})
target = &cfg.Profiles[len(cfg.Profiles)-1]
} else {
applyEnvAndOverrides(existing, env, cmd)
target = existing
}
applyEnvAndOverrides(target, env, cmd)
setIfChanged(cmd, "account", &target.AccountUUID)

if cfg.CurrentProfile == "" {
cfg.CurrentProfile = name
Expand Down Expand Up @@ -368,6 +370,7 @@ func init() {
configAddProfileCmd.Flags().String("oauth-client-id", "", "Override OAuth client ID for `auth login` (uses build-time default if unset)")
configAddProfileCmd.Flags().String("oauth-audience", "", "Override OAuth audience for `auth login` (uses build-time default if unset)")
configAddProfileCmd.Flags().String("env", "", "Apply a named environment preset (see \"config add-environment\"); explicit per-field flags override the preset")
configAddProfileCmd.Flags().String("account", "", "Account UUID to bind to the profile (sent as X-Zep-Account-UUID on bearer requests)")
configAddProfileCmd.Flags().Bool("no-api-key", false, "Create a bearer-only profile with no API key (skip prompt)")
configUpdateProfileCmd.Flags().String("api-key", "", "Update API key (stored in system keychain)")
configUpdateProfileCmd.Flags().String("api-url", "", "Update API URL")
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/policy_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func newABACClientDefault(cmd *cobra.Command) (*abac.Client, error) {
if projectUUID == "" {
return nil, fmt.Errorf("no project configured; run \"zepctl config set-project\" to select a project")
}
return abac.NewClient(httpClient, baseURL, projectUUID), nil
return abac.NewClient(httpClient, baseURL, projectUUID, config.GetAccountUUID()), nil
}

// validateUUID parses value as a UUID and returns a formatted error on failure.
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/policy_set_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ func withTestABACClient(t *testing.T, srv *httptest.Server) {
t.Helper()
orig := newABACClient
newABACClient = func(_ *cobra.Command) (*abac.Client, error) {
return abac.NewClient(srv.Client(), srv.URL, "test-project"), nil
return abac.NewClient(srv.Client(), srv.URL, "test-project", "test-account"), nil
}
t.Cleanup(func() { newABACClient = orig })
}
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@ func init() {
rootCmd.PersistentFlags().BoolP("quiet", "q", false, "Suppress non-essential output")
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output")
rootCmd.PersistentFlags().String("project", "", "Project UUID for this command")
rootCmd.PersistentFlags().String("account", "", "Account UUID for this command (bearer auth; disambiguates multi-account members)")

_ = viper.BindPFlag("api-key", rootCmd.PersistentFlags().Lookup("api-key"))
_ = viper.BindPFlag("api-url", rootCmd.PersistentFlags().Lookup("api-url"))
_ = viper.BindPFlag("profile", rootCmd.PersistentFlags().Lookup("profile"))
_ = viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
_ = viper.BindPFlag("project", rootCmd.PersistentFlags().Lookup("project"))
_ = viper.BindPFlag("account", rootCmd.PersistentFlags().Lookup("account"))
}

func initConfig() {
Expand Down
153 changes: 153 additions & 0 deletions internal/cli/set_account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package cli

import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"

"github.com/getzep/zepctl/internal/client"
"github.com/getzep/zepctl/internal/config"
"github.com/getzep/zepctl/internal/output"
"github.com/spf13/cobra"
)

var configSetAccountCmd = &cobra.Command{
Use: "set-account [uuid]",
Short: "Set the active account for the current profile",
Long: `Set the account bound to the current profile. It is sent as the
X-Zep-Account-UUID header on bearer requests to disambiguate multi-account
members. If a UUID is provided it is set directly. Otherwise the accounts you
belong to are fetched and you are prompted to choose.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

profile := cfg.GetCurrentProfile()
if profile == nil {
return fmt.Errorf("no active profile")
}

if len(args) == 1 {
// Direct UUID assignment.
profile.AccountUUID = args[0]
if err := cfg.Save(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
output.Info("Active account set to %s", args[0])
return nil
}

// Interactive account selection.
httpClient, baseURL, err := client.NewBearerHTTPClient(cmd.Context())
if err != nil {
return fmt.Errorf("creating client: %w", err)
}

accounts, err := listMyAccounts(cmd.Context(), httpClient, baseURL)
if err != nil {
return fmt.Errorf("fetching accounts: %w", err)
}
if len(accounts) == 0 {
return fmt.Errorf("no accounts found for this member")
}

var selected accountInfo
if len(accounts) == 1 {
selected = accounts[0]
output.Info("Auto-selected account %q (%s)", selected.Name, selected.UUID)
} else {
fmt.Println("Select an account:")
for i, a := range accounts {
marker := ""
if a.RequiresSSO {
marker = " [SSO -- requires enterprise SSO login]"
}
fmt.Printf(" %d. %s (%s)%s\n", i+1, a.Name, a.UUID, marker)
}
fmt.Printf("Select an account [1-%d]: ", len(accounts))

reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response)

idx, err := strconv.Atoi(response)
if err != nil || idx < 1 || idx > len(accounts) {
return fmt.Errorf("invalid selection: %q", response)
}
selected = accounts[idx-1]
}

profile.AccountUUID = selected.UUID
if err := cfg.Save(); err != nil {
return fmt.Errorf("saving config: %w", err)
}

// A SSO-bound account cannot be reached over zepctl's bearer session
// today; setting it is allowed (the header is still sent) but warn so
// the user isn't surprised by a 403 on the next command.
if selected.RequiresSSO {
output.Warn("Account %s requires enterprise SSO login; zepctl bearer auth cannot reach it yet.", selected.UUID)
}
output.Info("Active account set to %s (%s)", selected.UUID, selected.Name)
return nil
},
}

type accountInfo struct {
UUID string
Name string
RequiresSSO bool
}

// listMyAccounts calls GET /api/web/v1/me/accounts to list every account the
// bearer-authenticated member belongs to. SSO-bound accounts are included and
// flagged via RequiresSSO, which the response annotates for each account.
func listMyAccounts(ctx context.Context, httpClient *http.Client, baseURL string) ([]accountInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/web/v1/me/accounts", http.NoBody)
if err != nil {
return nil, fmt.Errorf("building me/accounts request: %w", err)
}

resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("calling me/accounts: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("me/accounts returned %d", resp.StatusCode)
}

var result struct {
Accounts []struct {
AccountUUID string `json:"account_uuid"`
Name string `json:"name"`
RequiresSSOLogin bool `json:"requires_sso_login"`
} `json:"accounts"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("parsing accounts response: %w", err)
}

accounts := make([]accountInfo, 0, len(result.Accounts))
for _, a := range result.Accounts {
accounts = append(accounts, accountInfo{
UUID: a.AccountUUID,
Name: a.Name,
RequiresSSO: a.RequiresSSOLogin,
})
}
return accounts, nil
}

func init() {
configCmd.AddCommand(configSetAccountCmd)
}
4 changes: 2 additions & 2 deletions internal/cli/set_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ type projectInfo struct {

// authenticateAndGetProjects calls POST /api/web/v1/authenticate to resolve
// the bearer-authenticated user's account UUID and accessible projects in a
// single round trip. The endpoint returns AccountMemberResponse, which carries
// both account_uuid and the projects array (src/api/apidata/account.go).
// single round trip. The response body carries both the account_uuid and the
// projects array.
func authenticateAndGetProjects(ctx context.Context, httpClient *http.Client, baseURL string) (string, []projectInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api/web/v1/authenticate", http.NoBody)
if err != nil {
Expand Down
Loading
Loading