From c45dbf23becb5b90c78d76360a70fb468e0797d1 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 3 Jun 2023 22:55:31 +1200 Subject: [PATCH] added org secrets api (#384) --- Cargo.toml | 2 + examples/create_org_secret.rs | 42 +++++++ src/api/orgs.rs | 9 ++ src/api/orgs/secrets.rs | 161 ++++++++++++++++++++++++ src/models/orgs.rs | 1 + src/models/orgs/secrets.rs | 48 +++++++ tests/org_secrets_test.rs | 189 ++++++++++++++++++++++++++++ tests/resources/org_public_key.json | 4 + tests/resources/org_secret.json | 7 ++ tests/resources/org_secrets.json | 24 ++++ 10 files changed, 487 insertions(+) create mode 100644 examples/create_org_secret.rs create mode 100644 src/api/orgs/secrets.rs create mode 100644 src/models/orgs/secrets.rs create mode 100644 tests/org_secrets_test.rs create mode 100644 tests/resources/org_public_key.json create mode 100644 tests/resources/org_secret.json create mode 100644 tests/resources/org_secrets.json diff --git a/Cargo.toml b/Cargo.toml index ed2c1e49..0235ff17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,8 @@ tokio = { version = "1.17.0", default-features = false, features = [ ] } tokio-test = "0.4.2" wiremock = "0.5.3" +crypto_box = { version = "0.8.2", features = ["seal"] } +base64 = "0.21.2" [features] default = ["rustls", "timeout", "tracing", "retry"] diff --git a/examples/create_org_secret.rs b/examples/create_org_secret.rs new file mode 100644 index 00000000..65f638da --- /dev/null +++ b/examples/create_org_secret.rs @@ -0,0 +1,42 @@ +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use crypto_box::{self, aead::OsRng, PublicKey}; +use octocrab::{ + models::orgs::secrets::{CreateOrganizationSecret, Visibility}, + Octocrab, +}; +use std::convert::TryInto; + +#[tokio::main] +async fn main() -> octocrab::Result<()> { + let token = std::env::var("GITHUB_TOKEN").expect("GITHUB_TOKEN env variable is required"); + + let octocrab = Octocrab::builder().personal_token(token).build()?; + let org = octocrab.orgs("owner"); + let secrets = org.secrets(); + + let public_key = secrets.get_public_key().await?; + + let crypto_pk = { + let org_pk_bytes = B64.decode(public_key.key).unwrap(); + let pk_array: [u8; crypto_box::KEY_SIZE] = org_pk_bytes.try_into().unwrap(); + PublicKey::from(pk_array) + }; + + let encrypted_value = crypto_box::seal(&mut OsRng, &crypto_pk, b"Very secret value").unwrap(); + + let result = secrets + .create_or_update_secret( + "TEST_SECRET_RS", + &CreateOrganizationSecret { + encrypted_value: &B64.encode(encrypted_value), + key_id: &public_key.key_id, + visibility: Visibility::Private, + selected_repository_ids: None, + }, + ) + .await?; + + println!("{:?}", result); + + Ok(()) +} diff --git a/src/api/orgs.rs b/src/api/orgs.rs index 5d319e64..cfed8f80 100644 --- a/src/api/orgs.rs +++ b/src/api/orgs.rs @@ -3,6 +3,7 @@ mod events; mod list_members; mod list_repos; +mod secrets; use crate::error::HttpSnafu; use crate::Octocrab; @@ -12,6 +13,7 @@ use snafu::ResultExt; pub use self::events::ListOrgEventsBuilder; pub use self::list_members::ListOrgMembersBuilder; pub use self::list_repos::ListReposBuilder; +pub use self::secrets::OrgSecretsHandler; /// A client to GitHub's organization API. /// @@ -220,4 +222,11 @@ impl<'octo> OrgHandler<'octo> { pub fn list_members(&self) -> list_members::ListOrgMembersBuilder { list_members::ListOrgMembersBuilder::new(self) } + + /// Handle secrets on the organizaton + /// ```no_run + /// ``` + pub fn secrets(&self) -> secrets::OrgSecretsHandler<'_> { + secrets::OrgSecretsHandler::new(self) + } } diff --git a/src/api/orgs/secrets.rs b/src/api/orgs/secrets.rs new file mode 100644 index 00000000..e3243c57 --- /dev/null +++ b/src/api/orgs/secrets.rs @@ -0,0 +1,161 @@ +use http::StatusCode; +use snafu::GenerateImplicitData; + +use super::OrgHandler; +use crate::models::orgs::secrets::{CreateOrganizationSecret, CreateOrganizationSecretResponse}; + +/// A client to GitHub's organization API. +/// +/// Created with [`Octocrab::orgs`]. +pub struct OrgSecretsHandler<'octo> { + org: &'octo OrgHandler<'octo>, +} + +impl<'octo> OrgSecretsHandler<'octo> { + pub(crate) fn new(org: &'octo OrgHandler<'octo>) -> Self { + Self { org } + } + + fn owner(&self) -> &String { + &self.org.owner + } + + /// Lists all secrets available in an organization without revealing their encrypted values. + /// You must authenticate using an access token with the admin:org scope to use this endpoint. + /// GitHub Apps must have the secrets organization permission to use this endpoint. + /// ```no_run + /// # async fn run() -> octocrab::Result<()> { + /// # let octocrab = octocrab::Octocrab::default(); + /// let org = octocrab.orgs("owner"); + /// let secrets = org.secrets(); + /// let all_secrets = secrets.get_secrets().await?; + /// # Ok(()) + /// # } + pub async fn get_secrets( + &self, + ) -> crate::Result { + let route = format!("/orgs/{org}/actions/secrets", org = self.owner()); + self.org.crab.get(route, None::<&()>).await + } + + // Gets your public key, which you need to encrypt secrets. You need to encrypt a secret before you can create or update secrets. + // You must authenticate using an access token with the admin:org scope to use this endpoint. + // GitHub Apps must have the secrets organization permission to use this endpoint. + /// ```no_run + /// # async fn run() -> octocrab::Result<()> { + /// # let octocrab = octocrab::Octocrab::default(); + /// let org = octocrab.orgs("owner"); + /// let secrets = org.secrets(); + /// let public_key = secrets.get_public_key().await?; + /// # Ok(()) + /// # } + pub async fn get_public_key(&self) -> crate::Result { + let route = format!("/orgs/{org}/actions/secrets/public-key", org = self.owner()); + self.org.crab.get(route, None::<&()>).await + } + + /// Gets a specific secret from the organization without revealing its encrypted values. + /// You must authenticate using an access token with the admin:org scope to use this endpoint. + /// GitHub Apps must have the secrets organization permission to use this endpoint. + /// ```no_run + /// # async fn run() -> octocrab::Result<()> { + /// # let octocrab = octocrab::Octocrab::default(); + /// let org = octocrab.orgs("owner"); + /// let secrets = org.secrets(); + /// let secret_info = secrets.get_secret("TOKEN").await?; + /// # Ok(()) + /// # } + pub async fn get_secret( + &self, + secret_name: impl AsRef, + ) -> crate::Result { + let route = format!( + "/orgs/{org}/actions/secrets/{secret_name}", + org = self.owner(), + secret_name = secret_name.as_ref() + ); + self.org.crab.get(route, None::<&()>).await + } + + /// Creates or updates an organization secret with an encrypted value. + /// Encrypt your secret using [`crypto_box`](https://crates.io/crates/crypto_box). + /// You must authenticate using an access token with the admin:org scope to use this endpoint. + /// GitHub Apps must have the secrets organization permission to use this endpoint + /// ```no_run + /// # async fn run() -> octocrab::Result<()> { + /// # let octocrab = octocrab::Octocrab::default(); + /// use octocrab::models::orgs::secrets::{ + /// CreateOrganizationSecret, CreateOrganizationSecretResponse, + /// Visibility + /// }; + /// + /// let org = octocrab.orgs("owner"); + /// let secrets = org.secrets(); + /// let result = secrets.create_or_update_secret("GH_TOKEN", &CreateOrganizationSecret{ + /// key_id: "123456", + /// encrypted_value: "some-b64-encrypted-string", + /// visibility: Visibility::Selected, + /// selected_repository_ids: None, + /// }).await?; + /// + /// match result { + /// CreateOrganizationSecretResponse::Created => println!("Created secret!"), + /// CreateOrganizationSecretResponse::Updated => println!("Updated secret!"), + /// } + /// # Ok(()) + /// # } + pub async fn create_or_update_secret( + &self, + secret_name: impl AsRef, + secret: &CreateOrganizationSecret<'_>, + ) -> crate::Result { + let route = format!( + "/orgs/{org}/actions/secrets/{secret_name}", + org = self.owner(), + secret_name = secret_name.as_ref() + ); + + let resp = { + let resp = self.org.crab._put(route, Some(secret)).await?; + crate::map_github_error(resp).await? + }; + + match resp.status() { + StatusCode::CREATED => Ok(CreateOrganizationSecretResponse::Created), + StatusCode::NO_CONTENT => Ok(CreateOrganizationSecretResponse::Updated), + status_code => Err(crate::Error::Other { + source: format!( + "Unexpected status code from request: {}", + status_code.as_str() + ) + .into(), + backtrace: snafu::Backtrace::generate(), + }), + } + } + + /// Deletes an organization secret. + /// You must authenticate using an access token with the admin:org scope to use this endpoint. + /// GitHub Apps must have the secrets organization permission to use this endpoint + /// ```no_run + /// # async fn run() -> octocrab::Result<()> { + /// # let octocrab = octocrab::Octocrab::default(); + /// let org = octocrab.orgs("owner"); + /// let secrets = org.secrets(); + /// + /// secrets.delete_secret("GH_TOKEN").await?; + /// + /// # Ok(()) + /// # } + pub async fn delete_secret(&self, secret_name: impl AsRef) -> crate::Result<()> { + let route = format!( + "/orgs/{org}/actions/secrets/{secret_name}", + org = self.owner(), + secret_name = secret_name.as_ref() + ); + + let resp = self.org.crab._delete(route, None::<&()>).await?; + crate::map_github_error(resp).await?; + Ok(()) + } +} diff --git a/src/models/orgs.rs b/src/models/orgs.rs index 6d9ad096..57afd5b0 100644 --- a/src/models/orgs.rs +++ b/src/models/orgs.rs @@ -1,4 +1,5 @@ use super::*; +pub mod secrets; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] diff --git a/src/models/orgs/secrets.rs b/src/models/orgs/secrets.rs new file mode 100644 index 00000000..9ac9916c --- /dev/null +++ b/src/models/orgs/secrets.rs @@ -0,0 +1,48 @@ +use super::super::*; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Visibility { + All, + Private, + Selected, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OrganizationSecret { + pub name: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub visibility: Visibility, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_repositories_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct OrganizationSecrets { + pub total_count: i32, + pub secrets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CreateOrganizationSecret<'a> { + /// Value for your secret, + /// encrypted with LibSodium using the public key retrieved from the Get an organization public key endpoint. + pub encrypted_value: &'a str, + /// ID of the key you used to encrypt the secret. + pub key_id: &'a str, + /// Which type of organization repositories have access to the organization secret. + pub visibility: Visibility, + /// An array of repository ids that can access the organization secret. + /// You can only provide a list of repository ids when the visibility is set to selected. + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_repository_ids: Option<&'a [u32]>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CreateOrganizationSecretResponse { + Created, + Updated, +} diff --git a/tests/org_secrets_test.rs b/tests/org_secrets_test.rs new file mode 100644 index 00000000..dff9e36d --- /dev/null +++ b/tests/org_secrets_test.rs @@ -0,0 +1,189 @@ +// Tests for calls to the /repos/{owner}/actions/secrets API. +mod mock_error; + +use chrono::DateTime; +use mock_error::setup_error_handler; +use octocrab::{ + models::{ + orgs::secrets::{ + CreateOrganizationSecret, CreateOrganizationSecretResponse, OrganizationSecret, + OrganizationSecrets, Visibility, + }, + PublicKey, + }, + Octocrab, +}; +use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, +}; + +const ORG: &str = "org"; + +async fn setup_get_api(template: ResponseTemplate, secrets_path: &str) -> MockServer { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path(format!("/orgs/{ORG}/actions/secrets{secrets_path}"))) + .respond_with(template) + .mount(&mock_server) + .await; + setup_error_handler( + &mock_server, + &format!("GET on /orgs/{ORG}/actions/secrets{secrets_path} was not received"), + ) + .await; + mock_server +} + +async fn setup_put_api(template: ResponseTemplate, secrets_path: &str) -> MockServer { + let mock_server = MockServer::start().await; + + Mock::given(method("PUT")) + .and(path(format!("/orgs/{ORG}/actions/secrets{secrets_path}"))) + .respond_with(template) + .mount(&mock_server) + .await; + setup_error_handler( + &mock_server, + &format!("GET on /orgs/{ORG}/actions/secrets{secrets_path} was not received"), + ) + .await; + mock_server +} + +fn setup_octocrab(uri: &str) -> Octocrab { + Octocrab::builder().base_uri(uri).unwrap().build().unwrap() +} + +#[tokio::test] +async fn should_return_org_secrets() { + let org_secrets: OrganizationSecrets = + serde_json::from_str(include_str!("resources/org_secrets.json")).unwrap(); + + let template = ResponseTemplate::new(200).set_body_json(&org_secrets); + let mock_server = setup_get_api(template, "").await; + let client = setup_octocrab(&mock_server.uri()); + let org = client.orgs(ORG.to_owned()); + let secrets = org.secrets(); + let result = secrets.get_secrets().await; + assert!( + result.is_ok(), + "expected successful result, got error: {:#?}", + result + ); + let item = result.unwrap(); + + assert_eq!(item.total_count, 3); + assert_eq!(item.secrets,vec![ + OrganizationSecret { + name: String::from("GIST_ID"), + created_at: DateTime::parse_from_rfc3339("2019-08-10T14:59:22Z").unwrap().into(), + updated_at: DateTime::parse_from_rfc3339("2020-01-10T14:59:22Z").unwrap().into(), + visibility: Visibility::Private, + selected_repositories_url: None, + }, + OrganizationSecret { + name: String::from("DEPLOY_TOKEN"), + created_at: DateTime::parse_from_rfc3339("2019-08-10T14:59:22Z").unwrap().into(), + updated_at: DateTime::parse_from_rfc3339("2020-01-10T14:59:22Z").unwrap().into(), + visibility: Visibility::All, + selected_repositories_url: None, + }, + OrganizationSecret { + name: String::from("GH_TOKEN"), + created_at: DateTime::parse_from_rfc3339("2019-08-10T14:59:22Z").unwrap().into(), + updated_at: DateTime::parse_from_rfc3339("2020-01-10T14:59:22Z").unwrap().into(), + visibility: Visibility::Selected, + selected_repositories_url: Some(String::from("https://api.github.com/orgs/octo-org/actions/secrets/SUPER_SECRET/repositories")), + }, + ] + ); +} + +#[tokio::test] +async fn should_return_org_public_key() { + let org_secrets: PublicKey = + serde_json::from_str(include_str!("resources/org_public_key.json")).unwrap(); + + let template = ResponseTemplate::new(200).set_body_json(&org_secrets); + let mock_server = setup_get_api(template, "/public-key").await; + let client = setup_octocrab(&mock_server.uri()); + let org = client.orgs(ORG.to_owned()); + let secrets = org.secrets(); + let result = secrets.get_public_key().await; + assert!( + result.is_ok(), + "expected successful result, got error: {:#?}", + result + ); + let item = result.unwrap(); + + assert_eq!(item.key_id, String::from("012345678912345678")); + assert_eq!( + item.key, + String::from("2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvv1234") + ); +} + +#[tokio::test] +async fn should_return_org_secret() { + let org_secrets: OrganizationSecret = + serde_json::from_str(include_str!("resources/org_secret.json")).unwrap(); + + let template = ResponseTemplate::new(200).set_body_json(&org_secrets); + let mock_server = setup_get_api(template, "/GH_TOKEN").await; + let client = setup_octocrab(&mock_server.uri()); + let org = client.orgs(ORG.to_owned()); + let secrets = org.secrets(); + let result = secrets.get_secret("GH_TOKEN").await; + assert!( + result.is_ok(), + "expected successful result, got error: {:#?}", + result + ); + let item = result.unwrap(); + assert_eq!( + item, + OrganizationSecret { + name: String::from("GH_TOKEN"), + created_at: DateTime::parse_from_rfc3339("2019-08-10T14:59:22Z") + .unwrap() + .into(), + updated_at: DateTime::parse_from_rfc3339("2020-01-10T14:59:22Z") + .unwrap() + .into(), + visibility: Visibility::Selected, + selected_repositories_url: Some(String::from( + "https://api.github.com/orgs/octo-org/actions/secrets/SUPER_SECRET/repositories" + )), + } + ); +} + +#[tokio::test] +async fn should_add_secret() { + let template = ResponseTemplate::new(201); + let mock_server = setup_put_api(template, "/GH_TOKEN").await; + let client = setup_octocrab(&mock_server.uri()); + let org = client.orgs(ORG.to_owned()); + let secrets = org.secrets(); + let result = secrets + .create_or_update_secret( + "GH_TOKEN", + &CreateOrganizationSecret { + key_id: "123456", + encrypted_value: "some-b64-string", + visibility: Visibility::Selected, + selected_repository_ids: None, + }, + ) + .await; + assert!( + result.is_ok(), + "expected successful result, got error: {:#?}", + result + ); + let item = result.unwrap(); + assert_eq!(item, CreateOrganizationSecretResponse::Created); +} diff --git a/tests/resources/org_public_key.json b/tests/resources/org_public_key.json new file mode 100644 index 00000000..b0bac2f3 --- /dev/null +++ b/tests/resources/org_public_key.json @@ -0,0 +1,4 @@ +{ + "key_id": "012345678912345678", + "key": "2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvv1234" +} diff --git a/tests/resources/org_secret.json b/tests/resources/org_secret.json new file mode 100644 index 00000000..333aa7d5 --- /dev/null +++ b/tests/resources/org_secret.json @@ -0,0 +1,7 @@ +{ + "name": "GH_TOKEN", + "created_at": "2019-08-10T14:59:22Z", + "updated_at": "2020-01-10T14:59:22Z", + "visibility": "selected", + "selected_repositories_url": "https://api.github.com/orgs/octo-org/actions/secrets/SUPER_SECRET/repositories" +} diff --git a/tests/resources/org_secrets.json b/tests/resources/org_secrets.json new file mode 100644 index 00000000..12268e8e --- /dev/null +++ b/tests/resources/org_secrets.json @@ -0,0 +1,24 @@ +{ + "total_count": 3, + "secrets": [ + { + "name": "GIST_ID", + "created_at": "2019-08-10T14:59:22Z", + "updated_at": "2020-01-10T14:59:22Z", + "visibility": "private" + }, + { + "name": "DEPLOY_TOKEN", + "created_at": "2019-08-10T14:59:22Z", + "updated_at": "2020-01-10T14:59:22Z", + "visibility": "all" + }, + { + "name": "GH_TOKEN", + "created_at": "2019-08-10T14:59:22Z", + "updated_at": "2020-01-10T14:59:22Z", + "visibility": "selected", + "selected_repositories_url": "https://api.github.com/orgs/octo-org/actions/secrets/SUPER_SECRET/repositories" + } + ] +}