Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ futures-util = "0.3.31"
http = "1.3.1"
humantime = "2.2.0"
humantime-serde = "1.1.1"
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
merni = "0.1.3"
mimalloc = { version = "0.1.48", features = ["v3", "override"] }
rand = "0.9.1"
Expand Down
1 change: 1 addition & 0 deletions clients/rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ async-compression = { version = "0.4.27", features = ["tokio", "zstd"] }
bytes = { workspace = true }
futures-util = { workspace = true }
infer = { version = "0.19.0", default-features = false }
jsonwebtoken = { workspace = true }
objectstore-types = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
sentry-core = { version = ">=0.41", features = ["client"] }
Expand Down
106 changes: 106 additions & 0 deletions clients/rust/src/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
use std::collections::{BTreeMap, HashSet};

use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
use objectstore_types::Permission;
use serde::{Deserialize, Serialize};

use crate::ScopeInner;

const DEFAULT_EXPIRY_SECONDS: u64 = 60;
const DEFAULT_PERMISSIONS: [Permission; 3] = [
Permission::ObjectRead,
Permission::ObjectWrite,
Permission::ObjectDelete,
];

/// Key configuration that will be used to sign tokens in Objectstore requests.
pub struct SecretKey {
/// A key ID that Objectstore must use to load the corresponding public key.
pub kid: String,

/// An EdDSA private key.
pub secret_key: String,
}

impl std::fmt::Debug for SecretKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretKey")
.field("kid", &self.kid)
.field("secret_key", &"[redacted]")
.finish()
}
}

/// A utility to generate auth tokens to be used in Objectstore requests.
///
/// Tokens are signed with an EdDSA private key and have certain permissions and expiry timeouts
/// applied.
#[derive(Debug)]
pub struct TokenGenerator {
kid: String,
encoding_key: EncodingKey,
expiry_seconds: u64,
permissions: HashSet<Permission>,
}

#[derive(Serialize, Deserialize)]
struct JwtRes {
#[serde(rename = "os:usecase")]
usecase: String,

#[serde(flatten)]
scopes: BTreeMap<String, String>,
}

#[derive(Serialize, Deserialize)]
struct JwtClaims {
exp: u64,
permissions: HashSet<Permission>,
res: JwtRes,
}

impl TokenGenerator {
/// Create a new [`TokenGenerator`] for a given key configuration.
pub fn new(secret_key: SecretKey) -> crate::Result<TokenGenerator> {
let encoding_key = EncodingKey::from_ed_pem(secret_key.secret_key.as_bytes())?;
Ok(TokenGenerator {
kid: secret_key.kid,
encoding_key,
expiry_seconds: DEFAULT_EXPIRY_SECONDS,
permissions: HashSet::from(DEFAULT_PERMISSIONS),
})
}

/// Set the expiry duration for tokens signed by this generator.
pub fn expiry_seconds(mut self, expiry_seconds: u64) -> Self {
self.expiry_seconds = expiry_seconds;
self
}

/// Set the permissions that will be granted to tokens signed by this generator.
pub fn permissions(mut self, permissions: &[Permission]) -> Self {
self.permissions = HashSet::from_iter(permissions.iter().cloned());
self
}

/// Sign a new token for the passed-in scope using the configured expiry and permissions.
pub(crate) fn sign_for_scope(&self, scope: &ScopeInner) -> crate::Result<String> {
let claims = JwtClaims {
exp: get_current_timestamp() + self.expiry_seconds,
permissions: self.permissions.clone(),
res: JwtRes {
usecase: scope.usecase().name().into(),
scopes: scope
.scopes()
.iter()
.map(|scope| (scope.name().to_string(), scope.value().to_string()))
.collect(),
},
};

let mut header = Header::new(Algorithm::EdDSA);
header.kid = Some(self.kid.clone());

Ok(encode(&header, &claims, &self.encoding_key)?)
}
}
42 changes: 39 additions & 3 deletions clients/rust/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ use futures_util::stream::BoxStream;
use objectstore_types::{Compression, ExpirationPolicy, scope};
use url::Url;

use crate::auth::TokenGenerator;

const USER_AGENT: &str = concat!("objectstore-client/", env!("CARGO_PKG_VERSION"));

#[derive(Debug)]
struct ClientBuilderInner {
service_url: Url,
propagate_traces: bool,
reqwest_builder: reqwest::ClientBuilder,
token_generator: Option<TokenGenerator>,
}

impl ClientBuilderInner {
Expand Down Expand Up @@ -66,6 +69,7 @@ impl ClientBuilder {
service_url,
propagate_traces: false,
reqwest_builder,
token_generator: None,
}))
}

Expand Down Expand Up @@ -105,6 +109,14 @@ impl ClientBuilder {
Self(Ok(inner))
}

/// Sets a [`TokenGenerator`] that will be used to sign authorization tokens before
/// sending requests to Objectstore.
pub fn token_generator(self, token_generator: TokenGenerator) -> Self {
Copy link
Member

Choose a reason for hiding this comment

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

We need to pass either an enum or a trait that has two implementations:

  1. The TokenGenerator, as implemented.
  2. A fully signed Token (could be a newtype around a string). This will be needed in sentry_cli.

There's a workaround currently that we configure the reqwest client builder and add a static header, but it would be much better that we make this a first-party functionality.

I'm good if this is added in a follow-up. Example for the trait in case you choose to implement it right now:

// disadvantage: Whatever we pass in must be public now. Maybe better to have a custom, internally borrowing struct.
pub use crate::client::ScopeInner as TokenScope;

pub trait TokenProvider {
  fn sign_for_scope(&self, scope: &TokenScope) -> crate::Result<Token>;
}

struct ClientBuilderInner {
  ...
  token_provider: Box<dyn TokenProvider>,
}

impl ClientBuilder {
  pub fn token_provider<T: TokenProvider>(self, provider: T) -> Self {
    let Ok(mut inner) = self.0 else { return self };
    inner.token_provider = Some(Box::new(provider));
    Self(Ok(inner))
  }
}

let Ok(mut inner) = self.0 else { return self };
inner.token_generator = Some(token_generator);
Self(Ok(inner))
}

