Skip to content

Commit e9a6e0f

Browse files
authored
ref(server): Create extractors for params (#238)
This creates a new module in `objectstore-server` that contains all extractors we need for our web API. The extractor for the auth-aware service has been moved into this module. This gives us a cleaner separation between the HTTP layer and the auth logic, which would even allow us to move that wrapper into a different crate. Additionally, there are minor changes to it: - The service no longer has an explicit `enforce` flag. This is inferred from `Option` and documented on its new constructor. - We now support case-insensitive parsing of the bearer prefix. There are also two new extractors for `ObjectId` and `ObjectContext`. Endpoints no longer have to use the Params types directly and also do not have to deal with configuring the Sentry scope. This change is a preparation for introducing killswitches.
1 parent 0ad3d0e commit e9a6e0f

File tree

9 files changed

+203
-165
lines changed

9 files changed

+203
-165
lines changed

objectstore-server/src/auth/service.rs

Lines changed: 10 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
use axum::extract::FromRequestParts;
2-
use axum::http::{StatusCode, header, request::Parts};
31
use objectstore_service::id::{ObjectContext, ObjectId};
42
use objectstore_service::{PayloadStream, StorageService};
53
use objectstore_types::{Metadata, Permission};
64

7-
use crate::auth::{AuthContext, AuthError};
8-
use crate::state::ServiceState;
9-
10-
const BEARER_PREFIX: &str = "Bearer ";
5+
use crate::auth::AuthContext;
116

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

4135
impl AuthAwareService {
36+
/// Creates a new `AuthAwareService` using the given service and auth context.
37+
///
38+
/// If no auth context is provided, authorization is disabled and all operations will be
39+
/// permitted.
40+
pub fn new(service: StorageService, context: Option<AuthContext>) -> Self {
41+
Self { service, context }
42+
}
43+
4244
fn assert_authorized(&self, perm: Permission, context: &ObjectContext) -> anyhow::Result<()> {
43-
if self.enforce {
44-
let auth = self
45-
.context
46-
.as_ref()
47-
.ok_or(AuthError::VerificationFailure)?;
45+
if let Some(auth) = &self.context {
4846
auth.assert_authorized(perm, context)?;
4947
}
5048

@@ -80,34 +78,3 @@ impl AuthAwareService {
8078
self.service.delete_object(id).await
8179
}
8280
}
83-
84-
impl FromRequestParts<ServiceState> for AuthAwareService {
85-
type Rejection = StatusCode;
86-
87-
async fn from_request_parts(
88-
parts: &mut Parts,
89-
state: &ServiceState,
90-
) -> Result<Self, Self::Rejection> {
91-
let encoded_token = parts
92-
.headers
93-
.get(header::AUTHORIZATION)
94-
.and_then(|v| v.to_str().ok())
95-
// TODO: Handle case-insensitive bearer prefix
96-
.and_then(|v| v.strip_prefix(BEARER_PREFIX));
97-
98-
let context = AuthContext::from_encoded_jwt(encoded_token, &state.config.auth);
99-
if context.is_err() && state.config.auth.enforce {
100-
tracing::debug!(
101-
"Authorization failed and enforcement is enabled: `{:?}`",
102-
context
103-
);
104-
return Err(StatusCode::UNAUTHORIZED);
105-
}
106-
107-
Ok(AuthAwareService {
108-
service: state.service.clone(),
109-
enforce: state.config.auth.enforce,
110-
context: context.ok(),
111-
})
112-
}
113-
}

objectstore-server/src/endpoints/helpers.rs

Lines changed: 0 additions & 17 deletions
This file was deleted.

objectstore-server/src/endpoints/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ use axum::Router;
77
use crate::state::ServiceState;
88

99
mod health;
10-
mod helpers;
1110
mod objects;
1211

1312
pub fn routes() -> Router<ServiceState> {
Lines changed: 8 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,22 @@
1-
use std::borrow::Cow;
21
use std::io;
32
use std::time::SystemTime;
43

5-
use anyhow::{Context, Result};
4+
use anyhow::Context;
65
use axum::body::Body;
7-
use axum::extract::Path;
86
use axum::http::{HeaderMap, StatusCode};
97
use axum::response::{IntoResponse, Response};
108
use axum::routing;
119
use axum::{Json, Router};
1210
use futures_util::{StreamExt, TryStreamExt};
1311
use objectstore_service::id::{ObjectContext, ObjectId};
1412
use objectstore_types::Metadata;
15-
use objectstore_types::scope::{Scope, Scopes};
16-
use serde::{Deserialize, Serialize, de};
13+
use serde::Serialize;
1714

1815
use crate::auth::AuthAwareService;
19-
use crate::endpoints::helpers;
2016
use crate::error::ApiResult;
17+
use crate::extractors::Xt;
2118
use crate::state::ServiceState;
2219

23-
/// Used in place of scopes in the URL to represent an empty set of scopes.
24-
const EMPTY_SCOPES: &str = "_";
25-
2620
pub fn router() -> Router<ServiceState> {
2721
let collection_routes = routing::post(objects_post);
2822
let object_routes = routing::get(object_get)
@@ -45,13 +39,10 @@ pub struct InsertObjectResponse {
4539

4640
async fn objects_post(
4741
service: AuthAwareService,
48-
Path(params): Path<CollectionParams>,
42+
Xt(context): Xt<ObjectContext>,
4943
headers: HeaderMap,
5044
body: Body,
5145
) -> ApiResult<Response> {
52-
let context = params.into_context();
53-
helpers::populate_sentry_context(&context);
54-
5546
let mut metadata =
5647
Metadata::from_headers(&headers, "").context("extracting metadata from headers")?;
5748
metadata.time_created = Some(SystemTime::now());
@@ -67,13 +58,7 @@ async fn objects_post(
6758
Ok((StatusCode::CREATED, response).into_response())
6859
}
6960

70-
async fn object_get(
71-
service: AuthAwareService,
72-
Path(params): Path<ObjectParams>,
73-
) -> ApiResult<Response> {
74-
let id = params.into_object_id();
75-
helpers::populate_sentry_object_id(&id);
76-
61+
async fn object_get(service: AuthAwareService, Xt(id): Xt<ObjectId>) -> ApiResult<Response> {
7762
let Some((metadata, stream)) = service.get_object(&id).await? else {
7863
return Ok(StatusCode::NOT_FOUND.into_response());
7964
};
@@ -84,13 +69,7 @@ async fn object_get(
8469
Ok((headers, Body::from_stream(stream)).into_response())
8570
}
8671

87-
async fn object_head(
88-
service: AuthAwareService,
89-
Path(params): Path<ObjectParams>,
90-
) -> ApiResult<Response> {
91-
let id = params.into_object_id();
92-
helpers::populate_sentry_object_id(&id);
93-
72+
async fn object_head(service: AuthAwareService, Xt(id): Xt<ObjectId>) -> ApiResult<Response> {
9473
let Some((metadata, _stream)) = service.get_object(&id).await? else {
9574
return Ok(StatusCode::NOT_FOUND.into_response());
9675
};
@@ -104,13 +83,10 @@ async fn object_head(
10483

10584
async fn object_put(
10685
service: AuthAwareService,
107-
Path(params): Path<ObjectParams>,
86+
Xt(id): Xt<ObjectId>,
10887
headers: HeaderMap,
10988
body: Body,
11089
) -> ApiResult<Response> {
111-
let id = params.into_object_id();
112-
helpers::populate_sentry_object_id(&id);
113-
11490
let mut metadata =
11591
Metadata::from_headers(&headers, "").context("extracting metadata from headers")?;
11692
metadata.time_created = Some(SystemTime::now());
@@ -130,77 +106,8 @@ async fn object_put(
130106

131107
async fn object_delete(
132108
service: AuthAwareService,
133-
Path(params): Path<ObjectParams>,
109+
Xt(id): Xt<ObjectId>,
134110
) -> ApiResult<impl IntoResponse> {
135-
let id = params.into_object_id();
136-
helpers::populate_sentry_object_id(&id);
137-
138111
service.delete_object(&id).await?;
139-
140112
Ok(StatusCode::NO_CONTENT)
141113
}
142-
143-
/// Path parameters used for collection-level endpoints without a key.
144-
///
145-
/// This is meant to be used with the axum `Path` extractor.
146-
#[derive(Clone, Debug, Deserialize)]
147-
struct CollectionParams {
148-
usecase: String,
149-
#[serde(deserialize_with = "deserialize_scopes")]
150-
scopes: Scopes,
151-
}
152-
153-
impl CollectionParams {
154-
/// Converts the params into an [`ObjectContext`].
155-
pub fn into_context(self) -> ObjectContext {
156-
ObjectContext {
157-
usecase: self.usecase,
158-
scopes: self.scopes,
159-
}
160-
}
161-
}
162-
163-
/// Path parameters used for object-level endpoints.
164-
///
165-
/// This is meant to be used with the axum `Path` extractor.
166-
#[derive(Clone, Debug, Deserialize)]
167-
struct ObjectParams {
168-
usecase: String,
169-
#[serde(deserialize_with = "deserialize_scopes")]
170-
scopes: Scopes,
171-
key: String,
172-
}
173-
174-
impl ObjectParams {
175-
/// Converts the params into an [`ObjectId`].
176-
pub fn into_object_id(self) -> ObjectId {
177-
ObjectId::from_parts(self.usecase, self.scopes, self.key)
178-
}
179-
}
180-
181-
/// Deserializes a `Scopes` instance from a string representation.
182-
///
183-
/// The string representation is a semicolon-separated list of `key=value` pairs, following the
184-
/// Matrix URIs proposal. An empty scopes string (`"_"`) represents no scopes.
185-
fn deserialize_scopes<'de, D>(deserializer: D) -> Result<Scopes, D::Error>
186-
where
187-
D: de::Deserializer<'de>,
188-
{
189-
let s = Cow::<str>::deserialize(deserializer)?;
190-
if s == EMPTY_SCOPES {
191-
return Ok(Scopes::empty());
192-
}
193-
194-
let scopes = s
195-
.split(';')
196-
.map(|s| {
197-
let (key, value) = s
198-
.split_once("=")
199-
.ok_or_else(|| de::Error::custom("scope must be 'key=value'"))?;
200-
201-
Scope::create(key, value).map_err(de::Error::custom)
202-
})
203-
.collect::<Result<_, _>>()?;
204-
205-
Ok(scopes)
206-
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
use std::borrow::Cow;
2+
3+
use axum::extract::rejection::PathRejection;
4+
use axum::extract::{FromRequestParts, Path};
5+
use axum::http::request::Parts;
6+
use objectstore_service::id::{ObjectContext, ObjectId};
7+
use objectstore_types::scope::{EMPTY_SCOPES, Scope, Scopes};
8+
use serde::{Deserialize, de};
9+
10+
use crate::extractors::Xt;
11+
use crate::state::ServiceState;
12+
13+
impl FromRequestParts<ServiceState> for Xt<ObjectId> {
14+
type Rejection = PathRejection;
15+
16+
async fn from_request_parts(
17+
parts: &mut Parts,
18+
state: &ServiceState,
19+
) -> Result<Self, Self::Rejection> {
20+
let Path(params) = Path::<ObjectParams>::from_request_parts(parts, state).await?;
21+
let id = ObjectId::from_parts(params.usecase, params.scopes, params.key);
22+
23+
populate_sentry_context(id.context());
24+
sentry::configure_scope(|s| s.set_extra("key", id.key().into()));
25+
26+
Ok(Xt(id))
27+
}
28+
}
29+
30+
/// Path parameters used for object-level endpoints.
31+
///
32+
/// This is meant to be used with the axum `Path` extractor.
33+
#[derive(Clone, Debug, Deserialize)]
34+
struct ObjectParams {
35+
usecase: String,
36+
#[serde(deserialize_with = "deserialize_scopes")]
37+
scopes: Scopes,
38+
key: String,
39+
}
40+
41+
/// Deserializes a `Scopes` instance from a string representation.
42+
///
43+
/// The string representation is a semicolon-separated list of `key=value` pairs, following the
44+
/// Matrix URIs proposal. An empty scopes string (`"_"`) represents no scopes.
45+
fn deserialize_scopes<'de, D>(deserializer: D) -> Result<Scopes, D::Error>
46+
where
47+
D: de::Deserializer<'de>,
48+
{
49+
let s = Cow::<str>::deserialize(deserializer)?;
50+
if s == EMPTY_SCOPES {
51+
return Ok(Scopes::empty());
52+
}
53+
54+
let scopes = s
55+
.split(';')
56+
.map(|s| {
57+
let (key, value) = s
58+
.split_once("=")
59+
.ok_or_else(|| de::Error::custom("scope must be 'key=value'"))?;
60+
61+
Scope::create(key, value).map_err(de::Error::custom)
62+
})
63+
.collect::<Result<_, _>>()?;
64+
65+
Ok(scopes)
66+
}
67+
68+
impl FromRequestParts<ServiceState> for Xt<ObjectContext> {
69+
type Rejection = PathRejection;
70+
71+
async fn from_request_parts(
72+
parts: &mut Parts,
73+
state: &ServiceState,
74+
) -> Result<Self, Self::Rejection> {
75+
let Path(params) = Path::<ContextParams>::from_request_parts(parts, state).await?;
76+
let context = ObjectContext {
77+
usecase: params.usecase,
78+
scopes: params.scopes,
79+
};
80+
81+
populate_sentry_context(&context);
82+
83+
Ok(Xt(context))
84+
}
85+
}
86+
87+
/// Path parameters used for collection-level endpoints without a key.
88+
///
89+
/// This is meant to be used with the axum `Path` extractor.
90+
#[derive(Clone, Debug, Deserialize)]
91+
struct ContextParams {
92+
usecase: String,
93+
#[serde(deserialize_with = "deserialize_scopes")]
94+
scopes: Scopes,
95+
}
96+
97+
fn populate_sentry_context(context: &ObjectContext) {
98+
sentry::configure_scope(|s| {
99+
s.set_tag("usecase", &context.usecase);
100+
for scope in &context.scopes {
101+
s.set_tag(&format!("scope.{}", scope.name()), scope.value());
102+
}
103+
});
104+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
mod id;
2+
mod service;
3+
4+
/// An extractor for a remote type.
5+
///
6+
/// This is a helper type that allows extracting a type `T` from a request, where `T` is defined in
7+
/// another crate. There must be an implementation of `FromRequestParts` or `FromRequest` for
8+
/// `Xt<T>` for this to work.
9+
#[derive(Debug)]
10+
pub struct Xt<T>(pub T);

0 commit comments

Comments
 (0)