diff --git a/README.md b/README.md index b484ab2..ed9485e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,20 @@ verbose output. For inherited Apps Platform trace context and failure correlation, see [Apps operation correlation](docs/apps-operation-correlation.md). +For deployments with people lookup enabled, an app owner can resolve an +existing workspace member by email without granting access: + +```bash +bl apps people resolve APP_ID --email alice@example.net --role viewer --json +``` + +Review the returned person and exact `subject_id`, then use it in the separate +access or publisher grant. LDAP and directory search aren't supported. No match, +ambiguity, missing verification and ineligibility return explicit statuses and +`next_action`; only `resolved` contains a person. Old verification evidence +requires the recipient to sign in again. Publisher lookup requires deployment +support; this command doesn't enable individual sharing in the bb-public pilot. + ## Local development Install the repository tools and git hooks once: diff --git a/src/bl/apps.rs b/src/bl/apps.rs index afcdb58..8156f9c 100644 --- a/src/bl/apps.rs +++ b/src/bl/apps.rs @@ -349,6 +349,21 @@ pub fn command() -> Command { .help("Log lines to collect per container (1-1000; control-plane default: 200)"), ), )) + .subcommand( + Command::new("people") + .about("Resolve an existing app-workspace member by verified email") + .subcommand_required(true) + .arg_required_else_help(true) + .subcommand(control_plane_args( + Command::new("resolve") + .about("Look up an exact email without granting access") + .long_about("Resolve a registered member of the app's workspace by exact email. Only the app owner may look up people. Review the returned person and stable subject before a separate access or publisher grant. Unknown or stale email verification requires the recipient to sign in again. Lookup must be enabled by the platform.") + .arg(Arg::new("app-id").required(true).value_name("APP_ID")) + .arg(Arg::new("email").long("email").required(true).value_name("EMAIL")) + .arg(Arg::new("role").long("role").required(true).value_parser(["viewer", "publisher"])) + .arg(Arg::new("environment").long("environment").value_name("ENVIRONMENT")) + )), + ) .subcommand( Command::new("access") .about("Read or update an app's viewer access policy") @@ -504,6 +519,7 @@ fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { Some(("delete", delete_matches)) => run_delete(config, delete_matches), Some(("ready", ready_matches)) => run_ready(config, ready_matches), Some(("debug", debug_matches)) => run_debug(config, debug_matches), + Some(("people", people_matches)) => run_people(config, people_matches), Some(("access", access_matches)) => run_access(config, access_matches), Some(("share", share_matches)) => run_share(config, share_matches), _ => anyhow::bail!("expected an apps subcommand"), @@ -874,6 +890,31 @@ fn run_debug(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { print_json(&response) } +fn run_people(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let (_, resolve) = matches.subcommand().context("expected people resolve")?; + let app_id = resolve + .get_one::("app-id") + .context("expected app id")?; + let email = resolve + .get_one::("email") + .context("expected email")? + .trim(); + if email.is_empty() + || email.len() > 320 + || !email.contains('@') + || email.chars().any(char::is_whitespace) + { + anyhow::bail!("an exact email address is required"); + } + let role = resolve.get_one::("role").context("expected role")?; + let environment = resolve + .get_one::("environment") + .map(|value| vec![("environment", value.clone())]) + .unwrap_or_default(); + let (client, credential) = control_plane_context(config, resolve)?; + print_json(&client.resolve_person(&credential, app_id, email, role, &environment)?) +} + fn run_access(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { match matches.subcommand() { Some(("get", get_matches)) => run_access_get(config, get_matches), @@ -1463,6 +1504,27 @@ impl ControlPlaneClient { self.get_app_resource(credential, app_id, "access", &query) } + fn resolve_person( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + email: &str, + role: &str, + query: &[(&str, String)], + ) -> Result { + let mut url = self.app_url(app_id, query)?; + url.path_segments_mut() + .map_err(|_| anyhow::anyhow!("invalid Apps Platform URL"))? + .extend(["people", "resolve"]); + let path = url.path().to_string(); + self.authorized_json_request(credential, "POST", &path, |authorization| { + self.standard_request(self.client.post(url.clone()), authorization) + .json(&json!({"email": email, "role": role})) + .build() + .context("build app people lookup request") + }) + } + fn set_access( &self, credential: &ComposeSessionCredential, @@ -2448,6 +2510,108 @@ mod tests { ); } + #[test] + fn bl_apps_people_requires_email_and_rejects_ldap_or_scope_flags() { + assert!(command() + .try_get_matches_from([ + "apps", + "people", + "resolve", + "lookup-app", + "--email", + "alice@example.net", + "--role", + "viewer", + "--base-url", + APPROVED_TEST_BASE_URL + ]) + .is_ok()); + for flag in ["--ldap", "--block-username", "--org", "--resolver"] { + assert!(command() + .try_get_matches_from([ + "apps", + "people", + "resolve", + "lookup-app", + "--email", + "alice@example.net", + "--role", + "viewer", + "--base-url", + APPROVED_TEST_BASE_URL, + flag, + "arbitrary" + ]) + .is_err()); + } + assert!(command() + .try_get_matches_from([ + "apps", + "people", + "resolve", + "lookup-app", + "--role", + "viewer", + "--base-url", + APPROVED_TEST_BASE_URL + ]) + .is_err()); + } + + #[test] + fn bl_apps_people_process_resolves_email_without_granting() { + let credential = "apps-e2e-only.people.session+credential"; + let result = json!({"status":"resolved", "person":{"subject_id":"User-Exact", "email":"alice@example.net"}, "role":"viewer"}); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(result.clone())]); + let mut cmd = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "people", + "resolve", + "lookup-app", + "--email", + "alice@example.net", + "--role", + "viewer", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--environment", + "staging", + "--json", + ], + credential, + ); + let output = cmd.output().expect("run people lookup"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)).unwrap(), + result + ); + let auth = auth_server.finish(); + let requests = control_plane.finish(); + assert_eq!(requests.len(), 1); + assert_process_auth(&auth[0], credential); + assert_process_control_plane( + &requests[0], + "POST", + "/v1/agent/apps/lookup-app/people/resolve?environment=staging", + credential, + ); + assert_eq!( + requests[0].body, + json!({"email":"alice@example.net","role":"viewer"}) + ); + } + #[test] fn bl_apps_access_process_gets_and_replaces_the_complete_policy() { let credential = "apps-e2e-only.access.session+credential";