simple-zanzibar 0.3.0

A simplified Rust implementation of Google's Zanzibar authorization system with DSL support
Documentation
use std::{
    io,
    sync::{Arc, Barrier},
    thread,
};

use simple_zanzibar::{
    EngineError, TenantId, ZanzibarEngine, ZanzibarTenantShards,
    domain::Relationship,
    model::{
        CheckRequest, ExpandRequest, ExpandedUserset, LookupResourcesRequest,
        LookupSubjectsRequest, Object, Relation, User,
    },
    relationship::{
        Precondition, RelationshipFilter, RelationshipMutation, StoreError, SubjectFilter,
    },
    revision::Consistency,
    schema::SchemaSource,
};

const DOC_SCHEMA: &str = r"
    namespace doc {
        relation viewer {}
    }
";

#[test]
fn test_should_make_engine_send_and_sync() {
    fn assert_send_sync<T: Send + Sync>() {}

    assert_send_sync::<ZanzibarEngine>();
}

#[test]
fn test_should_isolate_multiple_engine_instances() -> Result<(), Box<dyn std::error::Error>> {
    let first = ZanzibarEngine::builder().build();
    let second = ZanzibarEngine::builder().build();
    first.apply_schema(SchemaSource {
        name: Some("doc-schema"),
        text: DOC_SCHEMA,
    })?;
    second.apply_schema(SchemaSource {
        name: Some("doc-schema"),
        text: DOC_SCHEMA,
    })?;

    first.touch_relationship("doc:readme#viewer@user:alice")?;

    assert!(first.check_relation(&doc_object(), &viewer(), &User::user_id("alice"))?);
    assert!(!second.check_relation(&doc_object(), &viewer(), &User::user_id("alice"))?);
    Ok(())
}

#[test]
fn test_should_shard_engines_by_tenant() -> Result<(), Box<dyn std::error::Error>> {
    let shards = ZanzibarTenantShards::default();
    let tenant_a = TenantId::new("tenant-a")?;
    let tenant_b = TenantId::new("tenant-b")?;

    let first_a = shards.get_or_create(tenant_a.clone());
    let second_a = shards.get_or_create(tenant_a.clone());
    let engine_b = shards.get_or_create(tenant_b.clone());
    assert!(Arc::ptr_eq(&first_a, &second_a));

    for engine in [&first_a, &engine_b] {
        engine.apply_schema(SchemaSource {
            name: Some("doc-schema"),
            text: DOC_SCHEMA,
        })?;
    }
    first_a.touch_relationship("doc:readme#viewer@user:alice")?;

    assert!(first_a.check_relation(&doc_object(), &viewer(), &User::user_id("alice"))?);
    assert!(!engine_b.check_relation(&doc_object(), &viewer(), &User::user_id("alice"))?);
    assert_eq!(shards.tenants(), vec![tenant_a, tenant_b]);
    Ok(())
}

#[test]
fn test_should_use_public_engine_request_response_api() -> Result<(), Box<dyn std::error::Error>> {
    let engine = ZanzibarEngine::builder().build();
    engine.apply_schema(SchemaSource {
        name: Some("doc-schema"),
        text: DOC_SCHEMA,
    })?;

    let relationship: Relationship = "doc:readme#viewer@user:alice".parse()?;
    let subject = SubjectFilter::exact("user".try_into()?, "alice".try_into()?, None);
    let precondition = Precondition::MustNotMatch(RelationshipFilter::for_exact_subject(
        relationship.resource(),
        relationship.relation().clone(),
        subject,
    ));
    let token = engine.write_relationships_with_preconditions(
        [RelationshipMutation::Touch(relationship)],
        [precondition],
    )?;

    let object = doc_object();
    let relation = viewer();
    let alice = User::UserId("alice".to_string());

    assert!(
        engine
            .check(CheckRequest::new(
                object.clone(),
                relation.clone(),
                alice.clone(),
                Consistency::Latest,
            ))?
            .allowed
    );
    assert!(
        engine
            .check(CheckRequest::new(
                object.clone(),
                relation.clone(),
                alice.clone(),
                Consistency::Exact(token),
            ))?
            .allowed
    );
    assert!(matches!(
        engine
            .expand(ExpandRequest::new(
                object.clone(),
                relation.clone(),
                Consistency::Latest,
            ))?
            .expanded,
        ExpandedUserset::Union(_)
    ));
    assert_eq!(
        engine
            .lookup_resources(LookupResourcesRequest {
                subject: alice.clone(),
                permission: relation.clone(),
                resource_type: "doc".to_string(),
            })?
            .resources,
        vec![object.clone()],
    );
    assert_eq!(
        engine
            .lookup_subjects(LookupSubjectsRequest {
                resource: object,
                permission: relation,
                subject_type: "user".to_string(),
            })?
            .subjects,
        vec![alice],
    );
    Ok(())
}

#[test]
fn test_should_validate_public_requests_before_snapshot_access() {
    let engine = ZanzibarEngine::builder().build();

    let result = engine.check(CheckRequest::new(
        Object::new("doc", "bad id"),
        viewer(),
        User::user_id("alice"),
        Consistency::Latest,
    ));

    assert!(matches!(result, Err(EngineError::Domain(_))));
}

