Skip to content

Commit

Permalink
list-objects
Browse files Browse the repository at this point in the history
Signed-off-by: Leo Valais <[email protected]>
  • Loading branch information
leovalais committed Feb 5, 2025
1 parent e5ab146 commit cc19a88
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 18 deletions.
21 changes: 21 additions & 0 deletions fga/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions fga/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ itertools = "0.14.0"
reqwest = { version = "0.12.12", features = ["json"] }
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.135"
stdext = "0.3.3"
tempfile = "3.15.0"
thiserror = "2.0.11"
tokio = { version = "1.43.0", features = ["full"] }
url = "2.5.4"

[dev-dependencies]
derive_more = { version = "2.0.1", features = ["from"] }
stdext = "0.3.3"
tempfile = "3.15.0"
131 changes: 129 additions & 2 deletions fga/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use std::future::{self, Future};
use futures::{stream, TryStreamExt as _};
use itertools::Itertools as _;

use crate::model::ParsingError;
use crate::model::QueryObjects;
use crate::model::{AsUser, Check, Object, Relation, Tuple, User};

#[derive(Debug, Clone)]
Expand All @@ -30,6 +32,17 @@ pub struct ConnectionSettings {
port: u16,
}

#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Consistency {
MinimizeLatency,
HigherConsistency,
}

