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
53 changes: 10 additions & 43 deletions objectstore-server/src/auth/service.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
use axum::extract::FromRequestParts;
use axum::http::{StatusCode, header, request::Parts};
use objectstore_service::id::{ObjectContext, ObjectId};
use objectstore_service::{PayloadStream, StorageService};
use objectstore_types::{Metadata, Permission};

use crate::auth::{AuthContext, AuthError};
use crate::state::ServiceState;

const BEARER_PREFIX: &str = "Bearer ";
use crate::auth::AuthContext;

/// Wrapper around [`StorageService`] that ensures each operation is authorized.
///
Expand Down Expand Up @@ -35,16 +30,19 @@ const BEARER_PREFIX: &str = "Bearer ";
pub struct AuthAwareService {
service: StorageService,
context: Option<AuthContext>,
enforce: bool,
}

impl AuthAwareService {
/// Creates a new `AuthAwareService` using the given service and auth context.
///
/// If no auth context is provided, authorization is disabled and all operations will be
/// permitted.
pub fn new(service: StorageService, context: Option<AuthContext>) -> Self {
Self { service, context }
}

fn assert_authorized(&self, perm: Permission, context: &ObjectContext) -> anyhow::Result<()> {
if self.enforce {
let auth = self
.context
.as_ref()
.ok_or(AuthError::VerificationFailure)?;
if let Some(auth) = &self.context {
auth.assert_authorized(perm, context)?;
}

Expand Down Expand Up @@ -80,34 +78,3 @@ impl AuthAwareService {
self.service.delete_object(id).await
}
}

impl FromRequestParts<ServiceState> for AuthAwareService {
type Rejection = StatusCode;

async fn from_request_parts(
parts: &mut Parts,
state: &ServiceState,
) -> Result<Self, Self::Rejection> {
let encoded_token = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
// TODO: Handle case-insensitive bearer prefix
.and_then(|v| v.strip_prefix(BEARER_PREFIX));

let context = AuthContext::from_encoded_jwt(encoded_token, &state.config.auth);
if context.is_err() && state.config.auth.enforce {
tracing::debug!(
"Authorization failed and enforcement is enabled: `{:?}`",
context
);
return Err(StatusCode::UNAUTHORIZED);
}

Ok(AuthAwareService {
service: state.service.clone(),
enforce: state.config.auth.enforce,
context: context.ok(),
})
}
}
17 changes: 0 additions & 17 deletions objectstore-server/src/endpoints/helpers.rs

This file was deleted.

1 change: 0 additions & 1 deletion objectstore-server/src/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ use axum::Router;
use crate::state::ServiceState;

mod health;
mod helpers;
mod objects;

pub fn routes() -> Router<ServiceState> {
Expand Down
109 changes: 8 additions & 101 deletions objectstore-server/src/endpoints/objects.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
use std::borrow::Cow;
use std::io;
use std::time::SystemTime;

use anyhow::{Context, Result};
use anyhow::Context;
use axum::body::Body;
use axum::extract::Path;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing;
use axum::{Json, Router};
use futures_util::{StreamExt, TryStreamExt};
use objectstore_service::id::{ObjectContext, ObjectId};
use objectstore_types::Metadata;
use objectstore_types::scope::{Scope, Scopes};
use serde::{Deserialize, Serialize, de};
use serde::Serialize;

use crate::auth::AuthAwareService;
use crate::endpoints::helpers;
use crate::error::ApiResult;
use crate::extractors::Xt;
use crate::state::ServiceState;

/// Used in place of scopes in the URL to represent an empty set of scopes.
const EMPTY_SCOPES: &str = "_";

pub fn router() -> Router<ServiceState> {
let collection_routes = routing::post(objects_post);
let object_routes = routing::get(object_get)
Expand All @@ -45,13 +39,10 @@ pub struct InsertObjectResponse {

async fn objects_post(
service: AuthAwareService,
Path(params): Path<CollectionParams>,
Xt(context): Xt<ObjectContext>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let context = params.into_context();
helpers::populate_sentry_context(&context);

let mut metadata =
Metadata::from_headers(&headers, "").context("extracting metadata from headers")?;
metadata.time_created = Some(SystemTime::now());
Expand All @@ -67,13 +58,7 @@ async fn objects_post(
Ok((StatusCode::CREATED, response).into_response())
}

async fn object_get(
service: AuthAwareService,
Path(params): Path<ObjectParams>,
) -> ApiResult<Response> {
let id = params.into_object_id();
helpers::populate_sentry_object_id(&id);

async fn object_get(service: AuthAwareService, Xt(id): Xt<ObjectId>) -> ApiResult<Response> {
let Some((metadata, stream)) = service.get_object(&id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
Expand All @@ -84,13 +69,7 @@ async fn object_get(
Ok((headers, Body::from_stream(stream)).into_response())
}

async fn object_head(
service: AuthAwareService,
Path(params): Path<ObjectParams>,
) -> ApiResult<Response> {
let id = params.into_object_id();
helpers::populate_sentry_object_id(&id);

async fn object_head(service: AuthAwareService, Xt(id): Xt<ObjectId>) -> ApiResult<Response> {
let Some((metadata, _stream)) = service.get_object(&id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
Expand All @@ -104,13 +83,10 @@ async fn object_head(

async fn object_put(
service: AuthAwareService,
Path(params): Path<ObjectParams>,
Xt(id): Xt<ObjectId>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = params.into_object_id();
helpers::populate_sentry_object_id(&id);

let mut metadata =
Metadata::from_headers(&headers, "").context("extracting metadata from headers")?;
metadata.time_created = Some(SystemTime::now());
Expand All @@ -130,77 +106,8 @@ async fn object_put(

async fn object_delete(
service: AuthAwareService,
Path(params): Path<ObjectParams>,
Xt(id): Xt<ObjectId>,
) -> ApiResult<impl IntoResponse> {
let id = params.into_object_id();
helpers::populate_sentry_object_id(&id);

service.delete_object(&id).await?;

Ok(StatusCode::NO_CONTENT)
}

/// Path parameters used for collection-level endpoints without a key.
///
/// This is meant to be used with the axum `Path` extractor.
#[derive(Clone, Debug, Deserialize)]
struct CollectionParams {
usecase: String,
#[serde(deserialize_with = "deserialize_scopes")]
scopes: Scopes,
}

impl CollectionParams {
/// Converts the params into an [`ObjectContext`].
pub fn into_context(self) -> ObjectContext {
ObjectContext {
usecase: self.usecase,
scopes: self.scopes,
}
}
}

/// Path parameters used for object-level endpoints.
///
/// This is meant to be used with the axum `Path` extractor.
#[derive(Clone, Debug, Deserialize)]
struct ObjectParams {
usecase: String,
#[serde(deserialize_with = "deserialize_scopes")]
scopes: Scopes,
key: String,
}

impl ObjectParams {
/// Converts the params into an [`ObjectId`].
pub fn into_object_id(self) -> ObjectId {
ObjectId::from_parts(self.usecase, self.scopes, self.key)
}
}

/// Deserializes a `Scopes` instance from a string representation.
///
/// The string representation is a semicolon-separated list of `key=value` pairs, following the
/// Matrix URIs proposal. An empty scopes string (`"_"`) represents no scopes.
fn deserialize_scopes<'de, D>(deserializer: D) -> Result<Scopes, D::Error>
where
D: de::Deserializer<'de>,
{
let s = Cow::<str>::deserialize(deserializer)?;
if s == EMPTY_SCOPES {
return Ok(Scopes::empty());
}

let scopes = s
.split(';')
.map(|s| {
let (key, value) = s
.split_once("=")
.ok_or_else(|| de::Error::custom("scope must be 'key=value'"))?;

Scope::create(key, value).map_err(de::Error::custom)
})
.collect::<Result<_, _>>()?;

Ok(scopes)
}
104 changes: 104 additions & 0 deletions objectstore-server/src/extractors/id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use std::borrow::Cow;

use axum::extract::rejection::PathRejection;
use axum::extract::{FromRequestParts, Path};
use axum::http::request::Parts;
use objectstore_service::id::{ObjectContext, ObjectId};
use objectstore_types::scope::{EMPTY_SCOPES, Scope, Scopes};
use serde::{Deserialize, de};

use crate::extractors::Xt;
use crate::state::ServiceState;

impl FromRequestParts<ServiceState> for Xt<ObjectId> {
type Rejection = PathRejection;

async fn from_request_parts(
parts: &mut Parts,
state: &ServiceState,
) -> Result<Self, Self::Rejection> {
let Path(params) = Path::<ObjectParams>::from_request_parts(parts, state).await?;
let id = ObjectId::from_parts(params.usecase, params.scopes, params.key);

populate_sentry_context(id.context());
sentry::configure_scope(|s| s.set_extra("key", id.key().into()));

Ok(Xt(id))
}
}

/// Path parameters used for object-level endpoints.
///
/// This is meant to be used with the axum `Path` extractor.
#[derive(Clone, Debug, Deserialize)]
struct ObjectParams {
usecase: String,
#[serde(deserialize_with = "deserialize_scopes")]
scopes: Scopes,
key: String,
}

/// Deserializes a `Scopes` instance from a string representation.
///
/// The string representation is a semicolon-separated list of `key=value` pairs, following the
/// Matrix URIs proposal. An empty scopes string (`"_"`) represents no scopes.
fn deserialize_scopes<'de, D>(deserializer: D) -> Result<Scopes, D::Error>
where
D: de::Deserializer<'de>,
{
let s = Cow::<str>::deserialize(deserializer)?;
if s == EMPTY_SCOPES {
return Ok(Scopes::empty());
}

let scopes = s
.split(';')
.map(|s| {
let (key, value) = s
.split_once("=")
.ok_or_else(|| de::Error::custom("scope must be 'key=value'"))?;

Scope::create(key, value).map_err(de::Error::custom)
})
.collect::<Result<_, _>>()?;

Ok(scopes)
}

impl FromRequestParts<ServiceState> for Xt<ObjectContext> {
type Rejection = PathRejection;

async fn from_request_parts(
parts: &mut Parts,
state: &ServiceState,
) -> Result<Self, Self::Rejection> {
let Path(params) = Path::<ContextParams>::from_request_parts(parts, state).await?;
let context = ObjectContext {
usecase: params.usecase,
scopes: params.scopes,
};

populate_sentry_context(&context);

Ok(Xt(context))
}
}

/// Path parameters used for collection-level endpoints without a key.
///
/// This is meant to be used with the axum `Path` extractor.
#[derive(Clone, Debug, Deserialize)]
struct ContextParams {
usecase: String,
#[serde(deserialize_with = "deserialize_scopes")]
scopes: Scopes,
}

fn populate_sentry_context(context: &ObjectContext) {
sentry::configure_scope(|s| {
s.set_tag("usecase", &context.usecase);
for scope in &context.scopes {
s.set_tag(&format!("scope.{}", scope.name()), scope.value());
}
});
}
10 changes: 10 additions & 0 deletions objectstore-server/src/extractors/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
mod id;
mod service;

/// An extractor for a remote type.
///
/// This is a helper type that allows extracting a type `T` from a request, where `T` is defined in
/// another crate. There must be an implementation of `FromRequestParts` or `FromRequest` for
/// `Xt<T>` for this to work.
#[derive(Debug)]
pub struct Xt<T>(pub T);
Loading