From 8c43f9fec70ce7f29b0f0e697ed4ba31266cf16b Mon Sep 17 00:00:00 2001 From: btemplep Date: Sat, 29 Aug 2026 00:24:14 -0400 Subject: [PATCH 1/6] working --- CHANGELOG.md | 18 + pyproject.toml | 3 +- src/authzee/compute/compute_module.py | 44 ++- src/authzee/exceptions.py | 16 - src/authzee/storage/dict_storage.py | 5 +- src/authzee/storage/sql_storage.py | 492 ++++++++++++++++++++++++++ src/authzee/storage/storage_module.py | 84 +++-- src/authzee/types/authzee.py | 4 +- tests/unit/test_dict_storage.py | 268 +++++++------- tests/unit/test_exceptions.py | 24 -- tests/unit/test_in_process_compute.py | 169 +++++---- 11 files changed, 830 insertions(+), 297 deletions(-) create mode 100644 src/authzee/storage/sql_storage.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b7c1d5..265a1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## [Unreleased] - YYYY-MM-DD + +### Added +- `SQLStorage` - SQL based storage module. + +### Changed +- `ComputeModule` and `StorageModule` base classes now inherit from ABC. + +### Deprecated + +### Removed +- `NotImplementedError` since base classes now use auto checks from ABC. + +### Fixed + +### Security + + ## [0.1.0a6] - 2026-08-27 Support for Authzee spec 0.5.0. diff --git a/pyproject.toml b/pyproject.toml index 75852b1..a8fcb8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,8 @@ dependencies = [ [project.optional-dependencies] jmespath = ["jmespath"] -all = ["authzee[jmespath]"] +sql = ["SQLAlchemy"] +all = ["authzee[jmespath,sql]"] dev = [ "build", "coverage", diff --git a/src/authzee/compute/compute_module.py b/src/authzee/compute/compute_module.py index a9c0003..504f89a 100644 --- a/src/authzee/compute/compute_module.py +++ b/src/authzee/compute/compute_module.py @@ -7,9 +7,9 @@ "ComputeModule" ] +from abc import ABC, abstractmethod from typing import Any, Callable, Type -from authzee.exceptions import NotImplementedError from authzee.module_locality import ModuleLocality from authzee.storage.storage_module import StorageModule from authzee.types.authzee import * @@ -31,9 +31,10 @@ ) -class ComputeModule: +class ComputeModule(ABC): + @abstractmethod async def start( self, execute: Callable[[str, Any], Any], @@ -55,62 +56,70 @@ async def start( self.has_parallel_paging = False + @abstractmethod async def shutdown(self, config: ComputeShutdownConfig) -> GenericResult: """Shutdown Compute module. - clean up runtime resources """ - raise NotImplementedError() + ... + @abstractmethod async def construct(self, config: ComputeConstructConfig) -> GenericResult: """Construct backend resources for compute. - one time setup """ - raise NotImplementedError() + ... + @abstractmethod async def destroy(self, config: ComputeDestroyConfig) -> GenericResult: """Tear down backend resources. - destructive - may lose all long lasting compute resources """ - raise NotImplementedError() + ... + @abstractmethod async def validate_context_def( self, context_def: ContextDef, config: ValidateContextDefConfig ) -> GenericResult: - raise NotImplementedError() + ... + @abstractmethod async def validate_identity_def( self, identity_def: IdentityDef, config: ValidateIdentityDefConfig ) -> GenericResult: - raise NotImplementedError() + ... + @abstractmethod async def validate_resource_def( self, resource_def: ResourceDef, config: ValidateResourceDefConfig ) -> GenericResult: - raise NotImplementedError() + ... + @abstractmethod async def validate_grant( self, grant: Grant, config: ValidateGrantConfig ) -> GenericResult: - raise NotImplementedError() + ... + @abstractmethod async def validate_request( self, request: AuthzeeRequest, @@ -118,9 +127,10 @@ async def validate_request( ) -> GenericResult: """Validate a request. """ - raise NotImplementedError() + ... + @abstractmethod async def validate_batch_request( self, batch_request: AuthzeeBatchRequest, @@ -128,9 +138,10 @@ async def validate_batch_request( ) -> ValidateBatchRequestResult: """Validate a batch request. """ - raise NotImplementedError() + ... + @abstractmethod async def audit( self, request: AuthzeeRequest, @@ -141,9 +152,10 @@ async def audit( Pass the returned page reference to get the next page until a null page reference is returned. """ - raise NotImplementedError() + ... + @abstractmethod async def authorize( self, request: AuthzeeRequest, @@ -151,9 +163,10 @@ async def authorize( ) -> AuthorizeResult: """Run the Authorize Operation. """ - raise NotImplementedError() + ... + @abstractmethod async def batch_audit( self, batch_request: AuthzeeBatchRequest, @@ -164,9 +177,10 @@ async def batch_audit( Pass the returned page reference to get the next page until a null page reference is returned. """ - raise NotImplementedError() + ... + @abstractmethod async def batch_authorize( self, batch_request: AuthzeeBatchRequest, @@ -174,4 +188,4 @@ async def batch_authorize( ) -> BatchAuthorizeResult: """Run the Batch Authorize Operation. """ - raise NotImplementedError() + ... diff --git a/src/authzee/exceptions.py b/src/authzee/exceptions.py index feae3ee..30c289c 100644 --- a/src/authzee/exceptions.py +++ b/src/authzee/exceptions.py @@ -9,7 +9,6 @@ "DefinitionError", "GrantError", "LocalityIncompatibilityError", - "NotImplementedError", "ParallelPaginationNotSupported", "RequestError", "ResourceNotFoundError", @@ -70,20 +69,6 @@ class LocalityIncompatibilityError(AuthzeeSDKError): pass -class NotImplementedError(AuthzeeSDKError): - """The given method is not implemented for this class. - """ - - - def __init__( - self, - msg: str="This method is not implemented.", - *args, - **kwargs - ): - super().__init__(msg, *args, **kwargs) - - class ParallelPaginationNotSupported(AuthzeeSDKError): """Parallel pagination is not supported. """ @@ -112,7 +97,6 @@ class ResourceNotFoundError(StorageError): "grant": GrantError, "request": RequestError, "locality_incompatibility": LocalityIncompatibilityError, - "not_implemented": NotImplementedError, "parallel_pagination_not_supported": ParallelPaginationNotSupported, "compute": ComputeError, "storage": StorageError, diff --git a/src/authzee/storage/dict_storage.py b/src/authzee/storage/dict_storage.py index 1fa5c05..d21b4b7 100644 --- a/src/authzee/storage/dict_storage.py +++ b/src/authzee/storage/dict_storage.py @@ -451,7 +451,7 @@ async def create_latch(self, config: CreateLatchConfig) -> StorageLatchResult: latch = { "storage_latch_uuid": latch_uuid, "is_set": False, - "created_at": datetime.datetime.now(tz=datetime.timezone.utc) + "created_at": datetime.datetime.now(tz=datetime.timezone.utc).isoformat() } self._storage_dict['latches_lut'][latch_uuid] = latch @@ -523,8 +523,9 @@ async def cleanup_latches( config: CleanupLatchesConfig ) -> GenericResult: new_lut = {} + before_str = before.astimezone(datetime.UTC).isoformat() for lu, l in self._storage_dict['latches_lut'].items(): - if l['created_at'] > before: + if l['created_at'] > before_str: new_lut[lu] = l self._storage_dict['latches_lut'] = new_lut diff --git a/src/authzee/storage/sql_storage.py b/src/authzee/storage/sql_storage.py new file mode 100644 index 0000000..8b2ccb8 --- /dev/null +++ b/src/authzee/storage/sql_storage.py @@ -0,0 +1,492 @@ +"""""" + +__all__ = [ + "SQLStorage" +] + +import datetime +import json +from typing import Any, Literal +from uuid import UUID + +from sqlalchemy.types import JSON +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from sqlalchemy import delete, event, select +from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession, create_async_engine + +from authzee.storage.storage_module import StorageModule +from authzee.exceptions import StorageError +from authzee.module_locality import ModuleLocality +from authzee.types.authzee import * +from authzee.types.config import ( + CleanupLatchesConfig, + CreateLatchConfig, + DeleteContextDefConfig, + DeleteIdentityDefConfig, + DeleteLatchConfig, + DeleteResourceDefConfig, + EnactConfig, + GetContextDefConfig, + GetGrantConfig, + GetIdentityDefConfig, + GetLatchConfig, + GetResourceDefConfig, + ListContextDefsConfig, + ListGrantRefsConfig, + ListGrantsConfig, + ListIdentityDefsConfig, + ListResourceDefsConfig, + PutContextDefConfig, + PutIdentityDefConfig, + PutResourceDefConfig, + RepealConfig, + SetLatchConfig, + StorageConstructConfig, + StorageDestroyConfig, + StorageShutdownConfig, + StorageStartConfig +) + + + +class Base(AsyncAttrs, DeclarativeBase): + type_annotation_map = { + dict[str, Any]: JSON, + dict[str, str]: JSON, + Any: JSON + } + + +class ContextDefDB(Base): + __tablename__ = "context_defs" + + context_type: Mapped[str] = mapped_column(primary_key=True, nullable=False) + schema: Mapped[dict[str, Any]] = mapped_column(nullable=False) + + +class IdentityDefDB(Base): + __tablename__ = "identity_defs" + + identity_type: Mapped[str] = mapped_column(primary_key=True, nullable=False) + schema: Mapped[dict[str, Any]] + + +class ResourceDefDB(Base): + __tablename__ = "resource_defs" + + resource_type: Mapped[str] = mapped_column(primary_key=True, nullable=False) + actions: Mapped[list[str]] = mapped_column(nullable=False) + schema: Mapped[dict[str, Any]] = mapped_column(nullable=False) + + +class GrantDB(Base): + __tablename__ = "grants" + + grant_uuid: Mapped[UUID] = mapped_column(primary_key=True, nullable=False) + name: Mapped[str] = mapped_column(nullable=False) + description: Mapped[str] = mapped_column(nullable=False) + tags: Mapped[dict[str, str]] = mapped_column(nullable=False) + effect: Mapped[Literal["allow", "deny"]] = mapped_column(nullable=False) + actions: Mapped[list[str]] = mapped_column(nullable=False) + name: Mapped[str] = mapped_column(nullable=False) + equality: Mapped[Any] = mapped_column(nullable=True) + applicable_on_failure: Mapped[bool] = mapped_column(nullable=False) + data: Mapped[dict[str, Any]] = mapped_column(nullable=False) + + +class StorageLatchDB(Base): + __tablename__ = "storage_latches" + + storage_latch_uuid: Mapped[UUID] = mapped_column(primary_key=True, nullable=False) + is_set: Mapped[bool] = mapped_column(nullable=False) + created_at: Mapped[datetime.datetime] = mapped_column(nullable=False) + + + +class SQLStorage(StorageModule): + """Storage Module using SQL. + + For best performance, use UUID7 for all UUID fields. + + Parameters + ---------- + sqlalchemy_async_engine_kwargs : dict[str, Any] + SQLAlchemy Async Engine keyword args. + https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.create_async_engine + """ + + + def __init__( + self, + *, + sqlalchemy_async_engine_kwargs: dict[str, Any] + ): + self._sqlalchemy_async_engine_kwargs = sqlalchemy_async_engine_kwargs + self.has_parallel_paging = True + self.locality = ModuleLocality.NETWORK + url = sqlalchemy_async_engine_kwargs['url'] + if url.endswith("://:memory:") is True: + self.locality = ModuleLocality.PROCESS + + if ( + url.startswith("sqlite") is True + or "://localhost" in url + or "://127.0.0.1" in url + ): + self.locality = ModuleLocality.SYSTEM + + + async def start(self, config: StorageStartConfig) -> GenericResult: + self._engine = create_async_engine(**self._sqlalchemy_async_engine_kwargs) + self._async_sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=self._engine, + expire_on_commit=False + ) + + return { + "error": None + } + + async def shutdown(self, config: StorageShutdownConfig) -> GenericResult: + await self._engine.dispose() + + return { + "error": None + } + + + async def construct(self, config: StorageConstructConfig) -> GenericResult: + async with self._engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + return { + "error": None + } + + + async def destroy(self, config: StorageDestroyConfig) -> GenericResult: + return { + "error": None + } + + + async def list_context_defs( + self, + page_ref: str | None, + config: ListContextDefsConfig + ) -> ContextDefsPage: + + + + async def add_grant(self, effect: GrantEffect, grant: Grant) -> Grant: + """Add a grant. + + Parameters + ---------- + effect : GrantEffect + The effect of the grant. + grant : Grant + The grant. + + Returns + ------- + Grant + The grant that has been added with additional information for the specific backend. + """ + grant = self._check_uuid(grant=grant, generate_uuid=True) + async with self._async_sessionmaker() as session: + resource_action_strs = {str(action) for action in grant.actions} + result = await session.execute( + select(ResourceActionDB).where( + ResourceActionDB.action.in_(resource_action_strs) + ) + ) + re_actions = set(result.scalars().fetchall()) + grant_kwargs = { + "uuid": grant.uuid, + "name": grant.name, + "description": grant.description, + "resource_type": grant.resource_type.__name__, + "actions": re_actions, + "expression": grant.expression, + "context": grant.context, + "equality": grant.equality + } + if effect is GrantEffect.ALLOW: + db_grant = AllowGrantDB(**grant_kwargs) + else: + db_grant = DenyGrantDB(**grant_kwargs) + + session.add(db_grant) + await session.commit() + grant.storage_id = db_grant.storage_id + + return grant + + + async def delete_grant(self, effect: GrantEffect, uuid: str) -> None: + """Delete a grant. + + Parameters + ---------- + effect : GrantEffect + The effect of the grant. + uuid : str + UUID of grant to delete. + """ + async with self._async_sessionmaker() as session: + if effect is GrantEffect.ALLOW: + grant_table = AllowGrantDB + else: + grant_table = DenyGrantDB + + result = await session.execute( + select(grant_table).where(grant_table.uuid == uuid) + ) + db_grant = result.scalars().unique().one_or_none() + if db_grant is None: + raise exceptions.GrantDoesNotExistError( + f"{effect.value} Grant with UUID: '{uuid}' does not exist." + ) + + await session.delete(db_grant) + await session.commit() + + + async def get_raw_grants_page( + self, + effect: GrantEffect, + resource_type: Optional[Type[BaseModel]] = None, + action: Optional[ResourceAction] = None, + page_size: Optional[int] = None, + page_ref: Optional[str] = None + ) -> RawGrantsPage: + """Retrieve a page of raw grants matching the filters. + + If ``RawGrantsPage.next_page_ref`` is not ``None`` , there are more grants to retrieve. + To get the next page, pass ``page_ref=RawGrantsPage.next_page_ref`` . + + Use ``normalize_raw_grants_page`` to convert the ``RawGrantsPage`` to a ``GrantsPage`` model. + + **NOTE** - There is no guarantee of how many grants will be returned if any. + + Parameters + ---------- + effect : GrantEffect + The effect of the grant. + resource_type : Optional[Type[BaseModel]], optional + Filter by resource type. + By default no filter is applied. + action : Optional[ResourceAction], optional + Filter by `ResourceAction``. + By default no filter is applied. + page_size : Optional[int], optional + The suggested page size to return. + There is no guarantee of how much data will be returned if any. + The default is set on the storage backend. + page_ref : Optional[str], optional + The reference to the next page that is returned in ``RawGrantsPage``, + or one of the page references from ``StorageBackend.get_page_ref_page()`` (if parallel pagination is supported.) . + By default this will return the first page. + + Returns + ------- + RawGrantsPage + The page of raw grants. + """ + page_size = self._real_page_size(page_size=page_size) + async with self._async_sessionmaker() as session: + if effect is GrantEffect.ALLOW: + grant_table = AllowGrantDB + else: + grant_table = DenyGrantDB + + query = select(grant_table) + filters = [] + if resource_type is not None: + filters.append( + grant_table.resource_type == resource_type.__name__ + ) + + if action is not None: + filters.append( + grant_table.actions.any( + ResourceActionDB.action == str(action) + ) + ) + + if page_ref is not None: + sql_next_page = SQLNextPageRef(**json.loads(page_ref)) + filters.append( + grant_table.storage_id > sql_next_page.next_token + ) + + query = query.where(*filters) + query = query.limit(page_size) + + result = await session.execute(query) + db_grants = result.scalars().unique().all() + next_page_ref = None + if len(db_grants) >= page_size: + next_page_ref = SQLNextPageRef(next_token=db_grants[-1].storage_id).model_dump_json() + + return RawGrantsPage( + raw_grants=db_grants, + next_page_ref=next_page_ref + ) + + + async def normalize_raw_grants_page( + self, + raw_grants_page: RawGrantsPage + ) -> GrantsPage: + """Convert a ``RawGrantsPage`` to a ``GrantsPage``. + + Parameters + ---------- + raw_grants_page : RawGrantsPage + Raw grants page to convert. + + Returns + ------- + GrantsPage + Normalized grants page. + """ + grants = [] + db_grants: list[Union[AllowGrantDB, DenyGrantDB]] = raw_grants_page.raw_grants + for db_grant in db_grants: + grants.append( + Grant( + name=db_grant.name, + description=db_grant.description, + resource_type=self._resource_type_lookup[db_grant.resource_type], + actions={ + self._resource_action_lookup[action.action] for action in db_grant.actions + }, + expression=db_grant.expression, + context=db_grant.context, + equality=db_grant.equality, + storage_id=str(db_grant.storage_id), + uuid=db_grant.uuid + ) + ) + + return GrantsPage( + grants=grants, + next_page_ref=raw_grants_page.next_page_ref + ) + + + async def create_flag(self) -> StorageFlag: + """Create a new shared flag in the storage backend. + + Returns + ------- + StorageFlag + New storage flag. + """ + new_flag = StorageFlag() + async with self._async_sessionmaker() as session: + db_flag = StorageFlagDB(**new_flag.model_dump()) + session.add(db_flag) + await session.commit() + + return new_flag + + + async def get_flag(self, uuid: str) -> StorageFlag: + """Retrieve flag by UUID. + + Parameters + ---------- + uuid : str + Storage flag UUID. + + Returns + ------- + StorageFlag + The storage flag with the given UUID. + + Raises + ------ + authzee.exceptions.StorageFlagNotFoundError + The storage flag with the given UUID was not found. + """ + async with self._async_sessionmaker() as session: + query = select(StorageFlagDB).where(StorageFlagDB.uuid == uuid) + result = await session.execute(query) + db_flag = result.scalars().unique().one_or_none() + if db_flag is None: + raise exceptions.StorageFlagNotFoundError( + f"The storage flag with UUID '{uuid}' was not found!" + ) + + await session.commit() + + return StorageFlag.model_validate(db_flag, from_attributes=True) + + + async def set_flag(self, uuid: str) -> StorageFlag: + """set a flag for a given UUID. + + Parameters + ---------- + uuid : str + Storage flag UUID. + + Returns + ------- + StorageFlag + The storage flag with the given UUID and the flag set. + + Raises + ------ + authzee.exceptions.StorageFlagNotFoundError + The storage flag with the given UUID was not found. + """ + async with self._async_sessionmaker() as session: + query = select(StorageFlagDB).where(StorageFlagDB.uuid == uuid) + result = await session.execute(query) + db_flag = result.scalars().unique().one_or_none() + if db_flag is None: + raise exceptions.StorageFlagNotFoundError( + f"The storage flag with UUID '{uuid}' was not found!" + ) + + db_flag.is_set = True + await session.commit() + + return StorageFlag.model_validate(db_flag, from_attributes=True) + + + async def delete_flag(self, uuid: str) -> None: + """Delete a storage flag by UUID. + + Parameters + ---------- + uuid : str + Storage flag UUID. + """ + async with self._async_sessionmaker() as session: + await session.execute( + delete(StorageFlagDB).where(StorageFlagDB.uuid == uuid) + ) + await session.commit() + + + async def cleanup_flags(self, earlier_than: datetime.datetime) -> None: + """Delete zombie storage flags from before a certain point in time. + + Parameters + ---------- + earlier_than : datetime.datetime + Delete flags created earlier than this date. + Naive datetimes are assumed to be UTC. + """ + async with self._async_sessionmaker() as session: + await session.execute( + delete(StorageFlagDB).where(StorageFlagDB.created_at < earlier_than) + ) + await session.commit() \ No newline at end of file diff --git a/src/authzee/storage/storage_module.py b/src/authzee/storage/storage_module.py index 7d68164..ca92011 100644 --- a/src/authzee/storage/storage_module.py +++ b/src/authzee/storage/storage_module.py @@ -7,9 +7,9 @@ "StorageModule" ] +from abc import ABC, abstractmethod import datetime -from authzee.exceptions import NotImplementedError from authzee.module_locality import ModuleLocality from authzee.types.authzee import * from authzee.types.config import ( @@ -42,13 +42,10 @@ ) -class StorageModule: - - - def __init__(self): - pass +class StorageModule(ABC): + @abstractmethod async def start(self, config: StorageStartConfig) -> GenericResult: """Start up storage module. @@ -65,30 +62,34 @@ async def start(self, config: StorageStartConfig) -> GenericResult: } + @abstractmethod async def shutdown(self, config: StorageShutdownConfig) -> GenericResult: """Shutdown storage module. - clean up runtime resources """ - raise NotImplementedError() + ... + @abstractmethod async def construct(self, config: StorageConstructConfig) -> GenericResult: """Construct backend resources for storage. - one time setup """ - raise NotImplementedError() + ... + @abstractmethod async def destroy(self, config: StorageDestroyConfig) -> GenericResult: """Tear down backend resources. - destructive - may lose all long lasting storage resources """ - raise NotImplementedError() + ... + @abstractmethod async def list_context_defs( self, page_ref: str | None, @@ -98,9 +99,10 @@ async def list_context_defs( Pass the returned page reference to get the next page until a null page reference is returned. """ - raise NotImplementedError() + ... + @abstractmethod async def get_context_def( self, context_type: str, @@ -108,9 +110,10 @@ async def get_context_def( ) -> ContextDefResult: """Get a context definition by type. """ - raise NotImplementedError() + ... + @abstractmethod async def put_context_def( self, context_def: ContextDef, @@ -118,9 +121,10 @@ async def put_context_def( ) -> GenericResult: """Add a new Context Definition or update an existing one. """ - raise NotImplementedError() + ... + @abstractmethod async def delete_context_def( self, context_type: str, @@ -128,9 +132,10 @@ async def delete_context_def( ) -> GenericResult: """Delete a context definition by type. """ - raise NotImplementedError() + ... + @abstractmethod async def list_identity_defs( self, page_ref: str | None, @@ -140,9 +145,10 @@ async def list_identity_defs( Pass the returned page reference to get the next page until a null page reference is returned. """ - raise NotImplementedError() + ... + @abstractmethod async def get_identity_def( self, identity_type: str, @@ -150,9 +156,10 @@ async def get_identity_def( ) -> IdentityDefResult: """Get an identity definition by type. """ - raise NotImplementedError() + ... + @abstractmethod async def put_identity_def( self, identity_def: IdentityDef, @@ -160,9 +167,10 @@ async def put_identity_def( ) -> GenericResult: """Add a new Identity Definition or update an existing one. """ - raise NotImplementedError() + ... + @abstractmethod async def delete_identity_def( self, identity_type: str, @@ -170,9 +178,10 @@ async def delete_identity_def( ) -> GenericResult: """Delete an identity definition by type. """ - raise NotImplementedError() + ... + @abstractmethod async def list_resource_defs( self, page_ref: str | None, @@ -182,9 +191,10 @@ async def list_resource_defs( Pass the returned page reference to get the next page until a null page reference is returned. """ - raise NotImplementedError() + ... + @abstractmethod async def get_resource_def( self, resource_type: str, @@ -192,9 +202,10 @@ async def get_resource_def( ) -> ResourceDefResult: """Get a resource definition by type. """ - raise NotImplementedError() + ... + @abstractmethod async def put_resource_def( self, resource_def: ResourceDef, @@ -202,9 +213,10 @@ async def put_resource_def( ) -> GenericResult: """Add a new Resource Definition or update an existing one. """ - raise NotImplementedError() + ... + @abstractmethod async def delete_resource_def( self, resource_type: str, @@ -212,15 +224,17 @@ async def delete_resource_def( ) -> GenericResult: """Delete a resource definition by type. """ - raise NotImplementedError() + ... + @abstractmethod async def enact(self, grant: Grant, config: EnactConfig) -> GenericResult: """Add a new grant. """ - raise NotImplementedError() + ... + @abstractmethod async def repeal( self, grant_uuid: str, @@ -229,9 +243,10 @@ async def repeal( ) -> GenericResult: """Delete a grant. """ - raise NotImplementedError() + ... + @abstractmethod async def get_grant( self, grant_uuid: str, @@ -239,9 +254,10 @@ async def get_grant( ) -> GrantResult: """Get a grant by UUID. """ - raise NotImplementedError() + ... + @abstractmethod async def list_grants( self, effect: str | None, @@ -253,9 +269,10 @@ async def list_grants( Pass the returned page reference to get the next page until a null page reference is returned. """ - raise NotImplementedError() + ... + @abstractmethod async def list_grant_refs( self, effect: str | None, @@ -270,15 +287,17 @@ async def list_grant_refs( For some storage modules this may not be possible. Check the `parallel_paging` attribute on the storage module after `start()` is complete. """ - raise NotImplementedError() + ... + @abstractmethod async def create_latch(self, config: CreateLatchConfig) -> StorageLatchResult: """Create a new [storage latch](#storage-latches). """ - raise NotImplementedError() + ... + @abstractmethod async def get_latch( self, storage_latch_uuid: str, @@ -286,9 +305,10 @@ async def get_latch( ) -> StorageLatchResult: """Get a [storage latch](#storage-latches) by UUID. """ - raise NotImplementedError() + ... + @abstractmethod async def set_latch( self, storage_latch_uuid: str, @@ -296,9 +316,10 @@ async def set_latch( ) -> StorageLatchResult: """Set a [storage latch](#storage-latches) by UUID. """ - raise NotImplementedError() + ... + @abstractmethod async def delete_latch( self, storage_latch_uuid: str, @@ -306,9 +327,10 @@ async def delete_latch( ) -> GenericResult: """Delete a [storage latch](#storage-latches) by UUID. """ - raise NotImplementedError() + ... + @abstractmethod async def cleanup_latches( self, before: datetime.datetime, @@ -318,4 +340,4 @@ async def cleanup_latches( - operations should clean up their own latches, but in case of a failure this can be used to clean up zombie latches. """ - raise NotImplementedError() + ... diff --git a/src/authzee/types/authzee.py b/src/authzee/types/authzee.py index ecc2e66..eff30d6 100644 --- a/src/authzee/types/authzee.py +++ b/src/authzee/types/authzee.py @@ -518,7 +518,7 @@ class StorageLatch(TypedDict): { "storage_latch_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", "is_set": False, - "created_at": "2026-04-26T16:21:10.521220" + "created_at": "2026-04-26T16:21:10.521220Z" } ``` """ @@ -539,7 +539,7 @@ class StorageLatchResult(TypedDict): "storage_latch": { "storage_latch_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", "is_set": False, - "created_at": "2026-04-26T16:21:10.521220" + "created_at": "2026-04-26T16:21:10.521220Z" }, "error": { # or None "error_type": "", diff --git a/tests/unit/test_dict_storage.py b/tests/unit/test_dict_storage.py index c92c949..aaca9af 100644 --- a/tests/unit/test_dict_storage.py +++ b/tests/unit/test_dict_storage.py @@ -45,198 +45,192 @@ def storage(storage_dict): return s -def test_storage_module_start(): - sm = StorageModule() - result = asyncio.run(sm.start(config={})) - assert sm.locality == ModuleLocality.PROCESS - assert sm.has_parallel_paging is False +def test_storage_module_cannot_instantiate_abstract(): + with pytest.raises(TypeError): + StorageModule() -def test_storage_module_shutdown_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.shutdown(config={})) +class _ConcreteStorageModule(StorageModule): + """Minimal concrete StorageModule that defers to the abstract base bodies. + Used to exercise the base-class method bodies for coverage. Every method + calls ``super()`` so the base implementation runs. + """ -def test_storage_module_construct_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.construct(config={})) + async def start(self, config): + return await super().start(config=config) -def test_storage_module_destroy_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.destroy(config={})) + async def shutdown(self, config): + return await super().shutdown(config=config) -def test_storage_module_list_context_defs_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.list_context_defs(page_ref=None, config={})) + async def construct(self, config): + return await super().construct(config=config) -def test_storage_module_get_context_def_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.get_context_def(context_type="x", config={})) + async def destroy(self, config): + return await super().destroy(config=config) -def test_storage_module_put_context_def_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.put_context_def(context_def={}, config={})) + async def list_context_defs(self, page_ref, config): + return await super().list_context_defs(page_ref=page_ref, config=config) -def test_storage_module_delete_context_def_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run( - sm.delete_context_def(context_type="x", config={}) - ) + async def get_context_def(self, context_type, config): + return await super().get_context_def(context_type=context_type, config=config) -def test_storage_module_list_identity_defs_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.list_identity_defs(page_ref=None, config={})) + async def put_context_def(self, context_def, config): + return await super().put_context_def(context_def=context_def, config=config) -def test_storage_module_get_identity_def_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run( - sm.get_identity_def(identity_type="x", config={}) - ) + async def delete_context_def(self, context_type, config): + return await super().delete_context_def(context_type=context_type, config=config) -def test_storage_module_put_identity_def_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.put_identity_def(identity_def={}, config={})) + async def list_identity_defs(self, page_ref, config): + return await super().list_identity_defs(page_ref=page_ref, config=config) -def test_storage_module_delete_identity_def_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run( - sm.delete_identity_def(identity_type="x", config={}) - ) + async def get_identity_def(self, identity_type, config): + return await super().get_identity_def(identity_type=identity_type, config=config) -def test_storage_module_list_resource_defs_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.list_resource_defs(page_ref=None, config={})) + async def put_identity_def(self, identity_def, config): + return await super().put_identity_def(identity_def=identity_def, config=config) -def test_storage_module_get_resource_def_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run( - sm.get_resource_def(resource_type="x", config={}) - ) + async def delete_identity_def(self, identity_type, config): + return await super().delete_identity_def(identity_type=identity_type, config=config) -def test_storage_module_put_resource_def_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.put_resource_def(resource_def={}, config={})) + async def list_resource_defs(self, page_ref, config): + return await super().list_resource_defs(page_ref=page_ref, config=config) -def test_storage_module_delete_resource_def_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run( - sm.delete_resource_def(resource_type="x", config={}) - ) + async def get_resource_def(self, resource_type, config): + return await super().get_resource_def(resource_type=resource_type, config=config) -def test_storage_module_enact_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.enact(grant={}, config={})) + async def put_resource_def(self, resource_def, config): + return await super().put_resource_def(resource_def=resource_def, config=config) -def test_storage_module_repeal_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run( - sm.repeal( - grant_uuid="x", - purge=False, - config={} - ) - ) + async def delete_resource_def(self, resource_type, config): + return await super().delete_resource_def(resource_type=resource_type, config=config) -def test_storage_module_get_grant_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.get_grant(grant_uuid="x", config={})) + async def enact(self, grant, config): + return await super().enact(grant=grant, config=config) -def test_storage_module_list_grants_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run( - sm.list_grants( - effect=None, - action=None, - page_ref=None, - config={} - ) + + async def repeal(self, grant_uuid, purge, config): + return await super().repeal(grant_uuid=grant_uuid, purge=purge, config=config) + + + async def get_grant(self, grant_uuid, config): + return await super().get_grant(grant_uuid=grant_uuid, config=config) + + + async def list_grants(self, effect, action, page_ref, config): + return await super().list_grants( + effect=effect, + action=action, + page_ref=page_ref, + config=config ) -def test_storage_module_list_grant_refs_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run( - sm.list_grant_refs( - effect=None, - action=None, - page_ref=None, - config={} - ) + async def list_grant_refs(self, effect, action, page_ref, config): + return await super().list_grant_refs( + effect=effect, + action=action, + page_ref=page_ref, + config=config ) -def test_storage_module_create_latch_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.create_latch(config={})) + async def create_latch(self, config): + return await super().create_latch(config=config) -def test_storage_module_get_latch_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.get_latch(storage_latch_uuid="x", config={})) + async def get_latch(self, storage_latch_uuid, config): + return await super().get_latch(storage_latch_uuid=storage_latch_uuid, config=config) -def test_storage_module_set_latch_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run(sm.set_latch(storage_latch_uuid="x", config={})) + async def set_latch(self, storage_latch_uuid, config): + return await super().set_latch(storage_latch_uuid=storage_latch_uuid, config=config) -def test_storage_module_delete_latch_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run( - sm.delete_latch(storage_latch_uuid="x", config={}) - ) + async def delete_latch(self, storage_latch_uuid, config): + return await super().delete_latch(storage_latch_uuid=storage_latch_uuid, config=config) -def test_storage_module_cleanup_latches_raises(): - sm = StorageModule() - with pytest.raises(TypeError): - asyncio.run( - sm.cleanup_latches( + async def cleanup_latches(self, before, config): + return await super().cleanup_latches(before=before, config=config) + + +def test_storage_module_base_start_sets_defaults(): + sm = _ConcreteStorageModule() + result = asyncio.run(sm.start(config={})) + assert result['error'] is None + assert sm.locality == ModuleLocality.PROCESS + assert sm.has_parallel_paging is False + + +def test_storage_module_base_methods_return_none(): + sm = _ConcreteStorageModule() + + async def run(): + return [ + await sm.shutdown(config={}), + await sm.construct(config={}), + await sm.destroy(config={}), + await sm.list_context_defs(page_ref=None, config={}), + await sm.get_context_def(context_type="x", config={}), + await sm.put_context_def(context_def={}, config={}), + await sm.delete_context_def(context_type="x", config={}), + await sm.list_identity_defs(page_ref=None, config={}), + await sm.get_identity_def(identity_type="x", config={}), + await sm.put_identity_def(identity_def={}, config={}), + await sm.delete_identity_def(identity_type="x", config={}), + await sm.list_resource_defs(page_ref=None, config={}), + await sm.get_resource_def(resource_type="x", config={}), + await sm.put_resource_def(resource_def={}, config={}), + await sm.delete_resource_def(resource_type="x", config={}), + await sm.enact(grant={}, config={}), + await sm.repeal( + grant_uuid="x", + purge=False, + config={} + ), + await sm.get_grant(grant_uuid="x", config={}), + await sm.list_grants( + effect=None, + action=None, + page_ref=None, + config={} + ), + await sm.list_grant_refs( + effect=None, + action=None, + page_ref=None, + config={} + ), + await sm.create_latch(config={}), + await sm.get_latch(storage_latch_uuid="x", config={}), + await sm.set_latch(storage_latch_uuid="x", config={}), + await sm.delete_latch(storage_latch_uuid="x", config={}), + await sm.cleanup_latches( before=datetime.datetime.now(), config={} ) - ) + ] + + results = asyncio.run(run()) + assert all(r is None for r in results) def test_dict_storage_start_parallel_paging(storage_dict): diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index e3abc69..7faf92a 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -11,7 +11,6 @@ _exception_map, GrantError, LocalityIncompatibilityError, - NotImplementedError as AuthzeeNotImplementedError, ParallelPaginationNotSupported, RequestError, ResourceNotFoundError, @@ -95,28 +94,6 @@ def test_locality_incompatibility_error(): assert isinstance(exc, AuthzeeSDKError) -def test_not_implemented_error_default_message(): - result = { - "error": { - "error_type": "not_implemented", - "message": "This method is not implemented." - } - } - exc = AuthzeeNotImplementedError(result=result) - assert "not implemented" in exc.message.lower() - - -def test_not_implemented_error_custom_message(): - result = { - "error": { - "error_type": "not_implemented", - "message": "Custom msg" - } - } - exc = AuthzeeNotImplementedError("Custom msg", result=result) - assert exc.message == "Custom msg" - - def test_parallel_pagination_not_supported(): result = { "error": { @@ -167,7 +144,6 @@ def test_exception_map_contains_expected_keys(): "grant", "request", "locality_incompatibility", - "not_implemented", "parallel_pagination_not_supported", "compute", "storage", diff --git a/tests/unit/test_in_process_compute.py b/tests/unit/test_in_process_compute.py index 2bdc366..04ed362 100644 --- a/tests/unit/test_in_process_compute.py +++ b/tests/unit/test_in_process_compute.py @@ -21,6 +21,7 @@ from authzee.compute.compute_module import ComputeModule from authzee.compute.in_process_compute import InProcessCompute from authzee.jmespath import jmespath_execute +from authzee.module_locality import ModuleLocality from authzee.storage.dict_storage import DictStorage @@ -247,99 +248,129 @@ async def setup(): return compute -def test_compute_module_shutdown_raises(): - cm = ComputeModule() +def test_compute_module_cannot_instantiate_abstract(): with pytest.raises(TypeError): - asyncio.run(cm.shutdown(config={})) + ComputeModule() -def test_compute_module_construct_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run(cm.construct(config={})) - +class _ConcreteComputeModule(ComputeModule): + """Minimal concrete ComputeModule that defers to the abstract base bodies. -def test_compute_module_destroy_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run(cm.destroy(config={})) + Used to exercise the base-class method bodies for coverage. Every method + calls ``super()`` so the base implementation runs. + """ -def test_compute_module_validate_context_def_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run( - cm.validate_context_def(context_def={}, config={}) + async def start( + self, + execute, + storage_type, + storage_kwargs, + config + ): + return await super().start( + execute=execute, + storage_type=storage_type, + storage_kwargs=storage_kwargs, + config=config ) -def test_compute_module_validate_identity_def_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run( - cm.validate_identity_def(identity_def={}, config={}) - ) + async def shutdown(self, config): + return await super().shutdown(config=config) -def test_compute_module_validate_resource_def_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run( - cm.validate_resource_def(resource_def={}, config={}) - ) + async def construct(self, config): + return await super().construct(config=config) -def test_compute_module_validate_grant_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run(cm.validate_grant(grant={}, config={})) + async def destroy(self, config): + return await super().destroy(config=config) -def test_compute_module_validate_request_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run(cm.validate_request(request={}, config={})) + async def validate_context_def(self, context_def, config): + return await super().validate_context_def(context_def=context_def, config=config) -def test_compute_module_validate_batch_request_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run( - cm.validate_batch_request(batch_request={}, config={}) - ) + async def validate_identity_def(self, identity_def, config): + return await super().validate_identity_def(identity_def=identity_def, config=config) -def test_compute_module_audit_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run( - cm.audit( - request={}, - page_ref=None, - config={} - ) - ) + async def validate_resource_def(self, resource_def, config): + return await super().validate_resource_def(resource_def=resource_def, config=config) -def test_compute_module_authorize_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run(cm.authorize(request={}, config={})) + async def validate_grant(self, grant, config): + return await super().validate_grant(grant=grant, config=config) -def test_compute_module_batch_audit_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run( - cm.batch_audit( + async def validate_request(self, request, config): + return await super().validate_request(request=request, config=config) + + + async def validate_batch_request(self, batch_request, config): + return await super().validate_batch_request(batch_request=batch_request, config=config) + + + async def audit(self, request, page_ref, config): + return await super().audit(request=request, page_ref=page_ref, config=config) + + + async def authorize(self, request, config): + return await super().authorize(request=request, config=config) + + + async def batch_audit(self, batch_request, page_ref, config): + return await super().batch_audit(batch_request=batch_request, page_ref=page_ref, config=config) + + + async def batch_authorize(self, batch_request, config): + return await super().batch_authorize(batch_request=batch_request, config=config) + + +def test_compute_module_base_start_sets_defaults(): + cm = _ConcreteComputeModule() + + async def run(): + await cm.start( + execute=jmespath_execute, + storage_type=DictStorage, + storage_kwargs={}, + config={} + ) + + asyncio.run(run()) + assert cm.locality == ModuleLocality.PROCESS + assert cm.has_parallel_paging is False + + +def test_compute_module_base_methods_return_none(): + cm = _ConcreteComputeModule() + + async def run(): + return [ + await cm.shutdown(config={}), + await cm.construct(config={}), + await cm.destroy(config={}), + await cm.validate_context_def(context_def={}, config={}), + await cm.validate_identity_def(identity_def={}, config={}), + await cm.validate_resource_def(resource_def={}, config={}), + await cm.validate_grant(grant={}, config={}), + await cm.validate_request(request={}, config={}), + await cm.validate_batch_request(batch_request={}, config={}), + await cm.audit( + request={}, + page_ref=None, + config={} + ), + await cm.authorize(request={}, config={}), + await cm.batch_audit( batch_request={}, page_ref=None, config={} - ) - ) + ), + await cm.batch_authorize(batch_request={}, config={}) + ] - -def test_compute_module_batch_authorize_raises(): - cm = ComputeModule() - with pytest.raises(TypeError): - asyncio.run(cm.batch_authorize(batch_request={}, config={})) + results = asyncio.run(run()) + assert all(r is None for r in results) From 8f1560a81b632dd598c222038102fefb5782e1c9 Mon Sep 17 00:00:00 2001 From: btemplep Date: Sun, 30 Aug 2026 13:44:57 -0400 Subject: [PATCH 2/6] stubbed --- src/authzee/storage/sql_storage.py | 736 +++++++++++++++++------------ 1 file changed, 424 insertions(+), 312 deletions(-) diff --git a/src/authzee/storage/sql_storage.py b/src/authzee/storage/sql_storage.py index 8b2ccb8..293bb5b 100644 --- a/src/authzee/storage/sql_storage.py +++ b/src/authzee/storage/sql_storage.py @@ -9,16 +9,19 @@ from typing import Any, Literal from uuid import UUID -from sqlalchemy.types import JSON +from sqlalchemy import delete, event, select +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine +) from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.types import JSON -from sqlalchemy import delete, event, select -from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession, create_async_engine - -from authzee.storage.storage_module import StorageModule from authzee.exceptions import StorageError from authzee.module_locality import ModuleLocality +from authzee.storage.storage_module import StorageModule from authzee.types.authzee import * from authzee.types.config import ( CleanupLatchesConfig, @@ -50,7 +53,6 @@ ) - class Base(AsyncAttrs, DeclarativeBase): type_annotation_map = { dict[str, Any]: JSON, @@ -61,21 +63,18 @@ class Base(AsyncAttrs, DeclarativeBase): class ContextDefDB(Base): __tablename__ = "context_defs" - context_type: Mapped[str] = mapped_column(primary_key=True, nullable=False) schema: Mapped[dict[str, Any]] = mapped_column(nullable=False) class IdentityDefDB(Base): __tablename__ = "identity_defs" - identity_type: Mapped[str] = mapped_column(primary_key=True, nullable=False) schema: Mapped[dict[str, Any]] class ResourceDefDB(Base): __tablename__ = "resource_defs" - resource_type: Mapped[str] = mapped_column(primary_key=True, nullable=False) actions: Mapped[list[str]] = mapped_column(nullable=False) schema: Mapped[dict[str, Any]] = mapped_column(nullable=False) @@ -83,7 +82,6 @@ class ResourceDefDB(Base): class GrantDB(Base): __tablename__ = "grants" - grant_uuid: Mapped[UUID] = mapped_column(primary_key=True, nullable=False) name: Mapped[str] = mapped_column(nullable=False) description: Mapped[str] = mapped_column(nullable=False) @@ -98,13 +96,11 @@ class GrantDB(Base): class StorageLatchDB(Base): __tablename__ = "storage_latches" - storage_latch_uuid: Mapped[UUID] = mapped_column(primary_key=True, nullable=False) is_set: Mapped[bool] = mapped_column(nullable=False) created_at: Mapped[datetime.datetime] = mapped_column(nullable=False) - class SQLStorage(StorageModule): """Storage Module using SQL. @@ -113,53 +109,75 @@ class SQLStorage(StorageModule): Parameters ---------- sqlalchemy_async_engine_kwargs : dict[str, Any] - SQLAlchemy Async Engine keyword args. + SQLAlchemy Async Engine keyword args. https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.create_async_engine """ - def __init__( - self, - *, - sqlalchemy_async_engine_kwargs: dict[str, Any] - ): + def __init__(self, *, sqlalchemy_async_engine_kwargs: dict[str, Any]): self._sqlalchemy_async_engine_kwargs = sqlalchemy_async_engine_kwargs self.has_parallel_paging = True self.locality = ModuleLocality.NETWORK - url = sqlalchemy_async_engine_kwargs['url'] + url: str = sqlalchemy_async_engine_kwargs['url'] if url.endswith("://:memory:") is True: self.locality = ModuleLocality.PROCESS - + if ( url.startswith("sqlite") is True or "://localhost" in url or "://127.0.0.1" in url ): self.locality = ModuleLocality.SYSTEM - + async def start(self, config: StorageStartConfig) -> GenericResult: - self._engine = create_async_engine(**self._sqlalchemy_async_engine_kwargs) - self._async_sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker( - bind=self._engine, - expire_on_commit=False - ) + try: + self._engine = create_async_engine(**self._sqlalchemy_async_engine_kwargs) + self._async_sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=self._engine, + expire_on_commit=False + ) + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } return { "error": None } + async def shutdown(self, config: StorageShutdownConfig) -> GenericResult: - await self._engine.dispose() + try: + await self._engine.dispose() + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } return { "error": None } - + async def construct(self, config: StorageConstructConfig) -> GenericResult: - async with self._engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + try: + async with self._engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } return { "error": None @@ -167,6 +185,16 @@ async def construct(self, config: StorageConstructConfig) -> GenericResult: async def destroy(self, config: StorageDestroyConfig) -> GenericResult: + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + return { "error": None } @@ -177,316 +205,400 @@ async def list_context_defs( page_ref: str | None, config: ListContextDefsConfig ) -> ContextDefsPage: - + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } - async def add_grant(self, effect: GrantEffect, grant: Grant) -> Grant: - """Add a grant. + async def get_context_def( + self, + context_type: str, + config: GetContextDefConfig + ) -> ContextDefResult: + """Get a context definition by type. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } - Parameters - ---------- - effect : GrantEffect - The effect of the grant. - grant : Grant - The grant. - Returns - ------- - Grant - The grant that has been added with additional information for the specific backend. + async def put_context_def( + self, + context_def: ContextDef, + config: PutContextDefConfig + ) -> GenericResult: + """Add a new Context Definition or update an existing one. """ - grant = self._check_uuid(grant=grant, generate_uuid=True) - async with self._async_sessionmaker() as session: - resource_action_strs = {str(action) for action in grant.actions} - result = await session.execute( - select(ResourceActionDB).where( - ResourceActionDB.action.in_(resource_action_strs) - ) - ) - re_actions = set(result.scalars().fetchall()) - grant_kwargs = { - "uuid": grant.uuid, - "name": grant.name, - "description": grant.description, - "resource_type": grant.resource_type.__name__, - "actions": re_actions, - "expression": grant.expression, - "context": grant.context, - "equality": grant.equality + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } } - if effect is GrantEffect.ALLOW: - db_grant = AllowGrantDB(**grant_kwargs) - else: - db_grant = DenyGrantDB(**grant_kwargs) - session.add(db_grant) - await session.commit() - grant.storage_id = db_grant.storage_id - - return grant + async def delete_context_def( + self, + context_type: str, + config: DeleteContextDefConfig + ) -> GenericResult: + """Delete a context definition by type. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } - async def delete_grant(self, effect: GrantEffect, uuid: str) -> None: - """Delete a grant. - Parameters - ---------- - effect : GrantEffect - The effect of the grant. - uuid : str - UUID of grant to delete. + async def list_identity_defs( + self, + page_ref: str | None, + config: ListIdentityDefsConfig + ) -> IdentityDefsPage: + """Get a page of identity definitions. + + Pass the returned page reference to get the next page until a null page reference is returned. """ - async with self._async_sessionmaker() as session: - if effect is GrantEffect.ALLOW: - grant_table = AllowGrantDB - else: - grant_table = DenyGrantDB - - result = await session.execute( - select(grant_table).where(grant_table.uuid == uuid) - ) - db_grant = result.scalars().unique().one_or_none() - if db_grant is None: - raise exceptions.GrantDoesNotExistError( - f"{effect.value} Grant with UUID: '{uuid}' does not exist." - ) + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } - await session.delete(db_grant) - await session.commit() - - async def get_raw_grants_page( + async def get_identity_def( self, - effect: GrantEffect, - resource_type: Optional[Type[BaseModel]] = None, - action: Optional[ResourceAction] = None, - page_size: Optional[int] = None, - page_ref: Optional[str] = None - ) -> RawGrantsPage: - """Retrieve a page of raw grants matching the filters. - - If ``RawGrantsPage.next_page_ref`` is not ``None`` , there are more grants to retrieve. - To get the next page, pass ``page_ref=RawGrantsPage.next_page_ref`` . - - Use ``normalize_raw_grants_page`` to convert the ``RawGrantsPage`` to a ``GrantsPage`` model. - - **NOTE** - There is no guarantee of how many grants will be returned if any. - - Parameters - ---------- - effect : GrantEffect - The effect of the grant. - resource_type : Optional[Type[BaseModel]], optional - Filter by resource type. - By default no filter is applied. - action : Optional[ResourceAction], optional - Filter by `ResourceAction``. - By default no filter is applied. - page_size : Optional[int], optional - The suggested page size to return. - There is no guarantee of how much data will be returned if any. - The default is set on the storage backend. - page_ref : Optional[str], optional - The reference to the next page that is returned in ``RawGrantsPage``, - or one of the page references from ``StorageBackend.get_page_ref_page()`` (if parallel pagination is supported.) . - By default this will return the first page. - - Returns - ------- - RawGrantsPage - The page of raw grants. + identity_type: str, + config: GetIdentityDefConfig + ) -> IdentityDefResult: + """Get an identity definition by type. """ - page_size = self._real_page_size(page_size=page_size) - async with self._async_sessionmaker() as session: - if effect is GrantEffect.ALLOW: - grant_table = AllowGrantDB - else: - grant_table = DenyGrantDB - - query = select(grant_table) - filters = [] - if resource_type is not None: - filters.append( - grant_table.resource_type == resource_type.__name__ - ) - - if action is not None: - filters.append( - grant_table.actions.any( - ResourceActionDB.action == str(action) - ) - ) - - if page_ref is not None: - sql_next_page = SQLNextPageRef(**json.loads(page_ref)) - filters.append( - grant_table.storage_id > sql_next_page.next_token - ) - - query = query.where(*filters) - query = query.limit(page_size) - - result = await session.execute(query) - db_grants = result.scalars().unique().all() - next_page_ref = None - if len(db_grants) >= page_size: - next_page_ref = SQLNextPageRef(next_token=db_grants[-1].storage_id).model_dump_json() - - return RawGrantsPage( - raw_grants=db_grants, - next_page_ref=next_page_ref - ) - - - async def normalize_raw_grants_page( + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def put_identity_def( self, - raw_grants_page: RawGrantsPage - ) -> GrantsPage: - """Convert a ``RawGrantsPage`` to a ``GrantsPage``. + identity_def: IdentityDef, + config: PutIdentityDefConfig + ) -> GenericResult: + """Add a new Identity Definition or update an existing one. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } - Parameters - ---------- - raw_grants_page : RawGrantsPage - Raw grants page to convert. - Returns - ------- - GrantsPage - Normalized grants page. + async def delete_identity_def( + self, + identity_type: str, + config: DeleteIdentityDefConfig + ) -> GenericResult: + """Delete an identity definition by type. """ - grants = [] - db_grants: list[Union[AllowGrantDB, DenyGrantDB]] = raw_grants_page.raw_grants - for db_grant in db_grants: - grants.append( - Grant( - name=db_grant.name, - description=db_grant.description, - resource_type=self._resource_type_lookup[db_grant.resource_type], - actions={ - self._resource_action_lookup[action.action] for action in db_grant.actions - }, - expression=db_grant.expression, - context=db_grant.context, - equality=db_grant.equality, - storage_id=str(db_grant.storage_id), - uuid=db_grant.uuid - ) - ) + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def list_resource_defs( + self, + page_ref: str | None, + config: ListResourceDefsConfig + ) -> ResourceDefsPage: + """Get a page of resource definitions. + + Pass the returned page reference to get the next page until a null page reference is returned. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def get_resource_def( + self, + resource_type: str, + config: GetResourceDefConfig + ) -> ResourceDefResult: + """Get a resource definition by type. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } - return GrantsPage( - grants=grants, - next_page_ref=raw_grants_page.next_page_ref - ) + async def put_resource_def( + self, + resource_def: ResourceDef, + config: PutResourceDefConfig + ) -> GenericResult: + """Add a new Resource Definition or update an existing one. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } - async def create_flag(self) -> StorageFlag: - """Create a new shared flag in the storage backend. - Returns - ------- - StorageFlag - New storage flag. + async def delete_resource_def( + self, + resource_type: str, + config: DeleteResourceDefConfig + ) -> GenericResult: + """Delete a resource definition by type. """ - new_flag = StorageFlag() - async with self._async_sessionmaker() as session: - db_flag = StorageFlagDB(**new_flag.model_dump()) - session.add(db_flag) - await session.commit() - - return new_flag - - - async def get_flag(self, uuid: str) -> StorageFlag: - """Retrieve flag by UUID. - - Parameters - ---------- - uuid : str - Storage flag UUID. - - Returns - ------- - StorageFlag - The storage flag with the given UUID. - - Raises - ------ - authzee.exceptions.StorageFlagNotFoundError - The storage flag with the given UUID was not found. + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def enact(self, grant: Grant, config: EnactConfig) -> GenericResult: + """Add a new grant. """ - async with self._async_sessionmaker() as session: - query = select(StorageFlagDB).where(StorageFlagDB.uuid == uuid) - result = await session.execute(query) - db_flag = result.scalars().unique().one_or_none() - if db_flag is None: - raise exceptions.StorageFlagNotFoundError( - f"The storage flag with UUID '{uuid}' was not found!" - ) - - await session.commit() - - return StorageFlag.model_validate(db_flag, from_attributes=True) - - - async def set_flag(self, uuid: str) -> StorageFlag: - """set a flag for a given UUID. - - Parameters - ---------- - uuid : str - Storage flag UUID. - - Returns - ------- - StorageFlag - The storage flag with the given UUID and the flag set. - - Raises - ------ - authzee.exceptions.StorageFlagNotFoundError - The storage flag with the given UUID was not found. + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def repeal( + self, + grant_uuid: str, + purge: bool, + config: RepealConfig + ) -> GenericResult: + """Delete a grant. """ - async with self._async_sessionmaker() as session: - query = select(StorageFlagDB).where(StorageFlagDB.uuid == uuid) - result = await session.execute(query) - db_flag = result.scalars().unique().one_or_none() - if db_flag is None: - raise exceptions.StorageFlagNotFoundError( - f"The storage flag with UUID '{uuid}' was not found!" - ) - - db_flag.is_set = True - await session.commit() - - return StorageFlag.model_validate(db_flag, from_attributes=True) - - - async def delete_flag(self, uuid: str) -> None: - """Delete a storage flag by UUID. - - Parameters - ---------- - uuid : str - Storage flag UUID. + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def get_grant( + self, + grant_uuid: str, + config: GetGrantConfig + ) -> GrantResult: + """Get a grant by UUID. """ - async with self._async_sessionmaker() as session: - await session.execute( - delete(StorageFlagDB).where(StorageFlagDB.uuid == uuid) - ) - await session.commit() + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } - async def cleanup_flags(self, earlier_than: datetime.datetime) -> None: - """Delete zombie storage flags from before a certain point in time. + async def list_grants( + self, + effect: str | None, + action: str | None, + page_ref: str | None, + config: ListGrantsConfig + ) -> GrantsPage: + """Retrieve a page of grants. - Parameters - ---------- - earlier_than : datetime.datetime - Delete flags created earlier than this date. - Naive datetimes are assumed to be UTC. + Pass the returned page reference to get the next page until a null page reference is returned. """ - async with self._async_sessionmaker() as session: - await session.execute( - delete(StorageFlagDB).where(StorageFlagDB.created_at < earlier_than) - ) - await session.commit() \ No newline at end of file + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def list_grant_refs( + self, + effect: str | None, + action: str | None, + page_ref: str | None, + config: ListGrantRefsConfig + ) -> PageRefsPage: + """Retrieve a page of grant page references for parallel pagination. + + Pass the returned page reference to get the next page until a null page reference is returned. + + For some storage modules this may not be possible. + Check the `parallel_paging` attribute on the storage module after `start()` is complete. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def create_latch(self, config: CreateLatchConfig) -> StorageLatchResult: + """Create a new [storage latch](#storage-latches). + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def get_latch( + self, + storage_latch_uuid: str, + config: GetLatchConfig + ) -> StorageLatchResult: + """Get a [storage latch](#storage-latches) by UUID. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def set_latch( + self, + storage_latch_uuid: str, + config: SetLatchConfig + ) -> StorageLatchResult: + """Set a [storage latch](#storage-latches) by UUID. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def delete_latch( + self, + storage_latch_uuid: str, + config: DeleteLatchConfig + ) -> GenericResult: + """Delete a [storage latch](#storage-latches) by UUID. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } + + + async def cleanup_latches( + self, + before: datetime.datetime, + config: CleanupLatchesConfig + ) -> GenericResult: + """Delete all latches before the specified datetime. + + - operations should clean up their own latches, but in case of a failure this can be used to clean up zombie latches. + """ + try: + pass + except Exception as exc: + return { + "error": { + "error_type": "storage", + "message": f"[{exc.__class__.__qualname__}]: {exc}" + } + } From cc6464930358e7cfa245555181db074d6aa3af13 Mon Sep 17 00:00:00 2001 From: btemplep Date: Sun, 6 Sep 2026 00:05:43 -0400 Subject: [PATCH 3/6] metaclass testing --- .vscode/settings.json | 1 + src/authzee/storage/storage_module.py | 46 ++- tester_store.py | 549 ++++++++++++++++++++++++++ 3 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 tester_store.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 9da5117..fbfc07d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -51,6 +51,7 @@ "loguru", "lrange", "maxdepth", + "mcls", "modindex", "Multiprocess", "noindex", diff --git a/src/authzee/storage/storage_module.py b/src/authzee/storage/storage_module.py index ca92011..68a386c 100644 --- a/src/authzee/storage/storage_module.py +++ b/src/authzee/storage/storage_module.py @@ -7,8 +7,10 @@ "StorageModule" ] -from abc import ABC, abstractmethod +from abc import ABCMeta, abstractmethod import datetime +import functools +from typing import Any, Callable from authzee.module_locality import ModuleLocality from authzee.types.authzee import * @@ -42,7 +44,47 @@ ) -class StorageModule(ABC): +def _generic_result_handler(func, error_type): + @functools.wraps(func) + async def wrapper(self, *args, **kwargs) -> GenericResult: + try: + return await func(self, *args, **kwargs) + + except Exception as exc: + return { + "error": { + "error_type": error_type, + "message": f"[{exc.__class__.__qualname__}] {exc}" + } + } + + return wrapper + + +class _ModuleMeta(ABCMeta): + _error_type: str = "unknown" + _handler_map: dict[str, Callable] = {} + + + def __new__(mcls, name: str, bases, namespace: dict[str, Any]): + for attr_name, attr_value in namespace.items(): + if ( + attr_name in mcls._handler_map + and getattr(attr_value, "__isabstractmethod__", False) is False + ): + namespace[attr_name] = mcls._handler_map[attr_name](attr_value, mcls._error_type) + + return super().__new__(mcls, name, bases, namespace) + + +class _StorageMeta(_ModuleMeta): + _error_type: str = "storage" + _handler_map: dict[str, Callable] = { + "list_context_defs": _generic_result_handler + } + + +class StorageModule(metaclass=_StorageMeta): @abstractmethod diff --git a/tester_store.py b/tester_store.py new file mode 100644 index 0000000..9299a44 --- /dev/null +++ b/tester_store.py @@ -0,0 +1,549 @@ +"""Dict-based in-memory storage module for Authzee. + +See [](authzee.storage.dict_storage.DictStorage) +""" + +__all__ = [ + "DictStorage" +] + +import datetime +from uuid import uuid4 + +from authzee.module_locality import ModuleLocality +from authzee.storage.storage_module import StorageModule +from authzee.types.authzee import * +from authzee.types.config import ( + CleanupLatchesConfig, + CreateLatchConfig, + DeleteContextDefConfig, + DeleteIdentityDefConfig, + DeleteLatchConfig, + DeleteResourceDefConfig, + EnactConfig, + GetContextDefConfig, + GetGrantConfig, + GetIdentityDefConfig, + GetLatchConfig, + GetResourceDefConfig, + ListContextDefsConfig, + ListGrantRefsConfig, + ListGrantsConfig, + ListIdentityDefsConfig, + ListResourceDefsConfig, + PutContextDefConfig, + PutIdentityDefConfig, + PutResourceDefConfig, + RepealConfig, + SetLatchConfig, + StorageConstructConfig, + StorageDestroyConfig, + StorageShutdownConfig, + StorageStartConfig +) + + +class DictStorage(StorageModule): + """Storage module that keeps all Authzee data in a Python dict in main memory. + + Context, identity, and resource definitions, grants, and storage latches are all + stored within the given `storage_dict`. Because the data lives in a plain dict, it + is only shared by objects that reference the same dict and does not persist beyond + the lifetime of that dict. + + This storage module supports parallel pagination. + There is not really any penalty for using parallel pagination because it is just a python dict. + + Parameters + ---------- + storage_dict : dict + The dict used to hold all storage data. The same dict must be passed to every + `DictStorage` instance that should share state. + + Examples + -------- + + ```python + from authzee import Authzee, DictStorage, InProcessCompute, jmespath_execute + + storage_dict = {} + authz = Authzee( + execute=jmespath_execute, + compute_type=InProcessCompute, + compute_kwargs={}, + storage_type=DictStorage, + storage_kwargs={ + "storage_dict": storage_dict + } + ) + authz.construct() + authz.start() + ``` + """ + + + def __init__(self, storage_dict: dict): + super().__init__() + self._storage_dict = storage_dict + + + async def start(self, config: StorageStartConfig) -> GenericResult: + self.locality = ModuleLocality.PROCESS + self.has_parallel_paging = True + + return { + "error": None + } + + + async def shutdown(self, config: StorageShutdownConfig) -> GenericResult: + return { + "error": None + } + + + async def construct(self, config: StorageConstructConfig) -> GenericResult: + self._storage_dict['context_defs_lut'] = {} + self._storage_dict['identity_defs_lut'] = {} + self._storage_dict['resource_defs_lut'] = {} + self._storage_dict['grants_lut'] = {} + self._storage_dict['latches_lut'] = {} + + return { + "error": None + } + + + async def destroy(self, config: StorageDestroyConfig) -> GenericResult: + self._storage_dict.pop("context_defs_lut", None) + self._storage_dict.pop("identity_defs_lut", None) + self._storage_dict.pop("resource_defs_lut", None) + self._storage_dict.pop("grants_lut", None) + self._storage_dict.pop("latches_lut", None) + + return { + "error": None + } + + + async def list_context_defs( + self, + page_ref: str | None, + config: ListContextDefsConfig + ) -> ContextDefsPage: + raise Exception("TESTERRRRRR") + if page_ref is None: + start_index = 0 + else: + start_index = int(page_ref) + + context_defs = list(self._storage_dict['context_defs_lut'].values()) + end_index = start_index + config['page_size'] + + return { + "context_defs": context_defs[start_index:end_index], + "next_page_ref": str(end_index) if end_index < len(context_defs) else None, + "error": None + } + + + async def get_context_def( + self, + context_type: str, + config: GetContextDefConfig + ) -> ContextDefResult: + context_def = self._storage_dict['context_defs_lut'].get( + context_type, + None + ) + if context_def is None: + return { + "context_def": None, + "error": { + "error_type": "resource_not_found", + "message": f"Context type '{context_type}' was not found." + } + } + + return { + "context_def": context_def, + "error": None + } + + + async def put_context_def( + self, + context_def: ContextDef, + config: PutContextDefConfig + ) -> GenericResult: + self._storage_dict['context_defs_lut'][context_def['context_type']] = context_def + + return { + "error": None + } + + + async def delete_context_def( + self, + context_type: str, + config: DeleteContextDefConfig + ) -> GenericResult: + self._storage_dict['context_defs_lut'].pop( + context_type, + None + ) + + return { + "error": None + } + + + async def list_identity_defs( + self, + page_ref: str | None, + config: ListIdentityDefsConfig + ) -> IdentityDefsPage: + if page_ref is None: + start_index = 0 + else: + start_index = int(page_ref) + + identity_defs = list(self._storage_dict['identity_defs_lut'].values()) + end_index = start_index + config['page_size'] + + return { + "identity_defs": identity_defs[start_index:end_index], + "next_page_ref": str(end_index) if end_index < len(identity_defs) else None, + "error": None + } + + + async def get_identity_def( + self, + identity_type: str, + config: GetIdentityDefConfig + ) -> IdentityDefResult: + identity_def = self._storage_dict['identity_defs_lut'].get( + identity_type, + None + ) + if identity_def is None: + return { + "identity_def": None, + "error": { + "error_type": "resource_not_found", + "message": f"identity type '{identity_type}' was not found." + } + } + + return { + "identity_def": identity_def, + "error": None + } + + + async def put_identity_def( + self, + identity_def: IdentityDef, + config: PutIdentityDefConfig + ) -> GenericResult: + self._storage_dict['identity_defs_lut'][identity_def['identity_type']] = identity_def + + return { + "error": None + } + + + async def delete_identity_def( + self, + identity_type: str, + config: DeleteIdentityDefConfig + ) -> GenericResult: + self._storage_dict['identity_defs_lut'].pop( + identity_type, + None + ) + + return { + "error": None + } + + + async def list_resource_defs( + self, + page_ref: str | None, + config: ListResourceDefsConfig + ) -> ResourceDefsPage: + if page_ref is None: + start_index = 0 + else: + start_index = int(page_ref) + + resource_defs = list(self._storage_dict['resource_defs_lut'].values()) + end_index = start_index + config['page_size'] + + return { + "resource_defs": resource_defs[start_index:end_index], + "next_page_ref": str(end_index) if end_index < len(resource_defs) else None, + "error": None + } + + + async def get_resource_def( + self, + resource_type: str, + config: GetResourceDefConfig + ) -> ResourceDefResult: + resource_def = self._storage_dict['resource_defs_lut'].get( + resource_type, + None + ) + if resource_def is None: + return { + "resource_def": None, + "error": { + "error_type": "resource_not_found", + "message": f"resource type '{resource_type}' was not found." + } + } + + return { + "resource_def": resource_def, + "error": None + } + + + async def put_resource_def( + self, + resource_def: ResourceDef, + config: PutResourceDefConfig + ) -> GenericResult: + self._storage_dict['resource_defs_lut'][resource_def['resource_type']] = resource_def + + return { + "error": None + } + + + async def delete_resource_def( + self, + resource_type: str, + config: DeleteResourceDefConfig + ) -> GenericResult: + self._storage_dict['resource_defs_lut'].pop( + resource_type, + None + ) + + return { + "error": None + } + + + async def enact(self, grant: Grant, config: EnactConfig) -> GenericResult: + self._storage_dict['grants_lut'][grant['grant_uuid']] = grant + + return { + "error": None + } + + + async def repeal( + self, + grant_uuid: str, + purge: bool, + config: RepealConfig + ) -> GenericResult: + self._storage_dict['grants_lut'].pop(grant_uuid, None) + + return { + "error": None + } + + + async def get_grant( + self, + grant_uuid: str, + config: GetGrantConfig + ) -> GrantResult: + grant = self._storage_dict['grants_lut'].get(grant_uuid, None) + if grant is None: + return { + "grant": None, + "error": { + "error_type": "resource_not_found", + "message": f"Grant with UUID '{grant_uuid}' was not found." + } + } + + return { + "grant": grant, + "error": None + } + + + async def list_grants( + self, + effect: str | None, + action: str | None, + page_ref: str | None, + config: ListGrantsConfig + ) -> GrantsPage: + if page_ref is None: + start_index = 0 + else: + start_index = int(page_ref) + + grants: list[Grant] = list(self._storage_dict['grants_lut'].values()) + if effect is not None: + grants = [g for g in grants if g['effect'] == effect] + + if action is not None: + grants = [g for g in grants if action in g['actions']] + + end_index = start_index + config['page_size'] + + return { + "grants": grants[start_index:end_index], + "next_page_ref": str(end_index) if end_index < len(grants) else None, + "error": None + } + + + async def list_grant_refs( + self, + effect: str | None, + action: str | None, + page_ref: str | None, + config: ListGrantRefsConfig + ) -> PageRefsPage: + if page_ref is None: + start_index = 0 + else: + start_index = int(page_ref) + + grants: list[Grant] = list(self._storage_dict['grants_lut'].values()) + if effect is not None: + grants = [g for g in grants if g['effect'] == effect] + + if action is not None: + grants = [g for g in grants if action in g['actions']] + + num_grants = len(grants) + refs = [] + for _ in range(config['page_size']): + end_index = start_index + config['page_size'] + next_page_ref = end_index + refs.append(start_index) + start_index = end_index + if end_index >= num_grants: + next_page_ref = None + break + + return { + "page_refs": refs, + "next_page_ref": next_page_ref, + "error": None + } + + + async def create_latch(self, config: CreateLatchConfig) -> StorageLatchResult: + latch_uuid = str(uuid4()) + latch = { + "storage_latch_uuid": latch_uuid, + "is_set": False, + "created_at": datetime.datetime.now(tz=datetime.timezone.utc).isoformat() + } + self._storage_dict['latches_lut'][latch_uuid] = latch + + return { + "storage_latch": latch, + "error": None + } + + + async def get_latch( + self, + storage_latch_uuid: str, + config: GetLatchConfig + ) -> StorageLatchResult: + latch = self._storage_dict['latches_lut'].get( + storage_latch_uuid, + None + ) + if latch is None: + return { + "storage_latch": None, + "error": { + "error_type": "resource_not_found", + "message": f"Storage latch with UUID '{storage_latch_uuid}' was not found." + } + } + + return { + "storage_latch": latch, + "error": None + } + + + async def set_latch( + self, + storage_latch_uuid: str, + config: SetLatchConfig + ) -> StorageLatchResult: + result = await self.get_latch( + storage_latch_uuid=storage_latch_uuid, + config=config + ) + if result['error'] is not None: + return result + + result['storage_latch']['is_set'] = True + + return result + + + async def delete_latch( + self, + storage_latch_uuid: str, + config: DeleteLatchConfig + ) -> GenericResult: + self._storage_dict['latches_lut'].pop( + storage_latch_uuid, + None + ) + + return { + "error": None + } + + + async def cleanup_latches( + self, + before: datetime.datetime, + config: CleanupLatchesConfig + ) -> GenericResult: + new_lut = {} + before_str = before.astimezone(datetime.UTC).isoformat() + for lu, l in self._storage_dict['latches_lut'].items(): + if l['created_at'] > before_str: + new_lut[lu] = l + + self._storage_dict['latches_lut'] = new_lut + + return { + "error": None + } + + +async def main(): + my_dict = {} + store = DictStorage(storage_dict=my_dict) + result = await store.list_context_defs( + page_ref=None, + config={} + ) + print(result) + +import asyncio +asyncio.run(main()) From 1cd821d0d378701b9054dcb6ac2da4d4338538a9 Mon Sep 17 00:00:00 2001 From: btemplep Date: Sun, 6 Sep 2026 17:05:07 -0400 Subject: [PATCH 4/6] new module exception handling --- CHANGELOG.md | 11 + README.md | 17 + clr.py | 12 +- full_example.py | 2 +- noxfile.py | 76 +- src/authzee/_module_meta.py | 63 + src/authzee/compute/_compute_meta.py | 63 + src/authzee/compute/compute_module.py | 1288 ++++++++++++++++++- src/authzee/storage/_storage_meta.py | 100 ++ src/authzee/storage/sql_storage.py | 106 +- src/authzee/storage/storage_module.py | 1710 ++++++++++++++++++++++++- tester_store.py | 7 +- tests/unit/mock_modules.py | 221 ++++ tests/unit/test_dict_storage.py | 16 +- tests/unit/test_in_process_compute.py | 6 +- tests/unit/test_module_meta.py | 476 +++++++ 16 files changed, 4027 insertions(+), 147 deletions(-) create mode 100644 src/authzee/_module_meta.py create mode 100644 src/authzee/compute/_compute_meta.py create mode 100644 src/authzee/storage/_storage_meta.py create mode 100644 tests/unit/mock_modules.py create mode 100644 tests/unit/test_module_meta.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 265a1e2..daa2966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `SQLStorage` - SQL based storage module. +- `StorageModule` and `ComputeModule` now automatically translate exceptions raised in their methods into the method's expected result body. + - Uses new `_StorageMeta` / `_ComputeMeta` metaclasses (built on a shared `_ModuleMeta`). + - A raised exception is caught and returned as the correctly shaped result body with `error` populated and `error_type` set to `"storage"` or `"compute"` depending on where it originated. +- Full class and method docstrings for `StorageModule` and `ComputeModule`, including success and error return examples, call examples with the full config body, and notes on the automatic exception translation. + - `ComputeModule` docstring notes that a compute module must handle all errors returned from storage. +- Class docstrings for `InProcessCompute` and `DictStorage`. ### Changed - `ComputeModule` and `StorageModule` base classes now inherit from ABC. +- `DictStorage` now stores storage latch `created_at` as an ISO 8601 string instead of a `datetime` object. ### Deprecated @@ -36,6 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `NotImplementedError` since base classes now use auto checks from ABC. ### Fixed +- `InProcessCompute` request and batch request validation + - `get_context_def` / `get_resource_def` now use their own config instead of `get_identity_def`. + - The non-list (`get_*`) identity lookup in `validate_request` now populates the identity lookup and returns the correct identity error message. + - `validate_batch_request` no longer raises `KeyError` on the non-list identity lookup path and no longer silently succeeds for an unregistered root definition in the list path. ### Security diff --git a/README.md b/README.md index 1143f76..91bfcd3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Authzee is a highly expressive grant-based authorization engine. Check out the [ - [Full Example](#full-example) - [Development](#development) - [Compute and Storage Module Development](#compute-and-storage-module-development) + - [Return Values and Error Handling](#return-values-and-error-handling) - [Module Caching](#module-caching) @@ -509,6 +510,22 @@ The compute and storage modules are meant to be that - modular! You should be able to build custom ones based off of the base classes `ComputeModule` and `StorageModule`. Note that all underlying methods must be async. +#### Return Values and Error Handling + +Every method on a compute or storage module returns a result body (a `dict`) rather than raising on failure. + +- On success, populate the result fields and set `error` to `None`. +- On a handled failure, return the result body with its non-`error` fields set to safe defaults and `error` set to an error object (`{"error_type": ..., "message": ...}`). For example: + - A `GenericResult` method returns `{"error": }`. + - A single-item method (like `get_context_def`) returns the item as `None` alongside the error, e.g. `{"context_def": None, "error": }`. + - A page method (like `list_grants`) returns an empty list and a `None` page reference alongside the error, e.g. `{"grants": [], "next_page_ref": None, "error": }`. + +You do not have to wrap every method body in a try/except. `ComputeModule` and `StorageModule` use metaclasses (`_ComputeMeta` / `_StorageMeta`) that wrap every method so any raised exception is automatically caught and translated into that method's expected result body, with `error` populated and `error_type` set to `"compute"` or `"storage"` depending on where the exception originated. You can simply raise on unexpected failures and rely on this translation. + +A compute module retrieves definitions and grants from a storage module. Since storage methods return errors in their result body rather than raising, a compute module **must** check the `error` field of every storage result it receives and handle it - typically by short-circuiting and returning its own result body with that error propagated (its `error_type` will already be `"storage"`, identifying where the failure originated). Do not ignore storage errors or assume storage calls always succeed. + +See the `ComputeModule` and `StorageModule` class and method docstrings for per-method return shapes and success/error examples. + ### Module Caching Caching for validating a request or batch request should be self contained within the compute model per request. Besides that, it is up to the storage module to control caching for storage calls. diff --git a/clr.py b/clr.py index 73dd596..524ae01 100644 --- a/clr.py +++ b/clr.py @@ -1,10 +1,8 @@ -from cleer import cleer_default_config, Cleer +"""cleer config""" + +from cleer import Cleer, cleer_default_config clr = Cleer( - config=cleer_default_config( - python_packages=[ - "authzee" - ] - ) -) \ No newline at end of file + config=cleer_default_config(python_packages=["authzee"]) +) diff --git a/full_example.py b/full_example.py index 5fa6f19..987e097 100644 --- a/full_example.py +++ b/full_example.py @@ -10,10 +10,10 @@ from authzee import ( AuditResultPage, Authzee, + authzee_specification_version, BatchAuditResultPage, DictStorage, InProcessCompute, - authzee_specification_version, jmespath_execute, paginator ) diff --git a/noxfile.py b/noxfile.py index 4f53556..3b7ff79 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,13 +1,16 @@ +"""noxfile""" import sys import nox + nox.options.sessions = [ "build-docs", "unit-tests-versions" ] + @nox.session(name="build-docs") def build_docs(session: nox.Session): """Build the documentation. @@ -15,20 +18,33 @@ def build_docs(session: nox.Session): if "--no-venv" not in sys.argv: dev_venv_setup(session=session) - session.run("rm", "-rf", "./docs/_build/", + session.run( + "rm", + "-rf", + "./docs/_build/", external=True ) - session.run("sphinx-build", "-b", "html", "./docs", "./docs/_build/html/") + session.run( + "sphinx-build", + "-b", + "html", + "./docs", + "./docs/_build/html/" + ) -@nox.session( - name="docs-server", - venv_backend="none" -) +@nox.session(name="docs-server", venv_backend="none") def docs_server(session: nox.Session): """Run a local server for the docs at http://localhost:7999/index.html """ - session.run("python", "-m", "http.server", "-d", "docs/_build/html/", "7999") + session.run( + "python", + "-m", + "http.server", + "-d", + "docs/_build/html/", + "7999" + ) @nox.session(name="publish") @@ -37,23 +53,41 @@ def publish(session: nox.Session): """ dev_venv_setup(session=session) session.run( - "rm", "-rf", "./build/", "./dist/", + "rm", + "-rf", + "./build/", + "./dist/", external=True ) - session.run("python", "-m", "build", "--sdist", "--wheel") - session.run("twine", "upload", "dist/*", "--repository", "authzee") + session.run( + "python", + "-m", + "build", + "--sdist", + "--wheel" + ) + session.run( + "twine", + "upload", + "dist/*", + "--repository", + "authzee" + ) -@nox.session( - name="unit-tests", - python=False -) +@nox.session(name="unit-tests", python=False) def unit_tests(session: nox.Session): """Run tests with current python version and generate html coverage report. """ session.run("coverage", "erase") - session.run("pytest", "-vvv", - "--cov=src/authzee", "--cov-report", "html", "--cov-report", "term", + session.run( + "pytest", + "-vvv", + "--cov=src/authzee", + "--cov-report", + "html", + "--cov-report", + "term", "tests/unit" ) @@ -72,10 +106,16 @@ def unit_tests_versions(session: nox.Session): """ dev_venv_setup(session=session) session.run("coverage", "erase") - session.run("pytest", "-vvv", "--cov=src/authzee", "--cov-report", "term-missing", "tests/unit") + session.run( + "pytest", + "-vvv", + "--cov=src/authzee", + "--cov-report", + "term-missing", + "tests/unit" + ) def dev_venv_setup(session: nox.Session): session.install("-U", "pip", "build") session.install("-e", ".[dev,all]") - diff --git a/src/authzee/_module_meta.py b/src/authzee/_module_meta.py new file mode 100644 index 0000000..aac43ad --- /dev/null +++ b/src/authzee/_module_meta.py @@ -0,0 +1,63 @@ +"""Compute and Storage Module Base Meta class and method handlers""" + +__all__ = [] + +from abc import ABCMeta +import functools +from typing import Any, Callable + +from authzee.types import GenericResult + + +def _generic_result_handler(func, error_type): + @functools.wraps(func) + async def wrapper(self, *args, **kwargs) -> GenericResult: + try: + return await func(self, *args, **kwargs) + + except Exception as exc: + return { + "error": { + "error_type": error_type, + "message": f"[{exc.__class__.__qualname__}] {exc}" + } + } + + return wrapper + + +def _make_result_handler(default_fields: dict[str, Any]) -> Callable: + def handler(func, error_type): + @functools.wraps(func) + async def wrapper(self, *args, **kwargs): + try: + return await func(self, *args, **kwargs) + + except Exception as exc: + result = dict(default_fields) + result['error'] = { + "error_type": error_type, + "message": f"[{exc.__class__.__qualname__}] {exc}" + } + + return result + + return wrapper + + return handler + + +class _ModuleMeta(ABCMeta): + _error_type: str = "unknown" + _handler_map: dict[str, Callable] = {} + + + def __new__(mcls, name: str, bases, namespace: dict[str, Any]): + for attr_name, attr_value in namespace.items(): + if ( + attr_name in mcls._handler_map + and getattr(attr_value, "__isabstractmethod__", False) is False + ): + namespace[attr_name] = mcls._handler_map[attr_name](attr_value, mcls._error_type) + + return super().__new__(mcls, name, bases, namespace) diff --git a/src/authzee/compute/_compute_meta.py b/src/authzee/compute/_compute_meta.py new file mode 100644 index 0000000..5334ca0 --- /dev/null +++ b/src/authzee/compute/_compute_meta.py @@ -0,0 +1,63 @@ +"""TODO: Add module docstring.""" + +__all__ = [] + +from typing import Callable + +from authzee._module_meta import ( + _generic_result_handler, + _make_result_handler, + _ModuleMeta +) + + +_validate_batch_request_result_handler = _make_result_handler( + { + "batch": [] + } +) +_audit_result_page_handler = _make_result_handler( + { + "results": [], + "next_page_ref": None + } +) +_authorize_result_handler = _make_result_handler( + { + "is_authorized": False, + "grant": None, + "message": "An error has occurred. Therefore, the request is not authorized." + } +) +_batch_audit_result_page_handler = _make_result_handler( + { + "grants": [], + "batch": [], + "next_page_ref": None + } +) +_batch_authorize_result_handler = _make_result_handler( + { + "batch": [] + } +) + + +class _ComputeMeta(_ModuleMeta): + _error_type: str = "compute" + _handler_map: dict[str, Callable] = { + "start": _generic_result_handler, + "shutdown": _generic_result_handler, + "construct": _generic_result_handler, + "destroy": _generic_result_handler, + "validate_context_def": _generic_result_handler, + "validate_identity_def": _generic_result_handler, + "validate_resource_def": _generic_result_handler, + "validate_grant": _generic_result_handler, + "validate_request": _generic_result_handler, + "validate_batch_request": _validate_batch_request_result_handler, + "audit": _audit_result_page_handler, + "authorize": _authorize_result_handler, + "batch_audit": _batch_audit_result_page_handler, + "batch_authorize": _batch_authorize_result_handler + } diff --git a/src/authzee/compute/compute_module.py b/src/authzee/compute/compute_module.py index 504f89a..db2e8e2 100644 --- a/src/authzee/compute/compute_module.py +++ b/src/authzee/compute/compute_module.py @@ -7,9 +7,10 @@ "ComputeModule" ] -from abc import ABC, abstractmethod +from abc import abstractmethod from typing import Any, Callable, Type +from authzee.compute._compute_meta import _ComputeMeta from authzee.module_locality import ModuleLocality from authzee.storage.storage_module import StorageModule from authzee.types.authzee import * @@ -31,7 +32,67 @@ ) -class ComputeModule(ABC): +class ComputeModule(metaclass=_ComputeMeta): + """Abstract base class for Authzee compute modules. + + A compute module processes authorization requests: it validates requests and + definitions, and runs the audit/authorize operations by evaluating grants + retrieved from a storage module. + + Subclass this to build a custom compute module. All methods are abstract and + must be implemented, and all methods are asynchronous. + + Returning responses + ------------------- + Every method returns a result body (a `dict`) rather than raising on failure. + On success, populate the result fields and set `error` to `None`. On a handled + failure, return the result body with its non-`error` fields set to safe + defaults and `error` set to an `AuthzeeError` describing the problem. + + Automatic exception translation + ------------------------------- + This class uses the [](authzee.compute._compute_meta._ComputeMeta) metaclass, + which wraps every concrete (non-abstract) method in a try/except. Any exception + that propagates out of a method implementation is automatically caught and + translated into that method's expected result body, with `error` populated and + `error_type` set to `"compute"` (since the failure originated in a compute + module). Because of this, implementations may simply raise on unexpected + failures and rely on the metaclass to produce a correctly shaped error + response; there is no need to wrap every method body in your own try/except. + + Handling storage errors + ----------------------- + A compute module calls into a storage module to retrieve definitions and + grants. Storage methods do not raise; they return a result body with an + `error` field. A compute module **must** check the `error` field of every + storage result it receives and handle it, typically by short-circuiting and + returning its own result body with that error propagated (its `error_type` + will already be `"storage"`, identifying where the failure originated). Do not + ignore storage errors or assume storage calls always succeed. + + Parameters + ---------- + None + + Examples + -------- + + ```python + from authzee import Authzee, DictStorage, InProcessCompute, jmespath_execute + + authz = Authzee( + execute=jmespath_execute, + compute_type=InProcessCompute, + compute_kwargs={}, + storage_type=DictStorage, + storage_kwargs={ + "storage_dict": {} + } + ) + authz.construct() + authz.start() + ``` + """ @abstractmethod @@ -44,10 +105,73 @@ async def start( ) -> GenericResult: """Start up compute module. - - run before use - - After this method is complete these public instance vars or getters must be available and stable: - - locality - Compute [Module Locality](#module-locality) - - has_parallel_paging - if the compute module supports processing grants with parallel paging + Run before use. After this method is complete these public instance vars or + getters must be available and stable: + + - `locality` - Compute [Module Locality](#module-locality) + - `has_parallel_paging` - if the compute module supports processing grants + with parallel paging + + Parameters + ---------- + execute : Callable[[str, Any], Any] + The JSON query execute function used to evaluate grant queries. + storage_type : Type[StorageModule] + The storage module type the compute module will use to retrieve data. + storage_kwargs : dict[str, Any] + Keyword arguments used to instantiate the storage module. + config : ComputeStartConfig + The per-call configuration for starting the compute module. + + Examples + -------- + + ```python + result = await compute.start( + execute=jmespath_execute, + storage_type=DictStorage, + storage_kwargs={ + "storage_dict": {} + }, + config={ + "storage": {} + } + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "compute", + "message": "Failed to start the compute module." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. """ self._execute = execute self._storage_type = storage_type @@ -60,7 +184,57 @@ async def start( async def shutdown(self, config: ComputeShutdownConfig) -> GenericResult: """Shutdown Compute module. - - clean up runtime resources + Clean up runtime resources. + + Parameters + ---------- + config : ComputeShutdownConfig + The per-call configuration for shutting down the compute module. + + Examples + -------- + + ```python + result = await compute.shutdown( + config={ + "storage": {} + } + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "compute", + "message": "Failed to shut down the compute module." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. """ ... @@ -69,7 +243,55 @@ async def shutdown(self, config: ComputeShutdownConfig) -> GenericResult: async def construct(self, config: ComputeConstructConfig) -> GenericResult: """Construct backend resources for compute. - - one time setup + One time setup. + + Parameters + ---------- + config : ComputeConstructConfig + The per-call configuration for constructing compute resources. + + Examples + -------- + + ```python + result = await compute.construct( + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "compute", + "message": "Failed to construct compute resources." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. """ ... @@ -78,7 +300,55 @@ async def construct(self, config: ComputeConstructConfig) -> GenericResult: async def destroy(self, config: ComputeDestroyConfig) -> GenericResult: """Tear down backend resources. - - destructive - may lose all long lasting compute resources + Destructive - may lose all long lasting compute resources. + + Parameters + ---------- + config : ComputeDestroyConfig + The per-call configuration for destroying compute resources. + + Examples + -------- + + ```python + result = await compute.destroy( + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "compute", + "message": "Failed to destroy compute resources." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. """ ... @@ -89,6 +359,65 @@ async def validate_context_def( context_def: ContextDef, config: ValidateContextDefConfig ) -> GenericResult: + """Validate a context definition. + + Parameters + ---------- + context_def : ContextDef + The context definition to validate. + config : ValidateContextDefConfig + The per-call configuration for validating a context definition. + + Examples + -------- + + ```python + result = await compute.validate_context_def( + context_def={ + "context_type": "NONE", + "schema": { + "type": "object", + "additionalProperties": False + } + }, + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "compute", + "message": "The context definition is not valid." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. + """ ... @@ -98,6 +427,77 @@ async def validate_identity_def( identity_def: IdentityDef, config: ValidateIdentityDefConfig ) -> GenericResult: + """Validate an identity definition. + + Parameters + ---------- + identity_def : IdentityDef + The identity definition to validate. + config : ValidateIdentityDefConfig + The per-call configuration for validating an identity definition. + + Examples + -------- + + ```python + result = await compute.validate_identity_def( + identity_def={ + "identity_type": "user", + "schema": { + "type": "object", + "required": [ + "username", + "department" + ], + "additionalProperties": False, + "properties": { + "username": { + "type": "string" + }, + "department": { + "type": "string" + } + } + } + }, + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "compute", + "message": "The identity definition is not valid." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. + """ ... @@ -107,6 +507,82 @@ async def validate_resource_def( resource_def: ResourceDef, config: ValidateResourceDefConfig ) -> GenericResult: + """Validate a resource definition. + + Parameters + ---------- + resource_def : ResourceDef + The resource definition to validate. + config : ValidateResourceDefConfig + The per-call configuration for validating a resource definition. + + Examples + -------- + + ```python + result = await compute.validate_resource_def( + resource_def={ + "resource_type": "balloon", + "actions": [ + "balloon:read", + "balloon:inflate", + "balloon:pop" + ], + "schema": { + "type": "object", + "required": [ + "color", + "is_inflated" + ], + "additionalProperties": False, + "properties": { + "color": { + "type": "string" + }, + "is_inflated": { + "type": "boolean" + } + } + } + }, + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "compute", + "message": "The resource definition is not valid." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. + """ ... @@ -116,6 +592,73 @@ async def validate_grant( grant: Grant, config: ValidateGrantConfig ) -> GenericResult: + """Validate a grant. + + Parameters + ---------- + grant : Grant + The grant to validate. + config : ValidateGrantConfig + The per-call configuration for validating a grant. + + Examples + -------- + + ```python + result = await compute.validate_grant( + grant={ + "grant_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "name": "Allow inflate for balloon department", + "description": "Balloon department people are allowed to read and inflate all balloons.", + "tags": {}, + "effect": "allow", + "actions": [ + "balloon:read", + "balloon:inflate" + ], + "query": "length(request.identities.user[?department == 'Balloon Dept']) > `0`", + "equality": True, + "applicable_on_failure": False, + "data": {} + }, + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "compute", + "message": "The grant is not valid." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. + """ ... @@ -126,6 +669,99 @@ async def validate_request( config: ValidateRequestConfig ) -> GenericResult: """Validate a request. + + Parameters + ---------- + request : AuthzeeRequest + The authorization request to validate. + config : ValidateRequestConfig + The per-call configuration for validating a request. + + Examples + -------- + + ```python + result = await compute.validate_request( + request={ + "identities": { + "user": [ + { + "username": "balloon_person", + "department": "Balloon Dept" + } + ] + }, + "action": "balloon:inflate", + "resource_type": "balloon", + "resource": { + "color": "red", + "is_inflated": False + }, + "context_type": "NONE", + "context": {} + }, + config={ + "get_context_def": { + "use_cache": True + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_identity_def": { + "use_cache": True + }, + "use_list_identity_defs": True, + "list_identity_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_resource_def": { + "use_cache": True + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + "use_cache": True + } + } + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "compute", + "message": "The request is not valid." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. """ ... @@ -137,6 +773,108 @@ async def validate_batch_request( config: ValidateBatchRequestConfig ) -> ValidateBatchRequestResult: """Validate a batch request. + + Parameters + ---------- + batch_request : AuthzeeBatchRequest + The batch authorization request to validate. + config : ValidateBatchRequestConfig + The per-call configuration for validating a batch request. + + Examples + -------- + + ```python + result = await compute.validate_batch_request( + batch_request=[ + { + "identities": { + "user": [ + { + "username": "balloon_person", + "department": "Balloon Dept" + } + ] + }, + "action": "balloon:inflate", + "resource_type": "balloon", + "resource": { + "color": "red", + "is_inflated": False + }, + "context_type": "NONE", + "context": {} + } + ], + config={ + "get_context_def": { + "use_cache": True + }, + "use_list_context_defs": True, + "list_context_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_identity_def": { + "use_cache": True + }, + "use_list_identity_defs": True, + "list_identity_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_resource_def": { + "use_cache": True + }, + "use_list_resource_defs": True, + "list_resource_defs": { + "page_size": 1000, + "use_cache": True + } + } + ) + ``` + + Returns + ------- + + ValidateBatchRequestResult + A result with `error` (a batch level `AuthzeeError` or `None`) and + `batch` (a list where each item is `None` when the corresponding batch + item is valid or an `AuthzeeError` describing the item level failure). + + Successful return (each batch item is `None` when valid or an error object + when that item is invalid): + + ```python + { + "error": None, + "batch": [ + None, + None + ] + } + ``` + + Error return (a batch level error fails the whole request): + + ```python + { + "error": { + "error_type": "compute", + "message": "The batch request is not valid." + }, + "batch": [] + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. """ ... @@ -151,6 +889,136 @@ async def audit( """Run the Audit Operation for a page of results. Pass the returned page reference to get the next page until a null page reference is returned. + + Parameters + ---------- + request : AuthzeeRequest + The authorization request to audit against the stored grants. + page_ref : str | None + The page reference for the page to retrieve, or `None` for the first page. + config : AuditConfig + The per-call configuration for the audit operation. + + Examples + -------- + + ```python + page = await compute.audit( + request={ + "identities": { + "user": [ + { + "username": "balloon_person", + "department": "Balloon Dept" + } + ] + }, + "action": "balloon:inflate", + "resource_type": "balloon", + "resource": { + "color": "red", + "is_inflated": False + }, + "context_type": "NONE", + "context": {} + }, + page_ref=None, + config={ + "validate_request": { + "get_context_def": { + "use_cache": True + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_identity_def": { + "use_cache": True + }, + "use_list_identity_defs": True, + "list_identity_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_resource_def": { + "use_cache": True + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + "use_cache": True + } + }, + "list_grants": { + "page_size": 100, + "use_cache": True + } + } + ) + ``` + + Returns + ------- + + AuditResultPage + A page result with `results` (a list of audit result items each with a + `grant`, `is_applicable`, `query_result`, and `failure`), + `next_page_ref` (the reference for the next page or `None` when there + are no more pages), and `error` (`None` on success or an + `AuthzeeError` describing the failure). + + Successful return: + + ```python + { + "results": [ + { + "grant": { + "grant_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "name": "Allow inflate for balloon department", + "description": "Balloon department people are allowed to read and inflate all balloons.", + "tags": {}, + "effect": "allow", + "actions": [ + "balloon:read", + "balloon:inflate" + ], + "query": "length(request.identities.user[?department == 'Balloon Dept']) > `0`", + "equality": True, + "applicable_on_failure": False, + "data": {} + }, + "is_applicable": True, + "query_result": True, + "failure": None + } + ], + "next_page_ref": "abc123", + "error": None + } + ``` + + Error return: + + ```python + { + "results": [], + "next_page_ref": None, + "error": { + "error_type": "compute", + "message": "Failed to run the audit operation." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. """ ... @@ -162,6 +1030,138 @@ async def authorize( config: AuthorizeConfig ) -> AuthorizeResult: """Run the Authorize Operation. + + Parameters + ---------- + request : AuthzeeRequest + The authorization request to evaluate against the stored grants. + config : AuthorizeConfig + The per-call configuration for the authorize operation. + + Examples + -------- + + ```python + result = await compute.authorize( + request={ + "identities": { + "user": [ + { + "username": "balloon_person", + "department": "Balloon Dept" + } + ] + }, + "action": "balloon:inflate", + "resource_type": "balloon", + "resource": { + "color": "red", + "is_inflated": False + }, + "context_type": "NONE", + "context": {} + }, + config={ + "validate_request": { + "get_context_def": { + "use_cache": True + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_identity_def": { + "use_cache": True + }, + "use_list_identity_defs": True, + "list_identity_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_resource_def": { + "use_cache": True + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + "use_cache": True + } + }, + "list_grants": { + "page_size": 1000, + "use_cache": True + }, + "parallel_paging": False, + "list_grant_refs": { + "page_size": 10, + "use_cache": True + } + } + ) + ``` + + Returns + ------- + + AuthorizeResult + A result with `is_authorized` (whether the request is authorized), + `grant` (the grant responsible for the decision or `None`), `message` + (one of the fixed enum strings describing the decision), and `error` + (`None` on success or an `AuthzeeError` describing the failure). The + `message` is one of: + + - `"An error has occurred. Therefore, the request is not authorized."` + - `"A deny grant is applicable to the request. Therefore, the request is not authorized."` + - `"An allow grant is applicable to the request, and there are no deny grants that are applicable to the request. Therefore, the request is authorized."` + - `"No grants are applicable to the request. Therefore, the request is implicitly denied and is not authorized."` + + Successful return: + + ```python + { + "is_authorized": True, + "grant": { + "grant_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "name": "Allow inflate for balloon department", + "description": "Balloon department people are allowed to read and inflate all balloons.", + "tags": {}, + "effect": "allow", + "actions": [ + "balloon:read", + "balloon:inflate" + ], + "query": "length(request.identities.user[?department == 'Balloon Dept']) > `0`", + "equality": True, + "applicable_on_failure": False, + "data": {} + }, + "message": "An allow grant is applicable to the request, and there are no deny grants that are applicable to the request. Therefore, the request is authorized.", + "error": None + } + ``` + + Error return: + + ```python + { + "is_authorized": False, + "grant": None, + "message": "An error has occurred. Therefore, the request is not authorized.", + "error": { + "error_type": "compute", + "message": "Failed to run the authorize operation." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. """ ... @@ -176,6 +1176,146 @@ async def batch_audit( """Run the Batch Audit Operation for a page of results. Pass the returned page reference to get the next page until a null page reference is returned. + + Parameters + ---------- + batch_request : AuthzeeBatchRequest + The batch authorization request to audit against the stored grants. + page_ref : str | None + The page reference for the page to retrieve, or `None` for the first page. + config : BatchAuditConfig + The per-call configuration for the batch audit operation. + + Examples + -------- + + ```python + page = await compute.batch_audit( + batch_request=[ + { + "identities": { + "user": [ + { + "username": "balloon_person", + "department": "Balloon Dept" + } + ] + }, + "action": "balloon:inflate", + "resource_type": "balloon", + "resource": { + "color": "red", + "is_inflated": False + }, + "context_type": "NONE", + "context": {} + } + ], + page_ref=None, + config={ + "validate_batch_request": { + "get_context_def": { + "use_cache": True + }, + "use_list_context_defs": True, + "list_context_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_identity_def": { + "use_cache": True + }, + "use_list_identity_defs": True, + "list_identity_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_resource_def": { + "use_cache": True + }, + "use_list_resource_defs": True, + "list_resource_defs": { + "page_size": 1000, + "use_cache": True + } + }, + "list_grants": { + "page_size": 100, + "use_cache": True + } + } + ) + ``` + + Returns + ------- + + BatchAuditResultPage + A page result with `grants` (the list of grants processed for this + page), `batch` (a list of batch item results, each with `results` per + grant index and an item level `error`), `next_page_ref` (the reference + for the next page or `None` when there are no more pages), and `error` + (`None` on success or an `AuthzeeError` describing the failure). + + Successful return: + + ```python + { + "grants": [ + { + "grant_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "name": "Allow inflate for balloon department", + "description": "Balloon department people are allowed to read and inflate all balloons.", + "tags": {}, + "effect": "allow", + "actions": [ + "balloon:read", + "balloon:inflate" + ], + "query": "length(request.identities.user[?department == 'Balloon Dept']) > `0`", + "equality": True, + "applicable_on_failure": False, + "data": {} + } + ], + "batch": [ + { + "results": [ + { + "is_applicable": True, + "query_result": True, + "failure": None + } + ], + "error": None + } + ], + "next_page_ref": "abc123", + "error": None + } + ``` + + Error return: + + ```python + { + "grants": [], + "batch": [], + "next_page_ref": None, + "error": { + "error_type": "compute", + "message": "Failed to run the batch audit operation." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. """ ... @@ -187,5 +1327,135 @@ async def batch_authorize( config: BatchAuthorizeConfig ) -> BatchAuthorizeResult: """Run the Batch Authorize Operation. + + Parameters + ---------- + batch_request : AuthzeeBatchRequest + The batch authorization request to evaluate against the stored grants. + config : BatchAuthorizeConfig + The per-call configuration for the batch authorize operation. + + Examples + -------- + + ```python + result = await compute.batch_authorize( + batch_request=[ + { + "identities": { + "user": [ + { + "username": "balloon_person", + "department": "Balloon Dept" + } + ] + }, + "action": "balloon:inflate", + "resource_type": "balloon", + "resource": { + "color": "red", + "is_inflated": False + }, + "context_type": "NONE", + "context": {} + } + ], + config={ + "validate_batch_request": { + "get_context_def": { + "use_cache": True + }, + "use_list_context_defs": True, + "list_context_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_identity_def": { + "use_cache": True + }, + "use_list_identity_defs": True, + "list_identity_defs": { + "page_size": 1000, + "use_cache": True + }, + "get_resource_def": { + "use_cache": True + }, + "use_list_resource_defs": True, + "list_resource_defs": { + "page_size": 1000, + "use_cache": True + } + }, + "list_grants": { + "page_size": 1000, + "use_cache": True + }, + "parallel_paging": False, + "list_grant_refs": { + "page_size": 10, + "use_cache": True + } + } + ) + ``` + + Returns + ------- + + BatchAuthorizeResult + A result with `batch` (a list of authorize results, one per batch item, + each with `is_authorized`, `grant`, `message` from the fixed enum, and + `error`) and `error` (a batch level `AuthzeeError` or `None`). + + Successful return: + + ```python + { + "batch": [ + { + "is_authorized": True, + "grant": { + "grant_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "name": "Allow inflate for balloon department", + "description": "Balloon department people are allowed to read and inflate all balloons.", + "tags": {}, + "effect": "allow", + "actions": [ + "balloon:read", + "balloon:inflate" + ], + "query": "length(request.identities.user[?department == 'Balloon Dept']) > `0`", + "equality": True, + "applicable_on_failure": False, + "data": {} + }, + "message": "An allow grant is applicable to the request, and there are no deny grants that are applicable to the request. Therefore, the request is authorized.", + "error": None + } + ], + "error": None + } + ``` + + Error return: + + ```python + { + "batch": [], + "error": { + "error_type": "compute", + "message": "Failed to run the batch authorize operation." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_ComputeMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"compute"`. """ ... diff --git a/src/authzee/storage/_storage_meta.py b/src/authzee/storage/_storage_meta.py new file mode 100644 index 0000000..560f4a9 --- /dev/null +++ b/src/authzee/storage/_storage_meta.py @@ -0,0 +1,100 @@ +"""Storage meta class""" + +__all__ = [] + +from typing import Callable + +from authzee._module_meta import ( + _generic_result_handler, + _make_result_handler, + _ModuleMeta +) + + +_context_def_result_handler = _make_result_handler( + { + "context_def": None + } +) +_context_defs_page_handler = _make_result_handler( + { + "context_defs": [], + "next_page_ref": None + } +) +_identity_def_result_handler = _make_result_handler( + { + "identity_def": None + } +) +_identity_defs_page_handler = _make_result_handler( + { + "identity_defs": [], + "next_page_ref": None + } +) +_resource_def_result_handler = _make_result_handler( + { + "resource_def": None + } +) +_resource_defs_page_handler = _make_result_handler( + { + "resource_defs": [], + "next_page_ref": None + } +) +_grant_result_handler = _make_result_handler( + { + "grant": None + } +) +_grants_page_handler = _make_result_handler( + { + "grants": [], + "next_page_ref": None + } +) +_page_refs_page_handler = _make_result_handler( + { + "page_refs": [], + "next_page_ref": None + } +) +_storage_latch_result_handler = _make_result_handler( + { + "storage_latch": None + } +) + + +class _StorageMeta(_ModuleMeta): + _error_type: str = "storage" + _handler_map: dict[str, Callable] = { + "start": _generic_result_handler, + "shutdown": _generic_result_handler, + "construct": _generic_result_handler, + "destroy": _generic_result_handler, + "list_context_defs": _context_defs_page_handler, + "get_context_def": _context_def_result_handler, + "put_context_def": _generic_result_handler, + "delete_context_def": _generic_result_handler, + "list_identity_defs": _identity_defs_page_handler, + "get_identity_def": _identity_def_result_handler, + "put_identity_def": _generic_result_handler, + "delete_identity_def": _generic_result_handler, + "list_resource_defs": _resource_defs_page_handler, + "get_resource_def": _resource_def_result_handler, + "put_resource_def": _generic_result_handler, + "delete_resource_def": _generic_result_handler, + "enact": _generic_result_handler, + "repeal": _generic_result_handler, + "get_grant": _grant_result_handler, + "list_grants": _grants_page_handler, + "list_grant_refs": _page_refs_page_handler, + "create_latch": _storage_latch_result_handler, + "get_latch": _storage_latch_result_handler, + "set_latch": _storage_latch_result_handler, + "delete_latch": _generic_result_handler, + "cleanup_latches": _generic_result_handler + } diff --git a/src/authzee/storage/sql_storage.py b/src/authzee/storage/sql_storage.py index 293bb5b..201f22d 100644 --- a/src/authzee/storage/sql_storage.py +++ b/src/authzee/storage/sql_storage.py @@ -63,26 +63,30 @@ class Base(AsyncAttrs, DeclarativeBase): class ContextDefDB(Base): __tablename__ = "context_defs" - context_type: Mapped[str] = mapped_column(primary_key=True, nullable=False) + internal_id: Mapped[int] = mapped_column(primary_key=True, nullable=False) + context_type: Mapped[str] = mapped_column(unique=True, nullable=False) schema: Mapped[dict[str, Any]] = mapped_column(nullable=False) class IdentityDefDB(Base): __tablename__ = "identity_defs" - identity_type: Mapped[str] = mapped_column(primary_key=True, nullable=False) + internal_id: Mapped[int] = mapped_column(primary_key=True, nullable=False) + identity_type: Mapped[str] = mapped_column(unique=True, nullable=False) schema: Mapped[dict[str, Any]] class ResourceDefDB(Base): __tablename__ = "resource_defs" - resource_type: Mapped[str] = mapped_column(primary_key=True, nullable=False) + internal_id: Mapped[int] = mapped_column(primary_key=True, nullable=False) + resource_type: Mapped[str] = mapped_column(unique=True, nullable=False) actions: Mapped[list[str]] = mapped_column(nullable=False) schema: Mapped[dict[str, Any]] = mapped_column(nullable=False) class GrantDB(Base): __tablename__ = "grants" - grant_uuid: Mapped[UUID] = mapped_column(primary_key=True, nullable=False) + internal_id: Mapped[int] = mapped_column(primary_key=True, nullable=False) + grant_uuid: Mapped[UUID] = mapped_column(unique=True, nullable=False) name: Mapped[str] = mapped_column(nullable=False) description: Mapped[str] = mapped_column(nullable=False) tags: Mapped[dict[str, str]] = mapped_column(nullable=False) @@ -96,7 +100,8 @@ class GrantDB(Base): class StorageLatchDB(Base): __tablename__ = "storage_latches" - storage_latch_uuid: Mapped[UUID] = mapped_column(primary_key=True, nullable=False) + internal_id: Mapped[int] = mapped_column(primary_key=True, nullable=False) + storage_latch_uuid: Mapped[UUID] = mapped_column(unique=True, nullable=False) is_set: Mapped[bool] = mapped_column(nullable=False) created_at: Mapped[datetime.datetime] = mapped_column(nullable=False) @@ -131,19 +136,13 @@ def __init__(self, *, sqlalchemy_async_engine_kwargs: dict[str, Any]): async def start(self, config: StorageStartConfig) -> GenericResult: - try: - self._engine = create_async_engine(**self._sqlalchemy_async_engine_kwargs) - self._async_sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker( - bind=self._engine, - expire_on_commit=False - ) - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + self._engine = create_async_engine(**self._sqlalchemy_async_engine_kwargs) + self._async_sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=self._engine, + expire_on_commit=False + ) + self.locality = ModuleLocality.NETWORK + self.has_parallel_paging = False return { "error": None @@ -151,15 +150,7 @@ async def start(self, config: StorageStartConfig) -> GenericResult: async def shutdown(self, config: StorageShutdownConfig) -> GenericResult: - try: - await self._engine.dispose() - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + await self._engine.dispose() return { "error": None @@ -167,33 +158,18 @@ async def shutdown(self, config: StorageShutdownConfig) -> GenericResult: async def construct(self, config: StorageConstructConfig) -> GenericResult: - try: - async with self._engine.begin() as conn: + async with self._engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } - return { "error": None } async def destroy(self, config: StorageDestroyConfig) -> GenericResult: - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + async with self._engine.begin() as conn: + await conn.run_sync(Base.metadata.reflect) + await conn.run_sync(Base.metadata.drop_all) return { "error": None @@ -205,15 +181,31 @@ async def list_context_defs( page_ref: str | None, config: ListContextDefsConfig ) -> ContextDefsPage: - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" + async with self._async_sessionmaker() as db_sess: + query = select(ContextDefDB).limit(config["page_size"]).order_by(ContextDefDB.internal_id) + if page_ref is not None: + query = query.where(ContextDefDB.internal_id > int(page_ref)) + + context_defs: list[ContextDefDB] = (await db_sess.execute()).scalars().all() + + next_page_ref = None + if len(context_defs) > 0: + next_page_ref = str(context_defs[-1].internal_id) + + result: ContextDefsPage = { + "context_defs": [], + "next_page_ref": next_page_ref, + "error": None + } + for cd in context_defs: + result['context_defs'].append( + { + "context_type": cd.context_type, + "schema": cd.schema } - } + ) + + return result async def get_context_def( @@ -257,8 +249,6 @@ async def delete_context_def( context_type: str, config: DeleteContextDefConfig ) -> GenericResult: - """Delete a context definition by type. - """ try: pass except Exception as exc: @@ -275,10 +265,6 @@ async def list_identity_defs( page_ref: str | None, config: ListIdentityDefsConfig ) -> IdentityDefsPage: - """Get a page of identity definitions. - - Pass the returned page reference to get the next page until a null page reference is returned. - """ try: pass except Exception as exc: diff --git a/src/authzee/storage/storage_module.py b/src/authzee/storage/storage_module.py index 68a386c..acc7eb0 100644 --- a/src/authzee/storage/storage_module.py +++ b/src/authzee/storage/storage_module.py @@ -7,12 +7,11 @@ "StorageModule" ] -from abc import ABCMeta, abstractmethod +from abc import abstractmethod import datetime -import functools -from typing import Any, Callable from authzee.module_locality import ModuleLocality +from authzee.storage._storage_meta import _StorageMeta from authzee.types.authzee import * from authzee.types.config import ( CleanupLatchesConfig, @@ -44,57 +43,117 @@ ) -def _generic_result_handler(func, error_type): - @functools.wraps(func) - async def wrapper(self, *args, **kwargs) -> GenericResult: - try: - return await func(self, *args, **kwargs) - - except Exception as exc: - return { - "error": { - "error_type": error_type, - "message": f"[{exc.__class__.__qualname__}] {exc}" - } - } +class StorageModule(metaclass=_StorageMeta): + """Abstract base class for Authzee storage modules. + + A storage module persists and retrieves Authzee data: context, identity, and + resource definitions, grants, and storage latches. + + Subclass this to build a custom storage module. All methods are abstract and + must be implemented, and all methods are asynchronous. + + Returning responses + ------------------- + Every method returns a result body (a `dict`) rather than raising on failure. + On success, populate the result fields and set `error` to `None`. On a handled + failure, return the result body with its non-`error` fields set to safe + defaults (for example `None` for a single item, `[]` and `next_page_ref` of + `None` for a page) and `error` set to an `AuthzeeError` describing the problem. + + Automatic exception translation + ------------------------------- + This class uses the [](authzee.storage._storage_meta._StorageMeta) metaclass, + which wraps every concrete (non-abstract) method in a try/except. Any exception + that propagates out of a method implementation is automatically caught and + translated into that method's expected result body, with `error` populated and + `error_type` set to `"storage"` (since the failure originated in a storage + module). Because of this, implementations may simply raise on unexpected + failures and rely on the metaclass to produce a correctly shaped error + response; there is no need to wrap every method body in your own try/except. + + Parameters + ---------- + None + + Examples + -------- + + ```python + from authzee import Authzee, DictStorage, InProcessCompute, jmespath_execute + + authz = Authzee( + execute=jmespath_execute, + compute_type=InProcessCompute, + compute_kwargs={}, + storage_type=DictStorage, + storage_kwargs={ + "storage_dict": {} + } + ) + authz.construct() + authz.start() + ``` + """ - return wrapper + @abstractmethod + async def start(self, config: StorageStartConfig) -> GenericResult: + """Start up storage module. -class _ModuleMeta(ABCMeta): - _error_type: str = "unknown" - _handler_map: dict[str, Callable] = {} + Run before use. After this method is complete these public instance vars + or getters must be available: + - `locality` - Storage [Module Locality](#module-locality) + - `has_parallel_paging` - if the storage module supports parallel paging + (returning a page of grant page references). - def __new__(mcls, name: str, bases, namespace: dict[str, Any]): - for attr_name, attr_value in namespace.items(): - if ( - attr_name in mcls._handler_map - and getattr(attr_value, "__isabstractmethod__", False) is False - ): - namespace[attr_name] = mcls._handler_map[attr_name](attr_value, mcls._error_type) + Parameters + ---------- + config : StorageStartConfig + The per-call configuration for starting the storage module. - return super().__new__(mcls, name, bases, namespace) + Examples + -------- + ```python + result = await storage.start( + config={} + ) + ``` -class _StorageMeta(_ModuleMeta): - _error_type: str = "storage" - _handler_map: dict[str, Callable] = { - "list_context_defs": _generic_result_handler - } + Returns + ------- + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. -class StorageModule(metaclass=_StorageMeta): + Successful return: + ```python + { + "error": None + } + ``` - @abstractmethod - async def start(self, config: StorageStartConfig) -> GenericResult: - """Start up storage module. + Error return: - - run before use - - After this method is complete these public instance vars or getters must be available: - - locality - Storage [Module Locality](#module-locality) - - has_parallel_paging - if the storage module supports parallel paging (returning a page of grant page references). + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to start the storage module." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ self.locality = ModuleLocality.PROCESS self.has_parallel_paging = False @@ -108,7 +167,55 @@ async def start(self, config: StorageStartConfig) -> GenericResult: async def shutdown(self, config: StorageShutdownConfig) -> GenericResult: """Shutdown storage module. - - clean up runtime resources + Clean up runtime resources. + + Parameters + ---------- + config : StorageShutdownConfig + The per-call configuration for shutting down the storage module. + + Examples + -------- + + ```python + result = await storage.shutdown( + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to shut down the storage module." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -117,7 +224,55 @@ async def shutdown(self, config: StorageShutdownConfig) -> GenericResult: async def construct(self, config: StorageConstructConfig) -> GenericResult: """Construct backend resources for storage. - - one time setup + One time setup. + + Parameters + ---------- + config : StorageConstructConfig + The per-call configuration for constructing storage resources. + + Examples + -------- + + ```python + result = await storage.construct( + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to construct storage resources." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -126,7 +281,55 @@ async def construct(self, config: StorageConstructConfig) -> GenericResult: async def destroy(self, config: StorageDestroyConfig) -> GenericResult: """Tear down backend resources. - - destructive - may lose all long lasting storage resources + Destructive - may lose all long lasting storage resources. + + Parameters + ---------- + config : StorageDestroyConfig + The per-call configuration for destroying storage resources. + + Examples + -------- + + ```python + result = await storage.destroy( + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to destroy storage resources." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -140,6 +343,74 @@ async def list_context_defs( """Get a page of context definitions. Pass the returned page reference to get the next page until a null page reference is returned. + + Parameters + ---------- + page_ref : str | None + The page reference for the page to retrieve, or `None` for the first page. + config : ListContextDefsConfig + The per-call configuration for listing context definitions. + + Examples + -------- + + ```python + page = await storage.list_context_defs( + page_ref=None, + config={ + "page_size": 100, + "use_cache": False + } + ) + ``` + + Returns + ------- + + ContextDefsPage + A page result with `context_defs` (a list of context definitions), + `next_page_ref` (the reference for the next page or `None` when there + are no more pages), and `error` (`None` on success or an + `AuthzeeError` describing the failure). + + Successful return: + + ```python + { + "context_defs": [ + { + "context_type": "NONE", + "schema": { + "type": "object", + "additionalProperties": False + } + } + ], + "next_page_ref": "abc123", + "error": None + } + ``` + + Error return: + + ```python + { + "context_defs": [], + "next_page_ref": None, + "error": { + "error_type": "storage", + "message": "Failed to list context definitions." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -151,6 +422,68 @@ async def get_context_def( config: GetContextDefConfig ) -> ContextDefResult: """Get a context definition by type. + + Parameters + ---------- + context_type : str + The unique context type to retrieve. + config : GetContextDefConfig + The per-call configuration for getting a context definition. + + Examples + -------- + + ```python + result = await storage.get_context_def( + context_type="NONE", + config={ + "use_cache": False + } + ) + ``` + + Returns + ------- + + ContextDefResult + A result with `context_def` (the matching context definition or `None` + when not found) and `error` (`None` on success or an `AuthzeeError` + describing the failure). + + Successful return: + + ```python + { + "context_def": { + "context_type": "NONE", + "schema": { + "type": "object", + "additionalProperties": False + } + }, + "error": None + } + ``` + + Error return: + + ```python + { + "context_def": None, + "error": { + "error_type": "storage", + "message": "Failed to get the context definition." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -162,6 +495,63 @@ async def put_context_def( config: PutContextDefConfig ) -> GenericResult: """Add a new Context Definition or update an existing one. + + Parameters + ---------- + context_def : ContextDef + The context definition to add or update. + config : PutContextDefConfig + The per-call configuration for putting a context definition. + + Examples + -------- + + ```python + result = await storage.put_context_def( + context_def={ + "context_type": "NONE", + "schema": { + "type": "object", + "additionalProperties": False + } + }, + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to put the context definition." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -173,6 +563,57 @@ async def delete_context_def( config: DeleteContextDefConfig ) -> GenericResult: """Delete a context definition by type. + + Parameters + ---------- + context_type : str + The unique context type to delete. + config : DeleteContextDefConfig + The per-call configuration for deleting a context definition. + + Examples + -------- + + ```python + result = await storage.delete_context_def( + context_type="NONE", + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to delete the context definition." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -186,6 +627,86 @@ async def list_identity_defs( """Get a page of identity definitions. Pass the returned page reference to get the next page until a null page reference is returned. + + Parameters + ---------- + page_ref : str | None + The page reference for the page to retrieve, or `None` for the first page. + config : ListIdentityDefsConfig + The per-call configuration for listing identity definitions. + + Examples + -------- + + ```python + page = await storage.list_identity_defs( + page_ref=None, + config={ + "page_size": 100, + "use_cache": False + } + ) + ``` + + Returns + ------- + + IdentityDefsPage + A page result with `identity_defs` (a list of identity definitions), + `next_page_ref` (the reference for the next page or `None` when there + are no more pages), and `error` (`None` on success or an + `AuthzeeError` describing the failure). + + Successful return: + + ```python + { + "identity_defs": [ + { + "identity_type": "user", + "schema": { + "type": "object", + "required": [ + "username", + "department" + ], + "additionalProperties": False, + "properties": { + "username": { + "type": "string" + }, + "department": { + "type": "string" + } + } + } + } + ], + "next_page_ref": "abc123", + "error": None + } + ``` + + Error return: + + ```python + { + "identity_defs": [], + "next_page_ref": None, + "error": { + "error_type": "storage", + "message": "Failed to list identity definitions." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -197,6 +718,80 @@ async def get_identity_def( config: GetIdentityDefConfig ) -> IdentityDefResult: """Get an identity definition by type. + + Parameters + ---------- + identity_type : str + The unique identity type to retrieve. + config : GetIdentityDefConfig + The per-call configuration for getting an identity definition. + + Examples + -------- + + ```python + result = await storage.get_identity_def( + identity_type="user", + config={ + "use_cache": False + } + ) + ``` + + Returns + ------- + + IdentityDefResult + A result with `identity_def` (the matching identity definition or + `None` when not found) and `error` (`None` on success or an + `AuthzeeError` describing the failure). + + Successful return: + + ```python + { + "identity_def": { + "identity_type": "user", + "schema": { + "type": "object", + "required": [ + "username", + "department" + ], + "additionalProperties": False, + "properties": { + "username": { + "type": "string" + }, + "department": { + "type": "string" + } + } + } + }, + "error": None + } + ``` + + Error return: + + ```python + { + "identity_def": None, + "error": { + "error_type": "storage", + "message": "Failed to get the identity definition." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -208,6 +803,75 @@ async def put_identity_def( config: PutIdentityDefConfig ) -> GenericResult: """Add a new Identity Definition or update an existing one. + + Parameters + ---------- + identity_def : IdentityDef + The identity definition to add or update. + config : PutIdentityDefConfig + The per-call configuration for putting an identity definition. + + Examples + -------- + + ```python + result = await storage.put_identity_def( + identity_def={ + "identity_type": "user", + "schema": { + "type": "object", + "required": [ + "username", + "department" + ], + "additionalProperties": False, + "properties": { + "username": { + "type": "string" + }, + "department": { + "type": "string" + } + } + } + }, + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to put the identity definition." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -219,6 +883,57 @@ async def delete_identity_def( config: DeleteIdentityDefConfig ) -> GenericResult: """Delete an identity definition by type. + + Parameters + ---------- + identity_type : str + The unique identity type to delete. + config : DeleteIdentityDefConfig + The per-call configuration for deleting an identity definition. + + Examples + -------- + + ```python + result = await storage.delete_identity_def( + identity_type="user", + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to delete the identity definition." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -232,6 +947,91 @@ async def list_resource_defs( """Get a page of resource definitions. Pass the returned page reference to get the next page until a null page reference is returned. + + Parameters + ---------- + page_ref : str | None + The page reference for the page to retrieve, or `None` for the first page. + config : ListResourceDefsConfig + The per-call configuration for listing resource definitions. + + Examples + -------- + + ```python + page = await storage.list_resource_defs( + page_ref=None, + config={ + "page_size": 100, + "use_cache": False + } + ) + ``` + + Returns + ------- + + ResourceDefsPage + A page result with `resource_defs` (a list of resource definitions), + `next_page_ref` (the reference for the next page or `None` when there + are no more pages), and `error` (`None` on success or an + `AuthzeeError` describing the failure). + + Successful return: + + ```python + { + "resource_defs": [ + { + "resource_type": "balloon", + "actions": [ + "balloon:read", + "balloon:inflate", + "balloon:pop" + ], + "schema": { + "type": "object", + "required": [ + "color", + "is_inflated" + ], + "additionalProperties": False, + "properties": { + "color": { + "type": "string" + }, + "is_inflated": { + "type": "boolean" + } + } + } + } + ], + "next_page_ref": "abc123", + "error": None + } + ``` + + Error return: + + ```python + { + "resource_defs": [], + "next_page_ref": None, + "error": { + "error_type": "storage", + "message": "Failed to list resource definitions." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -243,6 +1043,85 @@ async def get_resource_def( config: GetResourceDefConfig ) -> ResourceDefResult: """Get a resource definition by type. + + Parameters + ---------- + resource_type : str + The unique resource type to retrieve. + config : GetResourceDefConfig + The per-call configuration for getting a resource definition. + + Examples + -------- + + ```python + result = await storage.get_resource_def( + resource_type="balloon", + config={ + "use_cache": False + } + ) + ``` + + Returns + ------- + + ResourceDefResult + A result with `resource_def` (the matching resource definition or + `None` when not found) and `error` (`None` on success or an + `AuthzeeError` describing the failure). + + Successful return: + + ```python + { + "resource_def": { + "resource_type": "balloon", + "actions": [ + "balloon:read", + "balloon:inflate", + "balloon:pop" + ], + "schema": { + "type": "object", + "required": [ + "color", + "is_inflated" + ], + "additionalProperties": False, + "properties": { + "color": { + "type": "string" + }, + "is_inflated": { + "type": "boolean" + } + } + } + }, + "error": None + } + ``` + + Error return: + + ```python + { + "resource_def": None, + "error": { + "error_type": "storage", + "message": "Failed to get the resource definition." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -254,6 +1133,80 @@ async def put_resource_def( config: PutResourceDefConfig ) -> GenericResult: """Add a new Resource Definition or update an existing one. + + Parameters + ---------- + resource_def : ResourceDef + The resource definition to add or update. + config : PutResourceDefConfig + The per-call configuration for putting a resource definition. + + Examples + -------- + + ```python + result = await storage.put_resource_def( + resource_def={ + "resource_type": "balloon", + "actions": [ + "balloon:read", + "balloon:inflate", + "balloon:pop" + ], + "schema": { + "type": "object", + "required": [ + "color", + "is_inflated" + ], + "additionalProperties": False, + "properties": { + "color": { + "type": "string" + }, + "is_inflated": { + "type": "boolean" + } + } + } + }, + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to put the resource definition." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -265,6 +1218,57 @@ async def delete_resource_def( config: DeleteResourceDefConfig ) -> GenericResult: """Delete a resource definition by type. + + Parameters + ---------- + resource_type : str + The unique resource type to delete. + config : DeleteResourceDefConfig + The per-call configuration for deleting a resource definition. + + Examples + -------- + + ```python + result = await storage.delete_resource_def( + resource_type="balloon", + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to delete the resource definition." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -272,6 +1276,71 @@ async def delete_resource_def( @abstractmethod async def enact(self, grant: Grant, config: EnactConfig) -> GenericResult: """Add a new grant. + + Parameters + ---------- + grant : Grant + The grant to add as a new authorization rule. + config : EnactConfig + The per-call configuration for enacting a grant. + + Examples + -------- + + ```python + result = await storage.enact( + grant={ + "grant_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "name": "Allow inflate for balloon department", + "description": "Balloon department people are allowed to read and inflate all balloons.", + "tags": {}, + "effect": "allow", + "actions": [ + "balloon:read", + "balloon:inflate" + ], + "query": "length(request.identities.user[?department == 'Balloon Dept']) > `0`", + "equality": True, + "applicable_on_failure": False, + "data": {} + }, + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to enact the grant." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -284,6 +1353,61 @@ async def repeal( config: RepealConfig ) -> GenericResult: """Delete a grant. + + Parameters + ---------- + grant_uuid : str + The UUID of the grant to delete. + purge : bool + If `True`, fully purge the grant from storage rather than performing a + soft delete. + config : RepealConfig + The per-call configuration for repealing a grant. + + Examples + -------- + + ```python + result = await storage.repeal( + grant_uuid="0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + purge=False, + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to repeal the grant." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -295,6 +1419,75 @@ async def get_grant( config: GetGrantConfig ) -> GrantResult: """Get a grant by UUID. + + Parameters + ---------- + grant_uuid : str + The UUID of the grant to retrieve. + config : GetGrantConfig + The per-call configuration for getting a grant. + + Examples + -------- + + ```python + result = await storage.get_grant( + grant_uuid="0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + config={ + "use_cache": False + } + ) + ``` + + Returns + ------- + + GrantResult + A result with `grant` (the matching grant or `None` when not found) and + `error` (`None` on success or an `AuthzeeError` describing the failure). + + Successful return: + + ```python + { + "grant": { + "grant_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "name": "Allow inflate for balloon department", + "description": "Balloon department people are allowed to read and inflate all balloons.", + "tags": {}, + "effect": "allow", + "actions": [ + "balloon:read", + "balloon:inflate" + ], + "query": "length(request.identities.user[?department == 'Balloon Dept']) > `0`", + "equality": True, + "applicable_on_failure": False, + "data": {} + }, + "error": None + } + ``` + + Error return: + + ```python + { + "grant": None, + "error": { + "error_type": "storage", + "message": "Failed to get the grant." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -310,6 +1503,87 @@ async def list_grants( """Retrieve a page of grants. Pass the returned page reference to get the next page until a null page reference is returned. + + Parameters + ---------- + effect : str | None + Filter grants by effect (`"allow"` or `"deny"`), or `None` to match any effect. + action : str | None + Filter grants by action (for example `"balloon:inflate"`), or `None` to match any action. + page_ref : str | None + The page reference for the page to retrieve, or `None` for the first page. + config : ListGrantsConfig + The per-call configuration for listing grants. + + Examples + -------- + + ```python + page = await storage.list_grants( + effect=None, + action=None, + page_ref=None, + config={ + "page_size": 100, + "use_cache": False + } + ) + ``` + + Returns + ------- + + GrantsPage + A page result with `grants` (a list of grants), `next_page_ref` (the + reference for the next page or `None` when there are no more pages), and + `error` (`None` on success or an `AuthzeeError` describing the failure). + + Successful return: + + ```python + { + "grants": [ + { + "grant_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "name": "Allow inflate for balloon department", + "description": "Balloon department people are allowed to read and inflate all balloons.", + "tags": {}, + "effect": "allow", + "actions": [ + "balloon:read", + "balloon:inflate" + ], + "query": "length(request.identities.user[?department == 'Balloon Dept']) > `0`", + "equality": True, + "applicable_on_failure": False, + "data": {} + } + ], + "next_page_ref": "abc123", + "error": None + } + ``` + + Error return: + + ```python + { + "grants": [], + "next_page_ref": None, + "error": { + "error_type": "storage", + "message": "Failed to list grants." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -328,6 +1602,75 @@ async def list_grant_refs( For some storage modules this may not be possible. Check the `parallel_paging` attribute on the storage module after `start()` is complete. + + Parameters + ---------- + effect : str | None + Filter grants by effect (`"allow"` or `"deny"`), or `None` to match any effect. + action : str | None + Filter grants by action (for example `"balloon:inflate"`), or `None` to match any action. + page_ref : str | None + The page reference for the page to retrieve, or `None` for the first page. + config : ListGrantRefsConfig + The per-call configuration for listing grant page references. + + Examples + -------- + + ```python + page = await storage.list_grant_refs( + effect=None, + action=None, + page_ref=None, + config={ + "page_size": 10, + "use_cache": False + } + ) + ``` + + Returns + ------- + + PageRefsPage + A page result with `page_refs` (a list of grant page reference + strings), `next_page_ref` (the reference for the next page or `None` + when there are no more pages), and `error` (`None` on success or an + `AuthzeeError` describing the failure). + + Successful return: + + ```python + { + "page_refs": [ + "abc123", + "def456" + ], + "next_page_ref": "ghi789", + "error": None + } + ``` + + Error return: + + ```python + { + "page_refs": [], + "next_page_ref": None, + "error": { + "error_type": "storage", + "message": "Failed to list grant page references." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -335,6 +1678,62 @@ async def list_grant_refs( @abstractmethod async def create_latch(self, config: CreateLatchConfig) -> StorageLatchResult: """Create a new [storage latch](#storage-latches). + + Parameters + ---------- + config : CreateLatchConfig + The per-call configuration for creating a storage latch. + + Examples + -------- + + ```python + result = await storage.create_latch( + config={} + ) + ``` + + Returns + ------- + + StorageLatchResult + A result with `storage_latch` (the created latch or `None` on failure) + and `error` (`None` on success or an `AuthzeeError` describing the + failure). The latch has `storage_latch_uuid`, `is_set`, and + `created_at` fields. + + Successful return: + + ```python + { + "storage_latch": { + "storage_latch_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "is_set": False, + "created_at": "2026-04-26T16:21:10.521220Z" + }, + "error": None + } + ``` + + Error return: + + ```python + { + "storage_latch": None, + "error": { + "error_type": "storage", + "message": "Failed to create the storage latch." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -346,6 +1745,65 @@ async def get_latch( config: GetLatchConfig ) -> StorageLatchResult: """Get a [storage latch](#storage-latches) by UUID. + + Parameters + ---------- + storage_latch_uuid : str + The UUID of the storage latch to retrieve. + config : GetLatchConfig + The per-call configuration for getting a storage latch. + + Examples + -------- + + ```python + result = await storage.get_latch( + storage_latch_uuid="0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + config={} + ) + ``` + + Returns + ------- + + StorageLatchResult + A result with `storage_latch` (the matching latch or `None` when not + found) and `error` (`None` on success or an `AuthzeeError` describing + the failure). The latch has `storage_latch_uuid`, `is_set`, and + `created_at` fields. + + Successful return: + + ```python + { + "storage_latch": { + "storage_latch_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "is_set": False, + "created_at": "2026-04-26T16:21:10.521220Z" + }, + "error": None + } + ``` + + Error return: + + ```python + { + "storage_latch": None, + "error": { + "error_type": "storage", + "message": "Failed to get the storage latch." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -357,6 +1815,65 @@ async def set_latch( config: SetLatchConfig ) -> StorageLatchResult: """Set a [storage latch](#storage-latches) by UUID. + + Parameters + ---------- + storage_latch_uuid : str + The UUID of the storage latch to set. + config : SetLatchConfig + The per-call configuration for setting a storage latch. + + Examples + -------- + + ```python + result = await storage.set_latch( + storage_latch_uuid="0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + config={} + ) + ``` + + Returns + ------- + + StorageLatchResult + A result with `storage_latch` (the updated latch with `is_set` set to + `True`, or `None` on failure) and `error` (`None` on success or an + `AuthzeeError` describing the failure). The latch has + `storage_latch_uuid`, `is_set`, and `created_at` fields. + + Successful return: + + ```python + { + "storage_latch": { + "storage_latch_uuid": "0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + "is_set": True, + "created_at": "2026-04-26T16:21:10.521220Z" + }, + "error": None + } + ``` + + Error return: + + ```python + { + "storage_latch": None, + "error": { + "error_type": "storage", + "message": "Failed to set the storage latch." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -368,6 +1885,57 @@ async def delete_latch( config: DeleteLatchConfig ) -> GenericResult: """Delete a [storage latch](#storage-latches) by UUID. + + Parameters + ---------- + storage_latch_uuid : str + The UUID of the storage latch to delete. + config : DeleteLatchConfig + The per-call configuration for deleting a storage latch. + + Examples + -------- + + ```python + result = await storage.delete_latch( + storage_latch_uuid="0da5dfc6-c919-4bd6-b80f-a351a9ac8d27", + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to delete the storage latch." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... @@ -380,6 +1948,58 @@ async def cleanup_latches( ) -> GenericResult: """Delete all latches before the specified datetime. - - operations should clean up their own latches, but in case of a failure this can be used to clean up zombie latches. + Operations should clean up their own latches, but in case of a failure this + can be used to clean up zombie latches. + + Parameters + ---------- + before : datetime.datetime + All storage latches created before this datetime are deleted. + config : CleanupLatchesConfig + The per-call configuration for cleaning up storage latches. + + Examples + -------- + + ```python + result = await storage.cleanup_latches( + before=datetime.datetime.now(tz=datetime.timezone.utc), + config={} + ) + ``` + + Returns + ------- + + GenericResult + A result body with an `error` field that is `None` on success or an + `AuthzeeError` describing the failure. + + Successful return: + + ```python + { + "error": None + } + ``` + + Error return: + + ```python + { + "error": { + "error_type": "storage", + "message": "Failed to clean up storage latches." + } + } + ``` + + Raises + ------ + None + This method returns errors in the result body rather than raising. Any + exception raised by the implementation is automatically caught by the + `_StorageMeta` metaclass and translated into this method's result body + with `error` populated and `error_type` set to `"storage"`. """ ... diff --git a/tester_store.py b/tester_store.py index 9299a44..3b5435e 100644 --- a/tester_store.py +++ b/tester_store.py @@ -539,11 +539,10 @@ async def cleanup_latches( async def main(): my_dict = {} store = DictStorage(storage_dict=my_dict) - result = await store.list_context_defs( - page_ref=None, - config={} - ) + result = await store.list_context_defs(page_ref=None, config={}) print(result) + import asyncio + asyncio.run(main()) diff --git a/tests/unit/mock_modules.py b/tests/unit/mock_modules.py new file mode 100644 index 0000000..f285049 --- /dev/null +++ b/tests/unit/mock_modules.py @@ -0,0 +1,221 @@ +"""Mock storage and compute modules whose every method raises an exception. + +These exist to exercise the ``_StorageMeta`` / ``_ComputeMeta`` metaclass +handlers, which wrap every concrete (non-abstract) module method in a +try/except that translates any raised exception into the method's expected +result body with the correct ``error_type``. + +Each mock is a full concrete implementation of its base module where every +method raises ``MockError`` so the wrapper's exception path is taken. The +metaclass then produces the shaped result body, which the tests assert against. +""" + +from authzee.compute.compute_module import ComputeModule +from authzee.storage.storage_module import StorageModule + + +class MockError(Exception): + """Distinct exception type raised by the mock modules.""" + pass + + +class MockRaisingStorage(StorageModule): + """A `StorageModule` where every method raises `MockError`. + + Used to verify that `_StorageMeta` catches exceptions from each method and + returns the correctly shaped result body with ``error_type == "storage"``. + """ + + + def __init__(self, message: str="mock storage failure"): + self._message = message + + + async def start(self, config): + raise MockError(self._message) + + + async def shutdown(self, config): + raise MockError(self._message) + + + async def construct(self, config): + raise MockError(self._message) + + + async def destroy(self, config): + raise MockError(self._message) + + + async def list_context_defs(self, page_ref, config): + raise MockError(self._message) + + + async def get_context_def(self, context_type, config): + raise MockError(self._message) + + + async def put_context_def(self, context_def, config): + raise MockError(self._message) + + + async def delete_context_def(self, context_type, config): + raise MockError(self._message) + + + async def list_identity_defs(self, page_ref, config): + raise MockError(self._message) + + + async def get_identity_def(self, identity_type, config): + raise MockError(self._message) + + + async def put_identity_def(self, identity_def, config): + raise MockError(self._message) + + + async def delete_identity_def(self, identity_type, config): + raise MockError(self._message) + + + async def list_resource_defs(self, page_ref, config): + raise MockError(self._message) + + + async def get_resource_def(self, resource_type, config): + raise MockError(self._message) + + + async def put_resource_def(self, resource_def, config): + raise MockError(self._message) + + + async def delete_resource_def(self, resource_type, config): + raise MockError(self._message) + + + async def enact(self, grant, config): + raise MockError(self._message) + + + async def repeal(self, grant_uuid, purge, config): + raise MockError(self._message) + + + async def get_grant(self, grant_uuid, config): + raise MockError(self._message) + + + async def list_grants( + self, + effect, + action, + page_ref, + config + ): + raise MockError(self._message) + + + async def list_grant_refs( + self, + effect, + action, + page_ref, + config + ): + raise MockError(self._message) + + + async def create_latch(self, config): + raise MockError(self._message) + + + async def get_latch(self, storage_latch_uuid, config): + raise MockError(self._message) + + + async def set_latch(self, storage_latch_uuid, config): + raise MockError(self._message) + + + async def delete_latch(self, storage_latch_uuid, config): + raise MockError(self._message) + + + async def cleanup_latches(self, before, config): + raise MockError(self._message) + + +class MockRaisingCompute(ComputeModule): + """A `ComputeModule` where every method raises `MockError`. + + Used to verify that `_ComputeMeta` catches exceptions from each method and + returns the correctly shaped result body with ``error_type == "compute"``. + """ + + + def __init__(self, message: str="mock compute failure"): + self._message = message + + + async def start( + self, + execute, + storage_type, + storage_kwargs, + config + ): + raise MockError(self._message) + + + async def shutdown(self, config): + raise MockError(self._message) + + + async def construct(self, config): + raise MockError(self._message) + + + async def destroy(self, config): + raise MockError(self._message) + + + async def validate_context_def(self, context_def, config): + raise MockError(self._message) + + + async def validate_identity_def(self, identity_def, config): + raise MockError(self._message) + + + async def validate_resource_def(self, resource_def, config): + raise MockError(self._message) + + + async def validate_grant(self, grant, config): + raise MockError(self._message) + + + async def validate_request(self, request, config): + raise MockError(self._message) + + + async def validate_batch_request(self, batch_request, config): + raise MockError(self._message) + + + async def audit(self, request, page_ref, config): + raise MockError(self._message) + + + async def authorize(self, request, config): + raise MockError(self._message) + + + async def batch_audit(self, batch_request, page_ref, config): + raise MockError(self._message) + + + async def batch_authorize(self, batch_request, config): + raise MockError(self._message) diff --git a/tests/unit/test_dict_storage.py b/tests/unit/test_dict_storage.py index aaca9af..037c9e0 100644 --- a/tests/unit/test_dict_storage.py +++ b/tests/unit/test_dict_storage.py @@ -134,7 +134,13 @@ async def get_grant(self, grant_uuid, config): return await super().get_grant(grant_uuid=grant_uuid, config=config) - async def list_grants(self, effect, action, page_ref, config): + async def list_grants( + self, + effect, + action, + page_ref, + config + ): return await super().list_grants( effect=effect, action=action, @@ -143,7 +149,13 @@ async def list_grants(self, effect, action, page_ref, config): ) - async def list_grant_refs(self, effect, action, page_ref, config): + async def list_grant_refs( + self, + effect, + action, + page_ref, + config + ): return await super().list_grant_refs( effect=effect, action=action, diff --git a/tests/unit/test_in_process_compute.py b/tests/unit/test_in_process_compute.py index 04ed362..5d2e2e2 100644 --- a/tests/unit/test_in_process_compute.py +++ b/tests/unit/test_in_process_compute.py @@ -321,7 +321,11 @@ async def authorize(self, request, config): async def batch_audit(self, batch_request, page_ref, config): - return await super().batch_audit(batch_request=batch_request, page_ref=page_ref, config=config) + return await super().batch_audit( + batch_request=batch_request, + page_ref=page_ref, + config=config + ) async def batch_authorize(self, batch_request, config): diff --git a/tests/unit/test_module_meta.py b/tests/unit/test_module_meta.py new file mode 100644 index 0000000..e4db35f --- /dev/null +++ b/tests/unit/test_module_meta.py @@ -0,0 +1,476 @@ +"""Unit tests for the module metaclasses (`_StorageMeta` / `_ComputeMeta`). + +Uses the full raising mock modules to verify that every wrapped method +translates a raised exception into the method's expected result body with the +correct ``error_type``. +""" + +import asyncio +import datetime +import os +import sys + +import jsonschema_rs +import pytest + +from authzee.reference import ( + audit_result_schema, + authorize_result_schema, + batch_audit_result_schema, + batch_authorize_result_schema, + general_result_schema, + validate_batch_request_result_schema, + validate_request_result_schema +) + + +sys.path.insert(0, os.path.dirname(__file__)) + +from mock_modules import MockRaisingCompute, MockRaisingStorage + + +def _assert_error(result, error_type, message): + assert result['error'] is not None + assert result['error']['error_type'] == error_type + assert "MockError" in result['error']['message'] + assert message in result['error']['message'] + + +def _assert_fields(result, expected_non_error): + for key, value in expected_non_error.items(): + assert result[key] == value, f"{key}: {result.get(key)!r} != {value!r}" + + assert set(result.keys()) == set(expected_non_error.keys()) | {"error"} + + +def _assert_matches_schema(result, schema): + if schema is None: + return + + jsonschema_rs.validator_for(schema).validate(result) + + +STORAGE_MESSAGE = "mock storage failure" + +# Only GenericResult-shaped storage methods have a published result schema +# (`general_result_schema`). The page/def/latch result shapes have no dedicated +# schema in the reference, so they are validated structurally only (None). +STORAGE_SCHEMAS = { + "start": general_result_schema, + "shutdown": general_result_schema, + "construct": general_result_schema, + "destroy": general_result_schema, + "list_context_defs": None, + "get_context_def": None, + "put_context_def": general_result_schema, + "delete_context_def": general_result_schema, + "list_identity_defs": None, + "get_identity_def": None, + "put_identity_def": general_result_schema, + "delete_identity_def": general_result_schema, + "list_resource_defs": None, + "get_resource_def": None, + "put_resource_def": general_result_schema, + "delete_resource_def": general_result_schema, + "enact": general_result_schema, + "repeal": general_result_schema, + "get_grant": None, + "list_grants": None, + "list_grant_refs": None, + "create_latch": None, + "get_latch": None, + "set_latch": None, + "delete_latch": general_result_schema, + "cleanup_latches": general_result_schema +} + +STORAGE_CASES = { + "start": ( + { + "config": {} + }, + {} + ), + "shutdown": ( + { + "config": {} + }, + {} + ), + "construct": ( + { + "config": {} + }, + {} + ), + "destroy": ( + { + "config": {} + }, + {} + ), + "list_context_defs": ( + { + "page_ref": None, + "config": {} + }, + { + "context_defs": [], + "next_page_ref": None + } + ), + "get_context_def": ( + { + "context_type": "x", + "config": {} + }, + { + "context_def": None + } + ), + "put_context_def": ( + { + "context_def": {}, + "config": {} + }, + {} + ), + "delete_context_def": ( + { + "context_type": "x", + "config": {} + }, + {} + ), + "list_identity_defs": ( + { + "page_ref": None, + "config": {} + }, + { + "identity_defs": [], + "next_page_ref": None + } + ), + "get_identity_def": ( + { + "identity_type": "x", + "config": {} + }, + { + "identity_def": None + } + ), + "put_identity_def": ( + { + "identity_def": {}, + "config": {} + }, + {} + ), + "delete_identity_def": ( + { + "identity_type": "x", + "config": {} + }, + {} + ), + "list_resource_defs": ( + { + "page_ref": None, + "config": {} + }, + { + "resource_defs": [], + "next_page_ref": None + } + ), + "get_resource_def": ( + { + "resource_type": "x", + "config": {} + }, + { + "resource_def": None + } + ), + "put_resource_def": ( + { + "resource_def": {}, + "config": {} + }, + {} + ), + "delete_resource_def": ( + { + "resource_type": "x", + "config": {} + }, + {} + ), + "enact": ( + { + "grant": {}, + "config": {} + }, + {} + ), + "repeal": ( + { + "grant_uuid": "x", + "purge": False, + "config": {} + }, + {} + ), + "get_grant": ( + { + "grant_uuid": "x", + "config": {} + }, + { + "grant": None + } + ), + "list_grants": ( + { + "effect": None, + "action": None, + "page_ref": None, + "config": {} + }, + { + "grants": [], + "next_page_ref": None + } + ), + "list_grant_refs": ( + { + "effect": None, + "action": None, + "page_ref": None, + "config": {} + }, + { + "page_refs": [], + "next_page_ref": None + } + ), + "create_latch": ( + { + "config": {} + }, + { + "storage_latch": None + } + ), + "get_latch": ( + { + "storage_latch_uuid": "x", + "config": {} + }, + { + "storage_latch": None + } + ), + "set_latch": ( + { + "storage_latch_uuid": "x", + "config": {} + }, + { + "storage_latch": None + } + ), + "delete_latch": ( + { + "storage_latch_uuid": "x", + "config": {} + }, + {} + ), + "cleanup_latches": ( + { + "before": datetime.datetime.now(tz=datetime.timezone.utc), + "config": {} + }, + {} + ) +} + + +@pytest.mark.parametrize( + "method_name", + list(STORAGE_CASES.keys()) +) +def test_storage_meta_wraps_all_methods(method_name): + storage = MockRaisingStorage() + kwargs, expected_non_error = STORAGE_CASES[method_name] + method = getattr(storage, method_name) + result = asyncio.run(method(**kwargs)) + _assert_error(result, "storage", STORAGE_MESSAGE) + _assert_fields(result, expected_non_error) + _assert_matches_schema(result, STORAGE_SCHEMAS[method_name]) + + +def test_storage_meta_covers_every_wrapped_method(): + from authzee.storage._storage_meta import _StorageMeta + assert set(STORAGE_CASES.keys()) == set(_StorageMeta._handler_map.keys()) + assert set(STORAGE_SCHEMAS.keys()) == set(_StorageMeta._handler_map.keys()) + + +COMPUTE_MESSAGE = "mock compute failure" + +COMPUTE_SCHEMAS = { + "start": general_result_schema, + "shutdown": general_result_schema, + "construct": general_result_schema, + "destroy": general_result_schema, + "validate_context_def": general_result_schema, + "validate_identity_def": general_result_schema, + "validate_resource_def": general_result_schema, + "validate_grant": general_result_schema, + "validate_request": validate_request_result_schema, + "validate_batch_request": validate_batch_request_result_schema, + "audit": audit_result_schema, + "authorize": authorize_result_schema, + "batch_audit": batch_audit_result_schema, + "batch_authorize": batch_authorize_result_schema +} + +COMPUTE_CASES = { + "start": ( + { + "execute": None, + "storage_type": None, + "storage_kwargs": {}, + "config": {} + }, + {} + ), + "shutdown": ( + { + "config": {} + }, + {} + ), + "construct": ( + { + "config": {} + }, + {} + ), + "destroy": ( + { + "config": {} + }, + {} + ), + "validate_context_def": ( + { + "context_def": {}, + "config": {} + }, + {} + ), + "validate_identity_def": ( + { + "identity_def": {}, + "config": {} + }, + {} + ), + "validate_resource_def": ( + { + "resource_def": {}, + "config": {} + }, + {} + ), + "validate_grant": ( + { + "grant": {}, + "config": {} + }, + {} + ), + "validate_request": ( + { + "request": {}, + "config": {} + }, + {} + ), + "validate_batch_request": ( + { + "batch_request": {}, + "config": {} + }, + { + "batch": [] + } + ), + "audit": ( + { + "request": {}, + "page_ref": None, + "config": {} + }, + { + "results": [], + "next_page_ref": None + } + ), + "authorize": ( + { + "request": {}, + "config": {} + }, + { + "is_authorized": False, + "grant": None, + "message": "An error has occurred. Therefore, the request is not authorized." + } + ), + "batch_audit": ( + { + "batch_request": {}, + "page_ref": None, + "config": {} + }, + { + "grants": [], + "batch": [], + "next_page_ref": None + } + ), + "batch_authorize": ( + { + "batch_request": {}, + "config": {} + }, + { + "batch": [] + } + ) +} + + +@pytest.mark.parametrize( + "method_name", + list(COMPUTE_CASES.keys()) +) +def test_compute_meta_wraps_all_methods(method_name): + compute = MockRaisingCompute() + kwargs, expected_non_error = COMPUTE_CASES[method_name] + method = getattr(compute, method_name) + result = asyncio.run(method(**kwargs)) + _assert_error(result, "compute", COMPUTE_MESSAGE) + _assert_fields(result, expected_non_error) + _assert_matches_schema(result, COMPUTE_SCHEMAS[method_name]) + + +def test_compute_meta_covers_every_wrapped_method(): + from authzee.compute._compute_meta import _ComputeMeta + assert set(COMPUTE_CASES.keys()) == set(_ComputeMeta._handler_map.keys()) + assert set(COMPUTE_SCHEMAS.keys()) == set(_ComputeMeta._handler_map.keys()) From c56fd838b1ca91f3419287e604f96f1d907248fb Mon Sep 17 00:00:00 2001 From: btemplep Date: Sun, 6 Sep 2026 17:05:15 -0400 Subject: [PATCH 5/6] changelog --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa2966..54653c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `SQLStorage` - SQL based storage module. - `StorageModule` and `ComputeModule` now automatically translate exceptions raised in their methods into the method's expected result body. - - Uses new `_StorageMeta` / `_ComputeMeta` metaclasses (built on a shared `_ModuleMeta`). - - A raised exception is caught and returned as the correctly shaped result body with `error` populated and `error_type` set to `"storage"` or `"compute"` depending on where it originated. - Full class and method docstrings for `StorageModule` and `ComputeModule`, including success and error return examples, call examples with the full config body, and notes on the automatic exception translation. - `ComputeModule` docstring notes that a compute module must handle all errors returned from storage. - Class docstrings for `InProcessCompute` and `DictStorage`. From c52f593f7c78e67f37e48d5cb7a136117d0f9274 Mon Sep 17 00:00:00 2001 From: btemplep Date: Sun, 6 Sep 2026 22:28:33 -0400 Subject: [PATCH 6/6] sql storage is done --- README.md | 16 +- pyproject.toml | 1 + src/authzee/__init__.py | 2 +- src/authzee/compute/compute_module.py | 35 ++ src/authzee/config.py | 77 ++- src/authzee/storage/sql_storage.py | 677 +++++++++++++++++--------- src/authzee/storage/storage_module.py | 27 + src/authzee/types/config.py | 273 +++++++++-- src/authzee/types/config_override.py | 259 +++++++++- tester_store.py | 548 --------------------- tests/unit/test_sql_storage.py | 311 ++++++++++++ 11 files changed, 1386 insertions(+), 840 deletions(-) delete mode 100644 tester_store.py create mode 100644 tests/unit/test_sql_storage.py diff --git a/README.md b/README.md index 91bfcd3..30f3f0c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Authzee is a highly expressive grant-based authorization engine. Check out the [ - [Development](#development) - [Compute and Storage Module Development](#compute-and-storage-module-development) - [Return Values and Error Handling](#return-values-and-error-handling) + - [Configuration](#configuration) - [Module Caching](#module-caching) @@ -47,9 +48,9 @@ pip install authzee[jmespath,sql-storage] Extra dependencies available/needed: - `jmespath` - needed if using the built in jmespath execute functions -- `sql-storage` - needed for `SQLStorage` class -- `dev` - development dependencies +- `sql` - needed for `SQLStorage` class - `all` - for all extra dependencies except for `dev` +- `dev` - development dependencies ## Tutorial @@ -526,6 +527,17 @@ A compute module retrieves definitions and grants from a storage module. Since s See the `ComputeModule` and `StorageModule` class and method docstrings for per-method return shapes and success/error examples. +#### Configuration + +Every method receives a per-call `config` (a `dict`). A module does **not** have to provide or honor a value for every key its config type allows - a config type describes every option that *could* apply to that call across all module implementations, so any key a given module does not understand can simply be ignored. But a module **should** utilize the config keys that map to behavior it actually implements (for example `page_size` and `use_cache` on list methods, or `use_cache` on get methods), so callers can tune those behaviors. + +Each base class documents the config it does **not** use: + +- `StorageModule` does not use the nested `get_*` / `use_list_*` / `list_*` sub-configs inside the put and delete definition configs and the repeal config (`PutContextDefConfig`, `DeleteContextDefConfig`, `PutIdentityDefConfig`, `DeleteIdentityDefConfig`, `PutResourceDefConfig`, `DeleteResourceDefConfig`, `RepealConfig`). A storage module acts on the target directly by type or UUID; those nested sub-configs describe an optional "look the target up first" step that belongs to the orchestration layer, not storage. +- `ComputeModule` does not use the storage-only definition/grant persistence and retrieval configs (get/put/delete definition configs, `GetGrantConfig`, `EnactConfig`, `RepealConfig`, and the standalone `List*Config` types) or the storage latch configs, since a compute module has no corresponding operation. When a compute operation does trigger a storage call (such as listing grants during an audit or authorize), it uses the sub-config embedded in the compute config it received rather than a standalone top-level config. + +See the `ComputeModule` and `StorageModule` class docstrings for the full, exact list of unused config. + ### Module Caching Caching for validating a request or batch request should be self contained within the compute model per request. Besides that, it is up to the storage module to control caching for storage calls. diff --git a/pyproject.toml b/pyproject.toml index a8fcb8b..6c17f99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ jmespath = ["jmespath"] sql = ["SQLAlchemy"] all = ["authzee[jmespath,sql]"] dev = [ + "aiosqlite", "build", "coverage", "moto[s3,server]", diff --git a/src/authzee/__init__.py b/src/authzee/__init__.py index 9ccc060..199a4bf 100644 --- a/src/authzee/__init__.py +++ b/src/authzee/__init__.py @@ -6,7 +6,7 @@ or [](authzee.authzee_async.AuthzeeAsync) for asyncio support! """ -__version__ = "0.1.0a6" +__version__ = "0.1.0a7" __all__ = [ "Authzee", diff --git a/src/authzee/compute/compute_module.py b/src/authzee/compute/compute_module.py index db2e8e2..1cdb029 100644 --- a/src/authzee/compute/compute_module.py +++ b/src/authzee/compute/compute_module.py @@ -70,6 +70,41 @@ class ComputeModule(metaclass=_ComputeMeta): will already be `"storage"`, identifying where the failure originated). Do not ignore storage errors or assume storage calls always succeed. + Using per-call configuration + ---------------------------- + Every method receives a per-call `config` (a `dict`). A compute module is not + required to honor every config key that its config type allows - a config type + describes every option that *could* apply to that call across all module + implementations, and any key a given module does not understand may simply be + ignored. However, a compute module **should** utilize the config keys that map + to behavior it actually implements. In particular, when a compute config + embeds storage-call sub-configs (for example the `get_*` / `use_list_*` / + `list_*` sub-configs in `ValidateRequestConfig` and `ValidateBatchRequestConfig`, + or `list_grants` / `list_grant_refs` / `parallel_paging` in the audit and + authorize configs), the compute module should pass those through to the + corresponding storage calls so callers can tune retrieval and paging. + + This base `ComputeModule` does **not** use the following config, and neither + should subclasses, because a compute module has no corresponding operation - + these are storage-only operations invoked through the storage module rather + than implemented on compute: + + - The definition and grant persistence and retrieval configs: + `GetContextDefConfig`, `PutContextDefConfig`, `DeleteContextDefConfig`, + `GetIdentityDefConfig`, `PutIdentityDefConfig`, `DeleteIdentityDefConfig`, + `GetResourceDefConfig`, `PutResourceDefConfig`, `DeleteResourceDefConfig`, + `GetGrantConfig`, `EnactConfig`, and `RepealConfig`, along with the + standalone `ListContextDefsConfig`, `ListIdentityDefsConfig`, + `ListResourceDefsConfig`, `ListGrantsConfig`, and `ListGrantRefsConfig` as + top-level (non-embedded) configs. + - The storage latch configs: `CreateLatchConfig`, `GetLatchConfig`, + `SetLatchConfig`, `DeleteLatchConfig`, and `CleanupLatchesConfig`. + + A compute module does still cause several of these storage calls to run (for + example listing grants during an audit or authorize); when it does, it uses the + versions of those sub-configs embedded in the compute config it received, not + the standalone top-level configs above. + Parameters ---------- None diff --git a/src/authzee/config.py b/src/authzee/config.py index cbb93ac..acb4605 100644 --- a/src/authzee/config.py +++ b/src/authzee/config.py @@ -41,8 +41,26 @@ "get_context_def": { "use_cache": False }, - "put_context_def": {}, - "delete_context_def": {}, + "put_context_def": { + "get_context_def": { + "use_cache": False + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + "use_cache": False + } + }, + "delete_context_def": { + "get_context_def": { + "use_cache": False + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + "use_cache": False + } + }, "validate_identity_def": {}, "list_identity_defs": { "page_size": 100, @@ -51,8 +69,26 @@ "get_identity_def": { "use_cache": False }, - "put_identity_def": {}, - "delete_identity_def": {}, + "put_identity_def": { + "get_identity_def": { + "use_cache": False + }, + "use_list_identity_defs": False, + "list_identity_defs": { + "page_size": 1000, + "use_cache": False + } + }, + "delete_identity_def": { + "get_identity_def": { + "use_cache": False + }, + "use_list_identity_defs": False, + "list_identity_defs": { + "page_size": 1000, + "use_cache": False + } + }, "validate_resource_def": {}, "list_resource_defs": { "page_size": 100, @@ -61,8 +97,26 @@ "get_resource_def": { "use_cache": False }, - "put_resource_def": {}, - "delete_resource_def": {}, + "put_resource_def": { + "get_resource_def": { + "use_cache": False + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + "use_cache": False + } + }, + "delete_resource_def": { + "get_resource_def": { + "use_cache": False + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + "use_cache": False + } + }, "validate_grant": {}, "list_grants": { "page_size": 100, @@ -72,7 +126,16 @@ "use_cache": False }, "enact": {}, - "repeal": {}, + "repeal": { + "get_grant": { + "use_cache": False + }, + "use_list_grants": False, + "list_grants": { + "page_size": 1000, + "use_cache": False + } + }, "list_grant_refs": { "page_size": 10, "use_cache": False diff --git a/src/authzee/storage/sql_storage.py b/src/authzee/storage/sql_storage.py index 201f22d..9215d52 100644 --- a/src/authzee/storage/sql_storage.py +++ b/src/authzee/storage/sql_storage.py @@ -5,11 +5,10 @@ ] import datetime -import json from typing import Any, Literal -from uuid import UUID +from uuid import UUID, uuid4 -from sqlalchemy import delete, event, select +from sqlalchemy import DateTime, delete, select from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, @@ -57,6 +56,7 @@ class Base(AsyncAttrs, DeclarativeBase): type_annotation_map = { dict[str, Any]: JSON, dict[str, str]: JSON, + list[str]: JSON, Any: JSON } @@ -92,7 +92,7 @@ class GrantDB(Base): tags: Mapped[dict[str, str]] = mapped_column(nullable=False) effect: Mapped[Literal["allow", "deny"]] = mapped_column(nullable=False) actions: Mapped[list[str]] = mapped_column(nullable=False) - name: Mapped[str] = mapped_column(nullable=False) + query: Mapped[str] = mapped_column(nullable=False) equality: Mapped[Any] = mapped_column(nullable=True) applicable_on_failure: Mapped[bool] = mapped_column(nullable=False) data: Mapped[dict[str, Any]] = mapped_column(nullable=False) @@ -103,13 +103,14 @@ class StorageLatchDB(Base): internal_id: Mapped[int] = mapped_column(primary_key=True, nullable=False) storage_latch_uuid: Mapped[UUID] = mapped_column(unique=True, nullable=False) is_set: Mapped[bool] = mapped_column(nullable=False) - created_at: Mapped[datetime.datetime] = mapped_column(nullable=False) + created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) class SQLStorage(StorageModule): """Storage Module using SQL. - For best performance, use UUID7 for all UUID fields. + `get_*` calls do not honor configs to use list or cache. + Parameters ---------- @@ -159,7 +160,7 @@ async def shutdown(self, config: StorageShutdownConfig) -> GenericResult: async def construct(self, config: StorageConstructConfig) -> GenericResult: async with self._engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + await conn.run_sync(Base.metadata.create_all) return { "error": None @@ -182,22 +183,28 @@ async def list_context_defs( config: ListContextDefsConfig ) -> ContextDefsPage: async with self._async_sessionmaker() as db_sess: - query = select(ContextDefDB).limit(config["page_size"]).order_by(ContextDefDB.internal_id) + query = select( + ContextDefDB + ).limit( + config['page_size'] + ).order_by( + ContextDefDB.internal_id + ) if page_ref is not None: query = query.where(ContextDefDB.internal_id > int(page_ref)) - - context_defs: list[ContextDefDB] = (await db_sess.execute()).scalars().all() + + db_cds: list[ContextDefDB] = (await db_sess.execute(query)).scalars().all() next_page_ref = None - if len(context_defs) > 0: - next_page_ref = str(context_defs[-1].internal_id) + if len(db_cds) == config['page_size']: + next_page_ref = str(db_cds[-1].internal_id) result: ContextDefsPage = { "context_defs": [], "next_page_ref": next_page_ref, "error": None } - for cd in context_defs: + for cd in db_cds: result['context_defs'].append( { "context_type": cd.context_type, @@ -213,35 +220,61 @@ async def get_context_def( context_type: str, config: GetContextDefConfig ) -> ContextDefResult: - """Get a context definition by type. - """ - try: - pass - except Exception as exc: + async with self._async_sessionmaker() as db_sess: + db_cd: ContextDefDB | None = ( + await db_sess.execute( + select(ContextDefDB).where(ContextDefDB.context_type == context_type) + ) + ).scalar_one_or_none() + + if db_cd is not None: return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } + "context_def": { + "context_type": db_cd.context_type, + "schema": db_cd.schema + }, + "error": None } + return { + "context_def": None, + "error": { + "error_type": "resource_not_found", + "message": f"Context type '{context_type}' was not found." + } + } + async def put_context_def( self, context_def: ContextDef, config: PutContextDefConfig ) -> GenericResult: - """Add a new Context Definition or update an existing one. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + async with self._async_sessionmaker() as db_sess: + db_cd: ContextDefDB | None = ( + await db_sess.execute( + select( + ContextDefDB + ).where( + ContextDefDB.context_type == context_def['context_type'] + ) + ) + ).scalar_one_or_none() + if db_cd is None: + db_sess.add( + ContextDefDB( + context_type=context_def['context_type'], + schema=context_def['schema'] + ) + ) + else: + db_cd.schema = context_def['schema'] + + await db_sess.commit() + + return { + "error": None + } async def delete_context_def( @@ -249,15 +282,15 @@ async def delete_context_def( context_type: str, config: DeleteContextDefConfig ) -> GenericResult: - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + async with self._async_sessionmaker() as db_sess: + await db_sess.execute( + delete(ContextDefDB).where(ContextDefDB.context_type == context_type) + ) + await db_sess.commit() + + return { + "error": None + } async def list_identity_defs( @@ -265,15 +298,37 @@ async def list_identity_defs( page_ref: str | None, config: ListIdentityDefsConfig ) -> IdentityDefsPage: - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" + async with self._async_sessionmaker() as db_sess: + query = select( + IdentityDefDB + ).limit( + config['page_size'] + ).order_by( + IdentityDefDB.internal_id + ) + if page_ref is not None: + query = query.where(IdentityDefDB.internal_id > int(page_ref)) + + db_ids: list[IdentityDefDB] = (await db_sess.execute(query)).scalars().all() + + next_page_ref = None + if len(db_ids) == config['page_size']: + next_page_ref = str(db_ids[-1].internal_id) + + result: IdentityDefsPage = { + "identity_defs": [], + "next_page_ref": next_page_ref, + "error": None + } + for id_def in db_ids: + result['identity_defs'].append( + { + "identity_type": id_def.identity_type, + "schema": id_def.schema } - } + ) + + return result async def get_identity_def( @@ -281,17 +336,29 @@ async def get_identity_def( identity_type: str, config: GetIdentityDefConfig ) -> IdentityDefResult: - """Get an identity definition by type. - """ - try: - pass - except Exception as exc: + async with self._async_sessionmaker() as db_sess: + db_id: IdentityDefDB | None = ( + await db_sess.execute( + select(IdentityDefDB).where(IdentityDefDB.identity_type == identity_type) + ) + ).scalar_one_or_none() + + if db_id is not None: return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } + "identity_def": { + "identity_type": db_id.identity_type, + "schema": db_id.schema + }, + "error": None + } + + return { + "identity_def": None, + "error": { + "error_type": "resource_not_found", + "message": f"Identity type '{identity_type}' was not found." } + } async def put_identity_def( @@ -299,17 +366,31 @@ async def put_identity_def( identity_def: IdentityDef, config: PutIdentityDefConfig ) -> GenericResult: - """Add a new Identity Definition or update an existing one. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + async with self._async_sessionmaker() as db_sess: + db_id: IdentityDefDB | None = ( + await db_sess.execute( + select( + IdentityDefDB + ).where( + IdentityDefDB.identity_type == identity_def['identity_type'] + ) + ) + ).scalar_one_or_none() + if db_id is None: + db_sess.add( + IdentityDefDB( + identity_type=identity_def['identity_type'], + schema=identity_def['schema'] + ) + ) + else: + db_id.schema = identity_def['schema'] + + await db_sess.commit() + + return { + "error": None + } async def delete_identity_def( @@ -317,17 +398,15 @@ async def delete_identity_def( identity_type: str, config: DeleteIdentityDefConfig ) -> GenericResult: - """Delete an identity definition by type. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + async with self._async_sessionmaker() as db_sess: + await db_sess.execute( + delete(IdentityDefDB).where(IdentityDefDB.identity_type == identity_type) + ) + await db_sess.commit() + + return { + "error": None + } async def list_resource_defs( @@ -335,19 +414,38 @@ async def list_resource_defs( page_ref: str | None, config: ListResourceDefsConfig ) -> ResourceDefsPage: - """Get a page of resource definitions. + async with self._async_sessionmaker() as db_sess: + query = select( + ResourceDefDB + ).limit( + config['page_size'] + ).order_by( + ResourceDefDB.internal_id + ) + if page_ref is not None: + query = query.where(ResourceDefDB.internal_id > int(page_ref)) - Pass the returned page reference to get the next page until a null page reference is returned. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" + db_rds: list[ResourceDefDB] = (await db_sess.execute(query)).scalars().all() + + next_page_ref = None + if len(db_rds) == config['page_size']: + next_page_ref = str(db_rds[-1].internal_id) + + result: ResourceDefsPage = { + "resource_defs": [], + "next_page_ref": next_page_ref, + "error": None + } + for rd in db_rds: + result['resource_defs'].append( + { + "resource_type": rd.resource_type, + "actions": rd.actions, + "schema": rd.schema } - } + ) + + return result async def get_resource_def( @@ -355,35 +453,64 @@ async def get_resource_def( resource_type: str, config: GetResourceDefConfig ) -> ResourceDefResult: - """Get a resource definition by type. - """ - try: - pass - except Exception as exc: + async with self._async_sessionmaker() as db_sess: + db_rd: ResourceDefDB | None = ( + await db_sess.execute( + select(ResourceDefDB).where(ResourceDefDB.resource_type == resource_type) + ) + ).scalar_one_or_none() + + if db_rd is not None: return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } + "resource_def": { + "resource_type": db_rd.resource_type, + "actions": db_rd.actions, + "schema": db_rd.schema + }, + "error": None } + return { + "resource_def": None, + "error": { + "error_type": "resource_not_found", + "message": f"Resource type '{resource_type}' was not found." + } + } + async def put_resource_def( self, resource_def: ResourceDef, config: PutResourceDefConfig ) -> GenericResult: - """Add a new Resource Definition or update an existing one. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + async with self._async_sessionmaker() as db_sess: + db_rd: ResourceDefDB | None = ( + await db_sess.execute( + select( + ResourceDefDB + ).where( + ResourceDefDB.resource_type == resource_def['resource_type'] + ) + ) + ).scalar_one_or_none() + if db_rd is None: + db_sess.add( + ResourceDefDB( + resource_type=resource_def['resource_type'], + actions=resource_def['actions'], + schema=resource_def['schema'] + ) + ) + else: + db_rd.actions = resource_def['actions'] + db_rd.schema = resource_def['schema'] + + await db_sess.commit() + + return { + "error": None + } async def delete_resource_def( @@ -391,31 +518,38 @@ async def delete_resource_def( resource_type: str, config: DeleteResourceDefConfig ) -> GenericResult: - """Delete a resource definition by type. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + async with self._async_sessionmaker() as db_sess: + await db_sess.execute( + delete(ResourceDefDB).where(ResourceDefDB.resource_type == resource_type) + ) + await db_sess.commit() + + return { + "error": None + } async def enact(self, grant: Grant, config: EnactConfig) -> GenericResult: - """Add a new grant. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + async with self._async_sessionmaker() as db_sess: + db_sess.add( + GrantDB( + grant_uuid=UUID(grant['grant_uuid']), + name=grant['name'], + description=grant['description'], + tags=grant['tags'], + effect=grant['effect'], + actions=grant['actions'], + query=grant['query'], + equality=grant['equality'], + applicable_on_failure=grant['applicable_on_failure'], + data=grant['data'] + ) + ) + await db_sess.commit() + + return { + "error": None + } async def repeal( @@ -424,17 +558,15 @@ async def repeal( purge: bool, config: RepealConfig ) -> GenericResult: - """Delete a grant. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + async with self._async_sessionmaker() as db_sess: + await db_sess.execute( + delete(GrantDB).where(GrantDB.grant_uuid == UUID(grant_uuid)) + ) + await db_sess.commit() + + return { + "error": None + } async def get_grant( @@ -442,18 +574,27 @@ async def get_grant( grant_uuid: str, config: GetGrantConfig ) -> GrantResult: - """Get a grant by UUID. - """ - try: - pass - except Exception as exc: + async with self._async_sessionmaker() as db_sess: + db_grant: GrantDB | None = ( + await db_sess.execute( + select(GrantDB).where(GrantDB.grant_uuid == UUID(grant_uuid)) + ) + ).scalar_one_or_none() + + if db_grant is not None: return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } + "grant": self._grant_from_db(db_grant), + "error": None } + return { + "grant": None, + "error": { + "error_type": "resource_not_found", + "message": f"Grant with UUID '{grant_uuid}' was not found." + } + } + async def list_grants( self, @@ -462,19 +603,32 @@ async def list_grants( page_ref: str | None, config: ListGrantsConfig ) -> GrantsPage: - """Retrieve a page of grants. + async with self._async_sessionmaker() as db_sess: + query = select(GrantDB).limit(config['page_size']).order_by(GrantDB.internal_id) + if effect is not None: + query = query.where(GrantDB.effect == effect) - Pass the returned page reference to get the next page until a null page reference is returned. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + if action is not None: + query = query.where(GrantDB.actions.contains(action)) + + if page_ref is not None: + query = query.where(GrantDB.internal_id > int(page_ref)) + + db_grants: list[GrantDB] = (await db_sess.execute(query)).scalars().all() + + next_page_ref = None + if len(db_grants) == config['page_size']: + next_page_ref = str(db_grants[-1].internal_id) + + result: GrantsPage = { + "grants": [], + "next_page_ref": next_page_ref, + "error": None + } + for db_grant in db_grants: + result['grants'].append(self._grant_from_db(db_grant)) + + return result async def list_grant_refs( @@ -484,36 +638,52 @@ async def list_grant_refs( page_ref: str | None, config: ListGrantRefsConfig ) -> PageRefsPage: - """Retrieve a page of grant page references for parallel pagination. + return { + "page_refs": [], + "next_page_ref": None, + "error": { + "error_type": "parallel_pagination_not_supported", + "message": "SQLStorage does not support parallel pagination." + } + } - Pass the returned page reference to get the next page until a null page reference is returned. - For some storage modules this may not be possible. - Check the `parallel_paging` attribute on the storage module after `start()` is complete. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + def _grant_from_db(self, db_grant: GrantDB) -> Grant: + return { + "grant_uuid": str(db_grant.grant_uuid), + "name": db_grant.name, + "description": db_grant.description, + "tags": db_grant.tags, + "effect": db_grant.effect, + "actions": db_grant.actions, + "query": db_grant.query, + "equality": db_grant.equality, + "applicable_on_failure": db_grant.applicable_on_failure, + "data": db_grant.data + } async def create_latch(self, config: CreateLatchConfig) -> StorageLatchResult: - """Create a new [storage latch](#storage-latches). - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + latch_uuid = uuid4() + created_at = datetime.datetime.now(tz=datetime.timezone.utc) + async with self._async_sessionmaker() as db_sess: + db_sess.add( + StorageLatchDB( + storage_latch_uuid=latch_uuid, + is_set=False, + created_at=created_at + ) + ) + await db_sess.commit() + + return { + "storage_latch": { + "storage_latch_uuid": str(latch_uuid), + "is_set": False, + "created_at": created_at.isoformat() + }, + "error": None + } async def get_latch( @@ -521,17 +691,30 @@ async def get_latch( storage_latch_uuid: str, config: GetLatchConfig ) -> StorageLatchResult: - """Get a [storage latch](#storage-latches) by UUID. - """ - try: - pass - except Exception as exc: + async with self._async_sessionmaker() as db_sess: + db_latch: StorageLatchDB | None = ( + await db_sess.execute( + select( + StorageLatchDB + ).where( + StorageLatchDB.storage_latch_uuid == UUID(storage_latch_uuid) + ) + ) + ).scalar_one_or_none() + + if db_latch is not None: return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } + "storage_latch": self._latch_from_db(db_latch), + "error": None + } + + return { + "storage_latch": None, + "error": { + "error_type": "resource_not_found", + "message": f"Storage latch with UUID '{storage_latch_uuid}' was not found." } + } async def set_latch( @@ -539,17 +722,33 @@ async def set_latch( storage_latch_uuid: str, config: SetLatchConfig ) -> StorageLatchResult: - """Set a [storage latch](#storage-latches) by UUID. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" + async with self._async_sessionmaker() as db_sess: + db_latch: StorageLatchDB | None = ( + await db_sess.execute( + select( + StorageLatchDB + ).where( + StorageLatchDB.storage_latch_uuid == UUID(storage_latch_uuid) + ) + ) + ).scalar_one_or_none() + if db_latch is None: + return { + "storage_latch": None, + "error": { + "error_type": "resource_not_found", + "message": f"Storage latch with UUID '{storage_latch_uuid}' was not found." + } } - } + + db_latch.is_set = True + latch = self._latch_from_db(db_latch) + await db_sess.commit() + + return { + "storage_latch": latch, + "error": None + } async def delete_latch( @@ -557,17 +756,19 @@ async def delete_latch( storage_latch_uuid: str, config: DeleteLatchConfig ) -> GenericResult: - """Delete a [storage latch](#storage-latches) by UUID. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + async with self._async_sessionmaker() as db_sess: + await db_sess.execute( + delete( + StorageLatchDB + ).where( + StorageLatchDB.storage_latch_uuid == UUID(storage_latch_uuid) + ) + ) + await db_sess.commit() + + return { + "error": None + } async def cleanup_latches( @@ -575,16 +776,26 @@ async def cleanup_latches( before: datetime.datetime, config: CleanupLatchesConfig ) -> GenericResult: - """Delete all latches before the specified datetime. + async with self._async_sessionmaker() as db_sess: + await db_sess.execute( + delete(StorageLatchDB).where(StorageLatchDB.created_at < before) + ) + await db_sess.commit() - - operations should clean up their own latches, but in case of a failure this can be used to clean up zombie latches. - """ - try: - pass - except Exception as exc: - return { - "error": { - "error_type": "storage", - "message": f"[{exc.__class__.__qualname__}]: {exc}" - } - } + return { + "error": None + } + + + def _latch_from_db(self, db_latch: StorageLatchDB) -> StorageLatch: + created_at = db_latch.created_at + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=datetime.timezone.utc) + else: + created_at = created_at.astimezone(datetime.timezone.utc) + + return { + "storage_latch_uuid": str(db_latch.storage_latch_uuid), + "is_set": db_latch.is_set, + "created_at": created_at.isoformat() + } diff --git a/src/authzee/storage/storage_module.py b/src/authzee/storage/storage_module.py index acc7eb0..7bab01e 100644 --- a/src/authzee/storage/storage_module.py +++ b/src/authzee/storage/storage_module.py @@ -71,6 +71,33 @@ class StorageModule(metaclass=_StorageMeta): failures and rely on the metaclass to produce a correctly shaped error response; there is no need to wrap every method body in your own try/except. + Using per-call configuration + ---------------------------- + Every method receives a per-call `config` (a `dict`). A storage module is not + required to honor every config key that its config type allows - a config type + describes every option that *could* apply to that call across all module + implementations, and any key a given module does not understand may simply be + ignored. However, a storage module **should** utilize the config keys that map + to behavior it actually implements (for example `page_size` and `use_cache` on + the list methods, or `use_cache` on the get methods), so that callers can tune + those behaviors. + + This base `StorageModule` does **not** use the following config, and neither + should subclasses, because the described behavior does not belong to the + storage layer: + + - The nested `get_*` / `use_list_*` / `list_*` sub-configs inside the put and + delete definition configs and the repeal config - specifically + `PutContextDefConfig`, `DeleteContextDefConfig`, `PutIdentityDefConfig`, + `DeleteIdentityDefConfig`, `PutResourceDefConfig`, `DeleteResourceDefConfig`, + and `RepealConfig`. A storage module puts, deletes, or repeals the target + directly by its type or UUID; those nested sub-configs describe an optional + "look the target up first via a get or a list" step that belongs to the + orchestration layer, not to storage. Subclasses should ignore them. + + Any config type not listed above is used by the corresponding storage method + where its keys map to that method's behavior. + Parameters ---------- None diff --git a/src/authzee/types/config.py b/src/authzee/types/config.py index 7f5c9b0..a5bb0d6 100644 --- a/src/authzee/types/config.py +++ b/src/authzee/types/config.py @@ -484,12 +484,33 @@ class PutContextDefConfig(TypedDict): Examples -------- - Example (the default is an empty dict): + Example showing the default values: ```python - {} + { + "get_context_def": { + "use_cache": False + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_context_def : GetContextDefConfig + Config for getting a context definition. + use_list_context_defs : bool + Whether to use list context defs instead of getting a single context definition. + list_context_defs : ListContextDefsConfig + Config for listing context definitions. """ - pass + get_context_def: GetContextDefConfig + use_list_context_defs: bool + list_context_defs: ListContextDefsConfig class DeleteContextDefConfig(TypedDict): @@ -500,12 +521,33 @@ class DeleteContextDefConfig(TypedDict): Examples -------- - Example (the default is an empty dict): + Example showing the default values: ```python - {} + { + "get_context_def": { + "use_cache": False + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_context_def : GetContextDefConfig + Config for getting a context definition. + use_list_context_defs : bool + Whether to use list context defs instead of getting a single context definition. + list_context_defs : ListContextDefsConfig + Config for listing context definitions. """ - pass + get_context_def: GetContextDefConfig + use_list_context_defs: bool + list_context_defs: ListContextDefsConfig class ValidateIdentityDefConfig(TypedDict): @@ -555,12 +597,33 @@ class PutIdentityDefConfig(TypedDict): Examples -------- - Example (the default is an empty dict): + Example showing the default values: ```python - {} + { + "get_identity_def": { + "use_cache": False + }, + "use_list_identity_defs": False, + "list_identity_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_identity_def : GetIdentityDefConfig + Config for getting an identity definition. + use_list_identity_defs : bool + Whether to use list identity defs instead of getting a single identity definition. + list_identity_defs : ListIdentityDefsConfig + Config for listing identity definitions. """ - pass + get_identity_def: GetIdentityDefConfig + use_list_identity_defs: bool + list_identity_defs: ListIdentityDefsConfig class DeleteIdentityDefConfig(TypedDict): @@ -571,12 +634,33 @@ class DeleteIdentityDefConfig(TypedDict): Examples -------- - Example (the default is an empty dict): + Example showing the default values: ```python - {} + { + "get_identity_def": { + "use_cache": False + }, + "use_list_identity_defs": False, + "list_identity_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_identity_def : GetIdentityDefConfig + Config for getting an identity definition. + use_list_identity_defs : bool + Whether to use list identity defs instead of getting a single identity definition. + list_identity_defs : ListIdentityDefsConfig + Config for listing identity definitions. """ - pass + get_identity_def: GetIdentityDefConfig + use_list_identity_defs: bool + list_identity_defs: ListIdentityDefsConfig class ValidateResourceDefConfig(TypedDict): @@ -626,12 +710,33 @@ class PutResourceDefConfig(TypedDict): Examples -------- - Example (the default is an empty dict): + Example showing the default values: ```python - {} + { + "get_resource_def": { + "use_cache": False + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_resource_def : GetResourceDefConfig + Config for getting a resource definition. + use_list_resource_defs : bool + Whether to use list resource defs instead of getting a single resource definition. + list_resource_defs : ListResourceDefsConfig + Config for listing resource definitions. """ - pass + get_resource_def: GetResourceDefConfig + use_list_resource_defs: bool + list_resource_defs: ListResourceDefsConfig class DeleteResourceDefConfig(TypedDict): @@ -642,12 +747,33 @@ class DeleteResourceDefConfig(TypedDict): Examples -------- - Example (the default is an empty dict): + Example showing the default values: ```python - {} + { + "get_resource_def": { + "use_cache": False + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_resource_def : GetResourceDefConfig + Config for getting a resource definition. + use_list_resource_defs : bool + Whether to use list resource defs instead of getting a single resource definition. + list_resource_defs : ListResourceDefsConfig + Config for listing resource definitions. """ - pass + get_resource_def: GetResourceDefConfig + use_list_resource_defs: bool + list_resource_defs: ListResourceDefsConfig class ValidateGrantConfig(TypedDict): @@ -713,12 +839,33 @@ class RepealConfig(TypedDict): Examples -------- - Example (the default is an empty dict): + Example showing the default values: ```python - {} + { + "get_grant": { + "use_cache": False + }, + "use_list_grants": False, + "list_grants": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_grant : GetGrantConfig + Config for getting a grant. + use_list_grants : bool + Whether to use list grants instead of getting a single grant. + list_grants : ListGrantsConfig + Config for listing grants. """ - pass + get_grant: GetGrantConfig + use_list_grants: bool + list_grants: ListGrantsConfig class CreateLatchConfig(TypedDict): @@ -1264,8 +1411,28 @@ class AuthzeeConfig(TypedDict): "get_context_def": { "use_cache": False }, - "put_context_def": {}, - "delete_context_def": {}, + "put_context_def": { + "get_context_def": { + "use_cache": False + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + + "use_cache": False + } + }, + "delete_context_def": { + "get_context_def": { + "use_cache": False + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + + "use_cache": False + } + }, "validate_identity_def": {}, "list_identity_defs": { "page_size": 100, @@ -1274,8 +1441,28 @@ class AuthzeeConfig(TypedDict): "get_identity_def": { "use_cache": False }, - "put_identity_def": {}, - "delete_identity_def": {}, + "put_identity_def": { + "get_identity_def": { + "use_cache": False + }, + "use_list_identity_defs": False, + "list_identity_defs": { + "page_size": 1000, + + "use_cache": False + } + }, + "delete_identity_def": { + "get_identity_def": { + "use_cache": False + }, + "use_list_identity_defs": False, + "list_identity_defs": { + "page_size": 1000, + + "use_cache": False + } + }, "validate_resource_def": {}, "list_resource_defs": { "page_size": 100, @@ -1284,8 +1471,28 @@ class AuthzeeConfig(TypedDict): "get_resource_def": { "use_cache": False }, - "put_resource_def": {}, - "delete_resource_def": {}, + "put_resource_def": { + "get_resource_def": { + "use_cache": False + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + + "use_cache": False + } + }, + "delete_resource_def": { + "get_resource_def": { + "use_cache": False + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + + "use_cache": False + } + }, "validate_grant": {}, "list_grants": { "page_size": 100, @@ -1295,7 +1502,17 @@ class AuthzeeConfig(TypedDict): "use_cache": False }, "enact": {}, - "repeal": {}, + "repeal": { + "get_grant": { + "use_cache": False + }, + "use_list_grants": False, + "list_grants": { + "page_size": 1000, + + "use_cache": False + } + }, "list_grant_refs": { "page_size": 10, "use_cache": False diff --git a/src/authzee/types/config_override.py b/src/authzee/types/config_override.py index bd8da67..c91a4fb 100644 --- a/src/authzee/types/config_override.py +++ b/src/authzee/types/config_override.py @@ -462,10 +462,31 @@ class PutContextDefConfigOverride(TypedDict, total=False): Examples -------- ```python - {} + { + "get_context_def": { + "use_cache": False + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_context_def : GetContextDefConfigOverride + Config override for getting a context definition. + use_list_context_defs : bool + Whether to use list context defs instead of getting a single context definition. + list_context_defs : ListContextDefsConfigOverride + Config override for listing context definitions. """ - pass + get_context_def: GetContextDefConfigOverride + use_list_context_defs: bool + list_context_defs: ListContextDefsConfigOverride class DeleteContextDefConfigOverride(TypedDict, total=False): @@ -477,10 +498,31 @@ class DeleteContextDefConfigOverride(TypedDict, total=False): Examples -------- ```python - {} + { + "get_context_def": { + "use_cache": False + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_context_def : GetContextDefConfigOverride + Config override for getting a context definition. + use_list_context_defs : bool + Whether to use list context defs instead of getting a single context definition. + list_context_defs : ListContextDefsConfigOverride + Config override for listing context definitions. """ - pass + get_context_def: GetContextDefConfigOverride + use_list_context_defs: bool + list_context_defs: ListContextDefsConfigOverride class ValidateIdentityDefConfigOverride(TypedDict, total=False): @@ -529,10 +571,31 @@ class PutIdentityDefConfigOverride(TypedDict, total=False): Examples -------- ```python - {} + { + "get_identity_def": { + "use_cache": False + }, + "use_list_identity_defs": False, + "list_identity_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_identity_def : GetIdentityDefConfigOverride + Config override for getting an identity definition. + use_list_identity_defs : bool + Whether to use list identity defs instead of getting a single identity definition. + list_identity_defs : ListIdentityDefsConfigOverride + Config override for listing identity definitions. """ - pass + get_identity_def: GetIdentityDefConfigOverride + use_list_identity_defs: bool + list_identity_defs: ListIdentityDefsConfigOverride class DeleteIdentityDefConfigOverride(TypedDict, total=False): @@ -544,10 +607,31 @@ class DeleteIdentityDefConfigOverride(TypedDict, total=False): Examples -------- ```python - {} + { + "get_identity_def": { + "use_cache": False + }, + "use_list_identity_defs": False, + "list_identity_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_identity_def : GetIdentityDefConfigOverride + Config override for getting an identity definition. + use_list_identity_defs : bool + Whether to use list identity defs instead of getting a single identity definition. + list_identity_defs : ListIdentityDefsConfigOverride + Config override for listing identity definitions. """ - pass + get_identity_def: GetIdentityDefConfigOverride + use_list_identity_defs: bool + list_identity_defs: ListIdentityDefsConfigOverride class ValidateResourceDefConfigOverride(TypedDict, total=False): @@ -596,10 +680,31 @@ class PutResourceDefConfigOverride(TypedDict, total=False): Examples -------- ```python - {} + { + "get_resource_def": { + "use_cache": False + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_resource_def : GetResourceDefConfigOverride + Config override for getting a resource definition. + use_list_resource_defs : bool + Whether to use list resource defs instead of getting a single resource definition. + list_resource_defs : ListResourceDefsConfigOverride + Config override for listing resource definitions. """ - pass + get_resource_def: GetResourceDefConfigOverride + use_list_resource_defs: bool + list_resource_defs: ListResourceDefsConfigOverride class DeleteResourceDefConfigOverride(TypedDict, total=False): @@ -611,10 +716,31 @@ class DeleteResourceDefConfigOverride(TypedDict, total=False): Examples -------- ```python - {} + { + "get_resource_def": { + "use_cache": False + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_resource_def : GetResourceDefConfigOverride + Config override for getting a resource definition. + use_list_resource_defs : bool + Whether to use list resource defs instead of getting a single resource definition. + list_resource_defs : ListResourceDefsConfigOverride + Config override for listing resource definitions. """ - pass + get_resource_def: GetResourceDefConfigOverride + use_list_resource_defs: bool + list_resource_defs: ListResourceDefsConfigOverride class ValidateGrantConfigOverride(TypedDict, total=False): @@ -678,10 +804,31 @@ class RepealConfigOverride(TypedDict, total=False): Examples -------- ```python - {} + { + "get_grant": { + "use_cache": False + }, + "use_list_grants": False, + "list_grants": { + "page_size": 1000, + + "use_cache": False + } + } ``` + + Attributes + ---------- + get_grant : GetGrantConfigOverride + Config override for getting a grant. + use_list_grants : bool + Whether to use list grants instead of getting a single grant. + list_grants : ListGrantsConfigOverride + Config override for listing grants. """ - pass + get_grant: GetGrantConfigOverride + use_list_grants: bool + list_grants: ListGrantsConfigOverride class CleanupLatchesConfigOverride(TypedDict, total=False): @@ -1180,8 +1327,28 @@ class AuthzeeConfigOverride(TypedDict, total=False): "get_context_def": { "use_cache": False }, - "put_context_def": {}, - "delete_context_def": {}, + "put_context_def": { + "get_context_def": { + "use_cache": False + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + + "use_cache": False + } + }, + "delete_context_def": { + "get_context_def": { + "use_cache": False + }, + "use_list_context_defs": False, + "list_context_defs": { + "page_size": 1000, + + "use_cache": False + } + }, "validate_identity_def": {}, "list_identity_defs": { "page_size": 100, @@ -1190,8 +1357,28 @@ class AuthzeeConfigOverride(TypedDict, total=False): "get_identity_def": { "use_cache": False }, - "put_identity_def": {}, - "delete_identity_def": {}, + "put_identity_def": { + "get_identity_def": { + "use_cache": False + }, + "use_list_identity_defs": False, + "list_identity_defs": { + "page_size": 1000, + + "use_cache": False + } + }, + "delete_identity_def": { + "get_identity_def": { + "use_cache": False + }, + "use_list_identity_defs": False, + "list_identity_defs": { + "page_size": 1000, + + "use_cache": False + } + }, "validate_resource_def": {}, "list_resource_defs": { "page_size": 100, @@ -1200,8 +1387,28 @@ class AuthzeeConfigOverride(TypedDict, total=False): "get_resource_def": { "use_cache": False }, - "put_resource_def": {}, - "delete_resource_def": {}, + "put_resource_def": { + "get_resource_def": { + "use_cache": False + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + + "use_cache": False + } + }, + "delete_resource_def": { + "get_resource_def": { + "use_cache": False + }, + "use_list_resource_defs": False, + "list_resource_defs": { + "page_size": 1000, + + "use_cache": False + } + }, "validate_grant": {}, "list_grants": { "page_size": 100, @@ -1211,7 +1418,17 @@ class AuthzeeConfigOverride(TypedDict, total=False): "use_cache": False }, "enact": {}, - "repeal": {}, + "repeal": { + "get_grant": { + "use_cache": False + }, + "use_list_grants": False, + "list_grants": { + "page_size": 1000, + + "use_cache": False + } + }, "list_grant_refs": { "page_size": 10, "use_cache": False diff --git a/tester_store.py b/tester_store.py deleted file mode 100644 index 3b5435e..0000000 --- a/tester_store.py +++ /dev/null @@ -1,548 +0,0 @@ -"""Dict-based in-memory storage module for Authzee. - -See [](authzee.storage.dict_storage.DictStorage) -""" - -__all__ = [ - "DictStorage" -] - -import datetime -from uuid import uuid4 - -from authzee.module_locality import ModuleLocality -from authzee.storage.storage_module import StorageModule -from authzee.types.authzee import * -from authzee.types.config import ( - CleanupLatchesConfig, - CreateLatchConfig, - DeleteContextDefConfig, - DeleteIdentityDefConfig, - DeleteLatchConfig, - DeleteResourceDefConfig, - EnactConfig, - GetContextDefConfig, - GetGrantConfig, - GetIdentityDefConfig, - GetLatchConfig, - GetResourceDefConfig, - ListContextDefsConfig, - ListGrantRefsConfig, - ListGrantsConfig, - ListIdentityDefsConfig, - ListResourceDefsConfig, - PutContextDefConfig, - PutIdentityDefConfig, - PutResourceDefConfig, - RepealConfig, - SetLatchConfig, - StorageConstructConfig, - StorageDestroyConfig, - StorageShutdownConfig, - StorageStartConfig -) - - -class DictStorage(StorageModule): - """Storage module that keeps all Authzee data in a Python dict in main memory. - - Context, identity, and resource definitions, grants, and storage latches are all - stored within the given `storage_dict`. Because the data lives in a plain dict, it - is only shared by objects that reference the same dict and does not persist beyond - the lifetime of that dict. - - This storage module supports parallel pagination. - There is not really any penalty for using parallel pagination because it is just a python dict. - - Parameters - ---------- - storage_dict : dict - The dict used to hold all storage data. The same dict must be passed to every - `DictStorage` instance that should share state. - - Examples - -------- - - ```python - from authzee import Authzee, DictStorage, InProcessCompute, jmespath_execute - - storage_dict = {} - authz = Authzee( - execute=jmespath_execute, - compute_type=InProcessCompute, - compute_kwargs={}, - storage_type=DictStorage, - storage_kwargs={ - "storage_dict": storage_dict - } - ) - authz.construct() - authz.start() - ``` - """ - - - def __init__(self, storage_dict: dict): - super().__init__() - self._storage_dict = storage_dict - - - async def start(self, config: StorageStartConfig) -> GenericResult: - self.locality = ModuleLocality.PROCESS - self.has_parallel_paging = True - - return { - "error": None - } - - - async def shutdown(self, config: StorageShutdownConfig) -> GenericResult: - return { - "error": None - } - - - async def construct(self, config: StorageConstructConfig) -> GenericResult: - self._storage_dict['context_defs_lut'] = {} - self._storage_dict['identity_defs_lut'] = {} - self._storage_dict['resource_defs_lut'] = {} - self._storage_dict['grants_lut'] = {} - self._storage_dict['latches_lut'] = {} - - return { - "error": None - } - - - async def destroy(self, config: StorageDestroyConfig) -> GenericResult: - self._storage_dict.pop("context_defs_lut", None) - self._storage_dict.pop("identity_defs_lut", None) - self._storage_dict.pop("resource_defs_lut", None) - self._storage_dict.pop("grants_lut", None) - self._storage_dict.pop("latches_lut", None) - - return { - "error": None - } - - - async def list_context_defs( - self, - page_ref: str | None, - config: ListContextDefsConfig - ) -> ContextDefsPage: - raise Exception("TESTERRRRRR") - if page_ref is None: - start_index = 0 - else: - start_index = int(page_ref) - - context_defs = list(self._storage_dict['context_defs_lut'].values()) - end_index = start_index + config['page_size'] - - return { - "context_defs": context_defs[start_index:end_index], - "next_page_ref": str(end_index) if end_index < len(context_defs) else None, - "error": None - } - - - async def get_context_def( - self, - context_type: str, - config: GetContextDefConfig - ) -> ContextDefResult: - context_def = self._storage_dict['context_defs_lut'].get( - context_type, - None - ) - if context_def is None: - return { - "context_def": None, - "error": { - "error_type": "resource_not_found", - "message": f"Context type '{context_type}' was not found." - } - } - - return { - "context_def": context_def, - "error": None - } - - - async def put_context_def( - self, - context_def: ContextDef, - config: PutContextDefConfig - ) -> GenericResult: - self._storage_dict['context_defs_lut'][context_def['context_type']] = context_def - - return { - "error": None - } - - - async def delete_context_def( - self, - context_type: str, - config: DeleteContextDefConfig - ) -> GenericResult: - self._storage_dict['context_defs_lut'].pop( - context_type, - None - ) - - return { - "error": None - } - - - async def list_identity_defs( - self, - page_ref: str | None, - config: ListIdentityDefsConfig - ) -> IdentityDefsPage: - if page_ref is None: - start_index = 0 - else: - start_index = int(page_ref) - - identity_defs = list(self._storage_dict['identity_defs_lut'].values()) - end_index = start_index + config['page_size'] - - return { - "identity_defs": identity_defs[start_index:end_index], - "next_page_ref": str(end_index) if end_index < len(identity_defs) else None, - "error": None - } - - - async def get_identity_def( - self, - identity_type: str, - config: GetIdentityDefConfig - ) -> IdentityDefResult: - identity_def = self._storage_dict['identity_defs_lut'].get( - identity_type, - None - ) - if identity_def is None: - return { - "identity_def": None, - "error": { - "error_type": "resource_not_found", - "message": f"identity type '{identity_type}' was not found." - } - } - - return { - "identity_def": identity_def, - "error": None - } - - - async def put_identity_def( - self, - identity_def: IdentityDef, - config: PutIdentityDefConfig - ) -> GenericResult: - self._storage_dict['identity_defs_lut'][identity_def['identity_type']] = identity_def - - return { - "error": None - } - - - async def delete_identity_def( - self, - identity_type: str, - config: DeleteIdentityDefConfig - ) -> GenericResult: - self._storage_dict['identity_defs_lut'].pop( - identity_type, - None - ) - - return { - "error": None - } - - - async def list_resource_defs( - self, - page_ref: str | None, - config: ListResourceDefsConfig - ) -> ResourceDefsPage: - if page_ref is None: - start_index = 0 - else: - start_index = int(page_ref) - - resource_defs = list(self._storage_dict['resource_defs_lut'].values()) - end_index = start_index + config['page_size'] - - return { - "resource_defs": resource_defs[start_index:end_index], - "next_page_ref": str(end_index) if end_index < len(resource_defs) else None, - "error": None - } - - - async def get_resource_def( - self, - resource_type: str, - config: GetResourceDefConfig - ) -> ResourceDefResult: - resource_def = self._storage_dict['resource_defs_lut'].get( - resource_type, - None - ) - if resource_def is None: - return { - "resource_def": None, - "error": { - "error_type": "resource_not_found", - "message": f"resource type '{resource_type}' was not found." - } - } - - return { - "resource_def": resource_def, - "error": None - } - - - async def put_resource_def( - self, - resource_def: ResourceDef, - config: PutResourceDefConfig - ) -> GenericResult: - self._storage_dict['resource_defs_lut'][resource_def['resource_type']] = resource_def - - return { - "error": None - } - - - async def delete_resource_def( - self, - resource_type: str, - config: DeleteResourceDefConfig - ) -> GenericResult: - self._storage_dict['resource_defs_lut'].pop( - resource_type, - None - ) - - return { - "error": None - } - - - async def enact(self, grant: Grant, config: EnactConfig) -> GenericResult: - self._storage_dict['grants_lut'][grant['grant_uuid']] = grant - - return { - "error": None - } - - - async def repeal( - self, - grant_uuid: str, - purge: bool, - config: RepealConfig - ) -> GenericResult: - self._storage_dict['grants_lut'].pop(grant_uuid, None) - - return { - "error": None - } - - - async def get_grant( - self, - grant_uuid: str, - config: GetGrantConfig - ) -> GrantResult: - grant = self._storage_dict['grants_lut'].get(grant_uuid, None) - if grant is None: - return { - "grant": None, - "error": { - "error_type": "resource_not_found", - "message": f"Grant with UUID '{grant_uuid}' was not found." - } - } - - return { - "grant": grant, - "error": None - } - - - async def list_grants( - self, - effect: str | None, - action: str | None, - page_ref: str | None, - config: ListGrantsConfig - ) -> GrantsPage: - if page_ref is None: - start_index = 0 - else: - start_index = int(page_ref) - - grants: list[Grant] = list(self._storage_dict['grants_lut'].values()) - if effect is not None: - grants = [g for g in grants if g['effect'] == effect] - - if action is not None: - grants = [g for g in grants if action in g['actions']] - - end_index = start_index + config['page_size'] - - return { - "grants": grants[start_index:end_index], - "next_page_ref": str(end_index) if end_index < len(grants) else None, - "error": None - } - - - async def list_grant_refs( - self, - effect: str | None, - action: str | None, - page_ref: str | None, - config: ListGrantRefsConfig - ) -> PageRefsPage: - if page_ref is None: - start_index = 0 - else: - start_index = int(page_ref) - - grants: list[Grant] = list(self._storage_dict['grants_lut'].values()) - if effect is not None: - grants = [g for g in grants if g['effect'] == effect] - - if action is not None: - grants = [g for g in grants if action in g['actions']] - - num_grants = len(grants) - refs = [] - for _ in range(config['page_size']): - end_index = start_index + config['page_size'] - next_page_ref = end_index - refs.append(start_index) - start_index = end_index - if end_index >= num_grants: - next_page_ref = None - break - - return { - "page_refs": refs, - "next_page_ref": next_page_ref, - "error": None - } - - - async def create_latch(self, config: CreateLatchConfig) -> StorageLatchResult: - latch_uuid = str(uuid4()) - latch = { - "storage_latch_uuid": latch_uuid, - "is_set": False, - "created_at": datetime.datetime.now(tz=datetime.timezone.utc).isoformat() - } - self._storage_dict['latches_lut'][latch_uuid] = latch - - return { - "storage_latch": latch, - "error": None - } - - - async def get_latch( - self, - storage_latch_uuid: str, - config: GetLatchConfig - ) -> StorageLatchResult: - latch = self._storage_dict['latches_lut'].get( - storage_latch_uuid, - None - ) - if latch is None: - return { - "storage_latch": None, - "error": { - "error_type": "resource_not_found", - "message": f"Storage latch with UUID '{storage_latch_uuid}' was not found." - } - } - - return { - "storage_latch": latch, - "error": None - } - - - async def set_latch( - self, - storage_latch_uuid: str, - config: SetLatchConfig - ) -> StorageLatchResult: - result = await self.get_latch( - storage_latch_uuid=storage_latch_uuid, - config=config - ) - if result['error'] is not None: - return result - - result['storage_latch']['is_set'] = True - - return result - - - async def delete_latch( - self, - storage_latch_uuid: str, - config: DeleteLatchConfig - ) -> GenericResult: - self._storage_dict['latches_lut'].pop( - storage_latch_uuid, - None - ) - - return { - "error": None - } - - - async def cleanup_latches( - self, - before: datetime.datetime, - config: CleanupLatchesConfig - ) -> GenericResult: - new_lut = {} - before_str = before.astimezone(datetime.UTC).isoformat() - for lu, l in self._storage_dict['latches_lut'].items(): - if l['created_at'] > before_str: - new_lut[lu] = l - - self._storage_dict['latches_lut'] = new_lut - - return { - "error": None - } - - -async def main(): - my_dict = {} - store = DictStorage(storage_dict=my_dict) - result = await store.list_context_defs(page_ref=None, config={}) - print(result) - - -import asyncio - -asyncio.run(main()) diff --git a/tests/unit/test_sql_storage.py b/tests/unit/test_sql_storage.py new file mode 100644 index 0000000..e857c4b --- /dev/null +++ b/tests/unit/test_sql_storage.py @@ -0,0 +1,311 @@ +"""Unit tests for authzee.storage.sql_storage (SQLStorage). + +Reuses the shared storage module test suite bound to an in-memory SQLite backed +`SQLStorage`. The shared `storage` fixture is defined here. A handful of base +tests are overridden below because they encode behavior specific to a dict +backed module (constructing an instance from a plain dict, `PROCESS` locality +after start, treating arbitrary non-UUID strings as "not found", and integer +page reference values). Everything else is exercised directly from the shared +suite. +""" + +import asyncio +import datetime +import os +import sys +from uuid import uuid4 + +import pytest +from sqlalchemy.pool import StaticPool + + +sys.path.insert(0, os.path.dirname(__file__)) + +from storage_module_test_base import * +from storage_module_test_base import _grant + +from authzee.module_locality import ModuleLocality +from authzee.storage.sql_storage import SQLStorage + + +def _new_sql_storage(): + """Build a fresh, unconstructed in-memory `SQLStorage` instance. + + Returns + ------- + SQLStorage + A new in-memory SQLite backed storage instance. + """ + return SQLStorage( + sqlalchemy_async_engine_kwargs={ + "url": "sqlite+aiosqlite:///:memory:", + "connect_args": { + "check_same_thread": False + }, + "poolclass": StaticPool + } + ) + + +@pytest.fixture +def storage(): + """A fully initialized in-memory SQLStorage instance.""" + s = _new_sql_storage() + asyncio.run(s.start(config={})) + asyncio.run(s.construct(config={})) + + yield s + + asyncio.run(s.shutdown(config={})) + + +def test_base_start(): + s = _new_sql_storage() + result = asyncio.run(s.start(config={})) + assert result['error'] is None + assert s.locality == ModuleLocality.NETWORK + + +def test_base_construct(): + s = _new_sql_storage() + asyncio.run(s.start(config={})) + result = asyncio.run(s.construct(config={})) + assert result['error'] is None + context_def = { + "context_type": "NONE", + "schema": { + "type": "object" + } + } + asyncio.run(s.put_context_def(context_def, config={})) + get_result = asyncio.run(s.get_context_def("NONE", config={})) + assert get_result['error'] is None + assert get_result['context_def'] == context_def + + +def test_base_get_grant_not_found(storage): + result = asyncio.run(storage.get_grant(str(uuid4()), config={})) + assert result['error'] is not None + assert result['error']['error_type'] == "resource_not_found" + assert result['grant'] is None + + +def test_base_get_latch_not_found(storage): + result = asyncio.run(storage.get_latch(str(uuid4()), config={})) + assert result['error'] is not None + assert result['error']['error_type'] == "resource_not_found" + + +def test_base_set_latch_not_found(storage): + result = asyncio.run(storage.set_latch(str(uuid4()), config={})) + assert result['error'] is not None + assert result['error']['error_type'] == "resource_not_found" + + +def test_base_list_grant_refs(storage): + result = asyncio.run( + storage.list_grant_refs( + effect=None, + action=None, + page_ref=None, + config={ + "page_size": 2 + } + ) + ) + assert result['error'] is not None + assert result['error']['error_type'] == "parallel_pagination_not_supported" + assert result['page_refs'] == [] + assert result['next_page_ref'] is None + + +def test_base_list_grant_refs_filter_effect(storage): + result = asyncio.run( + storage.list_grant_refs( + effect="deny", + action=None, + page_ref=None, + config={ + "page_size": 2 + } + ) + ) + assert result['error'] is not None + assert result['error']['error_type'] == "parallel_pagination_not_supported" + + +def test_base_list_grant_refs_filter_action(storage): + result = asyncio.run( + storage.list_grant_refs( + effect=None, + action="write", + page_ref=None, + config={ + "page_size": 10 + } + ) + ) + assert result['error'] is not None + assert result['error']['error_type'] == "parallel_pagination_not_supported" + + +def test_sql_storage_start_no_parallel_paging(): + s = _new_sql_storage() + result = asyncio.run(s.start(config={})) + assert result['error'] is None + assert s.has_parallel_paging is False + + +def test_sql_storage_shutdown(storage): + result = asyncio.run(storage.shutdown(config={})) + assert result['error'] is None + + +def test_sql_storage_destroy(storage): + result = asyncio.run(storage.destroy(config={})) + assert result['error'] is None + put_result = asyncio.run( + storage.put_context_def( + { + "context_type": "NONE", + "schema": { + "type": "object" + } + }, + config={} + ) + ) + assert put_result['error'] is not None + + +def test_sql_storage_get_grant_bad_uuid_is_storage_error(storage): + result = asyncio.run(storage.get_grant("not-a-uuid", config={})) + assert result['error'] is not None + assert result['error']['error_type'] == "storage" + assert result['grant'] is None + + +def test_sql_storage_put_context_def_updates_existing(storage): + first = { + "context_type": "NONE", + "schema": { + "type": "object", + "additionalProperties": False + } + } + second = { + "context_type": "NONE", + "schema": { + "type": "object", + "additionalProperties": True + } + } + asyncio.run(storage.put_context_def(first, config={})) + asyncio.run(storage.put_context_def(second, config={})) + get_result = asyncio.run(storage.get_context_def("NONE", config={})) + assert get_result['error'] is None + assert get_result['context_def'] == second + list_result = asyncio.run( + storage.list_context_defs( + page_ref=None, + config={ + "page_size": 10 + } + ) + ) + assert len(list_result['context_defs']) == 1 + + +def test_sql_storage_put_resource_def_updates_existing(storage): + first = { + "resource_type": "balloon", + "actions": [ + "balloon:read" + ], + "schema": { + "type": "object" + } + } + second = { + "resource_type": "balloon", + "actions": [ + "balloon:read", + "balloon:inflate" + ], + "schema": { + "type": "object" + } + } + asyncio.run(storage.put_resource_def(first, config={})) + asyncio.run(storage.put_resource_def(second, config={})) + get_result = asyncio.run(storage.get_resource_def("balloon", config={})) + assert get_result['error'] is None + assert get_result['resource_def'] == second + + +def test_sql_storage_put_identity_def_updates_existing(storage): + first = { + "identity_type": "user", + "schema": { + "type": "object", + "additionalProperties": False + } + } + second = { + "identity_type": "user", + "schema": { + "type": "object", + "additionalProperties": True + } + } + asyncio.run(storage.put_identity_def(first, config={})) + asyncio.run(storage.put_identity_def(second, config={})) + get_result = asyncio.run(storage.get_identity_def("user", config={})) + assert get_result['error'] is None + assert get_result['identity_def'] == second + + +def test_sql_storage_cleanup_latches_removes_old(storage): + create_result = asyncio.run(storage.create_latch(config={})) + latch_uuid = create_result['storage_latch']['storage_latch_uuid'] + future = ( + datetime.datetime.now(tz=datetime.timezone.utc) + + datetime.timedelta(seconds=1) + ) + result = asyncio.run( + storage.cleanup_latches(before=future, config={}) + ) + assert result['error'] is None + get_result = asyncio.run(storage.get_latch(latch_uuid, config={})) + assert get_result['error'] is not None + + +def test_sql_storage_bare_memory_url_locality(): + s = SQLStorage( + sqlalchemy_async_engine_kwargs={ + "url": "sqlite+aiosqlite://:memory:" + } + ) + assert s.locality == ModuleLocality.SYSTEM + + +def test_sql_storage_latch_from_db_normalizes_aware_datetime(): + from authzee.storage.sql_storage import StorageLatchDB + + s = _new_sql_storage() + aware = datetime.datetime( + 2026, + 1, + 1, + 12, + 0, + 0, + tzinfo=datetime.timezone(datetime.timedelta(hours=5)) + ) + db_latch = StorageLatchDB( + storage_latch_uuid=uuid4(), + is_set=False, + created_at=aware + ) + latch = s._latch_from_db(db_latch) + assert latch['created_at'] == aware.astimezone(datetime.timezone.utc).isoformat()