from __future__ import annotations from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse from app.core.http_responses import not_found_response from sqlalchemy import select from app.core.db.common import CommonSessionLocal from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.security.session_auth import get_current_user from app.core.templating import templates from app.modules.core.audit.service import write_audit_log from app.modules.core.iam.scope import build_scope, assert_can_manage_role_object from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.core.rbac.models import Permission, Role, RolePermission from app.modules.core.rbac.services import build_permissions_payload, build_roles_payload router = APIRouter(prefix="/system-settings/rbac", tags=["rbac-ui"]) def _csrf_rejected(request: Request): from app.core.http_responses import forbidden_response return forbidden_response(request, "CSRF validation failed") def _redirect_login(): return RedirectResponse(url="/login", status_code=303) def _redirect_denied(): from app.core.http_responses import ui_access_denied return ui_access_denied() def _is_system_admin(db, current_user) -> bool: return "System Admin" in get_user_roles(db, current_user.id) def _base_ctx(request: Request, current_user, db, **ctx): base = { "request": request, "current_user": current_user, "current_user_roles": get_user_roles(db, current_user.id), "current_user_permissions": get_user_permissions(db, current_user.id), "csrf_token": get_or_create_csrf_token(request), } base.update(ctx) return base @router.get("") def rbac_dashboard(request: Request): return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) # Phase 3 security hardening: the roles list endpoint must not accept direct unsafe POSTs. # Existing role creation continues to use /roles/new. @router.post("/roles") def roles_list_post_rejected(request: Request): return _csrf_rejected(request) @router.get("/roles") def roles_list(request: Request, q: str = "", page: int = 1, per_page: int = 10): db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_system_admin(db, current_user): return _redirect_denied() scope = build_scope(db, current_user) payload = build_roles_payload(db, scope, q=q, page=page, per_page=per_page) return templates.TemplateResponse( "modules/core/rbac/templates/roles_list.html", _base_ctx(request, current_user, db, title="RBAC Roles", **payload), ) finally: db.close() @router.post("/roles/new") def role_create_submit( request: Request, name: str = Form(...), is_active: str | None = Form(None), csrf_token: str = Form(...), ): try: validate_csrf(request, csrf_token) except PermissionError: return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_system_admin(db, current_user): return _redirect_denied() name = name.strip() if name and not db.execute(select(Role).where(Role.name == name)).scalar_one_or_none(): role = Role(name=name, is_active=is_active is not None) db.add(role) db.commit() db.refresh(role) write_audit_log( db, action="role.create", entity_type="role", actor=current_user, request=request, entity_id=role.id, entity_name=role.name, target_tenant_id=current_user.tenant_id, target_branch_id=current_user.branch_id, details={"name": role.name, "is_active": role.is_active}, ) return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) finally: db.close() @router.get("/roles/{role_id}") def role_permissions_edit(request: Request, role_id: int): db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_system_admin(db, current_user): return _redirect_denied() role = db.execute(select(Role).where(Role.id == role_id)).scalar_one_or_none() if not role: return not_found_response(request, "Role not found") scope = build_scope(db, current_user) try: assert_can_manage_role_object(scope, role) except Exception: return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) permissions = db.execute(select(Permission).order_by(Permission.code)).scalars().all() assigned_permission_ids = db.execute( select(RolePermission.permission_id).where(RolePermission.role_id == role_id) ).scalars().all() return templates.TemplateResponse( "modules/core/rbac/templates/role_permissions_form.html", _base_ctx( request, current_user, db, title=f"Role Permissions - {role.name}", role=role, permissions=permissions, assigned_permission_ids=list(assigned_permission_ids), ), ) finally: db.close() @router.post("/roles/{role_id}") def role_permissions_submit( request: Request, role_id: int, permission_ids: list[int] = Form([]), csrf_token: str = Form(...), ): try: validate_csrf(request, csrf_token) except PermissionError: return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_system_admin(db, current_user): return _redirect_denied() role = db.execute(select(Role).where(Role.id == role_id)).scalar_one_or_none() if not role: return not_found_response(request, "Role not found") old_permission_ids = db.execute( select(RolePermission.permission_id).where(RolePermission.role_id == role.id) ).scalars().all() db.execute(RolePermission.__table__.delete().where(RolePermission.role_id == role.id)) for permission_id in permission_ids: db.add(RolePermission(role_id=role.id, permission_id=permission_id)) db.commit() write_audit_log( db, action="role.permissions.update", entity_type="role", actor=current_user, request=request, entity_id=role.id, entity_name=role.name, target_tenant_id=current_user.tenant_id, target_branch_id=current_user.branch_id, details={ "old_permission_ids": list(old_permission_ids), "new_permission_ids": list(permission_ids), }, ) return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) finally: db.close() @router.get("/permissions") def permissions_list(request: Request, q: str = "", page: int = 1, per_page: int = 15): db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_system_admin(db, current_user): return _redirect_denied() payload = build_permissions_payload(db, q=q, page=page, per_page=per_page) return templates.TemplateResponse( "modules/core/rbac/templates/permissions_list.html", _base_ctx(request, current_user, db, title="RBAC Permissions", **payload), ) finally: db.close() @router.post("/permissions/new") def permission_create_submit( request: Request, code: str = Form(...), name: str = Form(...), is_active: str | None = Form(None), csrf_token: str = Form(...), ): try: validate_csrf(request, csrf_token) except PermissionError: return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) if not current_user: return _redirect_login() if not _is_system_admin(db, current_user): return _redirect_denied() code = code.strip() if code and not db.execute(select(Permission).where(Permission.code == code)).scalar_one_or_none(): permission = Permission(code=code, name=name.strip(), is_active=is_active is not None) db.add(permission) db.commit() db.refresh(permission) write_audit_log( db, action="permission.create", entity_type="permission", actor=current_user, request=request, entity_id=permission.id, entity_name=permission.code, target_tenant_id=current_user.tenant_id, target_branch_id=current_user.branch_id, details={"code": permission.code, "name": permission.name, "is_active": permission.is_active}, ) return RedirectResponse(url="/system-settings/rbac/permissions", status_code=303) finally: db.close()