Authorization Framework¶
castlecraft-engineer provides a flexible framework for handling authorization, ensuring that users or processes have the necessary permissions to perform actions or access data. This is primarily managed through an AuthorizationService, permission definitions, and integration with Command and Query Handlers.
1. AuthorizationService Abstract Base Class¶
The core of the authorization mechanism is the AuthorizationService abstract base class, located in castlecraft_engineer.authorization.base_service.AuthorizationService.
- Purpose: To define a contract for services that can check permissions.
- Key Method:
async check_permission(self, required_permissions: List[Permission], context: Optional[Dict[str, Any]] = None) -> None(or a synchronous version if your service is sync):- This method is responsible for verifying if the current actor (e.g., user, system process) possesses all the
required_permissions. required_permissions: A list ofPermissionobjects that the actor must have.context: An optional dictionary providing additional context for the permission check (e.g., resource IDs, user attributes).- If any permission is not granted, this method must raise an
AuthorizationError.
- This method is responsible for verifying if the current actor (e.g., user, system process) possesses all the
You will typically implement a concrete version of this service tailored to your application's authorization logic (e.g., checking against user roles, ACLs, or a policy engine).
2. AuthorizationError Exception¶
When a permission check fails within the check_permission method of an AuthorizationService, it should raise an AuthorizationError. This exception is defined in castlecraft_engineer.authorization.base_service.AuthorizationError and re-exported in castlecraft_engineer.exc.
The CommandBus and QueryBus are designed to catch this specific exception if it's raised during the authorize phase of a handler, preventing the execute method from being called.
3. Default AuthorizationService Implementations¶
castlecraft-engineer provides two simple default implementations in castlecraft_engineer.authorization.default_services:
DenyAllAuthorizationService: This service, as its name suggests, denies all permission checks by always raising anAuthorizationError. It's useful as a secure default if no specific authorization service is configured.AllowAllAuthorizationService: This service allows all permission checks by doing nothing in itscheck_permissionmethod. It's useful for development, testing, or in scenarios where authorization is handled externally or not required.
4. The Permission Class and Defining Permissions¶
Permissions are defined using the Permission class from castlecraft_engineer.authorization.permission.Permission. A Permission object typically encapsulates an action, a resource, and a scope.
from castlecraft_engineer.authorization.permission import Permission
from castlecraft_engineer.authorization.types import Action, Resource, Scope
# Example permission definitions
CAN_CREATE_PRODUCT = Permission(action=Action.CREATE, resource=Resource.PRODUCT, scope=Scope.ANY)
CAN_READ_OWN_ORDER = Permission(action=Action.READ, resource=Resource.ORDER, scope=Scope.OWN)
CAN_UPDATE_ANY_USER_PROFILE = Permission(action=Action.UPDATE, resource=Resource.USER_PROFILE, scope=Scope.ANY)
The Action, Resource, and Scope are enums defined in castlecraft_engineer.authorization.types. These enums provide a structured way to define common permission elements.
Extending Action, Resource, Scope¶
For application-specific needs, you will often need to extend these enums. You can do this by creating your own enum classes that inherit from the base enums or by defining new string constants if preferred (though enums offer better type safety).
Example: Extending Enums
from enum import Enum
from castlecraft_engineer.authorization.types import Action as BaseAction, Resource as BaseResource, Scope as BaseScope
class AppAction(BaseAction):
APPROVE = "approve"
REJECT = "reject"
class AppResource(BaseResource):
INVOICE = "invoice"
TIMESHEET = "timesheet"
class AppScope(BaseScope):
DEPARTMENT = "department"
# Using extended enums to define a permission
CAN_APPROVE_DEPARTMENT_TIMESHEET = Permission(action=AppAction.APPROVE, resource=AppResource.TIMESHEET, scope=AppScope.DEPARTMENT)
AuthorizationService is aware of these new values.
5. The @ctx Decorator for Handlers¶
To associate required permissions directly with Command or Query Handlers, castlecraft-engineer provides the @ctx decorator from castlecraft_engineer.authorization.permission.ctx.
- Purpose: To declaratively specify the permissions needed to execute a handler.
- Usage:
@ctx(required_permissions=[Permission(...)])
This decorator attaches the list of Permission objects to the handler class itself. This metadata can then be accessed within the handler's authorize method.
# Example: Using @ctx with a Command Handler
from castlecraft_engineer.abstractions.command import Command
from castlecraft_engineer.abstractions.command_handler import CommandHandler
from castlecraft_engineer.authorization.permission import ctx, Permission
from castlecraft_engineer.authorization.types import Action, Resource, Scope
from castlecraft_engineer.authorization.base_service import AuthorizationService
from dataclasses import dataclass
import uuid
CAN_CREATE_PRODUCT = Permission(action=Action.CREATE, resource=Resource.PRODUCT, scope=Scope.ANY)
@dataclass(frozen=True)
class CreateProductCommand(Command):
name: str
price: float
@ctx(required_permissions=[CAN_CREATE_PRODUCT])
class CreateProductHandler(CommandHandler[CreateProductCommand, uuid.UUID]):
def __init__(self, auth_service: AuthorizationService): # Other deps like session, repo omitted for brevity
self.auth_service = auth_service
async def authorize(self, command: CreateProductCommand) -> None:
print(f"Authorizing CreateProductCommand for '{command.name}'")
# Access permissions attached by @ctx
required_perms = getattr(self, 'required_permissions', [])
await self.auth_service.check_permission(required_permissions=required_perms)
print("Authorization successful for CreateProductCommand")
async def execute(self, command: CreateProductCommand) -> uuid.UUID:
product_id = uuid.uuid4()
print(f"Executing CreateProductCommand: Product '{command.name}' (ID: {product_id}) would be created.")
return product_id
6. Implementing the authorize Method in Handlers¶
The authorize method within your Command and Query Handlers is where you invoke the AuthorizationService.
- The handler receives an instance of your
AuthorizationServicevia dependency injection. - It retrieves the
required_permissions(often set by the@ctxdecorator or defined directly within the handler). - It calls
auth_service.check_permission(required_permissions=...), potentially passing additional context derived from the command or query (e.g., the ID of the resource being accessed for scope checks likeScope.OWN).
Example authorize method (continuation from above):
# Inside CreateProductHandler
# async def authorize(self, command: CreateProductCommand) -> None:
# print(f"Authorizing CreateProductCommand for '{command.name}'")
# required_perms = getattr(self, 'required_permissions', [])
# # Example of adding context if needed for more granular checks
# # context = {"user_department": "sales", "resource_owner_id": command.some_owner_id}
# await self.auth_service.check_permission(required_permissions=required_perms) #, context=context)
# print("Authorization successful for CreateProductCommand")
7. Registering AuthorizationService with DI¶
Your chosen AuthorizationService implementation (custom or default) must be registered with the Dependency Injection container so it can be injected into handlers.
Using ContainerBuilder from castlecraft_engineer.common.di:
from castlecraft_engineer.common.di import ContainerBuilder
from castlecraft_engineer.authorization.base_service import AuthorizationService
from castlecraft_engineer.authorization.default_services import AllowAllAuthorizationService # Or your custom service
# from my_app.custom_auth import MyCustomAuthorizationService
builder = ContainerBuilder()
# Register your chosen authorization service
builder.with_authorization(service=AllowAllAuthorizationService) # For development/testing
# builder.with_authorization(service=MyCustomAuthorizationService) # For production
# ... other registrations (database, command/query buses, handlers, etc.)
container = builder.build()
with_authorization method ensures that the specified service is registered against the AuthorizationService interface type.
Conclusion¶
The authorization framework in castlecraft-engineer provides a robust and extensible way to manage permissions. By defining Permission objects, using the @ctx decorator, implementing custom AuthorizationService logic, and integrating with Command/Query Handlers, you can build secure applications where actions and data access are appropriately controlled.