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
159 changes: 44 additions & 115 deletions objectstore-server/src/auth/context.rs
Original file line number Diff line number Diff line change
@@ -1,65 +1,13 @@
use std::collections::{BTreeMap, HashSet};

use jsonwebtoken::{Algorithm, DecodingKey, Header, TokenData, Validation, decode, decode_header};
use jsonwebtoken::{Algorithm, Header, TokenData, Validation, decode, decode_header};
use objectstore_service::id::ObjectContext;
use objectstore_types::Permission;
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::auth::error::AuthError;
use crate::auth::key_directory::PublicKeyDirectory;
use crate::auth::util::StringOrWildcard;
use crate::config::AuthZ;

/// `AuthContext` encapsulates the verified content of things like authorization tokens.
///
/// [`AuthContext::assert_authorized`] can be used to check whether a request is authorized to
/// perform certain operations on a given resource.
#[derive(Debug, PartialEq)]
#[non_exhaustive]
pub struct AuthContext {
/// The objectstore usecase that this request may act on.
///
/// See also: [`ObjectContext::usecase`].
pub usecase: String,

/// The scope elements that this request may act on.
///
/// See also: [`ObjectContext::scopes`].
pub scopes: BTreeMap<String, StringOrWildcard>,

/// The permissions that this request has been granted.
pub permissions: HashSet<Permission>,

/// If true, authorization checks are performed and logged but failures are suppressed.
/// If false, authorization failures result in errors.
pub enforce: bool,
}

