Skip to content

Commit bb79ee1

Browse files
committed
feat(auth): jwt validation and auth-aware service wrapper
1 parent 6d547a6 commit bb79ee1

File tree

9 files changed

+704
-36
lines changed

9 files changed

+704
-36
lines changed

Cargo.lock

Lines changed: 431 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

objectstore-server/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ console = "0.16.1"
1919
elegant-departure = { version = "0.3.2", features = ["tokio"] }
2020
figment = { version = "0.10.19", features = ["env", "test", "yaml"] }
2121
futures-util = { workspace = true }
22+
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
2223
merni = { workspace = true }
2324
mimalloc = { workspace = true }
2425
num_cpus = "1.17.0"
@@ -36,6 +37,7 @@ secrecy = { version = "0.10.3", features = ["serde"] }
3637
serde = { workspace = true }
3738
serde_json = { workspace = true }
3839
serde_with = "3.14.1"
40+
thiserror = "2.0.17"
3941
tokio = { workspace = true, features = ["full"] }
4042
tokio-stream = { workspace = true }
4143
tower = { version = "0.5.2" }

objectstore-server/src/auth.rs

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
use axum::extract::FromRequestParts;
2+
use axum::http::{StatusCode, header, request::Parts};
3+
use objectstore_service::BackendStream;
4+
use objectstore_service::{ObjectPath, StorageService};
5+
use objectstore_types::Metadata;
6+
7+
use super::{AuthContext, AuthError, Permission};
8+
use crate::state::ServiceState;
9+
10+
pub struct AuthAwareService {
11+
service: StorageService,
12+
13+
enforce: bool,
14+
15+
auth_context: Option<AuthContext>,
16+
}
17+
18+
impl AuthAwareService {
19+
fn assert_authorized(&self, perm: Permission, path: &ObjectPath) -> anyhow::Result<()> {
20+
let auth_result = self
21+
.auth_context
22+
.as_ref()
23+
.ok_or(AuthError::InvalidToken)
24+
.and_then(|ac| ac.assert_authorized(perm, path));
25+
if self.enforce {
26+
return Ok(auth_result?);
27+
}
28+
Ok(())
29+
}
30+
31+
/// Auth-aware wrapper around [`StorageService::put_object`].
32+
pub async fn put_object(
33+
&self,
34+
path: ObjectPath,
35+
metadata: &Metadata,
36+
stream: BackendStream,
37+
) -> anyhow::Result<ObjectPath> {
38+
self.assert_authorized(Permission::ObjectWrite, &path)?;
39+
40+
self.service.put_object(path, metadata, stream).await
41+
}
42+
43+
/// Auth-aware wrapper around [`StorageService::get_object`].
44+
pub async fn get_object(
45+
&self,
46+
path: &ObjectPath,
47+
) -> anyhow::Result<Option<(Metadata, BackendStream)>> {
48+
self.assert_authorized(Permission::ObjectRead, path)?;
49+
50+
self.service.get_object(path).await
51+
}
52+
53+
/// Auth-aware wrapper around [`StorageService::delete_object`].
54+
pub async fn delete_object(&self, path: &ObjectPath) -> anyhow::Result<()> {
55+
self.assert_authorized(Permission::ObjectDelete, path)?;
56+
57+
self.service.delete_object(path).await
58+
}
59+
}
60+
61+
impl FromRequestParts<ServiceState> for AuthAwareService {
62+
type Rejection = StatusCode;
63+
64+
async fn from_request_parts(
65+
parts: &mut Parts,
66+
state: &ServiceState,
67+
) -> Result<Self, Self::Rejection> {
68+
let encoded_token = parts
69+
.headers
70+
.get(header::AUTHORIZATION)
71+
.and_then(|v| v.to_str().ok());
72+
73+
let auth_context = AuthContext::from_encoded_jwt(encoded_token, &state.config.auth);
74+
if auth_context.is_err() && state.config.auth.enforce {
75+
return Err(StatusCode::UNAUTHORIZED);
76+
}
77+
78+
Ok(AuthAwareService {
79+
service: state.service.clone(),
80+
enforce: state.config.auth.enforce,
81+
auth_context: auth_context.ok(),
82+
})
83+
}
84+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
use std::collections::HashSet;
2+
3+
use jsonwebtoken::{DecodingKey, Header, TokenData, Validation, decode, decode_header};
4+
use objectstore_service::ObjectPath;
5+
use secrecy::ExposeSecret;
6+
use serde::{Deserialize, Serialize};
7+
use thiserror::Error;
8+
9+
use crate::config::AuthZ;
10+
11+
/// Permissions that control whether different operations are authorized.
12+
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
13+
pub enum Permission {
14+
/// The permission required to read objects from objectstore.
15+
#[serde(rename = "object.read")]
16+
ObjectRead,
17+
18+
/// The permission required to write/overwrite objects in objectstore.
19+
#[serde(rename = "object.write")]
20+
ObjectWrite,
21+
22+
/// The permission required to delete objects from objectstore.
23+
#[serde(rename = "object.delete")]
24+
ObjectDelete,
25+
}
26+
27+
#[derive(Debug)]
28+
pub struct AuthContext {
29+
pub usecase: String,
30+
31+
pub scope: Vec<String>,
32+
33+
pub permissions: HashSet<Permission>,
34+
35+
pub enforce: bool,
36+
37+
_phantom: std::marker::PhantomData<()>,
38+
}
39+
40+
#[derive(Error, Debug)]
41+
pub enum AuthError {
42+
#[error("could not process JWT")]
43+
InvalidToken,
44+
45+
#[error("could not verify JWT signature")]
46+
InvalidSignature,
47+
48+
#[error("operation not allowed")]
49+
NotPermitted,
50+
}
51+
52+
#[derive(Deserialize)]
53+
struct JwtClaims {
54+
resource: Vec<String>,
55+
permissions: HashSet<Permission>,
56+
}
57+
58+
fn jwt_validation_params(jwt_header: &Header) -> Validation {
59+
let mut validation = Validation::new(jwt_header.alg);
60+
validation.set_audience(&["objectstore"]);
61+
validation.set_issuer(&["sentry", "relay"]);
62+
validation.set_required_spec_claims(&["exp"]);
63+
validation
64+
}
65+
66+
impl AuthContext {
67+
pub fn from_encoded_jwt(
68+
encoded_token: Option<&str>,
69+
config: &AuthZ,
70+
) -> Result<AuthContext, AuthError> {
71+
let Some(encoded_token) = encoded_token else {
72+
tracing::debug!("No authorization token provided");
73+
return Err(AuthError::InvalidToken);
74+
};
75+
76+
let Ok(jwt_header) = decode_header(encoded_token) else {
77+
tracing::debug!("Provided authorization token is not valid JWT");
78+
return Err(AuthError::InvalidToken);
79+
};
80+
81+
let Some(key_id) = jwt_header.kid.as_ref() else {
82+
tracing::debug!("JWT header is missing `kid` field");
83+
return Err(AuthError::InvalidToken);
84+
};
85+
86+
let Some(key_config) = config.keys.get(key_id) else {
87+
return Err(AuthError::InvalidToken);
88+
};
89+
90+
let mut verified_claims: Option<TokenData<JwtClaims>> = None;
91+
for key in &key_config.key_versions {
92+
// TODO: Certain failure reasons (e.g. token expiration) should early-exit
93+
verified_claims = decode::<JwtClaims>(
94+
encoded_token,
95+
&DecodingKey::from_secret(key.expose_secret().as_bytes()),
96+
&jwt_validation_params(&jwt_header),
97+
)
98+
.ok();
99+
if verified_claims.is_some() {
100+
break;
101+
}
102+
}
103+
104+
let Some(mut verified_claims) = verified_claims else {
105+
tracing::debug!("Failed to verify JWT with all configured keys");
106+
return Err(AuthError::InvalidSignature);
107+
};
108+
109+
// The JWT's `resource` is `{usecase}/{*scope}`
110+
let usecase = verified_claims.claims.resource.remove(0);
111+
let scope = verified_claims.claims.resource;
112+
113+
// Taking the intersection here ensures the `AuthContext` does not have any permissions
114+
// that `key_config.max_permissions` doesn't have, even if the token tried to grant them.
115+
let permissions = verified_claims
116+
.claims
117+
.permissions
118+
.intersection(&key_config.max_permissions)
119+
.cloned()
120+
.collect();
121+
122+
Ok(AuthContext {
123+
usecase,
124+
scope,
125+
permissions,
126+
enforce: config.enforce,
127+
_phantom: std::marker::PhantomData,
128+
})
129+
}
130+
131+
fn fail_if_enforced(&self, perm: &Permission, path: &ObjectPath) -> Result<(), AuthError> {
132+
tracing::debug!(?self, ?perm, ?path, "Authorization failed");
133+
if self.enforce {
134+
return Err(AuthError::NotPermitted);
135+
}
136+
Ok(())
137+
}
138+
139+
pub fn assert_authorized(&self, perm: Permission, path: &ObjectPath) -> Result<(), AuthError> {
140+
if !self.permissions.contains(&perm) {
141+
self.fail_if_enforced(&perm, path)?;
142+
}
143+
144+
// Ensures that the `AuthContext`'s scope is a prefix or exact match of the `ObjectPath`'s
145+
// scope. For example:
146+
//
147+
// Allowed:
148+
// path.scope: state.wa / city.seattle
149+
// self.scope: state.wa / city.seattle
150+
//
151+
// Allowed:
152+
// path.scope: state.wa / city.seattle
153+
// self.scope: state.wa
154+
//
155+
// Not allowed:
156+
// path.scope: state.wa / city.seattle
157+
// self.scope: state.wa / city.seattle / neighborhood.fremont
158+
//
159+
// Not allowed:
160+
// path.scope: state.wa / city.seattle
161+
// self.scope: state.mi
162+
for (auth_segment, obj_segment) in self.scope.iter().zip(path.scope.iter()) {
163+
if auth_segment != obj_segment {
164+
self.fail_if_enforced(&perm, path)?;
165+
}
166+
}
167+
168+
Ok(())
169+
}
170+
}

objectstore-server/src/auth/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
mod auth_context;
2+
pub use auth_context::*;
3+
4+
mod auth_aware_service;
5+
pub use auth_aware_service::*;

objectstore-server/src/endpoints.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::time::SystemTime;
55

66
use anyhow::Context;
77
use axum::body::Body;
8-
use axum::extract::{Path, State};
8+
use axum::extract::Path;
99
use axum::http::{HeaderMap, Method, StatusCode};
1010
use axum::response::{IntoResponse, Response};
1111
use axum::routing;
@@ -15,6 +15,7 @@ use objectstore_service::{ObjectPath, OptionalObjectPath};
1515
use objectstore_types::Metadata;
1616
use serde::Serialize;
1717

18+
use crate::auth::AuthAwareService;
1819
use crate::error::ApiResult;
1920
use crate::state::ServiceState;
2021

@@ -42,8 +43,8 @@ struct PutBlobResponse {
4243
}
4344

4445
async fn insert_object(
45-
State(state): State<ServiceState>,
4646
Path(path): Path<OptionalObjectPath>,
47+
service: AuthAwareService,
4748
method: Method,
4849
headers: HeaderMap,
4950
body: Body,
@@ -66,7 +67,7 @@ async fn insert_object(
6667
metadata.time_created = Some(SystemTime::now());
6768

6869
let stream = body.into_data_stream().map_err(io::Error::other).boxed();
69-
let response_path = state.service.put_object(path, &metadata, stream).await?;
70+
let response_path = service.put_object(path, &metadata, stream).await?;
7071
let response = Json(PutBlobResponse {
7172
key: response_path.key.to_string(),
7273
});
@@ -75,11 +76,11 @@ async fn insert_object(
7576
}
7677

7778
async fn get_object(
78-
State(state): State<ServiceState>,
79+
service: AuthAwareService,
7980
Path(path): Path<ObjectPath>,
8081
) -> ApiResult<Response> {
8182
populate_sentry_scope(&path);
82-
let Some((metadata, stream)) = state.service.get_object(&path).await? else {
83+
let Some((metadata, stream)) = service.get_object(&path).await? else {
8384
return Ok(StatusCode::NOT_FOUND.into_response());
8485
};
8586

@@ -90,12 +91,12 @@ async fn get_object(
9091
}
9192

9293
async fn delete_object(
93-
State(state): State<ServiceState>,
94+
service: AuthAwareService,
9495
Path(path): Path<ObjectPath>,
9596
) -> ApiResult<impl IntoResponse> {
9697
populate_sentry_scope(&path);
9798

98-
state.service.delete_object(&path).await?;
99+
service.delete_object(&path).await?;
99100

100101
Ok(StatusCode::NO_CONTENT)
101102
}

objectstore-service/src/backend/common.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ use crate::ObjectPath;
1414
pub const USER_AGENT: &str = concat!("sentry-objectstore/", env!("CARGO_PKG_VERSION"));
1515

1616
pub type BoxedBackend = Box<dyn Backend>;
17+
18+
/// Type alias for data streams used in [`StorageService`] and [`Backend`] APIs.
1719
pub type BackendStream = BoxStream<'static, io::Result<Bytes>>;
1820

1921
#[async_trait::async_trait]

objectstore-service/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ use std::sync::Arc;
1717
use std::sync::atomic::{AtomicU64, Ordering};
1818
use std::time::Instant;
1919

20-
use crate::backend::common::{BackendStream, BoxedBackend};
20+
use crate::backend::common::BoxedBackend;
2121

22+
pub use crate::backend::common::BackendStream;
2223
pub use path::*;
2324

2425
/// The threshold up until which we will go to the "high volume" backend.

0 commit comments

Comments
 (0)