zndraw-auth 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: zndraw-auth
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Shared authentication for ZnDraw using fastapi-users
5
5
  Requires-Dist: fastapi>=0.128.0
6
6
  Requires-Dist: fastapi-users[sqlalchemy]>=14.0.0
@@ -222,6 +222,27 @@ Settings are loaded from environment variables with the `ZNDRAW_AUTH_` prefix:
222
222
  | `ZNDRAW_AUTH_DATABASE_URL` | `sqlite+aiosqlite:///./zndraw_auth.db` | Database connection URL |
223
223
  | `ZNDRAW_AUTH_RESET_PASSWORD_TOKEN_SECRET` | `CHANGE-ME-RESET` | Password reset token secret |
224
224
  | `ZNDRAW_AUTH_VERIFICATION_TOKEN_SECRET` | `CHANGE-ME-VERIFY` | Email verification token secret |
225
+ | `ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL` | `None` | Email for the default admin user |
226
+ | `ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD` | `None` | Password for the default admin user |
227
+
228
+ ### Dev Mode vs Production Mode
229
+
230
+ The system has two operating modes based on admin configuration:
231
+
232
+ **Dev Mode** (default - no admin configured):
233
+ - All newly registered users are automatically granted superuser privileges
234
+ - Useful for development and testing
235
+
236
+ **Production Mode** (admin configured):
237
+ - Set `ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL` and `ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD`
238
+ - The configured admin user is created/promoted on startup
239
+ - New users are created as regular users (not superusers)
240
+
241
+ ```bash
242
+ # Production mode example
243
+ export ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL=admin@example.com
244
+ export ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD=secure-password
245
+ ```
225
246
 
226
247
  ## Exports
227
248
 
@@ -204,6 +204,27 @@ Settings are loaded from environment variables with the `ZNDRAW_AUTH_` prefix:
204
204
  | `ZNDRAW_AUTH_DATABASE_URL` | `sqlite+aiosqlite:///./zndraw_auth.db` | Database connection URL |
205
205
  | `ZNDRAW_AUTH_RESET_PASSWORD_TOKEN_SECRET` | `CHANGE-ME-RESET` | Password reset token secret |
206
206
  | `ZNDRAW_AUTH_VERIFICATION_TOKEN_SECRET` | `CHANGE-ME-VERIFY` | Email verification token secret |
207
+ | `ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL` | `None` | Email for the default admin user |
208
+ | `ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD` | `None` | Password for the default admin user |
209
+
210
+ ### Dev Mode vs Production Mode
211
+
212
+ The system has two operating modes based on admin configuration:
213
+
214
+ **Dev Mode** (default - no admin configured):
215
+ - All newly registered users are automatically granted superuser privileges
216
+ - Useful for development and testing
217
+
218
+ **Production Mode** (admin configured):
219
+ - Set `ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL` and `ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD`
220
+ - The configured admin user is created/promoted on startup
221
+ - New users are created as regular users (not superusers)
222
+
223
+ ```bash
224
+ # Production mode example
225
+ export ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL=admin@example.com
226
+ export ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD=secure-password
227
+ ```
207
228
 
208
229
  ## Exports
209
230
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "zndraw-auth"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Shared authentication for ZnDraw using fastapi-users"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -29,6 +29,7 @@ from zndraw_auth.db import (
29
29
  Base,
30
30
  User,
31
31
  create_db_and_tables,
32
+ ensure_default_admin,
32
33
  get_async_session,
33
34
  get_user_db,
34
35
  )
