From 1c911284264a40a91834339d21f980a2ebda2b76 Mon Sep 17 00:00:00 2001 From: Daniel Reuter Date: Thu, 15 Oct 2026 12:39:16 +0200 Subject: [PATCH] feat: user list and user delete Lists the users of the tenant, with role, teams, what manages them and last login, and removes a user from the tenant. Both call the platform's new GET /api/users and DELETE /api/users/{username}; the client is regenerated from the dev spec, which has them. --- CHANGELOG.md | 7 + README.md | 6 +- api/platform.gen.go | 492 ++++++++++++++++++++++++++++++++++++- internal/cli/user.go | 26 +- internal/user/user.go | 86 +++++++ internal/user/user_test.go | 63 +++++ openapi/platform-api.json | 423 +++++++++++++++++++++++++------ 7 files changed, 1010 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fde0f76..65316e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index ddab399..8163daf 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/api/platform.gen.go b/api/platform.gen.go index c0eee8c..2978f75 100644 --- a/api/platform.gen.go +++ b/api/platform.gen.go @@ -45,6 +45,7 @@ func (e AccessTokenPrincipalALPrincipalType) Valid() bool { // Defines values for AccessTokenPrincipalALTokenType. const ( AccessTokenPrincipalALTokenTypeADMIN AccessTokenPrincipalALTokenType = "ADMIN" + AccessTokenPrincipalALTokenTypeSCIM AccessTokenPrincipalALTokenType = "SCIM" AccessTokenPrincipalALTokenTypeTEAM AccessTokenPrincipalALTokenType = "TEAM" AccessTokenPrincipalALTokenTypeWILDCARD AccessTokenPrincipalALTokenType = "WILDCARD" ) @@ -54,6 +55,8 @@ func (e AccessTokenPrincipalALTokenType) Valid() bool { switch e { case AccessTokenPrincipalALTokenTypeADMIN: return true + case AccessTokenPrincipalALTokenTypeSCIM: + return true case AccessTokenPrincipalALTokenTypeTEAM: return true case AccessTokenPrincipalALTokenTypeWILDCARD: @@ -539,6 +542,7 @@ const ( MemberAOManagedByLDAP MemberAOManagedBy = "LDAP" MemberAOManagedByMANUAL MemberAOManagedBy = "MANUAL" MemberAOManagedByOIDC MemberAOManagedBy = "OIDC" + MemberAOManagedBySCIM MemberAOManagedBy = "SCIM" ) // Valid indicates whether the value is a known member of the MemberAOManagedBy enum. @@ -550,6 +554,8 @@ func (e MemberAOManagedBy) Valid() bool { return true case MemberAOManagedByOIDC: return true + case MemberAOManagedBySCIM: + return true default: return false } @@ -977,6 +983,7 @@ const ( TeamAOManagedByLDAP TeamAOManagedBy = "LDAP" TeamAOManagedByMANUAL TeamAOManagedBy = "MANUAL" TeamAOManagedByOIDC TeamAOManagedBy = "OIDC" + TeamAOManagedBySCIM TeamAOManagedBy = "SCIM" ) // Valid indicates whether the value is a known member of the TeamAOManagedBy enum. @@ -988,6 +995,8 @@ func (e TeamAOManagedBy) Valid() bool { return true case TeamAOManagedByOIDC: return true + case TeamAOManagedBySCIM: + return true default: return false } @@ -1139,6 +1148,7 @@ const ( UpsertTeamAOManagedByLDAP UpsertTeamAOManagedBy = "LDAP" UpsertTeamAOManagedByMANUAL UpsertTeamAOManagedBy = "MANUAL" UpsertTeamAOManagedByOIDC UpsertTeamAOManagedBy = "OIDC" + UpsertTeamAOManagedBySCIM UpsertTeamAOManagedBy = "SCIM" ) // Valid indicates whether the value is a known member of the UpsertTeamAOManagedBy enum. @@ -1150,6 +1160,41 @@ func (e UpsertTeamAOManagedBy) Valid() bool { return true case UpsertTeamAOManagedByOIDC: return true + case UpsertTeamAOManagedBySCIM: + return true + default: + return false + } +} + +// Defines values for UserAOManagedBy. +const ( + UserAOManagedBySCIM UserAOManagedBy = "SCIM" +) + +// Valid indicates whether the value is a known member of the UserAOManagedBy enum. +func (e UserAOManagedBy) Valid() bool { + switch e { + case UserAOManagedBySCIM: + return true + default: + return false + } +} + +// Defines values for UserAORole. +const ( + UserAORoleADMIN UserAORole = "ADMIN" + UserAORoleUSER UserAORole = "USER" +) + +// Valid indicates whether the value is a known member of the UserAORole enum. +func (e UserAORole) Valid() bool { + switch e { + case UserAORoleADMIN: + return true + case UserAORoleUSER: + return true default: return false } @@ -1991,6 +2036,11 @@ type CreateAndRunExperimentAO struct { // // Example: ADM Team string `json:"team"` + + // Version Version for optimistic locking (optional in the API) + // + // Example: 1 + Version *int32 `json:"version,omitempty"` } // CreateAndRunExperimentFromTemplateAO Create or update an experiment based on an experiment template and run it. @@ -2085,6 +2135,11 @@ type CreateExperimentAO struct { // // Example: ADM Team string `json:"team"` + + // Version Version for optimistic locking (optional in the API) + // + // Example: 1 + Version *int32 `json:"version,omitempty"` } // CreateExperimentFromTemplateAO Create or update an experiment based on an experiment template. @@ -2337,7 +2392,7 @@ type EnvironmentAO struct { // Version Version for optimistic locking (optional in the API) // // Example: 1 - Version *int32 `json:"version,omitempty"` + Version int32 `json:"version"` } // EnvironmentAOState State of the environment to indicate current background tasks. @@ -2486,10 +2541,10 @@ type ExperimentAO struct { // TemplateTitle The title of the template that was used to create this experiment TemplateTitle *string `json:"templateTitle,omitempty"` - // Version Experiment database version. + // Version Version for optimistic locking (optional in the API) // // Example: 1 - Version *int32 `json:"version,omitempty"` + Version int32 `json:"version"` } // ExperimentExecutionAO A single experiment execution that was triggered from a single experiment. @@ -3240,18 +3295,23 @@ type ExperimentStepWaitAO = BaseExperimentStepAO // ExperimentSummariesAO List of experiments. // -// Example: {"experiments":[{"key":"ADM-1","name":"Shop survives unavailability of hot-deals products"},{"key":"SHOP-2","name":"Network latency of Message Broker doesn't interfere with Online shop"}]} +// Example: {"experiments":[{"externalId":"1234567","key":"ADM-1","name":"Shop survives unavailability of hot-deals products","tags":["myTag","myOtherTag"]},{"externalId":"test-id","key":"SHOP-2","name":"Network latency of Message Broker doesn't interfere with Online shop","tags":["yourTag","yourOtherTag"]}]} type ExperimentSummariesAO struct { // Experiments List of experiment summaries // - // Example: [{"key":"ADM-1","name":"Shop survives unavailability of hot-deals products"},{"key":"SHOP-2","name":"Network latency of Message Broker doesn't interfere with Online shop"}] + // Example: [{"externalId":"1234567","key":"ADM-1","name":"Shop survives unavailability of hot-deals products","tags":["myTag","myOtherTag"]},{"externalId":"test-id","key":"SHOP-2","name":"Network latency of Message Broker doesn't interfere with Online shop","tags":["yourTag","yourOtherTag"]}] Experiments *[]ExperimentSummaryAO `json:"experiments,omitempty"` } // ExperimentSummaryAO Summary of a single experiment. // -// Example: {"key":"ADM-1","name":"Shop survives unavailability of hot-deals products"} +// Example: {"externalId":"1234567","key":"ADM-1","name":"Shop survives unavailability of hot-deals products","tags":["myTag","myOtherTag"]} type ExperimentSummaryAO struct { + // ExternalId An optional external identifier used for create-or-update semantics. + // + // Example: 1234567 + ExternalId *string `json:"externalId,omitempty"` + // Key Unique experiment key that identifies the experiment. Combination of `team key` and increasing number // // Example: ADM-2 @@ -3261,6 +3321,11 @@ type ExperimentSummaryAO struct { // // Example: Shop survives unavailability of hot-deals products Name *string `json:"name,omitempty"` + + // Tags An optional set of tags you can use to search for. + // + // Example: ["myTag","myOtherTag"] + Tags *[]string `json:"tags,omitempty"` } // ExperimentTemplateAO Example: {"created":"2024-03-22T09:24:12.802961166Z","createdBy":{"name":"Daniel","pictureUrl":"https://s.gravatar.com/avatar/4f27f3856530f8f2e4ec050b1d594306?s=480\u0026r=pg\u0026d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fda.png","username":"2bd1c2d7-4051-46ad-9f05-315062edd85e"},"edited":"2024-03-22T09:24:12.802961166Z","editedBy":{"name":"Daniel","pictureUrl":"https://s.gravatar.com/avatar/4f27f3856530f8f2e4ec050b1d594306?s=480\u0026r=pg\u0026d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fda.png","username":"2bd1c2d7-4051-46ad-9f05-315062edd85e"},"hidden":false,"id":"6bea7aec-3572-44cf-9151-c6ada57d08ca","lanes":[{"steps":[{"actionType":"com.steadybit.extension_http.check.periodically","ignoreFailure":false,"parameters":{"connectTimeout":"5s","duration":"60s","followRedirects":false,"headers":[],"maxConcurrent":5,"method":"GET","readTimeout":"5s","requestsPerSecond":1,"statusCode":"200-299","successRate":"100","url":"[[HTTP_ENDPOINT]]"},"radius":{},"type":"action"}]},{"steps":[{"ignoreFailure":false,"parameters":{"duration":"10s"},"type":"wait"},{"actionType":"com.steadybit.extension_kubernetes.rollout-restart","ignoreFailure":false,"parameters":{"wait":false},"radius":{"percentage":50,"predicate":{"operator":"AND","predicates":[{"key":"k8s.cluster-name","operator":"EQUALS","values":["[[CLUSTER]]"]},{"key":"k8s.namespace","operator":"EQUALS","values":["[[NAMESPACE]]"]},{"key":"k8s.deployment","operator":"EQUALS","values":["[[DEPLOYMENT]]"]}]},"query":null,"targetType":"com.steadybit.extension_kubernetes.kubernetes-deployment"},"type":"action"},{"actionType":"com.steadybit.extension_kubernetes.rollout-status","ignoreFailure":false,"parameters":{"duration":"10m"},"radius":{"percentage":50,"predicate":{"operator":"AND","predicates":[{"key":"k8s.cluster-name","operator":"EQUALS","values":["[[CLUSTER]]"]},{"key":"k8s.namespace","operator":"EQUALS","values":["[[NAMESPACE]]"]},{"key":"k8s.deployment","operator":"EQUALS","values":["[[DEPLOYMENT]]"]}]},"query":null,"targetType":"com.steadybit.extension_kubernetes.kubernetes-deployment"},"type":"action"}]}],"placeholders":[{"description":"Which HTTP Endpoint should be checked during experiment execution?","key":"HTTP_ENDPOINT","name":"HTTP Endpoint"},{"description":"Which Kubernetes deployment do you want to restart?","key":"DEPLOYMENT","name":"Kubernetes Deployment"},{"description":"In which Kubernetes cluster is the deployment deployed to?","key":"CLUSTER","name":"Kubernetes Cluster"},{"description":"In which Kubernetes namespace is the deployment deployed to?","key":"NAMESPACE","name":"Kubernetes Namespace"}],"properties":{"EXAMPLE_CUSTOM_PROPERTY":"I like this experiment!"},"propertiesMetadata":[{"editableInExecution":false,"key":"EXAMPLE_CUSTOM_PROPERTY","required":true}],"tags":["Kubernetes"],"templateDescription":"Test if a given HTTP Endpoint remains funcitonal if a Kubernetes deployment is restarted.","templateTitle":"HTTP Endpoint remains functional during Kubernetes Rollout Restart","version":0} @@ -3333,7 +3398,7 @@ type ExperimentTemplateAO struct { // Version Version for optimistic locking (optional in the API) // // Example: 1 - Version *int32 `json:"version,omitempty"` + Version int32 `json:"version"` } // ExperimentTemplatePlaceholderAO A list of placeholders used in this experiment template. @@ -3992,6 +4057,21 @@ type PagedResponseAOString struct { TotalItems *int64 `json:"totalItems,omitempty"` } +// PagedResponseAOUserAO defines model for PagedResponseAOUserAO. +type PagedResponseAOUserAO struct { + Items *[]UserAO `json:"items,omitempty"` + + // NextPage Next page to query for next page of runs or null if there are none. + // + // Example: 4 + NextPage *int32 `json:"nextPage,omitempty"` + + // TotalItems Total amount of runs matching your query + // + // Example: 241 + TotalItems *int64 `json:"totalItems,omitempty"` +} + // ParameterAO Parameters that describe how to fetch metrics for this action. type ParameterAO struct { AcceptedFileTypes *[]string `json:"acceptedFileTypes,omitempty"` @@ -4554,10 +4634,10 @@ type ServiceAO struct { // Example: {"httpEndpoint":"http://dev.shop.products.internal","targetServices":["gateway","hot-deals","fashion-bestseller"]} Variables *map[string]VariableExpressionAO `json:"variables,omitempty"` - // Version Version for optimistic locking + // Version Version for optimistic locking (optional in the API) // // Example: 1 - Version *int32 `json:"version,omitempty"` + Version int32 `json:"version"` } // ServiceExperimentAO defines model for ServiceExperimentAO. @@ -4631,7 +4711,7 @@ type ServiceProfileAO struct { // Templates Template entries in this profile Templates []ServiceProfileCategoryAO `json:"templates"` - // Version Version for optimistic locking + // Version Version for optimistic locking (optional in the API) // // Example: 1 Version int32 `json:"version"` @@ -5201,7 +5281,7 @@ type TeamAO struct { // Version Version for optimistic locking (optional in the API) // // Example: 1 - Version *int32 `json:"version,omitempty"` + Version int32 `json:"version"` } // TeamAOManagedBy How a team or team membership is managed @@ -5391,6 +5471,11 @@ type UpdateExperimentAO struct { // // Example: ADM Team string `json:"team"` + + // Version Version for optimistic locking (optional in the API) + // + // Example: 1 + Version *int32 `json:"version,omitempty"` } // UpdateExperimentExecutionPropertiesAO Experiment execution data that should be used only for that specific experiment execution and will not update the experiment design. @@ -5462,6 +5547,11 @@ type UpdateExperimentExecutionPropertiesAO struct { type UpdateExperimentFromTemplateAO struct { // Placeholders List of template placeholder values Placeholders *[]ExperimentTemplatePlaceholderValueAO `json:"placeholders,omitempty"` + + // Version Version for optimistic locking (optional in the API) + // + // Example: 1 + Version *int32 `json:"version,omitempty"` } // UpsertEnvironmentAO Update or insert environment. @@ -5909,6 +5999,51 @@ type UpsertTeamAO struct { // Example: MANUAL type UpsertTeamAOManagedBy string +// UserAO defines model for UserAO. +type UserAO struct { + // Created When the user was created in Steadybit + // + // Example: 2026-01-15T09:30:00Z + Created *time.Time `json:"created,omitempty"` + + // Email Email address of the user + // + // Example: jane.doe@example.com + Email *string `json:"email,omitempty"` + + // LastLogin When the user last signed in; empty if the user never signed in + // + // Example: 2026-10-13T08:12:00Z + LastLogin *time.Time `json:"lastLogin,omitempty"` + + // ManagedBy What manages a user's membership in the tenant + ManagedBy *UserAOManagedBy `json:"managedBy,omitempty"` + + // Name Name of the user + // + // Example: Jane Doe + Name *string `json:"name,omitempty"` + + // Role Role of the user in the tenant + Role UserAORole `json:"role"` + + // Teams Keys of the teams the user is a member of; empty if the user is in no team + // + // Example: ["ADM","PAYMENTS"] + Teams []string `json:"teams"` + + // Username Username of the user, internal identifier of Steadybit + // + // Example: 13av2737-b318-4048-a79d-4789d645bc31 + Username string `json:"username"` +} + +// UserAOManagedBy What manages a user's membership in the tenant +type UserAOManagedBy string + +// UserAORole Role of the user in the tenant +type UserAORole string + // UserPrincipalAL A user has performed the logged event e.g. via UI // // Example: {"email":"example@example.com","name":"Jane Doe","principalType":"USER","role":"ADMIN","username":"1ava2afg-xju33-4c6a-9451-2854584c15be"} @@ -6526,6 +6661,15 @@ type AddTeamMembersParams struct { ValidateMembers *bool `form:"validateMembers,omitempty" json:"validateMembers,omitempty"` } +// GetUsersParams defines parameters for GetUsers. +type GetUsersParams struct { + // Query Only users whose username, name or email contains this text + Query *string `form:"query,omitempty" json:"query,omitempty"` + + // Page The number of the page and its size (at most 100, default 50) + Page PageRequestAO `form:"page" json:"page"` +} + // CreateAccessTokenJSONRequestBody defines body for CreateAccessToken for application/json ContentType. // // Deprecated: this type has been marked as deprecated upstream, but no `x-deprecated-reason` was set @@ -12920,6 +13064,13 @@ type ClientInterface interface { // Corresponds with POST /api/teams/{key}/members/remove (the `RemoveTeamMembers` operationId). RemoveTeamMembers(ctx context.Context, key string, body RemoveTeamMembersJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetUsers List the users of the tenant + // + // Lists all members of the tenant, including users that are in no team, sorted by username. Admins only. + // + // Corresponds with GET /api/users (the `GetUsers` operationId). + GetUsers(ctx context.Context, params *GetUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // InviteUserWithBody Invite users to a tenant // // Invite users to a tenant. The invited users will receive an email with an invitation link to join the tenant. @@ -12937,6 +13088,13 @@ type ClientInterface interface { // // Corresponds with POST /api/users/invite (the `InviteUser` operationId). InviteUser(ctx context.Context, body InviteUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RemoveUser Remove a user from the tenant + // + // Removes the user's membership in this tenant: the user leaves all teams of the tenant, loses its access tokens for the tenant and is logged out. A user that is then a member of no tenant at all is deleted. If your identity provider still grants the user access, the user comes back on the next login - remove it there to offboard it for good. Users managed via SCIM can only be removed by the identity provider, and while an LDAP sync manages the users of the tenant, no user can be removed by hand. Admins only. + // + // Corresponds with DELETE /api/users/{username} (the `RemoveUser` operationId). + RemoveUser(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error) } // GetAccessTokens Get access token list @@ -16399,6 +16557,23 @@ func (c *Client) RemoveTeamMembers(ctx context.Context, key string, body RemoveT return c.Client.Do(req) } +// GetUsers List the users of the tenant +// +// Lists all members of the tenant, including users that are in no team, sorted by username. Admins only. +// +// Corresponds with GET /api/users (the `GetUsers` operationId). +func (c *Client) GetUsers(ctx context.Context, params *GetUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetUsersRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // InviteUserWithBody Invite users to a tenant // // Invite users to a tenant. The invited users will receive an email with an invitation link to join the tenant. @@ -16437,6 +16612,23 @@ func (c *Client) InviteUser(ctx context.Context, body InviteUserJSONRequestBody, return c.Client.Do(req) } +// RemoveUser Remove a user from the tenant +// +// Removes the user's membership in this tenant: the user leaves all teams of the tenant, loses its access tokens for the tenant and is logged out. A user that is then a member of no tenant at all is deleted. If your identity provider still grants the user access, the user comes back on the next login - remove it there to offboard it for good. Users managed via SCIM can only be removed by the identity provider, and while an LDAP sync manages the users of the tenant, no user can be removed by hand. Admins only. +// +// Corresponds with DELETE /api/users/{username} (the `RemoveUser` operationId). +func (c *Client) RemoveUser(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRemoveUserRequest(c.Server, username) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // NewGetAccessTokensRequest constructs an http.Request for the GetAccessTokens method func NewGetAccessTokensRequest(server string, params *GetAccessTokensParams) (*http.Request, error) { var err error @@ -23454,6 +23646,68 @@ func NewRemoveTeamMembersRequestWithBody(server string, key string, contentType return req, nil } +// NewGetUsersRequest constructs an http.Request for the GetUsers method +func NewGetUsersRequest(server string, params *GetUsersParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/users") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Query != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "query", *params.Query, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "object", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewInviteUserRequest calls the generic InviteUser builder with application/json body func NewInviteUserRequest(server string, body InviteUserJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -23494,6 +23748,40 @@ func NewInviteUserRequestWithBody(server string, contentType string, body io.Rea return req, nil } +// NewRemoveUserRequest constructs an http.Request for the RemoveUser method +func NewRemoveUserRequest(server string, username string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "username", username, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/users/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { @@ -25275,6 +25563,15 @@ type ClientWithResponsesInterface interface { // Corresponds with POST /api/teams/{key}/members/remove (the `RemoveTeamMembers` operationId). RemoveTeamMembersWithResponse(ctx context.Context, key string, body RemoveTeamMembersJSONRequestBody, reqEditors ...RequestEditorFn) (*RemoveTeamMembersResponse, error) + // GetUsersWithResponse List the users of the tenant + // + // Lists all members of the tenant, including users that are in no team, sorted by username. Admins only. + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with GET /api/users (the `GetUsers` operationId). + GetUsersWithResponse(ctx context.Context, params *GetUsersParams, reqEditors ...RequestEditorFn) (*GetUsersResponse, error) + // InviteUserWithBodyWithResponse Invite users to a tenant // // Invite users to a tenant. The invited users will receive an email with an invitation link to join the tenant. @@ -25292,6 +25589,15 @@ type ClientWithResponsesInterface interface { // // Corresponds with POST /api/users/invite (the `InviteUser` operationId). InviteUserWithResponse(ctx context.Context, body InviteUserJSONRequestBody, reqEditors ...RequestEditorFn) (*InviteUserResponse, error) + + // RemoveUserWithResponse Remove a user from the tenant + // + // Removes the user's membership in this tenant: the user leaves all teams of the tenant, loses its access tokens for the tenant and is logged out. A user that is then a member of no tenant at all is deleted. If your identity provider still grants the user access, the user comes back on the next login - remove it there to offboard it for good. Users managed via SCIM can only be removed by the identity provider, and while an LDAP sync manages the users of the tenant, no user can be removed by hand. Admins only. + // + // Returns a wrapper object for the known response body format(s). + // + // Corresponds with DELETE /api/users/{username} (the `RemoveUser` operationId). + RemoveUserWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*RemoveUserResponse, error) } type GetAccessTokensResponse struct { @@ -31529,6 +31835,54 @@ func (r RemoveTeamMembersResponse) ContentType() string { return "" } +type GetUsersResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *PagedResponseAOUserAO + // YAML200 the response for an HTTP 200 `application/yaml` response + YAML200 *PagedResponseAOUserAO +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r GetUsersResponse) GetJSON200() *PagedResponseAOUserAO { + return r.JSON200 +} + +// GetYAML200 returns the response for an HTTP 200 `application/yaml` response +func (r GetUsersResponse) GetYAML200() *PagedResponseAOUserAO { + return r.YAML200 +} + +// GetBody returns the raw response body bytes +func (r GetUsersResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r GetUsersResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetUsersResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetUsersResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type InviteUserResponse struct { Body []byte HTTPResponse *http.Response @@ -31563,6 +31917,40 @@ func (r InviteUserResponse) ContentType() string { return "" } +type RemoveUserResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// GetBody returns the raw response body bytes +func (r RemoveUserResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r RemoveUserResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RemoveUserResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RemoveUserResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + // GetAccessTokensWithResponse Get access token list // // Deprecated, use v2 instead. Get a list of all access tokens. The access token itself is abbreviated for security reasons. Access tokens with v2 features are not returned, as they can not be represented cleanly in the old format. @@ -34433,6 +34821,21 @@ func (c *ClientWithResponses) RemoveTeamMembersWithResponse(ctx context.Context, return ParseRemoveTeamMembersResponse(rsp) } +// GetUsersWithResponse List the users of the tenant +// +// Lists all members of the tenant, including users that are in no team, sorted by username. Admins only. +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with GET /api/users (the `GetUsers` operationId). +func (c *ClientWithResponses) GetUsersWithResponse(ctx context.Context, params *GetUsersParams, reqEditors ...RequestEditorFn) (*GetUsersResponse, error) { + rsp, err := c.GetUsers(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetUsersResponse(rsp) +} + // InviteUserWithBodyWithResponse Invite users to a tenant // // Invite users to a tenant. The invited users will receive an email with an invitation link to join the tenant. @@ -34463,6 +34866,21 @@ func (c *ClientWithResponses) InviteUserWithResponse(ctx context.Context, body I return ParseInviteUserResponse(rsp) } +// RemoveUserWithResponse Remove a user from the tenant +// +// Removes the user's membership in this tenant: the user leaves all teams of the tenant, loses its access tokens for the tenant and is logged out. A user that is then a member of no tenant at all is deleted. If your identity provider still grants the user access, the user comes back on the next login - remove it there to offboard it for good. Users managed via SCIM can only be removed by the identity provider, and while an LDAP sync manages the users of the tenant, no user can be removed by hand. Admins only. +// +// Returns a wrapper object for the known response body format(s). +// +// Corresponds with DELETE /api/users/{username} (the `RemoveUser` operationId). +func (c *ClientWithResponses) RemoveUserWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*RemoveUserResponse, error) { + rsp, err := c.RemoveUser(ctx, username, reqEditors...) + if err != nil { + return nil, err + } + return ParseRemoveUserResponse(rsp) +} + // ParseGetAccessTokensResponse parses an HTTP response from a GetAccessTokensWithResponse call func ParseGetAccessTokensResponse(rsp *http.Response) (*GetAccessTokensResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -38769,6 +39187,42 @@ func ParseRemoveTeamMembersResponse(rsp *http.Response) (*RemoveTeamMembersRespo return response, nil } +// ParseGetUsersResponse parses an HTTP response from a GetUsersWithResponse call +func ParseGetUsersResponse(rsp *http.Response) (*GetUsersResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetUsersResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PagedResponseAOUserAO + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "yaml") && rsp.StatusCode == 200: + var dest PagedResponseAOUserAO + if err := yaml.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.YAML200 = &dest + + case rsp.StatusCode == 403: + break // No content-type + + } + + return response, nil +} + // ParseInviteUserResponse parses an HTTP response from a InviteUserWithResponse call func ParseInviteUserResponse(rsp *http.Response) (*InviteUserResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -38784,3 +39238,19 @@ func ParseInviteUserResponse(rsp *http.Response) (*InviteUserResponse, error) { return response, nil } + +// ParseRemoveUserResponse parses an HTTP response from a RemoveUserWithResponse call +func ParseRemoveUserResponse(rsp *http.Response) (*RemoveUserResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RemoveUserResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} diff --git a/internal/cli/user.go b/internal/cli/user.go index 10e65b6..5318619 100644 --- a/internal/cli/user.go +++ b/internal/cli/user.go @@ -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" ) @@ -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 } diff --git a/internal/user/user.go b/internal/user/user.go index 1aeecc2..2d7d933 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -14,6 +14,8 @@ import ( 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 { @@ -54,3 +56,87 @@ func Invite(ctx context.Context, c *platform.Client, o InviteOptions) error { 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{} + 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, ¶ms) + }) + 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)) + 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 +} diff --git a/internal/user/user_test.go b/internal/user/user_test.go index eecbfde..1d5efbb 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -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") +} diff --git a/openapi/platform-api.json b/openapi/platform-api.json index 0a2cd21..fc39dee 100644 --- a/openapi/platform-api.json +++ b/openapi/platform-api.json @@ -8,7 +8,7 @@ "url": "https://steadybit.com", "email": "support@steadybit.com" }, - "version": "2.8.7" + "version": "1.0.0-SNAPSHOT" }, "externalDocs": { "description": "Steadybit Docs - Integrate via API", @@ -16,7 +16,7 @@ }, "servers": [ { - "url": "https://platform.steadybit.com", + "url": "https://platform.dev.steadybit.com", "description": "Generated server url" } ], @@ -442,11 +442,11 @@ } } }, - "400": { - "description": "The given `id` is not a uuid." - }, "404": { "description": "Saved view with given `id` was not found." + }, + "400": { + "description": "The given `id` is not a uuid." } } }, @@ -3954,12 +3954,12 @@ } ], "responses": { - "200": { - "description": "Experiment execution was already canceled, errored or completed." - }, "202": { "description": "Request to cancel the experiment was accepted and will be performed by communicating to the experiment's agents." }, + "200": { + "description": "Experiment execution was already canceled, errored or completed." + }, "404": { "description": "Experiment execution not found." } @@ -4559,6 +4559,57 @@ } } }, + "/api/users": { + "get": { + "tags": [ + "Users" + ], + "summary": "List the users of the tenant", + "description": "Lists all members of the tenant, including users that are in no team, sorted by username. Admins only.", + "operationId": "getUsers", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "Only users whose username, name or email contains this text", + "required": false, + "schema": { + "type": "string" + }, + "example": "jane" + }, + { + "name": "page", + "in": "query", + "description": "The number of the page and its size (at most 100, default 50)", + "required": true, + "schema": { + "$ref": "#/components/schemas/PageRequestAO" + } + } + ], + "responses": { + "200": { + "description": "A page of users", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedResponseAOUserAO" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/PagedResponseAOUserAO" + } + } + } + }, + "403": { + "description": "Only admins can list users" + } + } + } + }, "/api/teams/{key}": { "get": { "tags": [ @@ -6406,11 +6457,11 @@ } } }, - "400": { - "description": "The given `id` is not a uuid" - }, "404": { "description": "Environment with given `id` was not found" + }, + "400": { + "description": "The given `id` is not a uuid" } } }, @@ -6739,6 +6790,42 @@ } } }, + "/api/users/{username}": { + "delete": { + "tags": [ + "Users" + ], + "summary": "Remove a user from the tenant", + "description": "Removes the user's membership in this tenant: the user leaves all teams of the tenant, loses its access tokens for the tenant and is logged out. A user that is then a member of no tenant at all is deleted. If your identity provider still grants the user access, the user comes back on the next login - remove it there to offboard it for good. Users managed via SCIM can only be removed by the identity provider, and while an LDAP sync manages the users of the tenant, no user can be removed by hand. Admins only.", + "operationId": "removeUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The username of the user to remove", + "required": true, + "schema": { + "type": "string" + }, + "example": "jane.doe" + } + ], + "responses": { + "204": { + "description": "The user was removed from the tenant" + }, + "400": { + "description": "Admins cannot remove themselves" + }, + "403": { + "description": "Only admins can remove users, and not users managed via SCIM or an LDAP sync" + }, + "404": { + "description": "The user is not a member of the tenant" + } + } + } + }, "/api/access-tokens/{id}": { "delete": { "tags": [ @@ -6926,7 +7013,8 @@ "enum": [ "MANUAL", "OIDC", - "LDAP" + "LDAP", + "SCIM" ] } }, @@ -7449,6 +7537,14 @@ "$ref": "#/components/schemas/MemberUpdateAO" } }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 + }, "id": { "type": "string", "format": "uuid" @@ -7474,14 +7570,6 @@ "type": "string", "description": "An optional description of a team" }, - "version": { - "minimum": 0, - "type": "integer", - "description": "Version for optimistic locking (optional in the API)", - "format": "int32", - "nullable": true, - "example": 1 - }, "logoId": { "maxLength": 255, "minLength": 0, @@ -7537,7 +7625,8 @@ "enum": [ "MANUAL", "OIDC", - "LDAP" + "LDAP", + "SCIM" ] } }, @@ -7577,7 +7666,8 @@ "allowedEnvironments", "key", "members", - "name" + "name", + "version" ], "type": "object", "properties": { @@ -7606,14 +7696,6 @@ "type": "string", "description": "An optional description of a team" }, - "version": { - "minimum": 0, - "type": "integer", - "description": "Version for optimistic locking (optional in the API)", - "format": "int32", - "nullable": true, - "example": 1 - }, "logoId": { "maxLength": 255, "minLength": 0, @@ -7669,9 +7751,17 @@ "enum": [ "MANUAL", "OIDC", - "LDAP" + "LDAP", + "SCIM" ] }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "example": 1 + }, "members": { "maxItems": 512, "minItems": 0, @@ -8485,7 +8575,8 @@ "query", "serviceProfile", "team", - "validations" + "validations", + "version" ], "type": "object", "properties": { @@ -8580,7 +8671,7 @@ "version": { "minimum": 0, "type": "integer", - "description": "Version for optimistic locking", + "description": "Version for optimistic locking (optional in the API)", "format": "int32", "example": 1 }, @@ -8895,7 +8986,7 @@ "version": { "minimum": 0, "type": "integer", - "description": "Version for optimistic locking", + "description": "Version for optimistic locking (optional in the API)", "format": "int32", "example": 1 }, @@ -10683,6 +10774,14 @@ "example": { "EXAMPLE_CUSTOM_PROPERTY": "I like this experiment!" } + }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 } }, "description": "Create or update the experiment with the given experiment design.", @@ -10992,6 +11091,14 @@ "example": { "EXAMPLE_CUSTOM_PROPERTY": "I like this experiment!" } + }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 } }, "description": "Update the experiment with the given experiment design.", @@ -11158,14 +11265,6 @@ "format": "uuid", "nullable": true }, - "version": { - "minimum": 0, - "type": "integer", - "description": "Version for optimistic locking (optional in the API)", - "format": "int32", - "nullable": true, - "example": 1 - }, "templateTitle": { "maxLength": 255, "minLength": 0, @@ -11280,6 +11379,14 @@ "type": "boolean", "description": "Should the experiment template be hidden", "example": false + }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 } }, "description": "Insert or update the experiment template in Steadybit. The `id` will be used to identify whether the template already exists and should be updated or newly inserted.", @@ -11442,7 +11549,8 @@ "editedBy", "lanes", "templateDescription", - "templateTitle" + "templateTitle", + "version" ], "type": "object", "properties": { @@ -11451,14 +11559,6 @@ "format": "uuid", "nullable": true }, - "version": { - "minimum": 0, - "type": "integer", - "description": "Version for optimistic locking (optional in the API)", - "format": "int32", - "nullable": true, - "example": 1 - }, "templateTitle": { "maxLength": 255, "minLength": 0, @@ -11574,6 +11674,13 @@ "description": "Should the experiment template be hidden", "example": false }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "example": 1 + }, "created": { "type": "string", "description": "Timestamp when the experiment template was created", @@ -11765,6 +11872,14 @@ "UpdateExperimentFromTemplateAO": { "type": "object", "properties": { + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 + }, "placeholders": { "type": "array", "description": "List of template placeholder values", @@ -12623,6 +12738,14 @@ "EXAMPLE_CUSTOM_PROPERTY": "I like this experiment!" } }, + "version": { + "minimum": 0, + "type": "integer", + "description": "Version for optimistic locking (optional in the API)", + "format": "int32", + "nullable": true, + "example": 1 + }, "executionVariables": { "type": "object", "additionalProperties": { @@ -12698,6 +12821,7 @@ "type": "integer", "description": "Version for optimistic locking (optional in the API)", "format": "int32", + "nullable": true, "example": 1 }, "predicate": { @@ -12724,7 +12848,8 @@ "required": [ "name", "predicate", - "state" + "state", + "version" ], "type": "object", "properties": { @@ -13181,6 +13306,115 @@ "enabled": false } }, + "PageRequestAO": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "format": "int32" + } + } + }, + "PagedResponseAOUserAO": { + "type": "object", + "properties": { + "totalItems": { + "type": "integer", + "description": "Total amount of runs matching your query", + "format": "int64", + "example": 241 + }, + "nextPage": { + "type": "integer", + "description": "Next page to query for next page of runs or null if there are none.", + "format": "int32", + "nullable": true, + "example": 4 + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserAO" + } + } + } + }, + "UserAO": { + "required": [ + "role", + "teams", + "username" + ], + "type": "object", + "properties": { + "username": { + "minLength": 1, + "type": "string", + "description": "Username of the user, internal identifier of Steadybit", + "example": "13av2737-b318-4048-a79d-4789d645bc31" + }, + "name": { + "type": "string", + "description": "Name of the user", + "example": "Jane Doe" + }, + "email": { + "type": "string", + "description": "Email address of the user", + "nullable": true, + "example": "jane.doe@example.com" + }, + "role": { + "type": "string", + "description": "Role of the user in the tenant", + "enum": [ + "ADMIN", + "USER" + ] + }, + "managedBy": { + "type": "string", + "description": "What manages a user's membership in the tenant", + "nullable": true, + "enum": [ + "SCIM" + ] + }, + "teams": { + "type": "array", + "description": "Keys of the teams the user is a member of; empty if the user is in no team", + "example": [ + "ADM", + "PAYMENTS" + ], + "items": { + "type": "string", + "description": "Keys of the teams the user is a member of; empty if the user is in no team", + "example": "[\"ADM\",\"PAYMENTS\"]" + } + }, + "created": { + "type": "string", + "description": "When the user was created in Steadybit", + "format": "date-time", + "nullable": true, + "example": "2026-01-15T09:30:00Z" + }, + "lastLogin": { + "type": "string", + "description": "When the user last signed in; empty if the user never signed in", + "format": "date-time", + "nullable": true, + "example": "2026-10-13T08:12:00Z" + } + } + }, "TeamSummariesAO": { "type": "object", "properties": { @@ -13333,21 +13567,6 @@ } } }, - "PageRequestAO": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "format": "int32" - }, - "size": { - "maximum": 100, - "minimum": 1, - "type": "integer", - "format": "int32" - } - } - }, "PagedResponseAOServiceSummaryAO": { "type": "object", "properties": { @@ -13971,11 +14190,21 @@ "example": [ { "key": "ADM-1", - "name": "Shop survives unavailability of hot-deals products" + "name": "Shop survives unavailability of hot-deals products", + "externalId": "1234567", + "tags": [ + "myTag", + "myOtherTag" + ] }, { "key": "SHOP-2", - "name": "Network latency of Message Broker doesn't interfere with Online shop" + "name": "Network latency of Message Broker doesn't interfere with Online shop", + "externalId": "test-id", + "tags": [ + "yourTag", + "yourOtherTag" + ] } ], "items": { @@ -13988,11 +14217,21 @@ "experiments": [ { "key": "ADM-1", - "name": "Shop survives unavailability of hot-deals products" + "name": "Shop survives unavailability of hot-deals products", + "externalId": "1234567", + "tags": [ + "myTag", + "myOtherTag" + ] }, { "key": "SHOP-2", - "name": "Network latency of Message Broker doesn't interfere with Online shop" + "name": "Network latency of Message Broker doesn't interfere with Online shop", + "externalId": "test-id", + "tags": [ + "yourTag", + "yourOtherTag" + ] } ] } @@ -14009,12 +14248,36 @@ "type": "string", "description": "Name of the experiment to easily identify the experiment", "example": "Shop survives unavailability of hot-deals products" + }, + "externalId": { + "type": "string", + "description": "An optional external identifier used for create-or-update semantics.", + "example": "1234567" + }, + "tags": { + "uniqueItems": true, + "type": "array", + "description": "An optional set of tags you can use to search for.", + "example": [ + "myTag", + "myOtherTag" + ], + "items": { + "type": "string", + "description": "An optional set of tags you can use to search for.", + "example": "[\"myTag\",\"myOtherTag\"]" + } } }, "description": "Summary of a single experiment.", "example": { "key": "ADM-1", - "name": "Shop survives unavailability of hot-deals products" + "name": "Shop survives unavailability of hot-deals products", + "externalId": "1234567", + "tags": [ + "myTag", + "myOtherTag" + ] } }, "ExperimentAO": { @@ -14026,7 +14289,8 @@ "key", "lanes", "name", - "team" + "team", + "version" ], "type": "object", "properties": { @@ -14174,8 +14438,9 @@ "example": "ADM-2" }, "version": { + "minimum": 0, "type": "integer", - "description": "Experiment database version.", + "description": "Version for optimistic locking (optional in the API)", "format": "int32", "example": 1 }, @@ -15351,7 +15616,8 @@ "enum": [ "ADMIN", "TEAM", - "WILDCARD" + "WILDCARD", + "SCIM" ] }, "principalType": { @@ -17144,10 +17410,9 @@ }, "securitySchemes": { "AccessToken": { - "type": "apiKey", - "description": "Learn more about [API Access Token in our docs](https://docs.steadybit.com/integrate-with-steadybit/api#access-tokens).
\nTo generate a new API Access Token, go to [Platform Settings / API Access Token](/settings/api-tokens).\nWhen using the API Access Token in the HTTP Header 'Authorization', use the following value format:

\n```\naccessToken \n```\n\n", - "name": "Authorization", - "in": "header" + "type": "http", + "description": "Learn more about [API Access Token in our docs](https://docs.steadybit.com/integrate-with-steadybit/api#access-tokens).
\nTo generate a new API Access Token, go to [Platform Settings / API Access Token](/settings/api-tokens).\nPaste the token as-is below, the `Bearer` prefix is added for you. Outside of this page, send it in the HTTP Header 'Authorization':

\n```\nBearer \n```\nThe legacy format `accessToken ` is still supported.\n\n", + "scheme": "bearer" } } }