//! Remora OIDC project-role grammar (`remora:v1`). //! //! - `remora:v1:admin` — deployment administrator //! - `remora:v1:storage|||` — 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, } pub fn decode_roles<'a>( roles: impl IntoIterator, reserved_segments: &[&str], ) -> Result { 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 { 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, 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 { 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()); } }