#[derive(Debug, thiserror::Error)]
#[error("HTTP request to OpenFGA failed: {0}")]
pub struct RequestFailure(#[source] reqwest::Error);

#[derive(Debug, thiserror::Error)]
pub enum InitializationError {
#[error("Store not found: {0}")]
Expand All @@ -39,8 +52,12 @@ pub enum InitializationError {
}

#[derive(Debug, thiserror::Error)]
#[error("HTTP request to OpenFGA failed: {0}")]
pub struct RequestFailure(#[source] reqwest::Error);
pub enum QueryError {
#[error(transparent)]
Parsing(#[from] ParsingError),
#[error(transparent)]
Request(#[from] RequestFailure),
}

impl From<reqwest::Error> for RequestFailure {
fn from(error: reqwest::Error) -> Self {
Expand Down Expand Up @@ -252,6 +269,26 @@ impl Client {
)
.await
}

pub async fn list_objects<'a, R: Relation, U: AsUser<User = R::User>>(
&self,
QueryObjects(user, _): QueryObjects<'a, R, U>,
) -> Result<Vec<R::Object>, QueryError> {
let objects = self
.post_stores_list_objects(
&self.store.id,
R::Object::NAMESPACE,
R::NAME,
&user.fga_ident(),
None,
None,
)
.await?
.into_iter()
.map(|ident| R::Object::parse_fga_ident(&ident))
.collect::<Result<Vec<_>, _>>()?;
Ok(objects)
}
}

// Mapping of OpenFGA HTTP API
Expand Down Expand Up @@ -356,6 +393,45 @@ impl<C> ContinuationUnfolder<C> {
}
}

/// Unfolds a continuation-based paginated API call into a stream of items
///
/// ```ignore
/// fn api_call(shift: u64, cont: Option<String>) -> (Vec<u64>, String) {
/// let Some(page) = cont.and_then(|s| s.parse::<u64>().ok()) else {
/// return (vec![shift], "1".to_string());
/// };
/// if page < 3 {
/// (
/// (1..(page + 1)).map(|x| x + shift).collect(),
/// (page + 1).to_string(),
/// )
/// } else {
/// (vec![], "".to_string())
/// }
/// }
///
/// let stream = ContinuationUnfolder::new(client, 0).stream(
/// |UnfoldArgs {
/// client: _client,
/// ctx: shift,
/// continuation,
/// }| async move {
/// let (items, continuation) = api_call(shift, continuation);
/// Ok((
/// items,
/// UnfoldNextState {
/// ctx: shift + 10,
/// continuation,
/// },
/// ))
/// },
/// );
/// assert_eq!(
/// stream.try_collect::<Vec<_>>().await.unwrap(),
/// vec![0, 11, 21, 22]
/// );
/// ```
///
// TODO: rewrite that using async closures once rust 1.85 lands :pepoparty:
fn stream<F, Fut, T>(self, f: F) -> impl stream::TryStream<Ok = T, Error = RequestFailure>
where
Expand Down Expand Up @@ -564,4 +640,55 @@ mod tests {
.assert_check(defs::Infra::can_read().check(&alice, &spain))
.assert_check(defs::Infra::can_read().check(&bob, &spain));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn list_objects() {
let model = compile_model(MODEL);
let client = test_client!();
client.push_authorization_model(&model).await.unwrap();
let alice = defs::User(s!("alice"));
let france = defs::Infra(s!("france"));
let spain = defs::Infra(s!("espagne"));
client
.write_tuples(&[defs::Infra::reader().tuple(&alice, &france)])
.await
.unwrap();
client
.write_tuples(&[defs::Infra::reader().tuple(&alice, &spain)])
.await
.unwrap();

let mut objects = client
.list_objects(defs::Infra::can_read().query_objects(&alice))
.await
.unwrap();
objects.sort();
assert_eq!(objects.as_slice(), &[spain, france]);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn list_objects_unknown_user() {
let model = compile_model(MODEL);
let client = test_client!();
client.push_authorization_model(&model).await.unwrap();
let bob = defs::User(s!("bob"));
let alice = defs::User(s!("alice"));
let france = defs::Infra(s!("france"));
let spain = defs::Infra(s!("espagne"));
client
.write_tuples(&[defs::Infra::reader().tuple(&alice, &france)])
.await
.unwrap();
client
.write_tuples(&[defs::Infra::reader().tuple(&alice, &spain)])
.await
.unwrap();

// bob has no tuple, so OpenFGA doesn't know about him
let objects = client
.list_objects(defs::Infra::can_read().query_objects(&bob))
.await
.unwrap();
assert!(objects.is_empty());
}
}
47 changes: 46 additions & 1 deletion fga/src/client/queries.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::model::{AsUser, Relation, Tuple};

use super::{Client, RawTuple, RequestFailure};
use super::{Client, Consistency, RawTuple, RequestFailure};

#[derive(Debug, serde::Serialize)]
pub(super) struct ContextualTuples {
Expand Down Expand Up @@ -57,4 +57,49 @@ impl Client {

Ok(allowed)
}

pub(super) async fn post_stores_list_objects(
&self,
store_id: &str,
type_: &str,
relation: &str,
user: &str,
contextual_tuples: Option<ContextualTuples>,
consistency: Option<Consistency>,
) -> Result<Vec<String>, RequestFailure> {
#[derive(serde::Serialize)]
struct Request {
#[serde(rename = "type")]
type_: String,
relation: String,
user: String,
#[serde(skip_serializing_if = "Option::is_none")]
contextual_tuples: Option<ContextualTuples>,
#[serde(skip_serializing_if = "Option::is_none")]
consistency: Option<Consistency>,
}

let request = Request {
type_: type_.to_string(),
relation: relation.to_string(),
user: user.to_string(),
contextual_tuples,
consistency,
};

let url = self
.base_url()
.join(format!("stores/{store_id}/list-objects").as_str())
.unwrap();
let response = self.inner.post(url).json(&request).send().await?;

#[derive(serde::Deserialize)]
struct Response {
objects: Vec<String>,
}

let Response { objects } = response.error_for_status()?.json::<Response>().await?;

Ok(objects)
}
}
18 changes: 10 additions & 8 deletions fga/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ fn compile_model(model: &str) -> serde_json::Value {

#[cfg(test)]
mod defs {
use derive_more::From;

use super::model::Relation;

macro_rules! user {
Expand Down Expand Up @@ -136,22 +138,22 @@ mod defs {

pub type Id = String;

#[derive(Debug)]
pub struct Role(pub Id);
#[derive(Debug, From, PartialEq, Eq, PartialOrd, Ord)]
pub struct Role(#[from] pub Id);
user!(Role, "role");

#[derive(Debug)]
pub struct User(pub Id);
#[derive(Debug, From, PartialEq, Eq, PartialOrd, Ord)]
pub struct User(#[from] pub Id);
user!(User, "user");
object!(User, "user");

#[derive(Debug)]
pub struct Group(pub Id);
#[derive(Debug, From, PartialEq, Eq, PartialOrd, Ord)]
pub struct Group(#[from] pub Id);
user!(Group, "group");
object!(Group, "group");

#[derive(Debug)]
pub struct Infra(pub Id);
#[derive(Debug, From, PartialEq, Eq, PartialOrd, Ord)]
pub struct Infra(#[from] pub Id);
object!(Infra, "infra");

relations! {
Expand Down
38 changes: 33 additions & 5 deletions fga/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ pub trait Relation: fmt::Debug + Sized {
}

// implicit
fn query_objects<'a>(&'a self, user: &'a Self::User) -> QueryObjects<'a, Self> {
QueryObjects(user)
fn query_objects<'a, U: AsUser<User = Self::User>>(
&'a self,
user: &'a U,
) -> QueryObjects<'a, Self, U> {
QueryObjects::<Self, U>(user, std::marker::PhantomData)
}

// tuple_key = { user: user:bob, relation: reader, object: document: }
Expand All @@ -64,14 +67,35 @@ pub trait Relation: fmt::Debug + Sized {
}
}

pub trait Object: fmt::Debug {
pub trait Object: fmt::Debug + From<String> {
const NAMESPACE: &'static str;

fn id(&self) -> &str;

fn fga_ident(&self) -> String {
format!("{}:{}", Self::NAMESPACE, self.id())
}

fn parse_fga_ident(ident: &str) -> Result<Self, ParsingError> {
let (ns, id) = ident.split_once(':').ok_or_else(|| ParsingError {
ident: ident.to_string(),
expected_type: Self::NAMESPACE,
})?;
if ns != Self::NAMESPACE {
return Err(ParsingError {
ident: ident.to_string(),
expected_type: Self::NAMESPACE,
});
}
Ok(Self::from(id.to_string()))
}
}

#[derive(Debug, thiserror::Error)]
#[error("Cannot parse string as '{expected_type}': '{ident}'")]
pub struct ParsingError {
ident: String,
expected_type: &'static str,
}

pub trait AsUser {
Expand All @@ -97,9 +121,13 @@ pub struct Check<'a, R: Relation> {
pub(crate) user: &'a R::User,
pub(crate) object: &'a R::Object,
}
#[expect(unused)]

#[derive(Debug)]
pub struct QueryObjects<'a, R: Relation>(&'a R::User);
pub struct QueryObjects<'a, R: Relation, U: AsUser<User = R::User>>(
pub(crate) &'a U,
pub(crate) std::marker::PhantomData<R>,
);

#[expect(unused)]
#[derive(Debug)]
pub struct QueryUsers<'a, R: Relation>(&'a R::Object);
Expand Down

0 comments on commit cc19a88

Please sign in to comment.