diff --git a/funnel/models/account.py b/funnel/models/account.py index 8a4d8ad87..e3ee722ab 100644 --- a/funnel/models/account.py +++ b/funnel/models/account.py @@ -6,7 +6,7 @@ import itertools from collections.abc import Iterable, Iterator, Sequence from datetime import datetime, timedelta -from typing import ClassVar, Literal, cast, overload +from typing import TYPE_CHECKING, ClassVar, Literal, cast, overload from uuid import UUID import phonenumbers @@ -354,6 +354,15 @@ class Account(UuidMixin, BaseMixin, Model): back_populates='account' ) + # auth_client.py + clients: Mapped[AuthClient] = relationship(back_populates='account') + authtokens: DynamicMapped[AuthToken] = relationship( + lazy='dynamic', back_populates='account' + ) + client_permissions: Mapped[list[AuthClientPermissions]] = relationship( + back_populates='account' + ) + __table_args__ = ( sa.Index( 'ix_account_name_lower', @@ -1462,6 +1471,11 @@ class Team(UuidMixin, BaseMixin, Model): sa.Boolean, nullable=False, default=False ) + # --- Backrefs + client_permissions: Mapped[list[AuthClientTeamPermissions]] = relationship( + back_populates='team' + ) + def __repr__(self) -> str: """Represent :class:`Team` as a string.""" return f'' @@ -2191,3 +2205,11 @@ def get( # Tail imports from .membership_mixin import ImmutableMembershipMixin # isort: skip from .account_membership import AccountMembership # isort:skip + +if TYPE_CHECKING: + from .auth_client import ( + AuthClient, + AuthClientPermissions, + AuthClientTeamPermissions, + AuthToken, + ) diff --git a/funnel/models/auth_client.py b/funnel/models/auth_client.py index af968271f..66e565e3a 100644 --- a/funnel/models/auth_client.py +++ b/funnel/models/auth_client.py @@ -23,7 +23,6 @@ Model, Query, UuidMixin, - backref, db, declarative_mixin, declared_attr, @@ -96,11 +95,7 @@ class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): sa.ForeignKey('account.id'), nullable=False ) account: Mapped[Account] = with_roles( - relationship( - Account, - foreign_keys=[account_id], - backref=backref('clients'), - ), + relationship(back_populates='clients'), read={'all'}, write={'owner'}, grants_via={None: {'owner': 'owner', 'admin': 'admin'}}, @@ -155,11 +150,27 @@ class AuthClient(ScopeMixin, UuidMixin, BaseMixin, Model): sa_orm.mapped_column(sa.Boolean, nullable=False, default=False), read={'all'} ) + # --- Backrefs + login_sessions: DynamicMapped[LoginSession] = relationship( - LoginSession, lazy='dynamic', secondary=auth_client_login_session, - backref=backref('auth_clients', lazy='dynamic'), + back_populates='auth_clients', + ) + credentials: Mapped[dict[str, AuthClientCredential]] = relationship( + collection_class=attribute_keyed_dict('name'), back_populates='auth_client' + ) + authcodes: DynamicMapped[AuthCode] = relationship( + lazy='dynamic', back_populates='auth_client' + ) + authtokens: DynamicMapped[AuthToken] = relationship( + lazy='dynamic', back_populates='auth_client' + ) + account_permissions: Mapped[list[AuthClientPermissions]] = relationship( + back_populates='auth_client' + ) + team_permissions: Mapped[list[AuthClientTeamPermissions]] = relationship( + back_populates='auth_client' ) __roles__ = { @@ -179,12 +190,12 @@ def secret_is(self, candidate: str, name: str) -> bool: return credential.secret_is(candidate) @property - def redirect_uris(self) -> Iterable[str]: + def redirect_uris(self) -> tuple[str, ...]: """Return redirect URIs as a sequence.""" return tuple(self._redirect_uris.split()) if self._redirect_uris else () @redirect_uris.setter - def redirect_uris(self, value: Iterable) -> None: + def redirect_uris(self, value: Iterable[str]) -> None: """Set redirect URIs from a sequence, storing internally as lines of text.""" self._redirect_uris = '\r\n'.join(value) @@ -217,7 +228,7 @@ def authtoken_for( self, account: Account | None, login_session: LoginSession | None = None ) -> AuthToken | None: """ - Return the authtoken for this account and client. + Return the auth token for this account and client. Only works for confidential clients. """ @@ -289,13 +300,7 @@ class AuthClientCredential(BaseMixin, Model): sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False ) auth_client: Mapped[AuthClient] = with_roles( - relationship( - AuthClient, - backref=backref( - 'credentials', - collection_class=attribute_keyed_dict('name'), - ), - ), + relationship(back_populates='credentials'), grants_via={None: {'owner'}}, ) @@ -378,11 +383,7 @@ class AuthCode(ScopeMixin, BaseMixin, Model): auth_client_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False ) - auth_client: Mapped[AuthClient] = relationship( - AuthClient, - foreign_keys=[auth_client_id], - backref=backref('authcodes'), - ) + auth_client: Mapped[AuthClient] = relationship(back_populates='authcodes') login_session_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('login_session.id'), nullable=True ) @@ -421,27 +422,21 @@ class AuthToken(ScopeMixin, BaseMixin, Model): account_id: Mapped[int | None] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=True ) - account: Mapped[Account | None] = relationship( - Account, - backref=backref('authtokens', lazy='dynamic'), - ) + account: Mapped[Account | None] = relationship(back_populates='authtokens') #: The session in which this token was issued, null for confidential clients login_session_id: Mapped[int | None] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('login_session.id'), nullable=True ) login_session: Mapped[LoginSession | None] = with_roles( - relationship(LoginSession, backref=backref('authtokens', lazy='dynamic')), + relationship(LoginSession, back_populates='authtokens'), read={'owner'}, ) - #: The client this authtoken is for + #: The client this auth token is for auth_client_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True ) auth_client: Mapped[AuthClient] = with_roles( - relationship( - AuthClient, - backref=backref('authtokens', lazy='dynamic'), - ), + relationship(back_populates='authtokens'), read={'owner'}, ) #: The token @@ -463,7 +458,7 @@ class AuthToken(ScopeMixin, BaseMixin, Model): sa.String(22), nullable=True, unique=True ) - # Only one authtoken per user and client. Add to scope as needed + # Only one auth token per user and client. Add to scope as needed __table_args__ = ( sa.UniqueConstraint('account_id', 'auth_client_id'), sa.UniqueConstraint('login_session_id', 'auth_client_id'), @@ -643,21 +638,13 @@ class AuthClientPermissions(BaseMixin, Model): account_id: Mapped[int] = sa_orm.mapped_column( sa.ForeignKey('account.id'), nullable=False ) - account: Mapped[Account] = relationship( - Account, - foreign_keys=[account_id], - backref=backref('client_permissions'), - ) + account: Mapped[Account] = relationship(back_populates='client_permissions') #: AuthClient app they are assigned on auth_client_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True ) auth_client: Mapped[AuthClient] = with_roles( - relationship( - AuthClient, - foreign_keys=[auth_client_id], - backref=backref('account_permissions'), - ), + relationship(back_populates='account_permissions'), grants_via={None: {'owner'}}, ) #: The permissions as a string of tokens @@ -722,20 +709,14 @@ class AuthClientTeamPermissions(BaseMixin, Model): team_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('team.id'), nullable=False ) - team: Mapped[Team] = relationship( - Team, - foreign_keys=[team_id], - backref=backref('client_permissions'), - ) + team: Mapped[Team] = relationship(back_populates='client_permissions') #: AuthClient app they are assigned on auth_client_id: Mapped[int] = sa_orm.mapped_column( sa.Integer, sa.ForeignKey('auth_client.id'), nullable=False, index=True ) auth_client: Mapped[AuthClient] = with_roles( relationship( - AuthClient, - foreign_keys=[auth_client_id], - backref=backref('team_permissions'), + back_populates='team_permissions', ), grants_via={None: {'owner'}}, ) diff --git a/funnel/models/login_session.py b/funnel/models/login_session.py index 006c43a74..6e722e71c 100644 --- a/funnel/models/login_session.py +++ b/funnel/models/login_session.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from typing import TYPE_CHECKING from coaster.utils import utcnow @@ -128,6 +129,16 @@ class LoginSession(UuidMixin, BaseMixin, Model): sa.TIMESTAMP(timezone=True), nullable=False, default=sa.func.utcnow() ) + # --- Backrefs + auth_clients: DynamicMapped[AuthClient] = relationship( + lazy='dynamic', + secondary=auth_client_login_session, + back_populates='login_sessions', + ) + authtokens: DynamicMapped[AuthToken] = relationship( + lazy='dynamic', back_populates='login_session' + ) + def __repr__(self) -> str: """Represent :class:`UserSession` as a string.""" return f'' @@ -201,3 +212,8 @@ class __Account: order_by=LoginSession.accessed_at.desc(), viewonly=True, ) + + +# Tail imports +if TYPE_CHECKING: + from .auth_client import AuthClient, AuthToken