Add identity and target authorization package
OIDC validation, principal mapping, remora:v1 roles, and current-authz filtering with kind acceptance on Keycloak and ZITADEL.
This commit is contained in:
parent
91ac6dee85
commit
bf028161ae
1 changed files with 3153 additions and 21 deletions
178
crates/remora-identity/src/roles.rs
Normal file
178
crates/remora-identity/src/roles.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue