simple-module-auth 0.0.34__tar.gz → 0.0.36__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.
Files changed (25) hide show
  1. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/PKG-INFO +3 -3
  2. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/contracts/schemas.py +18 -2
  3. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/deps.py +7 -5
  4. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/middleware.py +33 -1
  5. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/package.json +1 -1
  6. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/pyproject.toml +3 -3
  7. simple_module_auth-0.0.36/tests/test_auth_next_target.py +98 -0
  8. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/tests/test_deps.py +23 -12
  9. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/tests/test_user_context.py +44 -2
  10. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/.gitignore +0 -0
  11. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/LICENSE +0 -0
  12. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/README.md +0 -0
  13. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/__init__.py +0 -0
  14. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/contracts/__init__.py +0 -0
  15. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/contracts/provider.py +0 -0
  16. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/contracts/resolver.py +0 -0
  17. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/locales/en.json +0 -0
  18. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/locales/es.json +0 -0
  19. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/module.py +0 -0
  20. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/py.typed +0 -0
  21. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/auth/state.py +0 -0
  22. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/tests/test_auth_middleware.py +0 -0
  23. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/tests/test_auth_provider_protocol.py +0 -0
  24. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/tests/test_module.py +0 -0
  25. {simple_module_auth-0.0.34 → simple_module_auth-0.0.36}/tests/test_resolver_registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_auth
3
- Version: 0.0.34
3
+ Version: 0.0.36
4
4
  Summary: Session-cookie authentication primitives — middleware, login/logout, redirect helpers for simple_module
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -22,8 +22,8 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: itsdangerous>=2.2
25
- Requires-Dist: simple-module-core==0.0.34
26
- Requires-Dist: simple-module-db==0.0.34
25
+ Requires-Dist: simple-module-core==0.0.36
26
+ Requires-Dist: simple-module-db==0.0.36
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # simple_module_auth
@@ -20,13 +20,20 @@ class UserContext:
20
20
  name: str
21
21
  roles: list[str] = field(default_factory=list)
22
22
  tenant_id: str | None = None
23
+ # Permissions the principal carries itself — a token scope or IdP claim a
24
+ # provider maps in. Frozen in the session cookie like ``roles``; anything
25
+ # revocable belongs in a grant source. The resolver drops ``*`` from here.
26
+ permissions: list[str] = field(default_factory=list)
23
27
 
24
28
  @classmethod
25
29
  def from_user(cls, user: User | Any) -> UserContext:
26
30
  """Build a UserContext from a users.models.User with eagerly-loaded roles.
27
31
 
28
32
  Duck-typed to avoid importing users.models at runtime — any object
29
- exposing .id, .email, .full_name, .roles[*].name, .tenant_id works.
33
+ exposing .id, .email, .full_name, .roles[*].name works. The user row
34
+ carries no tenant: the active tenant is resolved per request from
35
+ ``tenants_membership``, so ``tenant_id`` stays ``None`` here (only a
36
+ generic provider's claim path sets it).
30
37
  The caller is responsible for eager-loading roles (selectinload).
31
38
  """
32
39
  return cls(
@@ -34,7 +41,6 @@ class UserContext:
34
41
  email=user.email,
35
42
  name=user.full_name or user.email,
36
43
  roles=[r.name for r in user.roles],
37
- tenant_id=user.tenant_id,
38
44
  )
39
45
 
40
46
  def to_session_dict(self) -> dict[str, Any]:
@@ -49,6 +55,7 @@ class UserContext:
49
55
  "name": self.name,
50
56
  "roles": list(self.roles),
51
57
  "tenant_id": self.tenant_id,
58
+ "permissions": list(self.permissions),
52
59
  }
53
60
 
54
61
  @classmethod
@@ -64,6 +71,8 @@ class UserContext:
64
71
  name=str(payload["name"]),
65
72
  roles=list(payload.get("roles") or []),
66
73
  tenant_id=payload.get("tenant_id"),
74
+ # Absent on cookies written before the field existed.
75
+ permissions=list(payload.get("permissions") or []),
67
76
  )
