diff --git a/core/sdk/src/http/personal_access_tokens.rs b/core/sdk/src/http/personal_access_tokens.rs index 2e54c99e7c..b3f0cf07fb 100644 --- a/core/sdk/src/http/personal_access_tokens.rs +++ b/core/sdk/src/http/personal_access_tokens.rs @@ -17,6 +17,7 @@ use crate::http::http_client::HttpClient; use crate::http::http_transport::HttpTransport; +use crate::http::path::encode_segment; use crate::prelude::IggyError; use async_trait::async_trait; use iggy_common::IdentityInfo; @@ -29,6 +30,10 @@ use secrecy::SecretString; const PATH: &str = "/personal-access-tokens"; +fn delete_path(name: &str) -> String { + format!("{PATH}/{}", encode_segment(name)) +} + #[async_trait] impl PersonalAccessTokenClient for HttpClient { async fn get_personal_access_tokens(&self) -> Result, IggyError> { @@ -62,7 +67,11 @@ impl PersonalAccessTokenClient for HttpClient { } async fn delete_personal_access_token(&self, name: &str) -> Result<(), IggyError> { - self.delete(&format!("{PATH}/{name}")).await?; + // The URL parser resolves "." and ".." even when percent-encoded, so the request would hit another route. + if matches!(name, "." | "..") { + return Err(IggyError::InvalidPersonalAccessTokenName); + } + self.delete(&delete_path(name)).await?; Ok(()) } @@ -86,3 +95,26 @@ impl PersonalAccessTokenClient for HttpClient { Ok(identity_info) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_reserved_characters_when_building_delete_path_should_percent_encode() { + assert_eq!(delete_path("abc?x"), "/personal-access-tokens/abc%3Fx"); + assert_eq!(delete_path("a/b"), "/personal-access-tokens/a%2Fb"); + } + + #[tokio::test] + async fn given_dot_segment_name_when_deleting_should_fail_before_sending() { + let client = HttpClient::default(); + + for name in [".", ".."] { + assert!(matches!( + client.delete_personal_access_token(name).await, + Err(IggyError::InvalidPersonalAccessTokenName) + )); + } + } +} diff --git a/foreign/cpp/README.md b/foreign/cpp/README.md index 46e1a642c1..61ad40b177 100644 --- a/foreign/cpp/README.md +++ b/foreign/cpp/README.md @@ -101,6 +101,7 @@ errors from these methods: - `Login`, `Logout`, `ResolveOperation`, and `AbandonOperation`. - `CreateUser`, `UpdateUser`, and `DeleteUser`. +- `DeletePersonalAccessToken`. - `CreateStream`, `UpdateStream`, and `DeleteStream`. - `CreateTopic`, `UpdateTopic`, and `DeleteTopic`. - `CreatePartitions`, `DeletePartitions`, and `DeleteSegments`. diff --git a/foreign/cpp/include/iggy.hpp b/foreign/cpp/include/iggy.hpp index bc32ca52d1..da6b712351 100644 --- a/foreign/cpp/include/iggy.hpp +++ b/foreign/cpp/include/iggy.hpp @@ -71,6 +71,7 @@ class GlobalPermissions; class Permissions; class StreamPermissions; class TopicPermissions; +class PersonalAccessTokenInfo; class UserInfo; class UserInfoDetails; class ConsumerGroup; @@ -1324,6 +1325,40 @@ class UserInfoDetails final { ResourceOptions options_; }; +/** + * @brief Snapshot of one personal access token's metadata. + * + * GetPersonalAccessTokens() returns one value for each token owned by the + * caller. The secret token string is never included. Expiry() is expressed + * in microseconds since the Unix epoch and is empty when the token never + * expires. + */ +class PersonalAccessTokenInfo final { + public: + /** + * @brief Returns the token name. + * @return The name the token was created with. + */ + [[nodiscard]] const std::string &Name() const noexcept { return name_; } + + /** + * @brief Returns the expiry timestamp. + * @return Timestamp in microseconds since the Unix epoch, or empty when the token never expires. + */ + [[nodiscard]] std::optional Expiry() const noexcept { return expires_at_; } + + private: + PersonalAccessTokenInfo(std::string name, std::optional expires_at) + : name_(std::move(name)), expires_at_(expires_at) {} + + static PersonalAccessTokenInfo FromFfi(ffi::PersonalAccessTokenInfo token); + + friend class IggyBlockingClient; + + std::string name_; + std::optional expires_at_; +}; + /** * @brief Snapshot of one topic's metadata and aggregate statistics. * @@ -5693,6 +5728,7 @@ class PollingStrategy final { * * - Login(), Logout(), ResolveOperation(), and AbandonOperation(). * - CreateUser(), UpdateUser(), and DeleteUser(). + * - DeletePersonalAccessToken(). * - EncryptionKeyFingerprint() and invalid keys passed to CreateStream(). * - CreateStream(), UpdateStream(), and DeleteStream(). * - CreateTopic(), UpdateTopic(), and DeleteTopic(). @@ -5960,6 +5996,31 @@ class IggyBlockingClient final { */ std::vector GetUsers(); + /** + * @brief Lists the personal access tokens owned by the authenticated caller. + * + * The result carries token names and expiry only. The server shows a + * secret token string only when the token is created. + * + * @return One entry per token, empty when the caller has none. + * @throws IggyException if the client is unavailable or unauthenticated, or + * the request fails. + */ + std::vector GetPersonalAccessTokens(); + + /** + * @brief Deletes one of the caller's personal access tokens. + * + * A failed or unknown transport outcome can leave the deletion committed; + * list the tokens before retrying. + * + * @param name Name of the token to delete. + * @throws IggyException if @p name is empty or exceeds 255 bytes; the + * client is unavailable or unauthenticated; the token does not + * exist; or the request fails. + */ + void DeletePersonalAccessToken(std::string name); + /** * @brief Creates a user account. * diff --git a/foreign/cpp/src/client.cpp b/foreign/cpp/src/client.cpp index a0b1605579..0435a41076 100644 --- a/foreign/cpp/src/client.cpp +++ b/foreign/cpp/src/client.cpp @@ -162,6 +162,23 @@ std::vector IggyBlockingClient::GetUsers() { }); } +std::vector IggyBlockingClient::GetPersonalAccessTokens() { + return RethrowAsIggyException([this] { + std::vector tokens; + auto ffi_tokens = Handle()->get_personal_access_tokens(); + tokens.reserve(ffi_tokens.size()); + for (auto &token : ffi_tokens) { + tokens.push_back(PersonalAccessTokenInfo::FromFfi(std::move(token))); + } + return tokens; + }); +} + +void IggyBlockingClient::DeletePersonalAccessToken(std::string name) { + RethrowAsIggyException( + [this, &name](ffi::ErrorInfo &error_info) { Handle()->delete_personal_access_token(name, error_info); }); +} + UserInfoDetails IggyBlockingClient::CreateUser(std::string username, std::string password, UserStatus status, diff --git a/foreign/cpp/src/client.rs b/foreign/cpp/src/client.rs index cfc1eae393..1587cfc25e 100644 --- a/foreign/cpp/src/client.rs +++ b/foreign/cpp/src/client.rs @@ -1340,6 +1340,37 @@ impl Client { }) } + pub fn get_personal_access_tokens(&self) -> Result, String> { + RUNTIME.block_on(async { + let tokens = self + .inner + .get_personal_access_tokens() + .await + .map_err(|error| format!("Could not get personal access tokens: {error}"))?; + Ok(tokens + .into_iter() + .map(ffi::PersonalAccessTokenInfo::from) + .collect()) + }) + } + + pub fn delete_personal_access_token( + &self, + name: &str, + error_info: &mut ffi::ErrorInfo, + ) -> Result<(), String> { + *error_info = ffi::ErrorInfo::default(); + RUNTIME.block_on(async { + self.inner + .delete_personal_access_token(name) + .await + .inspect_err(|error| error_info.record(error)) + .map_err(|error| { + format!("Could not delete personal access token '{name}': {error}") + }) + }) + } + pub fn get_user(&self, user_id: ffi::Identifier) -> Result { let rust_user_id = RustIdentifier::try_from(user_id) .map_err(|error| format!("Could not get user: invalid user identifier: {error}"))?; diff --git a/foreign/cpp/src/lib.rs b/foreign/cpp/src/lib.rs index 040a119730..27a1b5e675 100644 --- a/foreign/cpp/src/lib.rs +++ b/foreign/cpp/src/lib.rs @@ -468,6 +468,12 @@ mod ffi { derived_options: Vec, } + struct PersonalAccessTokenInfo { + name: String, + has_expiry: bool, + expiry: u64, + } + struct LoginInfo { user_id: u32, has_access_token: bool, @@ -1063,14 +1069,18 @@ mod ffi { error_info: &mut ErrorInfo, ) -> Result<()>; fn get_cluster_metadata(self: &Client) -> Result; - // fn get_personal_access_tokens(self: &Client) -> Result>; + fn get_personal_access_tokens(self: &Client) -> Result>; fn create_personal_access_token( self: &Client, name: String, expiry_kind: String, expiry_value: u64, ) -> Result; - // fn delete_personal_access_token(self: &Client, name: String) -> Result<()>; + fn delete_personal_access_token( + self: &Client, + name: &str, + error_info: &mut ErrorInfo, + ) -> Result<()>; // fn login_with_personal_access_token(self: &Client, token: String) -> Result; unsafe fn delete_client(client: *mut Client); diff --git a/foreign/cpp/src/type_conversion.rs b/foreign/cpp/src/type_conversion.rs index 809a2b44e7..6395a658bd 100644 --- a/foreign/cpp/src/type_conversion.rs +++ b/foreign/cpp/src/type_conversion.rs @@ -60,7 +60,8 @@ use iggy_common::{ MetadataPartitionsItem, MetadataSession, MetadataSessionClosed, MetadataStreamChange, MetadataStreamDeleted, MetadataTokenChange, MetadataTokenDeleted, MetadataTopicChange, MetadataTopicDeleted, MetadataTopicItem, MetadataUserChange, MetadataUserIdentity, - MetadataUserPermissionsUpdated, Permissions as RustPermissions, Stats as RustStats, + MetadataUserPermissionsUpdated, Permissions as RustPermissions, + PersonalAccessTokenInfo as RustPersonalAccessTokenInfo, Stats as RustStats, StreamPermissions as RustStreamPermissions, TopicPermissions as RustTopicPermissions, TransportEndpoints as RustTransportEndpoints, UserInfo as RustUserInfo, UserInfoDetails as RustUserInfoDetails, UserStatus as RustUserStatus, @@ -202,6 +203,17 @@ impl From for ffi::UserInfoDetails { } } +impl From for ffi::PersonalAccessTokenInfo { + fn from(token: RustPersonalAccessTokenInfo) -> Self { + let has_expiry = token.expiry_at.is_some(); + ffi::PersonalAccessTokenInfo { + name: token.name, + has_expiry, + expiry: token.expiry_at.map_or(0, |expiry_at| expiry_at.as_micros()), + } + } +} + impl From for ffi::UserStatus { fn from(status: RustUserStatus) -> Self { match status { @@ -2101,4 +2113,22 @@ mod tests { Err(IggyError::InvalidCommand) ); } + + #[test] + fn given_token_with_and_without_expiry_when_converting_should_preserve_presence() { + let expiring = ffi::PersonalAccessTokenInfo::from(RustPersonalAccessTokenInfo { + name: "ci-bot".to_owned(), + expiry_at: Some(IggyTimestamp::from(1_700_000_000_000_000)), + }); + assert_eq!(expiring.name, "ci-bot"); + assert!(expiring.has_expiry); + assert_eq!(expiring.expiry, 1_700_000_000_000_000); + + let permanent = ffi::PersonalAccessTokenInfo::from(RustPersonalAccessTokenInfo { + name: "laptop".to_owned(), + expiry_at: None, + }); + assert!(!permanent.has_expiry); + assert_eq!(permanent.expiry, 0); + } } diff --git a/foreign/cpp/src/type_conversions.cpp b/foreign/cpp/src/type_conversions.cpp index 523af49cc6..7ed71a9c1e 100644 --- a/foreign/cpp/src/type_conversions.cpp +++ b/foreign/cpp/src/type_conversions.cpp @@ -161,6 +161,14 @@ Permissions Permissions::FromFfi(const ffi::Permissions &permissions) { return result; } +PersonalAccessTokenInfo PersonalAccessTokenInfo::FromFfi(ffi::PersonalAccessTokenInfo token) { + std::optional expires_at; + if (token.has_expiry) { + expires_at = token.expiry; + } + return PersonalAccessTokenInfo(std::string(token.name.data(), token.name.size()), expires_at); +} + UserInfo UserInfo::FromFfi(ffi::UserInfo user) { return UserInfo(user.id, user.created_at, static_cast(user.status), std::string(user.username.c_str(), user.username.size()), diff --git a/foreign/cpp/tests/e2e/client.cpp b/foreign/cpp/tests/e2e/client.cpp index 6adb66d046..fdda975e78 100644 --- a/foreign/cpp/tests/e2e/client.cpp +++ b/foreign/cpp/tests/e2e/client.cpp @@ -1214,6 +1214,82 @@ TEST_F(E2E_Client, GetUsersBeforeLoginThrows) { ASSERT_THROW(client.GetUsers(), std::exception); } +TEST_F(E2E_Client, GetPersonalAccessTokensBeforeLoginThrows) { + RecordProperty("description", "Rejects listing personal access tokens without an active authenticated session."); + auto client = GetLoggedOutHighLevelClient(); + + ASSERT_THROW(client.GetPersonalAccessTokens(), std::exception); + ASSERT_NO_THROW(client.Connect()); + ASSERT_THROW(client.GetPersonalAccessTokens(), std::exception); + ASSERT_NO_THROW(client.Login("iggy", "iggy")); + ASSERT_NO_THROW(client.Logout()); + ASSERT_THROW(client.GetPersonalAccessTokens(), std::exception); + ASSERT_NO_THROW(client.Login("iggy", "iggy")); + ASSERT_NO_THROW(client.Disconnect()); + ASSERT_THROW(client.GetPersonalAccessTokens(), std::exception); +} + +TEST_F(E2E_Client, DeletePersonalAccessTokenBeforeLoginThrows) { + RecordProperty("description", "Rejects deleting a personal access token without an active authenticated session."); + auto client = GetLoggedOutHighLevelClient(); + const std::string token_name = GetRandomName(50); + + ASSERT_THROW(client.DeletePersonalAccessToken(token_name), std::exception); + ASSERT_NO_THROW(client.Connect()); + ASSERT_THROW(client.DeletePersonalAccessToken(token_name), std::exception); + ASSERT_NO_THROW(client.Login("iggy", "iggy")); + ASSERT_NO_THROW(client.Logout()); + ASSERT_THROW(client.DeletePersonalAccessToken(token_name), std::exception); + ASSERT_NO_THROW(client.Login("iggy", "iggy")); + ASSERT_NO_THROW(client.Disconnect()); + ASSERT_THROW(client.DeletePersonalAccessToken(token_name), std::exception); +} + +TEST_F(E2E_Client, GetPersonalAccessTokensAfterLoginSucceeds) { + RecordProperty("description", "Lists personal access tokens for an authenticated session."); + auto client = GetLoggedInHighLevelClient(); + + ASSERT_NO_THROW(client.GetPersonalAccessTokens()); +} + +TEST_F(E2E_Client, PersonalAccessTokenCanBeListedAndDeleted) { + RecordProperty("description", + "Lists a created personal access token with its expiry, deletes it, and lists again."); + auto client = GetLoggedInHighLevelClient(); + const std::string token_name = GetRandomName(50); + + ASSERT_NO_THROW((void)client.CreatePersonalAccessToken(token_name, iggy::Expiry::Duration(3'600'000'000))); + + const auto tokens = client.GetPersonalAccessTokens(); + const auto found = + std::find_if(tokens.begin(), tokens.end(), [&](const auto &token) { return token.Name() == token_name; }); + EXPECT_NE(found, tokens.end()); + if (found != tokens.end()) { + EXPECT_TRUE(found->Expiry().has_value()); + } + + ASSERT_NO_THROW(client.DeletePersonalAccessToken(token_name)); + const auto remaining = client.GetPersonalAccessTokens(); + EXPECT_EQ(std::count_if(remaining.begin(), remaining.end(), + [&](const auto &token) { return token.Name() == token_name; }), + 0); +} + +TEST_F(E2E_Client, DeleteUnknownPersonalAccessTokenThrows) { + RecordProperty("description", "Rejects deleting a personal access token that does not exist."); + auto client = GetLoggedInHighLevelClient(); + + try { + client.DeletePersonalAccessToken(GetRandomName(50)); + FAIL() << "Deleting an unknown personal access token must be rejected"; + } catch (const iggy::IggyException &error) { + constexpr std::uint32_t resource_not_found_code = 20; + EXPECT_EQ(error.Code(), resource_not_found_code); + EXPECT_EQ(error.Kind(), "resource_not_found"); + EXPECT_FALSE(error.OperationId().has_value()); + } +} + TEST_F(E2E_Client, GetUserAllowsSelfLookupWithoutReadUsersPermission) { RecordProperty("description", "Allows a user without read_users permission to read its own details."); auto root_client = GetLoggedInHighLevelClient();