Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion core/sdk/src/http/personal_access_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Vec<PersonalAccessTokenInfo>, IggyError> {
Expand Down Expand Up @@ -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(())
}

Expand All @@ -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)
));
}
}
}
1 change: 1 addition & 0 deletions foreign/cpp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
61 changes: 61 additions & 0 deletions foreign/cpp/include/iggy.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class GlobalPermissions;
class Permissions;
class StreamPermissions;
class TopicPermissions;
class PersonalAccessTokenInfo;
class UserInfo;
class UserInfoDetails;
class ConsumerGroup;
Expand Down Expand Up @@ -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<std::uint64_t> Expiry() const noexcept { return expires_at_; }

private:
PersonalAccessTokenInfo(std::string name, std::optional<std::uint64_t> expires_at)
: name_(std::move(name)), expires_at_(expires_at) {}

static PersonalAccessTokenInfo FromFfi(ffi::PersonalAccessTokenInfo token);

friend class IggyBlockingClient;

std::string name_;
std::optional<std::uint64_t> expires_at_;
};

/**
* @brief Snapshot of one topic's metadata and aggregate statistics.
*
Expand Down Expand Up @@ -5693,6 +5728,7 @@ class PollingStrategy final {
*
* - Login(), Logout(), ResolveOperation(), and AbandonOperation().
* - CreateUser(), UpdateUser(), and DeleteUser().
* - DeletePersonalAccessToken().
Comment thread
hubcio marked this conversation as resolved.
* - EncryptionKeyFingerprint() and invalid keys passed to CreateStream().
* - CreateStream(), UpdateStream(), and DeleteStream().
* - CreateTopic(), UpdateTopic(), and DeleteTopic().
Expand Down Expand Up @@ -5960,6 +5996,31 @@ class IggyBlockingClient final {
*/
std::vector<UserInfo> 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<PersonalAccessTokenInfo> GetPersonalAccessTokens();

/**
* @brief Deletes one of the caller's personal access tokens.
Comment thread
hubcio marked this conversation as resolved.
*
* 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.
*
Expand Down
17 changes: 17 additions & 0 deletions foreign/cpp/src/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,23 @@ std::vector<UserInfo> IggyBlockingClient::GetUsers() {
});
}

std::vector<PersonalAccessTokenInfo> IggyBlockingClient::GetPersonalAccessTokens() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simplification: this list-and-convert loop is the seventh copy of the same shape in this file. not for this PR, but one private template helper could replace all seven.

@Mitshah2406 Mitshah2406 Oct 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, a shared helper would touch the other six loops that aren't part of this change, so I am keeping it out of this PR. Happy to do it as a follow up if you want.

return RethrowAsIggyException([this] {
std::vector<PersonalAccessTokenInfo> 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,
Expand Down
31 changes: 31 additions & 0 deletions foreign/cpp/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,37 @@ impl Client {
})
}

pub fn get_personal_access_tokens(&self) -> Result<Vec<ffi::PersonalAccessTokenInfo>, 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<ffi::UserInfoDetails, String> {
let rust_user_id = RustIdentifier::try_from(user_id)
.map_err(|error| format!("Could not get user: invalid user identifier: {error}"))?;
Expand Down
14 changes: 12 additions & 2 deletions foreign/cpp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,12 @@ mod ffi {
derived_options: Vec<HeaderEntry>,
}

struct PersonalAccessTokenInfo {
name: String,
has_expiry: bool,
expiry: u64,
}

struct LoginInfo {
user_id: u32,
has_access_token: bool,
Expand Down Expand Up @@ -1063,14 +1069,18 @@ mod ffi {
error_info: &mut ErrorInfo,
) -> Result<()>;
fn get_cluster_metadata(self: &Client) -> Result<ClusterMetadata>;
// fn get_personal_access_tokens(self: &Client) -> Result<Vec<PersonalAccessTokenInfo>>;
fn get_personal_access_tokens(self: &Client) -> Result<Vec<PersonalAccessTokenInfo>>;
fn create_personal_access_token(
self: &Client,
name: String,
expiry_kind: String,
expiry_value: u64,
) -> Result<String>;
// 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<IdentityInfo>;

unsafe fn delete_client(client: *mut Client);
Expand Down
32 changes: 31 additions & 1 deletion foreign/cpp/src/type_conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -202,6 +203,17 @@ impl From<RustUserInfoDetails> for ffi::UserInfoDetails {
}
}

impl From<RustPersonalAccessTokenInfo> 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<RustUserStatus> for ffi::UserStatus {
fn from(status: RustUserStatus) -> Self {
match status {
Expand Down Expand Up @@ -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);
}
}
8 changes: 8 additions & 0 deletions foreign/cpp/src/type_conversions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ Permissions Permissions::FromFfi(const ffi::Permissions &permissions) {
return result;
}

PersonalAccessTokenInfo PersonalAccessTokenInfo::FromFfi(ffi::PersonalAccessTokenInfo token) {
std::optional<std::uint64_t> 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<UserStatus>(user.status),
std::string(user.username.c_str(), user.username.size()),
Expand Down
Loading
Loading