Skip to content

Permissions

Lilya incorporates an inherent permission system designed to facilitate the principle of separation of concerns. Interestingly, this permission system closely resembles middlewares.

In essence, permissions in Lilya function as pure ASGI applications, akin to middlewares, but are specifically tailored to manage access control within an application.

The rationale behind introducing another ASGI-like application, akin to middleware but for permissions, lies in maintaining a clear and singular purpose for each component. Lilya ensures this distinction.

Permissions operate in the sequence after the middleware and before reaching the handler, positioning them ideally for controlling access to the application.

Using the permission

The Lilya application class provides a means to include the ASGI permission in a manner that guarantees it remains encapsulated within the exception handler.

from lilya.apps import Lilya
from lilya.exceptions import PermissionDenied
from lilya.permissions import DefinePermission
from lilya.protocols.permissions import PermissionProtocol
from lilya.requests import Request
from lilya.responses import Ok
from lilya.routing import Path
from lilya.types import ASGIApp, Receive, Scope, Send


class AllowAccess(PermissionProtocol):
    def __init__(self, app: ASGIApp, *args, **kwargs):
        super().__init__(app, *args, **kwargs)
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        request = Request(scope=scope, receive=receive, send=send)

        if "allow-admin" in request.headers:
            await self.app(scope, receive, send)
            return
        raise PermissionDenied()


def user(user: str):
    return Ok({"message": f"Welcome {user}"})


app = Lilya(
    routes=[Path("/{user}", user)],
    permissions=[DefinePermission(AllowAccess)],
)

When defining a permission, it is imperative to utilize lilya.permissions.DefinePermission to encapsulate it. You can also define an import string ("path.to.class") and lazily import the Permission. Additionally, it is advisable to adhere to the PermissionProtocol from lilya.protocols.permissions.PermissionProtocol as it provides an interface for the definition.

Lilya includes a default exception specifically for denying permissions. Typically, when denying a permission, a status code 403 is raised along with a specific message. This functionality is encapsulated in lilya.exceptions.PermissionDenied.

Furthermore, the details of the message can be customized as needed.

PermissionProtocol

For those coming from a more enforced typed language like Java or C#, a protocol is the python equivalent to an interface.

The PermissionProtocol is simply an interface to build permissions for Lilya by enforcing the implementation of the __init__ and the async def __call__.

Enforcing this protocol also aligns with writing a Pure ASGI Permission.

Quick sample

from lilya.exceptions import PermissionDenied
from lilya.protocols.permissions import PermissionProtocol
from lilya.requests import Request
from lilya.types import ASGIApp, Receive, Scope, Send


class DenyAccess(PermissionProtocol):
    def __init__(self, app: ASGIApp, *args, **kwargs):
        super().__init__(app, *args, **kwargs)
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        raise PermissionDenied()


class AllowAccess(PermissionProtocol):
    def __init__(self, app: ASGIApp, *args, **kwargs):
        super().__init__(app, *args, **kwargs)
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        request = Request(scope=scope, receive=receive, send=send)

        if "allow-admin" in request.headers:
            await self.app(scope, receive, send)
            return
        raise PermissionDenied()

Permission and the application

Creating this type of permissions will make sure the protocols are followed and therefore reducing development errors by removing common mistakes.

To add middlewares to the application is very simple. You can add it at any level of the application. Those can be included in the Lilya/ChildLilya, Include, Path and WebSocketPath.

from lilya.apps import Lilya
from lilya.exceptions import PermissionDenied
from lilya.permissions import DefinePermission
from lilya.protocols.permissions import PermissionProtocol
from lilya.requests import Request
from lilya.types import ASGIApp, Receive, Scope, Send


class AllowAccess(PermissionProtocol):
    def __init__(self, app: ASGIApp, *args, **kwargs):
        super().__init__(app, *args, **kwargs)
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        request = Request(scope=scope, receive=receive, send=send)

        if "allow-admin" in request.headers:
            await self.app(scope, receive, send)
            return
        raise PermissionDenied()


app = Lilya(
    routes=[...],
    permissions=[
        DefinePermission(AllowAccess),
    ],
)
from lilya.apps import Lilya
from lilya.exceptions import PermissionDenied
from lilya.permissions import DefinePermission
from lilya.protocols.permissions import PermissionProtocol
from lilya.requests import Request
from lilya.routing import Include, Path
from lilya.types import ASGIApp, Receive, Scope, Send


class AllowAccess(PermissionProtocol):
    def __init__(self, app: ASGIApp, *args, **kwargs):
        super().__init__(app, *args, **kwargs)
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        request = Request(scope=scope, receive=receive, send=send)

        if "allow-access" in request.headers:
            await self.app(scope, receive, send)
            return
        raise PermissionDenied()


class AdminAccess(PermissionProtocol):
    def __init__(self, app: ASGIApp, *args, **kwargs):
        super().__init__(app, *args, **kwargs)
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        request = Request(scope=scope, receive=receive, send=send)

        if "allow-admin" in request.headers:
            await self.app(scope, receive, send)
            return
        raise PermissionDenied()


async def home():
    return "Hello world"


async def user(user: str):
    return f"Hello {user}"


# Via Path
app = Lilya(
    routes=[
        Path("/", handler=home),
        Path(
            "/{user}",
            handler=user,
            permissions=[
                DefinePermission(AdminAccess),
            ],
        ),
    ],
    permissions=[DefinePermission(AllowAccess)],
)


# Via Include
app = Lilya(
    routes=[
        Include(
            "/",
            routes=[
                Path("/", handler=home),
                Path(
                    "/{user}",
                    handler=user,
                    permissions=[
                        DefinePermission(AdminAccess),
                    ],
                ),
            ],
            permissions=[DefinePermission(AllowAccess)],
        )
    ]
)

Pure ASGI permission

Lilya follows the ASGI spec. This capability allows for the implementation of ASGI permissions using the ASGI interface directly. This involves creating a chain of ASGI applications that call into the next one.

Example of the most common approach

from lilya.types import ASGIApp, Scope, Receive, Send


class MyPermission:
    def __init__(self, app: ASGIApp):
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send):
        await self.app(scope, receive, send)

When implementing a Pure ASGI permission, it is like implementing an ASGI application, the first parameter should always be an app and the __call__ should always return the app.

Permissions and the settings

One of the advantages of Lilya is leveraging the settings to make the codebase tidy, clean and easy to maintain. As mentioned in the settings document, the permissions is one of the properties available to use to start a Lilya application.

from __future__ import annotations

from dataclasses import dataclass

from lilya.conf.global_settings import Settings
from lilya.exceptions import PermissionDenied
from lilya.permissions import DefinePermission
from lilya.protocols.permissions import PermissionProtocol
from lilya.requests import Request
from lilya.types import ASGIApp, Receive, Scope, Send


class AllowAccess(PermissionProtocol):
    def __init__(self, app: ASGIApp, *args, **kwargs):
        super().__init__(app, *args, **kwargs)
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        request = Request(scope=scope, receive=receive, send=send)

        if "allow-access" in request.headers:
            await self.app(scope, receive, send)
            return
        raise PermissionDenied()


@dataclass
class AppSettings(Settings):
    @property
    def permissions(self) -> list[DefinePermission]:
        """
        All the permissions to be added when the application starts.
        """
        return [
            DefinePermission(AllowAccess),
        ]