Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class AuditLog(CommonBase):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True, nullable=True)
|
||||
actor_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
actor_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
actor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
action: Mapped[str] = mapped_column(String(120), index=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(120), index=True)
|
||||
entity_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
entity_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), default="success", index=True)
|
||||
|
||||
target_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
target_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
ip_address: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
details_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.audit.models import AuditLog
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import UserScope
|
||||
|
||||
|
||||
def _safe_json(value: Any) -> str:
|
||||
return json.dumps(value or {}, ensure_ascii=False, default=_json_default, sort_keys=True)
|
||||
|
||||
|
||||
def _json_default(value: Any):
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if hasattr(value, "isoformat"):
|
||||
try:
|
||||
return value.isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
return str(value)
|
||||
|
||||
|
||||
def _request_meta(request: Request | None) -> tuple[str | None, str | None]:
|
||||
if not request:
|
||||
return None, None
|
||||
ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent")
|
||||
return ip, user_agent
|
||||
|
||||
|
||||
def write_audit_log(
|
||||
db: Session,
|
||||
*,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
actor: User | None = None,
|
||||
request: Request | None = None,
|
||||
entity_id: str | int | None = None,
|
||||
entity_name: str | None = None,
|
||||
status: str = "success",
|
||||
target_tenant_id: int | None = None,
|
||||
target_branch_id: int | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
actor_email: str | None = None,
|
||||
) -> AuditLog:
|
||||
ip_address, user_agent = _request_meta(request)
|
||||
log = AuditLog(
|
||||
actor_user_id=actor.id if actor else None,
|
||||
actor_email=(actor.email if actor else actor_email),
|
||||
actor_tenant_id=(actor.tenant_id if actor else None),
|
||||
actor_branch_id=(actor.branch_id if actor else None),
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=str(entity_id) if entity_id is not None else None,
|
||||
entity_name=entity_name,
|
||||
status=status,
|
||||
target_tenant_id=target_tenant_id,
|
||||
target_branch_id=target_branch_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
details_json=_safe_json(details),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
return log
|
||||
|
||||
|
||||
def model_snapshot(obj: Any, fields: list[str]) -> dict[str, Any]:
|
||||
return {field: getattr(obj, field, None) for field in fields}
|
||||
|
||||
|
||||
def pair_before_after(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"before": before, "after": after}
|
||||
|
||||
|
||||
def list_audit_logs(db: Session, scope: UserScope, limit: int = 200) -> list[AuditLog]:
|
||||
q = select(AuditLog)
|
||||
if not scope.is_system_admin:
|
||||
q = q.where(AuditLog.target_tenant_id == scope.actor.tenant_id)
|
||||
if scope.branch_scoped:
|
||||
q = q.where(AuditLog.target_branch_id == scope.actor.branch_id)
|
||||
return db.execute(q.order_by(AuditLog.created_at_utc.desc(), AuditLog.id.desc()).limit(limit)).scalars().all()
|
||||
|
||||
|
||||
def parse_details(log: AuditLog) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(log.details_json or "{}")
|
||||
except Exception:
|
||||
return {"raw": log.details_json}
|
||||
|
||||
|
||||
|
||||
def search_audit_logs(db: Session, scope: UserScope, q: str | None = None) -> list[AuditLog]:
|
||||
rows = list_audit_logs(db, scope, limit=1000)
|
||||
query = (q or "").strip().lower()
|
||||
if not query:
|
||||
return rows
|
||||
result = []
|
||||
for row in rows:
|
||||
hay = " ".join([str(row.action or ""), str(row.entity_type or ""), str(row.entity_name or ""), str(row.actor_email or ""), str(row.details_json or "")]).lower()
|
||||
if query in hay:
|
||||
result.append(row)
|
||||
return result
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% import "ui/templates/components/macros.html" as ui %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{{ ui.page_shell('Audit Logs', 'Latest security and admin changes captured from IAM, RBAC, login, audit firm and branch operations.') }}
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
{{ ui.search_bar('/system-settings/audit-logs', filters.q, filters.per_page) }}
|
||||
{% if logs %}
|
||||
<div class="overflow-x-auto"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-slate-600"><tr><th class="px-4 py-3 text-left font-semibold">When</th><th class="px-4 py-3 text-left font-semibold">Action</th><th class="px-4 py-3 text-left font-semibold">Entity</th><th class="px-4 py-3 text-left font-semibold">Actor</th><th class="px-4 py-3 text-left font-semibold">Target Scope</th><th class="px-4 py-3 text-left font-semibold">Details</th><th class="px-4 py-3 text-left font-semibold">Status</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in logs %}<tr class="align-top"><td class="px-4 py-3 whitespace-nowrap text-slate-600">{{ row.created_at_utc }}</td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.action }}</div><div class="text-xs text-slate-500">IP {{ row.ip_address or '-' }}</div></td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.entity_type }}</div><div class="text-xs text-slate-500">{{ row.entity_name or row.entity_id or '-' }}</div></td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.actor_email or 'System' }}</div><div class="text-xs text-slate-500">Audit Firm {{ row.actor_tenant_id or '-' }} • Branch {{ row.actor_branch_id or '-' }}</div></td><td class="px-4 py-3 text-slate-600">Audit Firm {{ row.target_tenant_id or '-' }} • Branch {{ row.target_branch_id or '-' }}</td><td class="px-4 py-3 max-w-[28rem]"><pre class="whitespace-pre-wrap break-words rounded-xl bg-slate-50 p-3 text-xs text-slate-700">{{ row.pretty_details }}</pre></td><td class="px-4 py-3">{% if row.status == 'success' %}{{ ui.badge(row.status, 'emerald') }}{% elif row.status == 'denied' %}{{ ui.badge(row.status, 'amber') }}{% else %}{{ ui.badge(row.status, 'rose') }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>
|
||||
{{ ui.pagination(logs_page, '/system-settings/audit-logs', request.url.query) }}
|
||||
{% else %}<div class="p-6">{{ ui.empty_state('No audit entries found for the current filter.') }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.templating import templates
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token
|
||||
from app.core.security.session_auth import get_current_user
|
||||
from app.modules.core.audit.service import parse_details, search_audit_logs
|
||||
from app.modules.core.iam.scope import build_scope
|
||||
from app.modules.core.iam.services import paginate_list
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
|
||||
router = APIRouter(prefix="/system-settings/audit-logs", tags=["audit-ui"])
|
||||
|
||||
|
||||
def _is_system_admin(db, user) -> bool:
|
||||
return "System Admin" in get_user_roles(db, user.id)
|
||||
|
||||
|
||||
def _is_firm_admin(db, user) -> bool:
|
||||
return "Firm Admin" in get_user_roles(db, user.id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def logs(request: Request, q: str = "", page: int = 1, per_page: int = 20):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
try:
|
||||
require_permission(db, user, "audit.view")
|
||||
except Exception:
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
if not _is_system_admin(db, user) and not _is_firm_admin(db, user):
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
scope = build_scope(db, user)
|
||||
rows = search_audit_logs(db, scope, q=q)
|
||||
|
||||
# Enforce final matrix explicitly:
|
||||
# - System Admin: all logs
|
||||
# - Firm Admin: own tenant logs only
|
||||
# - others: none
|
||||
if _is_firm_admin(db, user) and not _is_system_admin(db, user):
|
||||
rows = [r for r in rows if getattr(r, "target_tenant_id", None) == user.tenant_id]
|
||||
|
||||
paged = paginate_list(rows, page=page, per_page=per_page)
|
||||
decorated = []
|
||||
for row in paged.items:
|
||||
pretty = json.dumps(parse_details(row), indent=2, ensure_ascii=False, default=str)
|
||||
setattr(row, "pretty_details", pretty)
|
||||
decorated.append(row)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"modules/core/audit/templates/logs.html",
|
||||
{
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id),
|
||||
"current_user_permissions": get_user_permissions(db, user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"title": "Audit Logs",
|
||||
"logs": decorated,
|
||||
"logs_page": paged,
|
||||
"filters": {"q": (q or "").strip(), "per_page": paged.per_page},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.core.security.session_auth import require_login
|
||||
from app.modules.core.audit.service import model_snapshot, pair_before_after, write_audit_log
|
||||
from app.modules.core.iam.lifecycle import activate_user, deactivate_user, disable_login, enable_login, ensure_manageable_lifecycle, lock_user, restore_user, soft_delete_user, unlock_user, LifecycleError
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import (
|
||||
build_scope,
|
||||
ensure_assignable_roles,
|
||||
ensure_manageable_existing_user,
|
||||
ensure_users_manage_scope,
|
||||
ensure_users_view_scope,
|
||||
list_scoped_users,
|
||||
resolve_target_tenant_branch,
|
||||
scope_to_http,
|
||||
)
|
||||
from app.modules.core.rbac.deps import require_permission
|
||||
from app.modules.core.rbac.models import UserRole
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
password: str
|
||||
tenant_id: int | None = None
|
||||
branch_id: int | None = None
|
||||
role_ids: list[int] = []
|
||||
is_active: bool = True
|
||||
allow_login: bool = True
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
full_name: str
|
||||
tenant_id: int | None = None
|
||||
branch_id: int | None = None
|
||||
role_ids: list[int] = []
|
||||
is_active: bool = True
|
||||
allow_login: bool = True
|
||||
password: str | None = None
|
||||
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("users.view"))])
|
||||
def list_users(current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
try:
|
||||
ensure_users_view_scope(scope)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
users = list_scoped_users(db, scope)
|
||||
return [
|
||||
{
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"full_name": u.full_name,
|
||||
"tenant_id": u.tenant_id,
|
||||
"branch_id": u.branch_id,
|
||||
"is_active": u.is_active,
|
||||
"allow_login": getattr(u, "allow_login", True),
|
||||
"is_locked": getattr(u, "is_locked", False),
|
||||
"deleted_at": (u.deleted_at.isoformat() if getattr(u, "deleted_at", None) else None),
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def create_user(
|
||||
payload: UserCreateRequest,
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
scope = build_scope(db, current_user)
|
||||
try:
|
||||
ensure_users_manage_scope(scope)
|
||||
tenant_id, branch_id = resolve_target_tenant_branch(db, scope, payload.tenant_id, payload.branch_id)
|
||||
roles = ensure_assignable_roles(db, scope, payload.role_ids)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
email = payload.email.lower().strip()
|
||||
exists = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="Email already exists")
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
full_name=payload.full_name.strip(),
|
||||
password_hash=hash_password(payload.password),
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
is_active=payload.is_active,
|
||||
allow_login=payload.allow_login,
|
||||
is_locked=False,
|
||||
deleted_at=None,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
for role in roles:
|
||||
db.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="user.create.api",
|
||||
entity_type="user",
|
||||
actor=current_user,
|
||||
entity_id=user.id,
|
||||
entity_name=user.email,
|
||||
target_tenant_id=user.tenant_id,
|
||||
target_branch_id=user.branch_id,
|
||||
details={"after": model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"]), "role_ids": [role.id for role in roles]},
|
||||
)
|
||||
return {"status": "ok", "id": user.id}
|
||||
|
||||
|
||||
@router.get("/{user_id}", dependencies=[Depends(require_permission("users.view"))])
|
||||
def get_user(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
try:
|
||||
ensure_users_view_scope(scope)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_existing_user(db, scope, user)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
role_ids = db.execute(select(UserRole.role_id).where(UserRole.user_id == user.id)).scalars().all()
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"tenant_id": user.tenant_id,
|
||||
"branch_id": user.branch_id,
|
||||
"is_active": user.is_active,
|
||||
"allow_login": getattr(user, "allow_login", True),
|
||||
"is_locked": getattr(user, "is_locked", False),
|
||||
"deleted_at": (user.deleted_at.isoformat() if getattr(user, "deleted_at", None) else None),
|
||||
"role_ids": list(role_ids),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def update_user(
|
||||
user_id: int,
|
||||
payload: UserUpdateRequest,
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
try:
|
||||
ensure_users_manage_scope(scope)
|
||||
ensure_manageable_existing_user(db, scope, user)
|
||||
tenant_id, branch_id = resolve_target_tenant_branch(db, scope, payload.tenant_id, payload.branch_id)
|
||||
roles = ensure_assignable_roles(db, scope, payload.role_ids)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
before_snapshot = model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])
|
||||
old_role_ids = list(db.execute(select(UserRole.role_id).where(UserRole.user_id == user.id)).scalars().all())
|
||||
|
||||
user.full_name = payload.full_name.strip()
|
||||
user.tenant_id = tenant_id
|
||||
user.branch_id = branch_id
|
||||
user.is_active = payload.is_active
|
||||
user.allow_login = payload.allow_login
|
||||
if payload.password:
|
||||
user.password_hash = hash_password(payload.password)
|
||||
|
||||
db.execute(UserRole.__table__.delete().where(UserRole.user_id == user.id))
|
||||
for role in roles:
|
||||
db.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="user.update.api",
|
||||
entity_type="user",
|
||||
actor=current_user,
|
||||
entity_id=user.id,
|
||||
entity_name=user.email,
|
||||
target_tenant_id=user.tenant_id,
|
||||
target_branch_id=user.branch_id,
|
||||
details={**pair_before_after(before_snapshot, model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])), "old_role_ids": old_role_ids, "new_role_ids": [role.id for role in roles]},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
def _lifecycle_response(user: User) -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"user_id": user.id,
|
||||
"is_active": user.is_active,
|
||||
"allow_login": getattr(user, "allow_login", True),
|
||||
"is_locked": getattr(user, "is_locked", False),
|
||||
"deleted_at": (user.deleted_at.isoformat() if getattr(user, "deleted_at", None) else None),
|
||||
}
|
||||
|
||||
|
||||
def _apply_lifecycle_action(db: Session, current_user: User, user: User, action: str):
|
||||
before = model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])
|
||||
if action == "activate":
|
||||
activate_user(user)
|
||||
audit_action = "user.activate.api"
|
||||
elif action == "deactivate":
|
||||
deactivate_user(user)
|
||||
audit_action = "user.deactivate.api"
|
||||
elif action == "enable-login":
|
||||
enable_login(user)
|
||||
audit_action = "user.enable_login.api"
|
||||
elif action == "disable-login":
|
||||
disable_login(user)
|
||||
audit_action = "user.disable_login.api"
|
||||
elif action == "lock":
|
||||
lock_user(user)
|
||||
audit_action = "user.lock.api"
|
||||
elif action == "unlock":
|
||||
unlock_user(user)
|
||||
audit_action = "user.unlock.api"
|
||||
elif action == "delete":
|
||||
soft_delete_user(user)
|
||||
audit_action = "user.soft_delete.api"
|
||||
elif action == "restore":
|
||||
restore_user(user)
|
||||
audit_action = "user.restore.api"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unknown action")
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action=audit_action,
|
||||
entity_type="user",
|
||||
actor=current_user,
|
||||
entity_id=user.id,
|
||||
entity_name=user.email,
|
||||
target_tenant_id=user.tenant_id,
|
||||
target_branch_id=user.branch_id,
|
||||
details=pair_before_after(before, model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])),
|
||||
)
|
||||
return _lifecycle_response(user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/activate", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def activate_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "activate")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "activate")
|
||||
|
||||
|
||||
@router.post("/{user_id}/deactivate", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def deactivate_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "deactivate")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "deactivate")
|
||||
|
||||
|
||||
@router.post("/{user_id}/enable-login", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def enable_login_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "enable login for")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "enable-login")
|
||||
|
||||
|
||||
@router.post("/{user_id}/disable-login", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def disable_login_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "disable login for")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "disable-login")
|
||||
|
||||
|
||||
@router.post("/{user_id}/lock", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def lock_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "lock")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "lock")
|
||||
|
||||
|
||||
@router.post("/{user_id}/unlock", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def unlock_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "unlock")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "unlock")
|
||||
|
||||
|
||||
@router.post("/{user_id}/delete", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def delete_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "delete")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "delete")
|
||||
|
||||
|
||||
@router.post("/{user_id}/restore", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def restore_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "restore")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "restore")
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, timezone
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.settings import get_settings
|
||||
from app.core.security.passwords import verify_password
|
||||
from app.core.security.jwt_tokens import encode_access_token, decode_token, utcnow
|
||||
from app.modules.core.audit.service import write_audit_log
|
||||
from app.modules.core.iam.invite_service import accept_invite, issue_password_reset_token, reset_password_with_token
|
||||
from app.modules.email_integration.services import send_password_reset_link_email, send_password_changed_email
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.tokens_models import RefreshToken
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
class AcceptInviteRequest(BaseModel):
|
||||
token: str
|
||||
password: str
|
||||
|
||||
def _hash_refresh(rt: str) -> str:
|
||||
return hashlib.sha256(rt.encode("utf-8")).hexdigest()
|
||||
|
||||
def _roles(db: Session, user_id: int) -> list[str]:
|
||||
q = select(Role.name).join(UserRole, UserRole.role_id == Role.id).where(UserRole.user_id == user_id)
|
||||
return [r for (r,) in db.execute(q).all()]
|
||||
|
||||
def _issue_tokens(db: Session, user: User) -> TokenResponse:
|
||||
s = get_settings()
|
||||
roles = _roles(db, user.id)
|
||||
payload = {
|
||||
"sub": str(user.id),
|
||||
"email": user.email,
|
||||
"tenant_id": user.tenant_id,
|
||||
"branch_id": user.branch_id,
|
||||
"roles": roles,
|
||||
}
|
||||
access = encode_access_token(payload, expires_minutes=s.JWT_ACCESS_MINUTES)
|
||||
|
||||
refresh_plain = secrets.token_urlsafe(48)
|
||||
now = utcnow()
|
||||
exp = now + timedelta(days=s.JWT_REFRESH_DAYS)
|
||||
|
||||
rt = RefreshToken(
|
||||
user_id=user.id,
|
||||
token_hash=_hash_refresh(refresh_plain),
|
||||
created_at_utc=now,
|
||||
expires_at_utc=exp,
|
||||
revoked=False,
|
||||
rotated_from_id=None,
|
||||
)
|
||||
db.add(rt)
|
||||
db.commit()
|
||||
|
||||
return TokenResponse(access_token=access, expires_in=s.JWT_ACCESS_MINUTES * 60, refresh_token=refresh_plain)
|
||||
|
||||
@router.post("/token", response_model=TokenResponse)
|
||||
def token(req: TokenRequest, db: Session = Depends(get_common_db)):
|
||||
user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none()
|
||||
if (not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None or not verify_password(req.password, user.password_hash)):
|
||||
write_audit_log(db, action="auth.token.failed", entity_type="api_session", actor=user, actor_email=req.email.lower().strip(), status="error", target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None), details={"reason": "invalid credentials"})
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
if getattr(user, "must_change_password", False):
|
||||
raise HTTPException(status_code=403, detail="Password setup/change required before API login")
|
||||
token_response = _issue_tokens(db, user)
|
||||
write_audit_log(db, action="auth.token.success", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
return token_response
|
||||
|
||||
@router.post('/forgot-password')
|
||||
def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_common_db)):
|
||||
user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none()
|
||||
if user and user.is_active and getattr(user, "deleted_at", None) is None:
|
||||
reset_token = issue_password_reset_token(db, user)
|
||||
try:
|
||||
send_password_reset_link_email(db, user=user, reset_token=reset_token)
|
||||
except Exception as exc:
|
||||
write_audit_log(db, action="auth.password_reset.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)})
|
||||
write_audit_log(db, action="auth.password_reset.requested", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post('/reset-password')
|
||||
def reset_password(req: ResetPasswordRequest, db: Session = Depends(get_common_db)):
|
||||
try:
|
||||
user = reset_password_with_token(db, req.token, req.new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
||||
try:
|
||||
send_password_changed_email(db, user=user)
|
||||
except Exception as exc:
|
||||
write_audit_log(db, action="auth.password_changed.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)})
|
||||
write_audit_log(db, action="auth.password_reset.completed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post('/invite/accept')
|
||||
def invite_accept(req: AcceptInviteRequest, db: Session = Depends(get_common_db)):
|
||||
try:
|
||||
user = accept_invite(db, req.token, req.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired invite token")
|
||||
write_audit_log(db, action="auth.invite.accepted", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh(req: RefreshRequest, db: Session = Depends(get_common_db)):
|
||||
h = _hash_refresh(req.refresh_token)
|
||||
rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none()
|
||||
if not rt or rt.revoked:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
now = utcnow()
|
||||
if rt.expires_at_utc.replace(tzinfo=timezone.utc) < now:
|
||||
raise HTTPException(status_code=401, detail="Refresh token expired")
|
||||
|
||||
user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none()
|
||||
if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None:
|
||||
raise HTTPException(status_code=401, detail="User inactive")
|
||||
|
||||
rt.revoked = True
|
||||
db.commit()
|
||||
token_response = _issue_tokens(db, user)
|
||||
write_audit_log(db, action="auth.token.refresh", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
return token_response
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(req: LogoutRequest, db: Session = Depends(get_common_db)):
|
||||
h = _hash_refresh(req.refresh_token)
|
||||
rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none()
|
||||
if rt:
|
||||
rt.revoked = True
|
||||
db.commit()
|
||||
user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none()
|
||||
write_audit_log(db, action="auth.token.logout", entity_type="api_session", actor=user, entity_name=(user.email if user else None), target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None))
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/me")
|
||||
def me(token: str):
|
||||
return {"token": decode_token(token)}
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, timezone
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security.jwt_tokens import utcnow
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.core.settings import get_settings
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.password_flows_models import InviteToken, PasswordResetToken
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def validate_password_policy(password: str) -> str | None:
|
||||
s = get_settings()
|
||||
if len(password or "") < s.PASSWORD_MIN_LENGTH:
|
||||
return f"Password must be at least {s.PASSWORD_MIN_LENGTH} characters long."
|
||||
if not re.search(r"[A-Za-z]", password or ""):
|
||||
return "Password must include at least one letter."
|
||||
if not re.search(r"\d", password or ""):
|
||||
return "Password must include at least one number."
|
||||
return None
|
||||
|
||||
|
||||
def issue_invite_token(db: Session, user: User) -> str:
|
||||
plain = secrets.token_urlsafe(32)
|
||||
now = utcnow()
|
||||
token = InviteToken(
|
||||
user_id=user.id,
|
||||
token_hash=_hash_token(plain),
|
||||
created_at_utc=now,
|
||||
expires_at_utc=now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS),
|
||||
used_at_utc=None,
|
||||
)
|
||||
db.add(token)
|
||||
user.must_change_password = True
|
||||
db.commit()
|
||||
return plain
|
||||
|
||||
|
||||
def issue_password_reset_token(db: Session, user: User) -> str:
|
||||
plain = secrets.token_urlsafe(32)
|
||||
now = utcnow()
|
||||
token = PasswordResetToken(
|
||||
user_id=user.id,
|
||||
token_hash=_hash_token(plain),
|
||||
created_at_utc=now,
|
||||
expires_at_utc=now + timedelta(hours=get_settings().PASSWORD_RESET_HOURS),
|
||||
used_at_utc=None,
|
||||
)
|
||||
db.add(token)
|
||||
db.commit()
|
||||
return plain
|
||||
|
||||
|
||||
def _validate_unused(record) -> bool:
|
||||
if not record or record.used_at_utc is not None:
|
||||
return False
|
||||
now = utcnow()
|
||||
exp = record.expires_at_utc
|
||||
if getattr(exp, "tzinfo", None) is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
return exp >= now
|
||||
|
||||
|
||||
def accept_invite(db: Session, token: str, password: str) -> User | None:
|
||||
err = validate_password_policy(password)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
record = db.execute(select(InviteToken).where(InviteToken.token_hash == _hash_token(token))).scalar_one_or_none()
|
||||
if not _validate_unused(record):
|
||||
return None
|
||||
user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
user.password_hash = hash_password(password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
|
||||
user.allow_login = True
|
||||
user.is_active = True
|
||||
record.used_at_utc = utcnow().replace(tzinfo=None)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def reset_password_with_token(db: Session, token: str, password: str) -> User | None:
|
||||
err = validate_password_policy(password)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
record = db.execute(select(PasswordResetToken).where(PasswordResetToken.token_hash == _hash_token(token))).scalar_one_or_none()
|
||||
if not _validate_unused(record):
|
||||
return None
|
||||
user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
user.password_hash = hash_password(password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
|
||||
record.used_at_utc = utcnow().replace(tzinfo=None)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def force_change_password(db: Session, user: User, new_password: str) -> None:
|
||||
err = validate_password_policy(new_password)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
|
||||
db.commit()
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import UserScope, ensure_manageable_existing_user
|
||||
|
||||
|
||||
class LifecycleError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def utcnow_naive() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def ensure_not_self(actor: User, target: User, action_label: str) -> None:
|
||||
if actor.id == target.id:
|
||||
raise LifecycleError(f"You cannot {action_label} your own account.")
|
||||
|
||||
|
||||
def ensure_not_bootstrap_admin(target: User) -> None:
|
||||
if (target.email or '').strip().lower() == 'admin@auditfirm.local':
|
||||
raise LifecycleError('Bootstrap system admin cannot be modified by this action.')
|
||||
|
||||
|
||||
def ensure_manageable_lifecycle(scope: UserScope, db: Session, actor: User, target: User, action_label: str) -> None:
|
||||
ensure_manageable_existing_user(db, scope, target)
|
||||
ensure_not_self(actor, target, action_label)
|
||||
|
||||
|
||||
def activate_user(user: User) -> None:
|
||||
user.is_active = True
|
||||
if user.deleted_at is not None:
|
||||
user.deleted_at = None
|
||||
|
||||
|
||||
def deactivate_user(user: User) -> None:
|
||||
user.is_active = False
|
||||
|
||||
|
||||
def enable_login(user: User) -> None:
|
||||
user.allow_login = True
|
||||
|
||||
|
||||
def disable_login(user: User) -> None:
|
||||
user.allow_login = False
|
||||
|
||||
|
||||
def lock_user(user: User) -> None:
|
||||
user.is_locked = True
|
||||
user.locked_at_utc = utcnow_naive()
|
||||
|
||||
|
||||
def unlock_user(user: User) -> None:
|
||||
user.is_locked = False
|
||||
user.locked_at_utc = None
|
||||
|
||||
|
||||
def soft_delete_user(user: User) -> None:
|
||||
user.deleted_at = utcnow_naive()
|
||||
user.is_active = False
|
||||
user.allow_login = False
|
||||
user.is_locked = True
|
||||
if user.locked_at_utc is None:
|
||||
user.locked_at_utc = utcnow_naive()
|
||||
|
||||
|
||||
def restore_user(user: User) -> None:
|
||||
user.deleted_at = None
|
||||
user.is_active = True
|
||||
user.allow_login = True
|
||||
user.is_locked = False
|
||||
user.locked_at_utc = None
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, UniqueConstraint, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
class User(CommonBase):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (UniqueConstraint("email", name="uq_user_email"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), index=True)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
allow_login: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
password_changed_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Phase 7Q.3 - common user profile/personalisation fields.
|
||||
# Employee/consultant/client master records remain the source for official data;
|
||||
# these fields are used for display, dashboards, client-facing contact cards and branding.
|
||||
profile_photo_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
qualification: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
designation: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
signature_image_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
class LoginAttempt(CommonBase):
|
||||
__tablename__ = "login_attempts"
|
||||
__table_args__ = (UniqueConstraint("key", name="uq_login_attempt_key"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(255), index=True) # email|ip
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_until_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
updated_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Integer, String, ForeignKey, DateTime, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
class InviteToken(CommonBase):
|
||||
__tablename__ = "invite_tokens"
|
||||
__table_args__ = (UniqueConstraint("token_hash", name="uq_invite_token_hash"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
used_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
class PasswordResetToken(CommonBase):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
__table_args__ = (UniqueConstraint("token_hash", name="uq_password_reset_token_hash"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
used_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import imghdr
|
||||
import re
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
|
||||
STATIC_ROOT = Path("app/ui/static")
|
||||
PROFILE_UPLOAD_DIR = STATIC_ROOT / "uploads" / "user_profiles"
|
||||
MAX_IMAGE_BYTES = 2 * 1024 * 1024
|
||||
ALLOWED_IMAGE_TYPES = {"jpeg": ".jpg", "png": ".png", "gif": ".gif", "webp": ".webp"}
|
||||
|
||||
|
||||
def _blank_to_none(value: object) -> str | None:
|
||||
value = (str(value).strip() if value is not None else "")
|
||||
return value or None
|
||||
|
||||
|
||||
def _safe_static_path(path: str | None) -> str | None:
|
||||
path = (path or "").strip()
|
||||
if not path:
|
||||
return None
|
||||
if path.startswith("/static/"):
|
||||
return path
|
||||
if path.startswith("app/ui/static/"):
|
||||
return "/static/" + path.split("app/ui/static/", 1)[1].replace("\\", "/")
|
||||
return path
|
||||
|
||||
|
||||
def profile_photo_url(user: User | None) -> str | None:
|
||||
if not user:
|
||||
return None
|
||||
return _safe_static_path(getattr(user, "profile_photo_path", None))
|
||||
|
||||
|
||||
def user_initials(user: User | None) -> str:
|
||||
if not user:
|
||||
return "U"
|
||||
name = (getattr(user, "full_name", None) or getattr(user, "email", "") or "User").strip()
|
||||
if "@" in name and not getattr(user, "full_name", None):
|
||||
name = name.split("@", 1)[0]
|
||||
parts = [p for p in re.split(r"\s+", name) if p]
|
||||
if not parts:
|
||||
return "U"
|
||||
if len(parts) == 1:
|
||||
return parts[0][:2].upper()
|
||||
return (parts[0][0] + parts[-1][0]).upper()
|
||||
|
||||
|
||||
async def save_user_profile_photo(user: User, upload: UploadFile | None) -> str | None:
|
||||
"""Persist a profile photo and return a static path, or current path if nothing uploaded."""
|
||||
if not upload or not getattr(upload, "filename", None):
|
||||
return getattr(user, "profile_photo_path", None)
|
||||
|
||||
raw = await upload.read()
|
||||
if not raw:
|
||||
return getattr(user, "profile_photo_path", None)
|
||||
if len(raw) > MAX_IMAGE_BYTES:
|
||||
raise HTTPException(status_code=400, detail="Profile photo must be 2 MB or smaller.")
|
||||
|
||||
detected = imghdr.what(None, raw)
|
||||
suffix = ALLOWED_IMAGE_TYPES.get(detected or "")
|
||||
if not suffix:
|
||||
raise HTTPException(status_code=400, detail="Upload a valid JPG, PNG, GIF or WebP profile photo.")
|
||||
|
||||
PROFILE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"user_{user.id}_{uuid4().hex}{suffix}"
|
||||
target = PROFILE_UPLOAD_DIR / filename
|
||||
target.write_bytes(raw)
|
||||
return f"app/ui/static/uploads/user_profiles/{filename}"
|
||||
|
||||
|
||||
def update_user_public_profile(
|
||||
db: Session,
|
||||
user: User,
|
||||
*,
|
||||
qualification: object = None,
|
||||
designation: object = None,
|
||||
mobile: object = None,
|
||||
bio: object = None,
|
||||
profile_photo_path: str | None = None,
|
||||
) -> User:
|
||||
user.qualification = _blank_to_none(qualification)
|
||||
user.designation = _blank_to_none(designation)
|
||||
user.mobile = _blank_to_none(mobile)
|
||||
user.bio = _blank_to_none(bio)
|
||||
if profile_photo_path is not None:
|
||||
user.profile_photo_path = profile_photo_path
|
||||
db.add(user)
|
||||
return user
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
ROLE_SYSTEM_ADMIN = "System Admin"
|
||||
ROLE_FIRM_ADMIN = "Firm Admin"
|
||||
ROLE_PARTNER = "Partner"
|
||||
ROLE_BRANCH_MANAGER = "Branch Manager"
|
||||
ROLE_STAFF = "Staff"
|
||||
ROLE_CLIENT = "Client"
|
||||
ROLE_CONSULTANT = "Consultant"
|
||||
|
||||
# Matrix: only System Admin and Firm Admin manage users.
|
||||
MANAGEABLE_ROLES_BY_ACTOR = {
|
||||
ROLE_SYSTEM_ADMIN: {
|
||||
ROLE_SYSTEM_ADMIN,
|
||||
ROLE_FIRM_ADMIN,
|
||||
ROLE_PARTNER,
|
||||
ROLE_BRANCH_MANAGER,
|
||||
ROLE_STAFF,
|
||||
ROLE_CLIENT,
|
||||
ROLE_CONSULTANT,
|
||||
},
|
||||
ROLE_FIRM_ADMIN: {
|
||||
ROLE_PARTNER,
|
||||
ROLE_BRANCH_MANAGER,
|
||||
ROLE_STAFF,
|
||||
ROLE_CLIENT,
|
||||
ROLE_CONSULTANT,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserScope:
|
||||
actor: User
|
||||
role_names: list[str]
|
||||
is_system_admin: bool
|
||||
is_firm_admin: bool
|
||||
is_partner: bool
|
||||
is_branch_manager: bool
|
||||
|
||||
@property
|
||||
def tenant_scoped(self) -> bool:
|
||||
return self.is_firm_admin or self.is_partner or self.is_branch_manager
|
||||
|
||||
@property
|
||||
def branch_scoped(self) -> bool:
|
||||
return self.is_branch_manager
|
||||
|
||||
|
||||
class ScopeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_role_names(db: Session, user_id: int) -> list[str]:
|
||||
q = (
|
||||
select(Role.name)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == user_id, Role.is_active.is_(True))
|
||||
.order_by(Role.name)
|
||||
)
|
||||
return [name for (name,) in db.execute(q).all()]
|
||||
|
||||
|
||||
def build_scope(db: Session, actor: User) -> UserScope:
|
||||
role_names = get_role_names(db, actor.id)
|
||||
return UserScope(
|
||||
actor=actor,
|
||||
role_names=role_names,
|
||||
is_system_admin=ROLE_SYSTEM_ADMIN in role_names,
|
||||
is_firm_admin=ROLE_FIRM_ADMIN in role_names,
|
||||
is_partner=ROLE_PARTNER in role_names,
|
||||
is_branch_manager=ROLE_BRANCH_MANAGER in role_names,
|
||||
)
|
||||
|
||||
|
||||
def ensure_users_view_scope(scope: UserScope) -> None:
|
||||
if scope.is_system_admin or scope.is_firm_admin or scope.is_partner or scope.is_branch_manager:
|
||||
return
|
||||
raise ScopeError("You are not allowed to view users.")
|
||||
|
||||
|
||||
def ensure_users_manage_scope(scope: UserScope) -> None:
|
||||
if scope.is_system_admin or scope.is_firm_admin:
|
||||
return
|
||||
raise ScopeError("Only System Admin and Firm Admin can manage users.")
|
||||
|
||||
|
||||
def list_visible_tenants(db: Session, scope: UserScope) -> list[Tenant]:
|
||||
if scope.is_system_admin:
|
||||
return db.execute(
|
||||
select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name)
|
||||
).scalars().all()
|
||||
tenant = db.execute(select(Tenant).where(Tenant.id == scope.actor.tenant_id)).scalar_one_or_none()
|
||||
return [tenant] if tenant else []
|
||||
|
||||
|
||||
def list_visible_branches(db: Session, scope: UserScope, tenant_id: int | None = None) -> list[Branch]:
|
||||
effective_tenant_id = tenant_id or scope.actor.tenant_id
|
||||
q = select(Branch).where(Branch.is_active.is_(True), Branch.tenant_id == effective_tenant_id)
|
||||
if scope.branch_scoped:
|
||||
q = q.where(Branch.id == scope.actor.branch_id)
|
||||
return db.execute(q.order_by(Branch.name)).scalars().all()
|
||||
|
||||
|
||||
def list_scoped_users(db: Session, scope: UserScope) -> list[User]:
|
||||
q = select(User)
|
||||
if not scope.is_system_admin:
|
||||
q = q.where(User.tenant_id == scope.actor.tenant_id)
|
||||
if scope.branch_scoped:
|
||||
q = q.where(User.branch_id == scope.actor.branch_id)
|
||||
return db.execute(q.order_by(User.id)).scalars().all()
|
||||
|
||||
|
||||
def get_manageable_roles(db: Session, scope: UserScope) -> list[Role]:
|
||||
if scope.is_system_admin:
|
||||
return db.execute(
|
||||
select(Role).where(Role.is_active.is_(True)).order_by(Role.name)
|
||||
).scalars().all()
|
||||
|
||||
allowed_names: set[str] = set()
|
||||
for role_name in scope.role_names:
|
||||
allowed_names.update(MANAGEABLE_ROLES_BY_ACTOR.get(role_name, set()))
|
||||
if not allowed_names:
|
||||
return []
|
||||
return db.execute(
|
||||
select(Role).where(Role.is_active.is_(True), Role.name.in_(sorted(allowed_names))).order_by(Role.name)
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def get_user_role_names(db: Session, user_id: int) -> list[str]:
|
||||
return get_role_names(db, user_id)
|
||||
|
||||
|
||||
def get_user_role_ids(db: Session, user_id: int) -> list[int]:
|
||||
return db.execute(select(UserRole.role_id).where(UserRole.user_id == user_id)).scalars().all()
|
||||
|
||||
|
||||
def can_manage_role_names(scope: UserScope, role_names: list[str]) -> bool:
|
||||
if scope.is_system_admin:
|
||||
return True
|
||||
allowed: set[str] = set()
|
||||
for actor_role in scope.role_names:
|
||||
allowed.update(MANAGEABLE_ROLES_BY_ACTOR.get(actor_role, set()))
|
||||
return set(role_names).issubset(allowed)
|
||||
|
||||
|
||||
def validate_branch_matches_tenant(db: Session, tenant_id: int, branch_id: int) -> Branch:
|
||||
branch = db.execute(
|
||||
select(Branch).where(
|
||||
Branch.id == branch_id,
|
||||
Branch.tenant_id == tenant_id,
|
||||
Branch.is_active.is_(True),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not branch:
|
||||
raise ScopeError("Selected branch does not belong to the selected tenant.")
|
||||
return branch
|
||||
|
||||
|
||||
def resolve_target_tenant_branch(
|
||||
db: Session,
|
||||
scope: UserScope,
|
||||
tenant_id: int | None,
|
||||
branch_id: int | None,
|
||||
) -> tuple[int, int]:
|
||||
if scope.is_system_admin:
|
||||
if tenant_id is None or branch_id is None:
|
||||
raise ScopeError("Tenant and branch are required.")
|
||||
validate_branch_matches_tenant(db, tenant_id, branch_id)
|
||||
return tenant_id, branch_id
|
||||
|
||||
effective_tenant_id = scope.actor.tenant_id
|
||||
effective_branch_id = branch_id
|
||||
|
||||
if tenant_id is not None and tenant_id != scope.actor.tenant_id:
|
||||
raise ScopeError("Cross-tenant user creation is not allowed.")
|
||||
|
||||
if effective_branch_id is None:
|
||||
raise ScopeError("Branch is required.")
|
||||
|
||||
validate_branch_matches_tenant(db, effective_tenant_id, effective_branch_id)
|
||||
return effective_tenant_id, effective_branch_id
|
||||
|
||||
|
||||
def ensure_manageable_existing_user(db: Session, scope: UserScope, target_user: User) -> None:
|
||||
if scope.is_system_admin:
|
||||
return
|
||||
if not scope.is_firm_admin:
|
||||
raise ScopeError("Only System Admin and Firm Admin can manage users.")
|
||||
if target_user.tenant_id != scope.actor.tenant_id:
|
||||
raise ScopeError("You cannot manage users of another tenant.")
|
||||
|
||||
target_roles = get_user_role_names(db, target_user.id)
|
||||
if target_roles and not can_manage_role_names(scope, target_roles):
|
||||
raise ScopeError("You cannot manage the selected user's role level.")
|
||||
|
||||
|
||||
def ensure_assignable_roles(db: Session, scope: UserScope, role_ids: list[int]) -> list[Role]:
|
||||
if not role_ids:
|
||||
return []
|
||||
roles = db.execute(
|
||||
select(Role).where(Role.id.in_(role_ids), Role.is_active.is_(True)).order_by(Role.name)
|
||||
).scalars().all()
|
||||
if len(roles) != len(set(role_ids)):
|
||||
raise ScopeError("One or more selected roles are invalid.")
|
||||
if not can_manage_role_names(scope, [r.name for r in roles]):
|
||||
raise ScopeError("You cannot assign one or more selected roles.")
|
||||
return roles
|
||||
|
||||
|
||||
def assert_can_manage_role_object(scope: UserScope, role: Role) -> None:
|
||||
if scope.is_system_admin:
|
||||
return
|
||||
raise ScopeError("Only System Admin can manage RBAC roles.")
|
||||
|
||||
|
||||
def scope_to_http(exc: ScopeError) -> HTTPException:
|
||||
return HTTPException(status_code=403, detail=str(exc))
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
"""Scope guard utilities for tenant/branch enforcement (v2.0.3.1)"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
class ScopeError(Exception):
|
||||
pass
|
||||
|
||||
def ensure_same_tenant(actor_tenant_id: int, target_tenant_id: int):
|
||||
if actor_tenant_id != target_tenant_id:
|
||||
raise ScopeError("Cross-tenant operation is not allowed")
|
||||
|
||||
def ensure_same_branch(actor_branch_id: int, target_branch_id: int):
|
||||
if actor_branch_id != target_branch_id:
|
||||
raise ScopeError("Cross-branch operation is not allowed")
|
||||
|
||||
def validate_branch_belongs_to_tenant(branch_tenant_id: int, tenant_id: int):
|
||||
if branch_tenant_id != tenant_id:
|
||||
raise ScopeError("Branch does not belong to selected tenant")
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import ceil
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import (
|
||||
UserScope,
|
||||
get_manageable_roles,
|
||||
get_user_role_names,
|
||||
list_visible_branches,
|
||||
list_visible_tenants,
|
||||
)
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageResult:
|
||||
items: list
|
||||
page: int
|
||||
per_page: int
|
||||
total: int
|
||||
pages: int
|
||||
|
||||
@property
|
||||
def has_prev(self) -> bool:
|
||||
return self.page > 1
|
||||
|
||||
@property
|
||||
def has_next(self) -> bool:
|
||||
return self.page < self.pages
|
||||
|
||||
|
||||
def paginate_list(items: list, page: int = 1, per_page: int = 10) -> PageResult:
|
||||
total = len(items)
|
||||
per_page = max(1, min(per_page, 100))
|
||||
pages = max(1, ceil(total / per_page)) if total else 1
|
||||
page = max(1, min(page, pages))
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
return PageResult(items=items[start:end], page=page, per_page=per_page, total=total, pages=pages)
|
||||
|
||||
|
||||
def search_scoped_users(db: Session, scope: UserScope, q: str | None = None) -> list[User]:
|
||||
stmt = select(User)
|
||||
if not scope.is_system_admin:
|
||||
stmt = stmt.where(User.tenant_id == scope.actor.tenant_id)
|
||||
if scope.branch_scoped:
|
||||
stmt = stmt.where(User.branch_id == scope.actor.branch_id)
|
||||
|
||||
query = (q or "").strip()
|
||||
if query:
|
||||
like = f"%{query}%"
|
||||
stmt = stmt.where(or_(User.email.ilike(like), User.full_name.ilike(like)))
|
||||
|
||||
return db.execute(stmt.order_by(User.full_name, User.email, User.id)).scalars().all()
|
||||
|
||||
|
||||
def build_user_listing_payload(
|
||||
db: Session,
|
||||
scope: UserScope,
|
||||
q: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
) -> dict:
|
||||
users = search_scoped_users(db, scope, q=q)
|
||||
paged = paginate_list(users, page=page, per_page=per_page)
|
||||
user_ids = [u.id for u in paged.items]
|
||||
roles = {user_id: get_user_role_names(db, user_id) for user_id in user_ids}
|
||||
tenants = {t.id: t for t in list_visible_tenants(db, scope)}
|
||||
|
||||
if scope.is_system_admin:
|
||||
branch_rows = db.execute(select(Branch).order_by(Branch.name)).scalars().all()
|
||||
else:
|
||||
branch_rows = list_visible_branches(db, scope, scope.actor.tenant_id)
|
||||
|
||||
branches = {b.id: b for b in branch_rows}
|
||||
|
||||
return {
|
||||
"users_page": paged,
|
||||
"users": paged.items,
|
||||
"user_roles": roles,
|
||||
"tenants": tenants,
|
||||
"branches": branches,
|
||||
"filters": {
|
||||
"q": (q or "").strip(),
|
||||
"per_page": paged.per_page,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_user_form_payload(
|
||||
db: Session,
|
||||
scope: UserScope,
|
||||
actor: User,
|
||||
user_obj: User | None = None,
|
||||
assigned_role_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
tenants = list_visible_tenants(db, scope)
|
||||
|
||||
if user_obj:
|
||||
selected_tenant_id = user_obj.tenant_id
|
||||
selected_branch_id = user_obj.branch_id
|
||||
else:
|
||||
if scope.is_system_admin:
|
||||
selected_tenant_id = actor.tenant_id or (tenants[0].id if tenants else None)
|
||||
else:
|
||||
selected_tenant_id = actor.tenant_id
|
||||
selected_branch_id = actor.branch_id if scope.branch_scoped else None
|
||||
|
||||
branches = list_visible_branches(db, scope, selected_tenant_id)
|
||||
|
||||
selected_tenant = next((t for t in tenants if t.id == selected_tenant_id), None)
|
||||
selected_branch = next((b for b in branches if b.id == selected_branch_id), None)
|
||||
|
||||
manageable_roles = get_manageable_roles(db, scope)
|
||||
|
||||
return {
|
||||
"user_obj": user_obj,
|
||||
"assigned_role_ids": assigned_role_ids or [],
|
||||
"roles": manageable_roles,
|
||||
"tenants": tenants,
|
||||
"branches": branches,
|
||||
"scope": scope,
|
||||
"selected_tenant_id": selected_tenant_id,
|
||||
"selected_branch_id": selected_branch_id,
|
||||
"selected_tenant_name": selected_tenant.name if selected_tenant else "",
|
||||
"selected_branch_name": selected_branch.name if selected_branch else "",
|
||||
"can_change_tenant": scope.is_system_admin,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-2xl rounded-2xl border bg-white p-6 shadow-soft">
|
||||
<h1 class="text-2xl font-semibold">Change Password</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Enter your current password, then confirm the password change using the OTP sent to your registered email.</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
{{ flash }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-slate-700">Current Password</span>
|
||||
<input type="password" name="current_password" class="w-full rounded-xl border px-3 py-2" required />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-slate-700">New Password</span>
|
||||
<input type="password" name="new_password" class="w-full rounded-xl border px-3 py-2" required />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-slate-700">Confirm New Password</span>
|
||||
<input type="password" name="confirm_password" class="w-full rounded-xl border px-3 py-2" required />
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white">Send OTP</button>
|
||||
<a href="/system-settings" class="rounded-xl border px-4 py-2 text-sm">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-2xl rounded-2xl border bg-white p-6 shadow-soft">
|
||||
<h1 class="text-2xl font-semibold">Confirm Password Change</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Enter the OTP sent to your registered email to complete the password change.</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
{{ flash }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-slate-700">OTP</span>
|
||||
<input type="text" name="otp" class="w-full rounded-xl border px-3 py-2" required />
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white">Confirm Change</button>
|
||||
<a href="/change-password" class="rounded-xl border px-4 py-2 text-sm">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-lg rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Forgot Password</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Enter your login email to receive password reset instructions by email.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/forgot-password" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Enter your login email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
Send Reset Instructions
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm">
|
||||
<a class="text-brand-700 underline" href="/login">Back to login</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="mx-auto max-w-2xl rounded-3xl bg-white p-6 shadow-soft"><h2 class="text-xl font-semibold">Invite Link Generated</h2><p class="mt-1 text-sm text-slate-500">The invite email has been attempted through the configured firm SMTP. You may also copy and share this link manually with {{ invited_user.full_name or invited_user.email }} if required.</p><div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4 text-sm text-brand-900 break-all">{{ invite_url }}</div><div class="mt-5"><a href="/system-settings/users" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to users</a></div></div>{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-md rounded-2xl bg-white border p-6">
|
||||
<h1 class="text-2xl font-semibold">OTP Verification</h1>
|
||||
<p class="text-slate-600 mt-1 text-sm">Enter the OTP sent to your registered email.</p>
|
||||
|
||||
<form method="post" class="mt-5 grid gap-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span class="text-sm text-slate-600">OTP Code</span>
|
||||
<input class="border rounded-xl px-3 py-2" name="otp" inputmode="numeric" required />
|
||||
</label>
|
||||
|
||||
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm mt-2" type="submit">Verify</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-lg rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Reset Password</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Enter the OTP sent to your registered email and set your new password.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/reset-password" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">OTP</label>
|
||||
<input
|
||||
type="text"
|
||||
name="otp"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Enter OTP"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Re-enter new password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm">
|
||||
<a class="text-brand-700 underline" href="/login">Back to login</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-lg rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Reset Password</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Enter your new password to complete the password reset request.
|
||||
</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="mt-4 rounded-2xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ flash }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/password-reset/accept" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="token" value="{{ token }}" />
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500">Use at least 8 characters with letters and numbers.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm">
|
||||
<a class="text-brand-700 underline" href="/login">Back to login</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Integer, String, ForeignKey, DateTime, Boolean, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
class RefreshToken(CommonBase):
|
||||
__tablename__ = "refresh_tokens"
|
||||
__table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
|
||||
token_hash: Mapped[str] = mapped_column(String(64), index=True) # sha256 hex
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
rotated_from_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.security.session_auth import require_login
|
||||
from app.modules.core.audit.service import write_audit_log
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import build_scope, scope_to_http, assert_can_manage_role_object
|
||||
from app.modules.core.rbac.models import Permission, Role, RolePermission
|
||||
|
||||
router = APIRouter(prefix="/rbac", tags=["rbac"])
|
||||
|
||||
|
||||
class RoleCreateRequest(BaseModel):
|
||||
name: str
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class PermissionCreateRequest(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class RolePermissionUpdateRequest(BaseModel):
|
||||
permission_ids: list[int] = []
|
||||
|
||||
|
||||
def _require_system_admin_scope(db: Session, current_user: User):
|
||||
scope = build_scope(db, current_user)
|
||||
if not scope.is_system_admin:
|
||||
raise HTTPException(status_code=403, detail="Only System Admin can access RBAC.")
|
||||
return scope
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
def list_roles(
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
_require_system_admin_scope(db, current_user)
|
||||
roles = db.execute(select(Role).order_by(Role.name)).scalars().all()
|
||||
return [{"id": r.id, "name": r.name, "is_active": r.is_active} for r in roles]
|
||||
|
||||
|
||||
@router.post("/roles")
|
||||
def create_role(
|
||||
payload: RoleCreateRequest,
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
_require_system_admin_scope(db, current_user)
|
||||
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Role name is required")
|
||||
|
||||
exists = db.execute(select(Role).where(Role.name == name)).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="Role already exists")
|
||||
|
||||
role = Role(name=name, is_active=payload.is_active)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="role.create.api",
|
||||
entity_type="role",
|
||||
actor=current_user,
|
||||
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 {"status": "ok", "id": role.id}
|
||||
|
||||
|
||||
@router.get("/permissions")
|
||||
def list_permissions(
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
_require_system_admin_scope(db, current_user)
|
||||
permissions = db.execute(select(Permission).order_by(Permission.code)).scalars().all()
|
||||
return [{"id": p.id, "code": p.code, "name": p.name, "is_active": p.is_active} for p in permissions]
|
||||
|
||||
|
||||
@router.post("/permissions")
|
||||
def create_permission(
|
||||
payload: PermissionCreateRequest,
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
_require_system_admin_scope(db, current_user)
|
||||
|
||||
code = payload.code.strip()
|
||||
if not code:
|
||||
raise HTTPException(status_code=400, detail="Permission code is required")
|
||||
|
||||
exists = db.execute(select(Permission).where(Permission.code == code)).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="Permission already exists")
|
||||
|
||||
permission = Permission(code=code, name=payload.name.strip(), is_active=payload.is_active)
|
||||
db.add(permission)
|
||||
db.commit()
|
||||
db.refresh(permission)
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="permission.create.api",
|
||||
entity_type="permission",
|
||||
actor=current_user,
|
||||
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 {"status": "ok", "id": permission.id}
|
||||
|
||||
|
||||
@router.get("/roles/{role_id}")
|
||||
def role_detail(
|
||||
role_id: int,
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
scope = _require_system_admin_scope(db, current_user)
|
||||
|
||||
role = db.execute(select(Role).where(Role.id == role_id)).scalar_one_or_none()
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
try:
|
||||
assert_can_manage_role_object(scope, role)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
permission_ids = db.execute(
|
||||
select(RolePermission.permission_id).where(RolePermission.role_id == role.id)
|
||||
).scalars().all()
|
||||
|
||||
return {
|
||||
"id": role.id,
|
||||
"name": role.name,
|
||||
"is_active": role.is_active,
|
||||
"permission_ids": list(permission_ids),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/roles/{role_id}/permissions")
|
||||
def update_role_permissions(
|
||||
role_id: int,
|
||||
payload: RolePermissionUpdateRequest,
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
_require_system_admin_scope(db, current_user)
|
||||
|
||||
role = db.execute(select(Role).where(Role.id == role_id)).scalar_one_or_none()
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="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 payload.permission_ids:
|
||||
db.add(RolePermission(role_id=role.id, permission_id=permission_id))
|
||||
db.commit()
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="role.permissions.update.api",
|
||||
entity_type="role",
|
||||
actor=current_user,
|
||||
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(payload.permission_ids),
|
||||
},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.security.session_auth import require_login
|
||||
from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole
|
||||
|
||||
|
||||
def get_user_roles(db: Session, user_id: int) -> list[str]:
|
||||
q = (
|
||||
select(Role.name)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == user_id)
|
||||
.order_by(Role.name)
|
||||
)
|
||||
return [name for (name,) in db.execute(q).all()]
|
||||
|
||||
|
||||
def get_user_permissions(db: Session, user_id: int) -> list[str]:
|
||||
q = (
|
||||
select(Permission.code)
|
||||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.join(Role, Role.id == RolePermission.role_id)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == user_id, Permission.is_active.is_(True), Role.is_active.is_(True))
|
||||
.distinct()
|
||||
.order_by(Permission.code)
|
||||
)
|
||||
return [code for (code,) in db.execute(q).all()]
|
||||
|
||||
|
||||
def user_has_role(db: Session, user_id: int, role_name: str) -> bool:
|
||||
q = (
|
||||
select(UserRole.id)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(UserRole.user_id == user_id, Role.name == role_name)
|
||||
)
|
||||
return db.execute(q).first() is not None
|
||||
|
||||
|
||||
def user_has_permission(db: Session, user_id: int, permission_code: str) -> bool:
|
||||
if user_has_role(db, user_id, "System Admin"):
|
||||
return True
|
||||
q = (
|
||||
select(Permission.id)
|
||||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.join(Role, Role.id == RolePermission.role_id)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == user_id, Permission.code == permission_code)
|
||||
)
|
||||
return db.execute(q).first() is not None
|
||||
|
||||
|
||||
def require_role(role_name: str):
|
||||
def _dep(user=Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
if user_has_role(db, user.id, role_name):
|
||||
return True
|
||||
raise HTTPException(status_code=403, detail=f"Missing role: {role_name}")
|
||||
|
||||
return _dep
|
||||
|
||||
|
||||
def require_permission(permission_code: str):
|
||||
def _dep(user=Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
if user_has_permission(db, user.id, permission_code):
|
||||
return True
|
||||
raise HTTPException(status_code=403, detail=f"Missing permission: {permission_code}")
|
||||
|
||||
return _dep
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
class Role(CommonBase):
|
||||
__tablename__ = "roles"
|
||||
__table_args__ = (UniqueConstraint("name", name="uq_role_name"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
class Permission(CommonBase):
|
||||
__tablename__ = "permissions"
|
||||
__table_args__ = (UniqueConstraint("code", name="uq_permission_code"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(150), index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), default="")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
class RolePermission(CommonBase):
|
||||
__tablename__ = "role_permissions"
|
||||
__table_args__ = (UniqueConstraint("role_id","permission_id", name="uq_role_perm"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), index=True)
|
||||
permission_id: Mapped[int] = mapped_column(ForeignKey("permissions.id", ondelete="CASCADE"), index=True)
|
||||
|
||||
class UserRole(CommonBase):
|
||||
__tablename__ = "user_roles"
|
||||
__table_args__ = (UniqueConstraint("user_id","role_id", name="uq_user_role"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), index=True)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from app.modules.core.rbac.permissions_registry import expand_permission_codes
|
||||
|
||||
|
||||
def has_permission(db: Session, user: User | None, permission_code: str) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
roles = set(get_user_roles(db, user.id))
|
||||
if "System Admin" in roles:
|
||||
return True
|
||||
granted = set(get_user_permissions(db, user.id))
|
||||
for code in expand_permission_codes(permission_code):
|
||||
if code in granted:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def require_permission(db: Session, user: User | None, permission_code: str) -> None:
|
||||
if not has_permission(db, user, permission_code):
|
||||
raise HTTPException(status_code=403, detail=f"Missing permission: {permission_code}")
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
PERMISSIONS = {
|
||||
"system.settings.view": "View System Settings",
|
||||
"system.settings.edit": "Edit System Settings",
|
||||
"system.settings.manage": "Manage System Settings",
|
||||
"users.view": "View Users",
|
||||
"users.manage": "Create and Edit Users",
|
||||
"rbac.view": "View Roles and Permissions",
|
||||
"rbac.manage": "Manage Roles and Permissions",
|
||||
"audit.view": "View Audit Logs",
|
||||
"alerts.view_self": "View Own Alerts",
|
||||
"alerts.manage": "Create and Manage Alerts",
|
||||
"users.invite": "Invite Users",
|
||||
"users.reset_password": "Reset User Passwords",
|
||||
"services.view": "View Services",
|
||||
"services.create": "Create Services",
|
||||
"services.edit": "Edit Services",
|
||||
"services.deactivate": "Deactivate Services",
|
||||
"services.cross_branch": "Manage Services Across Branches",
|
||||
"services.cross_tenant": "Manage Services Across Tenants",
|
||||
"services.catalogue.manage": "Manage System Service Catalogue",
|
||||
"services.selection.manage": "Enable or Disable Firm Services",
|
||||
"service_tasks.view": "View Service Task Templates",
|
||||
"service_tasks.create": "Create Service Task Templates",
|
||||
"service_tasks.edit": "Edit Service Task Templates",
|
||||
"service_tasks.deactivate": "Deactivate Service Task Templates",
|
||||
"clients.view": "View Clients",
|
||||
"clients.create": "Create Clients",
|
||||
"clients.import": "Import Clients from Excel",
|
||||
"clients.edit": "Edit Clients",
|
||||
"clients.deactivate": "Deactivate Clients",
|
||||
"clients.activate": "Activate Clients",
|
||||
"clients.archive": "Archive Clients",
|
||||
"clients.restore": "Restore Clients",
|
||||
"clients.assign_partner": "Assign Partner to Clients",
|
||||
"clients.cross_branch": "Manage Clients Across Branches",
|
||||
"clients.cross_tenant": "Manage Clients Across Tenants",
|
||||
"clients.export": "Export Clients",
|
||||
"clients.audit_log.view": "View Client Audit Logs",
|
||||
"clients.view.own_only": "View Only Own Clients",
|
||||
|
||||
"employees.dashboard.view": "View HR Dashboard and Reports",
|
||||
"employees.view": "View Employees",
|
||||
"employees.create": "Create Employees",
|
||||
"employees.edit": "Edit Employees",
|
||||
"employees.status": "Change Employee Status",
|
||||
"employees.cross_branch": "Manage Employees Across Branches",
|
||||
"employees.cross_tenant": "Manage Employees Across Tenants",
|
||||
"employees.ess.view": "View Employee Self Service Portal",
|
||||
"employees.ess.profile.edit": "Edit Own Employee Profile",
|
||||
"employees.work.view_self": "View Own Engagement Work Dashboard",
|
||||
"employees.work.manage": "Manage Employee Engagement Work Allocation",
|
||||
"employees.progress.view": "View Engagement Progress Dashboard",
|
||||
"employees.registration.request": "Request Employee Profile Linkage",
|
||||
"employees.registration.approve": "Approve Employee Registration Requests",
|
||||
"employees.attendance.punch": "Punch Employee Attendance",
|
||||
"employees.attendance.view_self": "View Own Attendance",
|
||||
"employees.attendance.view_all": "View Employee Attendance",
|
||||
"employees.attendance.approve": "Approve or Manage Employee Attendance",
|
||||
"employees.leave.apply": "Apply Employee Leave",
|
||||
"employees.leave.view_self": "View Own Leave",
|
||||
"employees.leave.view_all": "View Employee Leave Requests",
|
||||
"employees.leave.approve": "Approve Employee Leave Requests",
|
||||
"employees.leave_type.manage": "Manage Employee Leave Types",
|
||||
"employees.leave_balance.manage": "Manage Employee Leave Balances",
|
||||
"employees.document_type.manage": "Manage Employee Document Types",
|
||||
"employees.documents.delete": "Archive Employee Documents",
|
||||
"employees.documents.verify": "Verify Employee Documents",
|
||||
"employees.documents.manage": "Upload and Manage Employee Documents",
|
||||
"employees.documents.view_all": "View Employee Documents",
|
||||
"employees.documents.upload_self": "Upload Own Employee Documents",
|
||||
"employees.documents.view_self": "View Own Employee Documents",
|
||||
"employees.onboarding.view": "View Employee Onboarding",
|
||||
"employees.onboarding.manage": "Manage Employee Onboarding Checklist and Tasks",
|
||||
"employees.onboarding.approve": "Complete or Approve Employee Onboarding Tasks",
|
||||
"employees.offboarding.view": "View Employee Offboarding",
|
||||
"employees.offboarding.manage": "Manage Employee Offboarding",
|
||||
"employees.offboarding.approve": "Approve and Complete Employee Offboarding",
|
||||
"employees.offboarding.request_self": "Request Own Employee Offboarding",
|
||||
"employees.payroll.structure.manage": "Manage Employee Salary Structures",
|
||||
"employees.payroll.run": "Run Employee Payroll",
|
||||
"employees.payroll.view": "View Employee Payroll and Payslips",
|
||||
"employees.payroll.view_self": "View Own Payslips",
|
||||
"employees.payroll.payout": "Approve or Mark Payroll Paid",
|
||||
"employees.import": "Import Employee HR Data from Excel",
|
||||
"employees.import.employee": "Import Employees from Excel",
|
||||
"employees.import.leave_type": "Import Employee Leave Types from Excel",
|
||||
"employees.import.leave_balance": "Import Employee Leave Balances from Excel",
|
||||
"employees.import.salary_structure": "Import Employee Salary Structures from Excel",
|
||||
"consultants.view": "View Consultants",
|
||||
"consultants.manage": "Create and Edit Consultants",
|
||||
"consultants.link_clients": "Link Consultants to Clients",
|
||||
"consultants.cross_branch": "Manage Consultants Across Branches",
|
||||
"consultants.portal.view": "View Consultant Portal",
|
||||
"consultants.managed_clients.manage": "Manage Own Consultant Portal Clients",
|
||||
"consultants.workspace.manage": "Manage Own Consultant SaaS Workspace",
|
||||
"consultants.service_requests.manage": "Review Consultant Service Requests",
|
||||
"consultants.conversions.manage": "Approve Consultant Managed Client Conversions",
|
||||
|
||||
"billing.view": "View Billing Invoices",
|
||||
"billing.create": "Create Billing Invoices",
|
||||
"billing.edit": "Edit Billing Invoices",
|
||||
"billing.approve": "Approve Billing Invoices",
|
||||
"billing.post": "Post Billing Invoices",
|
||||
"billing.cancel": "Cancel Billing Invoices",
|
||||
"billing.payment.create": "Record Billing Payments",
|
||||
"billing.payment.view": "View Billing Payments",
|
||||
"billing.reports": "View Billing Reports",
|
||||
"billing.cross_branch": "Manage Billing Across Branches",
|
||||
"billing.cross_tenant": "Manage Billing Across Audit Firms",
|
||||
"billing.view_own": "View Own Client Billing Only",
|
||||
"billing_fee_structure.view": "View Billing Fee Structure",
|
||||
"billing_fee_structure.import": "Import Billing Fee Structure",
|
||||
"billing_fee_structure.edit": "Edit Billing Fee Structure",
|
||||
"billing_fee_structure.delete": "Delete Billing Fee Structure",
|
||||
"billing_invoice.generate": "Generate Billing Invoices",
|
||||
"billing_invoice.bulk_generate": "Bulk Generate Billing Invoices",
|
||||
|
||||
"platform_billing.view": "View Platform Billing",
|
||||
"platform_billing.create": "Create Platform Billing Accounts and Invoices",
|
||||
"platform_billing.edit": "Edit Platform Billing Records",
|
||||
"platform_billing.generate": "Generate Platform Billing Invoices",
|
||||
"platform_billing.post": "Post Platform Invoices",
|
||||
"platform_billing.cancel": "Cancel Platform Invoices",
|
||||
"platform_billing.payment.create": "Record Platform Billing Payments",
|
||||
"platform_billing.payment.view": "View Platform Billing Payments",
|
||||
"platform_billing.reports": "View Platform Billing Reports",
|
||||
"platform_plans.manage": "Manage Platform Billing Plans",
|
||||
"platform_subscriptions.manage": "Manage Platform Subscriptions",
|
||||
|
||||
# Marketplace / public lead permissions
|
||||
"marketplace_leads.view": "View Marketplace Leads",
|
||||
"marketplace_leads.create": "Create Marketplace Leads",
|
||||
"marketplace_leads.assign": "Assign Marketplace Leads to Audit Firms",
|
||||
"marketplace_leads.update": "Update Marketplace Lead Status",
|
||||
"marketplace_leads.convert": "Convert Marketplace Leads to Clients",
|
||||
"marketplace_leads.reports": "View Marketplace Lead Reports",
|
||||
"marketplace_leads.view_assigned": "View Assigned Marketplace Leads",
|
||||
|
||||
"documents.view": "View Engagement Documents",
|
||||
"documents.upload": "Upload Engagement Documents",
|
||||
"documents.download": "Download Engagement Documents",
|
||||
"documents.delete": "Archive Engagement Documents",
|
||||
"documents.audit.view": "View Document Access Logs",
|
||||
|
||||
"notice_cases.view": "View Notice and Case Management",
|
||||
"notice_cases.create": "Create Notices and Cases",
|
||||
"notice_cases.edit": "Edit Notices and Cases",
|
||||
"notice_cases.events.manage": "Manage Notice/Case Timeline Events",
|
||||
"notice_cases.hearings.manage": "Manage Notice/Case Hearings",
|
||||
"notice_cases.orders.manage": "Manage Notice/Case Orders",
|
||||
"notice_cases.documents.upload": "Upload Notice/Case Documents",
|
||||
"notice_cases.documents.download": "Download Notice/Case Documents",
|
||||
"notice_cases.documents.delete": "Archive Notice/Case Documents",
|
||||
"notice_cases.cross_branch": "Manage Notices/Cases Across Branches",
|
||||
"notice_cases.cross_tenant": "Manage Notices/Cases Across Tenants",
|
||||
"notice_cases.view.own_only": "View Only Own Assigned Notices/Cases",
|
||||
}
|
||||
|
||||
PERMISSION_ALIASES = {
|
||||
"system.settings.manage": ["system.settings.edit"],
|
||||
"system.settings.edit": ["system.settings.manage"],
|
||||
}
|
||||
|
||||
|
||||
def expand_permission_codes(code: str) -> list[str]:
|
||||
codes = [code]
|
||||
for alias in PERMISSION_ALIASES.get(code, []):
|
||||
if alias not in codes:
|
||||
codes.append(alias)
|
||||
return codes
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.scope import UserScope, get_manageable_roles
|
||||
from app.modules.core.rbac.models import Permission, Role, RolePermission
|
||||
from app.modules.core.iam.services import paginate_list
|
||||
|
||||
|
||||
def search_roles(db: Session, scope: UserScope, q: str | None = None) -> list[Role]:
|
||||
roles = db.execute(select(Role).order_by(Role.name)).scalars().all() if scope.is_system_admin else get_manageable_roles(db, scope)
|
||||
query = (q or '').strip().lower()
|
||||
if not query:
|
||||
return roles
|
||||
return [r for r in roles if query in (r.name or '').lower()]
|
||||
|
||||
|
||||
def permissions_by_role(db: Session, roles: list[Role]) -> dict[int, list[str]]:
|
||||
return {
|
||||
role.id: [code for (code,) in db.execute(
|
||||
select(Permission.code)
|
||||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.where(RolePermission.role_id == role.id)
|
||||
.order_by(Permission.code)
|
||||
).all()]
|
||||
for role in roles
|
||||
}
|
||||
|
||||
|
||||
def build_roles_payload(db: Session, scope: UserScope, q: str | None = None, page: int = 1, per_page: int = 10) -> dict:
|
||||
roles = search_roles(db, scope, q=q)
|
||||
paged = paginate_list(roles, page=page, per_page=per_page)
|
||||
return {
|
||||
'roles': paged.items,
|
||||
'roles_page': paged,
|
||||
'permissions_by_role': permissions_by_role(db, paged.items),
|
||||
'filters': {'q': (q or '').strip(), 'per_page': paged.per_page},
|
||||
}
|
||||
|
||||
|
||||
def search_permissions(db: Session, q: str | None = None) -> list[Permission]:
|
||||
permissions = db.execute(select(Permission).order_by(Permission.code)).scalars().all()
|
||||
query = (q or '').strip().lower()
|
||||
if not query:
|
||||
return permissions
|
||||
return [p for p in permissions if query in (p.code or '').lower() or query in (p.name or '').lower()]
|
||||
|
||||
|
||||
def build_permissions_payload(db: Session, q: str | None = None, page: int = 1, per_page: int = 15) -> dict:
|
||||
permissions = search_permissions(db, q=q)
|
||||
paged = paginate_list(permissions, page=page, per_page=per_page)
|
||||
return {'permissions': paged.items, 'permissions_page': paged, 'filters': {'q': (q or '').strip(), 'per_page': paged.per_page}}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% import "ui/templates/components/macros.html" as ui %}
|
||||
{% block content %}
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold">Permissions</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Searchable permission registry used by API guards and UI gate checks.</p>
|
||||
<div class="mt-6 overflow-hidden rounded-2xl border border-slate-200">
|
||||
{{ ui.search_bar('/system-settings/rbac/permissions', filters.q, filters.per_page) }}
|
||||
{% if permissions %}
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left font-semibold text-slate-600">Code</th><th class="px-4 py-3 text-left font-semibold text-slate-600">Name</th><th class="px-4 py-3 text-left font-semibold text-slate-600">Status</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">{% for permission in permissions %}<tr><td class="px-4 py-4 font-mono text-xs text-slate-700">{{ permission.code }}</td><td class="px-4 py-4 text-slate-700">{{ permission.name }}</td><td class="px-4 py-4">{{ ui.badge('Active','emerald') if permission.is_active else ui.badge('Inactive','rose') }}</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
{{ ui.pagination(permissions_page, '/system-settings/rbac/permissions', request.url.query) }}
|
||||
{% else %}<div class="p-6">{{ ui.empty_state('No permissions matched the current filter.') }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-soft"><h3 class="text-lg font-semibold">Create Permission</h3>{% if "rbac.manage" in current_user_permissions and "System Admin" in current_user_roles %}<form method="post" action="/system-settings/rbac/permissions/new" class="mt-5 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token }}" /><div><label class="mb-2 block text-sm font-medium text-slate-700">Permission Code</label><input type="text" name="code" class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm" placeholder="Ex: clients.manage" required /></div><div><label class="mb-2 block text-sm font-medium text-slate-700">Display Name</label><input type="text" name="name" class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm" placeholder="Ex: Manage Clients" required /></div><label class="inline-flex items-center gap-3 rounded-2xl border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700"><input type="checkbox" name="is_active" checked class="h-4 w-4 rounded border-slate-300" />Permission is active</label><button type="submit" class="w-full rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Create Permission</button></form>{% else %}<div class="mt-5">{{ ui.alert('Only System Admin can create permissions.', 'amber') }}</div>{% endif %}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="rounded-3xl bg-white p-6 shadow-soft">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Configure Role Permissions</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Role: <span class="font-medium text-slate-800">{{ role.name }}</span></p>
|
||||
</div>
|
||||
<a href="/system-settings/rbac/roles" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to roles</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mt-6 space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{% for permission in permissions %}
|
||||
<label class="flex items-start gap-3 rounded-2xl border border-slate-200 p-4 hover:border-brand-300 hover:bg-brand-50/50">
|
||||
<input type="checkbox" name="permission_ids" value="{{ permission.id }}" {% if permission.id in assigned_permission_ids %}checked{% endif %} class="mt-1 h-4 w-4 rounded border-slate-300" />
|
||||
<span>
|
||||
<span class="block font-mono text-xs text-brand-700">{{ permission.code }}</span>
|
||||
<span class="mt-1 block text-sm font-medium text-slate-800">{{ permission.name }}</span>
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end border-t border-slate-200 pt-5">
|
||||
{% if "rbac.manage" in current_user_permissions and "System Admin" in current_user_roles %}<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Save Role Permissions</button>{% else %}<span class="text-sm text-slate-500">View-only access</span>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% import "ui/templates/components/macros.html" as ui %}
|
||||
{% block content %}
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="rounded-3xl bg-white p-6 shadow-soft">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div><h2 class="text-xl font-semibold">Roles</h2><p class="mt-1 text-sm text-slate-500">Core RBAC matrix with search and pagination for safer maintenance.</p></div>
|
||||
<a href="/system-settings/rbac/permissions" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">View permissions</a>
|
||||
</div>
|
||||
<div class="mt-6 overflow-hidden rounded-2xl border border-slate-200">
|
||||
{{ ui.search_bar('/system-settings/rbac/roles', filters.q, filters.per_page) }}
|
||||
{% if roles %}
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left font-semibold text-slate-600">Role</th><th class="px-4 py-3 text-left font-semibold text-slate-600">Permissions</th><th class="px-4 py-3 text-right font-semibold text-slate-600">Action</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">{% for role in roles %}<tr><td class="px-4 py-4"><div class="font-medium text-slate-900">{{ role.name }}</div><div class="text-xs text-slate-500">{{ 'Active' if role.is_active else 'Inactive' }}</div></td><td class="px-4 py-4"><div class="flex flex-wrap gap-2">{% for code in permissions_by_role.get(role.id, []) %}{{ ui.badge(code) }}{% else %}<span class="text-slate-400">No permissions</span>{% endfor %}</div></td><td class="px-4 py-4 text-right"><a href="/system-settings/rbac/roles/{{ role.id }}" class="rounded-xl border border-slate-300 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50">{{ "Configure" if "rbac.manage" in current_user_permissions else "View" }}</a></td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
{{ ui.pagination(roles_page, '/system-settings/rbac/roles', request.url.query) }}
|
||||
{% else %}<div class="p-6">{{ ui.empty_state('No roles matched the current filter.') }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-soft"><h3 class="text-lg font-semibold">Create Role</h3>{% if "rbac.manage" in current_user_permissions and "System Admin" in current_user_roles %}<form method="post" action="/system-settings/rbac/roles/new" class="mt-5 space-y-4"><input type="hidden" name="csrf_token" value="{{ csrf_token }}" /><div><label class="mb-2 block text-sm font-medium text-slate-700">Role Name</label><input type="text" name="name" class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm" placeholder="Ex: ComplianceAdmin" required /></div><label class="inline-flex items-center gap-3 rounded-2xl border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700"><input type="checkbox" name="is_active" checked class="h-4 w-4 rounded border-slate-300" />Role is active</label><button type="submit" class="w-full rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Create Role</button></form>{% else %}<div class="mt-5">{{ ui.alert('Only System Admin can create roles.', 'amber') }}</div>{% endif %}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
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 _redirect_login():
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
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 RedirectResponse(url="/system-settings/rbac/roles", status_code=303)
|
||||
|
||||
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(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
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 RedirectResponse(url="/system-settings/rbac/roles", status_code=303)
|
||||
|
||||
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(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
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()
|
||||
@@ -0,0 +1,493 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.core.rbac.permissions_registry import expand_permission_codes
|
||||
|
||||
|
||||
def _has(permissions: list[str] | None, code: str) -> bool:
|
||||
granted = set(permissions or [])
|
||||
for candidate in expand_permission_codes(code):
|
||||
if candidate in granted:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _roles(role_names: list[str] | None) -> set[str]:
|
||||
return set(role_names or [])
|
||||
|
||||
|
||||
def is_system_admin(role_names: list[str] | None = None) -> bool:
|
||||
return "System Admin" in _roles(role_names)
|
||||
|
||||
|
||||
def is_firm_admin(role_names: list[str] | None = None) -> bool:
|
||||
return "Firm Admin" in _roles(role_names)
|
||||
|
||||
|
||||
def can_view_own_alerts(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "alerts.view_self") or bool(user)
|
||||
|
||||
|
||||
def can_manage_alerts(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "alerts.manage") and (is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names))
|
||||
|
||||
|
||||
def can_view_users(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "users.view")
|
||||
|
||||
|
||||
def can_manage_users(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "users.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_view_settings(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "system.settings.view")
|
||||
|
||||
|
||||
def can_edit_settings(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "system.settings.edit")
|
||||
|
||||
|
||||
def can_manage_settings(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names)
|
||||
|
||||
|
||||
def can_view_tenants(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names)
|
||||
|
||||
|
||||
def can_manage_tenants(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names)
|
||||
|
||||
|
||||
def can_view_branches(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) or is_firm_admin(role_names)
|
||||
|
||||
|
||||
def can_manage_branches(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) or is_firm_admin(role_names)
|
||||
|
||||
|
||||
def can_change_branch_tenant(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names)
|
||||
|
||||
|
||||
def can_view_rbac(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names)
|
||||
|
||||
|
||||
def can_manage_rbac(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names)
|
||||
|
||||
|
||||
def can_view_audit(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "audit.view") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_view_services(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "services.view")
|
||||
|
||||
|
||||
def can_manage_services(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "services.create") or _has(permissions, "services.edit")
|
||||
|
||||
|
||||
def can_manage_service_tasks(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "service_tasks.create") or _has(permissions, "service_tasks.edit")
|
||||
|
||||
|
||||
def can_import_services(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "services.import")
|
||||
|
||||
|
||||
def can_import_service_tasks(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "service_tasks.import")
|
||||
|
||||
|
||||
def can_switch_service_tenant(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) and _has(permissions, "services.cross_tenant")
|
||||
|
||||
|
||||
def can_switch_service_branch(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "services.cross_branch") or can_switch_service_tenant(
|
||||
user, permissions, role_names
|
||||
)
|
||||
|
||||
|
||||
def can_view_clients(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "clients.view")
|
||||
|
||||
|
||||
def can_manage_clients(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "clients.create") or _has(permissions, "clients.edit")
|
||||
|
||||
|
||||
def can_export_clients(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "clients.export")
|
||||
|
||||
|
||||
def can_switch_client_tenant(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) and _has(permissions, "clients.cross_tenant")
|
||||
|
||||
|
||||
def can_switch_client_branch(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "clients.cross_branch") or can_switch_client_tenant(
|
||||
user, permissions, role_names
|
||||
)
|
||||
|
||||
def can_view_consultants(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "consultants.view")
|
||||
|
||||
|
||||
def can_manage_consultants(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "consultants.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_link_consultant_clients(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "consultants.link_clients")
|
||||
|
||||
|
||||
def can_manage_consultant_service_requests(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "consultants.service_requests.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_manage_consultant_conversions(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "consultants.conversions.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_view_consultant_portal(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "consultants.portal.view") or "Consultant" in _roles(role_names)
|
||||
|
||||
|
||||
def can_manage_own_consultant_workspace(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return can_view_consultant_portal(user, permissions, role_names) and _has(permissions, "consultants.workspace.manage")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def can_view_employee_dashboard(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.dashboard.view") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
def can_view_employees(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.view")
|
||||
|
||||
|
||||
def can_manage_employees(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.create") or _has(permissions, "employees.edit")
|
||||
|
||||
|
||||
def can_change_employee_status(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.status")
|
||||
|
||||
|
||||
def can_switch_employee_tenant(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) and _has(permissions, "employees.cross_tenant")
|
||||
|
||||
|
||||
def can_switch_employee_branch(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.cross_branch") or can_switch_employee_tenant(user, permissions, role_names)
|
||||
|
||||
|
||||
|
||||
def can_view_employee_portal(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.ess.view") or bool({"System Admin", "Firm Admin", "Partner", "Branch Manager", "Staff"}.intersection(_roles(role_names)))
|
||||
|
||||
|
||||
def can_edit_own_employee_profile(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return can_view_employee_portal(user, permissions, role_names) and _has(permissions, "employees.ess.profile.edit")
|
||||
|
||||
|
||||
def can_view_own_employee_work(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.work.view_self") or can_view_employee_portal(user, permissions, role_names)
|
||||
|
||||
|
||||
def can_request_employee_registration(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.registration.request") or can_view_employee_portal(user, permissions, role_names)
|
||||
|
||||
|
||||
def can_approve_employee_registrations(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.registration.approve") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
|
||||
def can_punch_employee_attendance(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.attendance.punch") or can_view_employee_portal(user, permissions, role_names)
|
||||
|
||||
|
||||
def can_view_own_employee_attendance(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.attendance.view_self") or can_view_employee_portal(user, permissions, role_names)
|
||||
|
||||
|
||||
def can_view_all_employee_attendance(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.attendance.view_all") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_approve_employee_attendance(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.attendance.approve") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
|
||||
def can_apply_employee_leave(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.leave.apply") or can_view_employee_portal(user, permissions, role_names)
|
||||
|
||||
|
||||
def can_view_own_employee_leave(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.leave.view_self") or can_view_employee_portal(user, permissions, role_names)
|
||||
|
||||
|
||||
def can_view_all_employee_leave(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.leave.view_all") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_approve_employee_leave(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.leave.approve") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_manage_employee_leave_types(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.leave_type.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_manage_employee_leave_balances(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.leave_balance.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
|
||||
def can_view_own_employee_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.documents.view_self") or can_view_employee_portal(user, permissions, role_names)
|
||||
|
||||
|
||||
def can_upload_own_employee_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.documents.upload_self") or can_view_employee_portal(user, permissions, role_names)
|
||||
|
||||
|
||||
def can_view_all_employee_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.documents.view_all") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_manage_employee_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.documents.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_verify_employee_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.documents.verify") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_manage_employee_document_types(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.document_type.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
|
||||
def can_view_employee_onboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.onboarding.view") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_manage_employee_onboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.onboarding.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_approve_employee_onboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.onboarding.approve") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_view_employee_offboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.offboarding.view") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_manage_employee_offboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.offboarding.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_approve_employee_offboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.offboarding.approve") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_request_own_employee_offboarding(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.offboarding.request_self") or can_view_employee_portal(user, permissions, role_names)
|
||||
|
||||
|
||||
|
||||
|
||||
def can_import_employee_hr(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.import") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
def can_manage_employee_payroll_structures(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.payroll.structure.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_run_employee_payroll(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.payroll.run") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_view_employee_payroll(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.payroll.view") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_view_own_employee_payslips(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.payroll.view_self") or can_view_employee_portal(user, permissions, role_names)
|
||||
|
||||
|
||||
def can_approve_employee_payroll(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.payroll.payout") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
|
||||
def can_view_employee_progress(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.progress.view") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_manage_employee_work(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "employees.work.manage") and (
|
||||
is_system_admin(role_names) or is_firm_admin(role_names) or "Partner" in _roles(role_names) or "Branch Manager" in _roles(role_names)
|
||||
)
|
||||
|
||||
|
||||
def can_view_billing(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "billing.view")
|
||||
|
||||
|
||||
def can_create_billing(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "billing.create")
|
||||
|
||||
|
||||
def can_import_billing_fee_structure(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "billing_fee_structure.import")
|
||||
|
||||
|
||||
def can_view_billing_fee_structure(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "billing_fee_structure.view")
|
||||
|
||||
|
||||
def can_generate_billing_invoices(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "billing_invoice.generate") or _has(permissions, "billing_invoice.bulk_generate")
|
||||
|
||||
|
||||
def can_view_platform_billing(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) and _has(permissions, "platform_billing.view")
|
||||
|
||||
|
||||
def can_manage_platform_billing(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) and (_has(permissions, "platform_billing.create") or _has(permissions, "platform_billing.edit"))
|
||||
|
||||
|
||||
def can_generate_platform_billing(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) and _has(permissions, "platform_billing.generate")
|
||||
|
||||
|
||||
def can_manage_platform_plans(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) and _has(permissions, "platform_plans.manage")
|
||||
|
||||
|
||||
def can_manage_platform_subscriptions(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) and _has(permissions, "platform_subscriptions.manage")
|
||||
|
||||
|
||||
def can_view_marketplace_leads(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "marketplace_leads.view") or _has(permissions, "marketplace_leads.view_assigned")
|
||||
|
||||
|
||||
def can_create_marketplace_leads(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) and _has(permissions, "marketplace_leads.create")
|
||||
|
||||
|
||||
def can_assign_marketplace_leads(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return is_system_admin(role_names) and _has(permissions, "marketplace_leads.assign")
|
||||
|
||||
|
||||
def can_update_marketplace_leads(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "marketplace_leads.update")
|
||||
|
||||
|
||||
def can_convert_marketplace_leads(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "marketplace_leads.convert")
|
||||
|
||||
|
||||
|
||||
def can_view_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "documents.view")
|
||||
|
||||
|
||||
def can_upload_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "documents.upload")
|
||||
|
||||
|
||||
def can_download_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "documents.download")
|
||||
|
||||
|
||||
def can_delete_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "documents.delete") and (is_firm_admin(role_names) or "Partner" in _roles(role_names))
|
||||
|
||||
|
||||
def can_view_notice_cases(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "notice_cases.view")
|
||||
|
||||
def can_manage_notice_cases(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "notice_cases.create") or _has(permissions, "notice_cases.edit")
|
||||
|
||||
def can_upload_notice_case_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "notice_cases.documents.upload")
|
||||
|
||||
def can_download_notice_case_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "notice_cases.documents.download")
|
||||
|
||||
def can_delete_notice_case_documents(user=None, permissions: list[str] | None = None, role_names: list[str] | None = None) -> bool:
|
||||
return _has(permissions, "notice_cases.documents.delete")
|
||||
@@ -0,0 +1,15 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.modules.core.tenancy.models import Tenant, Branch
|
||||
|
||||
router = APIRouter(prefix="/tenancy", tags=["tenancy"])
|
||||
|
||||
@router.get("/tenants")
|
||||
def list_tenants(db: Session = Depends(get_common_db)):
|
||||
return db.execute(select(Tenant).order_by(Tenant.id)).scalars().all()
|
||||
|
||||
@router.get("/branches")
|
||||
def list_branches(db: Session = Depends(get_common_db)):
|
||||
return db.execute(select(Branch).order_by(Branch.id)).scalars().all()
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from sqlalchemy import String, Boolean, Integer, Time, Date, DateTime, ForeignKey, UniqueConstraint, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class Tenant(CommonBase):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
firm_type: Mapped[str] = mapped_column(String(30), default="proprietorship") # partnership|proprietorship|individual
|
||||
|
||||
# Tenant-level defaults
|
||||
default_timezone: Mapped[str] = mapped_column(String(64), default="Asia/Kolkata")
|
||||
default_session_duration_minutes: Mapped[int] = mapped_column(Integer, default=480)
|
||||
default_otp_required_roles_csv: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
default="Partner,System Admin",
|
||||
)
|
||||
default_storage_mode: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="local_only",
|
||||
) # local_only|cloud_only|hybrid
|
||||
|
||||
# Phase 7Q.2 - firm branding defaults
|
||||
display_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
logo_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
favicon_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
primary_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
accent_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
website_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
contact_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
|
||||
class Branch(CommonBase):
|
||||
__tablename__ = "branches"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_branch_tenant_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(50), index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Kolkata")
|
||||
office_start_time: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
office_end_time: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
|
||||
# Branch control flags
|
||||
allow_login: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
allow_new_assignments: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_head_office: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# SMTP (credentials here; policy in BranchSettings)
|
||||
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Local storage root for branch PC
|
||||
local_storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
class FinancialYear(CommonBase):
|
||||
__tablename__ = "financial_years"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "year_code", name="uq_financial_year_tenant_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
year_code: Mapped[str] = mapped_column(String(9), index=True) # e.g. 2025-26
|
||||
assessment_year: Mapped[str] = mapped_column(String(9), index=True) # e.g. 2026-27
|
||||
start_date: Mapped[date] = mapped_column(Date)
|
||||
end_date: Mapped[date] = mapped_column(Date)
|
||||
is_current: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
locked_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class YearBackupExport(CommonBase):
|
||||
__tablename__ = "year_backup_exports"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
financial_year_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("financial_years.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
year_code: Mapped[str] = mapped_column(String(9), index=True)
|
||||
assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True)
|
||||
export_status: Mapped[str] = mapped_column(String(30), default="completed", index=True)
|
||||
export_file_path: Mapped[str] = mapped_column(String(1000))
|
||||
file_size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
manifest_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
generated_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
generated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.scope import (
|
||||
UserScope,
|
||||
list_visible_branches,
|
||||
list_visible_tenants,
|
||||
)
|
||||
from app.modules.core.tenancy.models import Branch
|
||||
from app.modules.core.iam.services import paginate_list
|
||||
|
||||
|
||||
def build_tenants_payload(
|
||||
db: Session,
|
||||
scope: UserScope,
|
||||
q: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
) -> dict:
|
||||
tenants = list_visible_tenants(db, scope)
|
||||
query = (q or "").strip().lower()
|
||||
|
||||
if query:
|
||||
tenants = [
|
||||
t
|
||||
for t in tenants
|
||||
if query in (t.name or "").lower()
|
||||
or query in (t.code or "").lower()
|
||||
]
|
||||
|
||||
paged = paginate_list(tenants, page=page, per_page=per_page)
|
||||
|
||||
return {
|
||||
"tenants": paged.items,
|
||||
"tenants_page": paged,
|
||||
"filters": {
|
||||
"q": (q or "").strip(),
|
||||
"per_page": paged.per_page,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_branches_payload(
|
||||
db: Session,
|
||||
scope: UserScope,
|
||||
q: str | None = None,
|
||||
tenant_id: int | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
) -> dict:
|
||||
if scope.is_system_admin:
|
||||
effective_tenant_id = tenant_id
|
||||
|
||||
if effective_tenant_id is None:
|
||||
branches = db.execute(
|
||||
select(Branch).order_by(Branch.name)
|
||||
).scalars().all()
|
||||
else:
|
||||
branches = db.execute(
|
||||
select(Branch)
|
||||
.where(Branch.tenant_id == effective_tenant_id)
|
||||
.order_by(Branch.name)
|
||||
).scalars().all()
|
||||
else:
|
||||
effective_tenant_id = scope.actor.tenant_id
|
||||
branches = list_visible_branches(db, scope, effective_tenant_id)
|
||||
|
||||
query = (q or "").strip().lower()
|
||||
if query:
|
||||
branches = [
|
||||
b
|
||||
for b in branches
|
||||
if query in (b.name or "").lower()
|
||||
or query in (b.code or "").lower()
|
||||
or query in (b.timezone or "").lower()
|
||||
]
|
||||
|
||||
paged = paginate_list(branches, page=page, per_page=per_page)
|
||||
tenants = {t.id: t for t in list_visible_tenants(db, scope)}
|
||||
|
||||
return {
|
||||
"branches": paged.items,
|
||||
"branches_page": paged,
|
||||
"tenants": tenants,
|
||||
"filters": {
|
||||
"q": (q or "").strip(),
|
||||
"per_page": paged.per_page,
|
||||
"tenant_id": effective_tenant_id,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, UniqueConstraint, Text, Float, Time
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class BranchSettings(CommonBase):
|
||||
__tablename__ = "branch_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("branch_id", name="uq_branch_settings_branch"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
branch_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Identity
|
||||
address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
pin_code: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
pan: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
|
||||
letterhead_logo_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
letterhead_signature_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
letterhead_stamp_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Geolocation and attendance controls
|
||||
geo_address: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
attendance_geo_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
attendance_geo_radius_meters: Mapped[int] = mapped_column(Integer, default=100)
|
||||
attendance_ip_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
attendance_allowed_ip_csv: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Working calendar and attendance timing rules
|
||||
working_days_csv: Mapped[str] = mapped_column(String(50), default="MON,TUE,WED,THU,FRI,SAT")
|
||||
holidays_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
timezone_locked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
attendance_grace_minutes: Mapped[int] = mapped_column(Integer, default=10)
|
||||
attendance_half_day_after_time = mapped_column(Time, nullable=True)
|
||||
attendance_rule_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Email policy
|
||||
email_from_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
email_from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
email_reply_to: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
default_cc_csv: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
default_bcc_csv: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
email_signature_html: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Phase 7Q.2 - branding / billing presentation defaults
|
||||
invoice_footer_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
bank_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
bank_account_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
bank_account_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
bank_ifsc: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
upi_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Storage policy
|
||||
storage_mode: Mapped[str] = mapped_column(String(20), default="local_only")
|
||||
folder_template: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
default="{root}/Clients/{client_code}/{fy}/{service}/",
|
||||
)
|
||||
max_file_mb: Mapped[int] = mapped_column(Integer, default=25)
|
||||
allowed_ext_csv: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
default="pdf,jpg,jpeg,png,xlsx,xls,docx,zip",
|
||||
)
|
||||
retention_years: Mapped[int] = mapped_column(Integer, default=8)
|
||||
|
||||
# Security policy
|
||||
otp_required_roles_csv: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
default="Partner,System Admin",
|
||||
)
|
||||
session_duration_minutes: Mapped[int] = mapped_column(Integer, default=480)
|
||||
lockout_attempts: Mapped[int] = mapped_column(Integer, default=5)
|
||||
lockout_minutes: Mapped[int] = mapped_column(Integer, default=15)
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.modules.core.tenancy.models import FinancialYear
|
||||
|
||||
|
||||
def active_financial_year_from_request(request: Request) -> str | None:
|
||||
session = request.scope.get("session") or {}
|
||||
value = session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
||||
value = (value or "").strip()
|
||||
if not value or value.upper() == "ALL":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def get_financial_year(db: Any, *, tenant_id: int, year_code: str | None) -> FinancialYear | None:
|
||||
code = (year_code or "").strip()
|
||||
if not tenant_id or not code:
|
||||
return None
|
||||
return db.execute(
|
||||
select(FinancialYear).where(
|
||||
FinancialYear.tenant_id == int(tenant_id),
|
||||
FinancialYear.year_code == code,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def is_financial_year_locked(db: Any, *, tenant_id: int, year_code: str | None) -> bool:
|
||||
fy = get_financial_year(db, tenant_id=tenant_id, year_code=year_code)
|
||||
return bool(fy and fy.is_locked)
|
||||
|
||||
|
||||
def redirect_if_financial_year_locked(
|
||||
db: Any,
|
||||
*,
|
||||
tenant_id: int,
|
||||
year_code: str | None,
|
||||
redirect_url: str,
|
||||
) -> RedirectResponse | None:
|
||||
if is_financial_year_locked(db, tenant_id=tenant_id, year_code=year_code):
|
||||
separator = "&" if "?" in redirect_url else "?"
|
||||
return RedirectResponse(url=f"{redirect_url}{separator}year_locked=1", status_code=303)
|
||||
return None
|
||||
|
||||
|
||||
def is_row_financial_year_locked(db: Any, row: Any) -> bool:
|
||||
tenant_id = getattr(row, "tenant_id", None)
|
||||
year_code = getattr(row, "financial_year", None)
|
||||
if not tenant_id or not year_code:
|
||||
return False
|
||||
return is_financial_year_locked(db, tenant_id=int(tenant_id), year_code=str(year_code))
|
||||
Reference in New Issue
Block a user