diff --git a/tildes/alembic/versions/054aaef690cd_move_user_permissions_to_their_own_table.py b/tildes/alembic/versions/054aaef690cd_move_user_permissions_to_their_own_table.py new file mode 100644 index 0000000..077db26 --- /dev/null +++ b/tildes/alembic/versions/054aaef690cd_move_user_permissions_to_their_own_table.py @@ -0,0 +1,113 @@ +"""Move user permissions to their own table + +Revision ID: 054aaef690cd +Revises: 51a1012f4f63 +Create Date: 2020-02-28 00:13:17.634015 + +""" +from collections import defaultdict + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from tildes.models.user import User + + +# revision identifiers, used by Alembic. +revision = "054aaef690cd" +down_revision = "51a1012f4f63" +branch_labels = None +depends_on = None + +# minimal definition for users table to query/update +users_table = sa.sql.table( + "users", + sa.sql.column("user_id", sa.Integer), + sa.sql.column("permissions", sa.dialects.postgresql.JSONB(none_as_null=True)), +) + + +def upgrade(): + permissions_table = op.create_table( + "user_permissions", + sa.Column("permission_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("group_id", sa.Integer(), nullable=True), + sa.Column("permission", sa.Text(), nullable=False), + sa.Column( + "permission_type", + postgresql.ENUM("ALLOW", "DENY", name="userpermissiontype"), + server_default="ALLOW", + nullable=False, + ), + sa.ForeignKeyConstraint( + ["group_id"], + ["groups.group_id"], + name=op.f("fk_user_permissions_group_id_groups"), + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.user_id"], + name=op.f("fk_user_permissions_user_id_users"), + ), + sa.PrimaryKeyConstraint("permission_id", name=op.f("pk_user_permissions")), + ) + + # convert existing permissions to rows in the new table + session = sa.orm.Session(bind=op.get_bind()) + + users = session.query(users_table).filter("permissions" != None).all() + + permission_rows = [] + for user in users: + if isinstance(user.permissions, str): + permission_rows.append( + {"user_id": user.user_id, "permission": user.permissions} + ) + elif isinstance(user.permissions, list): + for permission in user.permissions: + permission_rows.append( + {"user_id": user.user_id, "permission": permission} + ) + + if permission_rows: + op.bulk_insert(permissions_table, permission_rows) + + op.drop_column("users", "permissions") + + +def downgrade(): + op.add_column( + "users", + sa.Column( + "permissions", + postgresql.JSONB(astext_type=sa.Text()), + autoincrement=False, + nullable=True, + ), + ) + + # convert user_permissions rows back to JSONB columns in the users table + session = sa.orm.Session(bind=op.get_bind()) + + permissions_table = sa.sql.table( + "user_permissions", + sa.sql.column("user_id", sa.Integer), + sa.sql.column("permission", sa.Text), + ) + permissions_rows = session.query(permissions_table).all() + + permissions_updates = defaultdict(list) + for permission in permissions_rows: + permissions_updates[permission.user_id].append(permission.permission) + + for user_id, permissions in permissions_updates.items(): + session.query(users_table).filter_by(user_id=user_id).update( + {"permissions": permissions}, synchronize_session=False + ) + + session.commit() + + op.drop_table("user_permissions") + op.execute("drop type userpermissiontype") diff --git a/tildes/tildes/auth.py b/tildes/tildes/auth.py index e5d4766..b94929c 100644 --- a/tildes/tildes/auth.py +++ b/tildes/tildes/auth.py @@ -11,6 +11,7 @@ from pyramid.config import Configurator from pyramid.httpexceptions import HTTPFound from pyramid.request import Request from pyramid.security import Allow, Everyone +from sqlalchemy.orm import joinedload from tildes.models.user import User @@ -37,7 +38,11 @@ def get_authenticated_user(request: Request) -> Optional[User]: if not user_id: return None - query = request.query(User).filter_by(user_id=user_id) + query = ( + request.query(User) + .options(joinedload("permissions")) + .filter_by(user_id=user_id) + ) return query.one_or_none() diff --git a/tildes/tildes/enums.py b/tildes/tildes/enums.py index 29f39b2..a12bd4f 100644 --- a/tildes/tildes/enums.py +++ b/tildes/tildes/enums.py @@ -281,3 +281,10 @@ class HTMLSanitizationContext(enum.Enum): """Enum for the possible contexts for HTML sanitization.""" USER_BIO = enum.auto() + + +class UserPermissionType(enum.Enum): + """Enum for the types of user permissions.""" + + ALLOW = enum.auto() + DENY = enum.auto() diff --git a/tildes/tildes/models/user/__init__.py b/tildes/tildes/models/user/__init__.py index 8ab4788..4c4fd93 100644 --- a/tildes/tildes/models/user/__init__.py +++ b/tildes/tildes/models/user/__init__.py @@ -3,4 +3,5 @@ from .user import User from .user_group_settings import UserGroupSettings from .user_invite_code import UserInviteCode +from .user_permissions import UserPermissions from .user_rate_limit import UserRateLimit diff --git a/tildes/tildes/models/user/user.py b/tildes/tildes/models/user/user.py index c39541b..ea3a2d5 100644 --- a/tildes/tildes/models/user/user.py +++ b/tildes/tildes/models/user/user.py @@ -25,7 +25,7 @@ from sqlalchemy import ( Text, TIMESTAMP, ) -from sqlalchemy.dialects.postgresql import ARRAY, ENUM, JSONB +from sqlalchemy.dialects.postgresql import ARRAY, ENUM from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import deferred from sqlalchemy.sql.expression import text @@ -126,7 +126,6 @@ class User(DatabaseModel): deleted_time: Optional[datetime] = Column(TIMESTAMP(timezone=True)) is_banned: bool = Column(Boolean, nullable=False, server_default="false") banned_time: Optional[datetime] = Column(TIMESTAMP(timezone=True)) - permissions: Any = Column(JSONB(none_as_null=True)) home_default_order: Optional[TopicSortOption] = Column(ENUM(TopicSortOption)) home_default_period: Optional[str] = Column(Text) filtered_topic_tags: List[str] = Column( @@ -307,17 +306,7 @@ class User(DatabaseModel): @property def auth_principals(self) -> List[str]: """Return the user's authorization principals (used for permissions).""" - principals = [] - - # start with any principals manually defined in the permissions column - if not self.permissions: - pass - elif isinstance(self.permissions, str): - principals = [self.permissions] - elif isinstance(self.permissions, list): - principals = self.permissions - else: - raise ValueError("Unknown permissions format") + principals = [permission.auth_principal for permission in self.permissions] # give the user the "comment.label" permission if they're over a week old if self.age > timedelta(days=7): diff --git a/tildes/tildes/models/user/user_permissions.py b/tildes/tildes/models/user/user_permissions.py new file mode 100644 index 0000000..2733b03 --- /dev/null +++ b/tildes/tildes/models/user/user_permissions.py @@ -0,0 +1,44 @@ +# Copyright (c) 2020 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Contains the UserPermissions class.""" + +from sqlalchemy import Column, ForeignKey, Integer, Text +from sqlalchemy.dialects.postgresql import ENUM +from sqlalchemy.orm import relationship + +from tildes.enums import UserPermissionType +from tildes.models import DatabaseModel +from tildes.models.group import Group +from tildes.models.user import User + + +class UserPermissions(DatabaseModel): + """Model for a user's permissions in a group (or all groups).""" + + __tablename__ = "user_permissions" + + permission_id: int = Column(Integer, primary_key=True) + user_id: int = Column(Integer, ForeignKey("users.user_id"), nullable=False) + group_id: int = Column(Integer, ForeignKey("groups.group_id"), nullable=True) + permission: str = Column(Text, nullable=False) + permission_type: UserPermissionType = Column( + ENUM(UserPermissionType), nullable=False, server_default="ALLOW" + ) + + user: User = relationship("User", innerjoin=True, backref="permissions") + group: Group = relationship("Group", innerjoin=True) + + @property + def auth_principal(self) -> str: + """Return the permission as a string usable as an auth principal. + + WARNING: This isn't currently complete, and only handles ALLOW for all groups. + """ + if self.permission_type != UserPermissionType.ALLOW: + raise ValueError("Not an ALLOW permission.") + + if self.group_id: + raise ValueError("Not an all-groups permission.") + + return self.permission