diff --git a/cmd/lk/member_removal.go b/cmd/lk/member_removal.go new file mode 100644 index 00000000..062d2c1e --- /dev/null +++ b/cmd/lk/member_removal.go @@ -0,0 +1,142 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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 main + +import ( + "context" + "errors" + "fmt" + "strings" + + "charm.land/huh/v2" + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/public" + "github.com/livekit/livekit-cli/v2/pkg/util" +) + +// maxKeyConfirmations is how many times removeMember asks the user to confirm +// a key count: once, and once more if the count changes before the removal +// goes ahead. +const maxKeyConfirmations = 2 + +// projectRemovalScope tells the user how far a project member removal +// reaches: a project that isn't private shares its members with its +// workspace, so the Public API removes the member from all of it. +const projectRemovalScope = "Unless the project is private, this removes them from its whole workspace and every project in it." + +// removeMember removes userID with remove, which also deletes the API keys +// they own, and returns how many it deleted. remove is called with +// confirmedNumKeys 0 first. If the member owns keys, the Public API refuses +// with their number; once the user confirms it (or passed --yes), remove is +// called again with that number. A removal that would leave a project without +// an API key is refused without asking. scope, if set, is shown with the +// confirmation. +func removeMember(ctx context.Context, cmd *cli.Command, userID, scope string, remove func(confirmedNumKeys uint32) (uint32, error)) (uint32, error) { + var confirmed uint32 + for asked := 0; ; asked++ { + numKeys, err := remove(confirmed) + removal, ok := public.MemberRemovalNotConfirmed(err) + if !ok { + return numKeys, err + } + if removal.LeavesProjectWithoutKey() { + return 0, lastKeyError(userID, removal) + } + if asked == maxKeyConfirmations { + return 0, fmt.Errorf("%s's API keys changed again (they now own %d); nothing was removed, run the command again", userID, removal.NumKeys) + } + if removal.NumKeys == 0 { + // Their keys went before the retry, so there's nothing to confirm. + confirmed = 0 + continue + } + if err := confirmKeyDeletion(ctx, cmd, userID, scope, removal.NumKeys, asked > 0); err != nil { + return 0, err + } + confirmed = removal.NumKeys + } +} + +// confirmKeyDeletion asks the user to agree to deleting the numKeys API keys +// userID owns. --yes agrees; without it a non-interactive run fails, since the +// deletion can't be undone. +func confirmKeyDeletion(ctx context.Context, cmd *cli.Command, userID, scope string, numKeys uint32, changed bool) error { + if cmd.Bool("yes") { + return nil + } + if !isInteractive() { + msg := fmt.Sprintf("removing %s deletes the %d API key(s) they own, and anything using them stops working", userID, numKeys) + if scope != "" { + msg += "; " + strings.ToLower(scope[:1]) + strings.TrimSuffix(scope[1:], ".") + } + return errors.New(msg + "; re-run with --yes to go ahead") + } + title := fmt.Sprintf("Remove %s and delete the %d API key(s) they own?", userID, numKeys) + if changed { + title = fmt.Sprintf("%s's API keys changed. Remove %s and delete the %d API key(s) they own now?", userID, userID, numKeys) + } + description := "Anything using those keys stops working." + if scope != "" { + description = scope + " " + description + } + confirmed := false + if err := huh.NewForm(huh.NewGroup(util.Confirm(). + Title(title). + Description(description). + Value(&confirmed). + WithTheme(util.FormTheme()))). + RunWithContext(ctx); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return errors.New("aborted") + } + return err + } + if !confirmed { + return errors.New("aborted") + } + return nil +} + +// lastKeyError says a removal is refused because it would leave projects +// without an API key, and what to do first. +func lastKeyError(userID string, removal *public.MemberRemoval) error { + projects := make([]string, 0, len(removal.LastKeyProjectNames)+1) + for _, name := range removal.LastKeyProjectNames { + projects = append(projects, fmt.Sprintf("%q", name)) + } + if removal.NumPrivateLastKeyProjects > 0 { + projects = append(projects, fmt.Sprintf("%d private project(s)", removal.NumPrivateLastKeyProjects)) + } + list := projects[len(projects)-1] + if len(projects) > 1 { + list = strings.Join(projects[:len(projects)-1], ", ") + " and " + list + } + return fmt.Errorf("can't remove %s: it would delete the last API key of %s; a project without an API key stops handling inbound SIP calls and webhooks, "+ + "so have a member who is staying create a replacement API key there first", userID, list) +} + +// renderMemberRemoved reports a removal and the API keys it deleted. +func renderMemberRemoved(cmd *cli.Command, userID string, numKeys uint32) error { + if cmd.Bool("json") { + return util.PrintJSONTo(out.ResultWriter(), map[string]any{"userId": userID, "removed": true, "numKeys": numKeys}) + } + if numKeys == 0 { + out.Statusf("Removed member %s", util.Accented(userID)) + return nil + } + out.Statusf("Removed member %s and deleted the %d API key(s) they owned", util.Accented(userID), numKeys) + return nil +} diff --git a/cmd/lk/member_removal_test.go b/cmd/lk/member_removal_test.go new file mode 100644 index 00000000..a682e709 --- /dev/null +++ b/cmd/lk/member_removal_test.go @@ -0,0 +1,262 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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 main + +import ( + "cmp" + "context" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/public" +) + +// notConfirmed is the google.rpc.Status the Public API answers a removal with +// while the member owns numKeys keys the caller didn't confirm. +func notConfirmed(numKeys int) string { + return fmt.Sprintf(`{"code":9,"message":"removing this member deletes the API keys they own (%[1]d in total), and services using them stop working; repeat the request with confirmed_num_keys=%[1]d to remove them","details":[`+ + `{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed","numKeys":%[1]d,"lastKeyProjectNames":[],"numPrivateLastKeyProjects":0}]}`, numKeys) +} + +const lastKey = `{"code":9,"message":"removing this member would delete the API keys they own (3 in total), including the last API key of \"prod\", 1 private project(s); a project without an API key stops handling inbound SIP calls and webhooks, so have a member who is staying create a replacement API key first","details":[` + + `{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed","numKeys":3,"lastKeyProjectNames":["prod"],"numPrivateLastKeyProjects":1}]}` + +type cannedRemoval struct { + status int + body string +} + +// TestRemoveMember runs removeMember against a stand-in Public API that answers +// each confirmedNumKeys with a canned removal, as lk runs non-interactively. +func TestRemoveMember(t *testing.T) { + tests := []struct { + name string + yes bool + // answers are by the confirmedNumKeys the request sent. + answers map[string]cannedRemoval + // again answers a confirmedNumKeys the second time it is sent. + again map[string]cannedRemoval + wantSent []string + wantNumKeys uint32 + wantErr string + }{ + { + name: "no keys", + answers: map[string]cannedRemoval{"0": {http.StatusOK, `{}`}}, + wantSent: []string{"0"}, + }, + { + name: "keys confirmed with --yes", + yes: true, + answers: map[string]cannedRemoval{ + "0": {http.StatusBadRequest, notConfirmed(3)}, + "3": {http.StatusOK, `{"numKeys":3}`}, + }, + wantSent: []string{"0", "3"}, + wantNumKeys: 3, + }, + { + name: "keys without --yes", + answers: map[string]cannedRemoval{"0": {http.StatusBadRequest, notConfirmed(3)}}, + wantSent: []string{"0"}, + wantErr: "removing u_2 deletes the 3 API key(s) they own, and anything using them stops working; " + + "unless the project is private, this removes them from its whole workspace and every project in it; re-run with --yes to go ahead", + }, + { + name: "keys revoked before the retry", + yes: true, + answers: map[string]cannedRemoval{ + "0": {http.StatusBadRequest, notConfirmed(3)}, + "3": {http.StatusBadRequest, notConfirmed(0)}, + }, + again: map[string]cannedRemoval{"0": {http.StatusOK, `{}`}}, + wantSent: []string{"0", "3", "0"}, + }, + { + name: "count changes once", + yes: true, + answers: map[string]cannedRemoval{ + "0": {http.StatusBadRequest, notConfirmed(3)}, + "3": {http.StatusBadRequest, notConfirmed(4)}, + "4": {http.StatusOK, `{"numKeys":4}`}, + }, + wantSent: []string{"0", "3", "4"}, + wantNumKeys: 4, + }, + { + name: "count changes twice", + yes: true, + answers: map[string]cannedRemoval{ + "0": {http.StatusBadRequest, notConfirmed(3)}, + "3": {http.StatusBadRequest, notConfirmed(4)}, + "4": {http.StatusBadRequest, notConfirmed(5)}, + }, + wantSent: []string{"0", "3", "4"}, + wantErr: "u_2's API keys changed again (they now own 5); nothing was removed, run the command again", + }, + { + name: "last key, even with --yes", + yes: true, + answers: map[string]cannedRemoval{"0": {http.StatusBadRequest, lastKey}}, + wantSent: []string{"0"}, + wantErr: `can't remove u_2: it would delete the last API key of "prod" and 1 private project(s)`, + }, + { + name: "last key after confirming", + yes: true, + answers: map[string]cannedRemoval{ + "0": {http.StatusBadRequest, notConfirmed(3)}, + "3": {http.StatusBadRequest, lastKey}, + }, + wantSent: []string{"0", "3"}, + wantErr: `can't remove u_2: it would delete the last API key of "prod" and 1 private project(s)`, + }, + { + name: "another refusal", + yes: true, + answers: map[string]cannedRemoval{"0": {http.StatusForbidden, `{"code":7,"message":"permission denied"}`}}, + wantSent: []string{"0"}, + wantErr: "permission denied", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // A run with no terminal, the way CI runs lk. + t.Setenv("CI", "true") + var sent []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // lk leaves out a count of 0, which the API reads as 0. + confirmed := cmp.Or(r.URL.Query().Get("confirmedNumKeys"), "0") + answer, ok := tt.answers[confirmed] + if again, seen := tt.again[confirmed]; seen && slices.Contains(sent, confirmed) { + answer, ok = again, true + } + sent = append(sent, confirmed) + if !assert.True(t, ok, "no answer for confirmedNumKeys=%s", confirmed) { + w.WriteHeader(http.StatusNotImplemented) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(answer.status) + _, _ = w.Write([]byte(answer.body)) + })) + t.Cleanup(srv.Close) + client, err := public.New(srv.URL, "tok") + require.NoError(t, err) + + var numKeys uint32 + var removeErr error + app := &cli.Command{ + Name: "lk", + Flags: []cli.Flag{&cli.BoolFlag{Name: "yes"}}, + Action: func(ctx context.Context, cmd *cli.Command) error { + numKeys, removeErr = removeMember(ctx, cmd, "u_2", projectRemovalScope, func(confirmedNumKeys uint32) (uint32, error) { + return client.RemoveProjectMember(ctx, "p_1", "u_2", confirmedNumKeys) + }) + return nil + }, + } + args := []string{"lk"} + if tt.yes { + args = append(args, "--yes") + } + require.NoError(t, app.Run(context.Background(), args)) + + assert.Equal(t, tt.wantSent, sent) + if tt.wantErr != "" { + assert.ErrorContains(t, removeErr, tt.wantErr) + return + } + require.NoError(t, removeErr) + assert.Equal(t, tt.wantNumKeys, numKeys) + }) + } +} + +func TestLastKeyError(t *testing.T) { + const why = "; a project without an API key stops handling inbound SIP calls and webhooks, " + + "so have a member who is staying create a replacement API key there first" + tests := []struct { + name string + removal public.MemberRemoval + want string + }{ + { + name: "named projects", + removal: public.MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{"prod", "staging"}}, + want: `can't remove u_2: it would delete the last API key of "prod" and "staging"` + why, + }, + { + name: "three named projects", + removal: public.MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{"dev", "prod", "staging"}}, + want: `can't remove u_2: it would delete the last API key of "dev", "prod" and "staging"` + why, + }, + { + name: "private projects only", + removal: public.MemberRemoval{NumKeys: 3, NumPrivateLastKeyProjects: 2}, + want: `can't remove u_2: it would delete the last API key of 2 private project(s)` + why, + }, + { + name: "both", + removal: public.MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{"prod"}, NumPrivateLastKeyProjects: 1}, + want: `can't remove u_2: it would delete the last API key of "prod" and 1 private project(s)` + why, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.EqualError(t, lastKeyError("u_2", &tt.removal), tt.want) + }) + } +} + +func TestRenderMemberRemoved(t *testing.T) { + tests := []struct { + name string + json bool + numKeys uint32 + wantStdout string + wantStderr string + }{ + {name: "no keys", wantStderr: "Removed member u_2\n"}, + {name: "keys", numKeys: 3, wantStderr: "Removed member u_2 and deleted the 3 API key(s) they owned\n"}, + {name: "json", json: true, numKeys: 3, wantStdout: "{\n \"numKeys\": 3,\n \"removed\": true,\n \"userId\": \"u_2\"\n}\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, stderr := captureOut(t) + app := &cli.Command{ + Name: "lk", + Flags: []cli.Flag{&cli.BoolFlag{Name: "json"}}, + Action: func(_ context.Context, cmd *cli.Command) error { + return renderMemberRemoved(cmd, "u_2", tt.numKeys) + }, + } + args := []string{"lk"} + if tt.json { + args = append(args, "--json") + } + require.NoError(t, app.Run(context.Background(), args)) + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/cmd/lk/project_members.go b/cmd/lk/project_members.go index bd1b29a7..8f562431 100644 --- a/cmd/lk/project_members.go +++ b/cmd/lk/project_members.go @@ -61,8 +61,10 @@ func init() { }, { Name: "remove", - Usage: "Remove a member from a project", + Usage: "Remove a member from a project and delete the API keys they own", UsageText: "lk project member remove USER_ID --project PROJECT --user-auth", + Description: "Unless the project is private, it shares its members with its workspace, so this removes the member\n" + + "from the workspace and every project in it, and deletes the API keys they own in any of them.", ArgsUsage: "USER_ID", Action: removeProjectMember, Flags: []cli.Flag{jsonFlag}, @@ -223,10 +225,13 @@ func removeProjectMember(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - if err := client.RemoveProjectMember(ctx, projectID, userID); err != nil { + numKeys, err := removeMember(ctx, cmd, userID, projectRemovalScope, func(confirmedNumKeys uint32) (uint32, error) { + return client.RemoveProjectMember(ctx, projectID, userID, confirmedNumKeys) + }) + if err != nil { return cloudAPIError(err) } - return util.RenderAction(out, cmd.Bool("json"), "userId", userID, "removed", fmt.Sprintf("Removed member %s", util.Accented(userID))) + return renderMemberRemoved(cmd, userID, numKeys) } func addProjectMembers(ctx context.Context, cmd *cli.Command) error { diff --git a/cmd/lk/script_test.go b/cmd/lk/script_test.go index bf24c0ed..ede3c029 100644 --- a/cmd/lk/script_test.go +++ b/cmd/lk/script_test.go @@ -34,9 +34,11 @@ package main // Two script commands drive it: // // respond METHOD URL STATUS [BODY] -// Answer METHOD URL (any query) with STATUS and BODY. A request with -// no answer gets 501. A Public API body (a /v0 path) must be what the -// spec says that route sends: see checkPublicAPIBody. +// Answer METHOD URL with STATUS and BODY. A URL without a query +// answers any query; one with a query answers only that query, and +// wins over one without. A request with no answer gets 501. A Public +// API body (a /v0 path) must be what the spec says that route sends: +// see checkPublicAPIBody. // requests // Print every request the fake received, in order, with the // credential it carried (a session token as bearer=, or as apikey= the @@ -123,7 +125,7 @@ type fakeCloud struct { *httptest.Server mu sync.Mutex - responses map[string]cannedResponse // by "METHOD URL", without the query + responses map[string]cannedResponse // by "METHOD URL", with the query only if respond had one log []string // mint makes the fake answer a Public API token request it has no canned @@ -192,7 +194,10 @@ func (f *fakeCloud) serve(w http.ResponseWriter, r *http.Request) { } f.log = append(f.log, line) - res, ok := f.responses[target] + res, ok := f.responses[target+"?"+r.URL.Query().Encode()] + if !ok { + res, ok = f.responses[target] + } if m := tokenRequestPath.FindStringSubmatch(r.URL.Path); !ok && f.mint && r.Method == http.MethodPost && m != nil { res, ok = f.mintToken(r, m[1]), true } @@ -278,7 +283,11 @@ func (f *fakeCloud) respond(target string, status int, body string) error { } f.mu.Lock() defer f.mu.Unlock() - f.responses[method+" "+u.Scheme+"://"+u.Host+u.Path] = cannedResponse{status, body} + key := method + " " + u.Scheme + "://" + u.Host + u.Path + if u.RawQuery != "" { + key += "?" + u.Query().Encode() + } + f.responses[key] = cannedResponse{status, body} return nil } @@ -295,6 +304,8 @@ var publicAPIRoutes = []struct { {http.MethodPost, regexp.MustCompile(`^/v0/projects/[^/]+/keys$`), func() any { return new(oapi.LivekitPublicapiProjectsV1GenerateKeyResponse) }}, {http.MethodDelete, regexp.MustCompile(`^/v0/projects/[^/]+/keys/[^/]+$`), func() any { return new(oapi.LivekitPublicapiProjectsV1DeleteKeyResponse) }}, {http.MethodGet, regexp.MustCompile(`^/v0/projects/[^/]+/members$`), func() any { return new(oapi.LivekitPublicapiProjectsV1ListMembersResponse) }}, + {http.MethodDelete, regexp.MustCompile(`^/v0/projects/[^/]+/members/[^/]+$`), func() any { return new(oapi.LivekitPublicapiProjectsV1RemoveMemberResponse) }}, + {http.MethodDelete, regexp.MustCompile(`^/v0/workspaces/[^/]+/members/[^/]+$`), func() any { return new(oapi.LivekitPublicapiWorkspacesV1DeleteMemberResponse) }}, } // middlewareError is the body the Public API's auth middleware and panic diff --git a/cmd/lk/testdata/script/member-removal-confirmed.txtar b/cmd/lk/testdata/script/member-removal-confirmed.txtar new file mode 100644 index 00000000..2758ab9b --- /dev/null +++ b/cmd/lk/testdata/script/member-removal-confirmed.txtar @@ -0,0 +1,50 @@ +# Removing a member who owns API keys deletes them only once confirmed. The +# Public API refuses the first call, which confirms 0, and says how many keys +# the member owns. A run that can't prompt then fails and says to pass --yes; +# with --yes, lk calls again with that count and says how many keys went. +# lk leaves the first call's count of 0 out, so its answer has no query; the +# retry's answer is only for the confirmedNumKeys it names, and wins. +respond DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 400 '{"code":9, "message":"removing this member deletes the API keys they own (3 in total), and services using them stop working; repeat the request with confirmed_num_keys=3 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +respond DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2?confirmedNumKeys=3 200 '{"numKeys":3}' + +! exec lk --user-auth workspace member delete u_2 --workspace acme +! stdout . +stderr '^removing u_2 deletes the 3 API key\(s\) they own, and anything using them stops working; re-run with --yes to go ahead$' + +exec lk --user-auth --yes workspace member delete u_2 --workspace acme +! stdout . +stderr '^Removed member u_2 and deleted the 3 API key\(s\) they owned$' + +exec lk --user-auth --yes workspace member delete u_2 --workspace acme --json +cmp stdout want-json + +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_admin +users: + - id: u_admin + email: admin@example.com + session_token: st_admin + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one + workspaces: + - workspace_id: ws_1 + name: acme + alias: acme +-- want-json -- +{ + "numKeys": 3, + "removed": true, + "userId": "u_2" +} +-- want-requests -- +DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 bearer=st_admin +DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 bearer=st_admin +DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 bearer=st_admin +DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 bearer=st_admin +DELETE http://publicapi.test/v0/workspaces/ws_1/members/u_2 bearer=st_admin diff --git a/cmd/lk/testdata/script/member-removal-count-changes.txtar b/cmd/lk/testdata/script/member-removal-count-changes.txtar new file mode 100644 index 00000000..46ef807a --- /dev/null +++ b/cmd/lk/testdata/script/member-removal-count-changes.txtar @@ -0,0 +1,43 @@ +# If the member's API keys change between lk's calls, the Public API refuses +# the confirmed count and reports the new one. lk confirms once more (here +# with --yes) and goes ahead; if the count changes again, it fails and +# nothing is removed. lk leaves the first call's count of 0 out, so its +# answer has no query; each retry's answer is only for the confirmedNumKeys +# it names, and wins. +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2 400 '{"code":9, "message":"removing this member deletes the API keys they own (3 in total), and services using them stop working; repeat the request with confirmed_num_keys=3 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2?confirmedNumKeys=3 400 '{"code":9, "message":"removing this member deletes the API keys they own (4 in total), and services using them stop working; repeat the request with confirmed_num_keys=4 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":4, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2?confirmedNumKeys=4 200 '{"numKeys":4}' +exec lk --user-auth --yes project member remove u_2 --project one +stderr '^Removed member u_2 and deleted the 4 API key\(s\) they owned$' + +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_3 400 '{"code":9, "message":"removing this member deletes the API keys they own (3 in total), and services using them stop working; repeat the request with confirmed_num_keys=3 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_3?confirmedNumKeys=3 400 '{"code":9, "message":"removing this member deletes the API keys they own (4 in total), and services using them stop working; repeat the request with confirmed_num_keys=4 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":4, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_3?confirmedNumKeys=4 400 '{"code":9, "message":"removing this member deletes the API keys they own (5 in total), and services using them stop working; repeat the request with confirmed_num_keys=5 to remove them", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":5, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}' +! exec lk --user-auth --yes project member remove u_3 --project one +stderr '^u_3''s API keys changed again \(they now own 5\); nothing was removed, run the command again$' + +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_admin +users: + - id: u_admin + email: admin@example.com + session_token: st_admin + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one + workspaces: + - workspace_id: ws_1 + name: acme + alias: acme +-- want-requests -- +DELETE http://publicapi.test/v0/projects/p_1/members/u_2 bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/members/u_2 bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/members/u_2 bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/members/u_3 bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/members/u_3 bearer=st_admin project=p_1 +DELETE http://publicapi.test/v0/projects/p_1/members/u_3 bearer=st_admin project=p_1 diff --git a/cmd/lk/testdata/script/member-removal-last-key.txtar b/cmd/lk/testdata/script/member-removal-last-key.txtar new file mode 100644 index 00000000..0320a875 --- /dev/null +++ b/cmd/lk/testdata/script/member-removal-last-key.txtar @@ -0,0 +1,27 @@ +# A removal that would leave a project without an API key is refused even +# when the count is confirmed, so lk names the projects, counts the private +# ones, and makes no second call, even with --yes. +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2 400 '{"code":9, "message":"removing this member would delete the API keys they own (3 in total), including the last API key of \"one\", 1 private project(s); a project without an API key stops handling inbound SIP calls and webhooks, so have a member who is staying create a replacement API key first", "details":[{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":["one"], "numPrivateLastKeyProjects":1}]}' +! exec lk --user-auth --yes project member remove u_2 --project one +! stdout . +stderr '^can''t remove u_2: it would delete the last API key of "one" and 1 private project\(s\); a project without an API key stops handling inbound SIP calls and webhooks, so have a member who is staying create a replacement API key there first$' +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_admin +users: + - id: u_admin + email: admin@example.com + session_token: st_admin + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one + workspaces: + - workspace_id: ws_1 + name: acme + alias: acme +-- want-requests -- +DELETE http://publicapi.test/v0/projects/p_1/members/u_2 bearer=st_admin project=p_1 diff --git a/cmd/lk/testdata/script/member-removal-no-keys.txtar b/cmd/lk/testdata/script/member-removal-no-keys.txtar new file mode 100644 index 00000000..89b05aff --- /dev/null +++ b/cmd/lk/testdata/script/member-removal-no-keys.txtar @@ -0,0 +1,29 @@ +# Removing a member who owns no API keys goes ahead without asking: lk +# confirms 0 keys, which is all the member owns. It leaves a count of 0 out +# of the request, which the API reads as 0; a server from before the count +# answers an explicit one with a 500, as the second respond stands in for. +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2 200 '{"numKeys":0}' +respond DELETE http://publicapi.test/v0/projects/p_1/members/u_2?confirmedNumKeys=0 500 '{"code":2, "message":"unknown field in field path confirmedNumKeys"}' +exec lk --user-auth project member remove u_2 --project one +! stdout . +stderr '^Removed member u_2$' +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +default_user: u_admin +users: + - id: u_admin + email: admin@example.com + session_token: st_admin + api_url: http://publicapi.test + projects: + - project_id: p_1 + name: one + alias: one + workspaces: + - workspace_id: ws_1 + name: acme + alias: acme +-- want-requests -- +DELETE http://publicapi.test/v0/projects/p_1/members/u_2 bearer=st_admin project=p_1 diff --git a/cmd/lk/workspace.go b/cmd/lk/workspace.go index 9bd9c239..f36f563d 100644 --- a/cmd/lk/workspace.go +++ b/cmd/lk/workspace.go @@ -175,7 +175,7 @@ func workspaceMemberCommand() *cli.Command { }, { Name: "delete", - Usage: "Remove a member from a workspace", + Usage: "Remove a member from a workspace and delete the API keys they own", UsageText: "lk workspace member delete USER_ID --workspace WORKSPACE --user-auth", ArgsUsage: "USER_ID", Action: deleteWorkspaceMember, @@ -491,10 +491,13 @@ func deleteWorkspaceMember(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } - if err := client.DeleteWorkspaceMember(ctx, wsID, userID); err != nil { + numKeys, err := removeMember(ctx, cmd, userID, "", func(confirmedNumKeys uint32) (uint32, error) { + return client.DeleteWorkspaceMember(ctx, wsID, userID, confirmedNumKeys) + }) + if err != nil { return cloudAPIError(err) } - return util.RenderAction(out, cmd.Bool("json"), "userId", userID, "removed", fmt.Sprintf("Removed member %s", util.Accented(userID))) + return renderMemberRemoved(cmd, userID, numKeys) } func listWorkspaceInvites(ctx context.Context, cmd *cli.Command) error { diff --git a/pkg/public/members.go b/pkg/public/members.go index 03b3b6a1..a63c441b 100644 --- a/pkg/public/members.go +++ b/pkg/public/members.go @@ -58,13 +58,73 @@ func (c *Client) UpdateProjectMember(ctx context.Context, projectID, userID stri return requirePayload(resp.JSON200.Member, "member") } -// RemoveProjectMember removes a member from a project. -func (c *Client) RemoveProjectMember(ctx context.Context, projectID, userID string) error { - resp, err := c.gen.ProjectServiceRemoveMemberWithResponse(ctx, projectID, userID) +// RemoveProjectMember removes a member from a project (from its whole +// workspace when the project isn't private), deletes the API keys they own +// there, and returns how many it deleted. confirmedNumKeys must be that count: +// call with 0, and if the member owns keys the error carries the count (see +// MemberRemovalNotConfirmed). +func (c *Client) RemoveProjectMember(ctx context.Context, projectID, userID string, confirmedNumKeys uint32) (uint32, error) { + resp, err := c.gen.ProjectServiceRemoveMemberWithResponse(ctx, projectID, userID, &oapi.ProjectServiceRemoveMemberParams{ + ConfirmedNumKeys: confirmedNumKeysParam(confirmedNumKeys), + }) if err != nil { - return err + return 0, err + } + if resp.JSON200 == nil { + return 0, responseError(resp.StatusCode(), resp.Body) + } + return numKeys(resp.JSON200.NumKeys), nil +} + +// MemberRemoval is what removing a member would do to the API keys they own. +type MemberRemoval struct { + // NumKeys is how many of the member's API keys the removal deletes. Pass + // it as confirmedNumKeys to go ahead. + NumKeys uint32 `json:"numKeys"` + // LastKeyProjectNames are the projects the removal would leave without an + // API key. Private ones the caller may not see are only counted, in + // NumPrivateLastKeyProjects. + LastKeyProjectNames []string `json:"lastKeyProjectNames"` + NumPrivateLastKeyProjects uint32 `json:"numPrivateLastKeyProjects"` +} + +// LeavesProjectWithoutKey reports whether the removal would take a project's +// last API key. The Public API refuses such a removal even when the count is +// confirmed. +func (r *MemberRemoval) LeavesProjectWithoutKey() bool { + return len(r.LastKeyProjectNames) > 0 || r.NumPrivateLastKeyProjects > 0 +} + +// MemberRemovalNotConfirmed reports whether err is the Public API's refusal of +// a member removal that didn't go ahead: the member owns API keys and the +// caller didn't confirm their number, or the removal would leave a project +// without an API key. +func MemberRemovalNotConfirmed(err error) (*MemberRemoval, bool) { + var removal MemberRemoval + if !errorDetail(err, "livekit.publicapi.common.v1.MemberRemovalNotConfirmed", &removal) { + return nil, false + } + return &removal, true +} + +// confirmedNumKeysParam leaves a count of 0 out of the request, which the API +// reads as 0. A server from before confirmedNumKeys answers the parameter with +// an unknown-field error, so leaving it out keeps removing a member who owns +// no keys working there. +func confirmedNumKeysParam(n uint32) *int { + if n == 0 { + return nil + } + return ptr(int(n)) +} + +// numKeys reads a member removal's numKeys, which the API leaves out when no +// key was deleted. +func numKeys(n *int) uint32 { + if n == nil { + return 0 } - return okOrError(resp.StatusCode(), resp.Body) + return uint32(*n) } // AddWorkspaceMembersToProject grants a set of existing workspace members access diff --git a/pkg/public/members_test.go b/pkg/public/members_test.go new file mode 100644 index 00000000..a2717b4f --- /dev/null +++ b/pkg/public/members_test.go @@ -0,0 +1,138 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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 public + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The bodies are what the server's REST transcoder writes: the removal's +// numKeys, or a google.rpc.Status carrying a MemberRemovalNotConfirmed. +const ( + notConfirmedBody = `{"code":9, "message":"removing this member deletes the API keys they own (3 in total), and services using them stop working; repeat the request with confirmed_num_keys=3 to remove them", "details":[` + + `{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":[], "numPrivateLastKeyProjects":0}]}` + lastKeyBody = `{"code":9, "message":"removing this member would delete the API keys they own (3 in total), including the last API key of \"prod\", 1 private project(s); a project without an API key stops handling inbound SIP calls and webhooks, so have a member who is staying create a replacement API key first", "details":[` + + `{"@type":"type.googleapis.com/livekit.publicapi.common.v1.MemberRemovalNotConfirmed", "numKeys":3, "lastKeyProjectNames":["prod"], "numPrivateLastKeyProjects":1}]}` +) + +// TestRemoveMember checks that both removals send the confirmed count, leaving +// out a count of 0, and return the count the API deleted. +func TestRemoveMember(t *testing.T) { + tests := []struct { + name string + remove func(c *Client, confirmed uint32) (uint32, error) + wantPath string + }{ + { + name: "project", + remove: func(c *Client, confirmed uint32) (uint32, error) { + return c.RemoveProjectMember(context.Background(), "p_1", "u_1", confirmed) + }, + wantPath: "/v0/projects/p_1/members/u_1", + }, + { + name: "workspace", + remove: func(c *Client, confirmed uint32) (uint32, error) { + return c.DeleteWorkspaceMember(context.Background(), "ws_1", "u_1", confirmed) + }, + wantPath: "/v0/workspaces/ws_1/members/u_1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.RequestURI()) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("confirmedNumKeys") { + case "": + _, _ = w.Write([]byte(`{}`)) + default: + _, _ = w.Write([]byte(`{"numKeys":3}`)) + } + })) + t.Cleanup(srv.Close) + c, err := New(srv.URL, "tok") + require.NoError(t, err) + + n, err := tt.remove(c, 0) + require.NoError(t, err) + assert.Equal(t, uint32(0), n, "a member who owns no keys") + + n, err = tt.remove(c, 3) + require.NoError(t, err) + assert.Equal(t, uint32(3), n) + + assert.Equal(t, []string{ + "DELETE " + tt.wantPath, + "DELETE " + tt.wantPath + "?confirmedNumKeys=3", + }, calls) + }) + } +} + +func TestMemberRemovalNotConfirmed(t *testing.T) { + tests := []struct { + name string + body string + wantRemoval *MemberRemoval + }{ + { + name: "keys to confirm", + body: notConfirmedBody, + wantRemoval: &MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{}}, + }, + { + name: "last key", + body: lastKeyBody, + wantRemoval: &MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{"prod"}, NumPrivateLastKeyProjects: 1}, + }, + { + name: "another failed precondition", + body: `{"code":9,"message":"something else","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"X"}]}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := jsonServer(t, http.StatusBadRequest, tt.body, nil, nil) + c, err := New(srv.URL, "tok") + require.NoError(t, err) + + _, err = c.RemoveProjectMember(context.Background(), "p_1", "u_1", 0) + require.Error(t, err) + removal, ok := MemberRemovalNotConfirmed(err) + assert.Equal(t, tt.wantRemoval != nil, ok) + assert.Equal(t, tt.wantRemoval, removal) + }) + } + + t.Run("not an API error", func(t *testing.T) { + _, ok := MemberRemovalNotConfirmed(errors.New("connection refused")) + assert.False(t, ok) + }) +} + +func TestMemberRemovalLeavesProjectWithoutKey(t *testing.T) { + assert.False(t, (&MemberRemoval{NumKeys: 3}).LeavesProjectWithoutKey()) + assert.True(t, (&MemberRemoval{NumKeys: 3, LastKeyProjectNames: []string{"prod"}}).LeavesProjectWithoutKey()) + assert.True(t, (&MemberRemoval{NumKeys: 3, NumPrivateLastKeyProjects: 1}).LeavesProjectWithoutKey()) +} diff --git a/pkg/public/oapi/oapi.gen.go b/pkg/public/oapi/oapi.gen.go index b08a714d..49fc7dea 100644 --- a/pkg/public/oapi/oapi.gen.go +++ b/pkg/public/oapi/oapi.gen.go @@ -2849,7 +2849,10 @@ type LivekitPublicapiProjectsV1ProjectPreferences struct { } // LivekitPublicapiProjectsV1RemoveMemberResponse defines model for livekit.publicapi.projects.v1.RemoveMemberResponse. -type LivekitPublicapiProjectsV1RemoveMemberResponse = map[string]interface{} +type LivekitPublicapiProjectsV1RemoveMemberResponse struct { + // NumKeys Non-agent API keys the removal deleted. + NumKeys *int `json:"numKeys,omitempty"` +} // LivekitPublicapiProjectsV1SendTestWebhookResponse Reports the receiver's answer to one delivery attempt. The test is not // @@ -3156,7 +3159,10 @@ type LivekitPublicapiWorkspacesV1CreateWorkspaceResponse struct { type LivekitPublicapiWorkspacesV1DeleteInviteResponse = map[string]interface{} // LivekitPublicapiWorkspacesV1DeleteMemberResponse defines model for livekit.publicapi.workspaces.v1.DeleteMemberResponse. -type LivekitPublicapiWorkspacesV1DeleteMemberResponse = map[string]interface{} +type LivekitPublicapiWorkspacesV1DeleteMemberResponse struct { + // NumKeys Non-agent API keys the removal deleted. + NumKeys *int `json:"numKeys,omitempty"` +} // LivekitPublicapiWorkspacesV1DeleteWorkspaceResponse defines model for livekit.publicapi.workspaces.v1.DeleteWorkspaceResponse. type LivekitPublicapiWorkspacesV1DeleteWorkspaceResponse = map[string]interface{} @@ -3390,6 +3396,14 @@ type ProjectServiceGenerateKeyJSONBody struct { IsServiceAccount *bool `json:"isServiceAccount,omitempty"` } +// ProjectServiceRemoveMemberParams defines parameters for ProjectServiceRemoveMember. +type ProjectServiceRemoveMemberParams struct { + // ConfirmedNumKeys Number of the member's API keys the caller agrees to delete. Call with 0; + // if the member owns keys, the call fails with a MemberRemovalNotConfirmed + // detail, so show its num_keys to the user and call again with it. + ConfirmedNumKeys *int `form:"confirmedNumKeys,omitempty" json:"confirmedNumKeys,omitempty"` +} + // ProjectServiceUpdateMemberJSONBody defines parameters for ProjectServiceUpdateMember. type ProjectServiceUpdateMemberJSONBody struct { Role *int32 `json:"role,omitempty"` @@ -3791,6 +3805,14 @@ type WorkspaceServiceCreateInviteJSONBody struct { Role *int32 `json:"role,omitempty"` } +// WorkspaceServiceDeleteMemberParams defines parameters for WorkspaceServiceDeleteMember. +type WorkspaceServiceDeleteMemberParams struct { + // ConfirmedNumKeys Number of the member's API keys the caller agrees to delete. Call with 0; + // if the member owns keys, the call fails with a MemberRemovalNotConfirmed + // detail, so show its num_keys to the user and call again with it. + ConfirmedNumKeys *int `form:"confirmedNumKeys,omitempty" json:"confirmedNumKeys,omitempty"` +} + // WorkspaceServiceUpdateMemberJSONBody defines parameters for WorkspaceServiceUpdateMember. type WorkspaceServiceUpdateMemberJSONBody struct { Role *int32 `json:"role,omitempty"` @@ -6128,7 +6150,7 @@ type ClientInterface interface { // ProjectServiceRemoveMember RemoveMember // // Corresponds with DELETE /v0/projects/{projectId}/members/{userId} (the `ProjectServiceRemoveMember` operationId). - ProjectServiceRemoveMember(ctx context.Context, projectId string, userId string, reqEditors ...RequestEditorFn) (*http.Response, error) + ProjectServiceRemoveMember(ctx context.Context, projectId string, userId string, params *ProjectServiceRemoveMemberParams, reqEditors ...RequestEditorFn) (*http.Response, error) // ProjectServiceGetMember GetMember // @@ -6690,7 +6712,7 @@ type ClientInterface interface { // WorkspaceServiceDeleteMember DeleteMember // // Corresponds with DELETE /v0/workspaces/{workspaceId}/members/{userId} (the `WorkspaceServiceDeleteMember` operationId). - WorkspaceServiceDeleteMember(ctx context.Context, workspaceId string, userId string, reqEditors ...RequestEditorFn) (*http.Response, error) + WorkspaceServiceDeleteMember(ctx context.Context, workspaceId string, userId string, params *WorkspaceServiceDeleteMemberParams, reqEditors ...RequestEditorFn) (*http.Response, error) // WorkspaceServiceGetMember GetMember // @@ -7258,8 +7280,8 @@ func (c *Client) ProjectServiceListMembers(ctx context.Context, projectId string // ProjectServiceRemoveMember RemoveMember // // Corresponds with DELETE /v0/projects/{projectId}/members/{userId} (the `ProjectServiceRemoveMember` operationId). -func (c *Client) ProjectServiceRemoveMember(ctx context.Context, projectId string, userId string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewProjectServiceRemoveMemberRequest(c.Server, projectId, userId) +func (c *Client) ProjectServiceRemoveMember(ctx context.Context, projectId string, userId string, params *ProjectServiceRemoveMemberParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProjectServiceRemoveMemberRequest(c.Server, projectId, userId, params) if err != nil { return nil, err } @@ -8498,8 +8520,8 @@ func (c *Client) WorkspaceServiceListMembers(ctx context.Context, workspaceId st // WorkspaceServiceDeleteMember DeleteMember // // Corresponds with DELETE /v0/workspaces/{workspaceId}/members/{userId} (the `WorkspaceServiceDeleteMember` operationId). -func (c *Client) WorkspaceServiceDeleteMember(ctx context.Context, workspaceId string, userId string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewWorkspaceServiceDeleteMemberRequest(c.Server, workspaceId, userId) +func (c *Client) WorkspaceServiceDeleteMember(ctx context.Context, workspaceId string, userId string, params *WorkspaceServiceDeleteMemberParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewWorkspaceServiceDeleteMemberRequest(c.Server, workspaceId, userId, params) if err != nil { return nil, err } @@ -9821,7 +9843,7 @@ func NewProjectServiceListMembersRequest(server string, projectId string) (*http } // NewProjectServiceRemoveMemberRequest constructs an http.Request for the ProjectServiceRemoveMember method -func NewProjectServiceRemoveMemberRequest(server string, projectId string, userId string) (*http.Request, error) { +func NewProjectServiceRemoveMemberRequest(server string, projectId string, userId string, params *ProjectServiceRemoveMemberParams) (*http.Request, error) { var err error var pathParam0 string @@ -9853,6 +9875,33 @@ func NewProjectServiceRemoveMemberRequest(server string, projectId string, userI 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.ConfirmedNumKeys != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "confirmedNumKeys", *params.ConfirmedNumKeys, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", 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.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err @@ -12801,7 +12850,7 @@ func NewWorkspaceServiceListMembersRequest(server string, workspaceId string) (* } // NewWorkspaceServiceDeleteMemberRequest constructs an http.Request for the WorkspaceServiceDeleteMember method -func NewWorkspaceServiceDeleteMemberRequest(server string, workspaceId string, userId string) (*http.Request, error) { +func NewWorkspaceServiceDeleteMemberRequest(server string, workspaceId string, userId string, params *WorkspaceServiceDeleteMemberParams) (*http.Request, error) { var err error var pathParam0 string @@ -12833,6 +12882,33 @@ func NewWorkspaceServiceDeleteMemberRequest(server string, workspaceId string, u 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.ConfirmedNumKeys != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "confirmedNumKeys", *params.ConfirmedNumKeys, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", 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.MethodDelete, queryURL.String(), nil) if err != nil { return nil, err @@ -13425,7 +13501,7 @@ type ClientWithResponsesInterface interface { // Returns a wrapper object for the known response body format(s). // // Corresponds with DELETE /v0/projects/{projectId}/members/{userId} (the `ProjectServiceRemoveMember` operationId). - ProjectServiceRemoveMemberWithResponse(ctx context.Context, projectId string, userId string, reqEditors ...RequestEditorFn) (*ProjectServiceRemoveMemberResponse, error) + ProjectServiceRemoveMemberWithResponse(ctx context.Context, projectId string, userId string, params *ProjectServiceRemoveMemberParams, reqEditors ...RequestEditorFn) (*ProjectServiceRemoveMemberResponse, error) // ProjectServiceGetMemberWithResponse GetMember // @@ -14057,7 +14133,7 @@ type ClientWithResponsesInterface interface { // Returns a wrapper object for the known response body format(s). // // Corresponds with DELETE /v0/workspaces/{workspaceId}/members/{userId} (the `WorkspaceServiceDeleteMember` operationId). - WorkspaceServiceDeleteMemberWithResponse(ctx context.Context, workspaceId string, userId string, reqEditors ...RequestEditorFn) (*WorkspaceServiceDeleteMemberResponse, error) + WorkspaceServiceDeleteMemberWithResponse(ctx context.Context, workspaceId string, userId string, params *WorkspaceServiceDeleteMemberParams, reqEditors ...RequestEditorFn) (*WorkspaceServiceDeleteMemberResponse, error) // WorkspaceServiceGetMemberWithResponse GetMember // @@ -18387,8 +18463,8 @@ func (c *ClientWithResponses) ProjectServiceListMembersWithResponse(ctx context. // Returns a wrapper object for the known response body format(s). // // Corresponds with DELETE /v0/projects/{projectId}/members/{userId} (the `ProjectServiceRemoveMember` operationId). -func (c *ClientWithResponses) ProjectServiceRemoveMemberWithResponse(ctx context.Context, projectId string, userId string, reqEditors ...RequestEditorFn) (*ProjectServiceRemoveMemberResponse, error) { - rsp, err := c.ProjectServiceRemoveMember(ctx, projectId, userId, reqEditors...) +func (c *ClientWithResponses) ProjectServiceRemoveMemberWithResponse(ctx context.Context, projectId string, userId string, params *ProjectServiceRemoveMemberParams, reqEditors ...RequestEditorFn) (*ProjectServiceRemoveMemberResponse, error) { + rsp, err := c.ProjectServiceRemoveMember(ctx, projectId, userId, params, reqEditors...) if err != nil { return nil, err } @@ -19437,8 +19513,8 @@ func (c *ClientWithResponses) WorkspaceServiceListMembersWithResponse(ctx contex // Returns a wrapper object for the known response body format(s). // // Corresponds with DELETE /v0/workspaces/{workspaceId}/members/{userId} (the `WorkspaceServiceDeleteMember` operationId). -func (c *ClientWithResponses) WorkspaceServiceDeleteMemberWithResponse(ctx context.Context, workspaceId string, userId string, reqEditors ...RequestEditorFn) (*WorkspaceServiceDeleteMemberResponse, error) { - rsp, err := c.WorkspaceServiceDeleteMember(ctx, workspaceId, userId, reqEditors...) +func (c *ClientWithResponses) WorkspaceServiceDeleteMemberWithResponse(ctx context.Context, workspaceId string, userId string, params *WorkspaceServiceDeleteMemberParams, reqEditors ...RequestEditorFn) (*WorkspaceServiceDeleteMemberResponse, error) { + rsp, err := c.WorkspaceServiceDeleteMember(ctx, workspaceId, userId, params, reqEditors...) if err != nil { return nil, err } diff --git a/pkg/public/workspaces.go b/pkg/public/workspaces.go index d8c3087a..06f9296e 100644 --- a/pkg/public/workspaces.go +++ b/pkg/public/workspaces.go @@ -194,13 +194,20 @@ func (c *Client) UpdateWorkspaceMember(ctx context.Context, workspaceID, userID return requirePayload(resp.JSON200.Member, "member") } -// DeleteWorkspaceMember removes a member from a workspace. -func (c *Client) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID string) error { - resp, err := c.gen.WorkspaceServiceDeleteMemberWithResponse(ctx, workspaceID, userID) +// DeleteWorkspaceMember removes a member from a workspace and its projects, +// deletes the API keys they own there, and returns how many it deleted. +// confirmedNumKeys must be that count, as for RemoveProjectMember. +func (c *Client) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID string, confirmedNumKeys uint32) (uint32, error) { + resp, err := c.gen.WorkspaceServiceDeleteMemberWithResponse(ctx, workspaceID, userID, &oapi.WorkspaceServiceDeleteMemberParams{ + ConfirmedNumKeys: confirmedNumKeysParam(confirmedNumKeys), + }) if err != nil { - return err + return 0, err } - return okOrError(resp.StatusCode(), resp.Body) + if resp.JSON200 == nil { + return 0, responseError(resp.StatusCode(), resp.Body) + } + return numKeys(resp.JSON200.NumKeys), nil } // ListWorkspaceInvites returns the pending invites for a workspace.