Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
View File
+33
View File
@@ -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="{}")
+112
View File
@@ -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 %}
+77
View File
@@ -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()
View File
+363
View File
@@ -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")
+173
View File
@@ -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)}
+118
View File
@@ -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()
+75
View File
@@ -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
+45
View File
@@ -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)
+95
View File
@@ -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
+228
View File
@@ -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))
+19
View File
@@ -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")
+133
View File
@@ -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 %}
+18
View File
@@ -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 %}
+19
View File
@@ -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
View File
+195
View File
@@ -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"}
+72
View File
@@ -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
+33
View File
@@ -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)
+26
View File
@@ -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
+53
View File
@@ -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 %}
+267
View File
@@ -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()
+493
View File
@@ -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")
+15
View File
@@ -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()
+126
View File
@@ -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)
+92
View File
@@ -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)
+56
View File
@@ -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))