simple-zanzibar 0.3.0

A simplified Rust implementation of Google's Zanzibar authorization system with DSL support
Documentation
use std::sync::Arc;

use simple_zanzibar::{
    EngineError, ZanzibarEngine,
    domain::ObjectType,
    error::ZanzibarError,
    schema::{
        self, CompiledSchema, NamespaceDefinition, RelationDefinition, SchemaError,
        UsersetExpression,
    },
};

const VALID_SCHEMA: &str = r#"
    namespace doc {
        relation owner {}
        relation parent {}
        relation viewer {
            rewrite union(
                this,
                computed_userset(relation: "owner"),
                tuple_to_userset(tupleset: "parent", computed_userset: "viewer")
            )
        }
    }

    namespace folder {
        relation viewer {}
    }
"#;

#[test]
fn test_should_compile_legacy_dsl_to_schema_ir() -> Result<(), ZanzibarError> {
    let compiled = schema::compile_legacy_dsl(VALID_SCHEMA)?;
    let doc_type = "doc".try_into()?;
    let viewer = "viewer".try_into()?;

    let relation = compiled.resolver().relation(&doc_type, &viewer)?;
    assert_eq!(relation.name().as_str(), "viewer");
    assert!(relation.userset_rewrite().is_some());

    Ok(())
}

#[test]
fn test_should_reject_duplicate_namespace_definitions() {
    let dsl = r"
        namespace doc {
            relation viewer {}
        }

        namespace doc {
            relation owner {}
        }
    ";

    let error = schema::compile_legacy_dsl(dsl).err();
    assert!(matches!(
        error,
        Some(ZanzibarError::Schema(SchemaError::DuplicateNamespace { namespace }))
            if namespace == "doc"
    ));
}

#[test]
fn test_should_reject_duplicate_relation_definitions() {
    let dsl = r"
        namespace doc {
            relation viewer {}
            relation viewer {}
        }
    ";

    let error = schema::compile_legacy_dsl(dsl).err();
    assert!(matches!(
        error,
        Some(ZanzibarError::Schema(SchemaError::DuplicateRelation { namespace, relation }))
            if namespace == "doc" && relation == "viewer"
    ));
}

#[test]
fn test_should_reject_missing_computed_userset_relation() {
    let dsl = r#"
        namespace doc {
            relation viewer {
                rewrite computed_userset(relation: "owner")
            }
        }
    "#;

    let error = schema::compile_legacy_dsl(dsl).err();
    assert!(matches!(
        error,
        Some(ZanzibarError::Schema(
            SchemaError::MissingRelationReference { missing, .. }
        )) if missing == "owner"
    ));
}

#[test]
fn test_should_reject_missing_tuple_to_userset_left_relation() {
    let dsl = r#"
        namespace doc {
            relation viewer {
                rewrite tuple_to_userset(tupleset: "parent", computed_userset: "viewer")
            }
        }
    "#;

    let error = schema::compile_legacy_dsl(dsl).err();
    assert!(matches!(
        error,
        Some(ZanzibarError::Schema(
            SchemaError::MissingRelationReference { missing, .. }
        )) if missing == "parent"
    ));
}

#[test]
fn test_should_reject_missing_tuple_to_userset_target_relation() {
    let dsl = r#"
        namespace doc {
            relation parent {}
            relation viewer {
                rewrite tuple_to_userset(tupleset: "parent", computed_userset: "missing")
            }
        }
    "#;

    let error = schema::compile_legacy_dsl(dsl).err();
    assert!(matches!(
        error,
        Some(ZanzibarError::Schema(
            SchemaError::MissingTupleToUsersetTarget { missing, .. }
        )) if missing == "missing"
    ));
}

#[test]
fn test_should_validate_tuple_to_userset_target_against_explicit_allowed_subjects()
-> Result<(), ZanzibarError> {
    let doc_type = ObjectType::try_from("doc")?;
    let folder_type = ObjectType::try_from("folder")?;
    let group_type = ObjectType::try_from("group")?;

    let doc_namespace = NamespaceDefinition::new(
        doc_type,
        Arc::from(
            vec![
                RelationDefinition::with_allowed_subject_types(
                    "parent".try_into()?,
                    Arc::from(vec![folder_type.clone()].into_boxed_slice()),
                    None,
                ),
                RelationDefinition::new(
                    "viewer".try_into()?,
                    Some(UsersetExpression::TupleToUserset {
                        tupleset_relation: "parent".try_into()?,
                        computed_userset_relation: "viewer".try_into()?,
                    }),
                ),
            ]
            .into_boxed_slice(),
        ),
    );
    let folder_namespace = NamespaceDefinition::new(
        folder_type,
        Arc::from(vec![RelationDefinition::new("viewer".try_into()?, None)].into_boxed_slice()),
    );
    let unrelated_namespace = NamespaceDefinition::new(
        group_type,
        Arc::from(vec![RelationDefinition::new("member".try_into()?, None)].into_boxed_slice()),
    );

    let compiled =
        CompiledSchema::from_definitions([doc_namespace, folder_namespace, unrelated_namespace])?;
    let doc = "doc".try_into()?;
    let viewer = "viewer".try_into()?;

    assert!(compiled.resolver().relation(&doc, &viewer).is_ok());

    Ok(())
}

