Coverage for src/lexigram/auth/authz/service.py: 36%

116 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 00:58 +0800

1"""Unified Authorization Service for Lexigram. 

2 

3This service consolidates RBAC (Role-Based Access Control) logic, combining 

4the best features of permission management, role hierarchies, and ABAC 

5(Attribute-Based Access Control) policies. 

6 

7It serves as the single source of truth for all authorization decisions 

8in a Lexigram application. 

9 

10Example: 

11 Checking authorization:: 

12 

13 from lexigram.auth.authz import AuthorizationService 

14 

15 auth_service = AuthorizationService() 

16 

17 # Define roles with inheritance 

18 auth_service.set_roles({ 

19 "admin": {"permissions": ["*"]}, 

20 "editor": {"inherits": ["viewer"], "permissions": ["articles.write"]}, 

21 "viewer": {"permissions": ["articles.read"]}, 

22 }) 

23 

24 # Authorize a user action 

25 result = await auth_service.authorize(user, "articles", "read") 

26 if result.is_ok(): 

27 # User can read articles 

28 pass 

29 

30 Using with dependency injection:: 

31 

32 from lexigram.di import inject 

33 

34 class ArticleController(Controller): 

35 @inject 

36 def __init__(self, auth: AuthorizationService): 

37 self.auth = auth 

38 

39 async def check_permission(self, user, resource: str, action: str) -> bool: 

40 result = await self.auth.authorize(user, resource, action) 

41 return result.is_ok() 

42 

43See Also: 

44 - :class:`lexigram.auth.policies.engine.PolicyEngine`: ABAC policy engine. 

45 - :class:`lexigram.auth.types.RoleDefinition`: Role definition model. 

46""" 

47 

48from __future__ import annotations 

49 

50import asyncio 

51from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable 

52 

53from lexigram.auth.authz._check_mixin import _AuthCheckMixin 

54from lexigram.auth.authz._parsers import ( 

55 ListValueParser, 

56 NoneValueParser, 

57 StringValueParser, 

58 ValueParser, 

59 ValueParserRegistry, 

60) 

61from lexigram.auth.policies.engine import PolicyEngine 

62from lexigram.auth.types import RoleDefinition 

63from lexigram.contracts.exceptions import DependencyError, UnresolvableDependencyError 

64from lexigram.logging import get_logger 

65 

66if TYPE_CHECKING: 

67 from lexigram.contracts.audit import AuditLoggerProtocol 

68 

69 

70# Use local types if available, otherwise define minimal protocols 

71@runtime_checkable 

72class UserProtocol(Protocol): 

73 roles: list[str] 

74 permissions: list[str] 

75 

76 

77logger = get_logger(__name__) 

78 

79 

80class AuthorizationService(_AuthCheckMixin): 

81 """Central service for all authorization checks.""" 

82 

83 def __init__( 

84 self, 

85 permission_cache_ttl: float = 300.0, 

86 *, 

87 max_cache_entries: int = 10000, 

88 audit_logger: AuditLoggerProtocol | None = None, 

89 ) -> None: 

90 """Initialize a new authorization service instance. 

91 

92 Args: 

93 permission_cache_ttl: TTL in seconds for the per-user permission 

94 cache. Defaults to 300 seconds (5 minutes). 

95 max_cache_entries: Maximum number of entries in the permission cache. 

96 When the cache reaches this size the oldest entry (by insertion 

97 order) is evicted before adding a new one. Defaults to 10 000. 

98 audit_logger: Optional :class:`~lexigram.contracts.audit.AuditLoggerProtocol` 

99 used to record authorization decisions (granted / denied). 

100 No audit entries are written when *None*. 

101 """ 

102 self._lock = asyncio.Lock() 

103 self._roles: dict[str, Any] = {} 

104 self._schemas: dict[str, Any] = {} 

105 self._policy_engine: Any | None = None 

106 self._delegation_manager: Any | None = None 

107 self._value_parsers = ValueParserRegistry() 

108 self._role_flatten_cache: dict[frozenset[str], tuple[float, set[str]]] = {} 

109 self._permission_cache: dict[str, tuple[float, set[str]]] = {} 

110 self._permission_cache_ttl = permission_cache_ttl 

111 self._max_cache_entries = max_cache_entries 

112 self._audit_logger: AuditLoggerProtocol | None = audit_logger 

113 

114 def set_policies(self, policies: list[Any]) -> None: 

115 """Set the ABAC policies and initialize the engine.""" 

116 self._policy_engine = PolicyEngine(policies) 

117 logger.info("✓ ABAC Policy Engine configured with %d policies", len(policies)) 

118 

119 def set_roles(self, roles: dict[str, RoleDefinition | dict[str, Any]]) -> None: 

120 """Set the global role definitions (usually from config/seed).""" 

121 for name, role in roles.items(): 

122 self.register_role(name, role) 

123 logger.info("✓ RBAC Roles configured with %d definitions", len(self._roles)) 

124 

125 def __repr__(self) -> str: 

126 """Return developer-friendly string representation.""" 

127 return f"AuthorizationService(roles={len(self._roles)}, schemas={len(self._schemas)})" 

128 

129 def register_role(self, name: str, role: RoleDefinition | dict[str, Any]) -> None: 

130 """Register a role definition.""" 

131 # OPT-AUTH-1: Invalidate cache when roles change 

132 self._role_flatten_cache.clear() 

133 

134 if isinstance(role, dict): 

135 role_def = RoleDefinition( 

136 name=name, 

137 description=role.get("description", ""), 

138 permissions=self._parse_list(role.get("permissions", [])), 

139 inherits=self._parse_list(role.get("inherits", [])), 

140 ) 

