Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.core.security.session_auth import require_login
|
||||
from app.modules.core.audit.service import model_snapshot, pair_before_after, write_audit_log
|
||||
from app.modules.core.iam.lifecycle import activate_user, deactivate_user, disable_login, enable_login, ensure_manageable_lifecycle, lock_user, restore_user, soft_delete_user, unlock_user, LifecycleError
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import (
|
||||
build_scope,
|
||||
ensure_assignable_roles,
|
||||
ensure_manageable_existing_user,
|
||||
ensure_users_manage_scope,
|
||||
ensure_users_view_scope,
|
||||
list_scoped_users,
|
||||
resolve_target_tenant_branch,
|
||||
scope_to_http,
|
||||
)
|
||||
from app.modules.core.rbac.deps import require_permission
|
||||
from app.modules.core.rbac.models import UserRole
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
password: str
|
||||
tenant_id: int | None = None
|
||||
branch_id: int | None = None
|
||||
role_ids: list[int] = []
|
||||
is_active: bool = True
|
||||
allow_login: bool = True
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
full_name: str
|
||||
tenant_id: int | None = None
|
||||
branch_id: int | None = None
|
||||
role_ids: list[int] = []
|
||||
is_active: bool = True
|
||||
allow_login: bool = True
|
||||
password: str | None = None
|
||||
|
||||
|
||||
@router.get("", dependencies=[Depends(require_permission("users.view"))])
|
||||
def list_users(current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
try:
|
||||
ensure_users_view_scope(scope)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
users = list_scoped_users(db, scope)
|
||||
return [
|
||||
{
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"full_name": u.full_name,
|
||||
"tenant_id": u.tenant_id,
|
||||
"branch_id": u.branch_id,
|
||||
"is_active": u.is_active,
|
||||
"allow_login": getattr(u, "allow_login", True),
|
||||
"is_locked": getattr(u, "is_locked", False),
|
||||
"deleted_at": (u.deleted_at.isoformat() if getattr(u, "deleted_at", None) else None),
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def create_user(
|
||||
payload: UserCreateRequest,
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
scope = build_scope(db, current_user)
|
||||
try:
|
||||
ensure_users_manage_scope(scope)
|
||||
tenant_id, branch_id = resolve_target_tenant_branch(db, scope, payload.tenant_id, payload.branch_id)
|
||||
roles = ensure_assignable_roles(db, scope, payload.role_ids)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
email = payload.email.lower().strip()
|
||||
exists = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="Email already exists")
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
full_name=payload.full_name.strip(),
|
||||
password_hash=hash_password(payload.password),
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
is_active=payload.is_active,
|
||||
allow_login=payload.allow_login,
|
||||
is_locked=False,
|
||||
deleted_at=None,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
for role in roles:
|
||||
db.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="user.create.api",
|
||||
entity_type="user",
|
||||
actor=current_user,
|
||||
entity_id=user.id,
|
||||
entity_name=user.email,
|
||||
target_tenant_id=user.tenant_id,
|
||||
target_branch_id=user.branch_id,
|
||||
details={"after": model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"]), "role_ids": [role.id for role in roles]},
|
||||
)
|
||||
return {"status": "ok", "id": user.id}
|
||||
|
||||
|
||||
@router.get("/{user_id}", dependencies=[Depends(require_permission("users.view"))])
|
||||
def get_user(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
try:
|
||||
ensure_users_view_scope(scope)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_existing_user(db, scope, user)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
role_ids = db.execute(select(UserRole.role_id).where(UserRole.user_id == user.id)).scalars().all()
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"tenant_id": user.tenant_id,
|
||||
"branch_id": user.branch_id,
|
||||
"is_active": user.is_active,
|
||||
"allow_login": getattr(user, "allow_login", True),
|
||||
"is_locked": getattr(user, "is_locked", False),
|
||||
"deleted_at": (user.deleted_at.isoformat() if getattr(user, "deleted_at", None) else None),
|
||||
"role_ids": list(role_ids),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{user_id}", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def update_user(
|
||||
user_id: int,
|
||||
payload: UserUpdateRequest,
|
||||
current_user: User = Depends(require_login),
|
||||
db: Session = Depends(get_common_db),
|
||||
):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
try:
|
||||
ensure_users_manage_scope(scope)
|
||||
ensure_manageable_existing_user(db, scope, user)
|
||||
tenant_id, branch_id = resolve_target_tenant_branch(db, scope, payload.tenant_id, payload.branch_id)
|
||||
roles = ensure_assignable_roles(db, scope, payload.role_ids)
|
||||
except Exception as exc:
|
||||
raise scope_to_http(exc)
|
||||
|
||||
before_snapshot = model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])
|
||||
old_role_ids = list(db.execute(select(UserRole.role_id).where(UserRole.user_id == user.id)).scalars().all())
|
||||
|
||||
user.full_name = payload.full_name.strip()
|
||||
user.tenant_id = tenant_id
|
||||
user.branch_id = branch_id
|
||||
user.is_active = payload.is_active
|
||||
user.allow_login = payload.allow_login
|
||||
if payload.password:
|
||||
user.password_hash = hash_password(payload.password)
|
||||
|
||||
db.execute(UserRole.__table__.delete().where(UserRole.user_id == user.id))
|
||||
for role in roles:
|
||||
db.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action="user.update.api",
|
||||
entity_type="user",
|
||||
actor=current_user,
|
||||
entity_id=user.id,
|
||||
entity_name=user.email,
|
||||
target_tenant_id=user.tenant_id,
|
||||
target_branch_id=user.branch_id,
|
||||
details={**pair_before_after(before_snapshot, model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])), "old_role_ids": old_role_ids, "new_role_ids": [role.id for role in roles]},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
def _lifecycle_response(user: User) -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"user_id": user.id,
|
||||
"is_active": user.is_active,
|
||||
"allow_login": getattr(user, "allow_login", True),
|
||||
"is_locked": getattr(user, "is_locked", False),
|
||||
"deleted_at": (user.deleted_at.isoformat() if getattr(user, "deleted_at", None) else None),
|
||||
}
|
||||
|
||||
|
||||
def _apply_lifecycle_action(db: Session, current_user: User, user: User, action: str):
|
||||
before = model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])
|
||||
if action == "activate":
|
||||
activate_user(user)
|
||||
audit_action = "user.activate.api"
|
||||
elif action == "deactivate":
|
||||
deactivate_user(user)
|
||||
audit_action = "user.deactivate.api"
|
||||
elif action == "enable-login":
|
||||
enable_login(user)
|
||||
audit_action = "user.enable_login.api"
|
||||
elif action == "disable-login":
|
||||
disable_login(user)
|
||||
audit_action = "user.disable_login.api"
|
||||
elif action == "lock":
|
||||
lock_user(user)
|
||||
audit_action = "user.lock.api"
|
||||
elif action == "unlock":
|
||||
unlock_user(user)
|
||||
audit_action = "user.unlock.api"
|
||||
elif action == "delete":
|
||||
soft_delete_user(user)
|
||||
audit_action = "user.soft_delete.api"
|
||||
elif action == "restore":
|
||||
restore_user(user)
|
||||
audit_action = "user.restore.api"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unknown action")
|
||||
db.commit()
|
||||
write_audit_log(
|
||||
db,
|
||||
action=audit_action,
|
||||
entity_type="user",
|
||||
actor=current_user,
|
||||
entity_id=user.id,
|
||||
entity_name=user.email,
|
||||
target_tenant_id=user.tenant_id,
|
||||
target_branch_id=user.branch_id,
|
||||
details=pair_before_after(before, model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])),
|
||||
)
|
||||
return _lifecycle_response(user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/activate", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def activate_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "activate")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "activate")
|
||||
|
||||
|
||||
@router.post("/{user_id}/deactivate", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def deactivate_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "deactivate")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "deactivate")
|
||||
|
||||
|
||||
@router.post("/{user_id}/enable-login", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def enable_login_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "enable login for")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "enable-login")
|
||||
|
||||
|
||||
@router.post("/{user_id}/disable-login", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def disable_login_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "disable login for")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "disable-login")
|
||||
|
||||
|
||||
@router.post("/{user_id}/lock", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def lock_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "lock")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "lock")
|
||||
|
||||
|
||||
@router.post("/{user_id}/unlock", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def unlock_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "unlock")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "unlock")
|
||||
|
||||
|
||||
@router.post("/{user_id}/delete", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def delete_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "delete")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "delete")
|
||||
|
||||
|
||||
@router.post("/{user_id}/restore", dependencies=[Depends(require_permission("users.manage"))])
|
||||
def restore_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
|
||||
scope = build_scope(db, current_user)
|
||||
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
try:
|
||||
ensure_manageable_lifecycle(scope, db, current_user, user, "restore")
|
||||
except (Exception, LifecycleError) as exc:
|
||||
raise scope_to_http(exc)
|
||||
return _apply_lifecycle_action(db, current_user, user, "restore")
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, timezone
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.core.settings import get_settings
|
||||
from app.core.security.passwords import verify_password
|
||||
from app.core.security.jwt_tokens import encode_access_token, decode_token, utcnow
|
||||
from app.modules.core.audit.service import write_audit_log
|
||||
from app.modules.core.iam.invite_service import accept_invite, issue_password_reset_token, reset_password_with_token
|
||||
from app.modules.email_integration.services import send_password_reset_link_email, send_password_changed_email
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.tokens_models import RefreshToken
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
class AcceptInviteRequest(BaseModel):
|
||||
token: str
|
||||
password: str
|
||||
|
||||
def _hash_refresh(rt: str) -> str:
|
||||
return hashlib.sha256(rt.encode("utf-8")).hexdigest()
|
||||
|
||||
def _roles(db: Session, user_id: int) -> list[str]:
|
||||
q = select(Role.name).join(UserRole, UserRole.role_id == Role.id).where(UserRole.user_id == user_id)
|
||||
return [r for (r,) in db.execute(q).all()]
|
||||
|
||||
def _issue_tokens(db: Session, user: User) -> TokenResponse:
|
||||
s = get_settings()
|
||||
roles = _roles(db, user.id)
|
||||
payload = {
|
||||
"sub": str(user.id),
|
||||
"email": user.email,
|
||||
"tenant_id": user.tenant_id,
|
||||
"branch_id": user.branch_id,
|
||||
"roles": roles,
|
||||
}
|
||||
access = encode_access_token(payload, expires_minutes=s.JWT_ACCESS_MINUTES)
|
||||
|
||||
refresh_plain = secrets.token_urlsafe(48)
|
||||
now = utcnow()
|
||||
exp = now + timedelta(days=s.JWT_REFRESH_DAYS)
|
||||
|
||||
rt = RefreshToken(
|
||||
user_id=user.id,
|
||||
token_hash=_hash_refresh(refresh_plain),
|
||||
created_at_utc=now,
|
||||
expires_at_utc=exp,
|
||||
revoked=False,
|
||||
rotated_from_id=None,
|
||||
)
|
||||
db.add(rt)
|
||||
db.commit()
|
||||
|
||||
return TokenResponse(access_token=access, expires_in=s.JWT_ACCESS_MINUTES * 60, refresh_token=refresh_plain)
|
||||
|
||||
@router.post("/token", response_model=TokenResponse)
|
||||
def token(req: TokenRequest, db: Session = Depends(get_common_db)):
|
||||
user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none()
|
||||
if (not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None or not verify_password(req.password, user.password_hash)):
|
||||
write_audit_log(db, action="auth.token.failed", entity_type="api_session", actor=user, actor_email=req.email.lower().strip(), status="error", target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None), details={"reason": "invalid credentials"})
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
if getattr(user, "must_change_password", False):
|
||||
raise HTTPException(status_code=403, detail="Password setup/change required before API login")
|
||||
token_response = _issue_tokens(db, user)
|
||||
write_audit_log(db, action="auth.token.success", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
return token_response
|
||||
|
||||
@router.post('/forgot-password')
|
||||
def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_common_db)):
|
||||
user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none()
|
||||
if user and user.is_active and getattr(user, "deleted_at", None) is None:
|
||||
reset_token = issue_password_reset_token(db, user)
|
||||
try:
|
||||
send_password_reset_link_email(db, user=user, reset_token=reset_token)
|
||||
except Exception as exc:
|
||||
write_audit_log(db, action="auth.password_reset.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)})
|
||||
write_audit_log(db, action="auth.password_reset.requested", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post('/reset-password')
|
||||
def reset_password(req: ResetPasswordRequest, db: Session = Depends(get_common_db)):
|
||||
try:
|
||||
user = reset_password_with_token(db, req.token, req.new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
||||
try:
|
||||
send_password_changed_email(db, user=user)
|
||||
except Exception as exc:
|
||||
write_audit_log(db, action="auth.password_changed.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)})
|
||||
write_audit_log(db, action="auth.password_reset.completed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post('/invite/accept')
|
||||
def invite_accept(req: AcceptInviteRequest, db: Session = Depends(get_common_db)):
|
||||
try:
|
||||
user = accept_invite(db, req.token, req.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired invite token")
|
||||
write_audit_log(db, action="auth.invite.accepted", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh(req: RefreshRequest, db: Session = Depends(get_common_db)):
|
||||
h = _hash_refresh(req.refresh_token)
|
||||
rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none()
|
||||
if not rt or rt.revoked:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
now = utcnow()
|
||||
if rt.expires_at_utc.replace(tzinfo=timezone.utc) < now:
|
||||
raise HTTPException(status_code=401, detail="Refresh token expired")
|
||||
|
||||
user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none()
|
||||
if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None:
|
||||
raise HTTPException(status_code=401, detail="User inactive")
|
||||
|
||||
rt.revoked = True
|
||||
db.commit()
|
||||
token_response = _issue_tokens(db, user)
|
||||
write_audit_log(db, action="auth.token.refresh", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
|
||||
return token_response
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(req: LogoutRequest, db: Session = Depends(get_common_db)):
|
||||
h = _hash_refresh(req.refresh_token)
|
||||
rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none()
|
||||
if rt:
|
||||
rt.revoked = True
|
||||
db.commit()
|
||||
user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none()
|
||||
write_audit_log(db, action="auth.token.logout", entity_type="api_session", actor=user, entity_name=(user.email if user else None), target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None))
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/me")
|
||||
def me(token: str):
|
||||
return {"token": decode_token(token)}
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, timezone
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security.jwt_tokens import utcnow
|
||||
from app.core.security.passwords import hash_password
|
||||
from app.core.settings import get_settings
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.password_flows_models import InviteToken, PasswordResetToken
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def validate_password_policy(password: str) -> str | None:
|
||||
s = get_settings()
|
||||
if len(password or "") < s.PASSWORD_MIN_LENGTH:
|
||||
return f"Password must be at least {s.PASSWORD_MIN_LENGTH} characters long."
|
||||
if not re.search(r"[A-Za-z]", password or ""):
|
||||
return "Password must include at least one letter."
|
||||
if not re.search(r"\d", password or ""):
|
||||
return "Password must include at least one number."
|
||||
return None
|
||||
|
||||
|
||||
def issue_invite_token(db: Session, user: User) -> str:
|
||||
plain = secrets.token_urlsafe(32)
|
||||
now = utcnow()
|
||||
token = InviteToken(
|
||||
user_id=user.id,
|
||||
token_hash=_hash_token(plain),
|
||||
created_at_utc=now,
|
||||
expires_at_utc=now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS),
|
||||
used_at_utc=None,
|
||||
)
|
||||
db.add(token)
|
||||
user.must_change_password = True
|
||||
db.commit()
|
||||
return plain
|
||||
|
||||
|
||||
def issue_password_reset_token(db: Session, user: User) -> str:
|
||||
plain = secrets.token_urlsafe(32)
|
||||
now = utcnow()
|
||||
token = PasswordResetToken(
|
||||
user_id=user.id,
|
||||
token_hash=_hash_token(plain),
|
||||
created_at_utc=now,
|
||||
expires_at_utc=now + timedelta(hours=get_settings().PASSWORD_RESET_HOURS),
|
||||
used_at_utc=None,
|
||||
)
|
||||
db.add(token)
|
||||
db.commit()
|
||||
return plain
|
||||
|
||||
|
||||
def _validate_unused(record) -> bool:
|
||||
if not record or record.used_at_utc is not None:
|
||||
return False
|
||||
now = utcnow()
|
||||
exp = record.expires_at_utc
|
||||
if getattr(exp, "tzinfo", None) is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
return exp >= now
|
||||
|
||||
|
||||
def accept_invite(db: Session, token: str, password: str) -> User | None:
|
||||
err = validate_password_policy(password)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
record = db.execute(select(InviteToken).where(InviteToken.token_hash == _hash_token(token))).scalar_one_or_none()
|
||||
if not _validate_unused(record):
|
||||
return None
|
||||
user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
user.password_hash = hash_password(password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
|
||||
user.allow_login = True
|
||||
user.is_active = True
|
||||
record.used_at_utc = utcnow().replace(tzinfo=None)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def reset_password_with_token(db: Session, token: str, password: str) -> User | None:
|
||||
err = validate_password_policy(password)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
record = db.execute(select(PasswordResetToken).where(PasswordResetToken.token_hash == _hash_token(token))).scalar_one_or_none()
|
||||
if not _validate_unused(record):
|
||||
return None
|
||||
user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
user.password_hash = hash_password(password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
|
||||
record.used_at_utc = utcnow().replace(tzinfo=None)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def force_change_password(db: Session, user: User, new_password: str) -> None:
|
||||
err = validate_password_policy(new_password)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
|
||||
db.commit()
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import UserScope, ensure_manageable_existing_user
|
||||
|
||||
|
||||
class LifecycleError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def utcnow_naive() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def ensure_not_self(actor: User, target: User, action_label: str) -> None:
|
||||
if actor.id == target.id:
|
||||
raise LifecycleError(f"You cannot {action_label} your own account.")
|
||||
|
||||
|
||||
def ensure_not_bootstrap_admin(target: User) -> None:
|
||||
if (target.email or '').strip().lower() == 'admin@auditfirm.local':
|
||||
raise LifecycleError('Bootstrap system admin cannot be modified by this action.')
|
||||
|
||||
|
||||
def ensure_manageable_lifecycle(scope: UserScope, db: Session, actor: User, target: User, action_label: str) -> None:
|
||||
ensure_manageable_existing_user(db, scope, target)
|
||||
ensure_not_self(actor, target, action_label)
|
||||
|
||||
|
||||
def activate_user(user: User) -> None:
|
||||
user.is_active = True
|
||||
if user.deleted_at is not None:
|
||||
user.deleted_at = None
|
||||
|
||||
|
||||
def deactivate_user(user: User) -> None:
|
||||
user.is_active = False
|
||||
|
||||
|
||||
def enable_login(user: User) -> None:
|
||||
user.allow_login = True
|
||||
|
||||
|
||||
def disable_login(user: User) -> None:
|
||||
user.allow_login = False
|
||||
|
||||
|
||||
def lock_user(user: User) -> None:
|
||||
user.is_locked = True
|
||||
user.locked_at_utc = utcnow_naive()
|
||||
|
||||
|
||||
def unlock_user(user: User) -> None:
|
||||
user.is_locked = False
|
||||
user.locked_at_utc = None
|
||||
|
||||
|
||||
def soft_delete_user(user: User) -> None:
|
||||
user.deleted_at = utcnow_naive()
|
||||
user.is_active = False
|
||||
user.allow_login = False
|
||||
user.is_locked = True
|
||||
if user.locked_at_utc is None:
|
||||
user.locked_at_utc = utcnow_naive()
|
||||
|
||||
|
||||
def restore_user(user: User) -> None:
|
||||
user.deleted_at = None
|
||||
user.is_active = True
|
||||
user.allow_login = True
|
||||
user.is_locked = False
|
||||
user.locked_at_utc = None
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, UniqueConstraint, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
class User(CommonBase):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (UniqueConstraint("email", name="uq_user_email"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), index=True)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
allow_login: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
password_changed_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Phase 7Q.3 - common user profile/personalisation fields.
|
||||
# Employee/consultant/client master records remain the source for official data;
|
||||
# these fields are used for display, dashboards, client-facing contact cards and branding.
|
||||
profile_photo_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
qualification: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
designation: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
signature_image_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
class LoginAttempt(CommonBase):
|
||||
__tablename__ = "login_attempts"
|
||||
__table_args__ = (UniqueConstraint("key", name="uq_login_attempt_key"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(255), index=True) # email|ip
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_until_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
updated_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Integer, String, ForeignKey, DateTime, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
class InviteToken(CommonBase):
|
||||
__tablename__ = "invite_tokens"
|
||||
__table_args__ = (UniqueConstraint("token_hash", name="uq_invite_token_hash"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
used_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
class PasswordResetToken(CommonBase):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
__table_args__ = (UniqueConstraint("token_hash", name="uq_password_reset_token_hash"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
used_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import imghdr
|
||||
import re
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
|
||||
STATIC_ROOT = Path("app/ui/static")
|
||||
PROFILE_UPLOAD_DIR = STATIC_ROOT / "uploads" / "user_profiles"
|
||||
MAX_IMAGE_BYTES = 2 * 1024 * 1024
|
||||
ALLOWED_IMAGE_TYPES = {"jpeg": ".jpg", "png": ".png", "gif": ".gif", "webp": ".webp"}
|
||||
|
||||
|
||||
def _blank_to_none(value: object) -> str | None:
|
||||
value = (str(value).strip() if value is not None else "")
|
||||
return value or None
|
||||
|
||||
|
||||
def _safe_static_path(path: str | None) -> str | None:
|
||||
path = (path or "").strip()
|
||||
if not path:
|
||||
return None
|
||||
if path.startswith("/static/"):
|
||||
return path
|
||||
if path.startswith("app/ui/static/"):
|
||||
return "/static/" + path.split("app/ui/static/", 1)[1].replace("\\", "/")
|
||||
return path
|
||||
|
||||
|
||||
def profile_photo_url(user: User | None) -> str | None:
|
||||
if not user:
|
||||
return None
|
||||
return _safe_static_path(getattr(user, "profile_photo_path", None))
|
||||
|
||||
|
||||
def user_initials(user: User | None) -> str:
|
||||
if not user:
|
||||
return "U"
|
||||
name = (getattr(user, "full_name", None) or getattr(user, "email", "") or "User").strip()
|
||||
if "@" in name and not getattr(user, "full_name", None):
|
||||
name = name.split("@", 1)[0]
|
||||
parts = [p for p in re.split(r"\s+", name) if p]
|
||||
if not parts:
|
||||
return "U"
|
||||
if len(parts) == 1:
|
||||
return parts[0][:2].upper()
|
||||
return (parts[0][0] + parts[-1][0]).upper()
|
||||
|
||||
|
||||
async def save_user_profile_photo(user: User, upload: UploadFile | None) -> str | None:
|
||||
"""Persist a profile photo and return a static path, or current path if nothing uploaded."""
|
||||
if not upload or not getattr(upload, "filename", None):
|
||||
return getattr(user, "profile_photo_path", None)
|
||||
|
||||
raw = await upload.read()
|
||||
if not raw:
|
||||
return getattr(user, "profile_photo_path", None)
|
||||
if len(raw) > MAX_IMAGE_BYTES:
|
||||
raise HTTPException(status_code=400, detail="Profile photo must be 2 MB or smaller.")
|
||||
|
||||
detected = imghdr.what(None, raw)
|
||||
suffix = ALLOWED_IMAGE_TYPES.get(detected or "")
|
||||
if not suffix:
|
||||
raise HTTPException(status_code=400, detail="Upload a valid JPG, PNG, GIF or WebP profile photo.")
|
||||
|
||||
PROFILE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"user_{user.id}_{uuid4().hex}{suffix}"
|
||||
target = PROFILE_UPLOAD_DIR / filename
|
||||
target.write_bytes(raw)
|
||||
return f"app/ui/static/uploads/user_profiles/{filename}"
|
||||
|
||||
|
||||
def update_user_public_profile(
|
||||
db: Session,
|
||||
user: User,
|
||||
*,
|
||||
qualification: object = None,
|
||||
designation: object = None,
|
||||
mobile: object = None,
|
||||
bio: object = None,
|
||||
profile_photo_path: str | None = None,
|
||||
) -> User:
|
||||
user.qualification = _blank_to_none(qualification)
|
||||
user.designation = _blank_to_none(designation)
|
||||
user.mobile = _blank_to_none(mobile)
|
||||
user.bio = _blank_to_none(bio)
|
||||
if profile_photo_path is not None:
|
||||
user.profile_photo_path = profile_photo_path
|
||||
db.add(user)
|
||||
return user
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
ROLE_SYSTEM_ADMIN = "System Admin"
|
||||
ROLE_FIRM_ADMIN = "Firm Admin"
|
||||
ROLE_PARTNER = "Partner"
|
||||
ROLE_BRANCH_MANAGER = "Branch Manager"
|
||||
ROLE_STAFF = "Staff"
|
||||
ROLE_CLIENT = "Client"
|
||||
ROLE_CONSULTANT = "Consultant"
|
||||
|
||||
# Matrix: only System Admin and Firm Admin manage users.
|
||||
MANAGEABLE_ROLES_BY_ACTOR = {
|
||||
ROLE_SYSTEM_ADMIN: {
|
||||
ROLE_SYSTEM_ADMIN,
|
||||
ROLE_FIRM_ADMIN,
|
||||
ROLE_PARTNER,
|
||||
ROLE_BRANCH_MANAGER,
|
||||
ROLE_STAFF,
|
||||
ROLE_CLIENT,
|
||||
ROLE_CONSULTANT,
|
||||
},
|
||||
ROLE_FIRM_ADMIN: {
|
||||
ROLE_PARTNER,
|
||||
ROLE_BRANCH_MANAGER,
|
||||
ROLE_STAFF,
|
||||
ROLE_CLIENT,
|
||||
ROLE_CONSULTANT,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserScope:
|
||||
actor: User
|
||||
role_names: list[str]
|
||||
is_system_admin: bool
|
||||
is_firm_admin: bool
|
||||
is_partner: bool
|
||||
is_branch_manager: bool
|
||||
|
||||
@property
|
||||
def tenant_scoped(self) -> bool:
|
||||
return self.is_firm_admin or self.is_partner or self.is_branch_manager
|
||||
|
||||
@property
|
||||
def branch_scoped(self) -> bool:
|
||||
return self.is_branch_manager
|
||||
|
||||
|
||||
class ScopeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_role_names(db: Session, user_id: int) -> list[str]:
|
||||
q = (
|
||||
select(Role.name)
|
||||
.join(UserRole, UserRole.role_id == Role.id)
|
||||
.where(UserRole.user_id == user_id, Role.is_active.is_(True))
|
||||
.order_by(Role.name)
|
||||
)
|
||||
return [name for (name,) in db.execute(q).all()]
|
||||
|
||||
|
||||
def build_scope(db: Session, actor: User) -> UserScope:
|
||||
role_names = get_role_names(db, actor.id)
|
||||
return UserScope(
|
||||
actor=actor,
|
||||
role_names=role_names,
|
||||
is_system_admin=ROLE_SYSTEM_ADMIN in role_names,
|
||||
is_firm_admin=ROLE_FIRM_ADMIN in role_names,
|
||||
is_partner=ROLE_PARTNER in role_names,
|
||||
is_branch_manager=ROLE_BRANCH_MANAGER in role_names,
|
||||
)
|
||||
|
||||
|
||||
def ensure_users_view_scope(scope: UserScope) -> None:
|
||||
if scope.is_system_admin or scope.is_firm_admin or scope.is_partner or scope.is_branch_manager:
|
||||
return
|
||||
raise ScopeError("You are not allowed to view users.")
|
||||
|
||||
|
||||
def ensure_users_manage_scope(scope: UserScope) -> None:
|
||||
if scope.is_system_admin or scope.is_firm_admin:
|
||||
return
|
||||
raise ScopeError("Only System Admin and Firm Admin can manage users.")
|
||||
|
||||
|
||||
def list_visible_tenants(db: Session, scope: UserScope) -> list[Tenant]:
|
||||
if scope.is_system_admin:
|
||||
return db.execute(
|
||||
select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name)
|
||||
).scalars().all()
|
||||
tenant = db.execute(select(Tenant).where(Tenant.id == scope.actor.tenant_id)).scalar_one_or_none()
|
||||
return [tenant] if tenant else []
|
||||
|
||||
|
||||
def list_visible_branches(db: Session, scope: UserScope, tenant_id: int | None = None) -> list[Branch]:
|
||||
effective_tenant_id = tenant_id or scope.actor.tenant_id
|
||||
q = select(Branch).where(Branch.is_active.is_(True), Branch.tenant_id == effective_tenant_id)
|
||||
if scope.branch_scoped:
|
||||
q = q.where(Branch.id == scope.actor.branch_id)
|
||||
return db.execute(q.order_by(Branch.name)).scalars().all()
|
||||
|
||||
|
||||
def list_scoped_users(db: Session, scope: UserScope) -> list[User]:
|
||||
q = select(User)
|
||||
if not scope.is_system_admin:
|
||||
q = q.where(User.tenant_id == scope.actor.tenant_id)
|
||||
if scope.branch_scoped:
|
||||
q = q.where(User.branch_id == scope.actor.branch_id)
|
||||
return db.execute(q.order_by(User.id)).scalars().all()
|
||||
|
||||
|
||||
def get_manageable_roles(db: Session, scope: UserScope) -> list[Role]:
|
||||
if scope.is_system_admin:
|
||||
return db.execute(
|
||||
select(Role).where(Role.is_active.is_(True)).order_by(Role.name)
|
||||
).scalars().all()
|
||||
|
||||
allowed_names: set[str] = set()
|
||||
for role_name in scope.role_names:
|
||||
allowed_names.update(MANAGEABLE_ROLES_BY_ACTOR.get(role_name, set()))
|
||||
if not allowed_names:
|
||||
return []
|
||||
return db.execute(
|
||||
select(Role).where(Role.is_active.is_(True), Role.name.in_(sorted(allowed_names))).order_by(Role.name)
|
||||
).scalars().all()
|
||||
|
||||
|
||||
def get_user_role_names(db: Session, user_id: int) -> list[str]:
|
||||
return get_role_names(db, user_id)
|
||||
|
||||
|
||||
def get_user_role_ids(db: Session, user_id: int) -> list[int]:
|
||||
return db.execute(select(UserRole.role_id).where(UserRole.user_id == user_id)).scalars().all()
|
||||
|
||||
|
||||
def can_manage_role_names(scope: UserScope, role_names: list[str]) -> bool:
|
||||
if scope.is_system_admin:
|
||||
return True
|
||||
allowed: set[str] = set()
|
||||
for actor_role in scope.role_names:
|
||||
allowed.update(MANAGEABLE_ROLES_BY_ACTOR.get(actor_role, set()))
|
||||
return set(role_names).issubset(allowed)
|
||||
|
||||
|
||||
def validate_branch_matches_tenant(db: Session, tenant_id: int, branch_id: int) -> Branch:
|
||||
branch = db.execute(
|
||||
select(Branch).where(
|
||||
Branch.id == branch_id,
|
||||
Branch.tenant_id == tenant_id,
|
||||
Branch.is_active.is_(True),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not branch:
|
||||
raise ScopeError("Selected branch does not belong to the selected tenant.")
|
||||
return branch
|
||||
|
||||
|
||||
def resolve_target_tenant_branch(
|
||||
db: Session,
|
||||
scope: UserScope,
|
||||
tenant_id: int | None,
|
||||
branch_id: int | None,
|
||||
) -> tuple[int, int]:
|
||||
if scope.is_system_admin:
|
||||
if tenant_id is None or branch_id is None:
|
||||
raise ScopeError("Tenant and branch are required.")
|
||||
validate_branch_matches_tenant(db, tenant_id, branch_id)
|
||||
return tenant_id, branch_id
|
||||
|
||||
effective_tenant_id = scope.actor.tenant_id
|
||||
effective_branch_id = branch_id
|
||||
|
||||
if tenant_id is not None and tenant_id != scope.actor.tenant_id:
|
||||
raise ScopeError("Cross-tenant user creation is not allowed.")
|
||||
|
||||
if effective_branch_id is None:
|
||||
raise ScopeError("Branch is required.")
|
||||
|
||||
validate_branch_matches_tenant(db, effective_tenant_id, effective_branch_id)
|
||||
return effective_tenant_id, effective_branch_id
|
||||
|
||||
|
||||
def ensure_manageable_existing_user(db: Session, scope: UserScope, target_user: User) -> None:
|
||||
if scope.is_system_admin:
|
||||
return
|
||||
if not scope.is_firm_admin:
|
||||
raise ScopeError("Only System Admin and Firm Admin can manage users.")
|
||||
if target_user.tenant_id != scope.actor.tenant_id:
|
||||
raise ScopeError("You cannot manage users of another tenant.")
|
||||
|
||||
target_roles = get_user_role_names(db, target_user.id)
|
||||
if target_roles and not can_manage_role_names(scope, target_roles):
|
||||
raise ScopeError("You cannot manage the selected user's role level.")
|
||||
|
||||
|
||||
def ensure_assignable_roles(db: Session, scope: UserScope, role_ids: list[int]) -> list[Role]:
|
||||
if not role_ids:
|
||||
return []
|
||||
roles = db.execute(
|
||||
select(Role).where(Role.id.in_(role_ids), Role.is_active.is_(True)).order_by(Role.name)
|
||||
).scalars().all()
|
||||
if len(roles) != len(set(role_ids)):
|
||||
raise ScopeError("One or more selected roles are invalid.")
|
||||
if not can_manage_role_names(scope, [r.name for r in roles]):
|
||||
raise ScopeError("You cannot assign one or more selected roles.")
|
||||
return roles
|
||||
|
||||
|
||||
def assert_can_manage_role_object(scope: UserScope, role: Role) -> None:
|
||||
if scope.is_system_admin:
|
||||
return
|
||||
raise ScopeError("Only System Admin can manage RBAC roles.")
|
||||
|
||||
|
||||
def scope_to_http(exc: ScopeError) -> HTTPException:
|
||||
return HTTPException(status_code=403, detail=str(exc))
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
"""Scope guard utilities for tenant/branch enforcement (v2.0.3.1)"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
class ScopeError(Exception):
|
||||
pass
|
||||
|
||||
def ensure_same_tenant(actor_tenant_id: int, target_tenant_id: int):
|
||||
if actor_tenant_id != target_tenant_id:
|
||||
raise ScopeError("Cross-tenant operation is not allowed")
|
||||
|
||||
def ensure_same_branch(actor_branch_id: int, target_branch_id: int):
|
||||
if actor_branch_id != target_branch_id:
|
||||
raise ScopeError("Cross-branch operation is not allowed")
|
||||
|
||||
def validate_branch_belongs_to_tenant(branch_tenant_id: int, tenant_id: int):
|
||||
if branch_tenant_id != tenant_id:
|
||||
raise ScopeError("Branch does not belong to selected tenant")
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import ceil
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.iam.scope import (
|
||||
UserScope,
|
||||
get_manageable_roles,
|
||||
get_user_role_names,
|
||||
list_visible_branches,
|
||||
list_visible_tenants,
|
||||
)
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageResult:
|
||||
items: list
|
||||
page: int
|
||||
per_page: int
|
||||
total: int
|
||||
pages: int
|
||||
|
||||
@property
|
||||
def has_prev(self) -> bool:
|
||||
return self.page > 1
|
||||
|
||||
@property
|
||||
def has_next(self) -> bool:
|
||||
return self.page < self.pages
|
||||
|
||||
|
||||
def paginate_list(items: list, page: int = 1, per_page: int = 10) -> PageResult:
|
||||
total = len(items)
|
||||
per_page = max(1, min(per_page, 100))
|
||||
pages = max(1, ceil(total / per_page)) if total else 1
|
||||
page = max(1, min(page, pages))
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
return PageResult(items=items[start:end], page=page, per_page=per_page, total=total, pages=pages)
|
||||
|
||||
|
||||
def search_scoped_users(db: Session, scope: UserScope, q: str | None = None) -> list[User]:
|
||||
stmt = select(User)
|
||||
if not scope.is_system_admin:
|
||||
stmt = stmt.where(User.tenant_id == scope.actor.tenant_id)
|
||||
if scope.branch_scoped:
|
||||
stmt = stmt.where(User.branch_id == scope.actor.branch_id)
|
||||
|
||||
query = (q or "").strip()
|
||||
if query:
|
||||
like = f"%{query}%"
|
||||
stmt = stmt.where(or_(User.email.ilike(like), User.full_name.ilike(like)))
|
||||
|
||||
return db.execute(stmt.order_by(User.full_name, User.email, User.id)).scalars().all()
|
||||
|
||||
|
||||
def build_user_listing_payload(
|
||||
db: Session,
|
||||
scope: UserScope,
|
||||
q: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
) -> dict:
|
||||
users = search_scoped_users(db, scope, q=q)
|
||||
paged = paginate_list(users, page=page, per_page=per_page)
|
||||
user_ids = [u.id for u in paged.items]
|
||||
roles = {user_id: get_user_role_names(db, user_id) for user_id in user_ids}
|
||||
tenants = {t.id: t for t in list_visible_tenants(db, scope)}
|
||||
|
||||
if scope.is_system_admin:
|
||||
branch_rows = db.execute(select(Branch).order_by(Branch.name)).scalars().all()
|
||||
else:
|
||||
branch_rows = list_visible_branches(db, scope, scope.actor.tenant_id)
|
||||
|
||||
branches = {b.id: b for b in branch_rows}
|
||||
|
||||
return {
|
||||
"users_page": paged,
|
||||
"users": paged.items,
|
||||
"user_roles": roles,
|
||||
"tenants": tenants,
|
||||
"branches": branches,
|
||||
"filters": {
|
||||
"q": (q or "").strip(),
|
||||
"per_page": paged.per_page,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_user_form_payload(
|
||||
db: Session,
|
||||
scope: UserScope,
|
||||
actor: User,
|
||||
user_obj: User | None = None,
|
||||
assigned_role_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
tenants = list_visible_tenants(db, scope)
|
||||
|
||||
if user_obj:
|
||||
selected_tenant_id = user_obj.tenant_id
|
||||
selected_branch_id = user_obj.branch_id
|
||||
else:
|
||||
if scope.is_system_admin:
|
||||
selected_tenant_id = actor.tenant_id or (tenants[0].id if tenants else None)
|
||||
else:
|
||||
selected_tenant_id = actor.tenant_id
|
||||
selected_branch_id = actor.branch_id if scope.branch_scoped else None
|
||||
|
||||
branches = list_visible_branches(db, scope, selected_tenant_id)
|
||||
|
||||
selected_tenant = next((t for t in tenants if t.id == selected_tenant_id), None)
|
||||
selected_branch = next((b for b in branches if b.id == selected_branch_id), None)
|
||||
|
||||
manageable_roles = get_manageable_roles(db, scope)
|
||||
|
||||
return {
|
||||
"user_obj": user_obj,
|
||||
"assigned_role_ids": assigned_role_ids or [],
|
||||
"roles": manageable_roles,
|
||||
"tenants": tenants,
|
||||
"branches": branches,
|
||||
"scope": scope,
|
||||
"selected_tenant_id": selected_tenant_id,
|
||||
"selected_branch_id": selected_branch_id,
|
||||
"selected_tenant_name": selected_tenant.name if selected_tenant else "",
|
||||
"selected_branch_name": selected_branch.name if selected_branch else "",
|
||||
"can_change_tenant": scope.is_system_admin,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-2xl rounded-2xl border bg-white p-6 shadow-soft">
|
||||
<h1 class="text-2xl font-semibold">Change Password</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Enter your current password, then confirm the password change using the OTP sent to your registered email.</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
{{ flash }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-slate-700">Current Password</span>
|
||||
<input type="password" name="current_password" class="w-full rounded-xl border px-3 py-2" required />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-slate-700">New Password</span>
|
||||
<input type="password" name="new_password" class="w-full rounded-xl border px-3 py-2" required />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-slate-700">Confirm New Password</span>
|
||||
<input type="password" name="confirm_password" class="w-full rounded-xl border px-3 py-2" required />
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white">Send OTP</button>
|
||||
<a href="/system-settings" class="rounded-xl border px-4 py-2 text-sm">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-2xl rounded-2xl border bg-white p-6 shadow-soft">
|
||||
<h1 class="text-2xl font-semibold">Confirm Password Change</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Enter the OTP sent to your registered email to complete the password change.</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
{{ flash }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-slate-700">OTP</span>
|
||||
<input type="text" name="otp" class="w-full rounded-xl border px-3 py-2" required />
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white">Confirm Change</button>
|
||||
<a href="/change-password" class="rounded-xl border px-4 py-2 text-sm">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-lg rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Forgot Password</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Enter your login email to receive password reset instructions by email.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/forgot-password" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Enter your login email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
Send Reset Instructions
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm">
|
||||
<a class="text-brand-700 underline" href="/login">Back to login</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="mx-auto max-w-2xl rounded-3xl bg-white p-6 shadow-soft"><h2 class="text-xl font-semibold">Invite Link Generated</h2><p class="mt-1 text-sm text-slate-500">The invite email has been attempted through the configured firm SMTP. You may also copy and share this link manually with {{ invited_user.full_name or invited_user.email }} if required.</p><div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4 text-sm text-brand-900 break-all">{{ invite_url }}</div><div class="mt-5"><a href="/system-settings/users" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to users</a></div></div>{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-md rounded-2xl bg-white border p-6">
|
||||
<h1 class="text-2xl font-semibold">OTP Verification</h1>
|
||||
<p class="text-slate-600 mt-1 text-sm">Enter the OTP sent to your registered email.</p>
|
||||
|
||||
<form method="post" class="mt-5 grid gap-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span class="text-sm text-slate-600">OTP Code</span>
|
||||
<input class="border rounded-xl px-3 py-2" name="otp" inputmode="numeric" required />
|
||||
</label>
|
||||
|
||||
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm mt-2" type="submit">Verify</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-lg rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Reset Password</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Enter the OTP sent to your registered email and set your new password.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/reset-password" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">OTP</label>
|
||||
<input
|
||||
type="text"
|
||||
name="otp"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Enter OTP"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Re-enter new password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm">
|
||||
<a class="text-brand-700 underline" href="/login">Back to login</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-lg rounded-3xl bg-white p-6 shadow-soft">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Reset Password</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Enter your new password to complete the password reset request.
|
||||
</p>
|
||||
|
||||
{% if flash %}
|
||||
<div class="mt-4 rounded-2xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ flash }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/password-reset/accept" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="token" value="{{ token }}" />
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500">Use at least 8 characters with letters and numbers.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm">
|
||||
<a class="text-brand-700 underline" href="/login">Back to login</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Integer, String, ForeignKey, DateTime, Boolean, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
class RefreshToken(CommonBase):
|
||||
__tablename__ = "refresh_tokens"
|
||||
__table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
|
||||
token_hash: Mapped[str] = mapped_column(String(64), index=True) # sha256 hex
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
rotated_from_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user