/// Error type for different authorization failure scenarios.
#[derive(Error, Debug, PartialEq)]
pub enum AuthError {
/// Indicates that something about the request prevented authorization verification from
/// happening properly.
#[error("bad request: {0}")]
BadRequest(&'static str),

/// Indicates that something about Objectstore prevented authorization verification from
/// happening properly.
#[error("internal error: {0}")]
InternalError(String),

/// Indicates that the provided authorization token is invalid (e.g. expired or malformed).
#[error("failed to decode token: {0}")]
ValidationFailure(#[from] jsonwebtoken::errors::Error),

/// Indicates that an otherwise-valid token was unable to be verified with configured keys.
#[error("failed to verify token")]
VerificationFailure,

/// Indicates that the requested operation is not authorized and auth enforcement is enabled.
#[error("operation not allowed")]
NotPermitted,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
struct JwtRes {
Expand All @@ -84,6 +32,27 @@ fn jwt_validation_params(jwt_header: &Header) -> Validation {
validation
}

/// `AuthContext` encapsulates the verified content of things like authorization tokens.
///
/// [`AuthContext::assert_authorized`] can be used to check whether a request is authorized to
/// perform certain operations on a given resource.
#[derive(Debug, PartialEq)]
#[non_exhaustive]
pub struct AuthContext {
/// The objectstore usecase that this request may act on.
///
/// See also: [`ObjectContext::usecase`].
pub usecase: String,

/// The scope elements that this request may act on.
///
/// See also: [`ObjectContext::scopes`].
pub scopes: BTreeMap<String, StringOrWildcard>,

/// The permissions that this request has been granted.
pub permissions: HashSet<Permission>,
}

impl AuthContext {
/// Construct an `AuthContext` from an encoded JWT.
///
Expand All @@ -99,7 +68,7 @@ impl AuthContext {
/// `exp` claim field has not passed.
pub fn from_encoded_jwt(
encoded_token: Option<&str>,
config: &AuthZ,
key_directory: &PublicKeyDirectory,
) -> Result<AuthContext, AuthError> {
let encoded_token =
encoded_token.ok_or(AuthError::BadRequest("No authorization token provided"))?;
Expand All @@ -110,7 +79,7 @@ impl AuthContext {
.as_ref()
.ok_or(AuthError::BadRequest("JWT header is missing `kid` field"))?;

let key_config = config
let key_config = key_directory
.keys
.get(key_id)
.ok_or_else(|| AuthError::InternalError(format!("Key `{key_id}` not configured")))?;
Expand All @@ -125,21 +94,10 @@ impl AuthContext {
}

let mut verified_claims: Option<TokenData<JwtClaims>> = None;
for key in &key_config.key_versions {
let decoding_key = match DecodingKey::from_ed_pem(key.expose_secret().as_bytes()) {
Ok(key) => key,
Err(error) => {
tracing::error!(
error = &error as &dyn std::error::Error,
"Failed to construct decoding key from PEM",
);
continue;
}
};

for decoding_key in &key_config.key_versions {
let decode_result = decode::<JwtClaims>(
encoded_token,
&decoding_key,
decoding_key,
&jwt_validation_params(&jwt_header),
);

Expand Down Expand Up @@ -173,22 +131,9 @@ impl AuthContext {
usecase,
scopes: scope,
permissions,
enforce: config.enforce,
})
}

fn fail_if_enforced(
&self,
perm: &Permission,
context: &ObjectContext,
) -> Result<(), AuthError> {
tracing::debug!(?self, ?perm, ?context, "Authorization failed");
if self.enforce {
return Err(AuthError::NotPermitted);
}
Ok(())
}

/// Ensures that an operation requiring `perm` and applying to `path` is authorized. If not,
/// `Err(AuthError::NotPermitted)` is returned.
///
Expand All @@ -200,7 +145,8 @@ impl AuthContext {
context: &ObjectContext,
) -> Result<(), AuthError> {
if !self.permissions.contains(&perm) || self.usecase != context.usecase {
self.fail_if_enforced(&perm, context)?;
tracing::debug!(?self, ?perm, ?context, "Authorization failed");
return Err(AuthError::NotPermitted);
}

for scope in &context.scopes {
Expand All @@ -210,7 +156,8 @@ impl AuthContext {
None => false,
};
if !authorized {
self.fail_if_enforced(&perm, context)?;
tracing::debug!(?self, ?perm, ?context, "Authorization failed");
return Err(AuthError::NotPermitted);
}
}

Expand All @@ -221,9 +168,9 @@ impl AuthContext {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AuthZVerificationKey, ConfigSecret};
use crate::auth::PublicKeyConfig;
use jsonwebtoken::DecodingKey;
use objectstore_types::scope::{Scope, Scopes};
use secrecy::SecretBox;
use serde_json::json;

const TEST_SIGNING_KID: &str = "test-key";
Expand Down Expand Up @@ -253,15 +200,13 @@ MCowBQYDK2VwAyEA/TOsO19FvHFTsZqcYiO8HGfm02Df5oWBXgzulxYPvSs=
])
}

fn test_config(max_permissions: HashSet<Permission>) -> AuthZ {
let wrapped_key = SecretBox::new(Box::new(ConfigSecret::from(TEST_PUBLIC_KEY)));
let key_config = AuthZVerificationKey {
key_versions: vec![wrapped_key],
fn test_key_config(max_permissions: HashSet<Permission>) -> PublicKeyDirectory {
let public_key = PublicKeyConfig {
key_versions: vec![DecodingKey::from_ed_pem(TEST_PUBLIC_KEY.as_bytes()).unwrap()],
max_permissions,
};
AuthZ {
enforce: true,
keys: BTreeMap::from([(TEST_SIGNING_KID.into(), key_config)]),
PublicKeyDirectory {
keys: BTreeMap::from([(TEST_SIGNING_KID.into(), public_key)]),
}
}

Expand Down Expand Up @@ -302,7 +247,6 @@ MCowBQYDK2VwAyEA/TOsO19FvHFTsZqcYiO8HGfm02Df5oWBXgzulxYPvSs=
AuthContext {
usecase: "attachments".into(),
permissions,
enforce: true,
scopes: serde_json::from_value(json!({"org": org, "project": proj})).unwrap(),
}
}
Expand All @@ -314,7 +258,7 @@ MCowBQYDK2VwAyEA/TOsO19FvHFTsZqcYiO8HGfm02Df5oWBXgzulxYPvSs=
let encoded_token = sign_token(&claims, TEST_PRIVATE_KEY, None);

// Create test config with max permissions
let test_config = test_config(max_permission());
let test_config = test_key_config(max_permission());
let auth_context =
AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config)?;

Expand All @@ -333,7 +277,7 @@ MCowBQYDK2VwAyEA/TOsO19FvHFTsZqcYiO8HGfm02Df5oWBXgzulxYPvSs=

// Assign read-only permissions to the signing key in config
let ro_permission = HashSet::from([Permission::ObjectRead]);
let test_config = test_config(ro_permission.clone());
let test_config = test_key_config(ro_permission.clone());
let auth_context =
AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config)?;

Expand All @@ -350,7 +294,7 @@ MCowBQYDK2VwAyEA/TOsO19FvHFTsZqcYiO8HGfm02Df5oWBXgzulxYPvSs=
let encoded_token = "abcdef";

// Create test config with max permissions
let test_config = test_config(max_permission());
let test_config = test_key_config(max_permission());
let auth_context = AuthContext::from_encoded_jwt(Some(encoded_token), &test_config);

// Ensure the token failed verification
Expand All @@ -369,7 +313,7 @@ MC4CAQAwBQYDK2VwBCIEIKwVoE4TmTfWoqH3HgLVsEcHs9PHNe+ar/Hp6e4To8pK
let encoded_token = sign_token(&claims, unknown_key, None);

// Create test config with max permissions
let test_config = test_config(max_permission());
let test_config = test_key_config(max_permission());
let auth_context =
AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config);

Expand All @@ -389,7 +333,7 @@ MC4CAQAwBQYDK2VwBCIEIKwVoE4TmTfWoqH3HgLVsEcHs9PHNe+ar/Hp6e4To8pK
);

// Create test config with max permissions
let test_config = test_config(max_permission());
let test_config = test_key_config(max_permission());
let auth_context =
AuthContext::from_encoded_jwt(Some(encoded_token.as_str()), &test_config);

Expand Down Expand Up @@ -503,19 +447,4 @@ MC4CAQAwBQYDK2VwBCIEIKwVoE4TmTfWoqH3HgLVsEcHs9PHNe+ar/Hp6e4To8pK

Ok(())
}

#[test]
fn test_assert_authorized_passes_if_enforcement_disabled() -> Result<(), AuthError> {
// Auth context is read-only but we will try using write permissions
let mut auth_context =
sample_auth_context("123", "456", HashSet::from([Permission::ObjectRead]));
// Object's scope is not covered by the auth context
let object = sample_object_context("999", "999");

// Auth fails for two reasons, but because enforcement is off, it should not return an error
auth_context.enforce = false;
auth_context.assert_authorized(Permission::ObjectWrite, &object)?;

Ok(())
}
}
31 changes: 31 additions & 0 deletions objectstore-server/src/auth/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use thiserror::Error;

/// Error type for different authorization failure scenarios.
#[derive(Error, Debug, PartialEq)]
pub enum AuthError {
/// Indicates failure to initialize the auth system (e.g. a configured key is malformed).
#[error("failed to initialize auth system: {0}")]
InitFailure(String),

/// Indicates that something about the request prevented authorization verification from
/// happening properly.
#[error("bad request: {0}")]
BadRequest(&'static str),

/// Indicates that something about Objectstore prevented authorization verification from
/// happening properly.
#[error("internal error: {0}")]
InternalError(String),

/// Indicates that the provided authorization token is invalid (e.g. expired or malformed).
#[error("failed to decode token: {0}")]
ValidationFailure(#[from] jsonwebtoken::errors::Error),

/// Indicates that an otherwise-valid token was unable to be verified with configured keys.
#[error("failed to verify token")]
VerificationFailure,

/// Indicates that the requested operation is not authorized and auth enforcement is enabled.
#[error("operation not allowed")]
NotPermitted,
}
75 changes: 75 additions & 0 deletions objectstore-server/src/auth/key_directory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use std::collections::{BTreeMap, HashSet};
use std::path::Path;

use jsonwebtoken::DecodingKey;
use objectstore_types::Permission;

use crate::auth::AuthError;
use crate::config::{AuthZ, AuthZVerificationKey};

fn read_key_from_file(filename: &Path) -> Result<DecodingKey, AuthError> {
let key_content = std::fs::read_to_string(filename).map_err(|_| {
AuthError::InitFailure(format!("key could not be read from '{filename:?}'"))
})?;
DecodingKey::from_ed_pem(key_content.as_bytes())
.map_err(|_| AuthError::InitFailure("key could not be parsed".into()))
}

/// Configures the EdDSA public key(s) and permissions used to verify tokens from a single `kid`.
///
/// Note: [`jsonwebtoken::DecodingKey`] redacts key content in its `Debug` implementation.
#[derive(Debug)]
pub struct PublicKeyConfig {
/// Versions of this key's key material which may be used to verify signatures.
///
/// If a key is being rotated, the old and new versions of that key should both be
/// configured so objectstore can verify signatures while the updated key is still
/// rolling out. Otherwise, this should only contain the most recent version of a key.
pub key_versions: Vec<DecodingKey>,

/// The maximum set of permissions that this key's signer is authorized to grant.
///
/// If a request's `Authorization` header grants full permission but it was signed by
/// a key that is only allowed to grant read permission, then the request only has
/// read permission.
pub max_permissions: HashSet<Permission>,
}

impl TryFrom<&AuthZVerificationKey> for PublicKeyConfig {
type Error = AuthError;
fn try_from(key_config: &AuthZVerificationKey) -> Result<Self, Self::Error> {
Ok(Self {
max_permissions: key_config.max_permissions.clone(),
key_versions: key_config
.key_files
.iter()
.map(|filename| read_key_from_file(filename))
.collect::<Result<Vec<DecodingKey>, AuthError>>()?,
})
}
}

/// Directory of keys that may be used to verify a request's `Authorization` header.
///
/// This directory contains a map that is keyed on a key's ID. When verifying a JWT
/// from the `Authorization` header, the `kid` field should be read from the JWT
/// header and used to index into this directory to select the appropriate key.
#[derive(Debug)]
pub struct PublicKeyDirectory {
/// Mapping from key ID to key configuration.
pub keys: BTreeMap<String, PublicKeyConfig>,
}

impl TryFrom<&AuthZ> for PublicKeyDirectory {
type Error = AuthError;

fn try_from(auth_config: &AuthZ) -> Result<Self, Self::Error> {
Ok(Self {
keys: auth_config
.keys
.iter()
.map(|(kid, key)| Ok((kid.clone(), key.try_into()?)))
.collect::<Result<BTreeMap<String, PublicKeyConfig>, AuthError>>()?,
})
}
}
Loading