From 541fe3d4ee566fb55bbed3862474a40a207d8b3b Mon Sep 17 00:00:00 2001 From: viwe-monai Date: Sun, 25 Oct 2026 13:27:16 +0800 Subject: [PATCH] fix(cli): accept hyphen-leading --subject values in workspace member add/remove OIDC subjects are opaque strings and can start with '-', but the workspace member add/remove --subject flags were plain #[arg(long)], so the remediation hint the gateway prints on a permission denial openshell workspace member add --workspace '' --subject '' --role user failed to copy-paste for such principals: clap parsed the quoted value as flags ('error: unexpected argument -o found'). Mark both --subject arguments allow_hyphen_values so the hint works verbatim, and cover both sides: a CLI parsing test for add/remove with a hyphen-leading subject, and a gateway hint test asserting the quoted --subject '-...' form is emitted intact. --- crates/openshell-cli/src/main.rs | 58 ++++++++++++++++++- .../src/auth/workspace_authz.rs | 23 ++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 6e58330ed6..07870cd23e 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -2545,7 +2545,7 @@ enum WorkspaceMemberCommands { workspace: String, /// OIDC subject claim identifying the principal. - #[arg(long)] + #[arg(long, allow_hyphen_values = true)] subject: String, /// Role to assign (user or admin). @@ -2561,7 +2561,7 @@ enum WorkspaceMemberCommands { workspace: String, /// OIDC subject claim identifying the principal. - #[arg(long)] + #[arg(long, allow_hyphen_values = true)] subject: String, }, @@ -5448,6 +5448,60 @@ mod tests { )); } + #[test] + fn workspace_member_parses_hyphen_leading_subject() { + // OIDC subjects are opaque strings and can start with '-'. The remediation + // hint printed by the server quotes them (`--subject '-…'`), so that form + // must parse verbatim instead of failing with "unexpected argument". + let cli = Cli::try_parse_from([ + "openshell", + "workspace", + "member", + "add", + "--workspace", + "default", + "--subject", + "-oAbC123", + "--role", + "user", + ]) + .expect("hyphen-leading subject should parse on add"); + let Some(Commands::Workspace { + command: + Some(WorkspaceCommands::Member(WorkspaceMemberCommands::Add { + workspace, + subject, + role, + })), + }) = cli.command + else { + panic!("expected workspace member add"); + }; + assert_eq!(workspace, "default"); + assert_eq!(subject, "-oAbC123"); + assert_eq!(role, "user"); + + let cli = Cli::try_parse_from([ + "openshell", + "workspace", + "member", + "remove", + "--workspace", + "default", + "--subject", + "-oAbC123", + ]) + .expect("hyphen-leading subject should parse on remove"); + let Some(Commands::Workspace { + command: + Some(WorkspaceCommands::Member(WorkspaceMemberCommands::Remove { subject, .. })), + }) = cli.command + else { + panic!("expected workspace member remove"); + }; + assert_eq!(subject, "-oAbC123"); + } + #[test] fn remaining_list_commands_accept_structured_output() { for (format, expected) in [("json", OutputFormat::Json), ("yaml", OutputFormat::Yaml)] { diff --git a/crates/openshell-server/src/auth/workspace_authz.rs b/crates/openshell-server/src/auth/workspace_authz.rs index bc5cac8778..031b93e3b6 100644 --- a/crates/openshell-server/src/auth/workspace_authz.rs +++ b/crates/openshell-server/src/auth/workspace_authz.rs @@ -488,6 +488,29 @@ mod tests { ); } + #[tokio::test] + async fn non_member_remediation_handles_hyphen_leading_subject() { + // OIDC subjects are opaque strings and can start with '-'. The hint is + // meant to be copied verbatim, so the quoted value must stay intact and + // parse as one --subject value on the CLI side. + let store = test_store().await; + let principal = user_principal("-oAbC123", &["openshell-user"]); + let result = authorize_workspace( + &store, + "openshell-admin", + &principal, + "default", + MinWorkspaceRole::User, + ) + .await; + + let err = result.unwrap_err(); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert!(err.message().contains( + "openshell workspace member add --workspace 'default' --subject '-oAbC123' --role user" + )); + } + #[tokio::test] async fn anonymous_principal_rejected() { let store = test_store().await;