OIDC validation, principal mapping, remora:v1 roles, and current-authz filtering with kind acceptance on Keycloak and ZITADEL.
289 lines
8.9 KiB
Rust
289 lines
8.9 KiB
Rust
use remora_domain::{BucketName, OpaqueId, StorageScope, Target, TargetPrefix, TargetStoreId};
|
|
use remora_identity::{
|
|
AuthorizedResource, DefaultTarget, IdentityError, ManagementOperation, ResourceVisibility,
|
|
TargetOverride, can_target, decode_roles, encode_admin_role, encode_storage_role,
|
|
filter_visible, may_manage, may_request_execution_grant_revocation, parse_default_target,
|
|
principal_id_for, resolve_authorized_target, resolve_target, resource_visibility,
|
|
roles_from_names,
|
|
};
|
|
|
|
fn scope(store: &str, bucket: &str, prefix: &str) -> StorageScope {
|
|
StorageScope {
|
|
store: TargetStoreId::parse(store).unwrap(),
|
|
bucket: BucketName::parse(bucket).unwrap(),
|
|
prefix: TargetPrefix::parse(prefix, ["_remora"]).unwrap(),
|
|
}
|
|
}
|
|
|
|
fn target(store: &str, bucket: &str, prefix: &str) -> Target {
|
|
Target {
|
|
store: TargetStoreId::parse(store).unwrap(),
|
|
bucket: BucketName::parse(bucket).unwrap(),
|
|
prefix: TargetPrefix::parse(prefix, ["_remora"]).unwrap(),
|
|
}
|
|
}
|
|
|
|
fn principal(
|
|
label: &str,
|
|
administrator: bool,
|
|
scopes: Vec<StorageScope>,
|
|
) -> remora_identity::Principal {
|
|
remora_identity::Principal {
|
|
id: OpaqueId::parse(format!("principal-{label}-aaaa")).unwrap(),
|
|
issuer: format!("https://issuer.example/{label}"),
|
|
subject: format!("subject-{label}"),
|
|
administrator,
|
|
scopes,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scope_segment_matching_rejects_textual_prefix_coincidence() {
|
|
let owner = principal(
|
|
"owner",
|
|
false,
|
|
vec![scope("target-main", "target-bucket", "research")],
|
|
);
|
|
assert!(can_target(
|
|
&owner,
|
|
&target("target-main", "target-bucket", "research/data")
|
|
));
|
|
assert!(!can_target(
|
|
&owner,
|
|
&target("target-main", "target-bucket", "researchers")
|
|
));
|
|
assert!(!can_target(
|
|
&owner,
|
|
&target("target-main", "target-bucket", "researc")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn unicode_prefix_nfc_canonicalization_and_reserved_segments() {
|
|
let defaults = parse_default_target("target-main", "target-bucket", "", &["_remora"]).unwrap();
|
|
let resolved = resolve_target(
|
|
&defaults,
|
|
&TargetOverride {
|
|
bucket: None,
|
|
prefix: Some("cafe\u{0301}/models".into()),
|
|
},
|
|
&["_remora"],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(resolved.prefix.canonical(), "caf\u{e9}/models");
|
|
|
|
let reserved = resolve_target(
|
|
&defaults,
|
|
&TargetOverride {
|
|
bucket: None,
|
|
prefix: Some("_remora/private".into()),
|
|
},
|
|
&["_remora"],
|
|
);
|
|
assert!(reserved.is_err());
|
|
|
|
let category_c = resolve_target(
|
|
&defaults,
|
|
&TargetOverride {
|
|
bucket: None,
|
|
prefix: Some("private/\u{e000}".into()),
|
|
},
|
|
&["_remora"],
|
|
);
|
|
assert!(category_c.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn default_target_applies_bucket_and_prefix_overrides() {
|
|
let defaults = DefaultTarget {
|
|
store: TargetStoreId::parse("target-main").unwrap(),
|
|
bucket: BucketName::parse("default-bucket").unwrap(),
|
|
prefix: TargetPrefix::parse("defaults", []).unwrap(),
|
|
};
|
|
let full_default = resolve_target(&defaults, &TargetOverride::default(), &[]).unwrap();
|
|
assert_eq!(full_default.bucket.as_str(), "default-bucket");
|
|
assert_eq!(full_default.prefix.canonical(), "defaults");
|
|
|
|
let overridden = resolve_target(
|
|
&defaults,
|
|
&TargetOverride {
|
|
bucket: Some("other-bucket".into()),
|
|
prefix: Some("models/current/".into()),
|
|
},
|
|
&[],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(overridden.store.as_str(), "target-main");
|
|
assert_eq!(overridden.bucket.as_str(), "other-bucket");
|
|
assert_eq!(overridden.prefix.canonical(), "models/current");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_authorized_target_requires_covering_scope() {
|
|
let defaults = DefaultTarget {
|
|
store: TargetStoreId::parse("target-main").unwrap(),
|
|
bucket: BucketName::parse("target-bucket").unwrap(),
|
|
prefix: TargetPrefix::parse("defaults", []).unwrap(),
|
|
};
|
|
let scoped = principal(
|
|
"scoped",
|
|
false,
|
|
vec![scope("target-main", "target-bucket", "models")],
|
|
);
|
|
let ok = resolve_authorized_target(
|
|
&scoped,
|
|
&defaults,
|
|
&TargetOverride {
|
|
bucket: None,
|
|
prefix: Some("models/current".into()),
|
|
},
|
|
&[],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(ok.prefix.canonical(), "models/current");
|
|
|
|
let denied = resolve_authorized_target(
|
|
&scoped,
|
|
&defaults,
|
|
&TargetOverride {
|
|
bucket: None,
|
|
prefix: Some("other".into()),
|
|
},
|
|
&[],
|
|
);
|
|
assert!(matches!(denied, Err(IdentityError::UnauthorizedTarget)));
|
|
|
|
let admin = principal("admin", true, vec![]);
|
|
let admin_ok = resolve_authorized_target(
|
|
&admin,
|
|
&defaults,
|
|
&TargetOverride {
|
|
bucket: Some("any-bucket".into()),
|
|
prefix: Some("anywhere".into()),
|
|
},
|
|
&[],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(admin_ok.bucket.as_str(), "any-bucket");
|
|
}
|
|
|
|
#[test]
|
|
fn revocation_of_roles_removes_current_management_not_admin_revoke_for_owners() {
|
|
let target = target("target-main", "target-bucket", "models/current");
|
|
let resource = AuthorizedResource {
|
|
owner: OpaqueId::parse("principal-owner-aaaa").unwrap(),
|
|
target: target.clone(),
|
|
};
|
|
let with_scope = principal(
|
|
"owner",
|
|
false,
|
|
vec![scope("target-main", "target-bucket", "models")],
|
|
);
|
|
assert!(may_manage(
|
|
&with_scope,
|
|
&resource,
|
|
ManagementOperation::Cancel
|
|
));
|
|
assert!(!may_request_execution_grant_revocation(&with_scope));
|
|
|
|
let after_role_loss = principal("owner", false, vec![]);
|
|
assert!(!may_manage(
|
|
&after_role_loss,
|
|
&resource,
|
|
ManagementOperation::Read
|
|
));
|
|
assert_eq!(
|
|
resource_visibility(&after_role_loss, Some(&resource), ManagementOperation::Read),
|
|
ResourceVisibility::NotFound
|
|
);
|
|
|
|
let admin = principal("admin", true, vec![]);
|
|
assert!(may_manage(
|
|
&admin,
|
|
&resource,
|
|
ManagementOperation::HardDelete
|
|
));
|
|
assert!(may_request_execution_grant_revocation(&admin));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_and_unauthorized_resources_are_indistinguishable() {
|
|
let owner = principal(
|
|
"owner",
|
|
false,
|
|
vec![scope("target-main", "target-bucket", "")],
|
|
);
|
|
let other = principal(
|
|
"other",
|
|
false,
|
|
vec![scope("target-main", "target-bucket", "")],
|
|
);
|
|
let resource = AuthorizedResource {
|
|
owner: owner.id.clone(),
|
|
target: target("target-main", "target-bucket", "models"),
|
|
};
|
|
|
|
let missing = resource_visibility(&owner, None, ManagementOperation::Read);
|
|
let unauthorized = resource_visibility(&other, Some(&resource), ManagementOperation::Read);
|
|
assert_eq!(missing, ResourceVisibility::NotFound);
|
|
assert_eq!(unauthorized, ResourceVisibility::NotFound);
|
|
assert_eq!(missing, unauthorized);
|
|
|
|
let visible = resource_visibility(&owner, Some(&resource), ManagementOperation::Read);
|
|
assert_eq!(visible, ResourceVisibility::Visible);
|
|
}
|
|
|
|
#[test]
|
|
fn list_filtering_hides_unauthorized_existence() {
|
|
let owner = principal(
|
|
"owner",
|
|
false,
|
|
vec![scope("target-main", "target-bucket", "models")],
|
|
);
|
|
let mine = AuthorizedResource {
|
|
owner: owner.id.clone(),
|
|
target: target("target-main", "target-bucket", "models/a"),
|
|
};
|
|
let outside = AuthorizedResource {
|
|
owner: owner.id.clone(),
|
|
target: target("target-main", "target-bucket", "other"),
|
|
};
|
|
let peer = AuthorizedResource {
|
|
owner: OpaqueId::parse("principal-peer-aaaaaa").unwrap(),
|
|
target: target("target-main", "target-bucket", "models/b"),
|
|
};
|
|
let visible = filter_visible(&owner, [&mine, &outside, &peer]);
|
|
assert_eq!(visible, vec![&mine]);
|
|
}
|
|
|
|
#[test]
|
|
fn principal_ids_are_stable_for_issuer_subject_pairs() {
|
|
let first = principal_id_for("https://issuer.example", "user-1").unwrap();
|
|
let second = principal_id_for("https://issuer.example", "user-1").unwrap();
|
|
let other = principal_id_for("https://issuer.example", "user-2").unwrap();
|
|
assert_eq!(first, second);
|
|
assert_ne!(first, other);
|
|
}
|
|
|
|
#[test]
|
|
fn role_encoding_round_trip_and_foreign_roles_ignored() {
|
|
let storage =
|
|
encode_storage_role(&scope("target-main", "target-bucket", "models/current")).unwrap();
|
|
let decoded = decode_roles(
|
|
[encode_admin_role().as_str(), "test-user", storage.as_str()],
|
|
&["_remora"],
|
|
)
|
|
.unwrap();
|
|
assert!(decoded.administrator);
|
|
assert_eq!(decoded.scopes.len(), 1);
|
|
assert!(decoded.scopes[0].covers(&target(
|
|
"target-main",
|
|
"target-bucket",
|
|
"models/current/file"
|
|
)));
|
|
|
|
let roles = roles_from_names(&[remora_identity::ADMIN_ROLE], &[]).unwrap();
|
|
assert!(roles.administrator);
|
|
let bare = roles_from_names(&["admin"], &[]).unwrap();
|
|
assert!(!bare.administrator);
|
|
}
|