Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use AppKey in aiohttp 3.9 #808

Merged
merged 18 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ per-file-ignores =
examples/*:I900,S105

# flake8-import-order
application-import-names = aiohttp_admin, _auth, _auth_helpers, _models, _resources
application-import-names = aiohttp_admin, conftest, _auth, _auth_helpers, _models, _resources
import-order-style = pycharm

# flake8-quotes
Expand Down
19 changes: 10 additions & 9 deletions aiohttp_admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@

from .routes import setup_resources, setup_routes
from .security import AdminAuthorizationPolicy, Permissions, TokenIdentityPolicy, check
from .types import Schema, UserDetails
from .types import Schema, State, UserDetails, check_credentials_key, permission_re_key, state_key

__all__ = ("Permissions", "Schema", "UserDetails", "setup")
__all__ = ("Permissions", "Schema", "UserDetails", "permission_re_key", "setup")
__version__ = "0.1.0a2"


Expand Down Expand Up @@ -51,7 +51,7 @@ async def on_startup(admin: web.Application) -> None:
enclosing scope later.
"""
storage._cookie_params["path"] = prefixed_subapp.canonical
admin["state"]["urls"] = {
admin[state_key]["urls"] = {
"token": str(admin.router["token"].url_for()),
"logout": str(admin.router["logout"].url_for())
}
Expand All @@ -65,7 +65,8 @@ def value(r: web.RouteDef) -> tuple[str, str]:

for res in schema["resources"]:
m = res["model"]
admin["state"]["resources"][m.name]["urls"] = {key(r): value(r) for r in m.routes}
urls = admin[state_key]["resources"][m.name]["urls"]
urls.update((key(r), value(r)) for r in m.routes)

schema = check(Schema, schema)
if secret is None:
Expand All @@ -74,9 +75,9 @@ def value(r: web.RouteDef) -> tuple[str, str]:
admin = web.Application()
admin.middlewares.append(pydantic_middleware)
admin.on_startup.append(on_startup)
admin["check_credentials"] = schema["security"]["check_credentials"]
admin["identity_callback"] = schema["security"].get("identity_callback")
admin["state"] = {"view": schema.get("view", {}), "js_module": schema.get("js_module")}
admin[check_credentials_key] = schema["security"]["check_credentials"]
admin[state_key] = State({"view": schema.get("view", {}), "js_module": schema.get("js_module"),
"urls": {}, "resources": {}})

max_age = schema["security"].get("max_age")
secure = schema["security"].get("secure", True)
Expand All @@ -90,7 +91,7 @@ def value(r: web.RouteDef) -> tuple[str, str]:
setup_resources(admin, schema)

resource_patterns = []
for r, state in admin["state"]["resources"].items():
for r, state in admin[state_key]["resources"].items():
fields = state["fields"].keys()
resource_patterns.append(
r"(?#Resource name){r}"
Expand All @@ -102,7 +103,7 @@ def value(r: web.RouteDef) -> tuple[str, str]:
p_re = (r"(?#Global admin permission)~?admin\.(view|edit|add|delete|\*)"
r"|"
r"(?#Resource permission)(~)?admin\.({})").format("|".join(resource_patterns))
admin["permission_re"] = re.compile(p_re)
admin[permission_re_key] = re.compile(p_re)

prefixed_subapp = app.add_subapp(path, admin)
return admin
11 changes: 7 additions & 4 deletions aiohttp_admin/backends/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
import sys
from collections.abc import Callable, Coroutine, Iterator, Sequence
from types import MappingProxyType as MPT
from typing import Any, Literal, Optional, TypeVar, Union
from typing import Any, Literal, Optional, TypeVar, Union, cast

import sqlalchemy as sa
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
from sqlalchemy.orm import DeclarativeBase, DeclarativeBaseNoMeta, Mapper, QueryableAttribute
from sqlalchemy.sql.roles import ExpressionElementRole

from .abc import AbstractAdminResource, GetListParams, Meta, Record
Expand All @@ -26,6 +26,7 @@
_FValues = Union[bool, int, str]
_Filters = dict[Union[sa.Column[object], QueryableAttribute[Any]],
Union[_FValues, Sequence[_FValues]]]
_ModelOrTable = Union[sa.Table, type[DeclarativeBase], type[DeclarativeBaseNoMeta]]

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -155,7 +156,7 @@ def create_filters(columns: sa.ColumnCollection[str, sa.Column[object]],

# ID is based on PK, which we can't infer from types, so must use Any here.
class SAResource(AbstractAdminResource[Any]):
def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[DeclarativeBase]]):
def __init__(self, db: AsyncEngine, model_or_table: _ModelOrTable):
if isinstance(model_or_table, sa.Table):
table = model_or_table
else:
Expand Down Expand Up @@ -221,7 +222,9 @@ def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[Declara

if not isinstance(model_or_table, sa.Table):
# Append fields to represent ORM relationships.
mapper = sa.inspect(model_or_table)
# Mypy doesn't handle union well here.
mapper = cast(Union[Mapper[DeclarativeBase], Mapper[DeclarativeBaseNoMeta]],
sa.inspect(model_or_table))
assert mapper is not None # noqa: S101
for name, relationship in mapper.relationships.items():
# https://github.com/sqlalchemy/sqlalchemy/discussions/10161#discussioncomment-6583442
Expand Down
18 changes: 9 additions & 9 deletions aiohttp_admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@
from aiohttp import web

from . import views
from .types import Schema
from .types import Schema, _ResourceState, resources_key, state_key


def setup_resources(admin: web.Application, schema: Schema) -> None:
admin["resources"] = []
admin["state"]["resources"] = {}
admin[resources_key] = []

for r in schema["resources"]:
m = r["model"]
admin["resources"].append(m)
admin[resources_key].append(m)
admin.router.add_routes(m.routes)

try:
Expand Down Expand Up @@ -47,11 +46,12 @@ def setup_resources(admin: web.Application, schema: Schema) -> None:
for name, props in r.get("field_props", {}).items():
fields[name]["props"].update(props)

state = {"fields": fields, "inputs": inputs, "list_omit": tuple(omit_fields),
"repr": repr_field, "label": r.get("label"), "icon": r.get("icon"),
"bulk_update": r.get("bulk_update", {}),
"show_actions": r.get("show_actions", ())}
admin["state"]["resources"][m.name] = state
state: _ResourceState = {
"fields": fields, "inputs": inputs, "list_omit": tuple(omit_fields),
"repr": repr_field, "label": r.get("label"), "icon": r.get("icon"),
"bulk_update": r.get("bulk_update", {}), "urls": {},
"show_actions": r.get("show_actions", ())}
admin[state_key]["resources"][m.name] = state


def setup_routes(admin: web.Application) -> None:
Expand Down
18 changes: 11 additions & 7 deletions aiohttp_admin/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,20 @@ def permissions_as_dict(permissions: Collection[str]) -> dict[str, dict[str, lis
return p_dict


class AdminAuthorizationPolicy(AbstractAuthorizationPolicy): # type: ignore[misc,no-any-unimported]
class AdminAuthorizationPolicy(AbstractAuthorizationPolicy):
def __init__(self, schema: Schema):
super().__init__()
self._identity_callback = schema["security"].get("identity_callback")

async def authorized_userid(self, identity: str) -> str:
return identity

async def permits(self, identity: Optional[str], permission: Union[str, Enum],
context: tuple[web.Request, Optional[Mapping[str, object]]]) -> bool:
async def permits(
self, identity: Optional[str], permission: Union[str, Enum],
context: Optional[tuple[web.Request, Optional[Mapping[str, object]]]] = None
) -> bool:
# TODO: https://github.com/aio-libs/aiohttp-security/issues/677
assert context is not None # noqa: S101
if identity is None:
return False

Expand All @@ -101,7 +105,7 @@ async def permits(self, identity: Optional[str], permission: Union[str, Enum],
return has_permission(permission, permissions_as_dict(permissions), record)


class TokenIdentityPolicy(SessionIdentityPolicy): # type: ignore[misc,no-any-unimported]
class TokenIdentityPolicy(SessionIdentityPolicy):
def __init__(self, fernet: Fernet, schema: Schema):
super().__init__()
self._fernet = fernet
Expand Down Expand Up @@ -130,17 +134,17 @@ async def identify(self, request: web.Request) -> Optional[str]:
# Both identites must match.
return token_identity if token_identity == cookie_identity else None

async def remember(self, request: web.Request, response: web.Response,
async def remember(self, request: web.Request, response: web.StreamResponse,
identity: str, **kwargs: object) -> None:
"""Send auth tokens to client for authentication."""
# For proper security we send a token for JS to store and an HTTP only cookie:
# https://www.redotheweb.com/2015/11/09/api-security.html
# Send token that will be saved in local storage by the JS client.
response.headers["X-Token"] = json.dumps(await self.user_identity_dict(request, identity))
# Send httponly cookie, which will be invisible to JS.
await super().remember(request, response, identity, **kwargs)
await super().remember(request, response, identity, **kwargs) # type: ignore[arg-type]

async def forget(self, request: web.Request, response: web.Response) -> None:
async def forget(self, request: web.Request, response: web.StreamResponse) -> None:
"""Delete session cookie (JS client should choose to delete its token)."""
await super().forget(request, response)

Expand Down
12 changes: 11 additions & 1 deletion aiohttp_admin/types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import re
import sys
from collections.abc import Callable, Collection, Sequence
from typing import Any, Awaitable, Literal, Mapping, Optional

from aiohttp.web import AppKey

if sys.version_info >= (3, 12):
from typing import TypedDict
else:
Expand Down Expand Up @@ -110,14 +113,15 @@ class Schema(_Schema):


class _ResourceState(TypedDict):
display: Sequence[str]
fields: dict[str, ComponentState]
inputs: dict[str, InputState]
show_actions: Sequence[ComponentState]
repr: str
icon: Optional[str]
urls: dict[str, tuple[str, str]] # (method, url)
bulk_update: dict[str, dict[str, Any]]
list_omit: tuple[str, ...]
label: Optional[str]


class State(TypedDict):
Expand Down Expand Up @@ -146,3 +150,9 @@ def func(name: str, args: Optional[Sequence[object]] = None) -> FunctionState:
def regex(value: str) -> RegexState:
"""Convert value to a RegExp object on the frontend."""
return {"__type__": "regexp", "value": value}


check_credentials_key = AppKey[Callable[[str, str], Awaitable[bool]]]("check_credentials")
permission_re_key = AppKey("permission_re", re.Pattern[str])
resources_key = AppKey("resources", list[Any]) # TODO(pydantic): AbstractAdminResource
state_key = AppKey("state", State)
9 changes: 5 additions & 4 deletions aiohttp_admin/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pydantic import Json

from .security import check
from .types import check_credentials_key, state_key

if sys.version_info >= (3, 12):
from typing import TypedDict
Expand Down Expand Up @@ -38,15 +39,15 @@ async def index(request: web.Request) -> web.Response:
"""Root page which loads react-admin."""
static = request.app.router["static"]
js = static.url_for(filename="admin.js")
state = json.dumps(request.app["state"])
state = json.dumps(request.app[state_key])

# __package__ can be None, despite what the documentation claims.
package_name = __main__.__package__ or "My"
# Common convention is to have _app suffix for package name, so try and strip that.
package_name = package_name.removesuffix("_app").replace("_", " ").title()
name = request.app["state"]["view"].get("name", package_name)
name = request.app[state_key]["view"].get("name", package_name)

icon = request.app["state"]["view"].get("icon", static.url_for(filename="favicon.svg"))
icon = request.app[state_key]["view"].get("icon", static.url_for(filename="favicon.svg"))

output = INDEX_TEMPLATE.format(name=name, icon=icon, js=js, state=state)
return web.Response(text=output, content_type="text/html")
Expand All @@ -56,7 +57,7 @@ async def token(request: web.Request) -> web.Response:
"""Validate user credentials and log the user in."""
data = check(Json[_Login], await request.read())

check_credentials = request.app["check_credentials"]
check_credentials = request.app[check_credentials_key]
if not await check_credentials(data["username"], data["password"]):
raise web.HTTPUnauthorized(text="Wrong username or password")

Expand Down
16 changes: 10 additions & 6 deletions examples/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@

import sqlalchemy as sa
from aiohttp import web
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

import aiohttp_admin
from aiohttp_admin import Permissions, UserDetails
from aiohttp_admin import Permissions, UserDetails, permission_re_key
from aiohttp_admin.backends.sqlalchemy import SAResource, permission_for as p

db = web.AppKey("db", async_sessionmaker[AsyncSession])


class Base(DeclarativeBase):
"""Base model."""
Expand Down Expand Up @@ -49,14 +51,16 @@ class User(Base):

async def check_credentials(app: web.Application, username: str, password: str) -> bool:
"""Allow login to any user account regardless of password."""
async with app["db"]() as sess:
async with app[db]() as sess:
user = await sess.get(User, username.lower())
return user is not None


async def identity_callback(app: web.Application, identity: str) -> UserDetails:
async with app["db"]() as sess:
async with app[db]() as sess:
user = await sess.get(User, identity)
if not user:
raise ValueError("No user found for given identity")
return {"permissions": json.loads(user.permissions), "fullName": user.username.title()}


Expand All @@ -79,7 +83,7 @@ async def create_app() -> web.Application:
sess.add(SimpleParent(id=p_simple.id, date=datetime(2023, 2, 13, 19, 4)))

app = web.Application()
app["db"] = session
app[db] = session

# This is the setup required for aiohttp-admin.
schema: aiohttp_admin.Schema = {
Expand Down Expand Up @@ -123,7 +127,7 @@ async def create_app() -> web.Application:
filters={Simple.num: 5}))
}
for name, permissions in users.items():
if any(admin["permission_re"].fullmatch(p) is None for p in permissions):
if any(admin[permission_re_key].fullmatch(p) is None for p in permissions):
raise ValueError("Not a valid permission.")
sess.add(User(username=name, permissions=json.dumps(permissions)))

Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
-e .
aiohttp==3.8.6
aiohttp-security==0.4.0
aiohttp==3.9.0
aiohttp-security==0.5.0
aiohttp-session[secure]==2.12.0
aiosqlite==0.19.0
cryptography==41.0.5
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def read_version():
download_url="https://github.com/aio-libs/aiohttp-admin",
license="Apache 2",
packages=find_packages(),
install_requires=("aiohttp>=3.8.2", "aiohttp_security", "aiohttp_session",
install_requires=("aiohttp>=3.9", "aiohttp_security", "aiohttp_session",
"cryptography", "pydantic>2,<3",
'typing_extensions>=3.10; python_version<"3.12"'),
extras_require={"sa": ["sqlalchemy>=2.0.4,<3"]},
Expand Down
Loading
Loading