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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- `user list` lists the users of the tenant, with their role, teams, what manages them
(SCIM, an LDAP sync, or Steadybit) and their last login; `--query` narrows it down.
`user delete` removes a user from the tenant. Both need an admin access token, and
the platform refuses to remove users that SCIM or an LDAP sync manages.

## v6.2.3

- Built with Go 1.27.2, which fixes CVE-2026-78667, CVE-2026-78669 and CVE-2026-97031
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,14 +251,16 @@ steadybit team environment add -k ADM --environment Global
```bash
steadybit access-token create --name ci --type TEAM --team ADM --expires-at 2026-12-31
steadybit user invite --email jane@example.com --team ADM
steadybit user list --query jane
steadybit user delete --username <username>
steadybit killswitch status
steadybit audit-log --from 2026-09-01 -t json
steadybit license show
steadybit report experiments-executed --group-by STATE --rollup MONTHLY
```

Commands that cannot be undone, such as `killswitch activate`, `access-token delete` or
`team member set`, ask for confirmation on a terminal; `--yes` skips the question.
Commands that cannot be undone, such as `killswitch activate`, `access-token delete`,
`user delete` or `team member set`, ask for confirmation on a terminal; `--yes` skips the question.

### Targets and actions

Expand Down
492 changes: 481 additions & 11 deletions api/platform.gen.go

Large diffs are not rendered by default.

26 changes: 25 additions & 1 deletion internal/cli/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/spf13/cobra"
"github.com/steadybit/cli/v6/internal/platform"
"github.com/steadybit/cli/v6/internal/resource"
"github.com/steadybit/cli/v6/internal/user"
)

Expand All @@ -28,6 +29,29 @@ func newUser() *cobra.Command {
_ = invite.MarkFlagRequired("email")
variadic(invite, "email")

cmd.AddCommand(invite)
var l user.ListOptions
list := &cobra.Command{
Use: "list",
Short: "List the users of the tenant. Needs an admin access token.",
Args: cobra.NoArgs,
Example: examples("steadybit user list", "steadybit user list --query jane", `steadybit user list --jq '.[] | select(.managedBy == "SCIM") | .username'`),
RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return user.List(ctx, c, l) }),
}
list.Flags().StringVar(&l.Query, "query", "", "Only list users whose username, name or email contains this text.")
list.Flags().StringVarP(&l.Type, "type", "t", "", resource.ListTypeHelp)

var d user.DeleteOptions
del := &cobra.Command{
Use: "delete",
Short: "Remove a user from the tenant. Users managed by SCIM or an LDAP sync are removed there instead. Needs an admin access token.",
Args: cobra.NoArgs,
Example: examples("steadybit user delete --username 13av2737-b318-4048-a79d-4789d645bc31"),
RunE: withClient(func(ctx context.Context, c *platform.Client, _ []string) error { return user.Delete(ctx, c, d) }),
}
del.Flags().StringVarP(&d.Username, "username", "u", "", "The username, as user list shows it.")
del.Flags().BoolVar(&d.Yes, "yes", false, yesHelp)
_ = del.MarkFlagRequired("username")

cmd.AddCommand(invite, list, del)
return cmd
}
86 changes: 86 additions & 0 deletions internal/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
openapi_types "github.com/oapi-codegen/runtime/types"
"github.com/steadybit/cli/v6/api"
"github.com/steadybit/cli/v6/internal/platform"
"github.com/steadybit/cli/v6/internal/resource"
"github.com/steadybit/cli/v6/internal/table"
)

type InviteOptions struct {
Expand Down Expand Up @@ -54,3 +56,87 @@
fmt.Printf("%d user(s) invited. They receive an email to join.\n", len(o.Emails))
return nil
}

type ListOptions struct {
Query string
Type string
}

func List(ctx context.Context, c *platform.Client, o ListOptions) error {
params := api.GetUsersParams{}

Check failure on line 66 in internal/user/user.go

View workflow job for this annotation

GitHub Actions / api-compatibility

undefined: api.GetUsersParams
if o.Query != "" {
params.Query = &o.Query
}
raw, err := platform.AllPagesRaw(func(page, size int32) (*http.Response, error) {
params.Page = api.PageRequestAO{Page: &page, Size: &size}
return c.GetUsers(ctx, &params)

Check failure on line 72 in internal/user/user.go

View workflow job for this annotation

GitHub Actions / api-compatibility

c.GetUsers undefined (type *platform.Client has no field or method GetUsers)
})
if platform.IsStatus(err, http.StatusForbidden) {
return errors.New("Listing users needs an admin access token.")
}
if err != nil {
return platform.Failed(err, "Failed to get the users")
}
return resource.List(raw, o.Type, func() error {
var users []struct {
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Teams []string `json:"teams"`
ManagedBy string `json:"managedBy"`
LastLogin *string `json:"lastLogin"`
}
if err := resource.DecodeEach(raw, &users); err != nil {
return err
}
if len(users) == 0 {
fmt.Println("No users found.")
return nil
}
t := table.New(
table.Column{Name: "username", Title: "Username", Alignment: table.Left},
table.Column{Name: "name", Title: "Name", Alignment: table.Left},
table.Column{Name: "email", Title: "Email", Alignment: table.Left},
table.Column{Name: "role", Title: "Role", Alignment: table.Left},
table.Column{Name: "teams", Title: "Teams", Alignment: table.Left},
table.Column{Name: "managedBy", Title: "Managed by", Alignment: table.Left},
table.Column{Name: "lastLogin", Title: "Last login", Alignment: table.Left},
)
for _, u := range users {
managedBy, lastLogin := u.ManagedBy, "never"
if managedBy == "" {
managedBy = "Steadybit"
}
if u.LastLogin != nil && *u.LastLogin != "" {
lastLogin = *u.LastLogin
}
t.AddRow(table.Default, table.Cell("username", u.Username), table.Cell("name", u.Name), table.Cell("email", u.Email),
table.Cell("role", u.Role), table.Cell("teams", strings.Join(u.Teams, ", ")), table.Cell("managedBy", managedBy), table.Cell("lastLogin", lastLogin))
}
t.Print()
return nil
})
}

