simple-module-auth 0.0.1__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.
- simple_module_auth-0.0.1/.gitignore +59 -0
- simple_module_auth-0.0.1/LICENSE +21 -0
- simple_module_auth-0.0.1/PKG-INFO +71 -0
- simple_module_auth-0.0.1/README.md +43 -0
- simple_module_auth-0.0.1/auth/__init__.py +1 -0
- simple_module_auth-0.0.1/auth/contracts/__init__.py +5 -0
- simple_module_auth-0.0.1/auth/contracts/schemas.py +75 -0
- simple_module_auth-0.0.1/auth/deps.py +60 -0
- simple_module_auth-0.0.1/auth/locales/en.json +6 -0
- simple_module_auth-0.0.1/auth/locales/es.json +6 -0
- simple_module_auth-0.0.1/auth/module.py +26 -0
- simple_module_auth-0.0.1/auth/py.typed +0 -0
- simple_module_auth-0.0.1/package.json +16 -0
- simple_module_auth-0.0.1/pyproject.toml +52 -0
- simple_module_auth-0.0.1/tests/test_deps.py +100 -0
- simple_module_auth-0.0.1/tests/test_module.py +31 -0
- simple_module_auth-0.0.1/tests/test_user_context.py +95 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# UV
|
|
11
|
+
uv.lock
|
|
12
|
+
|
|
13
|
+
# Node
|
|
14
|
+
node_modules/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
|
|
22
|
+
# Environment
|
|
23
|
+
.env
|
|
24
|
+
|
|
25
|
+
# Database
|
|
26
|
+
*.db
|
|
27
|
+
*.sqlite3
|
|
28
|
+
|
|
29
|
+
# Module-managed runtime state (e.g. uploaded dataset files,
|
|
30
|
+
# default storage_dir for SM_DATASETS_STORAGE_DIR).
|
|
31
|
+
var/
|
|
32
|
+
|
|
33
|
+
# file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
|
|
34
|
+
uploads/
|
|
35
|
+
|
|
36
|
+
# Vite
|
|
37
|
+
host/static/dist/
|
|
38
|
+
|
|
39
|
+
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
40
|
+
# or via `make gen-pages`).
|
|
41
|
+
host/client_app/modules.manifest.json
|
|
42
|
+
host/client_app/modules.generated.ts
|
|
43
|
+
host/client_app/modules.generated.css
|
|
44
|
+
|
|
45
|
+
# Worktrees
|
|
46
|
+
.worktrees/
|
|
47
|
+
|
|
48
|
+
# Performance profiles
|
|
49
|
+
.memray/
|
|
50
|
+
.benchmarks/
|
|
51
|
+
|
|
52
|
+
# OS
|
|
53
|
+
.DS_Store
|
|
54
|
+
Thumbs.db
|
|
55
|
+
|
|
56
|
+
.playwright-cli/*
|
|
57
|
+
.playwright-mcp/*
|
|
58
|
+
host/client_app/.playwright-cli/*
|
|
59
|
+
.superpowers/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anto Subash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple_module_auth
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Session-cookie authentication primitives — middleware, login/logout, redirect helpers for simple_module
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: authentication,cookie,fastapi,session,simple-module
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: itsdangerous>=2.2
|
|
25
|
+
Requires-Dist: simple-module-core==0.0.1
|
|
26
|
+
Requires-Dist: simple-module-db==0.0.1
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# simple_module_auth
|
|
30
|
+
|
|
31
|
+
Session-cookie authentication primitives for [simple_module](https://github.com/antosubash/simple_module_python) apps. Provides the `SessionMiddleware` wiring, login/logout helpers, and login-redirect handling used by the `simple_module_users` module.
|
|
32
|
+
|
|
33
|
+
**Heads up:** for most apps you don't install this directly — `simple_module_users` pulls it in and builds the email+password auth flow on top of these primitives.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install simple_module_auth
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## What it provides
|
|
42
|
+
|
|
43
|
+
- Starlette `SessionMiddleware` configuration reading `SM_SECRET_KEY` and `SM_SESSION_COOKIE_*` env vars.
|
|
44
|
+
- `current_user_id` FastAPI dependency reading the signed session cookie.
|
|
45
|
+
- Redirect-to-login helpers for unauthenticated requests on Inertia routes.
|
|
46
|
+
- Login-required decorator / dependency for protecting routes without pulling in the heavier `simple_module_users` package.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from fastapi import APIRouter, Depends
|
|
52
|
+
from simple_module_auth import require_login
|
|
53
|
+
|
|
54
|
+
router = APIRouter()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/me")
|
|
58
|
+
async def me(user_id: int = Depends(require_login)):
|
|
59
|
+
return {"user_id": user_id}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Routes that need more than just "logged in" (e.g. role/permission checks) should use `simple_module_permissions` instead.
|
|
63
|
+
|
|
64
|
+
## Depends on
|
|
65
|
+
|
|
66
|
+
- `simple_module_core`, `simple_module_db`
|
|
67
|
+
- `itsdangerous`
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# simple_module_auth
|
|
2
|
+
|
|
3
|
+
Session-cookie authentication primitives for [simple_module](https://github.com/antosubash/simple_module_python) apps. Provides the `SessionMiddleware` wiring, login/logout helpers, and login-redirect handling used by the `simple_module_users` module.
|
|
4
|
+
|
|
5
|
+
**Heads up:** for most apps you don't install this directly — `simple_module_users` pulls it in and builds the email+password auth flow on top of these primitives.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install simple_module_auth
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What it provides
|
|
14
|
+
|
|
15
|
+
- Starlette `SessionMiddleware` configuration reading `SM_SECRET_KEY` and `SM_SESSION_COOKIE_*` env vars.
|
|
16
|
+
- `current_user_id` FastAPI dependency reading the signed session cookie.
|
|
17
|
+
- Redirect-to-login helpers for unauthenticated requests on Inertia routes.
|
|
18
|
+
- Login-required decorator / dependency for protecting routes without pulling in the heavier `simple_module_users` package.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from fastapi import APIRouter, Depends
|
|
24
|
+
from simple_module_auth import require_login
|
|
25
|
+
|
|
26
|
+
router = APIRouter()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/me")
|
|
30
|
+
async def me(user_id: int = Depends(require_login)):
|
|
31
|
+
return {"user_id": user_id}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Routes that need more than just "logged in" (e.g. role/permission checks) should use `simple_module_permissions` instead.
|
|
35
|
+
|
|
36
|
+
## Depends on
|
|
37
|
+
|
|
38
|
+
- `simple_module_core`, `simple_module_db`
|
|
39
|
+
- `itsdangerous`
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Auth module — shared contracts (UserContext, deps)."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Auth data types shared with other modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
# Runtime import would be circular: auth -> users -> auth.
|
|
10
|
+
# Only imported for type-hints, never at runtime.
|
|
11
|
+
from users.models import User
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class UserContext:
|
|
16
|
+
"""Authenticated user information for downstream handlers."""
|
|
17
|
+
|
|
18
|
+
id: str
|
|
19
|
+
email: str
|
|
20
|
+
name: str
|
|
21
|
+
roles: list[str] = field(default_factory=list)
|
|
22
|
+
tenant_id: str | None = None
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_user(cls, user: User | Any) -> UserContext:
|
|
26
|
+
"""Build a UserContext from a users.models.User with eagerly-loaded roles.
|
|
27
|
+
|
|
28
|
+
Duck-typed to avoid importing users.models at runtime — any object
|
|
29
|
+
exposing .id, .email, .full_name, .roles[*].name, .tenant_id works.
|
|
30
|
+
The caller is responsible for eager-loading roles (selectinload).
|
|
31
|
+
"""
|
|
32
|
+
return cls(
|
|
33
|
+
id=str(user.id),
|
|
34
|
+
email=user.email,
|
|
35
|
+
name=user.full_name or user.email,
|
|
36
|
+
roles=[r.name for r in user.roles],
|
|
37
|
+
tenant_id=user.tenant_id,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def to_session_dict(self) -> dict[str, Any]:
|
|
41
|
+
"""Serialize to a JSON-safe dict for the signed session cookie.
|
|
42
|
+
|
|
43
|
+
The inverse of :meth:`from_session_dict`. Adding a new field to
|
|
44
|
+
``UserContext`` requires updating both methods here — not the
|
|
45
|
+
AuthMiddleware cache helper, which stays schema-agnostic."""
|
|
46
|
+
return {
|
|
47
|
+
"id": self.id,
|
|
48
|
+
"email": self.email,
|
|
49
|
+
"name": self.name,
|
|
50
|
+
"roles": list(self.roles),
|
|
51
|
+
"tenant_id": self.tenant_id,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_session_dict(cls, payload: Any) -> UserContext | None:
|
|
56
|
+
"""Rebuild from :meth:`to_session_dict` output. Returns ``None`` on any
|
|
57
|
+
shape mismatch so callers can fall through to a fresh DB load."""
|
|
58
|
+
if not isinstance(payload, dict):
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return cls(
|
|
62
|
+
id=str(payload["id"]),
|
|
63
|
+
email=str(payload["email"]),
|
|
64
|
+
name=str(payload["name"]),
|
|
65
|
+
roles=list(payload.get("roles") or []),
|
|
66
|
+
tenant_id=payload.get("tenant_id"),
|
|
67
|
+
)
|
|
68
|
+
except (KeyError, TypeError, ValueError):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def has_role(self, role: str) -> bool:
|
|
72
|
+
return role in self.roles
|
|
73
|
+
|
|
74
|
+
def has_any_role(self, roles: list[str]) -> bool:
|
|
75
|
+
return bool(set(self.roles) & set(roles))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""FastAPI auth dependencies — get_current_user, require_permission."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, HTTPException, Request
|
|
8
|
+
from simple_module_hosting.i18n_deps import TranslatorDep
|
|
9
|
+
|
|
10
|
+
from auth.contracts.schemas import UserContext
|
|
11
|
+
|
|
12
|
+
_ADMIN_ROLE = "admin"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def get_current_user(request: Request, t: TranslatorDep) -> UserContext:
|
|
16
|
+
"""Extract the authenticated user from request state.
|
|
17
|
+
|
|
18
|
+
The auth middleware must set ``request.state.user`` before this runs.
|
|
19
|
+
"""
|
|
20
|
+
user = getattr(request.state, "user", None)
|
|
21
|
+
if user is None:
|
|
22
|
+
raise HTTPException(status_code=401, detail=t.t("auth.errors.not_authenticated"))
|
|
23
|
+
return user
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
CurrentUser = Annotated[UserContext, Depends(get_current_user)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def require_permission(*permissions: str):
|
|
30
|
+
"""Create a dependency that checks if the user has required permissions.
|
|
31
|
+
|
|
32
|
+
Usage::
|
|
33
|
+
|
|
34
|
+
@router.post("/", dependencies=[Depends(require_permission("products.create"))])
|
|
35
|
+
async def create_product(...): ...
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
async def check(
|
|
39
|
+
request: Request,
|
|
40
|
+
t: TranslatorDep,
|
|
41
|
+
user: UserContext = Depends(get_current_user),
|
|
42
|
+
):
|
|
43
|
+
# Admin role bypasses permission checks
|
|
44
|
+
if _ADMIN_ROLE in user.roles:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# Get permission registry from app state
|
|
48
|
+
perm_registry = request.app.state.sm.permissions
|
|
49
|
+
user_perms = perm_registry.get_permissions_for_roles(user.roles)
|
|
50
|
+
|
|
51
|
+
if not any(p in user_perms for p in permissions):
|
|
52
|
+
raise HTTPException(
|
|
53
|
+
status_code=403,
|
|
54
|
+
detail=t.t(
|
|
55
|
+
"auth.errors.missing_permission",
|
|
56
|
+
permissions=", ".join(permissions),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return Depends(check)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Auth module — shared contracts (UserContext, deps).
|
|
2
|
+
|
|
3
|
+
Intentionally minimal: this module owns the PUBLIC interface (UserContext,
|
|
4
|
+
get_current_user, CurrentUser, require_permission) that every other module
|
|
5
|
+
imports. Keeping it stable prevents churn when auth internals change.
|
|
6
|
+
|
|
7
|
+
All authentication logic (middleware, login, signup, OAuth) lives in the
|
|
8
|
+
users module.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthModule(ModuleBase):
|
|
20
|
+
meta = ModuleMeta(
|
|
21
|
+
name="Auth",
|
|
22
|
+
route_prefix="/auth",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def locale_dirs(self) -> dict[str, Path]:
|
|
26
|
+
return {"auth": Path(str(importlib.resources.files(__package__) / "locales"))}
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the Auth module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^2.0.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {}
|
|
16
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simple_module_auth"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Session-cookie authentication primitives — middleware, login/logout, redirect helpers for simple_module"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
|
|
10
|
+
keywords = ["simple-module", "fastapi", "authentication", "session", "cookie"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Framework :: FastAPI",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"itsdangerous>=2.2",
|
|
25
|
+
"simple_module_core==0.0.1",
|
|
26
|
+
"simple_module_db==0.0.1",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.entry-points.simple_module]
|
|
30
|
+
auth = "auth.module:AuthModule"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/antosubash/simple_module_python"
|
|
34
|
+
Repository = "https://github.com/antosubash/simple_module_python"
|
|
35
|
+
Issues = "https://github.com/antosubash/simple_module_python/issues"
|
|
36
|
+
Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["auth"]
|
|
44
|
+
|
|
45
|
+
# Ship the module-root package.json inside the wheel so the host can
|
|
46
|
+
# discover JS deps via importlib.resources after a pip install.
|
|
47
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
48
|
+
"package.json" = "auth/package.json"
|
|
49
|
+
|
|
50
|
+
[tool.uv.sources]
|
|
51
|
+
simple_module_core = { workspace = true }
|
|
52
|
+
simple_module_db = { workspace = true }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tests for auth FastAPI dependencies (get_current_user, require_permission)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from unittest.mock import MagicMock
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from auth.contracts.schemas import UserContext
|
|
12
|
+
from auth.deps import get_current_user, require_permission
|
|
13
|
+
from fastapi import HTTPException
|
|
14
|
+
from simple_module_core.i18n import I18nRegistry, Translator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _translator() -> Translator:
|
|
18
|
+
"""Build a Translator loaded with the auth module's ``en.json`` locale.
|
|
19
|
+
|
|
20
|
+
This exercises the real message templates so assertions on rendered
|
|
21
|
+
detail strings remain meaningful.
|
|
22
|
+
"""
|
|
23
|
+
locales = Path(str(importlib.resources.files("auth") / "locales"))
|
|
24
|
+
registry = I18nRegistry(default_locale="en", supported_locales=["en"])
|
|
25
|
+
registry.add_source("auth", locales)
|
|
26
|
+
registry.load()
|
|
27
|
+
return Translator(registry, locale="en", default_locale="en")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestGetCurrentUser:
|
|
31
|
+
async def test_raises_401_when_no_user(self):
|
|
32
|
+
"""get_current_user raises 401 when request.state has no user."""
|
|
33
|
+
request = MagicMock()
|
|
34
|
+
del request.state.user
|
|
35
|
+
|
|
36
|
+
with pytest.raises(HTTPException) as exc_info:
|
|
37
|
+
await get_current_user(request, _translator())
|
|
38
|
+
assert exc_info.value.status_code == 401
|
|
39
|
+
|
|
40
|
+
async def test_returns_user_when_present(self):
|
|
41
|
+
"""get_current_user returns the user from request.state."""
|
|
42
|
+
user = UserContext(id="u1", email="u@test.com", name="User", roles=["user"])
|
|
43
|
+
request = MagicMock()
|
|
44
|
+
request.state.user = user
|
|
45
|
+
|
|
46
|
+
result = await get_current_user(request, _translator())
|
|
47
|
+
assert result.id == "u1"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestRequirePermission:
|
|
51
|
+
async def test_raises_403_when_missing_permission(self, app):
|
|
52
|
+
"""The require_permission check raises 403 when user lacks permissions."""
|
|
53
|
+
dep = require_permission("products.delete")
|
|
54
|
+
check_fn = dep.dependency
|
|
55
|
+
|
|
56
|
+
request = MagicMock()
|
|
57
|
+
request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
|
|
58
|
+
|
|
59
|
+
user = UserContext(id="u1", email="u@test.com", name="User", roles=["viewer"])
|
|
60
|
+
|
|
61
|
+
with pytest.raises(HTTPException) as exc_info:
|
|
62
|
+
await check_fn(request, _translator(), user)
|
|
63
|
+
assert exc_info.value.status_code == 403
|
|
64
|
+
|
|
65
|
+
async def test_admin_bypasses_permission_check(self, app):
|
|
66
|
+
"""The require_permission check allows admin users through."""
|
|
67
|
+
dep = require_permission("products.delete")
|
|
68
|
+
check_fn = dep.dependency
|
|
69
|
+
|
|
70
|
+
request = MagicMock()
|
|
71
|
+
request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
|
|
72
|
+
|
|
73
|
+
admin_user = UserContext(id="a1", email="admin@test.com", name="Admin", roles=["admin"])
|
|
74
|
+
await check_fn(request, _translator(), admin_user)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestRequirePermissionAdvanced:
|
|
78
|
+
async def test_multiple_permissions_any_match(self, app):
|
|
79
|
+
"""User with any of the required permissions should pass."""
|
|
80
|
+
dep = require_permission("products.view", "products.edit")
|
|
81
|
+
check_fn = dep.dependency
|
|
82
|
+
|
|
83
|
+
request = MagicMock()
|
|
84
|
+
request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
|
|
85
|
+
|
|
86
|
+
admin = UserContext(id="a1", email="a@t.com", name="Admin", roles=["admin"])
|
|
87
|
+
await check_fn(request, _translator(), admin)
|
|
88
|
+
|
|
89
|
+
async def test_non_admin_without_permission_fails(self, app):
|
|
90
|
+
dep = require_permission("products.delete")
|
|
91
|
+
check_fn = dep.dependency
|
|
92
|
+
|
|
93
|
+
request = MagicMock()
|
|
94
|
+
request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
|
|
95
|
+
|
|
96
|
+
user = UserContext(id="u1", email="u@t.com", name="User", roles=["user"])
|
|
97
|
+
with pytest.raises(HTTPException) as exc_info:
|
|
98
|
+
await check_fn(request, _translator(), user)
|
|
99
|
+
assert exc_info.value.status_code == 403
|
|
100
|
+
assert "products.delete" in str(exc_info.value.detail)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Tests for AuthModule: meta-only after Keycloak removal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from auth.module import AuthModule
|
|
6
|
+
from simple_module_core.menu import MenuRegistry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestAuthModuleRegistration:
|
|
10
|
+
async def test_auth_module_has_correct_meta(self):
|
|
11
|
+
mod = AuthModule()
|
|
12
|
+
assert mod.meta.name == "Auth"
|
|
13
|
+
assert mod.meta.route_prefix == "/auth"
|
|
14
|
+
|
|
15
|
+
async def test_auth_module_registers_no_menu_items(self):
|
|
16
|
+
"""AuthModule is now meta-only — it registers no menu items."""
|
|
17
|
+
mod = AuthModule()
|
|
18
|
+
reg = MenuRegistry()
|
|
19
|
+
mod.register_menu_items(reg)
|
|
20
|
+
assert len(reg.all_items) == 0
|
|
21
|
+
|
|
22
|
+
async def test_auth_module_has_no_routes(self):
|
|
23
|
+
"""AuthModule no longer registers any routes — it's contracts-only."""
|
|
24
|
+
from fastapi import APIRouter
|
|
25
|
+
|
|
26
|
+
mod = AuthModule()
|
|
27
|
+
api_router = APIRouter()
|
|
28
|
+
view_router = APIRouter()
|
|
29
|
+
mod.register_routes(api_router, view_router)
|
|
30
|
+
assert len(api_router.routes) == 0
|
|
31
|
+
assert len(view_router.routes) == 0
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Tests for the UserContext value object (construction, roles, tenant)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from auth.contracts.schemas import UserContext
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestUserContextFromUser:
|
|
9
|
+
async def test_from_user_basic(self):
|
|
10
|
+
"""from_user correctly maps id, email, name, roles, and tenant_id."""
|
|
11
|
+
import uuid
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
|
|
14
|
+
role_a = SimpleNamespace(name="admin")
|
|
15
|
+
role_b = SimpleNamespace(name="editor")
|
|
16
|
+
fake_user = SimpleNamespace(
|
|
17
|
+
id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
|
18
|
+
email="charlie@example.com",
|
|
19
|
+
full_name="Charlie Brown",
|
|
20
|
+
roles=[role_a, role_b],
|
|
21
|
+
tenant_id="tenant-42",
|
|
22
|
+
)
|
|
23
|
+
ctx = UserContext.from_user(fake_user)
|
|
24
|
+
assert ctx.id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
25
|
+
assert ctx.email == "charlie@example.com"
|
|
26
|
+
assert ctx.name == "Charlie Brown"
|
|
27
|
+
assert ctx.roles == ["admin", "editor"]
|
|
28
|
+
assert ctx.tenant_id == "tenant-42"
|
|
29
|
+
|
|
30
|
+
async def test_from_user_name_fallback_to_email(self):
|
|
31
|
+
"""When full_name is None, ctx.name falls back to the user's email."""
|
|
32
|
+
import uuid
|
|
33
|
+
from types import SimpleNamespace
|
|
34
|
+
|
|
35
|
+
fake_user = SimpleNamespace(
|
|
36
|
+
id=uuid.uuid4(),
|
|
37
|
+
email="dana@example.com",
|
|
38
|
+
full_name=None,
|
|
39
|
+
roles=[],
|
|
40
|
+
tenant_id=None,
|
|
41
|
+
)
|
|
42
|
+
ctx = UserContext.from_user(fake_user)
|
|
43
|
+
assert ctx.name == "dana@example.com"
|
|
44
|
+
assert ctx.tenant_id is None
|
|
45
|
+
|
|
46
|
+
async def test_from_user_no_roles(self):
|
|
47
|
+
"""from_user with empty roles produces an empty list."""
|
|
48
|
+
import uuid
|
|
49
|
+
from types import SimpleNamespace
|
|
50
|
+
|
|
51
|
+
fake_user = SimpleNamespace(
|
|
52
|
+
id=uuid.uuid4(),
|
|
53
|
+
email="eve@example.com",
|
|
54
|
+
full_name="Eve",
|
|
55
|
+
roles=[],
|
|
56
|
+
tenant_id=None,
|
|
57
|
+
)
|
|
58
|
+
ctx = UserContext.from_user(fake_user)
|
|
59
|
+
assert ctx.roles == []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestUserContextRoles:
|
|
63
|
+
async def test_has_role(self):
|
|
64
|
+
ctx = UserContext(id="1", email="a@b.com", name="A", roles=["admin", "user"])
|
|
65
|
+
assert ctx.has_role("admin") is True
|
|
66
|
+
assert ctx.has_role("superadmin") is False
|
|
67
|
+
|
|
68
|
+
async def test_has_any_role(self):
|
|
69
|
+
ctx = UserContext(id="1", email="a@b.com", name="A", roles=["editor"])
|
|
70
|
+
assert ctx.has_any_role(["admin", "editor"]) is True
|
|
71
|
+
assert ctx.has_any_role(["admin", "superadmin"]) is False
|
|
72
|
+
|
|
73
|
+
async def test_has_any_role_empty_user_roles(self):
|
|
74
|
+
ctx = UserContext(id="1", email="a@b.com", name="A", roles=[])
|
|
75
|
+
assert ctx.has_any_role(["admin"]) is False
|
|
76
|
+
|
|
77
|
+
async def test_has_any_role_empty_check_list(self):
|
|
78
|
+
ctx = UserContext(id="1", email="a@b.com", name="A", roles=["admin"])
|
|
79
|
+
assert ctx.has_any_role([]) is False
|
|
80
|
+
|
|
81
|
+
async def test_has_role_case_sensitive(self):
|
|
82
|
+
ctx = UserContext(id="1", email="a@b.com", name="A", roles=["Admin"])
|
|
83
|
+
assert ctx.has_role("admin") is False
|
|
84
|
+
assert ctx.has_role("Admin") is True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestUserContextDefaults:
|
|
88
|
+
async def test_tenant_id_default_is_none(self):
|
|
89
|
+
"""Direct construction without tenant_id should default to None."""
|
|
90
|
+
ctx = UserContext(id="1", email="a@b.com", name="A")
|
|
91
|
+
assert ctx.tenant_id is None
|
|
92
|
+
|
|
93
|
+
async def test_roles_default_is_empty_list(self):
|
|
94
|
+
ctx = UserContext(id="1", email="a@b.com", name="A")
|
|
95
|
+
assert ctx.roles == []
|