simple-module-auth 0.0.35__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.
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/PKG-INFO +3 -3
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/contracts/schemas.py +18 -2
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/deps.py +7 -5
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/middleware.py +33 -1
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/pyproject.toml +3 -3
- simple_module_auth-0.0.36/tests/test_auth_next_target.py +98 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/tests/test_deps.py +23 -12
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/tests/test_user_context.py +44 -2
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/.gitignore +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/LICENSE +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/README.md +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/__init__.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/contracts/__init__.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/contracts/provider.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/contracts/resolver.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/locales/en.json +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/locales/es.json +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/module.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/py.typed +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/auth/state.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/package.json +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/tests/test_auth_middleware.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/tests/test_auth_provider_protocol.py +0 -0
- {simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/tests/test_module.py +0 -0
- {simple_module_auth-0.0.35 → 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.
|
|
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.
|
|
26
|
-
Requires-Dist: simple-module-db==0.0.
|
|
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
|
|
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
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if not any(p
|
|
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
|
-
|
|
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(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_auth"
|
|
3
|
-
version = "0.0.
|
|
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.
|
|
26
|
-
"simple_module_db==0.0.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_auth-0.0.35 → simple_module_auth-0.0.36}/tests/test_auth_provider_protocol.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|