diff --git a/clients/rust/README.md b/clients/rust/README.md index da1c7120..b4fca8ef 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -315,6 +315,19 @@ let client = Client::builder("http://localhost:8888/") .build()?; ``` +To mint a token with narrower permissions or a different expiry than the generator's +defaults, use [`TokenGenerator::sign_with`] (for a standalone token) or +[`Session::mint_token_with`] (scoped to a session). The requested permissions must be a +subset of those granted to the generator, otherwise an `Error::PermissionEscalation` is +returned: + +```rust,ignore +use objectstore_client::auth::Permission; + +// A read-only token that expires in 30 seconds, from a fully-privileged generator. +let read_only = session.mint_token_with(Some(&[Permission::ObjectRead]), Some(30))?; +``` + ## Configuration In production, store the [`Client`] and [`Usecase`] in a `static` and reuse them. diff --git a/clients/rust/src/auth.rs b/clients/rust/src/auth.rs index ae043ebf..55a3d026 100644 --- a/clients/rust/src/auth.rs +++ b/clients/rust/src/auth.rs @@ -158,10 +158,33 @@ impl TokenGenerator { /// Use this to produce a static token that can be handed to an external service /// which then passes it to [`ClientBuilder::token`](crate::ClientBuilder::token). /// + /// The token is signed with the generator's default permissions and expiry. Use + /// [`sign_with`](Self::sign_with) to override them for a single token. + /// /// # Errors /// /// Returns an error if the scope is invalid or the JWT cannot be signed. pub fn sign(&self, scope: &crate::Scope) -> crate::Result { + self.sign_with(scope, None, None) + } + + /// Sign a token for the given [`Scope`](crate::Scope), optionally overriding the + /// generator's default permissions and/or expiry for this token only. + /// + /// When `permissions` is `Some`, they must be a subset of the permissions granted to this + /// generator, otherwise an [`Error::PermissionEscalation`](crate::Error::PermissionEscalation) + /// is returned. When `expiry_seconds` is `Some`, it overrides the generator's default expiry. + /// + /// # Errors + /// + /// Returns an error if the scope is invalid, a requested permission is not granted to this + /// generator, or the JWT cannot be signed. + pub fn sign_with( + &self, + scope: &crate::Scope, + permissions: Option<&[Permission]>, + expiry_seconds: Option, + ) -> crate::Result { let scope = match &scope.0 { Ok(inner) => inner, Err(crate::Error::InvalidScope(err)) => { @@ -172,14 +195,20 @@ impl TokenGenerator { // `InvalidScope`, unless we add a new variant and forget to update this code path. _ => return Err(scope::InvalidScopeError::Unreachable.into()), }; - self.sign_for_scope(scope) + self.sign_for_scope(scope, permissions, expiry_seconds) } - /// 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 { + /// Sign a new token for the passed-in scope, applying the given permission and expiry + /// overrides (falling back to the generator's defaults when `None`). + pub(crate) fn sign_for_scope( + &self, + scope: &ScopeInner, + permissions: Option<&[Permission]>, + expiry_seconds: Option, + ) -> crate::Result { let claims = JwtClaims { - exp: get_current_timestamp() + self.expiry_seconds, - permissions: self.permissions.clone(), + exp: get_current_timestamp() + expiry_seconds.unwrap_or(self.expiry_seconds), + permissions: self.resolve_permissions(permissions)?, res: JwtRes { usecase: scope.usecase().name().into(), scopes: scope @@ -195,4 +224,24 @@ impl TokenGenerator { Ok(encode(&header, &claims, &self.encoding_key)?) } + + /// Resolves the permissions to embed in a token, validating that any explicitly requested + /// permissions are a subset of those granted to this generator. + fn resolve_permissions( + &self, + requested: Option<&[Permission]>, + ) -> crate::Result> { + let Some(requested) = requested else { + return Ok(self.permissions.clone()); + }; + + let requested: HashSet = requested.iter().copied().collect(); + let mut escalated: Vec = + requested.difference(&self.permissions).copied().collect(); + if !escalated.is_empty() { + escalated.sort_by_key(Permission::to_string); + return Err(crate::Error::PermissionEscalation { escalated }); + } + Ok(requested) + } } diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs index 3f34785d..35d2f85b 100644 --- a/clients/rust/src/client.rs +++ b/clients/rust/src/client.rs @@ -10,7 +10,7 @@ use reqwest::RequestBuilder; use url::Url; use crate::IntoTokenProvider; -use crate::auth::TokenProvider; +use crate::auth::{Permission, TokenProvider}; const USER_AGENT: &str = concat!("objectstore-client/", env!("CARGO_PKG_VERSION")); @@ -456,13 +456,43 @@ impl Session { url } - /// Returns a signed token if a token or generator was provided or `None` otherwise + /// Returns a signed token if a token or generator was provided or `None` otherwise. + /// + /// The token carries the configured provider's default permissions and expiry. Use + /// [`mint_token_with`](Self::mint_token_with) to override them for a single token. pub fn mint_token(&self) -> crate::Result> { + self.mint_token_with(None, None) + } + + /// Returns a signed token, optionally overriding the generator's default permissions + /// and/or expiry for this token only. + /// + /// When `permissions` is `Some`, they must be a subset of the permissions granted to the + /// underlying [`TokenGenerator`](crate::TokenGenerator), otherwise an + /// [`Error::PermissionEscalation`](crate::Error::PermissionEscalation) is returned. When + /// `expiry_seconds` is `Some`, it overrides the generator's default expiry. + /// + /// These overrides only apply to a [`TokenProvider::Generator`](crate::TokenProvider). A + /// static, pre-signed token cannot be re-scoped, so passing an override alongside one returns + /// [`Error::StaticTokenOverride`](crate::Error::StaticTokenOverride). Returns `None` when no + /// token or generator was provided. + pub fn mint_token_with( + &self, + permissions: Option<&[Permission]>, + expiry_seconds: Option, + ) -> crate::Result> { match &self.client.token { - Some(TokenProvider::Generator(generator)) => { - Ok(Some(generator.sign_for_scope(&self.scope)?)) + Some(TokenProvider::Generator(generator)) => Ok(Some(generator.sign_for_scope( + &self.scope, + permissions, + expiry_seconds, + )?)), + Some(TokenProvider::Static(token)) => { + if permissions.is_some() || expiry_seconds.is_some() { + return Err(crate::Error::StaticTokenOverride); + } + Ok(Some(token.clone())) } - Some(TokenProvider::Static(token)) => Ok(Some(token.clone())), None => Ok(None), } } diff --git a/clients/rust/src/error.rs b/clients/rust/src/error.rs index 341ea47d..e36b8144 100644 --- a/clients/rust/src/error.rs +++ b/clients/rust/src/error.rs @@ -21,6 +21,17 @@ pub enum Error { /// Error when creating auth tokens, such as invalid keys. #[error(transparent)] TokenError(#[from] jsonwebtoken::errors::Error), + /// Error when the permissions requested for a token exceed those granted to the + /// [`TokenGenerator`](crate::TokenGenerator). + #[error("requested permissions not granted to this token generator: {escalated:?}")] + PermissionEscalation { + /// The requested permissions that are not granted to the generator. + escalated: Vec, + }, + /// Error when per-token permission or expiry overrides are requested for a static + /// (pre-signed) token, which cannot be re-scoped. + #[error("static tokens cannot be re-scoped with custom permissions or expiry")] + StaticTokenOverride, /// Error when URL manipulation fails. #[error("{message}")] InvalidUrl { diff --git a/clients/rust/tests/e2e.rs b/clients/rust/tests/e2e.rs index 826b1d3b..8fb0b5ac 100644 --- a/clients/rust/tests/e2e.rs +++ b/clients/rust/tests/e2e.rs @@ -287,6 +287,56 @@ async fn fails_with_insufficient_auth_token_perms() { } } +#[tokio::test] +async fn mint_token_with_rejects_escalation() { + // A read-only generator cannot mint a token requesting write access. + let client = Client::builder("http://127.0.0.1:8888/") + .token(test_token_generator().permissions(&[Permission::ObjectRead])) + .build() + .unwrap(); + let usecase = Usecase::new("usecase"); + let session = client.session(usecase.for_organization(12345)).unwrap(); + + let result = session.mint_token_with( + Some(&[Permission::ObjectRead, Permission::ObjectWrite]), + None, + ); + match result { + Err(Error::PermissionEscalation { escalated }) => { + assert_eq!(escalated, vec![Permission::ObjectWrite]); + } + other => panic!("Expected PermissionEscalation, got: {other:?}"), + } + + // A subset of the granted permissions is accepted. + assert!( + session + .mint_token_with(Some(&[Permission::ObjectRead]), Some(30)) + .unwrap() + .is_some() + ); +} + +#[tokio::test] +async fn mint_token_with_rejects_static_override() { + let token = sign_static_token("usecase", &[("org", "12345")]); + let client = Client::builder("http://127.0.0.1:8888/") + .token(token) + .build() + .unwrap(); + let usecase = Usecase::new("usecase"); + let session = client.session(usecase.for_organization(12345)).unwrap(); + + // Overrides cannot be applied to a static, pre-signed token. + assert!(matches!( + session.mint_token_with(Some(&[Permission::ObjectRead]), None), + Err(Error::StaticTokenOverride) + )); + + // Without overrides, the static token is returned as-is. + assert!(session.mint_token().unwrap().is_some()); +} + #[tokio::test] async fn stores_with_static_token() { let server = test_server().await;