/// Returns a [`Client`] that uses this [`ClientBuilder`] configuration.
///
/// # Errors
Expand All @@ -121,6 +133,7 @@ impl ClientBuilder {
reqwest: inner.reqwest_builder.build()?,
service_url: inner.service_url,
propagate_traces: inner.propagate_traces,
token_generator: inner.token_generator,
}),
})
}
Expand Down Expand Up @@ -224,6 +237,11 @@ impl ScopeInner {
pub(crate) fn usecase(&self) -> &Usecase {
&self.usecase
}

#[inline]
pub(crate) fn scopes(&self) -> &scope::Scopes {
&self.scopes
}
}

/// A [`Scope`] is a sequence of key-value pairs that defines a (possibly nested) namespace within a
Expand Down Expand Up @@ -281,23 +299,36 @@ pub(crate) struct ClientInner {
reqwest: reqwest::Client,
service_url: Url,
propagate_traces: bool,
token_generator: Option<TokenGenerator>,
}

/// A client for Objectstore. Use [`Client::builder`] to configure and construct a Client.
///
/// To perform CRUD operations, one has to create a Client, and then scope it to a [`Usecase`]
/// and Scope in order to create a [`Session`].
///
/// If your Objectstore instance enforces authorization checks, you must provide a
/// [`TokenGenerator`] on creation.
///
/// # Example
///
/// ```no_run
/// use std::time::Duration;
/// use objectstore_client::{Client, Usecase};
/// use objectstore_client::{Client, SecretKey, TokenGenerator, Usecase};
/// use objectstore_types::Permission;
///
/// # async fn example() -> objectstore_client::Result<()> {
/// let token_generator = TokenGenerator::new(SecretKey {
/// secret_key: "<safely inject secret key>".into(),
/// kid: "my-service".into(),
/// })?
/// .expiry_seconds(30)
/// .permissions(&[Permission::ObjectRead]);
///
/// let client = Client::builder("http://localhost:8888/")
/// .timeout(Duration::from_secs(1))
/// .propagate_traces(true)
/// .token_generator(token_generator)
/// .build()?;
///
/// let session = Usecase::new("my_app")
Expand Down Expand Up @@ -384,11 +415,16 @@ impl Session {
&self,
method: reqwest::Method,
object_key: &str,
) -> reqwest::RequestBuilder {
) -> crate::Result<reqwest::RequestBuilder> {
let url = self.object_url(object_key);

let mut builder = self.client.reqwest.request(method, url);

if let Some(token_generator) = &self.client.token_generator {
let token = token_generator.sign_for_scope(&self.scope)?;
builder = builder.bearer_auth(token);
}

if self.client.propagate_traces {
let trace_headers =
sentry_core::configure_scope(|scope| Some(scope.iter_trace_propagation_headers()));
Expand All @@ -397,7 +433,7 @@ impl Session {
}
}

builder
Ok(builder)
}
}

Expand Down
2 changes: 1 addition & 1 deletion clients/rust/src/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ impl DeleteBuilder {
/// Sends the delete request.
pub async fn send(self) -> crate::Result<DeleteResponse> {
self.session
.request(reqwest::Method::DELETE, &self.key)
.request(reqwest::Method::DELETE, &self.key)?
.send()
.await?;
Ok(())
Expand Down
3 changes: 3 additions & 0 deletions clients/rust/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ pub enum Error {
/// Error when scope validation fails.
#[error("invalid scope: {0}")]
InvalidScope(#[from] objectstore_types::scope::InvalidScopeError),
/// Error when creating auth tokens, such as invalid keys.
#[error(transparent)]
TokenError(#[from] jsonwebtoken::errors::Error),
/// Error when URL manipulation fails.
#[error("{message}")]
InvalidUrl {
Expand Down
2 changes: 1 addition & 1 deletion clients/rust/src/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ impl GetBuilder {
pub async fn send(self) -> crate::Result<Option<GetResponse>> {
let response = self
.session
.request(reqwest::Method::GET, &self.key)
.request(reqwest::Method::GET, &self.key)?
.send()
.await?;
if response.status() == StatusCode::NOT_FOUND {
Expand Down
2 changes: 2 additions & 0 deletions clients/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#![warn(missing_docs)]
#![warn(missing_debug_implementations)]

mod auth;
mod client;
mod delete;
mod error;
Expand All @@ -11,6 +12,7 @@ pub mod utils;

pub use objectstore_types::{Compression, ExpirationPolicy};

pub use auth::*;
pub use client::*;
pub use delete::*;
pub use error::*;
Expand Down
2 changes: 1 addition & 1 deletion clients/rust/src/put.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ impl PutBuilder {

let mut builder = self
.session
.request(method, self.key.as_deref().unwrap_or_default());
.request(method, self.key.as_deref().unwrap_or_default())?;

let body = match (self.metadata.compression, self.body) {
(Some(Compression::Zstd), PutBody::Buffer(bytes)) => {
Expand Down
2 changes: 1 addition & 1 deletion objectstore-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ figment = { version = "0.10.19", features = ["env", "test", "yaml"] }
futures-util = { workspace = true }
humantime = { workspace = true }
humantime-serde = { workspace = true }
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
jsonwebtoken = { workspace = true }
merni = { workspace = true }
mimalloc = { workspace = true }
num_cpus = "1.17.0"
Expand Down