Coverage for src / lexigram / admin / auth / permissions.py: 35%
163 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Permission definitions and checks for lexigram-admin.
3Integrates with lexigram-auth authorization_service for RBAC.
4Provides declarative permission decorators and guard middleware.
5"""
7from __future__ import annotations
9from dataclasses import dataclass, field
10from enum import StrEnum
11from functools import wraps
12from typing import (
13 TYPE_CHECKING,
14 Any,
15 ParamSpec,
16 Protocol,
17 TypeVar,
18 runtime_checkable,
19)
21from lexigram.contracts.auth.guard import AuthorizerProtocol as AuthorizationService
23if TYPE_CHECKING:
24 from collections.abc import Awaitable, Callable, Sequence
27class Action(StrEnum):
28 """Standard CRUD actions for resources."""
30 LIST = "list"
31 VIEW = "view"
32 CREATE = "create"
33 UPDATE = "update"
34 DELETE = "delete"
35 EXPORT = "export"
36 BULK_DELETE = "bulk_delete"
37 BULK_UPDATE = "bulk_update"
39 @classmethod
40 def all_actions(cls) -> list[Action]:
41 """All standard actions."""
42 return list(cls)
44 @classmethod
45 def read_only(cls) -> list[Action]:
46 """Read-only actions."""
47 return [cls.LIST, cls.VIEW, cls.EXPORT]
49 @classmethod
50 def write(cls) -> list[Action]:
51 """Write actions."""
52 return [cls.CREATE, cls.UPDATE, cls.DELETE, cls.BULK_DELETE, cls.BULK_UPDATE]
55@dataclass(frozen=True, slots=True)
56class Permission:
57 """A single permission definition.
59 Permissions follow the pattern: resource.action
60 Examples: "users.list", "posts.delete", "admin.settings"
61 """
63 resource: str
64 action: str
66 def __str__(self) -> str:
67 return f"{self.resource}.{self.action}"
69 @classmethod
70 def from_string(cls, s: str) -> Permission:
71 """Parse from "resource.action" string."""
72 parts = s.split(".", 1)
73 if len(parts) != 2:
74 raise ValueError(f"Invalid permission format: {s}")
75 return cls(resource=parts[0], action=parts[1])
77 @classmethod
78 def for_resource(
79 cls,
80 resource: str,
81 actions: Sequence[Action] | None = None,
82 ) -> list[Permission]:
83 """Generate permissions for a resource."""
84 actions = actions or Action.all_actions()
85 return [cls(resource=resource, action=a.value) for a in actions]
88@dataclass(slots=True)
89class PermissionSet:
90 """Collection of permissions with set operations."""
92 permissions: set[str] = field(default_factory=set)
94 def add(self, *perms: str | Permission) -> PermissionSet:
95 """Add permissions."""
96 for p in perms:
97 self.permissions.add(str(p))
98 return self
100 def remove(self, *perms: str | Permission) -> PermissionSet:
101 """Remove permissions."""
102 for p in perms:
103 self.permissions.discard(str(p))
104 return self
106 def has(self, perm: str | Permission) -> bool:
107 """Check if permission exists."""
108 perm_str = str(perm)
109 # Check exact match
110 if perm_str in self.permissions:
111 return True
112 # Check wildcard patterns
113 if "*" in self.permissions:
114 return True
115 # Check resource wildcard (e.g., "users.*")
116 parts = perm_str.split(".", 1)
117 return bool(len(parts) == 2 and f"{parts[0]}.*" in self.permissions)
119 def has_any(self, *perms: str | Permission) -> bool:
120 """Check if any permission exists."""
121 return any(self.has(p) for p in perms)
123 def has_all(self, *perms: str | Permission) -> bool:
124 """Check if all permissions exist."""
125 return all(self.has(p) for p in perms)
127 def __contains__(self, perm: str | Permission) -> bool:
128 return self.has(perm)
130 def __iter__(self) -> Any:
131 return iter(self.permissions)
133 def __len__(self) -> int:
134 return len(self.permissions)
136 @classmethod
137 def from_roles(
138 cls,
139 roles: Sequence[str],
140 authorization_service: AuthorizationService,
141 ) -> PermissionSet:
142 """Build permission set from role names using authorization_service."""
143 perms = set()
144 for role in roles:
145 role_perms = authorization_service.get_role_permissions(role) # type: ignore[attr-defined]
146 perms.update(role_perms)
147 return cls(permissions=perms)
150@runtime_checkable
151class HasPermissions(Protocol):
152 """Protocol for objects that have permissions."""
154 @property
155 def permissions(self) -> Sequence[str]:
156 """Get list of permission strings."""
157 ...
159 @property
160 def roles(self) -> Sequence[str]:
161 """Get list of role names."""
162 ...
165def get_user_permissions(
166 user: Any,
167 authorization_service: AuthorizationService,
168) -> PermissionSet:
169 """Extract permissions from a user object.
171 Handles both direct permissions and role-based permissions.
172 """
173 perms = PermissionSet()
175 # Direct permissions
176 direct = getattr(user, "permissions", []) or []
177 perms.add(*direct)
179 # Role-based permissions
180 roles = getattr(user, "roles", []) or []
181 for role in roles:
182 role_perms = authorization_service.get_role_permissions(role) # type: ignore[attr-defined]
183 perms.add(*role_perms)
185 return perms
188P = ParamSpec("P")
189R = TypeVar("R")
192def _get_permission_set(request: Any) -> PermissionSet | None:
193 """Extract PermissionSet from request state."""
194 state = getattr(request, "state", None)
195 if state is None:
196 return None
197 ps = getattr(state, "permissions", None)
198 if isinstance(ps, PermissionSet):
199 return ps
200 return None
203def require_permission(*permissions: str | Permission) -> Any:
204 """Decorator to require any of the listed permissions on a handler.
206 Checks ``request.state.permissions`` (a PermissionSet). If not present
207 or the required permission is missing, raises PermissionDeniedError.
209 Usage:
210 @require_permission("users.list")
211 async def list_users(request): ...
213 @require_permission("posts.create", "posts.update")
214 async def manage_posts(request): ...
215 """
216 perm_strings = list(map(str, permissions))
218 def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
219 @wraps(func)
220 async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
221 request = args[0] if args else kwargs.get("request")
222 if request is None:
223 raise ValueError("No request object found for permission check")
225 ps = _get_permission_set(request)
226 if ps is None:
227 from lexigram.admin.exceptions import PermissionDeniedError
229 raise PermissionDeniedError(message="Authentication required")
231 if not ps.has_any(*perm_strings):
232 from lexigram.admin.exceptions import PermissionDeniedError
234 raise PermissionDeniedError(
235 message=f"Requires permission: {' or '.join(perm_strings)}",
236 )
238 return await func(*args, **kwargs)
240 wrapper.__required_permissions__ = list(perm_strings) # type: ignore[attr-defined]
241 return wrapper
243 return decorator
246def require_all_permissions(*permissions: str | Permission) -> Any:
247 """Decorator to require ALL listed permissions on a handler.
249 Checks ``request.state.permissions`` (a PermissionSet). Raises
250 PermissionDeniedError if any permission is missing.
252 Usage:
253 @require_all_permissions("users.list", "users.view")
254 async def view_users(request): ...
255 """
256 perm_strings = list(map(str, permissions))
258 def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
259 @wraps(func)
260 async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
261 request = args[0] if args else kwargs.get("request")
262 if request is None:
263 raise ValueError("No request object found for permission check")
265 ps = _get_permission_set(request)
266 if ps is None:
267 from lexigram.admin.exceptions import PermissionDeniedError
269 raise PermissionDeniedError(message="Authentication required")
271 if not ps.has_all(*perm_strings):
272 from lexigram.admin.exceptions import PermissionDeniedError
274 raise PermissionDeniedError(
275 message=f"Requires all permissions: {', '.join(perm_strings)}",
276 )
278 return await func(*args, **kwargs)
280 wrapper.__required_permissions__ = list(perm_strings) # type: ignore[attr-defined]
281 wrapper.__require_all__ = True # type: ignore[attr-defined]
282 return wrapper
284 return decorator
287def require_role(*roles: str) -> Any:
288 """Decorator to require the user to have any of the listed roles.
290 Checks ``request.user.roles`` (a list of role name strings). Raises
291 PermissionDeniedError if the user has none of the required roles.
293 Usage:
294 @require_role("admin")
295 async def admin_panel(request): ...
297 @require_role("admin", "editor")
298 async def editor_panel(request): ...
299 """
301 def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
302 @wraps(func)
303 async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
304 request = args[0] if args else kwargs.get("request")
305 if request is None:
306 raise ValueError("No request object found for role check")
308 user = getattr(request, "user", None)
309 if user is None:
310 from lexigram.admin.exceptions import PermissionDeniedError
312 raise PermissionDeniedError(message="Authentication required")
314 user_roles = set(getattr(user, "roles", []) or [])
315 if not user_roles.intersection(roles):
316 from lexigram.admin.exceptions import PermissionDeniedError
318 raise PermissionDeniedError(
319 message=f"Requires role: {' or '.join(roles)}",
320 )
322 return await func(*args, **kwargs)
324 wrapper.__required_roles__ = list(roles) # type: ignore[attr-defined]
325 return wrapper
327 return decorator
330def create_permission_context(
331 user: Any,
332 authorization_service: AuthorizationService,
333) -> dict[str, Any]:
334 """Create template context with permission helpers.
336 Returns a dict containing the user object and callables that delegate
337 to ``get_user_permissions`` so templates can check permissions without
338 depending on a ``PermissionChecker`` class.
340 Returns:
341 Dict with ``user``, ``can``, ``can_any``, ``can_all``, ``has_role``,
342 ``has_any_role``, and ``permissions`` keys.
343 """
344 perms = get_user_permissions(user, authorization_service)
345 user_roles: set[str] = set(getattr(user, "roles", []) or [])
346 return {
347 "user": user,
348 "can": perms.has,
349 "can_any": perms.has_any,
350 "can_all": perms.has_all,
351 "has_role": lambda role: role in user_roles,
352 "has_any_role": lambda *roles: bool(user_roles.intersection(roles)),
353 "permissions": perms,
354 }