Phase 1 fix auth redirect API handling and route issues

This commit is contained in:
A R R R Associates
2026-06-25 20:33:15 +05:30
parent f07544cfd7
commit b97af0ea1e
22 changed files with 185 additions and 37 deletions
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
def wants_json(request: Request) -> bool:
path = request.url.path or ""
accept = (request.headers.get("accept") or "").lower()
requested_with = (request.headers.get("x-requested-with") or "").lower()
return path.startswith("/api") or "application/json" in accept or requested_with == "xmlhttprequest"
def ui_access_denied(message: str = "Access denied") -> Response:
return Response(
content=f"403 Forbidden: {message}",
status_code=403,
media_type="text/plain; charset=utf-8",
)
def ui_not_found(message: str = "Not found") -> Response:
return Response(
content=f"404 Not Found: {message}",
status_code=404,
media_type="text/plain; charset=utf-8",
)
def auth_required_response(request: Request):
if wants_json(request):
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return RedirectResponse(url="/login", status_code=303)
def forbidden_response(request: Request, message: str = "Access denied"):
if wants_json(request):
return JSONResponse({"detail": message}, status_code=403)
return ui_access_denied(message)
def not_found_response(request: Request, message: str = "Not found"):
if wants_json(request):
return JSONResponse({"detail": message}, status_code=404)
return ui_not_found(message)
+2 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import Request, Depends from fastapi import Request, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import select from sqlalchemy import select
@@ -48,5 +48,5 @@ def get_current_user(request: Request, db: Session = Depends(get_common_db)) ->
def require_login(user: User | None = Depends(get_current_user)) -> User: def require_login(user: User | None = Depends(get_current_user)) -> User:
if not user: if not user:
raise PermissionError("Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
return user return user
+28 -1
View File
@@ -1,4 +1,5 @@
from fastapi import FastAPI from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app.core.settings import get_settings from app.core.settings import get_settings
@@ -7,9 +8,34 @@ from app.core.middleware.domain_resolver import DomainResolverMiddleware
from app.core.middleware.security_headers import SecurityHeadersMiddleware from app.core.middleware.security_headers import SecurityHeadersMiddleware
from app.core.startup import on_startup from app.core.startup import on_startup
from app.core.api import api_router from app.core.api import api_router
from app.core.http_responses import auth_required_response, forbidden_response, not_found_response, wants_json
from app.ui.app import mount_ui from app.ui.app import mount_ui
def _register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(PermissionError)
async def permission_error_handler(request: Request, exc: PermissionError):
message = str(exc) or "Access denied"
if "csrf" in message.lower():
return JSONResponse({"detail": "CSRF validation failed"}, status_code=403) if wants_json(request) else forbidden_response(request, "CSRF validation failed")
if "not authenticated" in message.lower():
return auth_required_response(request)
return forbidden_response(request, message)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
detail = exc.detail if isinstance(exc.detail, str) else "Error"
if request.url.path.startswith("/api"):
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers)
if exc.status_code == 401:
return auth_required_response(request)
if exc.status_code == 403:
return forbidden_response(request, detail or "Access denied")
if exc.status_code == 404:
return not_found_response(request, detail or "Not found")
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers)
def create_app() -> FastAPI: def create_app() -> FastAPI:
s = get_settings() s = get_settings()
app = FastAPI(title=s.APP_NAME, debug=s.DEBUG) app = FastAPI(title=s.APP_NAME, debug=s.DEBUG)
@@ -30,6 +56,7 @@ def create_app() -> FastAPI:
) )
app.add_event_handler("startup", lambda: on_startup(app)) app.add_event_handler("startup", lambda: on_startup(app))
_register_error_handlers(app)
app.include_router(api_router, prefix="/api") app.include_router(api_router, prefix="/api")
mount_ui(app) mount_ui(app)
+8 -6
View File
@@ -68,7 +68,8 @@ def _render(request: Request, template: str, db, user, **ctx):
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _has_perm(db, user, code: str) -> bool: def _has_perm(db, user, code: str) -> bool:
@@ -195,7 +196,7 @@ def invoice_list(request: Request, q: str = ""):
user, user,
title="Billing - Invoices", title="Billing - Invoices",
q=q, q=q,
active_financial_year=financial_year, active_financial_year=_active_financial_year(request),
rows=rows, rows=rows,
report_summary=report_summary, report_summary=report_summary,
can_create=_has_perm(db, user, "billing.create"), can_create=_has_perm(db, user, "billing.create"),
@@ -378,6 +379,7 @@ def billing_settings_submit(
db.close() db.close()
@router.get("/invoices/new")
@router.get("/new") @router.get("/new")
def invoice_create_page(request: Request): def invoice_create_page(request: Request):
db = CommonSessionLocal() db = CommonSessionLocal()
@@ -397,7 +399,7 @@ def invoice_create_page(request: Request):
db, db,
user, user,
title="Create Invoice", title="Create Invoice",
active_financial_year=financial_year, active_financial_year=_active_financial_year(request),
default_billing_period_from=_period_start_for_fy(_active_financial_year(request)).isoformat(), default_billing_period_from=_period_start_for_fy(_active_financial_year(request)).isoformat(),
default_billing_period_to=_period_end_for_fy(_active_financial_year(request)).isoformat(), default_billing_period_to=_period_end_for_fy(_active_financial_year(request)).isoformat(),
clients=clients, clients=clients,
@@ -512,7 +514,7 @@ def fee_structure_list(request: Request, q: str = ""):
user, user,
title="Fee Structure", title="Fee Structure",
q=q, q=q,
active_financial_year=financial_year, active_financial_year=_active_financial_year(request),
rows=rows, rows=rows,
can_import=_has_perm(db, user, "billing_fee_structure.import"), can_import=_has_perm(db, user, "billing_fee_structure.import"),
) )
@@ -627,7 +629,7 @@ def generate_invoices_page(
billing_period_to=period_to.isoformat(), billing_period_to=period_to.isoformat(),
auto_generate_only=auto_generate_only, auto_generate_only=auto_generate_only,
q=q, q=q,
active_financial_year=financial_year, active_financial_year=_active_financial_year(request),
result=None, result=None,
) )
finally: finally:
@@ -706,7 +708,7 @@ def generate_invoices_submit(
billing_period_to=(period_to or date.today()).isoformat(), billing_period_to=(period_to or date.today()).isoformat(),
auto_generate_only=auto_generate_only, auto_generate_only=auto_generate_only,
q=q, q=q,
active_financial_year=financial_year, active_financial_year=_active_financial_year(request),
result=result, result=result,
) )
except ValueError as exc: except ValueError as exc:
+2 -1
View File
@@ -87,7 +87,8 @@ def _render(request, template, db, user, **ctx):
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _has_perm_factory(db, user): def _has_perm_factory(db, user):
+2 -1
View File
@@ -103,7 +103,8 @@ def _render(request: Request, template_name: str, db, user, **ctx):
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _has_perm(db, user, code: str) -> bool: def _has_perm(db, user, code: str) -> bool:
+2 -1
View File
@@ -61,7 +61,8 @@ def _redirect_login():
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _flash_redirect(url: str) -> RedirectResponse: def _flash_redirect(url: str) -> RedirectResponse:
+2 -1
View File
@@ -22,7 +22,8 @@ def _redirect_login():
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _is_system_admin(db, current_user) -> bool: def _is_system_admin(db, current_user) -> bool:
+48 -5
View File
@@ -1,15 +1,58 @@
from fastapi import APIRouter, Depends from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import select from sqlalchemy import select
from app.core.db.deps import get_common_db from app.core.db.deps import get_common_db
from app.core.security.session_auth import require_login
from app.modules.core.iam.models import User
from app.modules.core.rbac.deps import get_user_roles
from app.modules.core.tenancy.models import Tenant, Branch from app.modules.core.tenancy.models import Tenant, Branch
router = APIRouter(prefix="/tenancy", tags=["tenancy"]) router = APIRouter(prefix="/tenancy", tags=["tenancy"])
def _require_tenancy_api_access(db: Session, user: User) -> None:
roles = set(get_user_roles(db, int(user.id)))
if not roles.intersection({"System Admin", "Firm Admin"}):
raise HTTPException(status_code=403, detail="Tenancy API access denied")
def _tenant_payload(t: Tenant) -> dict:
return {
"id": t.id,
"code": getattr(t, "code", None),
"name": getattr(t, "name", None),
"is_active": getattr(t, "is_active", None),
}
def _branch_payload(b: Branch) -> dict:
return {
"id": b.id,
"tenant_id": getattr(b, "tenant_id", None),
"code": getattr(b, "code", None),
"name": getattr(b, "name", None),
"is_active": getattr(b, "is_active", None),
}
@router.get("/tenants") @router.get("/tenants")
def list_tenants(db: Session = Depends(get_common_db)): def list_tenants(db: Session = Depends(get_common_db), current_user: User = Depends(require_login)):
return db.execute(select(Tenant).order_by(Tenant.id)).scalars().all() _require_tenancy_api_access(db, current_user)
stmt = select(Tenant).order_by(Tenant.id)
roles = set(get_user_roles(db, int(current_user.id)))
if "System Admin" not in roles:
stmt = stmt.where(Tenant.id == current_user.tenant_id)
return [_tenant_payload(t) for t in db.execute(stmt).scalars().all()]
@router.get("/branches") @router.get("/branches")
def list_branches(db: Session = Depends(get_common_db)): def list_branches(db: Session = Depends(get_common_db), current_user: User = Depends(require_login)):
return db.execute(select(Branch).order_by(Branch.id)).scalars().all() _require_tenancy_api_access(db, current_user)
stmt = select(Branch).order_by(Branch.id)
roles = set(get_user_roles(db, int(current_user.id)))
if "System Admin" not in roles:
stmt = stmt.where(Branch.tenant_id == current_user.tenant_id)
return [_branch_payload(b) for b in db.execute(stmt).scalars().all()]
+20 -7
View File
@@ -106,7 +106,8 @@ def _render(request: Request, template: str, db, user, **ctx):
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _require_user(request: Request, db, permission_code: str): def _require_user(request: Request, db, permission_code: str):
@@ -382,10 +383,13 @@ def download_latest_document(request: Request, document_id: int):
return response return response
scope = build_document_scope(request, db, user) scope = build_document_scope(request, db, user)
document = get_document(db, document_id) document = get_document(db, document_id)
if not document:
from app.core.http_responses import not_found_response
return not_found_response(request, "Document not found")
active_fy = _active_financial_year(request) active_fy = _active_financial_year(request)
if active_fy and document and getattr(document, "financial_year", None) != active_fy: if active_fy and document and getattr(document, "financial_year", None) != active_fy:
return RedirectResponse(url=f"/documents?financial_year={active_fy}&error=wrong_year", status_code=303) return RedirectResponse(url=f"/documents?financial_year={active_fy}&error=wrong_year", status_code=303)
if not document or not document.engagement or not user_can_view_engagement(db, user, document.engagement, scope): if not document.engagement or not user_can_view_engagement(db, user, document.engagement, scope):
return _redirect_denied() return _redirect_denied()
version = get_latest_version(document) version = get_latest_version(document)
if not version: if not version:
@@ -404,11 +408,14 @@ def download_document_version(request: Request, version_id: int):
return response return response
scope = build_document_scope(request, db, user) scope = build_document_scope(request, db, user)
version = get_version(db, version_id) version = get_version(db, version_id)
document = version.document if version else None if not version:
from app.core.http_responses import not_found_response
return not_found_response(request, "Document version not found")
document = version.document
active_fy = _active_financial_year(request) active_fy = _active_financial_year(request)
if active_fy and document and getattr(document, "financial_year", None) != active_fy: if active_fy and document and getattr(document, "financial_year", None) != active_fy:
return RedirectResponse(url=f"/documents?financial_year={active_fy}&error=wrong_year", status_code=303) return RedirectResponse(url=f"/documents?financial_year={active_fy}&error=wrong_year", status_code=303)
if not version or not document or not document.engagement or not user_can_view_engagement(db, user, document.engagement, scope): if not document or not document.engagement or not user_can_view_engagement(db, user, document.engagement, scope):
return _redirect_denied() return _redirect_denied()
return _download_or_queue_from_local_node(request, db, user, document, version, action="download_version") return _download_or_queue_from_local_node(request, db, user, document, version, action="download_version")
finally: finally:
@@ -578,7 +585,10 @@ def download_latest_permanent_document(request: Request, document_id: int):
return response return response
scope = build_document_scope(request, db, user) scope = build_document_scope(request, db, user)
document = get_permanent_document(db, document_id) document = get_permanent_document(db, document_id)
if not document or not document.client or not user_can_view_client_documents(db, user, document.client, scope): if not document:
from app.core.http_responses import not_found_response
return not_found_response(request, "Permanent document not found")
if not document.client or not user_can_view_client_documents(db, user, document.client, scope):
return _redirect_denied() return _redirect_denied()
version = get_latest_permanent_version(document) version = get_latest_permanent_version(document)
if not version: if not version:
@@ -597,8 +607,11 @@ def download_permanent_version(request: Request, version_id: int):
return response return response
scope = build_document_scope(request, db, user) scope = build_document_scope(request, db, user)
version = get_permanent_version(db, version_id) version = get_permanent_version(db, version_id)
document = version.document if version else None if not version:
if not version or not document or not document.client or not user_can_view_client_documents(db, user, document.client, scope): from app.core.http_responses import not_found_response
return not_found_response(request, "Permanent document version not found")
document = version.document
if not document or not document.client or not user_can_view_client_documents(db, user, document.client, scope):
return _redirect_denied() return _redirect_denied()
return _download_or_queue_permanent_from_local_node(request, db, user, document, version) return _download_or_queue_permanent_from_local_node(request, db, user, document, version)
finally: finally:
+4 -1
View File
@@ -143,7 +143,8 @@ def _redirect_login():
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _base_ctx(request: Request, db, current_user, **ctx): def _base_ctx(request: Request, db, current_user, **ctx):
@@ -687,6 +688,7 @@ def employee_leave_balance_adjust(request: Request, employee_id: int = Form(...)
db.close() db.close()
@router.get("/leaves")
@router.get("/leave") @router.get("/leave")
def employee_leave_requests(request: Request, employee_id: int | None = None, status: str = "pending"): def employee_leave_requests(request: Request, employee_id: int | None = None, status: str = "pending"):
db = CommonSessionLocal() db = CommonSessionLocal()
@@ -2379,6 +2381,7 @@ def employee_punch_out_submit(
db.close() db.close()
@portal_router.get("/leaves")
@portal_router.get("/leave") @portal_router.get("/leave")
def employee_self_leave(request: Request): def employee_self_leave(request: Request):
db = CommonSessionLocal() db = CommonSessionLocal()
+2 -1
View File
@@ -30,7 +30,8 @@ def _redirect_login():
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/employee/dashboard", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _base_ctx(request: Request, db: Session, current_user, **ctx): def _base_ctx(request: Request, db: Session, current_user, **ctx):
+2 -1
View File
@@ -161,7 +161,8 @@ def marketplace_public_request_service_submit(request: Request, csrf_token: str
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _has_perm(db, user, code: str) -> bool: def _has_perm(db, user, code: str) -> bool:
+2 -1
View File
@@ -71,7 +71,8 @@ def _render(request: Request, template_name: str, db, user, **ctx):
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _require_user(request: Request, db, permission: str): def _require_user(request: Request, db, permission: str):
+2 -1
View File
@@ -38,7 +38,8 @@ def _redirect_login():
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/employee/dashboard", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _is_partner_user(db: Session, current_user) -> bool: def _is_partner_user(db: Session, current_user) -> bool:
+2 -1
View File
@@ -49,7 +49,8 @@ router = APIRouter(prefix="/platform-billing", tags=["platform-billing-ui"])
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _has_perm(db, user, code: str) -> bool: def _has_perm(db, user, code: str) -> bool:
@@ -43,7 +43,8 @@ def _render(request: Request, template: str, db, user, **ctx):
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _has_perm(db, user, code: str) -> bool: def _has_perm(db, user, code: str) -> bool:
+2 -1
View File
@@ -52,7 +52,8 @@ def _render(request: Request, template: str, db, user, **ctx):
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _has_perm(db, user, code: str) -> bool: def _has_perm(db, user, code: str) -> bool:
+2 -1
View File
@@ -44,7 +44,8 @@ def _render(request: Request, template: str, db, user, **ctx):
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _has_perm(db, user, code: str) -> bool: def _has_perm(db, user, code: str) -> bool:
+2 -1
View File
@@ -83,7 +83,8 @@ def _render(request: Request, template: str, db, user, **ctx):
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _has_perm(db, user, code: str) -> bool: def _has_perm(db, user, code: str) -> bool:
+2 -1
View File
@@ -55,7 +55,8 @@ def _render(request: Request, template: str, db, user, **ctx):
def _redirect_denied(): def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _has_perm(db, user, code: str) -> bool: def _has_perm(db, user, code: str) -> bool:
+2 -1
View File
@@ -38,7 +38,8 @@ def _base_ctx(request: Request, user, db, **ctx):
def _redirect_denied(default_url: str = "/system-settings"): def _redirect_denied(default_url: str = "/system-settings"):
return RedirectResponse(url=default_url, status_code=303) from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _render_with_user(request: Request, template: str, ctx: dict, status_code: int = 200): def _render_with_user(request: Request, template: str, ctx: dict, status_code: int = 200):