68
77
  except (KeyError, TypeError, ValueError):
69
78
  return None
@@ -71,5 +80,12 @@ class UserContext:
71
80
  def has_role(self, role: str) -> bool:
72
81
  return role in self.roles
73
82
 
83
+ def has_permission(self, permission: str) -> bool:
84
+ """Exact match against :attr:`permissions` only — no roles, no wildcard.
85
+
86
+ Gate routes with ``RequiresPermission``, which also folds in roles and
87
+ grant sources; this is for the principal's own claims alone."""
88
+ return permission in self.permissions
89
+
74
90
  def has_any_role(self, roles: list[str]) -> bool:
75
91
  return bool(set(self.roles) & set(roles))
@@ -5,7 +5,9 @@ from __future__ import annotations
5
5
  from typing import Annotated
6
6
 
7
7
  from fastapi import Depends, HTTPException, Request
8
+ from simple_module_core.permissions import grants
8
9
  from simple_module_hosting.i18n_deps import TranslatorDep
10
+ from simple_module_hosting.permissions import ensure_resolved_permissions
9
11
 
10
12
  from auth.contracts.schemas import UserContext
11
13
 
@@ -44,11 +46,11 @@ def require_permission(*permissions: str):
44
46
  if _ADMIN_ROLE in user.roles:
45
47
  return
