From c6e22490e887bb5bbbb0acd98f87263f17c7ee9e Mon Sep 17 00:00:00 2001 From: Ronen Hilewicz Date: Thu, 4 Apr 2024 17:37:38 -0400 Subject: [PATCH] Add find_objects and find_subjects to Directory client. --- README.md | 30 +++ poetry.lock | 24 +-- pyproject.toml | 2 +- src/aserto/client/directory/v3/__init__.py | 177 ++++++++++++++---- .../client/directory/v3/aio/__init__.py | 174 +++++++++++++---- src/aserto/client/directory/v3/helpers.py | 24 ++- test/conftest.py | 8 +- test/test_authorizer.py | 1 + test/test_directory_v3.py | 37 +++- test/test_directory_v3_async.py | 41 +++- 10 files changed, 418 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 9196582..e13b7f8 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,36 @@ allowed = ds.check( ) ``` +#### `find_subjects` + +Find subjects that have a given relation to or permission on a specified object. + +```py +reponse = ds.find_subjects( + object_type="folder", + object_id="/path/to/folder", + relation="can_delete", + subject_type="user" +) + +assert ObjectIdentifier("user", "euang@acmecorp.com") in response.results +``` + +#### `find_objects` + +Find objects that a given subject has a specified relation to or permission on. + +```py +reponse = ds.find_objects( + object_type="folder", + relation="can_delete", + subject_type="user" + subjecct_id="euang@acmecorp.com" +) + +assert ObjectIdentifier("folder", "/path/to/folder") in response.results +``` + #### `get_manifest ` Download the directory manifest. diff --git a/poetry.lock b/poetry.lock index bc9165e..72ecb8a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -42,11 +42,11 @@ protobuf = ">=4.21.0,<5.0.0" [[package]] name = "aserto-directory" -version = "0.31.1" +version = "0.31.3" description = "gRPC client for Aserto Directory service instances" category = "main" optional = false -python-versions = ">=3.8,<4" +python-versions = "<4,>=3.8" [package.dependencies] grpcio = ">=1.49,<2.0" @@ -155,14 +155,14 @@ python-versions = ">=3.8" [[package]] name = "grpcio" -version = "1.60.1" +version = "1.62.1" description = "HTTP/2-based RPC framework" category = "main" optional = false python-versions = ">=3.7" [package.extras] -protobuf = ["grpcio-tools (>=1.60.1)"] +protobuf = ["grpcio-tools (>=1.62.1)"] [[package]] name = "idna" @@ -217,7 +217,7 @@ python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.* [[package]] name = "packaging" -version = "23.2" +version = "24.0" description = "Core utilities for Python packages" category = "dev" optional = false @@ -265,7 +265,7 @@ python-versions = ">=3.8" [[package]] name = "pyright" -version = "1.1.351" +version = "1.1.357" description = "Command line wrapper for pyright" category = "dev" optional = false @@ -280,7 +280,7 @@ dev = ["twine (>=3.4.1)"] [[package]] name = "pytest" -version = "8.0.1" +version = "8.1.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -291,11 +291,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.3.0,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +pluggy = ">=1.4,<2.0" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" @@ -339,7 +339,7 @@ python-versions = ">=3.7" [[package]] name = "typing-extensions" -version = "4.9.0" +version = "4.10.0" description = "Backported and Experimental Type Hints for Python 3.8+" category = "dev" optional = false @@ -374,7 +374,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "6ebe34d23bb613512cb69648f51d8aa3fac99c906b3eb32538001f3640c91071" +content-hash = "1ea3a3ccbffc0d1dd9a14feac0e123df1cc019e92b1f1844aef0eb310e7c3699" [metadata.files] aiohttp = [] diff --git a/pyproject.toml b/pyproject.toml index b66e0b8..abf6cd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ aiohttp = "^3.8.0" grpcio = "^1.49.0" protobuf = "^4.21.0" aserto-authorizer = "^0.20.2" -aserto-directory = "^0.31.0" +aserto-directory = "^0.31.3" [tool.poetry.dev-dependencies] black = "^23.0" diff --git a/src/aserto/client/directory/v3/__init__.py b/src/aserto/client/directory/v3/__init__.py index 5de1ae1..bed9654 100644 --- a/src/aserto/client/directory/v3/__init__.py +++ b/src/aserto/client/directory/v3/__init__.py @@ -1,23 +1,24 @@ import datetime -import typing +import typing +from aserto.directory.common.v3 import Object, PaginationRequest, Relation import aserto.directory.exporter.v3 as exporter import aserto.directory.importer.v3 as importer import aserto.directory.model.v3 as model import aserto.directory.reader.v3 as reader +from aserto.directory.reader.v3 import GetObjectResponse, GetObjectsResponse import aserto.directory.writer.v3 as writer import google.protobuf.json_format as json_format -import grpc -from aserto.directory.common.v3 import Object, PaginationRequest, Relation -from aserto.directory.reader.v3 import GetObjectResponse, GetObjectsResponse from google.protobuf.struct_pb2 import Struct +import grpc import aserto.client.directory as directory -import aserto.client.directory.v3.helpers as helpers from aserto.client.directory import NotFoundError +import aserto.client.directory.v3.helpers as helpers from aserto.client.directory.v3.helpers import ( ETagMismatchError, ExportOption, + FindResponse, ImportCounter, ImportResponse, Manifest, @@ -26,6 +27,7 @@ RelationsResponse, ) + class Directory: def __init__( self, @@ -39,76 +41,66 @@ def __init__( exporter_address: str = "", model_address: str = "", ) -> None: - - self._channels = directory.Channels(default_address=address, reader_address=reader_address, writer_address=writer_address, - importer_address=importer_address, exporter_address=exporter_address, model_address=model_address, ca_cert_path=ca_cert_path) + self._channels = directory.Channels( + default_address=address, + reader_address=reader_address, + writer_address=writer_address, + importer_address=importer_address, + exporter_address=exporter_address, + model_address=model_address, + ca_cert_path=ca_cert_path, + ) self._metadata = directory.get_metadata(api_key=api_key, tenant_id=tenant_id) reader_channel = self._channels.get(reader_address, address) - self._reader = ( - reader.ReaderStub(reader_channel) - if reader_channel is not None - else None - ) + self._reader = reader.ReaderStub(reader_channel) if reader_channel is not None else None writer_channel = self._channels.get(writer_address, address) - self._writer = ( - writer.WriterStub(writer_channel) - if writer_channel is not None - else None - ) + self._writer = writer.WriterStub(writer_channel) if writer_channel is not None else None model_channel = self._channels.get(model_address, address) - self._model = ( - model.ModelStub(model_channel) - if model_channel is not None - else None - ) + self._model = model.ModelStub(model_channel) if model_channel is not None else None importer_channel = self._channels.get(importer_address, address) self._importer = ( - importer.ImporterStub(importer_channel) - if importer_channel is not None - else None + importer.ImporterStub(importer_channel) if importer_channel is not None else None ) exporter_channel = self._channels.get(exporter_address, address) self._exporter = ( - exporter.ExporterStub(exporter_channel) - if exporter_channel is not None - else None + exporter.ExporterStub(exporter_channel) if exporter_channel is not None else None ) def reader(self) -> reader.ReaderStub: if self._reader is None: raise directory.ConfigError("reader service address not specified") - + return self._reader - + def writer(self) -> writer.WriterStub: if self._writer is None: raise directory.ConfigError("writer service address not specified") - + return self._writer - + def importer(self) -> importer.ImporterStub: if self._importer is None: raise directory.ConfigError("importer service address not specified") - + return self._importer - + def exporter(self) -> exporter.ExporterStub: if self._exporter is None: raise directory.ConfigError("expoerter service address not specified") - + return self._exporter + def model(self) -> model.ModelStub: if self._model is None: raise directory.ConfigError("model service address not specified") - - return self._model + return self._model @typing.overload def get_object( @@ -293,7 +285,6 @@ def set_object( properties: typing.Optional[typing.Union[typing.Mapping[str, typing.Any], Struct]] = None, etag: str = "", ) -> Object: - obj = object if obj is None: properties = properties or {} @@ -591,6 +582,112 @@ def delete_relation( metadata=self._metadata, ) + def find_subjects( + self, + object_type: str, + object_id: str, + relation: str, + subject_type: str, + subject_relation: str = "", + explain: bool = False, + trace: bool = False, + ) -> FindResponse: + """Find subjects that have a given relation to or permission on a specified object. + + Parameters + ---- + object_type : str + the type of object to search from. + object_id: str + the id of the object to search from. + relation: str + the relation or permission to look for. + subject_type : str + the type of subject to search for. + subject_relation: str + optional subject relation. This is useful when searching for intermediate subjects like groups. + explain: bool + if True, the response includes, for each match, the set of relations that grant the specified relation or + permission . + trace: bool + if True, the response includes the trace of the search process. + + Returns + ---- + FindResponse + """ + resp = self.reader().GetGraph( + reader.GetGraphRequest( + object_type=object_type, + object_id=object_id, + relation=relation, + subject_type=subject_type, + subject_relation=subject_relation, + explain=explain, + trace=trace, + ), + metadata=self._metadata, + ) + + return FindResponse( + [ObjectIdentifier(type=r.object_type, id=r.object_id) for r in resp.results], + helpers.explanation_to_dict(resp.explanation), + resp.trace, + ) + + def find_objects( + self, + object_type: str, + relation: str, + subject_type: str, + subject_id: str, + subject_relation: str = "", + explain: bool = False, + trace: bool = False, + ) -> FindResponse: + """Find objects that a given subject has a specified relation to or permission on. + + Parameters + ---- + object_type : str + the type of object to search for. + relation: str + the relation or permission to look for. + subject_type : str + the type of subject to search from. + subject_id: str + the id of the subject to search from. + subject_relation: str + optional subject relation. This is useful when searching for intermediate subjects like groups. + explain: bool + if True, the response includes, for each match, the set of relations that grant the specified relation or + permission . + trace: bool + if True, the response includes the trace of the search process. + + Returns + ---- + FindResponse + """ + resp = self.reader().GetGraph( + reader.GetGraphRequest( + object_type=object_type, + relation=relation, + subject_type=subject_type, + subject_id=subject_id, + subject_relation=subject_relation, + explain=explain, + trace=trace, + ), + metadata=self._metadata, + ) + + return FindResponse( + [ObjectIdentifier(type=r.object_type, id=r.object_id) for r in resp.results], + helpers.explanation_to_dict(resp.explanation), + resp.trace, + ) + def check( self, object_type: str, @@ -619,7 +716,7 @@ def check( ---- True or False """ - + response = self.reader().Check( reader.CheckRequest( object_type=object_type, diff --git a/src/aserto/client/directory/v3/aio/__init__.py b/src/aserto/client/directory/v3/aio/__init__.py index 99b4c60..2e5744b 100644 --- a/src/aserto/client/directory/v3/aio/__init__.py +++ b/src/aserto/client/directory/v3/aio/__init__.py @@ -1,25 +1,26 @@ import datetime import typing +from aserto.directory.common.v3 import Object, PaginationRequest, Relation import aserto.directory.exporter.v3 as exporter import aserto.directory.importer.v3 as importer import aserto.directory.model.v3 as model import aserto.directory.reader.v3 as reader +from aserto.directory.reader.v3 import GetObjectResponse, GetObjectsResponse import aserto.directory.writer.v3 as writer import google.protobuf.json_format as json_format -import grpc.aio as grpc -from aserto.directory.common.v3 import Object, PaginationRequest, Relation -from aserto.directory.reader.v3 import GetObjectResponse, GetObjectsResponse from google.protobuf.struct_pb2 import Struct from grpc import RpcError, StatusCode +import grpc.aio as grpc import aserto.client.directory as directory +from aserto.client.directory import NotFoundError import aserto.client.directory.aio as aio import aserto.client.directory.v3.helpers as helpers -from aserto.client.directory import NotFoundError from aserto.client.directory.v3.helpers import ( ETagMismatchError, ExportOption, + FindResponse, ImportCounter, ImportResponse, Manifest, @@ -28,6 +29,7 @@ RelationsResponse, ) + class Directory: def __init__( self, @@ -41,74 +43,65 @@ def __init__( exporter_address: str = "", model_address: str = "", ) -> None: - - self._channels = aio.Channels(default_address=address, reader_address=reader_address, writer_address=writer_address, - importer_address=importer_address, exporter_address=exporter_address, model_address=model_address, ca_cert_path=ca_cert_path) + self._channels = aio.Channels( + default_address=address, + reader_address=reader_address, + writer_address=writer_address, + importer_address=importer_address, + exporter_address=exporter_address, + model_address=model_address, + ca_cert_path=ca_cert_path, + ) self._metadata = directory.get_metadata(api_key=api_key, tenant_id=tenant_id) reader_channel = self._channels.get(reader_address, address) - self._reader = ( - reader.ReaderStub(reader_channel) - if reader_channel is not None - else None - ) + self._reader = reader.ReaderStub(reader_channel) if reader_channel is not None else None writer_channel = self._channels.get(writer_address, address) - self._writer = ( - writer.WriterStub(writer_channel) - if writer_channel is not None - else None - ) + self._writer = writer.WriterStub(writer_channel) if writer_channel is not None else None model_channel = self._channels.get(model_address, address) - self._model = ( - model.ModelStub(model_channel) - if model_channel is not None - else None - ) + self._model = model.ModelStub(model_channel) if model_channel is not None else None importer_channel = self._channels.get(importer_address, address) self._importer = ( - importer.ImporterStub(importer_channel) - if importer_channel is not None - else None + importer.ImporterStub(importer_channel) if importer_channel is not None else None ) exporter_channel = self._channels.get(exporter_address, address) self._exporter = ( - exporter.ExporterStub(exporter_channel) - if exporter_channel is not None - else None + exporter.ExporterStub(exporter_channel) if exporter_channel is not None else None ) def reader(self) -> reader.ReaderStub: if self._reader is None: raise directory.ConfigError("reader service address not specified") - + return self._reader - + def writer(self) -> writer.WriterStub: if self._writer is None: raise directory.ConfigError("writer service address not specified") - + return self._writer - + def importer(self) -> importer.ImporterStub: if self._importer is None: raise directory.ConfigError("importer service address not specified") - + return self._importer - + def exporter(self) -> exporter.ExporterStub: if self._exporter is None: raise directory.ConfigError("expoerter service address not specified") - + return self._exporter + def model(self) -> model.ModelStub: if self._model is None: raise directory.ConfigError("model service address not specified") - + return self._model async def get_objects( @@ -293,7 +286,6 @@ async def set_object( properties: typing.Optional[typing.Union[typing.Mapping[str, typing.Any], Struct]] = None, etag: str = "", ) -> Object: - obj = object if obj is None: properties = properties or {} @@ -593,6 +585,112 @@ async def delete_relation( metadata=self._metadata, ) + async def find_subjects( + self, + object_type: str, + object_id: str, + relation: str, + subject_type: str, + subject_relation: str = "", + explain: bool = False, + trace: bool = False, + ) -> FindResponse: + """Find subjects that have a given relation to or permission on a specified object. + + Parameters + ---- + object_type : str + the type of object to search from. + object_id: str + the id of the object to search from. + relation: str + the relation or permission to look for. + subject_type : str + the type of subject to search for. + subject_relation: str + optional subject relation. This is useful when searching for intermediate subjects like groups. + explain: bool + if True, the response includes, for each match, the set of relations that grant the specified relation or + permission . + trace: bool + if True, the response includes the trace of the search process. + + Returns + ---- + FindResponse + """ + resp = await self.reader().GetGraph( + reader.GetGraphRequest( + object_type=object_type, + object_id=object_id, + relation=relation, + subject_type=subject_type, + subject_relation=subject_relation, + explain=explain, + trace=trace, + ), + metadata=self._metadata, + ) + + return FindResponse( + [ObjectIdentifier(type=r.object_type, id=r.object_id) for r in resp.results], + helpers.explanation_to_dict(resp.explanation), + resp.trace, + ) + + async def find_objects( + self, + object_type: str, + relation: str, + subject_type: str, + subject_id: str, + subject_relation: str = "", + explain: bool = False, + trace: bool = False, + ) -> FindResponse: + """Find objects that a given subject has a specified relation to or permission on. + + Parameters + ---- + object_type : str + the type of object to search for. + relation: str + the relation or permission to look for. + subject_type : str + the type of subject to search from. + subject_id: str + the id of the subject to search from. + subject_relation: str + optional subject relation. This is useful when searching for intermediate subjects like groups. + explain: bool + if True, the response includes, for each match, the set of relations that grant the specified relation or + permission . + trace: bool + if True, the response includes the trace of the search process. + + Returns + ---- + FindResponse + """ + resp = await self.reader().GetGraph( + reader.GetGraphRequest( + object_type=object_type, + relation=relation, + subject_type=subject_type, + subject_id=subject_id, + subject_relation=subject_relation, + explain=explain, + trace=trace, + ), + metadata=self._metadata, + ) + + return FindResponse( + [ObjectIdentifier(type=r.object_type, id=r.object_id) for r in resp.results], + helpers.explanation_to_dict(resp.explanation), + resp.trace, + ) + async def check( self, object_type: str, @@ -842,7 +940,7 @@ async def export_data( start_from: typing.Optional[datetime.datetime] if provided, only objects and relations that have been modified after this date are exported. """ - + req = exporter.ExportRequest(options=options) if start_from is not None: req.start_from.FromDatetime(dt=start_from) diff --git a/src/aserto/client/directory/v3/helpers.py b/src/aserto/client/directory/v3/helpers.py index a71e827..babbabe 100644 --- a/src/aserto/client/directory/v3/helpers.py +++ b/src/aserto/client/directory/v3/helpers.py @@ -1,11 +1,12 @@ -import datetime from dataclasses import dataclass +import datetime from typing import List, Mapping, Optional from aserto.directory.common.v3 import Object from aserto.directory.common.v3 import ObjectIdentifier as ObjectIdentifierProto from aserto.directory.common.v3 import PaginationResponse, Relation from aserto.directory.exporter.v3 import Option +from google.protobuf.struct_pb2 import Struct MAX_CHUNK_BYTES = 64 * 1024 @@ -59,6 +60,23 @@ class RelationsResponse: page: PaginationResponse +@dataclass(frozen=True) +class FindResponse: + """ + Response to find_subjects and find_objects calls. + + Attributes + ---- + results The list of matching object identifiers. + explanation For each object in results, a list of paths that connect the result to the searched object. + trace The sequence of queries that were executed to find the results. + """ + + results: List[ObjectIdentifier] + explanation: Mapping[str, List[List[str]]] + trace: List[str] + + @dataclass(frozen=True) class Manifest: updated_at: datetime.datetime @@ -95,3 +113,7 @@ def relation_objects(objects: Mapping[str, Object]) -> Mapping[ObjectIdentifier, res[ObjectIdentifier(obj_type, obj_id)] = obj return res + + +def explanation_to_dict(explanation: Struct) -> Mapping[str, List[List[str]]]: + return {k: [[p for p in path] for path in v] for k, v in explanation.items()} # type: ignore diff --git a/test/conftest.py b/test/conftest.py index b6a5905..7838676 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,8 +1,8 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta import os.path import subprocess import time -from dataclasses import dataclass -from datetime import datetime, timedelta from typing import Optional import grpc @@ -91,6 +91,8 @@ def topaz(): svc.stop() + time.sleep(1) + subprocess.run( "rm ~/.config/topaz/db/directory.db", shell=True, @@ -104,7 +106,7 @@ def topaz(): def topaz_configure() -> Topaz: subprocess.run( - "topaz configure -r ghcr.io/aserto-policies/policy-todo:2.1.0 -n todo -d", + "topaz configure -r ghcr.io/aserto-policies/policy-todo:3 -n todo -d -f --enable-v2", shell=True, capture_output=True, check=True, diff --git a/test/test_authorizer.py b/test/test_authorizer.py index 6d77711..5be5f20 100644 --- a/test/test_authorizer.py +++ b/test/test_authorizer.py @@ -39,6 +39,7 @@ def make_decision_request(client: AuthorizerClient) -> Dict[str, bool]: def test_decision_tree_grpc(authorizer) -> None: + print("foobar") expected = { "todoApp.DELETE.todos.__id": {"allowed": False}, "todoApp.GET.todos": {"allowed": True}, diff --git a/test/test_directory_v3.py b/test/test_directory_v3.py index 0a5acbc..c8c75f9 100644 --- a/test/test_directory_v3.py +++ b/test/test_directory_v3.py @@ -3,6 +3,7 @@ import grpc import pytest +from aserto.client.directory import ConfigError from aserto.client.directory.v3 import ( Directory, ETagMismatchError, @@ -15,8 +16,6 @@ Struct, ) -from aserto.client.directory import ConfigError - @pytest.fixture(scope="module") def directory(topaz): @@ -28,10 +27,12 @@ def directory(topaz): client.close() + def test_client_without_address(topaz): with pytest.raises(ValueError): Directory(ca_cert_path=topaz.directory_grpc.ca_cert_path) + def test_client_without_writer(topaz): client = Directory( reader_address=topaz.directory_grpc.address, ca_cert_path=topaz.directory_grpc.ca_cert_path @@ -279,6 +280,38 @@ def test_check_permission(directory: Directory): assert check_false == False +def test_find_objects(directory: Directory): + results = directory.find_objects( + object_type="user", + relation="complain", + subject_type="user", + subject_id="morty@the-citadel.com", + explain=True, + trace=True, + ) + + assert len(results.results) == 1 + assert results.results[0].id == "rick@the-citadel.com" + assert results.results[0].type == "user" + + assert "user:rick@the-citadel.com" in results.explanation + assert len(results.explanation["user:rick@the-citadel.com"]) == 1 + assert len(results.explanation["user:rick@the-citadel.com"][0]) == 1 + assert isinstance(results.explanation["user:rick@the-citadel.com"][0][0], str) + + +def test_find_subjects(directory: Directory): + results = directory.find_subjects( + object_type="user", + object_id="rick@the-citadel.com", + relation="complain", + subject_type="user", + ) + + assert len(results.results) == 3 + assert ObjectIdentifier(type="user", id="morty@the-citadel.com") in results.results + + def test_get_manifest(directory: Directory): manifest = directory.get_manifest() diff --git a/test/test_directory_v3_async.py b/test/test_directory_v3_async.py index 2667dfa..2559f0a 100644 --- a/test/test_directory_v3_async.py +++ b/test/test_directory_v3_async.py @@ -1,9 +1,10 @@ import asyncio import datetime -import pytest from grpc import RpcError +import pytest +from aserto.client.directory import ConfigError from aserto.client.directory.v3.aio import ( Directory, ETagMismatchError, @@ -16,8 +17,6 @@ Struct, ) -from aserto.client.directory import ConfigError - @pytest.fixture(scope="session") def event_loop(): @@ -38,10 +37,12 @@ async def directory(topaz): await client.close() + def test_client_without_address(topaz): with pytest.raises(ValueError): Directory(ca_cert_path=topaz.directory_grpc.ca_cert_path) + @pytest.mark.asyncio async def test_client_without_writer(topaz): client = Directory( @@ -311,6 +312,40 @@ async def test_check_permission(directory: Directory): assert check_false == False +@pytest.mark.asyncio +async def test_find_objects(directory: Directory): + results = await directory.find_objects( + object_type="user", + relation="complain", + subject_type="user", + subject_id="morty@the-citadel.com", + explain=True, + trace=True, + ) + + assert len(results.results) == 1 + assert results.results[0].id == "rick@the-citadel.com" + assert results.results[0].type == "user" + + assert "user:rick@the-citadel.com" in results.explanation + assert len(results.explanation["user:rick@the-citadel.com"]) == 1 + assert len(results.explanation["user:rick@the-citadel.com"][0]) == 1 + assert isinstance(results.explanation["user:rick@the-citadel.com"][0][0], str) + + +@pytest.mark.asyncio +async def test_find_subjects(directory: Directory): + results = await directory.find_subjects( + object_type="user", + object_id="rick@the-citadel.com", + relation="complain", + subject_type="user", + ) + + assert len(results.results) == 3 + assert ObjectIdentifier(type="user", id="morty@the-citadel.com") in results.results + + @pytest.mark.asyncio async def test_get_manifest(directory: Directory): manifest = await directory.get_manifest()