chore: import upstream snapshot with attribution
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""User service module."""
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Abstract base class for user service."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, UserUpdateRequest
|
||||
|
||||
|
||||
class UserServiceBase(ABC):
|
||||
"""High-level service for user management."""
|
||||
|
||||
@abstractmethod
|
||||
def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
|
||||
"""Create a new user.
|
||||
|
||||
Args:
|
||||
user_data: User creation data
|
||||
strict_password_checking: If True (default), passwords must meet strength requirements.
|
||||
If False, any non-empty password is accepted.
|
||||
|
||||
Returns:
|
||||
The created user
|
||||
|
||||
Raises:
|
||||
ValueError: If email already exists or (when strict) password is weak
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get(self, user_id: str) -> UserDTO | None:
|
||||
"""Get user by ID.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
|
||||
Returns:
|
||||
UserDTO if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_by_email(self, email: str) -> UserDTO | None:
|
||||
"""Get user by email.
|
||||
|
||||
Args:
|
||||
email: The email address
|
||||
|
||||
Returns:
|
||||
UserDTO if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update(self, user_id: str, changes: UserUpdateRequest, strict_password_checking: bool = True) -> UserDTO:
|
||||
"""Update user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
changes: Fields to update
|
||||
strict_password_checking: If True (default), passwords must meet strength requirements.
|
||||
If False, any non-empty password is accepted.
|
||||
|
||||
Returns:
|
||||
The updated user
|
||||
|
||||
Raises:
|
||||
ValueError: If user not found or (when strict) password is weak
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, user_id: str) -> None:
|
||||
"""Delete user.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
|
||||
Raises:
|
||||
ValueError: If user not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def authenticate(self, email: str, password: str) -> UserDTO | None:
|
||||
"""Authenticate user credentials.
|
||||
|
||||
Args:
|
||||
email: User email
|
||||
password: User password
|
||||
|
||||
Returns:
|
||||
UserDTO if authentication successful, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def has_admin(self) -> bool:
|
||||
"""Check if any admin user exists.
|
||||
|
||||
Returns:
|
||||
True if at least one admin user exists, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
|
||||
"""Create an admin user (for initial setup).
|
||||
|
||||
Args:
|
||||
user_data: User creation data
|
||||
strict_password_checking: If True (default), passwords must meet strength requirements.
|
||||
If False, any non-empty password is accepted.
|
||||
|
||||
Returns:
|
||||
The created admin user
|
||||
|
||||
Raises:
|
||||
ValueError: If admin already exists or (when strict) password is weak
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
|
||||
"""List all users.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of users to return
|
||||
offset: Number of users to skip
|
||||
|
||||
Returns:
|
||||
List of users
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_admin_email(self) -> str | None:
|
||||
"""Get the email address of the first active admin user.
|
||||
|
||||
Returns:
|
||||
Email address of the first active admin, or None if no admin exists
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def count_admins(self) -> int:
|
||||
"""Count active admin users.
|
||||
|
||||
Returns:
|
||||
The number of active admin users
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Common types and data models for user service."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic_core import PydanticCustomError
|
||||
|
||||
|
||||
def validate_email_with_special_domains(email: str) -> str:
|
||||
"""Validate email address, allowing special-use domains like .local for testing.
|
||||
|
||||
This validator first tries standard email validation using email-validator library.
|
||||
If it fails due to special-use domains (like .local, .test, .localhost), it performs
|
||||
a basic syntax check instead. This allows development/testing with non-routable domains
|
||||
while still catching actual typos and malformed emails.
|
||||
|
||||
Args:
|
||||
email: The email address to validate
|
||||
|
||||
Returns:
|
||||
The validated email address (lowercased)
|
||||
|
||||
Raises:
|
||||
PydanticCustomError: If the email format is invalid
|
||||
"""
|
||||
try:
|
||||
# Try standard email validation using email-validator
|
||||
from email_validator import EmailNotValidError, validate_email
|
||||
|
||||
result = validate_email(email, check_deliverability=False)
|
||||
return result.normalized
|
||||
except EmailNotValidError as e:
|
||||
error_msg = str(e)
|
||||
|
||||
# Check if the error is specifically about special-use/reserved domains or localhost
|
||||
if (
|
||||
"special-use" in error_msg.lower()
|
||||
or "reserved" in error_msg.lower()
|
||||
or "should have a period" in error_msg.lower()
|
||||
):
|
||||
# Perform basic email syntax validation
|
||||
email = email.strip().lower()
|
||||
|
||||
if "@" not in email:
|
||||
raise PydanticCustomError(
|
||||
"value_error",
|
||||
"Email address must contain an @ symbol",
|
||||
)
|
||||
|
||||
local_part, domain = email.rsplit("@", 1)
|
||||
|
||||
if not local_part or not domain:
|
||||
raise PydanticCustomError(
|
||||
"value_error",
|
||||
"Email address must have both local and domain parts",
|
||||
)
|
||||
|
||||
# Allow localhost and domains with dots
|
||||
if domain == "localhost" or "." in domain:
|
||||
return email
|
||||
|
||||
raise PydanticCustomError(
|
||||
"value_error",
|
||||
"Email domain must contain a dot or be 'localhost'",
|
||||
)
|
||||
else:
|
||||
# Re-raise other validation errors
|
||||
raise PydanticCustomError(
|
||||
"value_error",
|
||||
f"Invalid email address: {error_msg}",
|
||||
)
|
||||
|
||||
|
||||
class UserDTO(BaseModel):
|
||||
"""User data transfer object."""
|
||||
|
||||
user_id: str = Field(description="Unique user identifier")
|
||||
email: str = Field(description="User email address")
|
||||
display_name: str | None = Field(default=None, description="Display name")
|
||||
is_admin: bool = Field(default=False, description="Whether user has admin privileges")
|
||||
is_active: bool = Field(default=True, description="Whether user account is active")
|
||||
created_at: datetime = Field(description="When the user was created")
|
||||
updated_at: datetime = Field(description="When the user was last updated")
|
||||
last_login_at: datetime | None = Field(default=None, description="When user last logged in")
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
"""Validate email address, allowing special-use domains."""
|
||||
return validate_email_with_special_domains(v)
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
"""Request to create a new user."""
|
||||
|
||||
email: str = Field(description="User email address")
|
||||
display_name: str | None = Field(default=None, description="Display name")
|
||||
password: str = Field(description="User password")
|
||||
is_admin: bool = Field(default=False, description="Whether user should have admin privileges")
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
"""Validate email address, allowing special-use domains."""
|
||||
return validate_email_with_special_domains(v)
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
"""Request to update a user."""
|
||||
|
||||
display_name: str | None = Field(default=None, description="Display name")
|
||||
password: str | None = Field(default=None, description="New password")
|
||||
is_admin: bool | None = Field(default=None, description="Whether user should have admin privileges")
|
||||
is_active: bool | None = Field(default=None, description="Whether user account should be active")
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Default SQLite implementation of user service."""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from invokeai.app.services.auth.password_utils import hash_password, validate_password_strength, verify_password
|
||||
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
from invokeai.app.services.users.users_base import UserServiceBase
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, UserUpdateRequest
|
||||
|
||||
|
||||
class UserService(UserServiceBase):
|
||||
"""SQLite-based user service."""
|
||||
|
||||
def __init__(self, db: SqliteDatabase):
|
||||
"""Initialize user service.
|
||||
|
||||
Args:
|
||||
db: SQLite database instance
|
||||
"""
|
||||
self._db = db
|
||||
|
||||
def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
|
||||
"""Create a new user."""
|
||||
# Validate password strength
|
||||
if strict_password_checking:
|
||||
is_valid, error_msg = validate_password_strength(user_data.password)
|
||||
if not is_valid:
|
||||
raise ValueError(error_msg)
|
||||
elif not user_data.password:
|
||||
raise ValueError("Password cannot be empty")
|
||||
|
||||
# Check if email already exists
|
||||
if self.get_by_email(user_data.email) is not None:
|
||||
raise ValueError(f"User with email {user_data.email} already exists")
|
||||
|
||||
user_id = str(uuid4())
|
||||
password_hash = hash_password(user_data.password)
|
||||
|
||||
with self._db.transaction() as cursor:
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO users (user_id, email, display_name, password_hash, is_admin)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, user_data.email, user_data.display_name, password_hash, user_data.is_admin),
|
||||
)
|
||||
except sqlite3.IntegrityError as e:
|
||||
raise ValueError(f"Failed to create user: {e}") from e
|
||||
|
||||
user = self.get(user_id)
|
||||
if user is None:
|
||||
raise RuntimeError("Failed to retrieve created user")
|
||||
return user
|
||||
|
||||
def get(self, user_id: str) -> UserDTO | None:
|
||||
"""Get user by ID."""
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return UserDTO(
|
||||
user_id=row[0],
|
||||
email=row[1],
|
||||
display_name=row[2],
|
||||
is_admin=bool(row[3]),
|
||||
is_active=bool(row[4]),
|
||||
created_at=datetime.fromisoformat(row[5]),
|
||||
updated_at=datetime.fromisoformat(row[6]),
|
||||
last_login_at=datetime.fromisoformat(row[7]) if row[7] else None,
|
||||
)
|
||||
|
||||
def get_by_email(self, email: str) -> UserDTO | None:
|
||||
"""Get user by email."""
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE email = ?
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return UserDTO(
|
||||
user_id=row[0],
|
||||
email=row[1],
|
||||
display_name=row[2],
|
||||
is_admin=bool(row[3]),
|
||||
is_active=bool(row[4]),
|
||||
created_at=datetime.fromisoformat(row[5]),
|
||||
updated_at=datetime.fromisoformat(row[6]),
|
||||
last_login_at=datetime.fromisoformat(row[7]) if row[7] else None,
|
||||
)
|
||||
|
||||
def update(self, user_id: str, changes: UserUpdateRequest, strict_password_checking: bool = True) -> UserDTO:
|
||||
"""Update user."""
|
||||
# Check if user exists
|
||||
user = self.get(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f"User {user_id} not found")
|
||||
|
||||
# Validate password if provided
|
||||
if changes.password is not None:
|
||||
if strict_password_checking:
|
||||
is_valid, error_msg = validate_password_strength(changes.password)
|
||||
if not is_valid:
|
||||
raise ValueError(error_msg)
|
||||
elif not changes.password:
|
||||
raise ValueError("Password cannot be empty")
|
||||
|
||||
# Build update query dynamically based on provided fields
|
||||
updates: list[str] = []
|
||||
params: list[str | bool | int] = []
|
||||
|
||||
if changes.display_name is not None:
|
||||
updates.append("display_name = ?")
|
||||
params.append(changes.display_name)
|
||||
|
||||
if changes.password is not None:
|
||||
updates.append("password_hash = ?")
|
||||
params.append(hash_password(changes.password))
|
||||
|
||||
if changes.is_admin is not None:
|
||||
updates.append("is_admin = ?")
|
||||
params.append(changes.is_admin)
|
||||
|
||||
if changes.is_active is not None:
|
||||
updates.append("is_active = ?")
|
||||
params.append(changes.is_active)
|
||||
|
||||
if not updates:
|
||||
return user
|
||||
|
||||
params.append(user_id)
|
||||
query = f"UPDATE users SET {', '.join(updates)} WHERE user_id = ?"
|
||||
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(query, params)
|
||||
|
||||
updated_user = self.get(user_id)
|
||||
if updated_user is None:
|
||||
raise RuntimeError("Failed to retrieve updated user")
|
||||
return updated_user
|
||||
|
||||
def delete(self, user_id: str) -> None:
|
||||
"""Delete user."""
|
||||
user = self.get(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f"User {user_id} not found")
|
||||
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute("DELETE FROM users WHERE user_id = ?", (user_id,))
|
||||
|
||||
def authenticate(self, email: str, password: str) -> UserDTO | None:
|
||||
"""Authenticate user credentials."""
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT user_id, email, display_name, password_hash, is_admin, is_active, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE email = ?
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
password_hash = row[3]
|
||||
if not verify_password(password, password_hash):
|
||||
return None
|
||||
|
||||
# Update last login time
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE users SET last_login_at = ? WHERE user_id = ?",
|
||||
(datetime.now(timezone.utc).isoformat(), row[0]),
|
||||
)
|
||||
|
||||
return UserDTO(
|
||||
user_id=row[0],
|
||||
email=row[1],
|
||||
display_name=row[2],
|
||||
is_admin=bool(row[4]),
|
||||
is_active=bool(row[5]),
|
||||
created_at=datetime.fromisoformat(row[6]),
|
||||
updated_at=datetime.fromisoformat(row[7]),
|
||||
last_login_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
def has_admin(self) -> bool:
|
||||
"""Check if any admin user exists."""
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = TRUE AND is_active = TRUE")
|
||||
row = cursor.fetchone()
|
||||
count = row[0] if row else 0
|
||||
return bool(count > 0)
|
||||
|
||||
def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
|
||||
"""Create an admin user (for initial setup)."""
|
||||
if self.has_admin():
|
||||
raise ValueError("Admin user already exists")
|
||||
|
||||
# Force is_admin to True
|
||||
admin_data = UserCreateRequest(
|
||||
email=user_data.email,
|
||||
display_name=user_data.display_name,
|
||||
password=user_data.password,
|
||||
is_admin=True,
|
||||
)
|
||||
return self.create(admin_data, strict_password_checking=strict_password_checking)
|
||||
|
||||
def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
|
||||
"""List all users."""
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(limit, offset),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
UserDTO(
|
||||
user_id=row[0],
|
||||
email=row[1],
|
||||
display_name=row[2],
|
||||
is_admin=bool(row[3]),
|
||||
is_active=bool(row[4]),
|
||||
created_at=datetime.fromisoformat(row[5]),
|
||||
updated_at=datetime.fromisoformat(row[6]),
|
||||
last_login_at=datetime.fromisoformat(row[7]) if row[7] else None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_admin_email(self) -> str | None:
|
||||
"""Get the email address of the first active admin user."""
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT email FROM users
|
||||
WHERE is_admin = TRUE AND is_active = TRUE
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def count_admins(self) -> int:
|
||||
"""Count active admin users."""
|
||||
with self._db.transaction() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = TRUE AND is_active = TRUE")
|
||||
row = cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
Reference in New Issue
Block a user