OIDC validation, principal mapping, remora:v1 roles, and current-authz filtering with kind acceptance on Keycloak and ZITADEL.
178 lines
5.7 KiB
Rust
178 lines
5.7 KiB
Rust
//! 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());
|
|
}
|
|
}
|