user.py 6.9 KB

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