#[test]
fn test_should_persist_direct_non_user_subject_relationships()
-> Result<(), Box<dyn std::error::Error>> {
    let engine = ZanzibarEngine::builder().build();
    engine.apply_schema(SchemaSource {
        name: Some("doc-schema"),
        text: DOC_SCHEMA,
    })?;
    let relationship: Relationship = "doc:readme#viewer@group:eng".parse()?;

    engine.write_relationships([RelationshipMutation::Create(relationship.clone())])?;
    let duplicate =
        engine.write_relationships([RelationshipMutation::Create(relationship.clone())]);

    assert!(matches!(
        duplicate,
        Err(EngineError::Store(
            StoreError::RelationshipAlreadyExists { .. }
        ))
    ));

    engine.apply_schema(SchemaSource {
        name: Some("doc-schema"),
        text: DOC_SCHEMA,
    })?;
    engine.write_relationships([RelationshipMutation::Delete(relationship)])?;
    Ok(())
}

#[test]
fn test_should_publish_atomic_snapshots_while_latest_readers_run()
-> Result<(), Box<dyn std::error::Error>> {
    let engine = Arc::new(ZanzibarEngine::builder().build());
    let schema_token = engine.apply_schema(SchemaSource {
        name: Some("doc-schema"),
        text: DOC_SCHEMA,
    })?;
    let object = doc_object();
    let relation = viewer();
    let alice = User::UserId("alice".to_string());
    let start = Arc::new(Barrier::new(5));

    let writer_engine = Arc::clone(&engine);
    let writer_start = Arc::clone(&start);
    let writer = thread::spawn(move || {
        writer_start.wait();
        let relationship: Relationship = "doc:readme#viewer@user:alice".parse()?;
        writer_engine.write_relationships([RelationshipMutation::Touch(relationship)])
    });

    let readers = (0..4)
        .map(|_| {
            let reader_engine = Arc::clone(&engine);
            let reader_start = Arc::clone(&start);
            let reader_object = object.clone();
            let reader_relation = relation.clone();
            let reader_alice = alice.clone();
            thread::spawn(move || -> Result<usize, simple_zanzibar::EngineError> {
                reader_start.wait();
                let mut allowed_reads = 0;
                for _ in 0..500 {
                    let response = reader_engine.check(CheckRequest::new(
                        reader_object.clone(),
                        reader_relation.clone(),
                        reader_alice.clone(),
                        Consistency::Latest,
                    ))?;
                    if response.allowed {
                        allowed_reads += 1;
                    }
                }
                Ok(allowed_reads)
            })
        })
        .collect::<Vec<_>>();

    let write_token = writer.join().map_err(|_| thread_panic_error("writer"))??;
    for reader in readers {
        let allowed_reads = reader.join().map_err(|_| thread_panic_error("reader"))??;
        assert!(allowed_reads <= 500);
    }

    assert!(
        engine
            .check(CheckRequest::new(
                object.clone(),
                relation.clone(),
                alice.clone(),
                Consistency::Exact(write_token),
            ))?
            .allowed
    );
    assert!(
        !engine
            .check(CheckRequest::new(
                object,
                relation,
                alice,
                Consistency::Exact(schema_token),
            ))?
            .allowed
    );
    Ok(())
}

fn doc_object() -> Object {
    Object {
        namespace: "doc".to_string(),
        id: "readme".to_string(),
    }
}

fn viewer() -> Relation {
    Relation("viewer".to_string())
}

fn thread_panic_error(name: &str) -> io::Error {
    io::Error::other(format!("{name} thread panicked"))
}

#[cfg(feature = "serde")]
#[test]
fn test_should_serialize_public_request_dtos_with_camel_case()
-> Result<(), Box<dyn std::error::Error>> {
    let request = CheckRequest::new(
        doc_object(),
        viewer(),
        User::UserId("alice".to_string()),
        Consistency::Latest,
    );

    let serialized = serde_json::to_value(&request)?;

    assert_eq!(
        serialized,
        serde_json::json!({
            "object": {
                "namespace": "doc",
                "id": "readme",
            },
            "relation": "viewer",
            "user": {
                "type": "userId",
                "value": "alice",
            },
            "consistency": {
                "kind": "latest",
            },
        }),
    );

    let decoded: CheckRequest = serde_json::from_value(serialized)?;
    assert_eq!(decoded, request);
    Ok(())
}

#[cfg(feature = "serde")]
#[test]
fn test_should_validate_domain_values_during_deserialization() {
    let result: Result<Relationship, _> = serde_json::from_str(r#""doc:bad id#viewer@user:alice""#);
    let invalid_check: Result<CheckRequest, _> = serde_json::from_value(serde_json::json!({
        "object": {
            "namespace": "doc",
            "id": "bad id",
        },
        "relation": "viewer",
        "user": {
            "type": "userId",
            "value": "alice",
        },
        "consistency": {
            "kind": "latest",
        },
    }));
    let invalid_lookup_resources: Result<LookupResourcesRequest, _> =
        serde_json::from_value(serde_json::json!({
            "subject": {
                "type": "userId",
                "value": "alice",
            },
            "permission": "viewer",
            "resourceType": "bad-type",
        }));
    let invalid_lookup_subjects: Result<LookupSubjectsRequest, _> =
        serde_json::from_value(serde_json::json!({
            "resource": {
                "namespace": "doc",
                "id": "readme",
            },
            "permission": "viewer",
            "subjectType": "bad type",
        }));

    assert!(result.is_err());
    assert!(invalid_check.is_err());
    assert!(invalid_lookup_resources.is_err());
    assert!(invalid_lookup_subjects.is_err());
}