From 2e057d848733d26d9d58250789d565eb8634ebd3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:25:36 +0000 Subject: [PATCH 1/2] Move to origincloudhq/api@992b5bdeca9c; generation fails API: https://github.com/origincloudhq/api/compare/ec8bbdfe780c903e4d0db8aa63f51e6c4cb5d331...992b5bdeca9c01d16aa77a789de6f2bdc636f3cd The generated code is left as it was: see the pull request. --- API_REVISION | 2 +- Cargo.lock | 44 ++++++++++++++++++++++++++++---------------- Cargo.toml | 16 ++++++++-------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/API_REVISION b/API_REVISION index 508a35d..c50a319 100644 --- a/API_REVISION +++ b/API_REVISION @@ -1 +1 @@ -ec8bbdfe780c903e4d0db8aa63f51e6c4cb5d331 +992b5bdeca9c01d16aa77a789de6f2bdc636f3cd diff --git a/Cargo.lock b/Cargo.lock index e4e3bf1..cd1d7a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -585,7 +585,7 @@ dependencies = [ [[package]] name = "fake-server" version = "0.0.0" -source = "git+https://github.com/origincloudhq/sdk-rust?rev=1235829ebe482f58fea66de6132de421d7f9fbcc#1235829ebe482f58fea66de6132de421d7f9fbcc" +source = "git+https://github.com/origincloudhq/sdk-rust?rev=5a673315cba8fd9e48b47ec9698870eb17b40a73#5a673315cba8fd9e48b47ec9698870eb17b40a73" dependencies = [ "bytes", "http-body-util", @@ -1534,8 +1534,8 @@ dependencies = [ [[package]] name = "origincloud-compute" -version = "0.3.0" -source = "git+https://github.com/origincloudhq/sdk-rust?rev=1235829ebe482f58fea66de6132de421d7f9fbcc#1235829ebe482f58fea66de6132de421d7f9fbcc" +version = "0.4.0" +source = "git+https://github.com/origincloudhq/sdk-rust?rev=5a673315cba8fd9e48b47ec9698870eb17b40a73#5a673315cba8fd9e48b47ec9698870eb17b40a73" dependencies = [ "origincloud-core", "pbjson", @@ -1545,8 +1545,8 @@ dependencies = [ [[package]] name = "origincloud-core" -version = "0.3.0" -source = "git+https://github.com/origincloudhq/sdk-rust?rev=1235829ebe482f58fea66de6132de421d7f9fbcc#1235829ebe482f58fea66de6132de421d7f9fbcc" +version = "0.4.0" +source = "git+https://github.com/origincloudhq/sdk-rust?rev=5a673315cba8fd9e48b47ec9698870eb17b40a73#5a673315cba8fd9e48b47ec9698870eb17b40a73" dependencies = [ "chrono", "fastrand", @@ -1565,10 +1565,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "origincloud-iam" +version = "0.4.0" +source = "git+https://github.com/origincloudhq/sdk-rust?rev=5a673315cba8fd9e48b47ec9698870eb17b40a73#5a673315cba8fd9e48b47ec9698870eb17b40a73" +dependencies = [ + "origincloud-core", + "pbjson", + "prost", + "serde", +] + [[package]] name = "origincloud-location" -version = "0.3.0" -source = "git+https://github.com/origincloudhq/sdk-rust?rev=1235829ebe482f58fea66de6132de421d7f9fbcc#1235829ebe482f58fea66de6132de421d7f9fbcc" +version = "0.4.0" +source = "git+https://github.com/origincloudhq/sdk-rust?rev=5a673315cba8fd9e48b47ec9698870eb17b40a73#5a673315cba8fd9e48b47ec9698870eb17b40a73" dependencies = [ "origincloud-core", "pbjson", @@ -1579,10 +1590,11 @@ dependencies = [ [[package]] name = "origincloud-mcp" version = "0.1.0" -source = "git+https://github.com/origincloudhq/mcp?rev=31604b53b5128808b1fe33ffcd71633b55013078#31604b53b5128808b1fe33ffcd71633b55013078" +source = "git+https://github.com/origincloudhq/mcp?rev=7c3bf43ad7f560eb83f099204d7ad219c4b5a3dc#7c3bf43ad7f560eb83f099204d7ad219c4b5a3dc" dependencies = [ "origincloud-compute", "origincloud-core", + "origincloud-iam", "origincloud-location", "origincloud-network", "origincloud-pvs", @@ -1596,8 +1608,8 @@ dependencies = [ [[package]] name = "origincloud-network" -version = "0.3.0" -source = "git+https://github.com/origincloudhq/sdk-rust?rev=1235829ebe482f58fea66de6132de421d7f9fbcc#1235829ebe482f58fea66de6132de421d7f9fbcc" +version = "0.4.0" +source = "git+https://github.com/origincloudhq/sdk-rust?rev=5a673315cba8fd9e48b47ec9698870eb17b40a73#5a673315cba8fd9e48b47ec9698870eb17b40a73" dependencies = [ "origincloud-core", "pbjson", @@ -1624,8 +1636,8 @@ dependencies = [ [[package]] name = "origincloud-pvs" -version = "0.3.0" -source = "git+https://github.com/origincloudhq/sdk-rust?rev=1235829ebe482f58fea66de6132de421d7f9fbcc#1235829ebe482f58fea66de6132de421d7f9fbcc" +version = "0.4.0" +source = "git+https://github.com/origincloudhq/sdk-rust?rev=5a673315cba8fd9e48b47ec9698870eb17b40a73#5a673315cba8fd9e48b47ec9698870eb17b40a73" dependencies = [ "origincloud-core", "pbjson", @@ -1635,8 +1647,8 @@ dependencies = [ [[package]] name = "origincloud-resourcemanager" -version = "0.3.0" -source = "git+https://github.com/origincloudhq/sdk-rust?rev=1235829ebe482f58fea66de6132de421d7f9fbcc#1235829ebe482f58fea66de6132de421d7f9fbcc" +version = "0.4.0" +source = "git+https://github.com/origincloudhq/sdk-rust?rev=5a673315cba8fd9e48b47ec9698870eb17b40a73#5a673315cba8fd9e48b47ec9698870eb17b40a73" dependencies = [ "origincloud-core", "pbjson", @@ -1785,7 +1797,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools", "log", "multimap", @@ -2619,7 +2631,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index f6d5b2a..3c92f65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,18 +13,18 @@ homepage = "https://origincloud.eu" # API_REVISION: the generated commands use its request and response types, # so the two must come from the same API definitions. scripts/update.sh moves # both together, and scripts/generate.sh --check fails when they differ. -origincloud-core = { git = "https://github.com/origincloudhq/sdk-rust", rev = "1235829ebe482f58fea66de6132de421d7f9fbcc" } -origincloud-pvs = { git = "https://github.com/origincloudhq/sdk-rust", rev = "1235829ebe482f58fea66de6132de421d7f9fbcc" } -origincloud-location = { git = "https://github.com/origincloudhq/sdk-rust", rev = "1235829ebe482f58fea66de6132de421d7f9fbcc" } -origincloud-compute = { git = "https://github.com/origincloudhq/sdk-rust", rev = "1235829ebe482f58fea66de6132de421d7f9fbcc" } -origincloud-network = { git = "https://github.com/origincloudhq/sdk-rust", rev = "1235829ebe482f58fea66de6132de421d7f9fbcc" } -origincloud-resourcemanager = { git = "https://github.com/origincloudhq/sdk-rust", rev = "1235829ebe482f58fea66de6132de421d7f9fbcc" } -fake-server = { git = "https://github.com/origincloudhq/sdk-rust", rev = "1235829ebe482f58fea66de6132de421d7f9fbcc" } +origincloud-core = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } +origincloud-pvs = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } +origincloud-location = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } +origincloud-compute = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } +origincloud-network = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } +origincloud-resourcemanager = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } +fake-server = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } # The MCP tools `origin mcp serve` serves, at a commit built on the same SDK # commit as the lines above: the CLI hands them the SDK's `Client`, so both # must be the same crate. scripts/update.sh moves the three together, and # scripts/generate.sh --check fails when they differ. -origincloud-mcp = { git = "https://github.com/origincloudhq/mcp", rev = "31604b53b5128808b1fe33ffcd71633b55013078" } +origincloud-mcp = { git = "https://github.com/origincloudhq/mcp", rev = "7c3bf43ad7f560eb83f099204d7ad219c4b5a3dc" } anyhow = "1" base64 = "0.22" From 828b69b1be6106dcee1778a5ed932f20f4754495 Mon Sep 17 00:00:00 2001 From: Marc Riegel Date: Tue, 3 Nov 2026 13:56:28 +0100 Subject: [PATCH 2/2] Retire the hand-written iam group for the generated one The API now has the iam service (origincloud.iam.v1beta1, at iam.origincloudapis.eu), so `origin iam` is generated and the hand-written group, its GraphQL queries and the identifier types only they used are removed. - commands.toml: iam off `hand_written`; a [[service]] entry for the group's help; a [[retired]] entry for each of the 13 commands no generated command of the same name replaces (create-service-account, list-service-accounts, get-role and list-roles are generated under their names); a [[shown_once]] notice for CreateServiceAccount's client_secret; [[view]]s for RoleBinding, ServiceAccount, Role and Permission, carrying over the columns the hand-written lists had. - `origin id login` still reads the signed-in user from the GraphQL API, since that read applies pending invitations; that query is all that is left of service_apis::iam, and its warning no longer names the retired `origin iam current-user`. - The origincloud-iam SDK crate, the README's groups and CONTRIBUTING. --- CONTRIBUTING.md | 24 +- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 12 +- crates/cli/Cargo.toml | 1 + .../graphql/iam/mutation_assign_role.graphql | 5 - .../mutation_create_service_account.graphql | 11 - .../iam/mutation_invite_member.graphql | 5 - .../iam/mutation_remove_member.graphql | 5 - .../iam/mutation_revoke_invite.graphql | 5 - .../graphql/iam/mutation_revoke_role.graphql | 5 - .../mutation_revoke_service_account.graphql | 5 - .../iam/query_current_user_grants.graphql | 16 - crates/cli/graphql/iam/query_get_role.graphql | 19 - .../graphql/iam/query_has_permission.graphql | 5 - .../cli/graphql/iam/query_list_roles.graphql | 19 - .../cli/graphql/iam/query_org_invites.graphql | 12 - .../cli/graphql/iam/query_org_members.graphql | 15 - .../cli/graphql/iam/query_permissions.graphql | 10 - .../iam/query_principal_grants.graphql | 16 - .../iam/query_service_accounts.graphql | 13 - crates/cli/src/graphql/mod.rs | 4 +- crates/cli/src/handler/iam/commands.rs | 202 ---- crates/cli/src/handler/iam/dispatch.rs | 95 -- crates/cli/src/handler/iam/mod.rs | 10 - crates/cli/src/handler/id/login.rs | 16 +- crates/cli/src/handler/mod.rs | 1 - crates/cli/src/identity.rs | 188 +-- crates/cli/src/lib.rs | 5 +- crates/cli/src/root_command.rs | 9 +- crates/cli/src/run_cli.rs | 3 - crates/cli/src/sdk/generated/api.binpb | Bin 117601 -> 127350 bytes crates/cli/src/sdk/generated/iam.rs | 1018 +++++++++++++++++ crates/cli/src/sdk/generated/services.rs | 39 + crates/cli/src/service_apis/iam.rs | 665 +---------- crates/cli/tests/output.rs | 30 + crates/cli/tests/services.rs | 48 + generator/commands.toml | 120 +- 38 files changed, 1301 insertions(+), 1357 deletions(-) delete mode 100644 crates/cli/graphql/iam/mutation_assign_role.graphql delete mode 100644 crates/cli/graphql/iam/mutation_create_service_account.graphql delete mode 100644 crates/cli/graphql/iam/mutation_invite_member.graphql delete mode 100644 crates/cli/graphql/iam/mutation_remove_member.graphql delete mode 100644 crates/cli/graphql/iam/mutation_revoke_invite.graphql delete mode 100644 crates/cli/graphql/iam/mutation_revoke_role.graphql delete mode 100644 crates/cli/graphql/iam/mutation_revoke_service_account.graphql delete mode 100644 crates/cli/graphql/iam/query_current_user_grants.graphql delete mode 100644 crates/cli/graphql/iam/query_get_role.graphql delete mode 100644 crates/cli/graphql/iam/query_has_permission.graphql delete mode 100644 crates/cli/graphql/iam/query_list_roles.graphql delete mode 100644 crates/cli/graphql/iam/query_org_invites.graphql delete mode 100644 crates/cli/graphql/iam/query_org_members.graphql delete mode 100644 crates/cli/graphql/iam/query_permissions.graphql delete mode 100644 crates/cli/graphql/iam/query_principal_grants.graphql delete mode 100644 crates/cli/graphql/iam/query_service_accounts.graphql delete mode 100644 crates/cli/src/handler/iam/commands.rs delete mode 100644 crates/cli/src/handler/iam/dispatch.rs delete mode 100644 crates/cli/src/handler/iam/mod.rs create mode 100644 crates/cli/src/sdk/generated/iam.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e49493..1e504de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,11 +20,11 @@ time on it. CI must pass: the checks below, run on every pull request. ## How the CLI is built -- `crates/cli` is the `origin` binary. The command groups `iam`, `id`, - `config` and `completion` are written by hand (`src/handler/`, - `src/service_apis/`); `iam` - and `id login` call the GraphQL API with the queries in `graphql/`, - checked against `graphql/schema.graphql`, the schema +- `crates/cli` is the `origin` binary. The command groups `id`, `config`, + `mcp` and `completion` are written by hand (`src/handler/`). `id login` + reads the signed-in user from the GraphQL API once after a login, which + applies their pending invitations (`src/service_apis/`), with the query + in `graphql/`, checked against `graphql/schema.graphql`, the schema `graph.origincloudapis.eu` serves. - A group per service of the API, such as `origin pvs` and `origin location`, is generated (`crates/cli/src/sdk/generated/`) from the @@ -226,7 +226,7 @@ pull request: 2. If the service's package has several services, give the group's help a `[[service]]` entry in `generator/commands.toml`, and regenerate; the generator warns, and the coverage test fails, until it has one. -3. If the service replaces a hand-written group, by name (`iam`) +3. If the service replaces a hand-written group, by name (as `iam` did) or by a `[[replacement]]` entry (`resourcemanager` replaces `admin`), generation fails until that group is retired in the same pull request, as the next section says. @@ -242,8 +242,8 @@ a string flag taking the whole name. ### A service replacing a hand-written group -The hand-written groups that call the GraphQL API (`iam`) are each replaced -by a service of the API in time. +A hand-written group that calls the GraphQL API is replaced by a service of +the API in time, as `admin`, `compute`, `network` and `iam` were. When the API gains the service that replaces one, the generated group lands and the hand-written one goes in the same change, so that `origin` never has both. A service replaces the group of its name; one replacing a group under @@ -258,10 +258,10 @@ regeneration pull request puts the switch on that pull request. The switch: `crates/cli/src/handler/{group}/`. 2. Delete its GraphQL client, `crates/cli/src/service_apis/{name}.rs`, its module line, and its queries, `crates/cli/graphql/{name}/`, unless another - group still uses part of it: `origin id login` uses `service_apis::iam`'s - current user, so retiring `iam` keeps that part, or moves `id` to the - generated group, and deletes the rest (clippy names what nothing uses any - more). + group still uses part of it, as `origin id login` still reads the + current user with `service_apis::iam` after `iam` was retired, since + that read applies pending invitations; delete the rest (clippy names + what nothing uses any more). 3. Take the group off `hand_written` in `generator/commands.toml`, and add a `[[retired]]` entry, with the reason and what to use instead, for each of its commands no command of the same name replaces (such as `admin diff --git a/Cargo.lock b/Cargo.lock index 08ece63..c9ae2d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1487,6 +1487,7 @@ dependencies = [ "http", "origincloud-compute", "origincloud-core", + "origincloud-iam", "origincloud-location", "origincloud-mcp", "origincloud-network", diff --git a/Cargo.toml b/Cargo.toml index 3c92f65..d7db02d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ origincloud-location = { git = "https://github.com/origincloudhq/sdk-rust", rev origincloud-compute = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } origincloud-network = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } origincloud-resourcemanager = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } +origincloud-iam = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } fake-server = { git = "https://github.com/origincloudhq/sdk-rust", rev = "5a673315cba8fd9e48b47ec9698870eb17b40a73" } # The MCP tools `origin mcp serve` serves, at a commit built on the same SDK # commit as the lines above: the CLI hands them the SDK's `Client`, so both diff --git a/README.md b/README.md index 5d3750a..2e5cdb6 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ origin config profile | `output-format` | `ORIGINCLOUD_OUTPUT_FORMAT` | `table`, `wide`, `json` or `name` (see [Output](#output)); `--output` (`-o`) overrides it. | | `default-project` | `ORIGINCLOUD_PROJECT` | `--project` of the generated commands, such as `origin pvs`'s, when not given. | | `default-zone` | `ORIGINCLOUD_ZONE` | `--zone` of the generated commands, when not given; not of the lists, which list every zone without `--zone`. | -| `region` | `ORIGINCLOUD_REGION` | The region the generated commands work in: the `--region` of the `network` commands' regional resources, when not given, and the front door the `compute`, `network` and `pvs` commands call, such as `pvs.{region}.origincloudapis.eu`. Without it, the region of the default zone (`eu-central1` for `eu-central1-az1`); without either, `--region` must be given, and the calls go to the regionless host, such as `pvs.origincloudapis.eu`, which serves every resource. The `location` and `resourcemanager` commands always call their regionless host. | +| `region` | `ORIGINCLOUD_REGION` | The region the generated commands work in: the `--region` of the `network` commands' regional resources, when not given, and the front door the `compute`, `network` and `pvs` commands call, such as `pvs.{region}.origincloudapis.eu`. Without it, the region of the default zone (`eu-central1` for `eu-central1-az1`); without either, `--region` must be given, and the calls go to the regionless host, such as `pvs.origincloudapis.eu`, which serves every resource. The `location`, `resourcemanager` and `iam` commands always call their regionless host. | As in the AWS CLI, a flag comes first, then the environment variable, then the profile's value: an environment variable overrides the profile while a @@ -146,14 +146,16 @@ A command that fails exits with status 1: in `json`, it prints always has; in the other formats, `error: ...` on standard error. A command line the CLI can't parse exits with status 2 before sending anything. -Irreversible commands (every delete, `origin iam revoke-service-account`, -and growing a volume with `origin pvs update-volume --size-gib`) refuse to run without `--yes` and say +Irreversible commands (every delete, and growing a volume with +`origin pvs update-volume --size-gib`) refuse to run without `--yes` and say what they would have done; the refusal happens before any request. `origin compute create-key-pair` without `--public-key` prints the private key it generates as `privateKey`, which the API returns this once, and says so on standard error: save it, readable by you alone, such as with `origin compute create-key-pair | (umask 077; jq -r .privateKey > my-key)`. +`origin iam create-service-account` does the same with the service +account's client secret, `clientSecret`. ## The generated commands @@ -167,6 +169,10 @@ A group per service of the API: - `origin pvs`: the persistent volume service's volumes, snapshots and storage classes. - `origin resourcemanager`: organizations and projects, and their quotas. +- `origin iam`: role bindings, which grant a role to a member + (`user:{email}` or `serviceAccount:{id}`) in an organization or one of its + projects; service accounts; the roles and permissions on offer, and which + permissions you hold; your user; and an organization's audit log. - `origin location`: the regions and zones, and which services each zone offers. diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ef05f21..4203a1b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,6 +25,7 @@ origincloud-location.workspace = true origincloud-compute.workspace = true origincloud-network.workspace = true origincloud-resourcemanager.workspace = true +origincloud-iam.workspace = true # A message field's JSON flag checks its value against the API's # descriptors, which the generator checks in beside the commands. prost-reflect = "0.16" diff --git a/crates/cli/graphql/iam/mutation_assign_role.graphql b/crates/cli/graphql/iam/mutation_assign_role.graphql deleted file mode 100644 index b404c06..0000000 --- a/crates/cli/graphql/iam/mutation_assign_role.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation AssignRole($input: IamRoleAssignmentInput!) { - iam { - assignRole(input: $input) - } -} diff --git a/crates/cli/graphql/iam/mutation_create_service_account.graphql b/crates/cli/graphql/iam/mutation_create_service_account.graphql deleted file mode 100644 index c085cdf..0000000 --- a/crates/cli/graphql/iam/mutation_create_service_account.graphql +++ /dev/null @@ -1,11 +0,0 @@ -mutation CreateServiceAccount($input: IamCreateServiceAccountInput!) { - iam { - createServiceAccount(input: $input) { - id - name - principal - clientId - clientSecret - } - } -} diff --git a/crates/cli/graphql/iam/mutation_invite_member.graphql b/crates/cli/graphql/iam/mutation_invite_member.graphql deleted file mode 100644 index fc5677e..0000000 --- a/crates/cli/graphql/iam/mutation_invite_member.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation InviteMember($input: IamInviteMemberInput!) { - iam { - inviteMember(input: $input) - } -} diff --git a/crates/cli/graphql/iam/mutation_remove_member.graphql b/crates/cli/graphql/iam/mutation_remove_member.graphql deleted file mode 100644 index b23ffe0..0000000 --- a/crates/cli/graphql/iam/mutation_remove_member.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation RemoveMember($input: IamRemoveMemberInput!) { - iam { - removeMember(input: $input) - } -} diff --git a/crates/cli/graphql/iam/mutation_revoke_invite.graphql b/crates/cli/graphql/iam/mutation_revoke_invite.graphql deleted file mode 100644 index a3b1081..0000000 --- a/crates/cli/graphql/iam/mutation_revoke_invite.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation RevokeInvite($input: IamRevokeInviteInput!) { - iam { - revokeInvite(input: $input) - } -} diff --git a/crates/cli/graphql/iam/mutation_revoke_role.graphql b/crates/cli/graphql/iam/mutation_revoke_role.graphql deleted file mode 100644 index 3b33263..0000000 --- a/crates/cli/graphql/iam/mutation_revoke_role.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation RevokeRole($input: IamRoleAssignmentInput!) { - iam { - revokeRole(input: $input) - } -} diff --git a/crates/cli/graphql/iam/mutation_revoke_service_account.graphql b/crates/cli/graphql/iam/mutation_revoke_service_account.graphql deleted file mode 100644 index 3dcd648..0000000 --- a/crates/cli/graphql/iam/mutation_revoke_service_account.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation RevokeServiceAccount($input: IamRevokeServiceAccountInput!) { - iam { - revokeServiceAccount(input: $input) - } -} diff --git a/crates/cli/graphql/iam/query_current_user_grants.graphql b/crates/cli/graphql/iam/query_current_user_grants.graphql deleted file mode 100644 index 4283186..0000000 --- a/crates/cli/graphql/iam/query_current_user_grants.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query CurrentUserGrants { - iam { - currentUserGrants { - id - urn - principal - orgId - projectId - roles { - roleUrn - } - createdAt - createdBy - } - } -} diff --git a/crates/cli/graphql/iam/query_get_role.graphql b/crates/cli/graphql/iam/query_get_role.graphql deleted file mode 100644 index 85951aa..0000000 --- a/crates/cli/graphql/iam/query_get_role.graphql +++ /dev/null @@ -1,19 +0,0 @@ -query GetRole($roleUrn: Urn!) { - iam { - role(roleUrn: $roleUrn) { - __typename - ... on IamBuiltinRole { - id - urn - name - description - permissions { - key - service - resourceType - action - } - } - } - } -} diff --git a/crates/cli/graphql/iam/query_has_permission.graphql b/crates/cli/graphql/iam/query_has_permission.graphql deleted file mode 100644 index 802408c..0000000 --- a/crates/cli/graphql/iam/query_has_permission.graphql +++ /dev/null @@ -1,5 +0,0 @@ -query HasPermission($input: IamHasPermissionInput!) { - iam { - hasPermission(input: $input) - } -} diff --git a/crates/cli/graphql/iam/query_list_roles.graphql b/crates/cli/graphql/iam/query_list_roles.graphql deleted file mode 100644 index 7c1641b..0000000 --- a/crates/cli/graphql/iam/query_list_roles.graphql +++ /dev/null @@ -1,19 +0,0 @@ -query ListRoles($input: IamListRolesInput!) { - iam { - roles(input: $input) { - __typename - ... on IamBuiltinRole { - id - urn - name - description - permissions { - key - service - resourceType - action - } - } - } - } -} diff --git a/crates/cli/graphql/iam/query_org_invites.graphql b/crates/cli/graphql/iam/query_org_invites.graphql deleted file mode 100644 index 956fde5..0000000 --- a/crates/cli/graphql/iam/query_org_invites.graphql +++ /dev/null @@ -1,12 +0,0 @@ -query OrgInvites($input: IamOrgInvitesInput!) { - iam { - orgInvites(input: $input) { - id - orgId - email - roleUrn - invitedBy - createdAt - } - } -} diff --git a/crates/cli/graphql/iam/query_org_members.graphql b/crates/cli/graphql/iam/query_org_members.graphql deleted file mode 100644 index 4586e56..0000000 --- a/crates/cli/graphql/iam/query_org_members.graphql +++ /dev/null @@ -1,15 +0,0 @@ -query OrgMembers($input: IamOrgMembersInput!) { - iam { - orgMembers(input: $input) { - principal - user { - userId - firstName - lastName - email - enabled - } - roleUrns - } - } -} diff --git a/crates/cli/graphql/iam/query_permissions.graphql b/crates/cli/graphql/iam/query_permissions.graphql deleted file mode 100644 index fab4262..0000000 --- a/crates/cli/graphql/iam/query_permissions.graphql +++ /dev/null @@ -1,10 +0,0 @@ -query Permissions { - iam { - permissions { - key - service - resourceType - action - } - } -} diff --git a/crates/cli/graphql/iam/query_principal_grants.graphql b/crates/cli/graphql/iam/query_principal_grants.graphql deleted file mode 100644 index f981638..0000000 --- a/crates/cli/graphql/iam/query_principal_grants.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query PrincipalGrants($input: IamPrincipalGrantsInput!) { - iam { - principalGrants(input: $input) { - id - urn - principal - orgId - projectId - roles { - roleUrn - } - createdAt - createdBy - } - } -} diff --git a/crates/cli/graphql/iam/query_service_accounts.graphql b/crates/cli/graphql/iam/query_service_accounts.graphql deleted file mode 100644 index 1154381..0000000 --- a/crates/cli/graphql/iam/query_service_accounts.graphql +++ /dev/null @@ -1,13 +0,0 @@ -query ServiceAccounts($input: IamServiceAccountsInput!) { - iam { - serviceAccounts(input: $input) { - id - orgId - name - principal - createdBy - createdAt - revokedAt - } - } -} diff --git a/crates/cli/src/graphql/mod.rs b/crates/cli/src/graphql/mod.rs index 044e030..5da567b 100644 --- a/crates/cli/src/graphql/mod.rs +++ b/crates/cli/src/graphql/mod.rs @@ -1,5 +1,5 @@ -//! The GraphQL client the hand-written commands (`iam`, and `id`'s current -//! user) send their queries with. +//! The GraphQL client `origin id login` reads the signed-in user with, and +//! the access tokens every command is sent with. mod auth; mod client; diff --git a/crates/cli/src/handler/iam/commands.rs b/crates/cli/src/handler/iam/commands.rs deleted file mode 100644 index c78a1b1..0000000 --- a/crates/cli/src/handler/iam/commands.rs +++ /dev/null @@ -1,202 +0,0 @@ -use crate::confirm::Confirmation; -use crate::identity::Principal; -use crate::identity::Urn; -use crate::identity::{OrgId, ProjectId}; -use crate::service_apis::iam::PermissionKey; -use clap::Subcommand; - -/// Covers the IAM operations that are real and usable today: org member -/// management (invite/list/remove), service accounts / API keys -/// (create/revoke/list), role assignment against built-in roles, -/// current-user/grants introspection, and permission checks. Group-scoped -/// role assignment, custom (org-defined) -/// roles, and role-assignment conditions are all still -/// stubbed at the API level - `roles`/`get-role` only ever return -/// built-in roles as a result, and there is deliberately no group-scoped -/// or conditional variant of `assign-role`/`revoke-role` here, to avoid -/// silently accepting input the backend would ignore. -#[derive(Subcommand)] -#[allow(clippy::large_enum_variant)] -pub(crate) enum IamCommands { - /// Invite a person to an org by email. Grants access immediately if - /// they already have an account; otherwise stores a pending invite - /// applied automatically the first time they authenticate. - InviteMember { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - - /// Email address to invite. - #[arg(long)] - email: String, - - /// URN of the built-in role to grant once accepted. - #[arg(long)] - role_urn: Urn, - }, - - /// Remove a member's access to an org - revokes every role they hold - /// in it, at every project scope. - RemoveMember { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - - /// The principal to remove, e.g. `user:`. - #[arg(long)] - principal: Principal, - }, - - /// Cancel a pending invite before it's been accepted. - RevokeInvite { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - - /// ID of the invite to revoke (see `list-invites`). - #[arg(long)] - invite_id: String, - }, - - /// List every principal with an active role assignment on an org. - ListMembers { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - }, - - /// List pending (not yet accepted) invites for an org. - ListInvites { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - }, - - /// Create a service account for an organization. The response contains an - /// OAuth client ID and client secret for the client-credentials grant; - /// the secret is shown once, so store it now. The service account starts - /// with no roles; grant it access with `assign-role`. - CreateServiceAccount { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - - /// Display name for the service account. - #[arg(long)] - name: String, - }, - - /// Revoke a service account. Its credential stops working immediately, - /// and every role assignment it holds is removed, so an access token - /// issued before the revocation is refused too. A revoked service - /// account can't be reactivated or assigned a role again. - RevokeServiceAccount { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - - /// ID of the service account to revoke (see `list-service-accounts`). - #[arg(long)] - service_account_id: String, - - #[command(flatten)] - confirmation: Confirmation, - }, - - /// List every service account (active or revoked) created for an org. - ListServiceAccounts { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - }, - - /// Assign a built-in role to a principal, org-wide or project-scoped. - AssignRole { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - - /// Narrows the assignment to a single project. Omit for an - /// org-wide grant. - #[arg(long)] - project_id: Option, - - /// The principal to grant the role to, e.g. `user:`. - #[arg(long)] - principal: Principal, - - /// URN of the built-in role to assign. - #[arg(long)] - role_urn: Urn, - }, - - /// Revoke a previously assigned role from a principal. - RevokeRole { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - - /// Must match the scope (org-wide vs. the given project) the role - /// was originally assigned with. - #[arg(long)] - project_id: Option, - - /// The principal to revoke the role from, e.g. `user:`. - #[arg(long)] - principal: Principal, - - /// URN of the role to revoke. - #[arg(long)] - role_urn: Urn, - }, - - /// Show the currently authenticated user. - CurrentUser {}, - - /// List the currently authenticated user's role grants. - CurrentUserGrants {}, - - /// List every role a principal holds in an org, with each grant's - /// project scope. - PrincipalGrants { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - - /// The principal to look up, e.g. `user:`. - #[arg(long)] - principal: Principal, - }, - - /// List the built-in roles available in an organization. - ListRoles { - /// ID of the organisation. - #[arg(long)] - org_id: OrgId, - }, - - /// Get a single built-in role and its permissions. - GetRole { - /// URN of the role. - #[arg(long)] - role_urn: Urn, - }, - - /// List every permission the platform knows about. - Permissions {}, - - /// Check whether a principal has a given permission on a resource. - HasPermission { - /// URN of the resource to check the permission against. - #[arg(long)] - on_resource_urn: Urn, - - /// The principal to check, e.g. `user:`. - #[arg(long)] - principal: Principal, - - /// Permission key to check, e.g. `network.vpc.list`. - #[arg(long)] - permission_key: PermissionKey, - }, -} diff --git a/crates/cli/src/handler/iam/dispatch.rs b/crates/cli/src/handler/iam/dispatch.rs deleted file mode 100644 index f2dde23..0000000 --- a/crates/cli/src/handler/iam/dispatch.rs +++ /dev/null @@ -1,95 +0,0 @@ -use super::commands::IamCommands; -use crate::config::ClientProfile; -use crate::output::SerializableOutput; -use crate::service_apis::iam::IamServiceClient; -use url::Url; - -pub async fn run_iam_command( - _profile: &ClientProfile, - api_url: &Url, - command: IamCommands, -) -> anyhow::Result> { - let client = IamServiceClient::new(api_url.clone()); - - match command { - IamCommands::InviteMember { - org_id, - email, - role_urn, - } => Ok(Box::new( - client.invite_member(org_id, email, role_urn).await?, - )), - IamCommands::RemoveMember { org_id, principal } => { - Ok(Box::new(client.remove_member(org_id, principal).await?)) - } - IamCommands::RevokeInvite { org_id, invite_id } => { - Ok(Box::new(client.revoke_invite(org_id, invite_id).await?)) - } - IamCommands::ListMembers { org_id } => Ok(Box::new(client.org_members(org_id).await?)), - IamCommands::ListInvites { org_id } => Ok(Box::new(client.org_invites(org_id).await?)), - IamCommands::CreateServiceAccount { org_id, name } => { - Ok(Box::new(client.create_service_account(org_id, name).await?)) - } - IamCommands::RevokeServiceAccount { - org_id, - service_account_id, - confirmation, - } => Ok(Box::new( - client - .revoke_service_account( - crate::confirm::require( - confirmation, - crate::confirm::Destructive { - verb: "revoke", - what: "service account", - target: &service_account_id.clone(), - consequence: "Its credential stops working for good.", - }, - )?, - org_id, - service_account_id, - ) - .await?, - )), - IamCommands::ListServiceAccounts { org_id } => { - Ok(Box::new(client.service_accounts(org_id).await?)) - } - IamCommands::AssignRole { - org_id, - project_id, - principal, - role_urn, - } => Ok(Box::new( - client - .assign_role(org_id, project_id, principal, role_urn) - .await?, - )), - IamCommands::RevokeRole { - org_id, - project_id, - principal, - role_urn, - } => Ok(Box::new( - client - .revoke_role(org_id, project_id, principal, role_urn) - .await?, - )), - IamCommands::CurrentUser {} => Ok(Box::new(client.current_user().await?)), - IamCommands::CurrentUserGrants {} => Ok(Box::new(client.current_user_grants().await?)), - IamCommands::PrincipalGrants { org_id, principal } => { - Ok(Box::new(client.principal_grants(org_id, principal).await?)) - } - IamCommands::ListRoles { org_id } => Ok(Box::new(client.list_roles(org_id).await?)), - IamCommands::GetRole { role_urn } => Ok(Box::new(client.get_role(role_urn).await?)), - IamCommands::Permissions {} => Ok(Box::new(client.permissions().await?)), - IamCommands::HasPermission { - on_resource_urn, - principal, - permission_key, - } => Ok(Box::new( - client - .has_permission(on_resource_urn, principal, permission_key) - .await?, - )), - } -} diff --git a/crates/cli/src/handler/iam/mod.rs b/crates/cli/src/handler/iam/mod.rs deleted file mode 100644 index d6ac34c..0000000 --- a/crates/cli/src/handler/iam/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! `origin iam` subcommand group. -//! -//! `commands.rs` declares the clap surface (`IamCommands`); `dispatch.rs` -//! matches each variant and performs the work. - -mod commands; -mod dispatch; - -pub(crate) use commands::IamCommands; -pub use dispatch::run_iam_command; diff --git a/crates/cli/src/handler/id/login.rs b/crates/cli/src/handler/id/login.rs index ca36858..45ea840 100644 --- a/crates/cli/src/handler/id/login.rs +++ b/crates/cli/src/handler/id/login.rs @@ -49,13 +49,13 @@ pub(super) async fn perform_login( /// Reads the signed-in user once, right after a user login. /// /// The platform applies a user's pending organization invites on their first -/// `currentUser` read, not at login. The console makes that read on every sign-in; without this one, a person -/// who only uses the CLI would get no invited role until they happened to run -/// `origin iam current-user`. +/// `currentUser` read, not at login. The console makes that read on every +/// sign-in; without this one, a person who only uses the CLI would get no +/// invited role until they signed in to the console. /// /// The login has already succeeded and its session is stored, so a failed read -/// does not fail it. Returns the warning to print instead, which names the -/// command that applies the invites later. The warning says "may": the server +/// does not fail it. Returns the warning to print instead, which says how to +/// apply the invites later. The warning says "may": the server /// applies invites before it looks the user up, so a read can fail after they /// were applied. async fn resolve_first_authentication(read_current_user: F) -> Option @@ -69,7 +69,7 @@ where Err(err) => Some(format!( "warning: could not read your user after logging in, so pending organization \ invites for your email address may not be applied yet ({err}). \ - Run `origin iam current-user` to apply them." + Log in again, or sign in to the console, to apply them." )), } } @@ -93,7 +93,7 @@ mod tests { } #[tokio::test] - async fn a_failed_read_warns_with_the_cause_and_the_command_that_applies_invites() { + async fn a_failed_read_warns_with_the_cause_and_how_to_apply_invites() { let warning = resolve_first_authentication(|| async { Err::<(), _>("transport: connection refused") }) @@ -106,7 +106,7 @@ mod tests { "{warning}" ); assert!( - warning.contains("Run `origin iam current-user` to apply them."), + warning.contains("Log in again, or sign in to the console, to apply them."), "{warning}" ); } diff --git a/crates/cli/src/handler/mod.rs b/crates/cli/src/handler/mod.rs index dabe597..7591781 100644 --- a/crates/cli/src/handler/mod.rs +++ b/crates/cli/src/handler/mod.rs @@ -1,6 +1,5 @@ pub(crate) mod completion; pub mod config; -pub mod iam; pub mod id; pub mod mcp; pub(crate) mod tag_args; diff --git a/crates/cli/src/identity.rs b/crates/cli/src/identity.rs index 3ab0160..4f07aff 100644 --- a/crates/cli/src/identity.rs +++ b/crates/cli/src/identity.rs @@ -1,144 +1,6 @@ -//! The identifiers the hand-written GraphQL commands take and send: URNs, -//! organisation and project ids, and principals. -//! -//! The GraphQL schema declares them as custom scalars, which the generated -//! query code expects as Rust types of the same names. They travel as -//! strings; the server validates them in full. The checks here only refuse -//! what can't be one of them, so that a mistyped argument fails before a -//! request is sent, with a message saying which argument it was. - -use std::fmt; -use std::str::FromStr; - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// Implements `Display`, `Serialize` and `Deserialize` through the type's -/// string form and `FromStr`. -macro_rules! string_form { - ($type:ty) => { - impl fmt::Display for $type { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } - } - - impl Serialize for $type { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.0) - } - } - - impl<'de> Deserialize<'de> for $type { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - s.parse().map_err(serde::de::Error::custom) - } - } - }; -} - -/// Whether `s` is a non-empty run of lowercase letters, digits and `-`: the -/// alphabet organisation and project ids are written in. -fn is_id(s: &str) -> bool { - !s.is_empty() - && s.bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') -} - -/// An organisation's id, such as `0a1b2c3d4e5f`. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct OrgId(String); - -impl FromStr for OrgId { - type Err = String; - - fn from_str(s: &str) -> Result { - if is_id(s) { - Ok(Self(s.to_owned())) - } else { - Err(format!( - "{s:?} isn't an organisation id: expected lowercase letters and digits" - )) - } - } -} - -string_form!(OrgId); - -/// A project's id, such as `my-project-123`. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct ProjectId(String); - -impl FromStr for ProjectId { - type Err = String; - - fn from_str(s: &str) -> Result { - if is_id(s) { - Ok(Self(s.to_owned())) - } else { - Err(format!( - "{s:?} isn't a project id: expected lowercase letters, digits and `-`" - )) - } - } -} - -string_form!(ProjectId); - -/// Who a role is granted to: `{type}:{id}`, such as `user:`, -/// `service_account:` or `org:`. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct Principal(String); - -const PRINCIPAL_TYPES: [&str; 4] = ["user", "service_account", "org", "service"]; - -impl FromStr for Principal { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.split_once(':') { - Some((kind, id)) if PRINCIPAL_TYPES.contains(&kind) && !id.is_empty() => { - Ok(Self(s.to_owned())) - } - _ => Err(format!( - "{s:?} isn't a principal: expected {{type}}:{{id}}, with a type of {}", - PRINCIPAL_TYPES.join(", ") - )), - } - } -} - -string_form!(Principal); - -/// A resource's URN: -/// `urn:origin:cloud:{service}:{region}:{org}:{project}:{resource}`, where -/// the region, organisation and project may be empty. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct Urn(String); - -/// The segments before the resource id, which may itself contain `:`. -const URN_SEGMENTS: usize = 8; - -impl FromStr for Urn { - type Err = String; - - fn from_str(s: &str) -> Result { - let segments: Vec<&str> = s.splitn(URN_SEGMENTS, ':').collect(); - let valid = segments.len() == URN_SEGMENTS - && segments[..3] == ["urn", "origin", "cloud"] - && !segments[3].is_empty() - && !segments[7].is_empty(); - if valid { - Ok(Self(s.to_owned())) - } else { - Err(format!( - "{s:?} isn't a URN: expected urn:origin:cloud:{{service}}:{{region}}:{{org}}:{{project}}:{{resource}}" - )) - } - } -} - -string_form!(Urn); +//! The ids and regions the CLI checks itself: a segment of a resource name, +//! as the SDK checks the names it sends, and a region, and the region of a +//! zone. /// Whether `s` can be one segment of a resource name, by the SDK's own /// check of the names it sends: not empty, `.` or `..`, and without `/`, @@ -155,7 +17,9 @@ pub(crate) const RESOURCE_ID_RULE: &str = "an id can't be empty, `.` or `..`, or /// Whether `s` can be a region, such as `eu-central1`: lowercase letters, /// digits and `-`, as the SDK requires of a host label. pub(crate) fn is_region(s: &str) -> bool { - is_id(s) + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') } /// The region of the zone `zone`, which the platform names `{region}-az{n}`: @@ -207,44 +71,4 @@ mod tests { assert_eq!(region_of_zone(other), None, "{other}"); } } - - #[test] - fn a_resource_id_may_contain_colons() { - let urn: Urn = "urn:origin:cloud:iam::0a1b2c3d4e5f::role/a:b" - .parse() - .unwrap(); - assert_eq!( - urn.to_string(), - "urn:origin:cloud:iam::0a1b2c3d4e5f::role/a:b" - ); - } - - #[test] - fn what_isnt_a_urn_is_refused() { - for s in [ - "", - "urn:origin:cloud", - "urn:other:cloud:compute::o:p:r", - "urn:origin:cloud:::o:p:r", - "urn:origin:cloud:compute::o:p:", - ] { - assert!(s.parse::().is_err(), "{s:?}"); - } - } - - #[test] - fn ids_and_principals_are_checked_and_travel_as_strings() { - assert!("my-project-123".parse::().is_ok()); - assert!("My-Project".parse::().is_err()); - assert!("".parse::().is_err()); - assert!("user:abc".parse::().is_ok()); - assert!("robot:abc".parse::().is_err()); - assert!("user:".parse::().is_err()); - let project: ProjectId = "p-1".parse().unwrap(); - assert_eq!(serde_json::to_string(&project).unwrap(), "\"p-1\""); - assert_eq!( - serde_json::from_str::("\"p-1\"").unwrap(), - project - ); - } } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 66a70bb..595afff 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -2,8 +2,9 @@ //! //! - `sdk` holds the command groups generated from the API definitions, //! one per service (`pvs`, `location`, ...), which call the Rust SDK; -//! - `handler` and `service_apis` hold the hand-written commands, which call -//! the GraphQL API with the queries in `graphql/`. +//! - `handler` holds the hand-written commands (`id`, `config`, `mcp`, +//! `completion`); `service_apis` the one GraphQL query left, `id login`'s +//! read of the signed-in user, in `graphql/`. pub use run_cli::run; diff --git a/crates/cli/src/root_command.rs b/crates/cli/src/root_command.rs index 699072b..f5926a0 100644 --- a/crates/cli/src/root_command.rs +++ b/crates/cli/src/root_command.rs @@ -2,7 +2,6 @@ use std::path::PathBuf; use crate::config::OutputFormat; use crate::handler; -use crate::handler::iam::IamCommands; use crate::handler::id::IdentityCommands; use crate::handler::mcp::McpCommands; use crate::output::ColorWhen; @@ -20,8 +19,8 @@ pub struct Cli { #[arg(short, long, value_name = "FILE")] pub(crate) config: Option, - /// GraphQL API endpoint the hand-written commands send their requests - /// to. + /// GraphQL API endpoint `origin id login` reads the signed-in user from, + /// which applies their pending invitations. #[arg(short, long, value_name = "URL", default_value_t = get_default_api_url())] pub(crate) api: String, @@ -64,10 +63,6 @@ pub(crate) enum Commands { #[command(subcommand)] Id(IdentityCommands), - /// IAM Service. - #[command(subcommand)] - Iam(IamCommands), - /// Serve Origin Cloud's API to an AI agent over MCP. #[command(subcommand)] Mcp(McpCommands), diff --git a/crates/cli/src/run_cli.rs b/crates/cli/src/run_cli.rs index fb7ebe1..2619f2f 100644 --- a/crates/cli/src/run_cli.rs +++ b/crates/cli/src/run_cli.rs @@ -28,9 +28,6 @@ pub async fn run() -> ! { Some(Commands::Id(id_cmd)) => { printing.exit_with(handler::id::run_identity_command(&profile, &api_url, id_cmd).await) } - Some(Commands::Iam(cmd)) => { - printing.exit_with(handler::iam::run_iam_command(&profile, &api_url, cmd).await) - } // The MCP session owns standard output: nothing is printed on it. Some(Commands::Mcp(cmd)) => match handler::mcp::run_mcp_command(&profile, cmd).await { Ok(()) => exit(0), diff --git a/crates/cli/src/sdk/generated/api.binpb b/crates/cli/src/sdk/generated/api.binpb index 336fe1cdbe6740311576cd6abe7715a5a4dbb63c..99cdb867a586facbfbbfd35e4e39566cc8006967 100644 GIT binary patch delta 6624 zcmbVRTWnlM8P@E2eVO%TJ-+P5u@n0^*KMKeo81%ll)X56 z)(%c$wkath70`qOJqi^n6_h?eFDOJoh_*uFp)F6`BnUzxp-*|JK&dLxC+2$2%vtXy z;>mmF|7Yf(|Ns80Z$97h(XEy@4qo`w%}%(vR5s_$Vm4n|%#E3u!r0RI38Ru3AImJ} z%t|_6nolg0OO+CeN{k82=)AN9&f32|rbV6$!HyK+-dCERDOSq%sn-Xq>tGAs_oR`n z!~(-$OWU5NTPYCK#UdJjp-i??D#wBnl`Ww`*o4ljkIN+F$jEYt1tqZoy;Sk zzj_nEwX2A$8`c6=sDX6Yttt(_B?c%MHr*Q-bia<#CfHRpzFJ9Rq3o`!gHiMd>^^N4 zbEVU13j?~6u3+%Rg7(MQ74sXs!v zMOsq==Yml#n3jdTutDTJ7k)Yb;p2?eKL6)+cI2HH37QtOqY@UyBH9W&%7#^1EN6{$ zWqH9+;$yoY9mW(XhAL5##ns8r-%cHcBfghq?8J$d16(F7K8kry!4bpzSc!+d4=V}1 z5A7)I#gKa3Re@90v5Sx(F><1}>aFt-5rh52gx)$f9|fRx97by>+!1G>d_8x;QQ1Z$ zKe-t34zKF+M$Le04r8p(W(ppQxE~F}R-sRPgGgWj-Cf1LE1!n5T##qEfO^d?IKZ!w z-tWQ`U!)vE0!o{5K=N`E?DixSj(!=y4n@^yTU`}!zAA&I@bP<5nRdVI-|Nwk=R+jM zPc68XrcVA{0K0rfbq zf8GMMdNHP4$~%|Of7~5=&y^ic^_O74KKM-69ENDlu(D-yp{AOW z3FynPmz02uIU|>r6o)e;!E~HdZXc&cX}} zyn))W#H8#Xe^%ll`ju=Vf3#1-PK-|-|K#>ZJ0VRe-0wEv>jNLBhP(r-n3a_n2u27b z$Y$ZW+GDU=TH`Te02*uc`65&@5KNNlW*a2%dmw?I*T8)x$X(-V+I;tW&i8q z$b)4fJxP&gV4EvB+FJJlm&Yk?2mL)VC^^YK_3TgwJyu54?mOKu-T+ZX`UEaL%woNY z-|qg+73Zn9!I1se=Y}KN3qhuxJ#3SGb7kV8A=q3n3MULv)`xDT0)ddu7tw=|nw+Pt zjg+WZb}O+g5ge`e`UjT4$Wb8V7J<(0Yhc*!eRnUMJa;>_3^jo^3=S?4+NtJuUr9`^ z;hY?p;{7Xe3Rgi)h9y?*bFhzFn9~JncQe%5FTT=E-I73m)V+cO_I<$en(ke07nere z4ad9t;w~r*GWmWvQqIj*{2GlAVllklivm~CZHsqs*7TG7FQZr zM4=H;=x(SBO6ftmtUc%WbrA3SJHBgg*~nB3iO()A(MEsl-o3~-%Z=X06f=D`6IR4APjZyq` zRKKfK(P95+xj$M*YUj{PVAwhM7Kj{>1gD|kSGbJ#8>c}T?_;&cFGflDb!#9W-N~E+ z+tR9c;ObWv0=)xZca6!=cwGS0@RFA+#k!5? z>!gAc^g}E;H~J?%JTwr!1|iP=H*w83szea#-E-VGrC^FRb!%KAp*C zON+%yosK|>tUcO)n_wr0UW)C*!F6qQ3vADs)8PU#?8+45|TG#Z@6GO}ehY8i2dIPHu!M@eWFhH$T1!Q&R^w^u;V zfQ&o!70$m`K(GCkC&TvF-sp>@U>F~bH?k(T6*svPWHCqml`Xd6#Vze@g|xid15V}| zSib>JMN)eY&mNt@6Ou4&nV#7M&ejf6lC6AHt1PP@V<8@}`1V6Vdne}}-S{rd0wl~}88 z)qRSfK|XdHU&Eq+%B{X5FTcYb`5cAfNFf4+cQ{$U&QdSo8Ng={k_4g|xRz-`-JzI8 z!3i@<@@g~eQ@8Lh^a~teR6ApPH3%>@yrR=_UuVC4Cv;zZzJu{@v=z>peJj9FB{ zt-Fj_RPI&BEGmoF?EF7t7N6Yc27tXj0Kp*5M)u zw9&*ft&QJ3@%Ut)^yBt1^i%wr)d1JvF{`oDBotBSYjHMT8}JF?%SaEr8LHnpF5vPR_y*l49b!>l+I}W~3}Bdi&hg(u0elD kxU^03{ACS_j@chw?2Ig85^;CA4*B}Q3)c@`0O-;G0V8YeV*mgE delta 18 acmezNi2dO-_6`0m%_*z4r>tTWat8oi5D1X~ diff --git a/crates/cli/src/sdk/generated/iam.rs b/crates/cli/src/sdk/generated/iam.rs new file mode 100644 index 0000000..f240d50 --- /dev/null +++ b/crates/cli/src/sdk/generated/iam.rs @@ -0,0 +1,1018 @@ +// Code generated by origincloud-cli-generator. DO NOT EDIT. +// source: origincloudhq/api, package origincloud.iam.v1beta1 + +#[allow(unused_imports)] +use crate::sdk::runtime::{ + api_error, deadline_after, deleted, duration, json, list_json, names_any, pace, + passed, shown_once, success, tags, wait, wait_for, waited, waiting, zones, + ConfirmedClient, Context, Listing, ReadClient, OPERATION, +}; +#[allow(unused_imports)] +use crate::confirm; +#[allow(unused_imports)] +use crate::handler::tag_args::TagArg; +use crate::output::SerializableOutput; +/// The commands of every method of `origincloud.iam.v1beta1`. +#[derive(clap::Subcommand, Debug)] +pub enum Commands { + #[command( + name = "get-audit-log-entry", + about = "Returns an audit log entry", + long_about = "Returns an audit log entry. Needs `iam.audit_log.list` on the organization." + )] + GetAuditLogEntry(GetAuditLogEntryArgs), + #[command( + name = "list-audit-log-entries", + about = "Lists an organization's audit log entries, most recent first, optionally only a project's, an actor's or an action's", + long_about = "Lists an organization's audit log entries, most recent first, optionally only a project's, an actor's or an action's. Needs `iam.audit_log.list` on the organization." + )] + ListAuditLogEntries(ListAuditLogEntriesArgs), + #[command( + name = "get-permission", + about = "Returns a permission", + long_about = "Returns a permission." + )] + GetPermission(GetPermissionArgs), + #[command( + name = "list-permissions", + about = "Lists every permission the platform checks, in catalog order, including those no predefined role grants by default, such as the billing account permissions", + long_about = "Lists every permission the platform checks, in catalog order, including those no predefined role grants by default, such as the billing account permissions." + )] + ListPermissions(ListPermissionsArgs), + #[command( + name = "test-iam-permissions", + about = "Returns the subset of `--permission` the caller holds on an organization or a project", + long_about = "Returns the subset of `--permission` the caller holds on an organization or a project. A permission held on the organization counts for each of its projects; one held on a project counts only there. Every resource in a project is governed by the project's permissions, so asking about the project answers for each of its resources. A permission the catalog doesn't name is never held." + )] + TestIamPermissions(TestIamPermissionsArgs), + #[command( + name = "get-role", + about = "Returns a role", + long_about = "Returns a role." + )] + GetRole(GetRoleArgs), + #[command( + name = "list-roles", + about = "Lists every predefined role, in catalog order", + long_about = "Lists every predefined role, in catalog order." + )] + ListRoles(ListRolesArgs), + #[command( + name = "get-role-binding", + about = "Returns a role binding", + long_about = "Returns a role binding. Needs `iam.member.list` on the organization." + )] + GetRoleBinding(GetRoleBindingArgs), + #[command( + name = "list-role-bindings", + about = "Lists an organization's role bindings, oldest first, optionally only a member's or a project's", + long_about = "Lists an organization's role bindings, oldest first, optionally only a member's or a project's. Needs `iam.member.list` on the organization. With the parent `organizations/-` it lists the caller's own bindings in every organization and needs no permission; `--member` must be empty there." + )] + ListRoleBindings(ListRoleBindingsArgs), + #[command( + name = "create-role-binding", + about = "Grants a role to a member", + long_about = "Grants a role to a member. Needs `iam.role.assign` on the organization, for a project binding as well, and every permission of the role at the binding's scope: a permission held on the organization counts for any scope, one held on a project only for that project. Refused with `NOT_FOUND` when no account has `--member`'s address as its verified email: nothing is held for an address until its account exists. Refused with `CONFLICT` when the member already holds the role at that scope, and with `BAD_REQUEST` when the member is a deleted service account." + )] + CreateRoleBinding(CreateRoleBindingArgs), + #[command( + name = "delete-role-binding", + about = "Removes a role binding", + long_about = "Removes a role binding. Needs `iam.role.revoke` on the organization and every permission of the role at the binding's scope, as CreateRoleBinding does. Refused with `CONFLICT`, removing nothing, when it is the organization's last organization-wide binding of `roles/owner`: bind another owner first." + )] + DeleteRoleBinding(DeleteRoleBindingArgs), + #[command( + name = "get-service-account", + about = "Returns a service account, deleted or not", + long_about = "Returns a service account, deleted or not. Needs `iam.service_account.list` on the organization." + )] + GetServiceAccount(GetServiceAccountArgs), + #[command( + name = "list-service-accounts", + about = "Lists an organization's service accounts, oldest first", + long_about = "Lists an organization's service accounts, oldest first. Deleted ones are listed only when `--show-deleted` is set. Needs `iam.service_account.list` on the organization." + )] + ListServiceAccounts(ListServiceAccountsArgs), + #[command( + name = "create-service-account", + about = "Creates a service account and its client secret", + long_about = "Creates a service account and its client secret. The response is the only place the secret is ever shown: store it now. Needs `iam.service_account.create` on the organization." + )] + CreateServiceAccount(CreateServiceAccountArgs), + #[command( + name = "delete-service-account", + about = "Deletes a service account for good: its credential is refused from then on, an access token it already holds is refused on its next request, and every role binding it holds, in any organization, is removed", + long_about = "Deletes a service account for good: its credential is refused from then on, an access token it already holds is refused on its next request, and every role binding it holds, in any organization, is removed. It stays readable as `DELETED`. Deleting a deleted service account succeeds. Needs `iam.service_account.revoke` on the organization." + )] + DeleteServiceAccount(DeleteServiceAccountArgs), + #[command( + name = "get-user", + about = "Returns the caller's own profile", + long_about = "Returns the caller's own profile. Any other user is `NOT_FOUND`; a service account caller is refused with `BAD_REQUEST`, having no profile." + )] + GetUser(GetUserArgs), +} +/// Runs `command`. +pub async fn run( + ctx: &Context<'_>, + command: Commands, +) -> anyhow::Result> { + match command { + Commands::GetAuditLogEntry(args) => get_audit_log_entry(ctx, args).await, + Commands::ListAuditLogEntries(args) => list_audit_log_entries(ctx, args).await, + Commands::GetPermission(args) => get_permission(ctx, args).await, + Commands::ListPermissions(args) => list_permissions(ctx, args).await, + Commands::TestIamPermissions(args) => test_iam_permissions(ctx, args).await, + Commands::GetRole(args) => get_role(ctx, args).await, + Commands::ListRoles(args) => list_roles(ctx, args).await, + Commands::GetRoleBinding(args) => get_role_binding(ctx, args).await, + Commands::ListRoleBindings(args) => list_role_bindings(ctx, args).await, + Commands::CreateRoleBinding(args) => create_role_binding(ctx, args).await, + Commands::DeleteRoleBinding(args) => delete_role_binding(ctx, args).await, + Commands::GetServiceAccount(args) => get_service_account(ctx, args).await, + Commands::ListServiceAccounts(args) => list_service_accounts(ctx, args).await, + Commands::CreateServiceAccount(args) => create_service_account(ctx, args).await, + Commands::DeleteServiceAccount(args) => delete_service_account(ctx, args).await, + Commands::GetUser(args) => get_user(ctx, args).await, + } +} +/// The endpoint the group's calls go to, which also serves its operations. +pub(crate) fn service( + client: &::origincloud_core::Client, +) -> ::origincloud_core::Service { + ::origincloud_iam::v1beta1::AuditLogClient::new(client).service().clone() +} +/// The flags of `get-audit-log-entry`. +#[derive(clap::Args, Debug)] +pub struct GetAuditLogEntryArgs { + /// The `{organization}` part of the resource names the command builds. Needed unless `--audit-log-entry` is given a whole name, which has it. + #[arg(long = "organization", value_name = "ORGANIZATION")] + pub organization: Option, + /// The entry to return. + /// + /// Its id, completed with `--organization`, or its whole name, `organizations/{organization}/auditLogEntries/{audit_log_entry}`. + #[arg(long = "audit-log-entry", value_name = "AUDIT_LOG_ENTRY")] + pub audit_log_entry: String, +} +async fn get_audit_log_entry( + ctx: &Context<'_>, + args: GetAuditLogEntryArgs, +) -> anyhow::Result> { + let mut names = ctx.names(&[("organization", args.organization.as_deref())]); + names + .learn( + &["organizations/{organization}/auditLogEntries/{audit_log_entry}"], + "audit-log-entry", + Some(args.audit_log_entry.as_str()), + )?; + let name = names + .reference( + &["organizations/{organization}/auditLogEntries/{audit_log_entry}"], + "audit-log-entry", + args.audit_log_entry.as_str(), + )?; + let request = ::origincloud_iam::v1beta1::GetAuditLogEntryRequest { + name, + }; + let client = ctx.client()?; + let response = call_get_audit_log_entry(&client, request).await.map_err(api_error)?; + json(&response, "origincloud.iam.v1beta1.AuditLogEntry") +} +async fn call_get_audit_log_entry( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::GetAuditLogEntryRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::AuditLogEntry, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::AuditLogClient::new(client.sdk()) + .get_audit_log_entry(request) + .await +} +/// The flags of `list-audit-log-entries`. +#[derive(clap::Args, Debug)] +pub struct ListAuditLogEntriesArgs { + /// The `{organization}` part of the resource names the command builds. + #[arg(long = "organization", value_name = "ORGANIZATION", required = true)] + pub organization: Option, + /// Only the entries of changes limited to this project, + /// `projects/{project}`. Every entry when empty. + #[arg(long = "project")] + pub project: Option, + /// Only the entries of changes made by this member's account, + /// `user:{email}` or `serviceAccount:{service_account}`. Every actor's + /// when empty. + #[arg(long = "actor")] + pub actor: Option, + /// Only the entries of this action, such as `iam.role.assign`. Every + /// action's when empty. + #[arg(long = "action")] + pub action: Option, + /// The most entries to return; the server clamps it, and 0 means its + /// default. + /// + /// Without `--page-size` or `--page-token`, every page is listed. + #[arg(long = "page-size")] + pub page_size: Option, + /// A previous response's `nextPageToken`, for the page after it. + /// + /// Without `--page-size` or `--page-token`, every page is listed. + #[arg(long = "page-token")] + pub page_token: Option, +} +async fn list_audit_log_entries( + ctx: &Context<'_>, + args: ListAuditLogEntriesArgs, +) -> anyhow::Result> { + let names = ctx.names(&[("organization", args.organization.as_deref())]); + let parent = names.name("organizations/{organization}")?; + let request = ::origincloud_iam::v1beta1::ListAuditLogEntriesRequest { + parent, + project: args.project.clone().unwrap_or_default(), + actor: args.actor.clone().unwrap_or_default(), + action: args.action.clone().unwrap_or_default(), + page_size: args.page_size.unwrap_or_default(), + page_token: args.page_token.clone().unwrap_or_default(), + }; + let client = ctx.client()?; + let listed: Vec = vec![request.parent.clone()]; + if args.page_size.is_some() || args.page_token.is_some() { + let page = call_list_audit_log_entries(&client, request) + .await + .map_err(api_error)?; + return list_json( + &page, + Listing { + field: "auditLogEntries", + item: Some("origincloud.iam.v1beta1.AuditLogEntry"), + columns: &[], + parents: listed, + create: None, + }, + ); + } + let mut request = request; + let mut items = Vec::new(); + loop { + let page = call_list_audit_log_entries(&client, request.clone()) + .await + .map_err(api_error)?; + items.extend(page.audit_log_entries); + if page.next_page_token.is_empty() { + break; + } + request.page_token = page.next_page_token; + } + list_json( + &::origincloud_iam::v1beta1::ListAuditLogEntriesResponse { + audit_log_entries: items, + ..::core::default::Default::default() + }, + Listing { + field: "auditLogEntries", + item: Some("origincloud.iam.v1beta1.AuditLogEntry"), + columns: &[], + parents: listed, + create: None, + }, + ) +} +async fn call_list_audit_log_entries( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::ListAuditLogEntriesRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::ListAuditLogEntriesResponse, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::AuditLogClient::new(client.sdk()) + .list_audit_log_entries(request) + .await +} +/// The flags of `get-permission`. +#[derive(clap::Args, Debug)] +pub struct GetPermissionArgs { + /// The permission to return, `permissions/{permission}`. + /// + /// Its id, or its whole name, `permissions/{permission}`. + #[arg(long = "permission", value_name = "PERMISSION")] + pub permission: String, +} +async fn get_permission( + ctx: &Context<'_>, + args: GetPermissionArgs, +) -> anyhow::Result> { + let mut names = ctx.names(&[]); + names + .learn( + &["permissions/{permission}"], + "permission", + Some(args.permission.as_str()), + )?; + let name = names + .reference( + &["permissions/{permission}"], + "permission", + args.permission.as_str(), + )?; + let request = ::origincloud_iam::v1beta1::GetPermissionRequest { + name, + }; + let client = ctx.client()?; + let response = call_get_permission(&client, request).await.map_err(api_error)?; + json(&response, "origincloud.iam.v1beta1.Permission") +} +async fn call_get_permission( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::GetPermissionRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::Permission, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::PermissionClient::new(client.sdk()) + .get_permission(request) + .await +} +/// The flags of `list-permissions`. +#[derive(clap::Args, Debug)] +pub struct ListPermissionsArgs {} +async fn list_permissions( + ctx: &Context<'_>, + _args: ListPermissionsArgs, +) -> anyhow::Result> { + let request = ::origincloud_iam::v1beta1::ListPermissionsRequest { + }; + let listed: Vec = Vec::new(); + let client = ctx.client()?; + let response = call_list_permissions(&client, request).await.map_err(api_error)?; + list_json( + &response, + Listing { + field: "permissions", + item: Some("origincloud.iam.v1beta1.Permission"), + columns: &["name", "service", "resourceType", "action"], + parents: listed, + create: None, + }, + ) +} +async fn call_list_permissions( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::ListPermissionsRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::ListPermissionsResponse, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::PermissionClient::new(client.sdk()) + .list_permissions(request) + .await +} +/// The flags of `test-iam-permissions`. +#[derive(clap::Args, Debug)] +pub struct TestIamPermissionsArgs { + /// The organization, `organizations/{organization}`, or the project, + /// `projects/{project}`, to test the permissions on. + #[arg(long = "resource")] + pub resource: String, + /// The permissions to test, by their keys, such as `pvs.volume.create`; + /// at most 100. + /// + /// Repeat for more. + #[arg(long = "permission", required = true)] + pub permission: Vec, +} +async fn test_iam_permissions( + ctx: &Context<'_>, + args: TestIamPermissionsArgs, +) -> anyhow::Result> { + let request = ::origincloud_iam::v1beta1::TestIamPermissionsRequest { + resource: args.resource.clone(), + permissions: args.permission.clone(), + }; + let client = ctx.client()?; + let response = call_test_iam_permissions(&client, request).await.map_err(api_error)?; + json(&response, "origincloud.iam.v1beta1.TestIamPermissionsResponse") +} +async fn call_test_iam_permissions( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::TestIamPermissionsRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::TestIamPermissionsResponse, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::PermissionClient::new(client.sdk()) + .test_iam_permissions(request) + .await +} +/// The flags of `get-role`. +#[derive(clap::Args, Debug)] +pub struct GetRoleArgs { + /// The role to return, `roles/{role}`. + /// + /// Its id, or its whole name, `roles/{role}`. + #[arg(long = "role", value_name = "ROLE")] + pub role: String, +} +async fn get_role( + ctx: &Context<'_>, + args: GetRoleArgs, +) -> anyhow::Result> { + let mut names = ctx.names(&[]); + names.learn(&["roles/{role}"], "role", Some(args.role.as_str()))?; + let name = names.reference(&["roles/{role}"], "role", args.role.as_str())?; + let request = ::origincloud_iam::v1beta1::GetRoleRequest { + name, + }; + let client = ctx.client()?; + let response = call_get_role(&client, request).await.map_err(api_error)?; + json(&response, "origincloud.iam.v1beta1.Role") +} +async fn call_get_role( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::GetRoleRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::Role, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::RoleClient::new(client.sdk()).get_role(request).await +} +/// The flags of `list-roles`. +#[derive(clap::Args, Debug)] +pub struct ListRolesArgs {} +async fn list_roles( + ctx: &Context<'_>, + _args: ListRolesArgs, +) -> anyhow::Result> { + let request = ::origincloud_iam::v1beta1::ListRolesRequest { + }; + let listed: Vec = Vec::new(); + let client = ctx.client()?; + let response = call_list_roles(&client, request).await.map_err(api_error)?; + list_json( + &response, + Listing { + field: "roles", + item: Some("origincloud.iam.v1beta1.Role"), + columns: &["name", "description"], + parents: listed, + create: None, + }, + ) +} +async fn call_list_roles( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::ListRolesRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::ListRolesResponse, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::RoleClient::new(client.sdk()).list_roles(request).await +} +/// The flags of `get-role-binding`. +#[derive(clap::Args, Debug)] +pub struct GetRoleBindingArgs { + /// The `{organization}` part of the resource names the command builds. Needed unless `--role-binding` is given a whole name, which has it. + #[arg(long = "organization", value_name = "ORGANIZATION")] + pub organization: Option, + /// The binding to return. + /// + /// Its id, completed with `--organization`, or its whole name, `organizations/{organization}/roleBindings/{role_binding}`. + #[arg(long = "role-binding", value_name = "ROLE_BINDING")] + pub role_binding: String, +} +async fn get_role_binding( + ctx: &Context<'_>, + args: GetRoleBindingArgs, +) -> anyhow::Result> { + let mut names = ctx.names(&[("organization", args.organization.as_deref())]); + names + .learn( + &["organizations/{organization}/roleBindings/{role_binding}"], + "role-binding", + Some(args.role_binding.as_str()), + )?; + let name = names + .reference( + &["organizations/{organization}/roleBindings/{role_binding}"], + "role-binding", + args.role_binding.as_str(), + )?; + let request = ::origincloud_iam::v1beta1::GetRoleBindingRequest { + name, + }; + let client = ctx.client()?; + let response = call_get_role_binding(&client, request).await.map_err(api_error)?; + json(&response, "origincloud.iam.v1beta1.RoleBinding") +} +async fn call_get_role_binding( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::GetRoleBindingRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::RoleBinding, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::RoleBindingClient::new(client.sdk()) + .get_role_binding(request) + .await +} +/// The flags of `list-role-bindings`. +#[derive(clap::Args, Debug)] +pub struct ListRoleBindingsArgs { + /// The `{organization}` part of the resource names the command builds. + #[arg(long = "organization", value_name = "ORGANIZATION", required = true)] + pub organization: Option, + /// Only the bindings of this member, `user:{email}` or + /// `serviceAccount:{service_account}`. Every member's when empty. + #[arg(long = "member")] + pub member: Option, + /// Only the bindings limited to this project, `projects/{project}`. Every + /// binding, organization-wide or not, when empty. + #[arg(long = "project")] + pub project: Option, + /// The most bindings to return; the server clamps it, and 0 means its + /// default. + /// + /// Without `--page-size` or `--page-token`, every page is listed. + #[arg(long = "page-size")] + pub page_size: Option, + /// A previous response's `nextPageToken`, for the page after it. + /// + /// Without `--page-size` or `--page-token`, every page is listed. + #[arg(long = "page-token")] + pub page_token: Option, +} +async fn list_role_bindings( + ctx: &Context<'_>, + args: ListRoleBindingsArgs, +) -> anyhow::Result> { + let names = ctx.names(&[("organization", args.organization.as_deref())]); + let parent = names.name("organizations/{organization}")?; + let request = ::origincloud_iam::v1beta1::ListRoleBindingsRequest { + parent, + member: args.member.clone().unwrap_or_default(), + project: args.project.clone().unwrap_or_default(), + page_size: args.page_size.unwrap_or_default(), + page_token: args.page_token.clone().unwrap_or_default(), + }; + let client = ctx.client()?; + let listed: Vec = vec![request.parent.clone()]; + if args.page_size.is_some() || args.page_token.is_some() { + let page = call_list_role_bindings(&client, request).await.map_err(api_error)?; + return list_json( + &page, + Listing { + field: "roleBindings", + item: Some("origincloud.iam.v1beta1.RoleBinding"), + columns: &["name", "member", "role", "project", "createTime"], + parents: listed, + create: Some("origin iam create-role-binding"), + }, + ); + } + let mut request = request; + let mut items = Vec::new(); + loop { + let page = call_list_role_bindings(&client, request.clone()) + .await + .map_err(api_error)?; + items.extend(page.role_bindings); + if page.next_page_token.is_empty() { + break; + } + request.page_token = page.next_page_token; + } + list_json( + &::origincloud_iam::v1beta1::ListRoleBindingsResponse { + role_bindings: items, + ..::core::default::Default::default() + }, + Listing { + field: "roleBindings", + item: Some("origincloud.iam.v1beta1.RoleBinding"), + columns: &["name", "member", "role", "project", "createTime"], + parents: listed, + create: Some("origin iam create-role-binding"), + }, + ) +} +async fn call_list_role_bindings( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::ListRoleBindingsRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::ListRoleBindingsResponse, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::RoleBindingClient::new(client.sdk()) + .list_role_bindings(request) + .await +} +/// The flags of `create-role-binding`. +#[derive(clap::Args, Debug)] +pub struct CreateRoleBindingArgs { + /// The `{organization}` part of the resource names the command builds. + #[arg(long = "organization", value_name = "ORGANIZATION", required = true)] + pub organization: Option, + /// Who holds the role: `user:{email}` or + /// `serviceAccount:{service_account}`. A user is shown with their + /// account's current verified address, as `userId:{user}` when it has + /// none, or as `deleted:user:{user}` once the account is gone. + #[arg(long = "member")] + pub member: String, + /// The role granted, `roles/{role}`. + /// + /// Its id, or its whole name, `roles/{role}`. + #[arg(long = "role", value_name = "ROLE")] + pub role: String, + /// The project the binding is limited to, `projects/{project}`, one of + /// the organization's. Empty for a binding on the whole organization, + /// which covers every project in it. + #[arg(long = "project")] + pub project: Option, + /// A UUID. A repeat of the request with the same `--request-id` within a + /// day returns the binding the first request created, as it is now, + /// instead of being refused as a conflict. One is generated when not given. + #[arg(long = "request-id")] + pub request_id: Option, +} +async fn create_role_binding( + ctx: &Context<'_>, + args: CreateRoleBindingArgs, +) -> anyhow::Result> { + let mut names = ctx.names(&[("organization", args.organization.as_deref())]); + names.learn(&["roles/{role}"], "role", Some(args.role.as_str()))?; + let parent = names.name("organizations/{organization}")?; + let role = names.reference(&["roles/{role}"], "role", args.role.as_str())?; + let role_binding = ::origincloud_iam::v1beta1::RoleBinding { + member: args.member.clone(), + role, + project: args.project.clone().unwrap_or_default(), + ..::core::default::Default::default() + }; + let request = ::origincloud_iam::v1beta1::CreateRoleBindingRequest { + parent, + role_binding: Some(role_binding), + request_id: args.request_id.clone().unwrap_or_default(), + }; + let client = ctx.client()?; + let response = call_create_role_binding(&client, request).await.map_err(api_error)?; + json(&response, "origincloud.iam.v1beta1.RoleBinding") +} +async fn call_create_role_binding( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::CreateRoleBindingRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::RoleBinding, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::RoleBindingClient::new(client.sdk()) + .create_role_binding(request) + .await +} +/// The flags of `delete-role-binding`. +#[derive(clap::Args, Debug)] +pub struct DeleteRoleBindingArgs { + /// The `{organization}` part of the resource names the command builds. Needed unless `--role-binding` is given a whole name, which has it. + #[arg(long = "organization", value_name = "ORGANIZATION")] + pub organization: Option, + /// The binding to delete. + /// + /// Its id, completed with `--organization`, or its whole name, `organizations/{organization}/roleBindings/{role_binding}`. + #[arg(long = "role-binding", value_name = "ROLE_BINDING")] + pub role_binding: String, + /// A UUID. A repeat of the request with the same `--request-id` within a + /// day succeeds as the first did, instead of answering `NOT_FOUND`. One is generated when not given. + #[arg(long = "request-id")] + pub request_id: Option, + #[command(flatten)] + pub confirmation: confirm::Confirmation, +} +async fn delete_role_binding( + ctx: &Context<'_>, + args: DeleteRoleBindingArgs, +) -> anyhow::Result> { + let mut names = ctx.names(&[("organization", args.organization.as_deref())]); + names + .learn( + &["organizations/{organization}/roleBindings/{role_binding}"], + "role-binding", + Some(args.role_binding.as_str()), + )?; + let name = names + .reference( + &["organizations/{organization}/roleBindings/{role_binding}"], + "role-binding", + args.role_binding.as_str(), + )?; + let request = ::origincloud_iam::v1beta1::DeleteRoleBindingRequest { + name, + request_id: args.request_id.clone().unwrap_or_default(), + }; + let confirmed = confirm::require( + args.confirmation, + confirm::Destructive { + verb: "delete", + what: "role binding", + target: &request.name, + consequence: "It can't be restored.", + }, + )?; + let deleted_name = request.name.clone(); + let client = ctx.confirmed_client(confirmed)?; + let response = call_delete_role_binding(&client, request).await.map_err(api_error)?; + let _ = response; + Ok(deleted("role binding", &deleted_name)) +} +async fn call_delete_role_binding( + client: &ConfirmedClient, + request: ::origincloud_iam::v1beta1::DeleteRoleBindingRequest, +) -> ::core::result::Result< + ::origincloud_core::protobuf::Empty, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::RoleBindingClient::new(client.sdk()) + .delete_role_binding(request) + .await +} +/// The flags of `get-service-account`. +#[derive(clap::Args, Debug)] +pub struct GetServiceAccountArgs { + /// The `{organization}` part of the resource names the command builds. Needed unless `--service-account` is given a whole name, which has it. + #[arg(long = "organization", value_name = "ORGANIZATION")] + pub organization: Option, + /// The service account to return. + /// + /// Its id, completed with `--organization`, or its whole name, `organizations/{organization}/serviceAccounts/{service_account}`. + #[arg(long = "service-account", value_name = "SERVICE_ACCOUNT")] + pub service_account: String, +} +async fn get_service_account( + ctx: &Context<'_>, + args: GetServiceAccountArgs, +) -> anyhow::Result> { + let mut names = ctx.names(&[("organization", args.organization.as_deref())]); + names + .learn( + &["organizations/{organization}/serviceAccounts/{service_account}"], + "service-account", + Some(args.service_account.as_str()), + )?; + let name = names + .reference( + &["organizations/{organization}/serviceAccounts/{service_account}"], + "service-account", + args.service_account.as_str(), + )?; + let request = ::origincloud_iam::v1beta1::GetServiceAccountRequest { + name, + }; + let client = ctx.client()?; + let response = call_get_service_account(&client, request).await.map_err(api_error)?; + json(&response, "origincloud.iam.v1beta1.ServiceAccount") +} +async fn call_get_service_account( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::GetServiceAccountRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::ServiceAccount, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::ServiceAccountClient::new(client.sdk()) + .get_service_account(request) + .await +} +/// The flags of `list-service-accounts`. +#[derive(clap::Args, Debug)] +pub struct ListServiceAccountsArgs { + /// The `{organization}` part of the resource names the command builds. + #[arg(long = "organization", value_name = "ORGANIZATION", required = true)] + pub organization: Option, + /// The most service accounts to return; the server clamps it, and 0 means + /// its default. + /// + /// Without `--page-size` or `--page-token`, every page is listed. + #[arg(long = "page-size")] + pub page_size: Option, + /// A previous response's `nextPageToken`, for the page after it. + /// + /// Without `--page-size` or `--page-token`, every page is listed. + #[arg(long = "page-token")] + pub page_token: Option, + /// Whether deleted service accounts are listed too. + #[arg( + long = "show-deleted", + value_name = "true|false", + action = clap::ArgAction::Set + )] + pub show_deleted: Option, +} +async fn list_service_accounts( + ctx: &Context<'_>, + args: ListServiceAccountsArgs, +) -> anyhow::Result> { + let names = ctx.names(&[("organization", args.organization.as_deref())]); + let parent = names.name("organizations/{organization}")?; + let request = ::origincloud_iam::v1beta1::ListServiceAccountsRequest { + parent, + page_size: args.page_size.unwrap_or_default(), + page_token: args.page_token.clone().unwrap_or_default(), + show_deleted: args.show_deleted.unwrap_or_default(), + }; + let client = ctx.client()?; + let listed: Vec = vec![request.parent.clone()]; + if args.page_size.is_some() || args.page_token.is_some() { + let page = call_list_service_accounts(&client, request) + .await + .map_err(api_error)?; + return list_json( + &page, + Listing { + field: "serviceAccounts", + item: Some("origincloud.iam.v1beta1.ServiceAccount"), + columns: &["name", "displayName", "member", "createTime", "deleteTime"], + parents: listed, + create: Some("origin iam create-service-account"), + }, + ); + } + let mut request = request; + let mut items = Vec::new(); + loop { + let page = call_list_service_accounts(&client, request.clone()) + .await + .map_err(api_error)?; + items.extend(page.service_accounts); + if page.next_page_token.is_empty() { + break; + } + request.page_token = page.next_page_token; + } + list_json( + &::origincloud_iam::v1beta1::ListServiceAccountsResponse { + service_accounts: items, + ..::core::default::Default::default() + }, + Listing { + field: "serviceAccounts", + item: Some("origincloud.iam.v1beta1.ServiceAccount"), + columns: &["name", "displayName", "member", "createTime", "deleteTime"], + parents: listed, + create: Some("origin iam create-service-account"), + }, + ) +} +async fn call_list_service_accounts( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::ListServiceAccountsRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::ListServiceAccountsResponse, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::ServiceAccountClient::new(client.sdk()) + .list_service_accounts(request) + .await +} +/// The flags of `create-service-account`. +#[derive(clap::Args, Debug)] +pub struct CreateServiceAccountArgs { + /// The `{organization}` part of the resource names the command builds. + #[arg(long = "organization", value_name = "ORGANIZATION", required = true)] + pub organization: Option, + /// A name of your own, shown in the console; at most 100 characters. + #[arg(long = "display-name")] + pub display_name: String, + /// A UUID. A repeat of the request with the same `--request-id` within a + /// day returns the service account the first request created, as it is + /// now, instead of creating a second one. The repeat's response has no + /// `clientSecret`: the secret is shown once. One is generated when not given. + #[arg(long = "request-id")] + pub request_id: Option, +} +async fn create_service_account( + ctx: &Context<'_>, + args: CreateServiceAccountArgs, +) -> anyhow::Result> { + let names = ctx.names(&[("organization", args.organization.as_deref())]); + let parent = names.name("organizations/{organization}")?; + let service_account = ::origincloud_iam::v1beta1::ServiceAccount { + display_name: args.display_name.clone(), + ..::core::default::Default::default() + }; + let request = ::origincloud_iam::v1beta1::CreateServiceAccountRequest { + parent, + service_account: Some(service_account), + request_id: args.request_id.clone().unwrap_or_default(), + }; + let client = ctx.client()?; + let response = call_create_service_account(&client, request) + .await + .map_err(api_error)?; + shown_once( + &response.client_secret, + "`clientSecret` holds the service account's client secret, which the API returns this once. Save it now where only you can read it, such as by piping the output through `(umask 077; jq -r .clientSecret > client-secret)`. A lost secret can't be fetched again: delete the service account and create another.", + ); + json(&response, "origincloud.iam.v1beta1.ServiceAccount") +} +async fn call_create_service_account( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::CreateServiceAccountRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::ServiceAccount, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::ServiceAccountClient::new(client.sdk()) + .create_service_account(request) + .await +} +/// The flags of `delete-service-account`. +#[derive(clap::Args, Debug)] +pub struct DeleteServiceAccountArgs { + /// The `{organization}` part of the resource names the command builds. Needed unless `--service-account` is given a whole name, which has it. + #[arg(long = "organization", value_name = "ORGANIZATION")] + pub organization: Option, + /// The service account to delete. + /// + /// Its id, completed with `--organization`, or its whole name, `organizations/{organization}/serviceAccounts/{service_account}`. + #[arg(long = "service-account", value_name = "SERVICE_ACCOUNT")] + pub service_account: String, + #[command(flatten)] + pub confirmation: confirm::Confirmation, +} +async fn delete_service_account( + ctx: &Context<'_>, + args: DeleteServiceAccountArgs, +) -> anyhow::Result> { + let mut names = ctx.names(&[("organization", args.organization.as_deref())]); + names + .learn( + &["organizations/{organization}/serviceAccounts/{service_account}"], + "service-account", + Some(args.service_account.as_str()), + )?; + let name = names + .reference( + &["organizations/{organization}/serviceAccounts/{service_account}"], + "service-account", + args.service_account.as_str(), + )?; + let request = ::origincloud_iam::v1beta1::DeleteServiceAccountRequest { + name, + }; + let confirmed = confirm::require( + args.confirmation, + confirm::Destructive { + verb: "delete", + what: "service account", + target: &request.name, + consequence: "It can't be restored.", + }, + )?; + let deleted_name = request.name.clone(); + let client = ctx.confirmed_client(confirmed)?; + let response = call_delete_service_account(&client, request) + .await + .map_err(api_error)?; + let _ = response; + Ok(deleted("service account", &deleted_name)) +} +async fn call_delete_service_account( + client: &ConfirmedClient, + request: ::origincloud_iam::v1beta1::DeleteServiceAccountRequest, +) -> ::core::result::Result< + ::origincloud_core::protobuf::Empty, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::ServiceAccountClient::new(client.sdk()) + .delete_service_account(request) + .await +} +/// The flags of `get-user`. +#[derive(clap::Args, Debug)] +pub struct GetUserArgs { + /// The user to return: `users/me`, or the caller's own `users/{user}`. + /// + /// Its id, or its whole name, `users/{user}`. + #[arg(long = "user", value_name = "USER")] + pub user: String, +} +async fn get_user( + ctx: &Context<'_>, + args: GetUserArgs, +) -> anyhow::Result> { + let mut names = ctx.names(&[]); + names.learn(&["users/{user}"], "user", Some(args.user.as_str()))?; + let name = names.reference(&["users/{user}"], "user", args.user.as_str())?; + let request = ::origincloud_iam::v1beta1::GetUserRequest { + name, + }; + let client = ctx.client()?; + let response = call_get_user(&client, request).await.map_err(api_error)?; + json(&response, "origincloud.iam.v1beta1.User") +} +async fn call_get_user( + client: &ReadClient, + request: ::origincloud_iam::v1beta1::GetUserRequest, +) -> ::core::result::Result< + ::origincloud_iam::v1beta1::User, + ::origincloud_core::Error, +> { + ::origincloud_iam::v1beta1::UserClient::new(client.sdk()).get_user(request).await +} diff --git a/crates/cli/src/sdk/generated/services.rs b/crates/cli/src/sdk/generated/services.rs index 52bbeb5..4799926 100644 --- a/crates/cli/src/sdk/generated/services.rs +++ b/crates/cli/src/sdk/generated/services.rs @@ -16,6 +16,18 @@ pub(crate) static COMPUTE: Group = Group { regional: true, service: compute::service, }; +/// `origin iam`. +#[allow(clippy::enum_variant_names, clippy::large_enum_variant)] +pub mod iam { + include!("iam.rs"); +} +/// What the runtime knows of `origin iam`. +pub(crate) static IAM: Group = Group { + name: "iam", + endpoint_variable: "ORIGINCLOUD_IAM_ENDPOINT", + regional: false, + service: iam::service, +}; /// `origin location`. #[allow(clippy::enum_variant_names, clippy::large_enum_variant)] pub mod location { @@ -83,6 +95,7 @@ pub(crate) static SCRATCH: Group = Group { #[allow(dead_code)] pub(crate) static GROUPS: &[&Group] = &[ &COMPUTE, + &IAM, &LOCATION, &NETWORK, &PVS, @@ -93,6 +106,7 @@ pub(crate) static GROUPS: &[&Group] = &[ #[allow(dead_code)] pub(crate) static GROUPS: &[&Group] = &[ &COMPUTE, + &IAM, &LOCATION, &NETWORK, &PVS, @@ -109,6 +123,12 @@ pub enum ServiceCommands { long_about = "Compute service: instances, images, key pairs, volume attachments, and the instance types and image families on offer.\n\nResource names are given as their parts (`--project`, `--zone`, `--instance`), or the resource's flag takes a whole name; `--project` and `--zone` default to the profile's `default-project` and `default-zone`." )] Compute(ComputeCli), + #[command( + name = "iam", + about = "Identity and access management: role bindings, service accounts, roles, permissions, your user and the audit log", + long_about = "Identity and access management: role bindings, service accounts, roles, permissions, your user and the audit log.\n\nRole bindings, service accounts and the audit log are an organization's (`--organization`); roles, permissions and your user are the same in every organization. A role binding's member is `user:{email}` or `serviceAccount:{service_account}`; `origin iam list-role-bindings --organization -` lists the bindings you hold, in every organization." + )] + Iam(IamCli), #[command( name = "location", about = "The platform's regions and zones, and which services each zone offers", @@ -156,6 +176,21 @@ pub struct ComputeCli { #[command(subcommand)] pub command: compute::Commands, } +/// `origin iam`: its options, and the command. +#[derive(clap::Args, Debug)] +pub struct IamCli { + /// Base URL to send the calls to, such as a test server's, instead of the service's own host, `https://iam.origincloudapis.eu`, the same in every region. It must be `https://`, or `http://` to this machine (`localhost`, `127.0.0.1`, `[::1]`). + #[arg( + long, + value_name = "URL", + env = "ORIGINCLOUD_IAM_ENDPOINT", + global = true, + help_heading = "Global options" + )] + pub endpoint: Option, + #[command(subcommand)] + pub command: iam::Commands, +} /// `origin location`: its options, and the command. #[derive(clap::Args, Debug)] pub struct LocationCli { @@ -242,6 +277,10 @@ pub async fn run( let ctx = Context::new(profile, &COMPUTE, cli.endpoint.as_deref())?; compute::run(&ctx, cli.command).await } + ServiceCommands::Iam(cli) => { + let ctx = Context::new(profile, &IAM, cli.endpoint.as_deref())?; + iam::run(&ctx, cli.command).await + } ServiceCommands::Location(cli) => { let ctx = Context::new(profile, &LOCATION, cli.endpoint.as_deref())?; location::run(&ctx, cli.command).await diff --git a/crates/cli/src/service_apis/iam.rs b/crates/cli/src/service_apis/iam.rs index dcf87a5..6442233 100644 --- a/crates/cli/src/service_apis/iam.rs +++ b/crates/cli/src/service_apis/iam.rs @@ -1,399 +1,22 @@ +//! The GraphQL API's read of the signed-in user, which `origin id login` +//! makes once after a user login: the platform applies a user's pending +//! organization invites on that read. + use crate::client_auth::ConfigAuthenticator; use crate::graphql::Error as ClientError; use crate::graphql::GenericGraphQLClient; -#[allow(unused_imports)] -use crate::identity::*; -use crate::output::{Listing, SerializableOutput, SerializationError, Shape}; use graphql_client::*; -use std::str::FromStr; use url::Url; -pub type DateTime = chrono::DateTime; pub type Result = std::result::Result; -/// The list in the GraphQL response's `field` (`iam.orgMembers`), as the -/// human formats print it: `table` with `columns`, JSON paths from an item, -/// such as `user.email`; `wide` with every other field that fits in a cell -/// after them; an empty one suggesting `create`. -fn listing( - field: &'static str, - columns: &'static [&'static str], - create: Option<&'static str>, -) -> Shape { - Shape::List(Listing { - field, - columns, - create, - ..Listing::default() - }) -} - -/// A permission's stable identifier, e.g. `network.vpc.list`. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(transparent)] -pub(crate) struct PermissionKey(String); - -impl std::fmt::Display for PermissionKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl FromStr for PermissionKey { - type Err = std::convert::Infallible; - - fn from_str(s: &str) -> std::result::Result { - Ok(Self(s.to_string())) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/mutation_assign_role.graphql", - response_derives = "Serialize" -)] -pub(crate) struct AssignRole; - -#[derive(serde::Serialize)] -pub(crate) struct AssignRoleResponse(assign_role::ResponseData); - -impl SerializableOutput for AssignRoleResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/mutation_revoke_role.graphql", - response_derives = "Serialize" -)] -pub(crate) struct RevokeRole; - -#[derive(serde::Serialize)] -pub(crate) struct RevokeRoleResponse(revoke_role::ResponseData); - -impl SerializableOutput for RevokeRoleResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/mutation_invite_member.graphql", - response_derives = "Serialize" -)] -pub(crate) struct InviteMember; - -#[derive(serde::Serialize)] -pub(crate) struct InviteMemberResponse(invite_member::ResponseData); - -impl SerializableOutput for InviteMemberResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/mutation_remove_member.graphql", - response_derives = "Serialize" -)] -pub(crate) struct RemoveMember; - -#[derive(serde::Serialize)] -pub(crate) struct RemoveMemberResponse(remove_member::ResponseData); - -impl SerializableOutput for RemoveMemberResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/mutation_revoke_invite.graphql", - response_derives = "Serialize" -)] -pub(crate) struct RevokeInvite; - -#[derive(serde::Serialize)] -pub(crate) struct RevokeInviteResponse(revoke_invite::ResponseData); - -impl SerializableOutput for RevokeInviteResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/query_org_members.graphql", - response_derives = "Serialize" -)] -pub(crate) struct OrgMembers; - -#[derive(serde::Serialize)] -pub(crate) struct OrgMembersResponse(org_members::ResponseData); - -impl SerializableOutput for OrgMembersResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } - - fn shape(&self) -> Shape { - listing( - "iam.orgMembers", - &[ - "user.email", - "user.firstName", - "user.lastName", - "roleUrns", - "user.enabled", - ], - None, - ) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/query_org_invites.graphql", - response_derives = "Serialize" -)] -pub(crate) struct OrgInvites; - -#[derive(serde::Serialize)] -pub(crate) struct OrgInvitesResponse(org_invites::ResponseData); - -impl SerializableOutput for OrgInvitesResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } - - fn shape(&self) -> Shape { - listing( - "iam.orgInvites", - &["email", "roleUrn", "invitedBy", "createdAt"], - None, - ) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/mutation_create_service_account.graphql", - response_derives = "Serialize" -)] -pub(crate) struct CreateServiceAccount; - -#[derive(serde::Serialize)] -pub(crate) struct CreateServiceAccountResponse(create_service_account::ResponseData); - -impl SerializableOutput for CreateServiceAccountResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } -} - #[derive(GraphQLQuery)] #[graphql( schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/mutation_revoke_service_account.graphql", - response_derives = "Serialize" -)] -pub(crate) struct RevokeServiceAccount; - -#[derive(serde::Serialize)] -pub(crate) struct RevokeServiceAccountResponse(revoke_service_account::ResponseData); - -impl SerializableOutput for RevokeServiceAccountResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/query_service_accounts.graphql", - response_derives = "Serialize" -)] -pub(crate) struct ServiceAccounts; - -#[derive(serde::Serialize)] -pub(crate) struct ServiceAccountsResponse(service_accounts::ResponseData); - -impl SerializableOutput for ServiceAccountsResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } - - fn shape(&self) -> Shape { - listing( - "iam.serviceAccounts", - &["name", "principal", "createdAt", "revokedAt"], - Some("origin iam create-service-account"), - ) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/query_current_user.graphql", - response_derives = "Serialize" + query_path = "graphql/iam/query_current_user.graphql" )] pub(crate) struct CurrentUser; -#[derive(serde::Serialize)] -pub(crate) struct CurrentUserResponse(current_user::ResponseData); - -impl SerializableOutput for CurrentUserResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/query_current_user_grants.graphql", - response_derives = "Serialize" -)] -pub(crate) struct CurrentUserGrants; - -#[derive(serde::Serialize)] -pub(crate) struct CurrentUserGrantsResponse(current_user_grants::ResponseData); - -impl SerializableOutput for CurrentUserGrantsResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } - - fn shape(&self) -> Shape { - listing( - "iam.currentUserGrants", - &["orgId", "projectId", "roles.roleUrn", "createdAt"], - None, - ) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/query_principal_grants.graphql", - response_derives = "Serialize" -)] -pub(crate) struct PrincipalGrants; - -#[derive(serde::Serialize)] -pub(crate) struct PrincipalGrantsResponse(principal_grants::ResponseData); - -impl SerializableOutput for PrincipalGrantsResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } - - fn shape(&self) -> Shape { - listing( - "iam.principalGrants", - &["principal", "projectId", "roles.roleUrn", "createdAt"], - None, - ) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/query_list_roles.graphql", - response_derives = "Serialize" -)] -pub(crate) struct ListRoles; - -#[derive(serde::Serialize)] -pub(crate) struct ListRolesResponse(list_roles::ResponseData); - -impl SerializableOutput for ListRolesResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } - - fn shape(&self) -> Shape { - listing("iam.roles", &["name", "description"], None) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/query_get_role.graphql", - response_derives = "Serialize" -)] -pub(crate) struct GetRole; - -#[derive(serde::Serialize)] -pub(crate) struct GetRoleResponse(get_role::ResponseData); - -impl SerializableOutput for GetRoleResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/query_permissions.graphql", - response_derives = "Serialize" -)] -pub(crate) struct Permissions; - -#[derive(serde::Serialize)] -pub(crate) struct PermissionsResponse(permissions::ResponseData); - -impl SerializableOutput for PermissionsResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } - - fn shape(&self) -> Shape { - listing( - "iam.permissions", - &["key", "service", "resourceType", "action"], - None, - ) - } -} - -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "graphql/schema.graphql", - query_path = "graphql/iam/query_has_permission.graphql", - response_derives = "Serialize" -)] -pub(crate) struct HasPermission; - -#[derive(serde::Serialize)] -pub(crate) struct HasPermissionResponse(has_permission::ResponseData); - -impl SerializableOutput for HasPermissionResponse { - fn to_json(&self) -> std::result::Result { - serde_json::to_value(self).map_err(|err| SerializationError::Other(err.to_string())) - } -} - pub(crate) struct IamServiceClient { client: GenericGraphQLClient, } @@ -405,280 +28,10 @@ impl IamServiceClient { } } - pub(crate) async fn assign_role( - &self, - org_id: OrgId, - project_id: Option, - principal: Principal, - role_urn: Urn, - ) -> Result { - let variables = assign_role::Variables { - input: assign_role::IamRoleAssignmentInput { - org_id, - project_id, - principal, - role_urn, - }, - }; - let response_data = self.client.call::(variables).await?; - Ok(AssignRoleResponse(response_data)) - } - - pub(crate) async fn revoke_role( - &self, - org_id: OrgId, - project_id: Option, - principal: Principal, - role_urn: Urn, - ) -> Result { - let variables = revoke_role::Variables { - input: revoke_role::IamRoleAssignmentInput { - org_id, - project_id, - principal, - role_urn, - }, - }; - let response_data = self.client.call::(variables).await?; - Ok(RevokeRoleResponse(response_data)) - } - - pub(crate) async fn invite_member( - &self, - org_id: OrgId, - email: String, - role_urn: Urn, - ) -> Result { - let variables = invite_member::Variables { - input: invite_member::IamInviteMemberInput { - org_id, - email, - role_urn, - }, - }; - let response_data = self.client.call::(variables).await?; - Ok(InviteMemberResponse(response_data)) - } - - pub(crate) async fn remove_member( - &self, - org_id: OrgId, - principal: Principal, - ) -> Result { - let variables = remove_member::Variables { - input: remove_member::IamRemoveMemberInput { org_id, principal }, - }; - let response_data = self.client.call::(variables).await?; - Ok(RemoveMemberResponse(response_data)) - } - - pub(crate) async fn revoke_invite( - &self, - org_id: OrgId, - invite_id: String, - ) -> Result { - let variables = revoke_invite::Variables { - input: revoke_invite::IamRevokeInviteInput { org_id, invite_id }, - }; - let response_data = self.client.call::(variables).await?; - Ok(RevokeInviteResponse(response_data)) - } - - pub(crate) async fn org_members(&self, org_id: OrgId) -> Result { - let variables = org_members::Variables { - input: org_members::IamOrgMembersInput { org_id }, - }; - let response_data = self.client.call::(variables).await?; - Ok(OrgMembersResponse(response_data)) - } - - pub(crate) async fn org_invites(&self, org_id: OrgId) -> Result { - let variables = org_invites::Variables { - input: org_invites::IamOrgInvitesInput { org_id }, - }; - let response_data = self.client.call::(variables).await?; - Ok(OrgInvitesResponse(response_data)) - } - - pub(crate) async fn create_service_account( - &self, - org_id: OrgId, - name: String, - ) -> Result { - let variables = create_service_account::Variables { - input: create_service_account::IamCreateServiceAccountInput { org_id, name }, - }; - let response_data = self.client.call::(variables).await?; - Ok(CreateServiceAccountResponse(response_data)) - } - - pub(crate) async fn revoke_service_account( - &self, - _confirmed: crate::confirm::Confirmed, - org_id: OrgId, - service_account_id: String, - ) -> Result { - let variables = revoke_service_account::Variables { - input: revoke_service_account::IamRevokeServiceAccountInput { - org_id, - service_account_id, - }, - }; - let response_data = self.client.call::(variables).await?; - Ok(RevokeServiceAccountResponse(response_data)) - } - - pub(crate) async fn service_accounts(&self, org_id: OrgId) -> Result { - let variables = service_accounts::Variables { - input: service_accounts::IamServiceAccountsInput { org_id }, - }; - let response_data = self.client.call::(variables).await?; - Ok(ServiceAccountsResponse(response_data)) - } - - pub(crate) async fn current_user(&self) -> Result { - let response_data = self - .client + /// Reads the signed-in user, which applies their pending invites. + pub(crate) async fn current_user(&self) -> Result { + self.client .call::(current_user::Variables) - .await?; - Ok(CurrentUserResponse(response_data)) - } - - pub(crate) async fn current_user_grants(&self) -> Result { - let response_data = self - .client - .call::(current_user_grants::Variables) - .await?; - Ok(CurrentUserGrantsResponse(response_data)) - } - - pub(crate) async fn principal_grants( - &self, - org_id: OrgId, - principal: Principal, - ) -> Result { - let variables = principal_grants::Variables { - input: principal_grants::IamPrincipalGrantsInput { org_id, principal }, - }; - let response_data = self.client.call::(variables).await?; - Ok(PrincipalGrantsResponse(response_data)) - } - - pub(crate) async fn list_roles(&self, org_id: OrgId) -> Result { - let variables = list_roles::Variables { - input: list_roles::IamListRolesInput { org_id }, - }; - let response_data = self.client.call::(variables).await?; - Ok(ListRolesResponse(response_data)) - } - - pub(crate) async fn get_role(&self, role_urn: Urn) -> Result { - let variables = get_role::Variables { role_urn }; - let response_data = self.client.call::(variables).await?; - Ok(GetRoleResponse(response_data)) - } - - pub(crate) async fn permissions(&self) -> Result { - let response_data = self - .client - .call::(permissions::Variables) - .await?; - Ok(PermissionsResponse(response_data)) - } - - pub(crate) async fn has_permission( - &self, - on_resource_urn: Urn, - principal: Principal, - permission_key: PermissionKey, - ) -> Result { - let variables = has_permission::Variables { - input: has_permission::IamHasPermissionInput { - on_resource_urn, - principal, - permission_key, - }, - }; - let response_data = self.client.call::(variables).await?; - Ok(HasPermissionResponse(response_data)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::OutputFormat; - use crate::output::{render, Context}; - use serde_json::json; - - fn table(output: &dyn SerializableOutput) -> (String, String) { - let cx = Context { - width: None, - now: "2026-11-03T12:00:00Z".parse().unwrap(), - }; - let printed = render(OutputFormat::Table, output, &cx).unwrap(); - let strip = |s: &str| anstream::adapter::strip_str(s).to_string(); - (strip(&printed.out), strip(&printed.err)) - } - - #[test] - fn members_are_listed_by_their_email() { - let data: org_members::ResponseData = serde_json::from_value(json!({"iam": {"orgMembers": [ - {"principal": "user:u1", "roleUrns": ["urn:origin:cloud:iam::acme::role/owner"], - "user": {"userId": "u1", "firstName": "Ada", "lastName": "Lovelace", - "email": "ada@example.com", "enabled": true}}, - {"principal": "user:u2", "roleUrns": ["urn:origin:cloud:iam::acme::role/viewer", "urn:origin:cloud:iam::acme::role/billing"], - "user": {"userId": "u2", "firstName": "Alan", "lastName": "Turing", - "email": "alan@example.com", "enabled": false}}, - ]}})) - .unwrap(); - assert_eq!( - table(&OrgMembersResponse(data)).0, - "\ -EMAIL FIRST NAME LAST NAME ROLES ENABLED -ada@example.com Ada Lovelace owner true -alan@example.com Alan Turing viewer, billing false -" - ); - } - - #[test] - fn invites_and_service_accounts_say_how_long_ago() { - let data: org_invites::ResponseData = serde_json::from_value(json!({"iam": {"orgInvites": [ - {"id": "i1", "orgId": "o1", "email": "grace@example.com", "roleUrn": "urn:origin:cloud:iam::acme::role/viewer", - "invitedBy": "user:u1", "createdAt": "2026-11-01T12:00:00Z"}, - ]}})) - .unwrap(); - assert_eq!( - table(&OrgInvitesResponse(data)).0, - "\ -EMAIL ROLE INVITED BY CREATED AT -grace@example.com viewer user:u1 2d ago -" - ); - - let none: service_accounts::ResponseData = - serde_json::from_value(json!({"iam": {"serviceAccounts": []}})).unwrap(); - assert_eq!( - table(&ServiceAccountsResponse(none)).1, - "No service accounts. Create one with `origin iam create-service-account`.\n" - ); - } - - #[test] - fn a_grant_shows_its_roles() { - let data: principal_grants::ResponseData = serde_json::from_value(json!({"iam": {"principalGrants": [ - {"id": "g1", "urn": "urn:origin:cloud:iam::acme::grant/g1", "principal": "user:u1", "orgId": "o1", "projectId": "p1", - "roles": [{"roleUrn": "urn:origin:cloud:iam::acme::role/viewer"}], - "createdAt": "2026-11-03T11:00:00Z", "createdBy": "user:u0"}, - ]}})) - .unwrap(); - assert_eq!( - table(&PrincipalGrantsResponse(data)).0, - "\ -PRINCIPAL PROJECT ID ROLE CREATED AT -user:u1 p1 viewer 60m ago -" - ); + .await } } diff --git a/crates/cli/tests/output.rs b/crates/cli/tests/output.rs index 91d6ac0..2fe9d91 100644 --- a/crates/cli/tests/output.rs +++ b/crates/cli/tests/output.rs @@ -235,6 +235,36 @@ v2 ERROR 16 gp1 ); } +/// Role bindings, the caller's own among them (`organizations/-`), are +/// listed by member and role, a role by its id rather than its whole name. +#[tokio::test(flavor = "multi_thread")] +async fn role_bindings_are_listed_by_member_and_role() { + const BINDINGS: &str = r#"{"roleBindings":[ + {"name":"organizations/o/roleBindings/rb-1","urn":"urn:origin:cloud:iam::o::roleBinding/rb-1", + "member":"user:ada@example.com","role":"roles/owner","creator":"platform"}, + {"name":"organizations/o/roleBindings/rb-2","member":"serviceAccount:sa-1", + "role":"roles/viewer","project":"projects/p1","creator":"user:ada@example.com"}]}"#; + let server = recording(|_| Response::json(BINDINGS)).await; + let table = server + .run( + "iam", + &["list-role-bindings", "--organization", "-", "-o", "table"], + ) + .await; + assert_eq!( + text(&table.stdout), + "\ +NAME MEMBER ROLE PROJECT CREATE TIME +rb-1 user:ada@example.com owner +rb-2 serviceAccount:sa-1 viewer projects/p1 +" + ); + assert_eq!( + server.requests()[0].path, + "/v1beta1/organizations/-/roleBindings" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn an_empty_list_says_where_it_looked_and_how_to_make_one() { let server = recording(|_| Response::json("{}")).await; diff --git a/crates/cli/tests/services.rs b/crates/cli/tests/services.rs index 31061c1..f3a46fd 100644 --- a/crates/cli/tests/services.rs +++ b/crates/cli/tests/services.rs @@ -652,6 +652,54 @@ async fn a_generated_private_key_comes_with_a_notice() { assert!(!stderr.contains("privateKey"), "{stderr}"); } +/// A new service account's client secret is printed in the JSON as the API +/// sends it, and standard error says it is shown this once and how to keep +/// it; a repeat of the request, whose response has no secret, says nothing. +#[tokio::test(flavor = "multi_thread")] +async fn a_service_account_s_client_secret_comes_with_a_notice() { + const CREATED: &str = r#"{"name":"organizations/o/serviceAccounts/sa-1","displayName":"ci","member":"serviceAccount:sa-1","clientId":"c-1","clientSecret":"s3cr3t-value","state":"ACTIVE"}"#; + const REPEATED: &str = r#"{"name":"organizations/o/serviceAccounts/sa-1","displayName":"ci","member":"serviceAccount:sa-1","clientId":"c-1","state":"ACTIVE"}"#; + let calls = Arc::new(AtomicUsize::new(0)); + let seen = calls.clone(); + let server = recording(move |_: &Received| { + if seen.fetch_add(1, Ordering::SeqCst) == 0 { + Response::json(CREATED) + } else { + Response::json(REPEATED) + } + }) + .await; + let create = [ + "create-service-account", + "--organization", + "o", + "--display-name", + "ci", + ]; + + let output = server.run("iam", &create).await; + assert_eq!(success(&output), json(CREATED)); + let stderr = String::from_utf8_lossy(&output.stderr); + for part in [ + "`clientSecret`", + "this once", + "umask 077", + "jq -r .clientSecret", + ] { + assert!(stderr.contains(part), "{part}: {stderr}"); + } + assert!(!stderr.contains("s3cr3t-value"), "{stderr}"); + assert_eq!( + server.requests()[0].path, + "/v1beta1/organizations/o/serviceAccounts" + ); + + let output = server.run("iam", &create).await; + assert_eq!(success(&output), json(REPEATED)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("clientSecret"), "{stderr}"); +} + /// `create-instance` reaches the server with an instance type, whose ids /// hold dots, and an image of either form: the project's own, completed /// from its id, or a public one, whose zone fills in `--zone`. diff --git a/generator/commands.toml b/generator/commands.toml index 5ce49ef..8c45cdf 100644 --- a/generator/commands.toml +++ b/generator/commands.toml @@ -8,14 +8,12 @@ # service, a method or a field the API doesn't have, so that an entry can't # silently stop applying. -# The hand-written groups the CLI still has: the ones calling the GraphQL -# API until the API has the service replacing each (`iam`), and the CLI's -# own (`completion`, `config`, `id`, `mcp`, `version`). A service of the API -# named like one (`iam`), or replacing one under another name -# ([[replacement]] below), fails generation until that group is retired in -# the same change, as CONTRIBUTING.md says. The coverage test checks this -# list is the CLI's. -hand_written = ["completion", "config", "iam", "id", "mcp", "version"] +# The hand-written groups the CLI still has: the CLI's own (`completion`, +# `config`, `id`, `mcp`, `version`). A service of the API named like one, or +# replacing one under another name ([[replacement]] below), fails generation +# until that group is retired in the same change, as CONTRIBUTING.md says, +# as `iam` was. The coverage test checks this list is the CLI's. +hand_written = ["completion", "config", "id", "mcp", "version"] # A service replacing a hand-written group under another name. It is written # before the API has the service, so that generation fails as soon as a @@ -61,6 +59,16 @@ else to the profile's `default-project`, `default-zone` and `region`; \ name = "resourcemanager" about = "Resource manager: organizations and projects, and their quotas" +[[service]] +name = "iam" +about = "Identity and access management: role bindings, service accounts, roles, permissions, your user and the audit log" +details = """ +Role bindings, service accounts and the audit log are an organization's \ +(`--organization`); roles, permissions and your user are the same in every \ +organization. A role binding's member is `user:{email}` or \ +`serviceAccount:{service_account}`; `origin iam list-role-bindings \ +--organization -` lists the bindings you hold, in every organization.""" + # A reference field that also takes the names of another resource type than # the one its definition declares: a `google.api.resource_reference` names # one type only. Its flag (or key) then takes a whole name of either type; an @@ -274,6 +282,71 @@ group = "network" command = "remove-resource-tags" why = "Set tags with the resource's `origin network update-...` command and `--tag`, such as `origin network update-vpc --tag`, giving every tag to keep, or remove them all with `--no-tags`." +[[retired]] +group = "iam" +command = "assign-role" +why = "Use `origin iam create-role-binding --organization --member user: --role `, or `--member serviceAccount:` for a service account, and `--project projects/` to limit it to one project; `origin iam list-roles` lists the roles." + +[[retired]] +group = "iam" +command = "revoke-role" +why = "Use `origin iam delete-role-binding --organization --role-binding --yes`; `origin iam list-role-bindings --organization --member ` finds the binding." + +[[retired]] +group = "iam" +command = "revoke-service-account" +why = "Use `origin iam delete-service-account --organization --service-account --yes`." + +[[retired]] +group = "iam" +command = "current-user" +why = "Use `origin iam get-user --user me`." + +[[retired]] +group = "iam" +command = "current-user-grants" +why = "Use `origin iam list-role-bindings --organization -`: the bindings you hold, in every organization." + +[[retired]] +group = "iam" +command = "principal-grants" +why = "Use `origin iam list-role-bindings --organization --member `, the member as `user:` or `serviceAccount:`." + +[[retired]] +group = "iam" +command = "list-members" +why = "Use `origin iam list-role-bindings --organization `: each binding names its member, `user:` or `serviceAccount:`, and its role." + +[[retired]] +group = "iam" +command = "remove-member" +why = "Delete each of the member's role bindings with `origin iam delete-role-binding --organization --role-binding --yes`; `origin iam list-role-bindings --organization --member ` lists them." + +[[retired]] +group = "iam" +command = "permissions" +why = "Use `origin iam list-permissions`." + +[[retired]] +group = "iam" +command = "has-permission" +why = "Use `origin iam test-iam-permissions --resource organizations/ --permission `, or `--resource projects/`, repeating `--permission` for more; it tests the permissions you hold, not another member's: `origin iam list-role-bindings --organization --member ` lists theirs." + +[[retired]] +group = "iam" +command = "invite-member" +why = "Invitations aren't in the API: invite people in the console. Someone who already has an account is given a role with `origin iam create-role-binding --member user:`." + +[[retired]] +group = "iam" +command = "list-invites" +why = "Invitations aren't in the API: the console lists pending invitations." + +[[retired]] +group = "iam" +command = "revoke-invite" +why = "Invitations aren't in the API: revoke a pending invitation in the console." + # Methods left without a command, with the reason. Every other method of the # API has one: the coverage test fails for a method that has neither. # @@ -315,6 +388,17 @@ can read, such as by piping the output through \ `(umask 077; jq -r .privateKey > my-key)`. A lost key can't be fetched \ again: delete the key pair and create another.""" +[[shown_once]] +service = "iam" +method = "CreateServiceAccount" +field = "client_secret" +notice = """ +`clientSecret` holds the service account's client secret, which the API \ +returns this once. Save it now where only you can read it, such as by piping \ +the output through `(umask 077; jq -r .clientSecret > client-secret)`. A lost \ +secret can't be fetched again: delete the service account and create \ +another.""" + # The columns of the `table` a list of a message is printed as, by the # fields' paths from the message, through messages and lists of them: # what tells one item from another, and what someone listing them looks @@ -411,3 +495,23 @@ columns = ["name", "display_name", "organization", "create_time"] service = "resourcemanager" message = "Organization" columns = ["name", "display_name", "state", "create_time"] + +[[view]] +service = "iam" +message = "RoleBinding" +columns = ["name", "member", "role", "project", "create_time"] + +[[view]] +service = "iam" +message = "ServiceAccount" +columns = ["name", "display_name", "member", "create_time", "delete_time"] + +[[view]] +service = "iam" +message = "Role" +columns = ["name", "description"] + +[[view]] +service = "iam" +message = "Permission" +columns = ["name", "service", "resource_type", "action"]