#[test]
fn test_should_reject_tuple_to_userset_target_missing_on_explicit_allowed_subject()
-> Result<(), ZanzibarError> {
    let doc_type = ObjectType::try_from("doc")?;
    let folder_type = ObjectType::try_from("folder")?;
    let group_type = ObjectType::try_from("group")?;

    let doc_namespace = NamespaceDefinition::new(
        doc_type,
        Arc::from(
            vec![
                RelationDefinition::with_allowed_subject_types(
                    "parent".try_into()?,
                    Arc::from(vec![folder_type.clone()].into_boxed_slice()),
                    None,
                ),
                RelationDefinition::new(
                    "viewer".try_into()?,
                    Some(UsersetExpression::TupleToUserset {
                        tupleset_relation: "parent".try_into()?,
                        computed_userset_relation: "member".try_into()?,
                    }),
                ),
            ]
            .into_boxed_slice(),
        ),
    );
    let folder_namespace = NamespaceDefinition::new(
        folder_type,
        Arc::from(vec![RelationDefinition::new("viewer".try_into()?, None)].into_boxed_slice()),
    );
    let unrelated_namespace = NamespaceDefinition::new(
        group_type,
        Arc::from(vec![RelationDefinition::new("member".try_into()?, None)].into_boxed_slice()),
    );

    let error =
        CompiledSchema::from_definitions([doc_namespace, folder_namespace, unrelated_namespace])
            .err();
    assert!(matches!(
        error,
        Some(SchemaError::MissingTupleToUsersetTarget { missing, .. }) if missing == "member"
    ));

    Ok(())
}

#[test]
fn test_should_reject_relationship_with_unknown_userset_subject_relation()
-> Result<(), ZanzibarError> {
    let service = ZanzibarEngine::builder().build();
    service.add_dsl(VALID_SCHEMA)?;

    let tuple = simple_zanzibar::model::RelationTuple {
        object: simple_zanzibar::model::Object {
            namespace: "doc".to_string(),
            id: "readme".to_string(),
        },
        relation: simple_zanzibar::model::Relation("parent".to_string()),
        user: simple_zanzibar::model::User::Userset(
            simple_zanzibar::model::Object {
                namespace: "folder".to_string(),
                id: "root".to_string(),
            },
            simple_zanzibar::model::Relation("missing".to_string()),
        ),
    };

    let error = service.write_tuple(tuple).err();
    assert!(matches!(
        error,
        Some(EngineError::Schema(SchemaError::RelationNotFound { relation, .. }))
            if relation == "missing"
    ));

    Ok(())
}

#[test]
fn test_should_reject_empty_union() {
    let dsl = r"
        namespace doc {
            relation viewer {
                rewrite union()
            }
        }
    ";

    let error = schema::compile_legacy_dsl(dsl).err();
    assert!(matches!(
        error,
        Some(ZanzibarError::Schema(SchemaError::EmptyExpression {
            operator: "union",
            ..
        }))
    ));
}

#[test]
fn test_service_should_reject_invalid_schema_without_mutating_previous_schema()
-> Result<(), ZanzibarError> {
    let service = ZanzibarEngine::builder().build();
    service.add_dsl(VALID_SCHEMA)?;

    let invalid = r#"
        namespace invalid {
            relation viewer {
                rewrite computed_userset(relation: "missing")
            }
        }
    "#;

    assert!(service.add_dsl(invalid).is_err());

    let doc = simple_zanzibar::model::Object {
        namespace: "doc".to_string(),
        id: "readme".to_string(),
    };
    let viewer = simple_zanzibar::model::Relation("viewer".to_string());
    let alice = simple_zanzibar::model::User::UserId("alice".to_string());

    assert!(!service.check_relation(&doc, &viewer, &alice)?);

    Ok(())
}