From 774587dfddb197f5171bf49587ec9f20831569d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:48:56 +0000 Subject: [PATCH] feat(identity): add --project/--project-domain to user set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `koc user set` could not change a user's default project: the flag pair existed on `user create` only, while `user show` printed `Default Project ID` all along — settable at creation, visible forever, never changeable. Upstream `openstack user set` has taken `--project` (and `--project-domain`) since forever (python-openstackclient `identity/v3/user.py`, `SetUser`), so the workaround was to reach for `openstack`, which is what this binary exists to avoid. `users.UpdateOpts.DefaultProjectID` was already in the pinned gophercloud v2.13.0, so this is unwired capability rather than a vendor change. The project-domain resolution `runUserCreate` grew is now a shared `resolveDefaultProjectID` helper that both write verbs call: an empty --project short-circuits in `resolveProjectID` and the empty ID is `omitempty` on both opts structs, so the field stays untouched when the flag is absent. Absent --project-domain the lookup falls back to the user's own domain, where upstream resolves the project unscoped across all domains. The two differ only when --domain is given and the default project lives elsewhere; scoping to the user's domain is the safer reading of an ambiguous project name and keeps the two user write verbs consistent. Recorded under "Naming deviations" in docs/coverage.md, which needs no count change — this adds no leaf command. Verified end-to-end against a mock keystone: `koc user set --project admin admin` resolves the user, resolves the project, then PATCHes `{"user":{"default_project_id":"..."}}` and prints nothing, matching upstream. Closes #19 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Dnh8BhczXscNe67sYomCw --- docs/coverage.md | 16 ++++- internal/cli/identity/flagnames.go | 9 ++- internal/cli/identity/user.go | 46 +++++++++----- internal/cli/identity/user_test.go | 96 ++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 20 deletions(-) diff --git a/docs/coverage.md b/docs/coverage.md index 7961a6d..d3760d6 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -302,9 +302,19 @@ flags (`--loadbalancer`, `--listener`, `--pool`, `--member`, `--healthmonitor`, clear-everything behaviour moved to the new `koc loadbalancer quota reset`. This is a breaking change for anyone who relied on the old flagless `unset`. -One flag deviates rather than a command: `koc dns service list --service-name`, -where upstream spells the same filter `--service_name` — the only underscored flag -in designate's CLI. Both work; the underscored form is registered hidden. +One flag deviates in **spelling** rather than a command: `koc dns service list +--service-name`, where upstream spells the same filter `--service_name` — the only +underscored flag in designate's CLI. Both work; the underscored form is +registered hidden. + +One deviates in **semantics**: on `koc user create` and `koc user set`, +`--project-domain` falls back to `--domain` — the user's own domain — when it is +absent, where upstream resolves the default project unscoped across all domains +(`identity/v3/user.py`, `CreateUser`/`SetUser.take_action`). The two differ only +when `--domain` is given and the default project lives in another domain, which +`--project-domain` then states explicitly; scoping to the user's domain is the +safer reading of a project name that is ambiguous cloud-wide, and it keeps the +two user write verbs consistent with each other. One flag is **koc-native**: `koc image list --name-contains`, a case-insensitive substring filter applied client-side. Glance's query builder accepts only `in:` diff --git a/internal/cli/identity/flagnames.go b/internal/cli/identity/flagnames.go index 2bc0627..93a283b 100644 --- a/internal/cli/identity/flagnames.go +++ b/internal/cli/identity/flagnames.go @@ -20,7 +20,10 @@ const ( // Flag help strings reused across commands, so the wording stays identical. const ( helpDomainProject = "domain owning the project (name or ID)" - helpDomainUser = "domain owning the user (name or ID)" - helpDomainRole = "domain the role belongs to (name or ID)" - helpOwningUser = "owning user (name or ID; defaults to the current user)" + // helpDomainDefaultProject qualifies --project on the user write verbs, + // where the flag names the user's default project rather than a scope. + helpDomainDefaultProject = "domain owning --project (name or ID; defaults to --domain)" + helpDomainUser = "domain owning the user (name or ID)" + helpDomainRole = "domain the role belongs to (name or ID)" + helpOwningUser = "owning user (name or ID; defaults to the current user)" ) diff --git a/internal/cli/identity/user.go b/internal/cli/identity/user.go index 128212f..591a90a 100644 --- a/internal/cli/identity/user.go +++ b/internal/cli/identity/user.go @@ -126,6 +126,26 @@ type userWriteFlags struct { disableSet bool } +// resolveDefaultProjectID turns --project into the ID that goes on the user's +// `default_project_id`. The default project may live in a different domain than +// the user, so it is resolved with its own qualifier; absent --project-domain +// the lookup falls back to the user's domain, which is where upstream leaves it +// unscoped (see docs/coverage.md, "Naming deviations"). An empty --project +// short-circuits inside resolveProjectID, so callers may invoke this +// unconditionally: the resulting empty ID is omitempty on both opts structs and +// leaves the field untouched. +func resolveDefaultProjectID(ctx context.Context, client *gophercloud.ServiceClient, f *userWriteFlags, userDomainID string) (string, error) { + projectDomainID := userDomainID + if f.projectDomain != "" { + var err error + projectDomainID, err = resolveDomainID(ctx, client, f.projectDomain) + if err != nil { + return "", err + } + } + return resolveProjectID(ctx, client, f.project, projectDomainID) +} + func newUserCreateCommand(a *auth.Options, o *output.Options) *cobra.Command { f := &userWriteFlags{} cmd := &cobra.Command{ @@ -153,7 +173,7 @@ func newUserCreateCommand(a *auth.Options, o *output.Options) *cobra.Command { fl.StringVar(&f.domain, "domain", "", "domain to create the user in (name or ID)") fl.StringVar(&f.password, "password", "", "user password") fl.StringVar(&f.project, "project", "", "default project (name or ID)") - fl.StringVar(&f.projectDomain, "project-domain", "", "domain owning --project (name or ID; defaults to --domain)") + fl.StringVar(&f.projectDomain, "project-domain", "", helpDomainDefaultProject) fl.StringVar(&f.description, "description", "", "user description") fl.BoolVar(&f.enable, "enable", true, "enable the user (default)") fl.BoolVar(new(bool), "disable", false, "disable the user") @@ -165,16 +185,7 @@ func runUserCreate(ctx context.Context, client *gophercloud.ServiceClient, o *ou if err != nil { return err } - // The default project may live in a different domain than the user; resolve - // it with its own qualifier, falling back to the user's domain. - projectDomainID := domainID - if f.projectDomain != "" { - projectDomainID, err = resolveDomainID(ctx, client, f.projectDomain) - if err != nil { - return err - } - } - projectID, err := resolveProjectID(ctx, client, f.project, projectDomainID) + projectID, err := resolveDefaultProjectID(ctx, client, f, domainID) if err != nil { return err } @@ -259,6 +270,8 @@ func newUserSetCommand(a *auth.Options, o *output.Options) *cobra.Command { fl.StringVar(&f.domain, "domain", "", helpDomainUser) fl.StringVar(&f.name, "name", "", "new user name") fl.StringVar(&f.password, "password", "", "new user password") + fl.StringVar(&f.project, "project", "", "new default project (name or ID)") + fl.StringVar(&f.projectDomain, "project-domain", "", helpDomainDefaultProject) fl.StringVar(&f.description, "description", "", "new user description") fl.BoolVar(&f.enable, "enable", false, "enable the user") fl.BoolVar(new(bool), "disable", false, "disable the user") @@ -274,10 +287,15 @@ func runUserSet(ctx context.Context, client *gophercloud.ServiceClient, nameOrID if err != nil { return err } + projectID, err := resolveDefaultProjectID(ctx, client, f, domainID) + if err != nil { + return err + } opts := users.UpdateOpts{ - Name: f.name, - Password: f.password, - Enabled: enabledFromFlags(f.enableSet, f.disableSet, f.enable), + Name: f.name, + Password: f.password, + DefaultProjectID: projectID, + Enabled: enabledFromFlags(f.enableSet, f.disableSet, f.enable), } if descSet { opts.Description = &f.description diff --git a/internal/cli/identity/user_test.go b/internal/cli/identity/user_test.go index bf70c8a..4d3311b 100644 --- a/internal/cli/identity/user_test.go +++ b/internal/cli/identity/user_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "net/http" + "net/url" "strings" "testing" @@ -178,3 +179,98 @@ func TestRunUserSet_ResolvesNameAndPatches(t *testing.T) { t.Errorf("method = %q, want PATCH", patchMethod) } } + +// `openstack user set --project ` is upstream's way of moving a +// user's default project; koc carried the flag on `create` only until this +// test's subject was wired up. +func TestRunUserSet_ProjectSetsDefaultProjectID(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + var projQuery url.Values + fakeServer.Mux.HandleFunc("/users", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"users":[{"id":"u1","name":"admin"}]}`)) + }) + fakeServer.Mux.HandleFunc("/projects", func(w http.ResponseWriter, r *http.Request) { + projQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"admin"}]}`)) + }) + fakeServer.Mux.HandleFunc("/users/u1", func(w http.ResponseWriter, r *http.Request) { + th.TestJSONRequest(t, r, `{"user":{"default_project_id":"p1"}}`) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"user":{"id":"u1","name":"admin","default_project_id":"p1"}}`)) + }) + + client := identityClient(fakeServer) + f := &userWriteFlags{project: "admin"} + if err := runUserSet(context.Background(), client, "admin", f, false); err != nil { + t.Fatalf("runUserSet error: %v", err) + } + if got := projQuery.Get("name"); got != "admin" { + t.Errorf("project resolve name = %q, want admin", got) + } + if got := projQuery.Get("domain_id"); got != "" { + t.Errorf("project resolve domain_id = %q, want empty", got) + } +} + +// The default project may live outside the user's domain, so --project-domain +// qualifies the lookup; without it koc falls back to the user's own domain +// (where upstream leaves the lookup unscoped) — see docs/coverage.md. +func TestRunUserSet_ProjectDomainQualifiesLookup(t *testing.T) { + for _, tc := range []struct { + name string + projectDomain string + wantDomainID string + }{ + {name: "falls back to the user domain", wantDomainID: "d-user"}, + {name: "--project-domain wins", projectDomain: "other", wantDomainID: "d-other"}, + } { + t.Run(tc.name, func(t *testing.T) { + fakeServer := th.SetupHTTP() + defer fakeServer.Teardown() + + var projDomainID string + fakeServer.Mux.HandleFunc("/domains", func(w http.ResponseWriter, r *http.Request) { + id := "d-user" + if r.URL.Query().Get("name") == "other" { + id = "d-other" + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"domains":[{"id":"` + id + `","name":"x"}]}`)) + }) + fakeServer.Mux.HandleFunc("/users", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"users":[{"id":"u1","name":"admin"}]}`)) + }) + fakeServer.Mux.HandleFunc("/projects", func(w http.ResponseWriter, r *http.Request) { + projDomainID = r.URL.Query().Get("domain_id") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"projects":[{"id":"p1","name":"admin"}]}`)) + }) + fakeServer.Mux.HandleFunc("/users/u1", func(w http.ResponseWriter, r *http.Request) { + th.TestJSONRequest(t, r, `{"user":{"default_project_id":"p1"}}`) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"user":{"id":"u1","name":"admin","default_project_id":"p1"}}`)) + }) + + client := identityClient(fakeServer) + f := &userWriteFlags{domain: "default", project: "admin", projectDomain: tc.projectDomain} + if err := runUserSet(context.Background(), client, "admin", f, false); err != nil { + t.Fatalf("runUserSet error: %v", err) + } + if projDomainID != tc.wantDomainID { + t.Errorf("project resolve domain_id = %q, want %q", projDomainID, tc.wantDomainID) + } + }) + } +}