46
48
 
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):
49
+ # The same resolution RequiresPermission reads: the registry's role map
50
+ # plus module grant sources. This used to call get_permissions_for_roles
51
+ # without a map, which grants every non-admin role nothing (GH #337).
52
+ held = await ensure_resolved_permissions(request)
53
+ if not any(grants(held, p) for p in permissions):
52
54
  raise HTTPException(
53
55
  status_code=403,
54
56
  detail=t.t(
@@ -33,6 +33,27 @@ def _query_suffix(request: Request) -> str:
33
33
  return f"?{request.url.query}" if request.url.query else ""
34
34
 
35
35
 
36
+ def _is_navigation(request: Request) -> bool:
37
+ """Whether this request is the page the visitor asked for, not something it loaded.
38
+
39
+ Only a navigation may become the post-login target: the sign-in page's
40
+ own favicon request used to overwrite it, so sign-in landed on
41
+ /favicon.ico (#416). Inertia visits count — a session expiring mid-click
42
+ must still return the user to that page. Without fetch metadata (old
43
+ clients, scripts) keep the previous behaviour.
44
+ """
45
+ headers = request.headers
46
+ if headers.get("x-inertia") == "true":
47
+ return True
48
+ dest = headers.get("sec-fetch-dest")
49
+ if dest is not None:
50
+ return dest == "document"
51
+ mode = headers.get("sec-fetch-mode")
52
+ if mode is not None:
53
+ return mode == "navigate"
54
+ return True
55
+
56
+
36
57
  class AuthMiddleware:
37
58
  """Authenticate requests via the registered AuthProvider.
38
59
 
@@ -76,6 +97,11 @@ class AuthMiddleware:
76
97
  prefix_paths, exact_paths = provider.get_public_paths()
77
98
  is_public = any(path.startswith(p) for p in prefix_paths) or path in exact_paths
78
99
 
100
+ # Contract with the host's RateLimitMiddleware (plain scope key, no import
101
+ # between them): whether *this* middleware treats the request as anonymous-
102
+ # allowed, from all three sources above. One source of truth.
103
+ scope.setdefault("state", {})["auth_public"] = is_public
104
+
79
105
  request = Request(scope)
80
106
  user_ctx = await provider.resolve_user(request)
81
107
 
@@ -103,7 +129,13 @@ class AuthMiddleware:
103
129
  # goes to the provider — session-based providers ignore it,
104
130
  # but a redirect-based one (OIDC) needs it in the auth URL.
105
131
  session = scope.get("session", {})
106
- next_url = safe_next_or_none(request.url.path + _query_suffix(request))
132
+ # Only a navigation is a target: a subresource (the sign-in
133
+ # page's own favicon) would overwrite it (#416).
134
+ next_url = (
135
+ safe_next_or_none(request.url.path + _query_suffix(request))
136
+ if _is_navigation(request)
137
+ else None
138
+ )
107
139
  if next_url is not None:
108
140
  session[SESSION_NEXT_KEY] = next_url
109
141
  response = RedirectResponse(
@@ -6,7 +6,7 @@
6
6
  "peerDependencies": {
7
7
  "react": "^19.0.0",
8
8
  "react-dom": "^19.0.0",
9
- "@inertiajs/react": "^2.0.0",
9
+ "@inertiajs/react": "^3.7.0",
10
10
  "@simple-module-py/ui": "*"
11
11
  },
12
12
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_auth"
3
- version = "0.0.34"
3
+ version = "0.0.36"
4
4
  description = "Session-cookie authentication primitives — middleware, login/logout, redirect helpers for simple_module"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -22,8 +22,8 @@ classifiers = [
22
22
  ]
23
23
  dependencies = [
24
24
  "itsdangerous>=2.2",
25
- "simple_module_core==0.0.34",
26
- "simple_module_db==0.0.34",
25
+ "simple_module_core==0.0.36",
26
+ "simple_module_db==0.0.36",
27
27
  ]
28
28
 
29
29
  [project.entry-points.simple_module]
@@ -0,0 +1,98 @@
1
+ """Only top-level navigations may become the post-login target (#416)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import pytest
7
+ from auth.middleware import AuthMiddleware
8
+ from auth.state import AuthState
9
+ from fastapi import FastAPI, Request
10
+ from simple_module_core.redirect_safety import SESSION_NEXT_KEY
11
+ from starlette.middleware.sessions import SessionMiddleware
12
+ from starlette.responses import JSONResponse
13
+
14
+ SECRET = "test-next-target-secret"
15
+
16
+
17
+ class _StubProvider:
18
+ name = "stub"
19
+
20
+ async def resolve_user(self, request):
21
+ return None
22
+
23
+ def get_login_url(self, request, next_url=None):
24
+ return "/stub/login"
25
+
26
+ def get_logout_url(self, request):
27
+ return "/stub/logout"
28
+
29
+ def get_public_paths(self):
30
+ return (("/stub/login", "/stub/public/"), ())
31
+
32
+ def is_bearer_request(self, request):
33
+ return False
34
+
35
+
36
+ def _build_app(provider) -> FastAPI:
37
+ app = FastAPI()
38
+ app.state.auth = AuthState(auth_provider=provider, principal_resolvers=[])
39
+
40
+ # /static/ is framework-public, so this reads the session without auth.
41
+ async def _next(request: Request):
42
+ return JSONResponse({"next": request.session.get(SESSION_NEXT_KEY)})
43
+
44
+ async def _any(path: str = ""):
45
+ return JSONResponse({})
46
+
47
+ app.add_api_route("/static/next", _next, methods=["GET"])
48
+ app.add_api_route("/{path:path}", _any, methods=["GET"])
49
+ app.add_middleware(AuthMiddleware)
50
+ app.add_middleware(SessionMiddleware, secret_key=SECRET)
51
+ return app
52
+
53
+
54
+ CASES = [
55
+ ({"Sec-Fetch-Dest": "image", "Sec-Fetch-Mode": "no-cors"}, None), # favicon
56
+ ({"Sec-Fetch-Dest": "script", "Sec-Fetch-Mode": "no-cors"}, None),
57
+ ({"Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors"}, None), # fetch()
58
+ ({"Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate"}, "/protected/page"),
59
+ ({"Sec-Fetch-Mode": "navigate"}, "/protected/page"),
60
+ ({"X-Inertia": "true", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors"}, "/protected/page"),
61
+ ({}, "/protected/page"), # no metadata: previous behaviour
62
+ ]
63
+
64
+
65
+ @pytest.mark.parametrize(("headers", "expected"), CASES)
66
+ async def test_only_navigations_record_the_target(headers, expected):
67
+ seen: list[str | None] = []
68
+
69
+ class _Recording(_StubProvider):
70
+ def get_login_url(self, request, next_url=None):
71
+ seen.append(next_url)
72
+ return "/stub/login"
73
+
74
+ app = _build_app(_Recording())
75
+ async with httpx.AsyncClient(
76
+ transport=httpx.ASGITransport(app=app), base_url="http://test"
77
+ ) as c:
78
+ resp = await c.get("/protected/page", headers=headers)
79
+ recorded = (await c.get("/static/next")).json()["next"]
80
+ assert resp.status_code == 302
81
+ assert resp.headers["location"] == "/stub/login"
82
+ assert seen == [expected]
83
+ assert recorded == expected
84
+
85
+
86
+ async def test_a_subresource_does_not_overwrite_a_recorded_target():
87
+ """The bug: the sign-in page's favicon request replaced the real target."""
88
+ app = _build_app(_StubProvider())
89
+ async with httpx.AsyncClient(
90
+ transport=httpx.ASGITransport(app=app), base_url="http://test"
91
+ ) as c:
92
+ nav = {"Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate"}
93
+ assert (await c.get("/protected/page", headers=nav)).status_code == 302
94
+ icon = {"Sec-Fetch-Dest": "image", "Sec-Fetch-Mode": "no-cors"}
95
+ resp = await c.get("/favicon.ico", headers=icon)
96
+ assert resp.status_code == 302 # the redirect itself is unchanged
97
+ recorded = (await c.get("/static/next")).json()["next"]
98
+ assert recorded == "/protected/page"
@@ -27,6 +27,14 @@ def _translator() -> Translator:
27
27
  return Translator(registry, locale="en", default_locale="en")
28
28
 
29
29
 
30
+ def _request(app, user: UserContext) -> MagicMock:
31
+ """A request whose state is real, so the permission cache reads as absent."""
32
+ request = MagicMock()
33
+ request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
34
+ request.state = SimpleNamespace(user=user)
35
+ return request
36
+
37
+
30
38
  class TestGetCurrentUser:
31
39
  async def test_raises_401_when_no_user(self):
32
40
  """get_current_user raises 401 when request.state has no user."""
@@ -53,10 +61,8 @@ class TestRequirePermission:
53
61
  dep = require_permission("products.delete")
54
62
  check_fn = dep.dependency
55
63
 
56
- request = MagicMock()
57
- request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
58
-
59
64
  user = UserContext(id="u1", email="u@test.com", name="User", roles=["viewer"])
65
+ request = _request(app, user)
60
66
 
61
67
  with pytest.raises(HTTPException) as exc_info:
62
68
  await check_fn(request, _translator(), user)
@@ -67,10 +73,8 @@ class TestRequirePermission:
67
73
  dep = require_permission("products.delete")
68
74
  check_fn = dep.dependency
69
75
 
70
- request = MagicMock()
71
- request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
72
-
73
76
  admin_user = UserContext(id="a1", email="admin@test.com", name="Admin", roles=["admin"])
77
+ request = _request(app, admin_user)
74
78
  await check_fn(request, _translator(), admin_user)
75
79
 
76
80
 
@@ -80,21 +84,28 @@ class TestRequirePermissionAdvanced:
80
84
  dep = require_permission("products.view", "products.edit")
81
85
  check_fn = dep.dependency
82
86
 
83
- request = MagicMock()
84
- request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
85
-
86
87
  admin = UserContext(id="a1", email="a@t.com", name="Admin", roles=["admin"])
88
+ request = _request(app, admin)
87
89
  await check_fn(request, _translator(), admin)
88
90
 
89
91
  async def test_non_admin_without_permission_fails(self, app):
90
92
  dep = require_permission("products.delete")
91
93
  check_fn = dep.dependency
92
94
 
93
- request = MagicMock()
94
- request.app.state.sm = SimpleNamespace(permissions=app.state.sm.permissions)
95
-
96
95
  user = UserContext(id="u1", email="u@t.com", name="User", roles=["user"])
96
+ request = _request(app, user)
97
97
  with pytest.raises(HTTPException) as exc_info:
98
98
  await check_fn(request, _translator(), user)
99
99
  assert exc_info.value.status_code == 403
100
100
  assert "products.delete" in str(exc_info.value.detail)
101
+
102
+ async def test_non_admin_role_mapped_in_registry_passes(self, app):
103
+ """A role granted the key via the registry's role map is admitted.
104
+
105
+ Regression for GH #337: the check resolved roles without the role map,
106
+ so every non-admin role held nothing and this was a 403.
107
+ """
108
+ app.state.sm.permissions.map_role("clerk", ["products.delete"])
109
+ dep = require_permission("products.delete")
110
+ user = UserContext(id="u1", email="u@t.com", name="User", roles=["clerk"])
111
+ await dep.dependency(_request(app, user), _translator(), user)
@@ -18,14 +18,14 @@ class TestUserContextFromUser:
18
18
  email="charlie@example.com",
19
19
  full_name="Charlie Brown",
20
20
  roles=[role_a, role_b],
21
- tenant_id="tenant-42",
22
21
  )
23
22
  ctx = UserContext.from_user(fake_user)
24
23
  assert ctx.id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
25
24
  assert ctx.email == "charlie@example.com"
26
25
  assert ctx.name == "Charlie Brown"
27
26
  assert ctx.roles == ["admin", "editor"]
28
- assert ctx.tenant_id == "tenant-42"
27
+ # The user row carries no tenant; the resolver supplies it per request.
28
+ assert ctx.tenant_id is None
29
29
 
30
30
  async def test_from_user_name_fallback_to_email(self):
31
31
  """When full_name is None, ctx.name falls back to the user's email."""
@@ -93,3 +93,45 @@ class TestUserContextDefaults:
93
93
  async def test_roles_default_is_empty_list(self):
94
94
  ctx = UserContext(id="1", email="a@b.com", name="A")
95
95
  assert ctx.roles == []
96
+
97
+ async def test_permissions_default_is_empty_list(self):
98
+ ctx = UserContext(id="1", email="a@b.com", name="A")
99
+ assert ctx.permissions == []
100
+
101
+ async def test_positional_construction_unchanged(self):
102
+ ctx = UserContext("1", "a@b.com", "A", ["admin"], "t1")
103
+ assert ctx.tenant_id == "t1"
104
+ assert ctx.permissions == []
105
+
106
+
107
+ class TestUserContextPermissions:
108
+ async def test_session_round_trip_keeps_permissions(self):
109
+ ctx = UserContext(id="1", email="a@b.com", name="A", permissions=["x.read", "x.write"])
110
+ payload = ctx.to_session_dict()
111
+ assert payload["permissions"] == ["x.read", "x.write"]
112
+ assert UserContext.from_session_dict(payload) == ctx
113
+
114
+ async def test_legacy_cookie_without_permissions_key(self):
115
+ """A cookie written before the field existed still decodes."""
116
+ payload = {"id": "1", "email": "a@b.com", "name": "A", "roles": [], "tenant_id": None}
117
+ ctx = UserContext.from_session_dict(payload)
118
+ assert ctx is not None
119
+ assert ctx.permissions == []
120
+
121
+ async def test_null_permissions_decodes_empty(self):
122
+ payload = {"id": "1", "email": "a@b.com", "name": "A", "permissions": None}
123
+ ctx = UserContext.from_session_dict(payload)
124
+ assert ctx is not None
125
+ assert ctx.permissions == []
126
+
127
+ async def test_from_user_leaves_permissions_empty(self):
128
+ from types import SimpleNamespace
129
+
130
+ user = SimpleNamespace(id=1, email="a@b.com", full_name="A", roles=[])
131
+ assert UserContext.from_user(user).permissions == []
132
+
133
+ async def test_has_permission_exact_match(self):
134
+ ctx = UserContext(id="1", email="a@b.com", name="A", permissions=["x.read"])
135
+ assert ctx.has_permission("x.read") is True
136
+ assert ctx.has_permission("x.write") is False
137
+ assert ctx.has_permission("x") is False