diff --git a/src/api/_util/role.py b/src/api/_util/role.py index 5cb4c0206..9aa6000de 100644 --- a/src/api/_util/role.py +++ b/src/api/_util/role.py @@ -27,7 +27,7 @@ async def clone_user_role_assignment( branch_id=target.id, role_id=assignment.role_id, user_id=assignment.user_id, - env_type=assignment.env_type, + env_types=assignment.env_types, ) ) await session.commit() diff --git a/src/api/access_right_utils.py b/src/api/access_right_utils.py index 664b90862..d33171592 100644 --- a/src/api/access_right_utils.py +++ b/src/api/access_right_utils.py @@ -1,5 +1,7 @@ from uuid import UUID +from sqlalchemy import String, cast +from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select @@ -51,7 +53,8 @@ async def get_user_rights(session: AsyncSession, user_id: UUID, context: Permiss if context.branch_id is not None: stmt = stmt.where(RoleUserLink.branch_id == context.branch_id) if context.env_type is not None: - stmt = stmt.where(RoleUserLink.env_type == context.env_type) + env_types = cast(RoleUserLink.env_types, ARRAY(String)) + stmt = stmt.where(env_types.contains([context.env_type])) result = await session.execute(stmt) return list(result.scalars().all()) diff --git a/src/api/organization/role.py b/src/api/organization/role.py index c6f05cf9a..8d39cbec2 100644 --- a/src/api/organization/role.py +++ b/src/api/organization/role.py @@ -186,7 +186,7 @@ async def list_role_assignments( branch_id=link.branch_id, role_id=link.role_id, user_id=link.user_id, - env_type=link.env_type, + env_types=link.env_types, ) for link in result.scalars().all() ] @@ -286,10 +286,26 @@ async def assign_role( session.add(link) created_links.append(link) - for env_type in payload.env_types: - link = RoleUserLink(organization_id=organization.id, role_id=role.id, user_id=user_id, env_type=env_type) - session.add(link) - created_links.append(link) + if payload.env_types: + stmt = select(RoleUserLink).where( + RoleUserLink.organization_id == organization.id, + RoleUserLink.role_id == role.id, + RoleUserLink.user_id == user_id, + ) + result = await session.execute(stmt) + env_link = result.scalar_one_or_none() # type: RoleUserLink | None + if env_link is None: + env_link = RoleUserLink( + organization_id=organization.id, + role_id=role.id, + user_id=user_id, + env_types=payload.env_types, + ) + session.add(env_link) + else: + existing = env_link.env_types or [] + env_link.env_types = list(dict.fromkeys(existing + payload.env_types)) + created_links.append(env_link) for branch_id in payload.branch_ids: link = RoleUserLink(organization_id=organization.id, role_id=role.id, user_id=user_id, branch_id=branch_id) @@ -309,7 +325,7 @@ async def assign_role( branch_id=link.branch_id, role_id=link.role_id, user_id=link.user_id, - env_type=link.env_type, + env_types=link.env_types, ) for link in created_links ] diff --git a/src/api/user.py b/src/api/user.py index 5d7c05a66..786bd6b96 100644 --- a/src/api/user.py +++ b/src/api/user.py @@ -118,7 +118,7 @@ async def list_user_roles( branch_id=row.branch_id, role_id=row.role_id, user_id=row.user_id, - env_type=row.env_type, + env_types=row.env_types, ) for row in result.scalars().all() ] @@ -135,7 +135,7 @@ async def list_user_permissions( RoleUserLink.organization_id, RoleUserLink.project_id, RoleUserLink.branch_id, - RoleUserLink.env_type, + RoleUserLink.env_types, ) .select_from(RoleUserLink) .join(Role, Role.id == RoleUserLink.role_id) @@ -152,7 +152,7 @@ async def list_user_permissions( result = await session.execute(stmt) def is_organization_level_permission(row): - return row.project_id is None and row.branch_id is None and row.env_type is None + return row.project_id is None and row.branch_id is None and not row.env_types return [ UserPermissionPublic( @@ -160,7 +160,7 @@ def is_organization_level_permission(row): organization_id=row.organization_id if is_organization_level_permission(row) else None, project_id=row.project_id, branch_id=row.branch_id, - env_type=row.env_type, + env_types=row.env_types, ) for row in result.all() ] diff --git a/src/models/migrations/versions/283e171eb906_roleuserlink_env.py b/src/models/migrations/versions/283e171eb906_roleuserlink_env.py new file mode 100644 index 000000000..e7bbac4d5 --- /dev/null +++ b/src/models/migrations/versions/283e171eb906_roleuserlink_env.py @@ -0,0 +1,40 @@ +"""roleuserlink_env + +Revision ID: 283e171eb906 +Revises: c9dc672278b5 +Create Date: 2026-01-07 20:25:55.914908 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +import sqlmodel.sql +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '283e171eb906' +down_revision: Union[str, Sequence[str], None] = 'c9dc672278b5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('roleuserlink', sa.Column('env_types', postgresql.ARRAY(sa.String()), nullable=True)) + op.execute("UPDATE roleuserlink SET env_types = ARRAY[env_type] WHERE env_type IS NOT NULL") + op.execute("UPDATE roleuserlink SET env_types = '{}' WHERE env_types IS NULL") + op.alter_column('roleuserlink', 'env_types', nullable=False) + op.drop_column('roleuserlink', 'env_type') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('roleuserlink', sa.Column('env_type', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.execute("UPDATE roleuserlink SET env_type = env_types[1] WHERE env_types IS NOT NULL") + op.drop_column('roleuserlink', 'env_types') + # ### end Alembic commands ### diff --git a/src/models/role.py b/src/models/role.py index 213d084be..0ed008f89 100644 --- a/src/models/role.py +++ b/src/models/role.py @@ -3,6 +3,8 @@ from uuid import UUID from pydantic import BaseModel +from sqlalchemy import Column, String +from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.ext.asyncio import AsyncAttrs from sqlmodel import Field, Relationship, SQLModel @@ -102,7 +104,7 @@ class RoleUserLink(AsyncAttrs, SQLModel, table=True): organization_id: Identifier = Model.foreign_key_field("organization", nullable=False, primary_key=True) role_id: Identifier = Model.foreign_key_field("role", nullable=False, primary_key=True) user_id: UUID = Field(foreign_key="user.id", primary_key=True) - env_type: str | None + env_types: list[str] = Field(default_factory=list, sa_column=Column(ARRAY(String), nullable=False)) project_id: Identifier | None = Model.foreign_key_field("project", nullable=True) branch_id: Identifier | None = Model.foreign_key_field("branch", nullable=True) @@ -169,7 +171,7 @@ class RoleUserLinkPublic(BaseModel): branch_id: Identifier | None role_id: Identifier user_id: UUID - env_type: str | None + env_types: list[str] class RoleAssignmentPublic(BaseModel): @@ -211,4 +213,4 @@ class UserPermissionPublic(BaseModel): organization_id: Identifier | None project_id: Identifier | None branch_id: Identifier | None - env_type: str | None + env_types: list[str]