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 @@ -
- @for (p of item.permissions; track p; let i = $index) { -
- {{ p.name }} -
- } +
+
+

Permissions

+ @for (p of item.permissions; track p; let i = $index) { +
+ {{ p.name }} +
+ } +
+ +
+

Includes Roles

+ @for (r of item.includedRoles; track r; let i = $index) { +
+ {{ r.name }} +
+ } +
diff --git a/bookie/src/app/roles/role-detail/role-detail.component.ts b/bookie/src/app/roles/role-detail/role-detail.component.ts index 0463a3ae..efa4f2fc 100644 --- a/bookie/src/app/roles/role-detail/role-detail.component.ts +++ b/bookie/src/app/roles/role-detail/role-detail.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; import { FormArray, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -7,6 +7,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; +import { Subject, takeUntil } from 'rxjs'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { Role } from '../role'; @@ -18,13 +19,15 @@ import { RoleService } from '../role.service'; styleUrls: ['./role-detail.component.css'], imports: [MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatInputModule, ReactiveFormsModule], }) -export class RoleDetailComponent implements OnInit, AfterViewInit { +export class RoleDetailComponent implements OnInit, AfterViewInit, OnDestroy { private route = inject(ActivatedRoute); private router = inject(Router); private snackBar = inject(MatSnackBar); private dialog = inject(MatDialog); private ser = inject(RoleService); + private destroyed$ = new Subject(); + @ViewChild('nameElement', { static: true }) nameElement?: ElementRef; form: FormGroup<{ name: FormControl; @@ -33,6 +36,11 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { permission: FormControl; }> >; + includedRoles: FormArray< + FormGroup<{ + role: FormControl; + }> + >; }>; item: Role = new Role(); @@ -42,6 +50,7 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { this.form = new FormGroup({ name: new FormControl('', { nonNullable: true }), permissions: new FormArray }>>([]), + includedRoles: new FormArray }>>([]), }); } @@ -51,7 +60,7 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { this.item = data.item; this.form.controls.name.setValue(this.item.name); - this.form.controls.permissions.reset(); + this.form.controls.permissions.clear(); this.item.permissions.forEach((x) => this.form.controls.permissions.push( new FormGroup({ @@ -59,6 +68,26 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { }), ), ); + + this.form.controls.includedRoles.clear(); + this.item.includedRoles.forEach((x) => + this.form.controls.includedRoles.push( + new FormGroup({ + role: new FormControl(x.enabled, { nonNullable: true }), + }), + ), + ); + + // Rebind listeners (important when route data changes) + this.destroyed$.next(); + + // When included roles change, recompute locks + this.form.controls.includedRoles.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(() => { + this.applyIncludedRolePermissionLocks(); + }); + + // Apply initial locks immediately + this.applyIncludedRolePermissionLocks(); }); } @@ -70,6 +99,43 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { }, 0); } + ngOnDestroy(): void { + this.destroyed$.next(); + this.destroyed$.complete(); + } + + private applyIncludedRolePermissionLocks(): void { + // 1) collect all permission IDs implied by enabled included roles + const implied = new Set(); + + this.item.includedRoles.forEach((roleOpt, idx) => { + const checked = this.form.controls.includedRoles.at(idx).controls.role.value; + if (checked) { + (roleOpt.permissionIds || []).forEach((pid) => implied.add(pid)); + } + }); + + // 2) apply to permission checkboxes + this.item.permissions.forEach((perm, idx) => { + const ctrl = this.form.controls.permissions.at(idx).controls.permission; + + const isImplied = implied.has(perm.id as string); + const isDirect = perm.enabled === true; // from backend: direct assignment + + if (isImplied) { + // must be checked + disabled + ctrl.setValue(true, { emitEvent: false }); + ctrl.disable({ emitEvent: false }); + } else { + // not implied -> enable checkbox + ctrl.enable({ emitEvent: false }); + + // restore to direct-enabled state (so user sees what is explicitly on the role) + ctrl.setValue(isDirect, { emitEvent: false }); + } + }); + } + save() { this.ser.saveOrUpdate(this.getItem()).subscribe({ next: () => { @@ -110,9 +176,21 @@ export class RoleDetailComponent implements OnInit, AfterViewInit { getItem(): Role { const formModel = this.form.value; this.item.name = formModel.name ?? ''; - const array = this.form.controls.permissions; - this.item.permissions.forEach((item, index) => { - item.enabled = array.controls[index].value.permission ?? false; + const permArray = this.form.controls.permissions; + this.item.permissions.forEach((p, index) => { + // If disabled, keep p.enabled as it was originally direct (don’t accidentally mark direct) + if (permArray.at(index).controls.permission.disabled) { + // leave p.enabled unchanged + return; + } + p.enabled = permArray.at(index).controls.permission.value; + }); + // this.item.permissions.forEach((item, index) => { + // item.enabled = permArray.controls[index].value.permission ?? false; + // }); + const includeArray = this.form.controls.includedRoles; + this.item.includedRoles.forEach((r, index) => { + r.enabled = includeArray.controls[index]?.value.role ?? false; }); return this.item; } diff --git a/bookie/src/app/roles/role-list/role-list.component.html b/bookie/src/app/roles/role-list/role-list.component.html index 08e4f9c9..b5ca6f31 100644 --- a/bookie/src/app/roles/role-list/role-list.component.html +++ b/bookie/src/app/roles/role-list/role-list.component.html @@ -14,6 +14,22 @@ > + + + Includes + + @if (row.includedRoles?.length) { +
    + @for (r of row.includedRoles; track r) { +
  • {{ r }}
  • + } +
+ } @else { + - + } +
+
+ Permissions diff --git a/bookie/src/app/roles/role-list/role-list.component.ts b/bookie/src/app/roles/role-list/role-list.component.ts index 8867819c..541342d5 100644 --- a/bookie/src/app/roles/role-list/role-list.component.ts +++ b/bookie/src/app/roles/role-list/role-list.component.ts @@ -19,7 +19,7 @@ export class RoleListComponent implements OnInit { list: Role[] = []; dataSource: RoleListDataSource = new RoleListDataSource(this.list); /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ - displayedColumns = ['name', 'permissions']; + displayedColumns = ['name', 'includedRoles', 'permissions']; ngOnInit() { this.route.data.subscribe((value) => { diff --git a/bookie/src/app/roles/role.ts b/bookie/src/app/roles/role.ts index 812207d2..0282091b 100644 --- a/bookie/src/app/roles/role.ts +++ b/bookie/src/app/roles/role.ts @@ -1,14 +1,32 @@ import { Permission } from './permission'; +export class RoleItem { + id: string | undefined; + name: string; + enabled: boolean; + + permissionIds: string[]; + + public constructor(init?: Partial) { + this.id = undefined; + this.name = ''; + this.enabled = false; + this.permissionIds = []; + + Object.assign(this, init); + } +} export class Role { id: string | undefined; name: string; permissions: Permission[]; + includedRoles: RoleItem[]; public constructor(init?: Partial) { this.id = undefined; this.name = ''; this.permissions = []; + this.includedRoles = []; Object.assign(this, init); } }