diff --git a/alembic/env.py b/alembic/env.py index e12d48e..5608fe5 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -47,6 +47,7 @@ from app.modules.domain_management import models as domain_management_models # from app.modules.notice_cases import models as notice_cases_models # noqa: F401 from app.modules.registrations import models as registration_models # noqa: F401 from app.modules.credential_vault import models as credential_vault_models # noqa: F401 +from app.modules.client_identity import models as client_identity_models # noqa: F401 config = context.config diff --git a/alembic/versions/20260723_phase6_pan_login_client_identity.py b/alembic/versions/20260723_phase6_pan_login_client_identity.py new file mode 100644 index 0000000..d9c3294 --- /dev/null +++ b/alembic/versions/20260723_phase6_pan_login_client_identity.py @@ -0,0 +1,44 @@ +"""Phase 6 PAN login, invitation and client identity. + +Revision ID: 20260723_phase6_pan_login_client_identity +Revises: 20260723_phase5_secure_credential_vault +""" +from alembic import op +import sqlalchemy as sa +revision="20260723_phase6_pan_login_client_identity" +down_revision="20260723_phase5_secure_credential_vault" +branch_labels=None +depends_on=None + +def upgrade(): + op.create_table("client_portal_identities", + sa.Column("id",sa.Integer(),primary_key=True), + sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False), + sa.Column("branch_id",sa.Integer(),sa.ForeignKey("branches.id",ondelete="CASCADE"),nullable=False), + sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False), + sa.Column("user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")), + sa.Column("normalized_pan",sa.String(10),nullable=False), + sa.Column("login_email",sa.String(255),nullable=False), + sa.Column("email_is_placeholder",sa.Boolean(),nullable=False,server_default=sa.false()), + sa.Column("status",sa.String(30),nullable=False,server_default="not_invited"), + sa.Column("invited_at_utc",sa.DateTime(timezone=True)), + sa.Column("activated_at_utc",sa.DateTime(timezone=True)), + sa.Column("last_invited_at_utc",sa.DateTime(timezone=True)), + sa.Column("created_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")), + sa.Column("updated_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")), + sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False), + sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False), + sa.UniqueConstraint("client_id",name="uq_client_portal_identity_client"), + sa.UniqueConstraint("tenant_id","normalized_pan",name="uq_client_portal_identity_tenant_pan")) + for c in ["tenant_id","branch_id","client_id","user_id","normalized_pan","login_email","status"]: op.create_index(f"ix_client_portal_identities_{c}","client_portal_identities",[c]) + op.create_table("client_portal_invitations", + sa.Column("id",sa.Integer(),primary_key=True),sa.Column("identity_id",sa.Integer(),sa.ForeignKey("client_portal_identities.id",ondelete="CASCADE"),nullable=False),sa.Column("user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="CASCADE"),nullable=False),sa.Column("delivery_mode",sa.String(30),nullable=False,server_default="email"),sa.Column("recipient_email",sa.String(255)),sa.Column("status",sa.String(30),nullable=False,server_default="issued"),sa.Column("issued_by_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("issued_at_utc",sa.DateTime(timezone=True),nullable=False),sa.Column("expires_at_utc",sa.DateTime(timezone=True)),sa.Column("accepted_at_utc",sa.DateTime(timezone=True)),sa.Column("note",sa.Text())) + for c in ["identity_id","user_id","status"]: op.create_index(f"ix_client_portal_invitations_{c}","client_portal_invitations",[c]) + op.create_table("client_identity_audit_logs", + sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False),sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False),sa.Column("identity_id",sa.Integer(),sa.ForeignKey("client_portal_identities.id",ondelete="SET NULL")),sa.Column("actor_user_id",sa.Integer(),sa.ForeignKey("users.id",ondelete="SET NULL")),sa.Column("action",sa.String(50),nullable=False),sa.Column("summary",sa.String(255),nullable=False),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False)) + for c in ["tenant_id","client_id","identity_id","action"]: op.create_index(f"ix_client_identity_audit_logs_{c}","client_identity_audit_logs",[c]) + +def downgrade(): + op.drop_table("client_identity_audit_logs") + op.drop_table("client_portal_invitations") + op.drop_table("client_portal_identities") diff --git a/app/modules/client_identity/__init__.py b/app/modules/client_identity/__init__.py new file mode 100644 index 0000000..95c4896 --- /dev/null +++ b/app/modules/client_identity/__init__.py @@ -0,0 +1 @@ +"""PAN-based client identity and invitation lifecycle.""" diff --git a/app/modules/client_identity/models.py b/app/modules/client_identity/models.py new file mode 100644 index 0000000..7b8926f --- /dev/null +++ b/app/modules/client_identity/models.py @@ -0,0 +1,53 @@ +from __future__ import annotations +from datetime import datetime, timezone +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from app.core.db.common import CommonBase + +class ClientPortalIdentity(CommonBase): + __tablename__ = "client_portal_identities" + __table_args__ = ( + UniqueConstraint("client_id", name="uq_client_portal_identity_client"), + UniqueConstraint("tenant_id", "normalized_pan", name="uq_client_portal_identity_tenant_pan"), + ) + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + normalized_pan: Mapped[str] = mapped_column(String(10), nullable=False, index=True) + login_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + email_is_placeholder: Mapped[bool] = mapped_column(default=False, nullable=False) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_invited", index=True) + invited_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + activated_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_invited_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + +class ClientPortalInvitation(CommonBase): + __tablename__ = "client_portal_invitations" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + identity_id: Mapped[int] = mapped_column(ForeignKey("client_portal_identities.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + delivery_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="email") + recipient_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="issued", index=True) + issued_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + issued_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + expires_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + accepted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + +class ClientIdentityAuditLog(CommonBase): + __tablename__ = "client_identity_audit_logs" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + identity_id: Mapped[int | None] = mapped_column(ForeignKey("client_portal_identities.id", ondelete="SET NULL"), nullable=True, index=True) + actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + action: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + summary: Mapped[str] = mapped_column(String(255), nullable=False) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) diff --git a/app/modules/client_identity/service.py b/app/modules/client_identity/service.py new file mode 100644 index 0000000..97fa43d --- /dev/null +++ b/app/modules/client_identity/service.py @@ -0,0 +1,127 @@ +from __future__ import annotations +import re, secrets +from datetime import datetime, timezone, timedelta +from sqlalchemy import select +from sqlalchemy.orm import Session +from app.core.security.passwords import hash_password +from app.core.settings import get_settings +from app.modules.clients.models import Client +from app.modules.core.iam.invite_service import issue_invite_token +from app.modules.core.iam.models import User +from app.modules.core.rbac.models import Role, UserRole +from app.modules.email_integration.services import send_user_invite_email +from .models import ClientIdentityAuditLog, ClientPortalIdentity, ClientPortalInvitation + +PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$") + +def normalize_pan(value: str | None) -> str: + pan = re.sub(r"\s+", "", (value or "").upper()) + if not PAN_RE.fullmatch(pan): + raise ValueError("A valid 10-character PAN is required for client portal identity.") + return pan + +def normalize_email(value: str | None) -> str: + email = (value or "").strip().lower() + if not email or "@" not in email or len(email) > 255: + raise ValueError("A valid email address is required.") + return email + +def placeholder_email(tenant_id: int, pan: str) -> str: + return f"client+{tenant_id}.{pan.lower()}@invalid.local" + +def _audit(db: Session, identity: ClientPortalIdentity, actor_user_id: int | None, action: str, summary: str): + db.add(ClientIdentityAuditLog(tenant_id=identity.tenant_id, client_id=identity.client_id, identity_id=identity.id, actor_user_id=actor_user_id, action=action, summary=summary)) + +def _ensure_client_role(db: Session, user_id: int): + role = db.execute(select(Role).where(Role.name == "Client")).scalar_one_or_none() + if not role: + raise ValueError("Client role is not configured in RBAC.") + exists = db.execute(select(UserRole).where(UserRole.user_id == user_id, UserRole.role_id == role.id)).scalar_one_or_none() + if not exists: + db.add(UserRole(user_id=user_id, role_id=role.id)) + +def get_identity(db: Session, client_id: int) -> ClientPortalIdentity | None: + return db.execute(select(ClientPortalIdentity).where(ClientPortalIdentity.client_id == client_id)).scalar_one_or_none() + +def ensure_identity(db: Session, *, client: Client, actor_user_id: int | None, requested_email: str | None = None) -> ClientPortalIdentity: + pan = normalize_pan(client.pan) + existing_pan = db.execute(select(ClientPortalIdentity).where(ClientPortalIdentity.tenant_id == client.tenant_id, ClientPortalIdentity.normalized_pan == pan, ClientPortalIdentity.client_id != client.id)).scalar_one_or_none() + if existing_pan: + raise ValueError("This PAN is already linked to another client portal identity in the firm.") + identity = get_identity(db, client.id) + real_email = (requested_email or client.email or "").strip().lower() + is_placeholder = not bool(real_email) + email = placeholder_email(client.tenant_id, pan) if is_placeholder else normalize_email(real_email) + other_user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + if other_user and (not identity or identity.user_id != other_user.id) and client.portal_user_id != other_user.id: + raise ValueError("This email address is already used by another ERP user.") + if identity: + identity.normalized_pan = pan + identity.updated_by_user_id = actor_user_id + if identity.user_id: + user = db.get(User, identity.user_id) + if user and user.email != email: + user.email = email + identity.login_email = email + identity.email_is_placeholder = is_placeholder + else: + user = db.get(User, client.portal_user_id) if client.portal_user_id else None + if not user: + user = User(email=email, full_name=client.client_name, password_hash=hash_password(secrets.token_urlsafe(48)), tenant_id=client.tenant_id, branch_id=client.branch_id, is_active=True, allow_login=False, must_change_password=True) + db.add(user); db.flush() + _ensure_client_role(db, user.id) + client.portal_user_id = user.id + identity = ClientPortalIdentity(tenant_id=client.tenant_id, branch_id=client.branch_id, client_id=client.id, user_id=user.id, normalized_pan=pan, login_email=email, email_is_placeholder=is_placeholder, status="not_invited", created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id) + db.add(identity); db.flush() + _audit(db, identity, actor_user_id, "identity_created", "PAN-linked client portal identity created.") + db.commit(); db.refresh(identity) + return identity + +def update_login_email(db: Session, *, identity: ClientPortalIdentity, email: str, actor_user_id: int | None): + new_email = normalize_email(email) + other = db.execute(select(User).where(User.email == new_email, User.id != identity.user_id)).scalar_one_or_none() + if other: raise ValueError("This email address is already used by another ERP user.") + client = db.get(Client, identity.client_id); user = db.get(User, identity.user_id) if identity.user_id else None + old = identity.login_email + identity.login_email = new_email; identity.email_is_placeholder = False; identity.updated_by_user_id = actor_user_id + if client: client.email = new_email + if user: user.email = new_email + _audit(db, identity, actor_user_id, "email_changed", f"Portal login email changed from {old} to {new_email}.") + db.commit() + +def issue_client_invitation(db: Session, *, identity: ClientPortalIdentity, actor_user_id: int | None, send_email: bool = True) -> tuple[str, ClientPortalInvitation]: + user = db.get(User, identity.user_id) if identity.user_id else None + if not user: raise ValueError("Client portal user is not linked.") + token = issue_invite_token(db, user) + now = datetime.now(timezone.utc) + user.allow_login = False + identity.status = "invited"; identity.invited_at_utc = identity.invited_at_utc or now; identity.last_invited_at_utc = now; identity.updated_by_user_id = actor_user_id + mode = "manual_link" if identity.email_is_placeholder or not send_email else "email" + row = ClientPortalInvitation(identity_id=identity.id, user_id=user.id, delivery_mode=mode, recipient_email=None if identity.email_is_placeholder else identity.login_email, status="issued", issued_by_user_id=actor_user_id, issued_at_utc=now, expires_at_utc=now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS)) + db.add(row); _audit(db, identity, actor_user_id, "invite_issued", f"Client portal invitation issued using {mode}.") + if mode == "email": + try: send_user_invite_email(db, user=user, invite_token=token) + except Exception as exc: row.note = f"Email delivery failed: {exc}"; row.delivery_mode = "manual_link" + db.commit(); db.refresh(row) + return token, row + +def resolve_login_user(db: Session, identifier: str, bound_tenant_id: int | None = None) -> User | None: + value = (identifier or "").strip() + if "@" in value: + return db.execute(select(User).where(User.email == value.lower())).scalar_one_or_none() + try: pan = normalize_pan(value) + except ValueError: return None + q = select(ClientPortalIdentity).where(ClientPortalIdentity.normalized_pan == pan) + if bound_tenant_id is not None: q = q.where(ClientPortalIdentity.tenant_id == bound_tenant_id) + rows = list(db.execute(q).scalars().all()) + if len(rows) != 1: return None + return db.get(User, rows[0].user_id) if rows[0].user_id else None + +def mark_identity_activated(db: Session, user_id: int): + identity = db.execute(select(ClientPortalIdentity).where(ClientPortalIdentity.user_id == user_id)).scalar_one_or_none() + if identity and identity.status != "active": + now = datetime.now(timezone.utc); identity.status="active"; identity.activated_at_utc=now + inv = db.execute(select(ClientPortalInvitation).where(ClientPortalInvitation.identity_id==identity.id, ClientPortalInvitation.status=="issued").order_by(ClientPortalInvitation.id.desc())).scalars().first() + if inv: inv.status="accepted"; inv.accepted_at_utc=now + _audit(db, identity, user_id, "identity_activated", "Client accepted invitation and activated portal identity.") + db.commit() diff --git a/app/modules/client_identity/templates/client_identity/detail.html b/app/modules/client_identity/templates/client_identity/detail.html new file mode 100644 index 0000000..babca8d --- /dev/null +++ b/app/modules/client_identity/templates/client_identity/detail.html @@ -0,0 +1,9 @@ +{% extends "ui/templates/base/layout.html" %}{% block content %} +
{{ client.client_name }} · PAN {{ client.pan or 'Not available' }}
This plain token is displayed only once.
PAN is the permanent identity. Email may be added or changed later.
Issue or reissue a single-use password setup link.
{% if can_manage %}{% endif %}| Issued | Mode | Recipient | Status | Expiry |
|---|---|---|---|---|
| {{ x.issued_at_utc }} | {{ x.delivery_mode }} | {{ x.recipient_email or '-' }} | {{ x.status }} | {{ x.expires_at_utc or '-' }} |
| No invitations issued. | ||||