diff --git a/barker/alembic/versions/5cb65066be86_inculded_roles.py b/barker/alembic/versions/5cb65066be86_inculded_roles.py new file mode 100644 index 00000000..1c9cf66b --- /dev/null +++ b/barker/alembic/versions/5cb65066be86_inculded_roles.py @@ -0,0 +1,40 @@ +"""inculded roles + +Revision ID: 5cb65066be86 +Revises: 367ecf7b898f +Create Date: 2026-02-11 08:21:01.679893 + +""" + +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "5cb65066be86" +down_revision = "367ecf7b898f" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "role_includes", + sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("role_id", sa.Uuid(), nullable=False), + sa.Column("included_role_id", sa.Uuid(), nullable=False), + sa.CheckConstraint("role_id <> included_role_id", name=op.f("ck_role_includes_no_self_include")), + sa.ForeignKeyConstraint( + ["included_role_id"], ["roles.id"], name=op.f("fk_role_includes_included_role_id_roles") + ), + sa.ForeignKeyConstraint(["role_id"], ["roles.id"], name=op.f("fk_role_includes_role_id_roles")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_role_includes")), + sa.UniqueConstraint("role_id", "included_role_id", name=op.f("uq_role_includes_role_id")), + sa.Index(op.f("ix_role_includes_role_id"), "role_id"), + sa.Index(op.f("ix_role_includes_included_role_id"), "included_role_id"), + ) + + +def downgrade(): + op.drop_table("role_includes") diff --git a/barker/barker/models/role.py b/barker/barker/models/role.py index ffafb028..19f10b2b 100644 --- a/barker/barker/models/role.py +++ b/barker/barker/models/role.py @@ -8,6 +8,7 @@ from sqlalchemy import Text, Uuid, text from sqlalchemy.orm import Mapped, mapped_column, relationship from ..db.base_class import reg +from .role_includes import RoleInclude from .role_permission import RolePermission @@ -28,6 +29,23 @@ class Role: back_populates="roles", ) + included_roles: Mapped[list[Role]] = relationship( + "Role", + secondary=RoleInclude.__table__, # type: ignore[attr-defined] + primaryjoin=(id == RoleInclude.role_id), + secondaryjoin=(id == RoleInclude.included_role_id), + back_populates="included_by_roles", + ) + + # "included_by_roles" = roles that include THIS role (parents) + included_by_roles: Mapped[list[Role]] = relationship( + "Role", + secondary=RoleInclude.__table__, # type: ignore[attr-defined] + primaryjoin=(id == RoleInclude.included_role_id), + secondaryjoin=(id == RoleInclude.role_id), + back_populates="included_roles", + ) + def __init__(self, name: str, id_: uuid.UUID | None = None): self.name = name if id_ is not None: diff --git a/barker/barker/models/role_includes.py b/barker/barker/models/role_includes.py new file mode 100644 index 00000000..4c11ca94 --- /dev/null +++ b/barker/barker/models/role_includes.py @@ -0,0 +1,28 @@ +import uuid + +from sqlalchemy import CheckConstraint, ForeignKey, UniqueConstraint, Uuid, text +from sqlalchemy.orm import Mapped, mapped_column + +from ..db.base_class import reg + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class RoleInclude: + """ + A "role includes role" edge: + - role_id (parent/composite role) + - included_role_id (child role that is included) + + This lets Role A inherit all permissions of Role B (and B's included roles, recursively). + """ + + __tablename__ = "role_includes" + __table_args__ = ( + UniqueConstraint("role_id", "included_role_id"), + CheckConstraint("role_id <> included_role_id", name="no_self_include"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, server_default=text("gen_random_uuid()")) + + role_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("roles.id"), nullable=False) + included_role_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("roles.id"), nullable=False) diff --git a/barker/barker/routers/login.py b/barker/barker/routers/login.py index be10db77..4835ea7f 100644 --- a/barker/barker/routers/login.py +++ b/barker/barker/routers/login.py @@ -13,6 +13,8 @@ from fastapi import ( ) from fastapi.responses import JSONResponse from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy import select +from sqlalchemy.orm import Session from .. import __version__ from ..core.config import settings @@ -25,12 +27,36 @@ from ..core.security import ( ) from ..db.session import SessionFuture from ..models.login_history import LoginHistory +from ..models.permission import Permission +from ..models.role_includes import RoleInclude +from ..models.role_permission import RolePermission +from ..models.user_role import UserRole from ..schemas.user_token import UserToken router = APIRouter() +def user_effective_permission_scopes(user_id: uuid.UUID, db: Session) -> list[str]: + """ + Returns the effective permission scope strings for a user, considering: + user_roles -> role_includes (recursive) -> role_permissions -> permissions + """ + # Start roles = user's direct roles + roles = select(UserRole.role_id.label("role_id")).where(UserRole.user_id == user_id).cte("roles", recursive=True) + roles = roles.union_all(select(RoleInclude.included_role_id).where(RoleInclude.role_id == roles.c.role_id)) + + q = ( + select(Permission.name) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(roles, roles.c.role_id == RolePermission.role_id) + .distinct() + ) + names = [r for r in db.execute(q).scalars().all()] + # match your existing scope normalization + return sorted(set([n.replace(" ", "-").lower() for n in names])) + + @router.post("/token", response_model=Token) def login_for_access_token( response: Response, @@ -65,11 +91,11 @@ def login_for_access_token( not_allowed_response.set_cookie(key="section", value=device.section.name, max_age=10 * 365 * 24 * 60 * 60) return not_allowed_response access_token_expires = timedelta(minutes=settings.JWT_TOKEN_EXPIRE_MINUTES) + perm_scopes = user_effective_permission_scopes(user.id, db) access_token = create_access_token( data={ "sub": user.name, - "scopes": ["authenticated"] - + list(set([p.name.replace(" ", "-").lower() for r in user.roles for p in r.permissions])), + "scopes": ["authenticated"] + perm_scopes, "userId": str(user.id), "lockedOut": user.locked_out, "ver": __version__.__version__, diff --git a/barker/barker/routers/role.py b/barker/barker/routers/role.py index 4a7203ed..b00b4799 100644 --- a/barker/barker/routers/role.py +++ b/barker/barker/routers/role.py @@ -1,7 +1,7 @@ import uuid from fastapi import APIRouter, HTTPException, Security, status -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from sqlalchemy.sql.functions import count @@ -10,6 +10,7 @@ from ..core.security import get_current_active_user as get_user from ..db.session import SessionFuture from ..models.permission import Permission from ..models.role import Role +from ..models.role_includes import RoleInclude from ..models.role_permission import RolePermission from ..models.user_role import UserRole from ..schemas import role as schemas @@ -30,6 +31,7 @@ def save( item = Role(data.name) db.add(item) add_permissions(item, data.permissions, db) + add_included_roles(item, data.included_roles, db) db.commit() return role_info(item, db) except SQLAlchemyError as e: @@ -50,6 +52,7 @@ def update_route( item: Role = db.execute(select(Role).where(Role.id == id_)).scalar_one() item.name = data.name add_permissions(item, data.permissions, db) + add_included_roles(item, data.included_roles, db) db.commit() return role_info(item, db) except SQLAlchemyError as e: @@ -75,6 +78,18 @@ def delete_route( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="This Role has permissions and cannot be deleted.", ) + if ( + db.execute( + select(count(RoleInclude.id)).where( + or_(RoleInclude.role_id == id_, RoleInclude.included_role_id == id_) + ) + ).scalar_one() + > 0 + ): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="This Role is used in role composition (includes/being included) and cannot be deleted.", + ) item: Role = db.execute(select(Role).where(Role.id == id_)).scalar_one() db.delete(item) db.commit() @@ -99,6 +114,7 @@ def show_list( id_=item.id, name=item.name, permissions=[p.name for p in sorted(item.permissions, key=lambda p: p.name)], + included_roles=[r.name for r in sorted(item.included_roles, key=lambda r: r.name)], ) for item in db.execute(select(Role).order_by(Role.name)).scalars().all() ] @@ -115,6 +131,8 @@ def show_id( def role_info(item: Role, db: Session) -> schemas.Role: + all_roles = db.execute(select(Role).order_by(Role.name)).scalars().all() + all_perms = db.execute(select(Permission).order_by(Permission.name)).scalars().all() return schemas.Role( id_=item.id, name=item.name, @@ -124,17 +142,36 @@ def role_info(item: Role, db: Session) -> schemas.Role: name=p.name, enabled=p in item.permissions, ) - for p in db.execute(select(Permission).order_by(Permission.name)).scalars().all() + for p in all_perms + ], + included_roles=[ + schemas.RoleItem( + id_=r.id, + name=r.name, + enabled=r in item.included_roles, + permission_ids=role_effective_permission_ids(r.id, db), + ) + for r in all_roles + if r.id != item.id ], ) def role_blank(db: Session) -> schemas.RoleBlank: + all_roles = db.execute(select(Role).order_by(Role.name)).scalars().all() + all_perms = db.execute(select(Permission).order_by(Permission.name)).scalars().all() + return schemas.RoleBlank( name="", - permissions=[ - PermissionItem(id_=p.id, name=p.name, enabled=False) - for p in db.execute(select(Permission).order_by(Permission.name)).scalars().all() + permissions=[PermissionItem(id_=p.id, name=p.name, enabled=False) for p in all_perms], + included_roles=[ + schemas.RoleItem( + id_=r.id, + name=r.name, + enabled=False, + permission_ids=role_effective_permission_ids(r.id, db), + ) + for r in all_roles ], ) @@ -146,3 +183,78 @@ def add_permissions(role: Role, permissions: list[PermissionItem], db: Session) role.permissions.append(db.execute(select(Permission).where(Permission.id == permission.id_)).scalar_one()) elif not permission.enabled and gp: role.permissions.remove(gp) + + +def add_included_roles(role: Role, included_roles: list[schemas.RoleItem], db: Session) -> None: + for inc in included_roles: + if inc.id_ == role.id: + raise HTTPException(status_code=400, detail="A role cannot include itself.") + + existing = next((r for r in role.included_roles if r.id == inc.id_), None) + + if inc.enabled and existing is None: + # cycle prevention: if role is already reachable from inc.id_, adding would create a cycle + if role_is_reachable_from(start_role_id=inc.id_, target_role_id=role.id, db=db): + raise HTTPException( + status_code=400, + detail="Invalid include: would create a cycle in role hierarchy.", + ) + + role.included_roles.append(db.execute(select(Role).where(Role.id == inc.id_)).scalar_one()) + + elif not inc.enabled and existing is not None: + role.included_roles.remove(existing) + + +def role_is_reachable_from(start_role_id: uuid.UUID, target_role_id: uuid.UUID, db: Session) -> bool: + """ + Returns True if target_role_id is reachable from start_role_id via role_includes edges. + This prevents cycles when adding: role -> included_role. + """ + cte = ( + select(RoleInclude.included_role_id.label("role_id")) + .where(RoleInclude.role_id == start_role_id) + .cte("role_tree", recursive=True) + ) + cte = cte.union_all(select(RoleInclude.included_role_id).where(RoleInclude.role_id == cte.c.role_id)) + + q = select(count()).select_from(cte).where(cte.c.role_id == target_role_id) + return db.execute(q).scalar_one() > 0 + + +def role_effective_permission_names(role_id: uuid.UUID, db: Session) -> list[str]: + """ + All permissions for this role INCLUDING inherited permissions via included roles (recursive). + """ + # role closure CTE + roles_cte = select(Role.id.label("role_id")).where(Role.id == role_id).cte("roles_cte", recursive=True) + roles_cte = roles_cte.union_all( + select(RoleInclude.included_role_id).where(RoleInclude.role_id == roles_cte.c.role_id) + ) + + q = ( + select(Permission.name) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(roles_cte, roles_cte.c.role_id == RolePermission.role_id) + .distinct() + .order_by(Permission.name) + ) + return [r for r in db.execute(q).scalars().all()] + + +def role_effective_permission_ids(role_id: uuid.UUID, db: Session) -> list[uuid.UUID]: + """ + All permission IDs for this role INCLUDING inherited permissions via included roles (recursive). + """ + roles_cte = select(Role.id.label("role_id")).where(Role.id == role_id).cte("roles_cte", recursive=True) + roles_cte = roles_cte.union_all( + select(RoleInclude.included_role_id).where(RoleInclude.role_id == roles_cte.c.role_id) + ) + + q = ( + select(Permission.id) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(roles_cte, roles_cte.c.role_id == RolePermission.role_id) + .distinct() + ) + return [r for r in db.execute(q).scalars().all()] diff --git a/barker/barker/schemas/role.py b/barker/barker/schemas/role.py index caa8ad67..07d4c7d8 100644 --- a/barker/barker/schemas/role.py +++ b/barker/barker/schemas/role.py @@ -8,10 +8,21 @@ from . import to_camel from .permission import PermissionItem +class RoleItem(BaseModel): + id_: uuid.UUID + name: str + enabled: bool + permission_ids: list[uuid.UUID] = [] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) + + class RoleIn(BaseModel): name: Annotated[str, Field(min_length=1)] permissions: list[PermissionItem] - model_config = ConfigDict(str_strip_whitespace=True) + included_roles: list[RoleItem] + + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) class Role(RoleIn): @@ -21,18 +32,12 @@ class Role(RoleIn): class RoleBlank(RoleIn): name: str - model_config = ConfigDict(str_strip_whitespace=True) + model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) class RoleList(BaseModel): id_: uuid.UUID name: str permissions: list[str] + included_roles: list[str] model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, str_strip_whitespace=True) - - -class RoleItem(BaseModel): - id_: uuid.UUID - name: str - enabled: bool - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/bookie/src/app/roles/role-detail/role-detail.component.css b/bookie/src/app/roles/role-detail/role-detail.component.css index e69de29b..e7e1a83f 100644 --- a/bookie/src/app/roles/role-detail/role-detail.component.css +++ b/bookie/src/app/roles/role-detail/role-detail.component.css @@ -0,0 +1,8 @@ +.two-col { + display: flex; + gap: 16px; +} +.col { + flex: 1; + min-width: 0; +} diff --git a/bookie/src/app/roles/role-detail/role-detail.component.html b/bookie/src/app/roles/role-detail/role-detail.component.html index b7ec1b68..fb53e2e4 100644 --- a/bookie/src/app/roles/role-detail/role-detail.component.html +++ b/bookie/src/app/roles/role-detail/role-detail.component.html @@ -7,12 +7,24 @@ -