141 else: 

142 role_def = role 

143 

144 self._roles[name] = role_def 

145 # Invalidate caches 

146 self._role_flatten_cache.clear() 

147 self._permission_cache.clear() 

148 logger.debug("Registered role: %s (inherits: %s)", name, role_def.inherits) 

149 

150 def get_role(self, name: str) -> Any | None: 

151 return self._roles.get(name) 

152 

153 async def sync_from_db(self, container: Any) -> None: 

154 """Load roles from database and merge with existing (YAML) roles.""" 

155 # Dynamic import to avoid circular deps 

156 from lexigram.contracts.data import DatabaseProviderProtocol 

157 

158 # GenericRepository is a concrete class from lexigram-sql. 

159 # We resolve it dynamically to avoid top-level sibling dependencies. 

160 

161 # We assume a standard Role entity exists or use a generic dict approach 

162 # For now, strict typing is relaxed to allow flexible adoption 

163 

164 db = await container.resolve(DatabaseProviderProtocol) 

165 if db: 

166 # Try to query a standard roles table if it exists 

167 try: 

168 # 1. Try 'roles' first (modern standard) 

169 try: 

170 generic_repository = await container.resolve( 

171 "GenericRepository", 

172 ) 

173 except ( 

174 DependencyError, 

175 UnresolvableDependencyError, 

176 KeyError, 

177 AttributeError, 

178 ): 

179 # GenericRepository not registered — skip DB role sync 

180 logger.debug( 

181 "GenericRepository not found in container; skipping DB role sync", 

182 ) 

183 return 

184 

185 repo = generic_repository(db, "roles", dict) 

186 db_roles = await repo.find_many() 

187 except (RuntimeError, OSError, LookupError): 

188 # 2. Fallback to 'admin_roles' (legacy) 

189 try: 

190 repo = generic_repository(db, "admin_roles", dict) 

191 db_roles = await repo.find_many() 

192 except (RuntimeError, OSError, LookupError) as e: 

193 logger.debug( 

194 "Table 'admin_roles' not found or inaccessible: %s", 

195 e, 

196 ) 

197 db_roles = [] 

198 

199 for role in db_roles: 

200 # DB takes priority over YAML if same name 

201 if isinstance(role, dict): 

202 name = role.get("name") 

203 if name: 

204 self.register_role(name, role) 

205 else: 

206 name = getattr(role, "name", None) 

207 if name: 

208 self.register_role(name, role) 

209 

210 if db_roles: 

211 logger.info( 

212 "✓ RBAC Roles synced from database (%d roles)", 

213 len(db_roles), 

214 ) 

215 

216 def create_role( 

217 self, 

218 name: str, 

219 permissions: list[str] | None = None, 

220 inherits: list[str] | None = None, 

221 ) -> None: 

222 """Create or update a role definition.""" 

223 

224 self.register_role( 

225 name, 

226 RoleDefinition( 

227 name=name, 

228 permissions=permissions or [], 

229 inherits=inherits or [], 

230 ), 

231 ) 

232 

233 def add_role_permission(self, role_name: str, permission: str) -> None: 

234 """Add a permission to an existing role.""" 

235 role = self._roles.get(role_name) 

236 if not role: 

237 self.create_role(role_name, [permission]) 

238 return 

239 

240 # Handle dict or object 

241 if isinstance(role, dict): 

242 perms = role.get("permissions") 

243 if isinstance(perms, list): 

244 perms = set(perms) 

245 role["permissions"] = perms 

246 elif isinstance(perms, set): 

247 pass 

248 else: 

249 role["permissions"] = set() 

250 role["permissions"].add(permission) 

251 elif hasattr(role, "permissions"): 

252 # If object has mutable permissions set/list 

253 if isinstance(role.permissions, list): 

254 if permission not in role.permissions: 

255 role.permissions.append(permission) 

256 elif isinstance(role.permissions, set): 

257 role.permissions.add(permission) 

258 

259 # Invalidate caches 

260 self._role_flatten_cache.clear() 

261 self._permission_cache.clear() 

262 

263 def get_role_permissions(self, role: str) -> set[str]: 

264 """Get all permissions for a role, including inherited ones.""" 

265 effective_roles = self._flatten_roles({role}) 

266 return self._get_user_permissions(effective_roles) 

267 

268 def remove_role(self, name: str) -> None: 

269 """Remove a role definition and invalidate dependent caches. 

270 

271 Missing roles are a no-op. In-memory merges mean a removed role 

272 can reappear if DB-synced again; callers persist the deletion. 

273 

274 Args: 

275 name: Role name to remove. 

276 """ 

277 self._roles.pop(name, None) 

278 self._role_flatten_cache.clear() 

279 self._permission_cache.clear() 

280 logger.debug("Removed role: %s", name) 

281 

282 def invalidate_user(self, user_id: str) -> None: 

283 """Invalidate the permission cache for a specific user.""" 

284 self._permission_cache.pop(user_id, None) 

285 logger.debug("Invalidated permission cache for user: %s", user_id) 

286 

287 

288def __getattr__(name: str) -> Any: 

289 if name == "authorization_service": 

290 raise AttributeError( 

291 "authorization_service is now async. Use the container to resolve AuthorizationService.", 

292 ) 

293 raise AttributeError(f"module {__name__} has no attribute {name}") 

294 

295 

296__all__ = [ 

297 "AuthorizationService", 

298 "ListValueParser", 

299 "NoneValueParser", 

300 "StringValueParser", 

301 "UserProtocol", 

302 "ValueParser", 

303 "ValueParserRegistry", 

304 "logger", 

305]