type DeleteOptions struct {
Username string
Yes bool
}

// Delete removes a user from the tenant. Users managed by SCIM or an LDAP sync are
// removed in the identity provider instead; the platform refuses them.
func Delete(ctx context.Context, c *platform.Client, o DeleteOptions) error {
if ok, err := resource.Confirmed(o.Yes, fmt.Sprintf("Remove user %s from the tenant? They lose access to it.", o.Username)); !ok || err != nil {
return err
}
_, _, err := platform.Read(c.RemoveUser(ctx, o.Username))

Check failure on line 133 in internal/user/user.go

View workflow job for this annotation

GitHub Actions / api-compatibility

c.RemoveUser undefined (type *platform.Client has no field or method RemoveUser)

Check failure on line 133 in internal/user/user.go

View workflow job for this annotation

GitHub Actions / api-compatibility

not enough arguments in call to platform.Read
if platform.IsStatus(err, http.StatusNotFound) {
return fmt.Errorf("User %s is not a member of the tenant.", o.Username)
}
if err != nil {
return platform.Failed(err, "Failed to remove user %s", o.Username)
}
fmt.Printf("User %s removed from the tenant.\n", o.Username)
return nil
}
63 changes: 63 additions & 0 deletions internal/user/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,66 @@ func TestInviteRefusals(t *testing.T) {
assert.EqualError(t, user.Invite(ctx, p.Client, user.InviteOptions{}), "No one to invite. Pass --email.")
assert.Len(t, p.Requests("POST /api/users/invite"), 1)
}

var users = map[string]any{"items": []any{
map[string]any{"username": "u-1", "name": "Jane Doe", "email": "jane@example.com", "role": "ADMIN", "teams": []any{"ADM"}, "lastLogin": "2026-10-13T08:12:00Z"},
map[string]any{"username": "u-2", "name": "Joe", "email": "joe@example.com", "role": "USER", "teams": []any{}, "managedBy": "SCIM"},
}}

func TestList(t *testing.T) {
p := platformtest.New(t)
p.Reply("GET /api/users", platformtest.Reply{JSON: users})

out, err := platformtest.Stdout(t, func() error { return user.List(ctx, p.Client, user.ListOptions{Query: "jo"}) })

require.NoError(t, err)
assert.Contains(t, out, "Jane Doe")
assert.Contains(t, out, "2026-10-13T08:12:00Z")
assert.Contains(t, out, "Steadybit")
assert.Contains(t, out, "SCIM")
assert.Contains(t, out, "never")
assert.Equal(t, []string{"jo"}, p.Requests("GET /api/users")[0].Query["query"])
}

func TestListAsJSON(t *testing.T) {
p := platformtest.New(t)
p.Reply("GET /api/users", platformtest.Reply{JSON: users})

out, err := platformtest.Stdout(t, func() error { return user.List(ctx, p.Client, user.ListOptions{Type: "json"}) })

require.NoError(t, err)
assert.Contains(t, out, `"username": "u-2"`)
assert.Contains(t, out, `"managedBy": "SCIM"`)
assert.Empty(t, p.Requests("GET /api/users")[0].Query["query"])
}

func TestListNeedsAnAdminToken(t *testing.T) {
p := platformtest.New(t)
p.Reply("GET /api/users", platformtest.Reply{Status: http.StatusForbidden})

assert.EqualError(t, user.List(ctx, p.Client, user.ListOptions{}), "Listing users needs an admin access token.")
}

func TestDelete(t *testing.T) {
p := platformtest.New(t)
p.Reply("DELETE /api/users/u-1", platformtest.Reply{Status: http.StatusNoContent})

out, err := platformtest.Stdout(t, func() error { return user.Delete(ctx, p.Client, user.DeleteOptions{Username: "u-1", Yes: true}) })

require.NoError(t, err)
assert.Equal(t, "User u-1 removed from the tenant.\n", out)
assert.Len(t, p.Requests("DELETE /api/users/u-1"), 1)
}

func TestDeleteRefusals(t *testing.T) {
p := platformtest.New(t)
p.Reply("DELETE /api/users/nobody", platformtest.Reply{Status: http.StatusNotFound})
p.Reply("DELETE /api/users/u-2", platformtest.Reply{Status: http.StatusForbidden,
JSON: map[string]any{"title": "Forbidden", "detail": "The user is managed by SCIM"}})

assert.EqualError(t, user.Delete(ctx, p.Client, user.DeleteOptions{Username: "nobody", Yes: true}), "User nobody is not a member of the tenant.")
err := user.Delete(ctx, p.Client, user.DeleteOptions{Username: "u-2", Yes: true})
require.Error(t, err)
assert.Contains(t, err.Error(), "Failed to remove user u-2")
assert.Contains(t, err.Error(), "managed by SCIM")
}
Loading
Loading