Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 108 additions & 2 deletions src/client/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ use crate::types::*;

use super::Client;

/// Validates that a required string field is non-empty and non-whitespace.
fn validate_non_empty(field: &str, value: &str) -> Result<(), Error> {
if value.trim().is_empty() {
return Err(Error::InvalidArgument(format!(
"{field} must not be empty"
)));
}
Ok(())
}

// ── CheckPermission ──────────────────────────────────────────────

/// Builder for a CheckPermission request.
Expand Down Expand Up @@ -60,6 +70,8 @@ impl<'a> std::future::IntoFuture for CheckPermissionRequest<'a> {

fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
validate_non_empty("permission", &self.permission)?;

Comment on lines 71 to +74
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same trim().is_empty() validation (with identical error strings) is now duplicated across multiple request builders in this module. Consider factoring this into a small helper (e.g., validate_non_empty(field, value)) to avoid message drift and to keep future validations consistent.

Copilot uses AI. Check for mistakes.
let proto_req = proto::CheckPermissionRequest {
consistency: self.consistency,
resource: Some(self.resource),
Expand Down Expand Up @@ -279,6 +291,8 @@ impl<'a> LookupResourcesRequest<'a> {
pub async fn send(
self,
) -> Result<impl Stream<Item = Result<LookupResourceResult, Error>>, Error> {
validate_non_empty("resource_type", &self.resource_type)?;
validate_non_empty("permission", &self.permission)?;
let client = self.client;
let (proto_req, timeout) = self.to_request_parts();

Expand Down Expand Up @@ -371,6 +385,8 @@ impl<'a> LookupSubjectsRequest<'a> {
pub async fn send(
self,
) -> Result<impl Stream<Item = Result<LookupSubjectResult, Error>>, Error> {
validate_non_empty("permission", &self.permission)?;
validate_non_empty("subject_type", &self.subject_type)?;
let client = self.client;
let (proto_req, timeout) = self.to_request_parts();

Expand Down Expand Up @@ -511,6 +527,8 @@ impl<'a> std::future::IntoFuture for ExpandPermissionTreeRequest<'a> {

fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
validate_non_empty("permission", &self.permission)?;

let proto_req = proto::ExpandPermissionTreeRequest {
consistency: self.consistency,
resource: Some(self.resource),
Expand Down Expand Up @@ -682,6 +700,94 @@ mod tests {
SubjectReference::new(ObjectReference::new("user", id).unwrap(), None::<String>).unwrap()
}

#[tokio::test]
async fn check_permission_empty_permission_rejected() {
let c = test_client();
let resource = ObjectReference::new("document", "doc1").unwrap();
let subject = SubjectReference::new(
ObjectReference::new("user", "alice").unwrap(),
None::<String>,
)
.unwrap();
let err = c.check_permission(&resource, "", &subject).await.unwrap_err();
assert!(matches!(err, Error::InvalidArgument(_)));
}

#[tokio::test]
async fn check_permission_whitespace_permission_rejected() {
let c = test_client();
let resource = ObjectReference::new("document", "doc1").unwrap();
let subject = SubjectReference::new(
ObjectReference::new("user", "alice").unwrap(),
None::<String>,
)
.unwrap();
let err = c.check_permission(&resource, " ", &subject).await.unwrap_err();
assert!(matches!(err, Error::InvalidArgument(_)));
}

#[tokio::test]
async fn lookup_resources_empty_permission_rejected() {
let c = test_client();
let subject = SubjectReference::new(
ObjectReference::new("user", "alice").unwrap(),
None::<String>,
)
.unwrap();
let result = c.lookup_resources("document", "", &subject).send().await;
assert!(matches!(result, Err(Error::InvalidArgument(_))));
}

#[tokio::test]
async fn lookup_resources_empty_resource_type_rejected() {
let c = test_client();
let subject = SubjectReference::new(
ObjectReference::new("user", "alice").unwrap(),
None::<String>,
)
.unwrap();
let result = c.lookup_resources("", "view", &subject).send().await;
assert!(matches!(result, Err(Error::InvalidArgument(_))));
}

#[tokio::test]
async fn lookup_subjects_empty_permission_rejected() {
let c = test_client();
let resource = ObjectReference::new("document", "doc1").unwrap();
let result = c.lookup_subjects(&resource, "", "user").send().await;
assert!(matches!(result, Err(Error::InvalidArgument(_))));
}

#[tokio::test]
async fn lookup_subjects_empty_subject_type_rejected() {
let c = test_client();
let resource = ObjectReference::new("document", "doc1").unwrap();
let result = c.lookup_subjects(&resource, "view", "").send().await;
assert!(matches!(result, Err(Error::InvalidArgument(_))));
}

#[tokio::test]
async fn expand_permission_tree_empty_permission_rejected() {
let c = test_client();
let resource = ObjectReference::new("document", "doc1").unwrap();
let err = c
.expand_permission_tree(&resource, "")
.await
.unwrap_err();
assert!(matches!(err, Error::InvalidArgument(_)));
}

#[tokio::test]
async fn expand_permission_tree_whitespace_permission_rejected() {
let c = test_client();
let resource = ObjectReference::new("document", "doc1").unwrap();
let err = c
.expand_permission_tree(&resource, " ")
.await
.unwrap_err();
assert!(matches!(err, Error::InvalidArgument(_)));
}

#[tokio::test]
async fn lookup_resources_pagination_defaults() {
let client = test_client();
Expand Down Expand Up @@ -749,7 +855,7 @@ mod tests {
#[tokio::test]
async fn read_relationships_pagination_defaults() {
let client = test_client();
let filter = RelationshipFilter::new("document").resource_id("rel1");
let filter = RelationshipFilter::new("document").unwrap().resource_id("rel1");

let (proto_req, timeout) = client
.read_relationships(filter)
Expand All @@ -763,7 +869,7 @@ mod tests {
#[tokio::test]
async fn read_relationships_pagination_customized() {
let client = test_client();
let filter = RelationshipFilter::new("document").resource_id("rel2");
let filter = RelationshipFilter::new("document").unwrap().resource_id("rel2");

let (proto_req, _) = client
.read_relationships(filter)
Expand Down
122 changes: 107 additions & 15 deletions src/types/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,32 @@ use crate::types::{Relationship, ZedToken};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RelationshipFilter {
/// Resource type to filter on.
pub resource_type: String,
resource_type: String,
/// Optional resource ID.
pub optional_resource_id: Option<String>,
optional_resource_id: Option<String>,
/// Optional relation name.
pub optional_relation: Option<String>,
optional_relation: Option<String>,
/// Optional subject filter.
pub optional_subject_filter: Option<SubjectFilter>,
optional_subject_filter: Option<SubjectFilter>,
}

impl RelationshipFilter {
/// Creates a new filter for the given resource type.
pub fn new(resource_type: impl Into<String>) -> Self {
Self {
resource_type: resource_type.into(),
///
/// Returns `Err` if `resource_type` is empty or whitespace-only.
pub fn new(resource_type: impl Into<String>) -> Result<Self, Error> {
let resource_type = resource_type.into();
Comment on lines 20 to +24
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RelationshipFilter::new now validates resource_type, but the struct still exposes pub resource_type, so callers can bypass validation via a struct literal. If the intent is to guarantee non-empty types across the API, consider making the field private (as with ObjectReference) and providing accessors/builders instead.

Copilot uses AI. Check for mistakes.
if resource_type.trim().is_empty() {
return Err(Error::InvalidArgument(
"resource_type must not be empty".into(),
));
}
Ok(Self {
resource_type,
optional_resource_id: None,
optional_relation: None,
optional_subject_filter: None,
}
})
}

/// Adds a resource ID filter.
Expand All @@ -44,6 +52,26 @@ impl RelationshipFilter {
self.optional_subject_filter = Some(filter);
self
}

/// Returns the resource type.
pub fn resource_type(&self) -> &str {
&self.resource_type
}

/// Returns the optional resource ID filter.
pub fn resource_id_filter(&self) -> Option<&str> {
self.optional_resource_id.as_deref()
}

/// Returns the optional relation filter.
pub fn relation_filter(&self) -> Option<&str> {
self.optional_relation.as_deref()
}

/// Returns the optional subject filter.
pub fn subject_filter_ref(&self) -> Option<&SubjectFilter> {
self.optional_subject_filter.as_ref()
}
}

impl From<&RelationshipFilter> for crate::proto::RelationshipFilter {
Expand All @@ -62,21 +90,29 @@ impl From<&RelationshipFilter> for crate::proto::RelationshipFilter {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SubjectFilter {
/// The subject object type.
pub subject_type: String,
subject_type: String,
/// Optional subject object ID.
pub optional_subject_id: Option<String>,
optional_subject_id: Option<String>,
/// Optional relation on the subject.
pub optional_relation: Option<String>,
optional_relation: Option<String>,
}

impl SubjectFilter {
/// Creates a new subject filter for the given type.
pub fn new(subject_type: impl Into<String>) -> Self {
Self {
subject_type: subject_type.into(),
///
/// Returns `Err` if `subject_type` is empty or whitespace-only.
pub fn new(subject_type: impl Into<String>) -> Result<Self, Error> {
Comment on lines 100 to +104
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SubjectFilter::new now validates subject_type, but the struct still exposes pub subject_type, so invalid values can still be created via struct literals (bypassing the constructor). If you want the invariant to hold everywhere, make the field private and expose accessors/builders instead.

Copilot uses AI. Check for mistakes.
let subject_type = subject_type.into();
if subject_type.trim().is_empty() {
return Err(Error::InvalidArgument(
"subject_type must not be empty".into(),
));
}
Ok(Self {
subject_type,
optional_subject_id: None,
optional_relation: None,
}
})
}

/// Adds a subject ID filter.
Expand All @@ -90,6 +126,21 @@ impl SubjectFilter {
self.optional_relation = Some(relation.into());
self
}

/// Returns the subject type.
pub fn subject_type(&self) -> &str {
&self.subject_type
}

/// Returns the optional subject ID filter.
pub fn subject_id_filter(&self) -> Option<&str> {
self.optional_subject_id.as_deref()
}

/// Returns the optional relation filter on the subject.
pub fn relation_filter(&self) -> Option<&str> {
self.optional_relation.as_deref()
}
}

impl From<&SubjectFilter> for crate::proto::SubjectFilter {
Expand Down Expand Up @@ -133,3 +184,44 @@ impl ReadRelationshipResult {
})
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn relationship_filter_valid() {
let f = RelationshipFilter::new("document").unwrap();
assert_eq!(f.resource_type(), "document");
}

#[test]
fn relationship_filter_empty_type_rejected() {
let err = RelationshipFilter::new("").unwrap_err();
assert!(matches!(err, Error::InvalidArgument(_)));
}

#[test]
fn relationship_filter_whitespace_type_rejected() {
let err = RelationshipFilter::new(" ").unwrap_err();
assert!(matches!(err, Error::InvalidArgument(_)));
}

#[test]
fn subject_filter_valid() {
let f = SubjectFilter::new("user").unwrap();
assert_eq!(f.subject_type(), "user");
}

#[test]
fn subject_filter_empty_type_rejected() {
let err = SubjectFilter::new("").unwrap_err();
assert!(matches!(err, Error::InvalidArgument(_)));
}

#[test]
fn subject_filter_whitespace_type_rejected() {
let err = SubjectFilter::new(" ").unwrap_err();
assert!(matches!(err, Error::InvalidArgument(_)));
}
}
Loading