user.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. from __future__ import annotations
  2. from datetime import datetime
  3. from typing import TYPE_CHECKING
  4. from sqlalchemy import DateTime, String, func
  5. from sqlalchemy.orm import Mapped, mapped_column, relationship
  6. from backend.app.core.database import Base
  7. from backend.app.utils.local_time import utcnow_naive
  8. if TYPE_CHECKING:
  9. from backend.app.models.group import Group
  10. from backend.app.models.user_email_pref import UserEmailPreference
  11. class User(Base):
  12. """User model for authentication and authorization.
  13. Users can belong to multiple groups, and their permissions are additive
  14. across all groups. The legacy 'role' field is kept for backward compatibility
  15. but is_admin property now also considers group membership.
  16. """
  17. __tablename__ = "users"
  18. id: Mapped[int] = mapped_column(primary_key=True)
  19. username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
  20. email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
  21. password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
  22. role: Mapped[str] = mapped_column(
  23. String(20), default="user"
  24. ) # "admin" or "user" (legacy, kept for backward compat)
  25. auth_source: Mapped[str] = mapped_column(String(20), default="local") # "local", "ldap", or "oidc"
  26. is_active: Mapped[bool] = mapped_column(default=True)
  27. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  28. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  29. # Set whenever the local password is changed/reset — used to invalidate JWTs
  30. # issued before the change (M-R7-B). NULL means no password change recorded yet.
  31. # Set at creation too, whichever way the user is created: sessions name the
  32. # user by username, and a deleted user's still-valid JWTs must not sign in
  33. # whoever is given the name next.
  34. password_changed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=utcnow_naive)
  35. # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
  36. cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
  37. cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
  38. # "global" or "china"; NULL treated as "global" for legacy rows.
  39. cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
  40. # Set when Bambu answers 401 to a call made with ``cloud_token`` — the token
  41. # has expired or been revoked. NULL means "not known to be dead". The token
  42. # itself is kept: clearing it would lose the email/region we show on the
  43. # re-login form, and a token can only be replaced by signing in again anyway.
  44. # Bambu's token is opaque and carries no expiry we can read, and Bambuddy
  45. # does not persist the refresh token, so this flag is the *only* record that
  46. # a stored credential has stopped working (#2562 follow-up).
  47. cloud_token_invalid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
  48. # Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
  49. # with short-lived access tokens (1h) and rotating single-use refresh tokens,
  50. # so we store the refresh token + expiry alongside the access token.
  51. orca_cloud_token: Mapped[str | None] = mapped_column(String(2000), nullable=True, default=None)
  52. orca_cloud_refresh_token: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
  53. orca_cloud_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
  54. orca_cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
  55. orca_cloud_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
  56. # Transient PKCE state held between /orca-cloud/auth/start and /orca-cloud/auth/finish.
  57. # Cleared on successful finish; expires after 10 minutes if the user abandons the flow.
  58. orca_cloud_pending_verifier: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
  59. orca_cloud_pending_state: Mapped[str | None] = mapped_column(String(32), nullable=True, default=None)
  60. orca_cloud_pending_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
  61. # Relationship to groups through association table
  62. groups: Mapped[list[Group]] = relationship(
  63. "Group",
  64. secondary="user_groups",
  65. back_populates="users",
  66. lazy="selectin",
  67. )
  68. # Relationship to email notification preferences
  69. email_preferences: Mapped[UserEmailPreference | None] = relationship(
  70. "UserEmailPreference",
  71. back_populates="user",
  72. uselist=False,
  73. cascade="all, delete-orphan",
  74. lazy="select",
  75. )
  76. @property
  77. def is_admin(self) -> bool:
  78. """Check if user is an admin.
  79. Returns True if:
  80. - User has legacy role='admin', OR
  81. - User belongs to the Administrators group
  82. """
  83. if self.role == "admin":
  84. return True
  85. return any(g.name == "Administrators" for g in self.groups)
  86. def get_permissions(self) -> set[str]:
  87. """Get all permissions from all groups the user belongs to.
  88. Returns a set of permission strings. Permissions are additive across groups.
  89. """
  90. permissions: set[str] = set()
  91. for group in self.groups:
  92. if group.permissions:
  93. permissions.update(group.permissions)
  94. return permissions
  95. def has_permission(self, permission: str) -> bool:
  96. """Check if user has a specific permission.
  97. Admins have all permissions. For other users, checks if the permission
  98. exists in any of their groups.
  99. """
  100. if self.is_admin:
  101. return True
  102. return permission in self.get_permissions()
  103. def has_all_permissions(self, *permissions: str) -> bool:
  104. """Check if user has ALL specified permissions.
  105. Admins have all permissions. For other users, checks if all permissions
  106. exist in their combined group permissions.
  107. """
  108. if self.is_admin:
  109. return True
  110. user_permissions = self.get_permissions()
  111. return all(p in user_permissions for p in permissions)
  112. def has_any_permission(self, *permissions: str) -> bool:
  113. """Check if user has ANY of the specified permissions.
  114. Admins have all permissions. For other users, checks if at least one
  115. permission exists in their combined group permissions.
  116. """
  117. if self.is_admin:
  118. return True
  119. user_permissions = self.get_permissions()
  120. return any(p in user_permissions for p in permissions)
  121. def __repr__(self) -> str:
  122. return f"<User {self.username}>"