diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d7eba8a..879ae8a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,7 +33,7 @@ jobs: env: # We pass the config as a JSON here to simulate one service with 3 nodes. # TODO: Ideally, we should use the same setup in local environment (`testdata/config.hcl`) in GHA test. - CONSUL_LOCAL_CONFIG: '{"acl": [{"default_policy": "allow", "enable_token_persistence": true, "enabled": true}], "services": [ {"address": "1.1.1.1", "checks": [], "id": "test-service-1", "name": "test-service", "port": 20001, "tags": ["first"]}, {"address": "2.2.2.2", "checks": [], "id": "test-service-2", "name": "test-service", "port": 20002, "tags": ["second"]}, {"address": "3.3.3.3", "checks": [], "id": "test-service-3", "name": "test-service", "port": 20003, "tags": ["third"]} ]}' + CONSUL_LOCAL_CONFIG: '{"acl":[{"enabled":true,"default_policy":"allow","enable_token_persistence":true,"tokens":[{"initial_management":"8fc9e787-674f-0709-cfd5-bfdabd73a70d"}]}],"services":[{"id":"test-service-1","name":"test-service","address":"1.1.1.1","port":20001,"checks":[],"tags":["first"]},{"id":"test-service-2","name":"test-service","address":"2.2.2.2","port":20002,"checks":[],"tags":["second"]},{"id":"test-service-3","name":"test-service","address":"3.3.3.3","port":20003,"checks":[],"tags":["third"]}]}' env: CONSUL_HTTP_ADDR: http://consul:8500 @@ -42,4 +42,4 @@ jobs: - uses: actions/checkout@v2 - name: Test - run: cargo test ${{ matrix.features }} + run: cargo test "${{ matrix.features }}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b3e65cd..e894eac 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,15 +13,17 @@ jobs: env: # We pass the config as a JSON here to simulate one service with 3 nodes. # TODO: Ideally, we should use the same setup in local environment (`testdata/config.hcl`) in GHA test. - CONSUL_LOCAL_CONFIG: '{"acl": [{"default_policy": "allow", "enable_token_persistence": true, "enabled": true}], "services": [ {"address": "1.1.1.1", "checks": [], "id": "test-service-1", "name": "test-service", "port": 20001, "tags": ["first"]}, {"address": "2.2.2.2", "checks": [], "id": "test-service-2", "name": "test-service", "port": 20002, "tags": ["second"]}, {"address": "3.3.3.3", "checks": [], "id": "test-service-3", "name": "test-service", "port": 20003, "tags": ["third"]} ]}' + CONSUL_LOCAL_CONFIG: '{"acl":[{"enabled":true,"default_policy":"allow","enable_token_persistence":true,"tokens":[{"initial_management":"8fc9e787-674f-0709-cfd5-bfdabd73a70d"}]}],"services":[{"id":"test-service-1","name":"test-service","address":"1.1.1.1","port":20001,"checks":[],"tags":["first"]},{"id":"test-service-2","name":"test-service","address":"2.2.2.2","port":20002,"checks":[],"tags":["second"]},{"id":"test-service-3","name":"test-service","address":"3.3.3.3","port":20003,"checks":[],"tags":["third"]}]}' env: CONSUL_HTTP_ADDR: http://consul:8500 - + strategy: + matrix: + features: [""] steps: - uses: actions/checkout@v2 - - name: Test - run: cargo test + - name: Tests + run: cargo test "${{ matrix.features }}" dry-run: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index f574ebb..ca2ec7d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ Cargo.lock # Ignore auto-generated files from JetBrain products .idea/ + +# Ignore .swp generated by vim +**/*.swp diff --git a/Cargo.toml b/Cargo.toml index 3dba02a..5f254c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,3 +34,4 @@ ureq = { version = "3", features = ["json"] } [dev-dependencies] tokio-test = "0.4" # Explicitly add if missing + diff --git a/examples/create_acl_policy.rs b/examples/create_acl_policy.rs new file mode 100644 index 0000000..a251e79 --- /dev/null +++ b/examples/create_acl_policy.rs @@ -0,0 +1,18 @@ +use rs_consul::{Config, Consul, CreateACLPolicyRequest}; + +#[tokio::main] // Enables async main +async fn main() { + let consul_config = Config { + address: "http://localhost:8500".to_string(), + token: Some(String::from("8fc9e787-674f-0709-cfd5-bfdabd73a70d")), // use bootstraped + // token (with write perm) + ..Default::default() + }; + let consul = Consul::new(consul_config); + let policy_payload = CreateACLPolicyRequest { + name: "dev-policy-test-1".to_owned(), + description: Some("this is not a test policy".to_owned()), + rules: Some("".to_owned()), + }; + consul.create_acl_policy(&policy_payload).await.unwrap(); +} diff --git a/examples/create_acl_token.rs b/examples/create_acl_token.rs new file mode 100644 index 0000000..753de43 --- /dev/null +++ b/examples/create_acl_token.rs @@ -0,0 +1,50 @@ +// +// curl --request PUT \ +// --url http://localhost:8500/v1/acl/token \ +// --header "X-Consul-Token: 8fc9e787-674f-0709-cfd5-bfdabd73a70d" \ +// --header "Content-Type: application/json" \ +// --data '{ +// "Description": "Minimal token for read-only access", +// "Policies": [ +// { +// "Name": "dev-policy-test-1" +// } +// ] +// }' +// + +use rs_consul::{Config, Consul, CreateACLTokenPayload}; +#[tokio::main] // Enables async main +async fn main() { + let consul_config = Config { + address: "http://localhost:8500".to_string(), + token: Some(String::from("8fc9e787-674f-0709-cfd5-bfdabd73a70d")), // use bootstraped + // token (with write perm) + ..Default::default() + }; + let consul = Consul::new(consul_config); + let token_payload = CreateACLTokenPayload { + description: Some("Test token".to_owned()), + ..Default::default() + }; + let result = consul.create_acl_token(&token_payload).await.unwrap(); + println!( + " +Token created successfully: + accessor_id: {} + secret_id: {} + description: {} + policies: {:#?} + local: {} + create_time:{} + hash: {} +", + result.accessor_id, + result.secret_id, + result.description, + result.policies, + result.local, + result.create_time, + result.hash, + ); +} diff --git a/examples/delete_acl_token.rs b/examples/delete_acl_token.rs new file mode 100644 index 0000000..9fbaa3c --- /dev/null +++ b/examples/delete_acl_token.rs @@ -0,0 +1,18 @@ +use rs_consul::{Config, Consul}; + +#[tokio::main] // Enables async main +async fn main() { + let consul_config = Config { + address: "http://localhost:8500".to_string(), + token: Some(String::from("8fc9e787-674f-0709-cfd5-bfdabd73a70d")), // use bootstraped + // token (with write perm) + ..Default::default() + }; + let consul = Consul::new(consul_config); + let _res = consul + .delete_acl_token("58df5025-134c-8999-6bc3-992fe268a39e".to_string()) + .await + .unwrap(); + + println!("Token deleted successfully"); +} diff --git a/examples/get_acl_policies.rs b/examples/get_acl_policies.rs new file mode 100644 index 0000000..b414a6f --- /dev/null +++ b/examples/get_acl_policies.rs @@ -0,0 +1,28 @@ +use rs_consul::{Config, Consul}; + +#[tokio::main] // Enables async main +async fn main() { + let consul_config = Config { + address: "http://localhost:8500".to_string(), + token: Some(String::from("8fc9e787-674f-0709-cfd5-bfdabd73a70d")), // use bootstraped + // token (with write perm) + ..Default::default() + }; + let consul = Consul::new(consul_config); + let acl_policies = consul.get_acl_policies().await.unwrap(); + + println!("{} Policies found", acl_policies.len()); + for (i, policy) in acl_policies.iter().enumerate() { + println!( + "id #{}\n\ + ├─ ID: {}\n\ + ├─ Name: {}\n\ + └─ Description: {}", + i + 1, + policy.id, + policy.name, + policy.description, + ); + println!("\n=========\n") + } +} diff --git a/examples/get_acl_tokens.rs b/examples/get_acl_tokens.rs new file mode 100644 index 0000000..7b28eaf --- /dev/null +++ b/examples/get_acl_tokens.rs @@ -0,0 +1,35 @@ +use rs_consul::{Config, Consul}; + +#[tokio::main] // Enables async main +async fn main() { + let consul_config = Config { + address: "http://localhost:8500".to_string(), + token: Some(String::from("8fc9e787-674f-0709-cfd5-bfdabd73a70d")), // use bootstraped + // token (with write perm) + ..Default::default() + }; + let consul = Consul::new(consul_config); + let acl_tokens = consul.get_acl_tokens().await.unwrap(); + + println!("{} Tokens found", acl_tokens.len()); + for (i, token) in acl_tokens.iter().enumerate() { + println!( + "Token #{}\n\ + ├─ Accessor ID: {}\n\ + ├─ Secret ID: {}\n\ + ├─ Description: {}\n\ + └─ Policies: {}", + i + 1, + token.accessor_id, + token.secret_id, + token.description, + token + .policies + .iter() + .map(|p| format!(" ({:?})", p)) + .collect::>() + .join(", ") + ); + println!("\n=========\n") + } +} diff --git a/examples/lock.rs b/examples/lock.rs new file mode 100644 index 0000000..430c7ad --- /dev/null +++ b/examples/lock.rs @@ -0,0 +1,34 @@ +use rs_consul::{Config, Consul, types::*}; + +#[tokio::main] // Enables async main +async fn main() { + let consul_config = Config { + address: "http://localhost:8500".to_string(), + token: None, // Token is None in developpement mode + ..Default::default() + }; + let consul = Consul::new(consul_config); + + let node_id = "root-node"; + let service_name = "new-service-1"; + let payload = RegisterEntityPayload { + ID: None, + Node: node_id.to_string(), + Address: "127.0.0.1".to_string(), + Datacenter: None, + TaggedAddresses: Default::default(), + NodeMeta: Default::default(), + Service: Some(RegisterEntityService { + ID: None, + Service: service_name.to_string(), + Tags: vec![], + TaggedAddresses: Default::default(), + Meta: Default::default(), + Port: Some(42424), + Namespace: None, + }), + Checks: vec![], + SkipNodeUpdate: None, + }; + consul.register_entity(&payload).await.unwrap(); +} diff --git a/examples/lock_key.rs b/examples/lock_key.rs new file mode 100644 index 0000000..2d91de1 --- /dev/null +++ b/examples/lock_key.rs @@ -0,0 +1,28 @@ +use std::time::Duration; + +use rs_consul::{Config, Consul, types::*}; +use tokio::time::sleep; + +#[tokio::main] // Enables async main +async fn main() { + let consul_config = Config { + address: "http://localhost:8500".to_string(), + token: None, // Token is None in developpement mode + ..Default::default() + }; + let consul = Consul::new(consul_config); + + let key = "key-locked"; + let key_value = "\"locked_value\""; + // Lock request + let req = LockRequest { + key, + behavior: LockExpirationBehavior::Release, + lock_delay: std::time::Duration::from_secs(1), + ..Default::default() + }; + let _res = consul.get_lock(req, key_value.as_bytes()).await.unwrap(); + println!("Lock aquired for `locked-key`"); + sleep(Duration::from_secs(5)).await; // Aquire the lock for 5 seconds + println!("Lock released"); +} diff --git a/examples/read_token.rs b/examples/read_token.rs new file mode 100644 index 0000000..54c46ea --- /dev/null +++ b/examples/read_token.rs @@ -0,0 +1,35 @@ +use rs_consul::{Config, Consul}; + +#[tokio::main] // Enables async main +async fn main() { + let consul_config = Config { + address: "http://localhost:8500".to_string(), + token: Some(String::from("8fc9e787-674f-0709-cfd5-bfdabd73a70d")), // use bootstraped + // token (with write perm) + ..Default::default() + }; + let consul = Consul::new(consul_config); + // this is equivalent to consul/token/self + let result = consul + .read_acl_token("dd72f645-4dc2-5b25-9a0b-70134ab5d1dc".to_owned()) + .await + .unwrap(); + println!( + "Token: + accessor_id: {} + secret_id: {} + description: {} + policies: {:#?} + hash: {} + local: {} + create_time:{} + ", + result.accessor_id, + result.secret_id, + result.description, + result.policies, + result.local, + result.create_time, + result.hash, + ); +} diff --git a/src/acl.rs b/src/acl.rs new file mode 100644 index 0000000..4232067 --- /dev/null +++ b/src/acl.rs @@ -0,0 +1,209 @@ +use std::time::Duration; + +use crate::ACLPolicy; +use crate::ACLToken; +use crate::Consul; +use crate::CreateACLPolicyRequest; +use crate::CreateACLTokenPayload; +use crate::Function; +use crate::Result; +use crate::errors::ConsulError; + +use http::Method; +use http_body_util::Empty; +use http_body_util::Full; +use http_body_util::combinators::BoxBody; + +use hyper::body::Buf; +use hyper::body::Bytes; + +impl Consul { + /// Returns all ACL tokens. + /// + /// Fetches the list of ACL tokens from Consul’s `/v1/acl/tokens` endpoint. + /// Users can use these tokens to manage access control for Consul resources. + /// See the [Consul API docs](https://developer.hashicorp.com/consul/api-docs/acl/tokens#list-tokens) for more information. + /// + /// # Arguments: + /// - `&self` – the `Consul` client instance. + /// + /// # Errors: + /// - [`ConsulError::ResponseDeserializationFailed`] if the response JSON can’t be parsed. + pub async fn get_acl_tokens(&self) -> Result> { + let uri = format!("{}/v1/acl/tokens", self.config.address); + let request = hyper::Request::builder().method(Method::GET).uri(uri); + let (body, _) = self + .execute_request( + request, + BoxBody::new(Empty::::new()), + Some(Duration::from_secs(5)), + crate::Function::GetAclTokens, + ) + .await?; + serde_json::from_reader(body.reader()).map_err(ConsulError::ResponseDeserializationFailed) + } + + /// Returns all ACL policies. + /// + /// Retrieves the list of ACL policies defined in Consul via the `/v1/acl/policies` endpoint. + /// ACL policies define sets of rules for tokens to grant or restrict permissions. + /// See the [Consul API docs](https://developer.hashicorp.com/consul/api-docs/acl/policies#list-policies) for more information. + /// + /// # Arguments: + /// - `&self` – the `Consul` client instance. + /// + /// # Errors: + /// - [`ConsulError::ResponseDeserializationFailed`] if the response JSON can’t be parsed. + pub async fn get_acl_policies(&self) -> Result> { + let uri = format!("{}/v1/acl/policies", self.config.address); + let request = hyper::Request::builder().method(Method::GET).uri(uri); + let (body, _) = self + .execute_request( + request, + BoxBody::new(Empty::::new()), + Some(Duration::from_secs(5)), + crate::Function::GetACLPolicies, + ) + .await?; + serde_json::from_reader(body.reader()).map_err(ConsulError::ResponseDeserializationFailed) + } + + /// Delete an acl policy. + /// + /// Sends a `DELETE` to `/v1/acl/policy/:id` to delete an ACL policy in Consul. + /// + /// # Arguments: + /// - `&self` – the `Consul` client instance. + /// - `id` – the policy ID. + /// + /// # Errors: + /// - [`ConsulError::InvalidRequest`] if the payload fails to serialize. + /// - [`ConsulError::ResponseDeserializationFailed`] if the Consul response can’t be parsed. + pub async fn delete_acl_policy(&self, id: String) -> Result<()> { + let uri = format!("{}/v1/acl/policy/{}", self.config.address, id); + let request = hyper::Request::builder().method(Method::DELETE).uri(uri); + self.execute_request( + request, + BoxBody::new(Empty::::new()), + Some(Duration::from_secs(5)), + Function::DeleteACLPolicy, + ) + .await?; + Ok(()) + } + + /// Creates a new ACL policy. + /// + /// Sends a `PUT` to `/v1/acl/policy` to define a new ACL policy in Consul. + /// ACL policies consist of rules that can be attached to tokens to control access. + /// See the [Consul API docs](https://developer.hashicorp.com/consul/api-docs/acl/policies#create-policy) for more information. + /// + /// # Arguments: + /// - `&self` – the `Consul` client instance. + /// - `payload` – the [`CreateACLPolicyRequest`](crate::types::CreateACLPolicyRequest) payload. + /// + /// # Errors: + /// - [`ConsulError::InvalidRequest`] if the payload fails to serialize. + /// - [`ConsulError::ResponseDeserializationFailed`] if the Consul response can’t be parsed. + pub async fn create_acl_policy(&self, payload: &CreateACLPolicyRequest) -> Result { + let uri = format!("{}/v1/acl/policy", self.config.address); + let request = hyper::Request::builder().method(Method::PUT).uri(uri); + let payload = serde_json::to_string(payload).map_err(ConsulError::InvalidRequest)?; + let (resp, _) = self + .execute_request( + request, + BoxBody::new(Full::::new(Bytes::from(payload.into_bytes()))), + Some(Duration::from_secs(5)), + Function::CreateACLPolicy, + ) + .await?; + serde_json::from_reader(resp.reader()).map_err(ConsulError::ResponseDeserializationFailed) + } + + /// Creates a new ACL token. + /// + /// Sends a `PUT` to `/v1/acl/token` to generate a new token which can be attached to ACL policies. + /// Tokens grant the permissions defined by their associated policies. + /// See the [Consul API docs](https://developer.hashicorp.com/consul/api-docs/acl/tokens#create-token) for more information. + /// + /// # Arguments: + /// - `&self` – the `Consul` client instance. + /// - `payload` – the [`CreateACLTokenPayload`](crate::CreateACLTokenPayload) payload. + /// + /// # Errors: + /// - [`ConsulError::InvalidRequest`] if the payload fails to serialize. + /// - [`ConsulError::ResponseDeserializationFailed`] if the response JSON can’t be parsed. + pub async fn create_acl_token(&self, payload: &CreateACLTokenPayload) -> Result { + let uri = format!("{}/v1/acl/token", self.config.address); + let request = hyper::Request::builder().method(Method::PUT).uri(uri); + let payload = serde_json::to_string(payload).map_err(ConsulError::InvalidRequest)?; + let (resp, _) = self + .execute_request( + request, + BoxBody::new(Full::::new(Bytes::from(payload.into_bytes()))), + Some(Duration::from_secs(5)), + Function::CreateACLPolicy, + ) + .await?; + serde_json::from_reader(resp.reader()).map_err(ConsulError::ResponseDeserializationFailed) + } + + /// Reads an ACL token. + /// + /// Fetches a single ACL token by its ID using the `/v1/acl/token/{token}` endpoint. + /// Useful for inspecting the token’s properties and associated policies. + /// See the [Consul API docs](https://developer.hashicorp.com/consul/api-docs/acl/tokens#read-token) for more information. + /// + /// # Arguments: + /// - `&self` – the `Consul` client instance. + /// - `accessor_id` – the accessor_id to read. + /// + /// # Errors: + /// - [`ConsulError::ResponseDeserializationFailed`] if the response JSON can’t be parsed. + pub async fn read_acl_token(&self, accessor_id: String) -> Result { + let uri = format!("{}/v1/acl/token/{}", self.config.address, accessor_id); + let request = hyper::Request::builder().method(Method::GET).uri(uri); + let (resp_body, _) = self + .execute_request( + request, + BoxBody::new(Empty::::new()), + Some(Duration::from_secs(5)), + crate::Function::ReadACLToken, + ) + .await?; + serde_json::from_reader(resp_body.reader()) + .map_err(ConsulError::ResponseDeserializationFailed) + } + + /// Deletes an ACL token. + /// + /// Sends a `DELETE` to `/v1/acl/token/{token}` to remove the specified ACL token. + /// Returns `false` if deletion failed, in which case this method returns an error. + /// See the [Consul API docs](https://developer.hashicorp.com/consul/api-docs/acl/tokens#delete-token) for more information. + /// + /// # Arguments: + /// - `&self` – the `Consul` client instance. + /// - `token` – the token ID to delete. + /// + /// # Errors: + /// - [`ConsulError::ResponseDeserializationFailed`] if the response JSON can’t be parsed. + /// - [`ConsulError::TokenDeleteFailed`] if Consul indicates deletion did not succeed. + pub async fn delete_acl_token(&self, token: String) -> Result<()> { + let uri = format!("{}/v1/acl/token/{}", self.config.address, token); + let request = hyper::Request::builder().method(Method::DELETE).uri(uri); + let (resp_body, _) = self + .execute_request( + request, + BoxBody::new(Empty::::new()), + Some(Duration::from_secs(5)), + crate::Function::DeleteACLToken, + ) + .await?; + let ok: bool = serde_json::from_reader(resp_body.reader()) + .map_err(ConsulError::ResponseDeserializationFailed)?; + if !ok { + return Err(ConsulError::TokenDeleteFailed); + } + Ok(()) + } +} diff --git a/src/acl_types.rs b/src/acl_types.rs new file mode 100644 index 0000000..eac56ce --- /dev/null +++ b/src/acl_types.rs @@ -0,0 +1,125 @@ +use serde::{self, Deserialize, Serialize}; + +/// Information related ACL token. +/// See https://developer.hashicorp.com/consul/docs/security/acl/tokens for more information. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ACLToken { + /// Unique ID + #[serde(rename = "AccessorID")] + pub accessor_id: String, + /// Secret for authentication + #[serde(rename = "SecretID")] + pub secret_id: String, + /// Description + pub description: String, + /// Policies + #[serde(default)] + pub policies: Vec, + /// Token only valid in this datacenter + #[serde(default)] + pub local: bool, + /// Creation time + pub create_time: String, + /// Hash + pub hash: String, + /// Create index + pub create_index: u64, + /// ModifyIndex is the last index that modified this key. + /// It can be used to establish blocking queries by setting the ?index query parameter. + pub modify_index: i64, +} + +/// Information related to Policies +/// see https://developer.hashicorp.com/consul/docs/security/acl/acl-policies for more information +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub struct ACLTokenPolicyLink { + /// Policy ID + #[serde(rename = "ID")] + pub id: Option, + /// Policy name + pub name: Option, +} + +/// Create ACL token payload +/// See https://developer.hashicorp.com/consul/api-docs/acl/tokens for more information. +/// TODO: NodeIdentities,TemplatedPolicies, ServiceIdentities +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub struct CreateACLTokenPayload { + /// Unique ID + #[serde(rename = "AccessorID")] + #[serde(skip_serializing_if = "Option::is_none")] + pub accessor_id: Option, + /// Secret for authentication + #[serde(rename = "SecretID")] + #[serde(skip_serializing_if = "Option::is_none")] + pub secret_id: Option, + /// Description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Policies + #[serde(skip_serializing_if = "Vec::is_empty")] + pub policies: Vec, + /// Token only valid in this datacenter + #[serde(default)] + pub local: bool, + /// Creation time + #[serde(skip_serializing_if = "Option::is_none")] + pub create_time: Option, + /// Hash + #[serde(skip_serializing_if = "Option::is_none")] + pub hash: Option, + /// Optional expiration time for the ACL token. + /// + /// If set, this field specifies the point in time after which the token is considered revoked and eligible for destruction. + /// The default value means the token does not expire. + /// + /// The expiration time must be between 1 minute and 24 hours in the future. This constraint is enforced by Consul to promote + /// security best practices, ensuring that tokens are short-lived and reducing the risk of unauthorized access in case of token compromise. + /// + /// The value should be provided in RFC 3339 format, representing a date and time with optional fractional seconds and time zone offset. + /// For example: "2025-05-29T15:00:00Z" or "2025-05-29T15:00:00+02:00". + /// + /// Added in Consul 1.5.0. + #[serde(skip_serializing_if = "Option::is_none")] + pub expiration_time: Option, +} + +/// Represents an ACL (Access Control List) policy. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ACLPolicy { + /// Unique identifier for the policy. + #[serde(rename = "ID")] + pub id: String, + /// The name of the policy + pub name: String, + /// Description of the policy. + pub description: String, + /// Hash of the policy. + pub hash: String, + /// Index at which the policy was created. + pub create_index: u32, + // `datacenters` is an Option::Vec because Consul may return `null` for this field. + // Using `Option` avoids the need for a custom deserializer that would be required + // if the field was just a `Vec`. + /// List of applicable datacenters. + pub datacenters: Option>, + /// Index at which the policy was last modified. + pub modify_index: u32, +} + +/// Payload to create an ACL Policy +#[derive(Debug, Serialize, Default)] +#[serde(rename_all = "PascalCase")] +pub struct CreateACLPolicyRequest { + /// Name of the policy (unique) + pub name: String, + /// Description + pub description: Option, + /// Rules in HCL format + // TODO: Make the rules strongly typed + pub rules: Option, +} diff --git a/src/errors.rs b/src/errors.rs index 02d26f3..ae0402b 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -70,4 +70,8 @@ pub enum ConsulError { /// An error from ureq occurred. #[error("UReq error: {0}")] UReqError(#[from] ureq::Error), + + /// Failed to delete Token. + #[error("Failed to delete the token")] + TokenDeleteFailed, } diff --git a/src/lib.rs b/src/lib.rs index 4466b6f..14948e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,6 +70,13 @@ pub use metrics::MetricInfo; pub use metrics::{Function, HttpMethod}; pub use types::*; +/// Access Control List(acl) to control authentication and authorization +pub mod acl; + +/// Types for acl related operations +pub mod acl_types; +pub use acl_types::*; + /// Consul errors and Result type mod errors; #[cfg(feature = "trace")] @@ -128,7 +135,6 @@ impl Config { } } } - /// Type alias for a Hyper client using a hyper_rusttls HttpsConnector pub type HttpsClient = Client, BoxBody>; @@ -587,35 +593,6 @@ impl Consul { req.uri(url) } - async fn get_session(&self, request: LockRequest<'_>) -> Result { - let session_req = CreateSessionRequest { - lock_delay: request.lock_delay, - behavior: request.behavior, - ttl: request.timeout, - ..Default::default() - }; - - let mut req = hyper::Request::builder().method(Method::PUT); - let mut url = String::new(); - url.push_str(&format!("{}/v1/session/create?", self.config.address)); - url = utils::add_namespace_and_datacenter(url, request.namespace, request.datacenter); - req = req.uri(url); - let create_session_json = - serde_json::to_string(&session_req).map_err(ConsulError::InvalidRequest)?; - let (response_body, _index) = self - .execute_request( - req, - BoxBody::new(Full::::new(Bytes::from( - create_session_json.into_bytes(), - ))), - None, - Function::GetSession, - ) - .await?; - serde_json::from_reader(response_body.reader()) - .map_err(ConsulError::ResponseDeserializationFailed) - } - fn build_get_service_nodes_req( &self, request: GetServiceNodesRequest<'_>, @@ -709,6 +686,7 @@ impl Consul { Some(resp.to_string()), )); } + let index = match response.headers().get("x-consul-index") { Some(header) => header.to_str().unwrap_or("0").parse::().unwrap_or(0), None => 0, diff --git a/src/lock.rs b/src/lock.rs index cee7fd5..9e273ef 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -1,9 +1,12 @@ -use http_body_util::combinators::BoxBody; +use http::Method; +use http_body_util::{Full, combinators::BoxBody}; +use hyper::body::Buf; use hyper::body::Bytes; use crate::{ - Consul, CreateOrUpdateKeyRequest, LockRequest, LockWatchRequest, ReadKeyRequest, - ReadKeyResponse, ResponseMeta, Result, errors::ConsulError, + Consul, CreateOrUpdateKeyRequest, CreateSessionRequest, LockRequest, LockWatchRequest, + ReadKeyRequest, ReadKeyResponse, ResponseMeta, Result, SessionResponse, errors::ConsulError, + utils, }; /// Represents a lock against Consul. @@ -46,6 +49,40 @@ impl Drop for Lock<'_> { } } impl Consul { + /// Obtains a session ID + /// # Arguments: + /// - request - the [LockRequest](consul::types::LockRequest) + /// # Errors: + /// [ConsulError](consul::ConsulError) describes all possible errors returned by this api. + async fn get_session(&self, request: LockRequest<'_>) -> Result { + let session_req = CreateSessionRequest { + lock_delay: request.lock_delay, + behavior: request.behavior, + ttl: request.timeout, + ..Default::default() + }; + + let mut req = hyper::Request::builder().method(Method::PUT); + let mut url = String::new(); + url.push_str(&format!("{}/v1/session/create?", self.config.address)); + url = utils::add_namespace_and_datacenter(url, request.namespace, request.datacenter); + req = req.uri(url); + let create_session_json = + serde_json::to_string(&session_req).map_err(ConsulError::InvalidRequest)?; + let (response_body, _index) = self + .execute_request( + req, + BoxBody::new(Full::::new(Bytes::from( + create_session_json.into_bytes(), + ))), + None, + crate::Function::GetSession, + ) + .await?; + serde_json::from_reader(response_body.reader()) + .map_err(ConsulError::ResponseDeserializationFailed) + } + /// Obtains a lock against a specific key in consul. See the [consul docs](https://learn.hashicorp.com/tutorials/consul/application-leader-elections?in=consul/developer-configuration) for more information. /// # Arguments: /// - request - the [LockRequest](consul::types::LockRequest) diff --git a/src/metrics.rs b/src/metrics.rs index 45fbe89..5a08ced 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -162,6 +162,20 @@ pub enum Function { GetAllRegisteredServices, /// The get_session function. GetSession, + /// The list_acl_tokens function + GetAclTokens, + /// The create_acl_policy function + CreateACLPolicy, + /// The list_acl_policies function + GetACLPolicies, + /// The read_acl_token function + ReadACLPolicies, + /// The delete_acl_token function + DeleteACLToken, + /// The read_acl_token function + ReadACLToken, + /// The delete_acl_policy function + DeleteACLPolicy, } impl Function { @@ -177,6 +191,13 @@ impl Function { Function::GetServiceNodes => "get_service_nodes", Function::GetAllRegisteredServices => "get_all_registered_services", Function::GetSession => "get_session", + Function::GetAclTokens => "list_acl_tokens", + Function::CreateACLPolicy => "create_acl_policy", + Function::GetACLPolicies => "get_acl_policies", + Function::ReadACLPolicies => "read_acl_policies", + Function::DeleteACLToken => "delete_acl_token", + Function::ReadACLToken => "read_acl_token", + Function::DeleteACLPolicy => "delete_acl_policy", } } } diff --git a/testdata/config.hcl b/testdata/config.hcl index a99f278..87206b9 100644 --- a/testdata/config.hcl +++ b/testdata/config.hcl @@ -2,6 +2,9 @@ acl { enabled = true default_policy = "allow" enable_token_persistence = true + tokens { + initial_management = "8fc9e787-674f-0709-cfd5-bfdabd73a70d" + } } # A service with 3 instances. diff --git a/tests/test_runner.rs b/tests/test_runner.rs index ae6a59e..ea056ab 100644 --- a/tests/test_runner.rs +++ b/tests/test_runner.rs @@ -7,7 +7,124 @@ use test_setup::*; pub use types::*; -#[cfg(test)] +mod acl_tests { + use super::*; + #[tokio::test(flavor = "multi_thread")] + async fn test_acl_retrieve_tokens() { + let consul = get_privileged_client(); + let result = consul.get_acl_tokens().await.unwrap(); + + // test against the initial managment token hardcoded in config.hcl + assert!( + result + .iter() + .any(|token| token.secret_id == "8fc9e787-674f-0709-cfd5-bfdabd73a70d") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_acl_create_token() { + let consul = get_privileged_client(); + + let token_payload = CreateACLTokenPayload { + description: Some("Test token".to_owned()), + secret_id: Some("00000000-2223-1111-1111-222222222223".to_owned()), + ..Default::default() + }; + let result = consul.create_acl_token(&token_payload).await.unwrap(); + + assert!(result.secret_id == "00000000-2223-1111-1111-222222222223"); + assert!(result.description == "Test token"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_read_token() { + let consul = get_privileged_client(); + + // create a token with a specific accessor_id for testing + let token_payload = CreateACLTokenPayload { + description: Some("Token created in acl_tests::test_read_token".to_owned()), + secret_id: Some("20000000-9494-1111-1111-222222222229".to_owned()), + accessor_id: Some("1d5faa9a-ec33-4514-b0c8-52ea5346d814".to_owned()), + ..Default::default() + }; + let _ = consul.create_acl_token(&token_payload).await.unwrap(); + // now read the token by the accessor_id + let result = consul + .read_acl_token("1d5faa9a-ec33-4514-b0c8-52ea5346d814".to_owned()) + .await + .unwrap(); + + assert!(result.secret_id == "20000000-9494-1111-1111-222222222229"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_get_acl_policies() { + let consul = get_privileged_client(); + + let result = consul.get_acl_policies().await.unwrap(); + + assert!( + result + .iter() + .any(|policy| policy.name == "global-management" + && policy.id == "00000000-0000-0000-0000-000000000001") + ); + } +} + +mod smoke_acl { + + use super::*; + + #[tokio::test] + async fn smoke_test_token_policy_retrieval() { + // get an instance of a privileged acl client + let consul = get_privileged_client(); + + // Create a policy + let policy_payload = CreateACLPolicyRequest { + name: "smoke_test_policy_1".to_owned(), + ..Default::default() + }; + let policy_result = consul.create_acl_policy(&policy_payload).await.unwrap(); + + // Create a token with the newly created policy + let policy_link_vec = vec![ACLTokenPolicyLink { + name: Some("smoke_test_policy_1".to_owned()), + ..Default::default() + }]; + let token_payload = CreateACLTokenPayload { + description: Some("Smmoke test".to_owned()), + secret_id: Some("00000000-9494-1111-1111-222222222229".to_owned()), + accessor_id: Some("8d5faa9a-1111-1111-b0c8-52ea5346d814".to_owned()), + policies: policy_link_vec, + ..Default::default() + }; + let _ = consul.create_acl_token(&token_payload).await.unwrap(); + + // read the newly created token + let result = consul + .read_acl_token("8d5faa9a-1111-1111-b0c8-52ea5346d814".to_owned()) + .await + .unwrap(); + assert!(result.policies.first().unwrap().name == Some("smoke_test_policy_1".to_owned())); + + assert!(result.secret_id == "00000000-9494-1111-1111-222222222229".to_owned()); + + // delete the created token + let token_delete_result = consul + .delete_acl_token("00000000-9494-1111-1111-222222222229".to_owned()) + .await + .unwrap(); + let policy_delete_result = consul.delete_acl_policy(policy_result.id).await.unwrap(); + + // delete the policy + assert_eq!(token_delete_result, ()); + assert_eq!(policy_delete_result, ()); + } +} + mod tests { use std::time::Duration; diff --git a/tests/utils/test_setup.rs b/tests/utils/test_setup.rs index ddccf23..a142622 100644 --- a/tests/utils/test_setup.rs +++ b/tests/utils/test_setup.rs @@ -5,6 +5,18 @@ pub(crate) fn get_client() -> Consul { let conf: Config = Config::from_env(); Consul::new(conf) } +/// a consul client with write permission allows for manipulating tokens +pub(crate) fn get_privileged_client() -> Consul { + use std::env; + let addr = env::var("CONSUL_HTTP_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8500".to_string()); + let conf: Config = Config { + address: addr, + token: Some(String::from("8fc9e787-674f-0709-cfd5-bfdabd73a70d")), // use initial-managment + // token hardcoded in config.hcl + ..Default::default() + }; + Consul::new(conf) +} pub(crate) async fn register_entity(consul: &Consul, service_name: &String, node_id: &str) { let ResponseMeta { response: service_names_before_register,