remora/crates/remora-identity/src/roles.rs

178 lines
5.7 KiB
Rust
Raw Normal View History

//! Remora OIDC project-role grammar (`remora:v1`).
//!
//! - `remora:v1:admin` — deployment administrator
//! - `remora:v1:storage|<store_b64url>|<bucket_b64url>|<prefix_b64url>` — one storage scope
//!
//! The versioned `remora:v1:` prefix is part of every role key so generic IdP
//! roles such as `admin` never grant Remora authority. Store, bucket, and
//! prefix are unpadded base64url encodings of the UTF-8 canonical values. An
//! empty prefix means the complete bucket. Malformed roles are ignored so an
//! unrelated IdP role cannot fail authentication closed.
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use remora_domain::{BucketName, StorageScope, TargetPrefix, TargetStoreId};
use crate::error::IdentityError;
pub const ROLE_GRAMMAR_VERSION: &str = "v1";
const ROLE_NAMESPACE: &str = "remora:v1:";
pub const ADMIN_ROLE: &str = "remora:v1:admin";
const STORAGE_PREFIX: &str = "remora:v1:storage|";
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct DecodedRoles {
pub administrator: bool,
pub scopes: Vec<StorageScope>,
}
pub fn decode_roles<'a>(
roles: impl IntoIterator<Item = &'a str>,
reserved_segments: &[&str],
) -> Result<DecodedRoles, IdentityError> {
let mut administrator = false;
let mut scopes = Vec::new();
for role in roles {
if role == ADMIN_ROLE {
administrator = true;
continue;
}
if let Some(scope) = try_decode_storage_role(role, reserved_segments)?
&& !scopes.iter().any(|existing| existing == &scope)
{
scopes.push(scope);
}
}
scopes.sort_by(|left, right| {
(
left.store.as_str(),
left.bucket.as_str(),
left.prefix.canonical(),
)
.cmp(&(
right.store.as_str(),
right.bucket.as_str(),
right.prefix.canonical(),
))
});
Ok(DecodedRoles {
administrator,
scopes,
})
}
pub fn encode_admin_role() -> String {
ADMIN_ROLE.to_owned()
}
pub fn encode_storage_role(scope: &StorageScope) -> Result<String, IdentityError> {
Ok(format!(
"{STORAGE_PREFIX}{}|{}|{}",
encode_component(scope.store.as_str()),
encode_component(scope.bucket.as_str()),
encode_component(&scope.prefix.canonical()),
))
}
fn try_decode_storage_role(
role: &str,
reserved_segments: &[&str],
) -> Result<Option<StorageScope>, IdentityError> {
let Some(rest) = role.strip_prefix(STORAGE_PREFIX) else {
// Reject unversioned `storage|…` and other non-Remora names.
let _ = ROLE_NAMESPACE;
return Ok(None);
};
let parts: Vec<&str> = rest.splitn(3, '|').collect();
if parts.len() != 3 {
return Ok(None);
}
let store = match decode_component(parts[0]) {
Ok(value) => value,
Err(_) => return Ok(None),
};
let bucket = match decode_component(parts[1]) {
Ok(value) => value,
Err(_) => return Ok(None),
};
let prefix = match decode_component(parts[2]) {
Ok(value) => value,
Err(_) => return Ok(None),
};
let store = match TargetStoreId::parse(store) {
Ok(value) => value,
Err(_) => return Ok(None),
};
let bucket = match BucketName::parse(bucket) {
Ok(value) => value,
Err(_) => return Ok(None),
};
let prefix = match TargetPrefix::parse(&prefix, reserved_segments.iter().copied()) {
Ok(value) => value,
Err(_) => return Ok(None),
};
Ok(Some(StorageScope {
store,
bucket,
prefix,
}))
}
fn encode_component(value: &str) -> String {
URL_SAFE_NO_PAD.encode(value.as_bytes())
}
fn decode_component(value: &str) -> Result<String, IdentityError> {
let bytes = URL_SAFE_NO_PAD
.decode(value.as_bytes())
.map_err(|_| IdentityError::InvalidRole("invalid base64url component".into()))?;
String::from_utf8(bytes)
.map_err(|_| IdentityError::InvalidRole("role component is not UTF-8".into()))
}
#[cfg(test)]
mod tests {
use super::*;
fn scope(store: &str, bucket: &str, prefix: &str) -> StorageScope {
StorageScope {
store: TargetStoreId::parse(store).unwrap(),
bucket: BucketName::parse(bucket).unwrap(),
prefix: TargetPrefix::parse(prefix, []).unwrap(),
}
}
#[test]
fn round_trips_storage_roles_including_unicode_prefix() {
let original = scope("target-main", "target-bucket", "caf\u{e9}/data");
let encoded = encode_storage_role(&original).unwrap();
assert!(encoded.starts_with("remora:v1:storage|"));
let decoded = decode_roles([encoded.as_str()], &[]).unwrap();
assert_eq!(decoded.scopes, vec![original]);
assert!(!decoded.administrator);
}
#[test]
fn namespaced_admin_required_foreign_admin_ignored() {
let storage = encode_storage_role(&scope("s1", "bucket-one", "models")).unwrap();
let decoded = decode_roles(["admin", "test-user", storage.as_str(), "other"], &[]).unwrap();
assert!(!decoded.administrator);
assert_eq!(decoded.scopes.len(), 1);
let with_admin = decode_roles([ADMIN_ROLE, storage.as_str()], &[]).unwrap();
assert!(with_admin.administrator);
}
#[test]
fn empty_prefix_means_whole_bucket() {
let original = scope("target-main", "target-bucket", "");
let encoded = encode_storage_role(&original).unwrap();
let decoded = decode_roles([encoded.as_str()], &[]).unwrap();
assert_eq!(decoded.scopes[0].prefix.segments(), &[] as &[String]);
}
#[test]
fn unversioned_storage_role_is_ignored() {
let decoded = decode_roles(["storage|YQ|YnVja2V0|"], &[]).unwrap();
assert!(decoded.scopes.is_empty());
}
}