@@ -51,6 +52,7 @@ __all__ = [
51
52
  "User",
52
53
  # Database
53
54
  "create_db_and_tables",
55
+ "ensure_default_admin",
54
56
  "get_async_session",
55
57
  "get_user_db",
56
58
  # Schemas
@@ -0,0 +1,146 @@
1
+ """Database models and session management."""
2
+
3
+ import logging
4
+ import uuid
5
+ from collections.abc import AsyncGenerator
6
+ from functools import lru_cache
7
+ from typing import Annotated
8
+
9
+ from fastapi import Depends
10
+ from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
11
+ from fastapi_users.password import PasswordHelper
12
+ from sqlalchemy import select
13
+ from sqlalchemy.ext.asyncio import (
14
+ AsyncEngine,
15
+ AsyncSession,
16
+ async_sessionmaker,
17
+ create_async_engine,
18
+ )
19
+ from sqlalchemy.orm import DeclarativeBase
20
+
21
+ from zndraw_auth.settings import AuthSettings, get_auth_settings
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ class Base(DeclarativeBase):
27
+ """SQLAlchemy declarative base."""
28
+
29
+ pass
30
+
31
+
32
+ class User(SQLAlchemyBaseUserTableUUID, Base):
33
+ """User model for authentication.
34
+
35
+ Inherits from fastapi-users base which provides:
36
+ - id: UUID (primary key)
37
+ - email: str (unique, indexed)
38
+ - hashed_password: str
39
+ - is_active: bool (default True)
40
+ - is_superuser: bool (default False)
41
+ - is_verified: bool (default False)
42
+ """
43
+
44
+ pass
45
+
46
+
47
+ @lru_cache
48
+ def get_engine(database_url: str) -> AsyncEngine:
49
+ """Get or create the async engine (cached by URL)."""
50
+ return create_async_engine(database_url, echo=False)
51
+
52
+
53
+ @lru_cache
54
+ def get_session_maker(database_url: str) -> async_sessionmaker[AsyncSession]:
55
+ """Get or create the session maker (cached by URL)."""
56
+ engine = get_engine(database_url)
57
+ return async_sessionmaker(engine, expire_on_commit=False)
58
+
59
+
60
+ async def create_db_and_tables(settings: AuthSettings | None = None) -> None:
61
+ """Create all database tables and ensure default admin exists.
62
+
63
+ Call this in your app's lifespan or startup.
64
+ """
65
+ if settings is None:
66
+ settings = get_auth_settings()
67
+ engine = get_engine(settings.database_url)
68
+ async with engine.begin() as conn:
69
+ await conn.run_sync(Base.metadata.create_all)
70
+
71
+ # Ensure default admin user exists (if configured)
72
+ await ensure_default_admin(settings)
73
+
74
+
75
+ async def ensure_default_admin(settings: AuthSettings | None = None) -> None:
76
+ """Create or promote the default admin user if configured.
77
+
78
+ If DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD are set:
79
+ - Creates the user with is_superuser=True if they don't exist
80
+ - Promotes existing user to superuser if they exist but aren't one
81
+
82
+ If not configured, does nothing (dev mode - all users are superusers).
83
+ """
84
+ if settings is None:
85
+ settings = get_auth_settings()
86
+
87
+ admin_email = settings.default_admin_email
88
+ admin_password = settings.default_admin_password
89
+
90
+ if admin_email is None:
91
+ log.info("No default admin configured - running in dev mode")
92
+ return
93
+
94
+ if admin_password is None:
95
+ log.warning(
96
+ "DEFAULT_ADMIN_EMAIL is set but DEFAULT_ADMIN_PASSWORD is not - "
97
+ "skipping admin creation"
98
+ )
99
+ return
100
+
101
+ session_maker = get_session_maker(settings.database_url)
102
+ password_helper = PasswordHelper()
103
+
104
+ async with session_maker() as session:
105
+ # Check if user already exists
106
+ result = await session.execute(
107
+ select(User).where(User.email == admin_email) # type: ignore[arg-type]
108
+ )
109
+ existing_user = result.scalar_one_or_none()
110
+
111
+ if existing_user is None:
112
+ # Create new admin user
113
+ hashed_password = password_helper.hash(admin_password.get_secret_value())
114
+ admin_user = User(
115
+ email=admin_email,
116
+ hashed_password=hashed_password,
117
+ is_active=True,
118
+ is_superuser=True,
119
+ is_verified=True,
120
+ )
121
+ session.add(admin_user)
122
+ await session.commit()
123
+ log.info(f"Created default admin user: {admin_email}")
124
+ elif not existing_user.is_superuser:
125
+ # Promote existing user to superuser
126
+ existing_user.is_superuser = True
127
+ await session.commit()
128
+ log.info(f"Promoted user to superuser: {admin_email}")
129
+ else:
130
+ log.debug(f"Default admin already exists: {admin_email}")
131
+
132
+
133
+ async def get_async_session(
134
+ settings: Annotated[AuthSettings, Depends(get_auth_settings)],
135
+ ) -> AsyncGenerator[AsyncSession, None]:
136
+ """FastAPI dependency that yields an async database session."""
137
+ session_maker = get_session_maker(settings.database_url)
138
+ async with session_maker() as session:
139
+ yield session
140
+
141
+
142
+ async def get_user_db(
143
+ session: Annotated[AsyncSession, Depends(get_async_session)],
144
+ ) -> AsyncGenerator[SQLAlchemyUserDatabase[User, uuid.UUID], None]:
145
+ """FastAPI dependency that yields the user database adapter."""
146
+ yield SQLAlchemyUserDatabase[User, uuid.UUID](session, User)
@@ -2,7 +2,7 @@
2
2
 
3
3
  from functools import lru_cache
4
4
 
5
- from pydantic import SecretStr
5
+ from pydantic import SecretStr, computed_field
6
6
  from pydantic_settings import BaseSettings, SettingsConfigDict
7
7
 
8
8
 
@@ -11,6 +11,13 @@ class AuthSettings(BaseSettings):
11
11
 
12
12
  All settings can be overridden with ZNDRAW_AUTH_ prefix.
13
13
  Example: ZNDRAW_AUTH_SECRET_KEY=your-secret-key
14
+
15
+ Admin mode vs Dev mode:
16
+ - If DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD are set, the system
17
+ runs in "production mode": only the configured admin is a superuser,
18
+ and new users are created as regular users.
19
+ - If they are NOT set, the system runs in "dev mode": all newly
20
+ registered users are automatically granted superuser privileges.
14
21
  """
15
22
 
16
23
  model_config = SettingsConfigDict(
@@ -30,6 +37,19 @@ class AuthSettings(BaseSettings):
30
37
  reset_password_token_secret: SecretStr = SecretStr("CHANGE-ME-RESET")
31
38
  verification_token_secret: SecretStr = SecretStr("CHANGE-ME-VERIFY")
32
39
 
40
+ # Default admin user (production mode)
41
+ default_admin_email: str | None = None
42
+ """Email for the default admin user. If set, enables production mode."""
43
+
44
+ default_admin_password: SecretStr | None = None
45
+ """Password for the default admin user."""
46
+
47
+ @computed_field # type: ignore[prop-decorator]
48
+ @property
49
+ def is_dev_mode(self) -> bool:
50
+ """True if no admin credentials configured (all users become superusers)."""
51
+ return self.default_admin_email is None
52
+
33
53
 
34
54
  @lru_cache
35
55
  def get_auth_settings() -> AuthSettings:
@@ -28,6 +28,7 @@ from fastapi_users.authentication import (
28
28
  from fastapi_users.db import SQLAlchemyUserDatabase
29
29
 
30
30
  from zndraw_auth.db import User, get_user_db
31
+ from zndraw_auth.schemas import UserUpdate
31
32
  from zndraw_auth.settings import AuthSettings, get_auth_settings
32
33
 
33
34
  # --- User Manager ---
@@ -41,12 +42,17 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
41
42
 
42
43
  reset_password_token_secret: str
43
44
  verification_token_secret: str
45
+ is_dev_mode: bool = False
44
46
 
45
47
  async def on_after_register(
46
48
  self, user: User, request: Request | None = None
47
49
  ) -> None:
48
50
  """Called after successful registration."""
49
- print(f"User {user.id} has registered.")
51
+ if self.is_dev_mode and not user.is_superuser:
52
+ await self.update(UserUpdate(is_superuser=True), user, safe=False)
53
+ print(f"User {user.id} has registered (granted superuser - dev mode).")
54
+ else:
55
+ print(f"User {user.id} has registered.")
50
56
 
51
57
  async def on_after_forgot_password(
52
58
  self, user: User, token: str, request: Request | None = None
@@ -73,6 +79,7 @@ async def get_user_manager(
73
79
  manager.verification_token_secret = (
74
80
  settings.verification_token_secret.get_secret_value()
75
81
  )
82
+ manager.is_dev_mode = settings.is_dev_mode
76
83
  yield manager
77
84
 
78
85
 
@@ -1,80 +0,0 @@
1
- """Database models and session management."""
2
-
3
- import uuid
4
- from collections.abc import AsyncGenerator
5
- from functools import lru_cache
6
- from typing import Annotated
7
-
8
- from fastapi import Depends
9
- from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
10
- from sqlalchemy.ext.asyncio import (
11
- AsyncEngine,
12
- AsyncSession,
13
- async_sessionmaker,
14
- create_async_engine,
15
- )
16
- from sqlalchemy.orm import DeclarativeBase
17
-
18
- from zndraw_auth.settings import AuthSettings, get_auth_settings
19
-
20
-
21
- class Base(DeclarativeBase):
22
- """SQLAlchemy declarative base."""
23
-
24
- pass
25
-
26
-
27
- class User(SQLAlchemyBaseUserTableUUID, Base):
28
- """User model for authentication.
29
-
30
- Inherits from fastapi-users base which provides:
31
- - id: UUID (primary key)
32
- - email: str (unique, indexed)
33
- - hashed_password: str
34
- - is_active: bool (default True)
35
- - is_superuser: bool (default False)
36
- - is_verified: bool (default False)
37
- """
38
-
39
- pass
40
-
41
-
42
- @lru_cache
43
- def get_engine(database_url: str) -> AsyncEngine:
44
- """Get or create the async engine (cached by URL)."""
45
- return create_async_engine(database_url, echo=False)
46
-
47
-
48
- @lru_cache
49
- def get_session_maker(database_url: str) -> async_sessionmaker[AsyncSession]:
50
- """Get or create the session maker (cached by URL)."""
51
- engine = get_engine(database_url)
52
- return async_sessionmaker(engine, expire_on_commit=False)
53
-
54
-
55
- async def create_db_and_tables(settings: AuthSettings | None = None) -> None:
56
- """Create all database tables.
57
-
58
- Call this in your app's lifespan or startup.
59
- """
60
- if settings is None:
61
- settings = get_auth_settings()
62
- engine = get_engine(settings.database_url)
63
- async with engine.begin() as conn:
64
- await conn.run_sync(Base.metadata.create_all)
65
-
66
-
67
- async def get_async_session(
68
- settings: Annotated[AuthSettings, Depends(get_auth_settings)],
69
- ) -> AsyncGenerator[AsyncSession, None]:
70
- """FastAPI dependency that yields an async database session."""
71
- session_maker = get_session_maker(settings.database_url)
72
- async with session_maker() as session:
73
- yield session
74
-
75
-
76
- async def get_user_db(
77
- session: Annotated[AsyncSession, Depends(get_async_session)],
78
- ) -> AsyncGenerator[SQLAlchemyUserDatabase[User, uuid.UUID], None]:
79
- """FastAPI dependency that yields the user database adapter."""
80
- yield SQLAlchemyUserDatabase[User, uuid.UUID](session, User)