kekkai-cli 1.1.0__py3-none-any.whl → 2.0.0__py3-none-any.whl

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.
@@ -1,408 +0,0 @@
1
- """Enterprise license validation and feature gating.
2
-
3
- Security controls:
4
- - Server-side license enforcement
5
- - Asymmetric (ECDSA P-256) signed license tokens to prevent tampering
6
- - Grace period handling for expiration
7
- - Private key for signing (admin only), public key for validation (distributed)
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import base64
13
- import hashlib
14
- import json
15
- import logging
16
- import time
17
- from dataclasses import dataclass, field
18
- from datetime import UTC, datetime, timedelta
19
- from enum import Enum
20
- from typing import TYPE_CHECKING, Any
21
-
22
- from cryptography.exceptions import InvalidSignature
23
- from cryptography.hazmat.primitives import hashes, serialization
24
- from cryptography.hazmat.primitives.asymmetric import ec
25
-
26
- if TYPE_CHECKING:
27
- pass
28
-
29
- logger = logging.getLogger(__name__)
30
-
31
- GRACE_PERIOD_DAYS = 7
32
- LICENSE_CHECK_INTERVAL = 3600
33
-
34
-
35
- class LicenseStatus(Enum):
36
- """License validation status."""
37
-
38
- VALID = "valid"
39
- EXPIRED = "expired"
40
- GRACE_PERIOD = "grace_period"
41
- INVALID = "invalid"
42
- MISSING = "missing"
43
-
44
-
45
- class LicenseTier(Enum):
46
- """License tier levels."""
47
-
48
- COMMUNITY = "community"
49
- PROFESSIONAL = "professional"
50
- ENTERPRISE = "enterprise"
51
-
52
-
53
- class EnterpriseFeature(Enum):
54
- """Features available in enterprise tier."""
55
-
56
- SSO_SAML = "sso_saml"
57
- RBAC = "rbac"
58
- AUDIT_LOGGING = "audit_logging"
59
- CUSTOM_BRANDING = "custom_branding"
60
- API_RATE_LIMIT_INCREASE = "api_rate_limit_increase"
61
- PRIORITY_SUPPORT = "priority_support"
62
- SLA_GUARANTEE = "sla_guarantee"
63
- MULTI_REGION = "multi_region"
64
- ADVANCED_REPORTS = "advanced_reports"
65
-
66
-
67
- TIER_FEATURES: dict[LicenseTier, frozenset[EnterpriseFeature]] = {
68
- LicenseTier.COMMUNITY: frozenset(),
69
- LicenseTier.PROFESSIONAL: frozenset(
70
- {
71
- EnterpriseFeature.AUDIT_LOGGING,
72
- EnterpriseFeature.API_RATE_LIMIT_INCREASE,
73
- }
74
- ),
75
- LicenseTier.ENTERPRISE: frozenset(
76
- {
77
- EnterpriseFeature.SSO_SAML,
78
- EnterpriseFeature.RBAC,
79
- EnterpriseFeature.AUDIT_LOGGING,
80
- EnterpriseFeature.CUSTOM_BRANDING,
81
- EnterpriseFeature.API_RATE_LIMIT_INCREASE,
82
- EnterpriseFeature.PRIORITY_SUPPORT,
83
- EnterpriseFeature.SLA_GUARANTEE,
84
- EnterpriseFeature.MULTI_REGION,
85
- EnterpriseFeature.ADVANCED_REPORTS,
86
- }
87
- ),
88
- }
89
-
90
-
91
- @dataclass(frozen=True)
92
- class EnterpriseLicense:
93
- """Represents an enterprise license."""
94
-
95
- license_id: str
96
- tenant_id: str
97
- tier: LicenseTier
98
- issued_at: datetime
99
- expires_at: datetime
100
- features: frozenset[EnterpriseFeature] = field(default_factory=frozenset)
101
- max_users: int = 0
102
- max_projects: int = 0
103
- metadata: dict[str, Any] = field(default_factory=dict)
104
-
105
- def is_expired(self) -> bool:
106
- """Check if license is expired (without grace period)."""
107
- return datetime.now(UTC) > self.expires_at
108
-
109
- def is_in_grace_period(self) -> bool:
110
- """Check if license is in grace period."""
111
- now = datetime.now(UTC)
112
- if now <= self.expires_at:
113
- return False
114
- grace_end = self.expires_at + timedelta(days=GRACE_PERIOD_DAYS)
115
- return now <= grace_end
116
-
117
- def has_feature(self, feature: EnterpriseFeature) -> bool:
118
- """Check if license includes a specific feature."""
119
- tier_features = TIER_FEATURES.get(self.tier, frozenset())
120
- return feature in self.features or feature in tier_features
121
-
122
- def to_dict(self) -> dict[str, Any]:
123
- return {
124
- "license_id": self.license_id,
125
- "tenant_id": self.tenant_id,
126
- "tier": self.tier.value,
127
- "issued_at": self.issued_at.isoformat(),
128
- "expires_at": self.expires_at.isoformat(),
129
- "features": [f.value for f in self.features],
130
- "max_users": self.max_users,
131
- "max_projects": self.max_projects,
132
- "metadata": self.metadata,
133
- }
134
-
135
- @classmethod
136
- def from_dict(cls, data: dict[str, Any]) -> EnterpriseLicense:
137
- features = frozenset(EnterpriseFeature(f) for f in data.get("features", []))
138
- return cls(
139
- license_id=str(data["license_id"]),
140
- tenant_id=str(data["tenant_id"]),
141
- tier=LicenseTier(data["tier"]),
142
- issued_at=datetime.fromisoformat(str(data["issued_at"])),
143
- expires_at=datetime.fromisoformat(str(data["expires_at"])),
144
- features=features,
145
- max_users=int(data.get("max_users", 0)),
146
- max_projects=int(data.get("max_projects", 0)),
147
- metadata=data.get("metadata", {}),
148
- )
149
-
150
-
151
- @dataclass
152
- class LicenseCheckResult:
153
- """Result of a license check."""
154
-
155
- status: LicenseStatus
156
- license: EnterpriseLicense | None = None
157
- message: str | None = None
158
- days_until_expiry: int | None = None
159
- grace_days_remaining: int | None = None
160
-
161
-
162
- def generate_keypair() -> tuple[bytes, bytes]:
163
- """Generate a new ECDSA P-256 keypair for license signing.
164
-
165
- Returns:
166
- Tuple of (private_key_pem, public_key_pem) as bytes.
167
- Keep private_key_pem SECRET - only used for signing licenses.
168
- Distribute public_key_pem with the application for verification.
169
- """
170
- private_key = ec.generate_private_key(ec.SECP256R1())
171
- private_pem = private_key.private_bytes(
172
- encoding=serialization.Encoding.PEM,
173
- format=serialization.PrivateFormat.PKCS8,
174
- encryption_algorithm=serialization.NoEncryption(),
175
- )
176
- public_pem = private_key.public_key().public_bytes(
177
- encoding=serialization.Encoding.PEM,
178
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
179
- )
180
- return private_pem, public_pem
181
-
182
-
183
- class LicenseSigner:
184
- """Signs enterprise licenses using ECDSA private key.
185
-
186
- This class should only be used server-side by administrators
187
- to generate license tokens. Never distribute the private key.
188
- """
189
-
190
- _private_key: ec.EllipticCurvePrivateKey
191
-
192
- def __init__(self, private_key_pem: bytes) -> None:
193
- loaded_key = serialization.load_pem_private_key(private_key_pem, password=None)
194
- if not isinstance(loaded_key, ec.EllipticCurvePrivateKey):
195
- raise ValueError("Expected ECDSA private key")
196
- self._private_key = loaded_key
197
-
198
- def create_license_token(self, license: EnterpriseLicense) -> str:
199
- """Create a signed license token using ECDSA.
200
-
201
- The token format is: base64(payload).base64(signature)
202
- """
203
- payload = license.to_dict()
204
- payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
205
- payload_b64 = base64.urlsafe_b64encode(payload_json.encode()).decode().rstrip("=")
206
-
207
- signature = self._private_key.sign(
208
- payload_b64.encode(),
209
- ec.ECDSA(hashes.SHA256()),
210
- )
211
- signature_b64 = base64.urlsafe_b64encode(signature).decode().rstrip("=")
212
-
213
- return f"{payload_b64}.{signature_b64}"
214
-
215
-
216
- class LicenseValidator:
217
- """Validates enterprise licenses using ECDSA public key.
218
-
219
- This class can be safely distributed with the application.
220
- It only requires the public key for verification.
221
- """
222
-
223
- _public_key: ec.EllipticCurvePublicKey
224
-
225
- def __init__(self, public_key_pem: bytes) -> None:
226
- loaded_key = serialization.load_pem_public_key(public_key_pem)
227
- if not isinstance(loaded_key, ec.EllipticCurvePublicKey):
228
- raise ValueError("Expected ECDSA public key")
229
- self._public_key = loaded_key
230
- self._cache: dict[str, tuple[float, LicenseCheckResult]] = {}
231
-
232
- @classmethod
233
- def from_public_key_string(cls, public_key_str: str) -> LicenseValidator:
234
- """Create validator from PEM string (convenience method)."""
235
- return cls(public_key_str.encode())
236
-
237
- def validate_token(self, token: str) -> LicenseCheckResult:
238
- """Validate a license token and return the license if valid.
239
-
240
- Args:
241
- token: The signed license token
242
-
243
- Returns:
244
- LicenseCheckResult with status and license details
245
- """
246
- if not token:
247
- return LicenseCheckResult(
248
- status=LicenseStatus.MISSING,
249
- message="No license token provided",
250
- )
251
-
252
- parts = token.split(".")
253
- if len(parts) != 2:
254
- logger.warning("license.invalid_format")
255
- return LicenseCheckResult(
256
- status=LicenseStatus.INVALID,
257
- message="Invalid license token format",
258
- )
259
-
260
- payload_b64, signature_b64 = parts
261
-
262
- try:
263
- sig_padding = 4 - len(signature_b64) % 4
264
- if sig_padding != 4:
265
- signature_b64 += "=" * sig_padding
266
- signature = base64.urlsafe_b64decode(signature_b64)
267
-
268
- self._public_key.verify(
269
- signature,
270
- payload_b64.encode(),
271
- ec.ECDSA(hashes.SHA256()),
272
- )
273
- except InvalidSignature:
274
- logger.warning("license.invalid_signature")
275
- return LicenseCheckResult(
276
- status=LicenseStatus.INVALID,
277
- message="Invalid license signature",
278
- )
279
- except Exception as e:
280
- logger.warning("license.signature_error: %s", e)
281
- return LicenseCheckResult(
282
- status=LicenseStatus.INVALID,
283
- message="Failed to verify license signature",
284
- )
285
-
286
- try:
287
- padding = 4 - len(payload_b64) % 4
288
- if padding != 4:
289
- payload_b64 += "=" * padding
290
- payload_json = base64.urlsafe_b64decode(payload_b64).decode()
291
- payload = json.loads(payload_json)
292
- license = EnterpriseLicense.from_dict(payload)
293
- except (ValueError, json.JSONDecodeError, KeyError) as e:
294
- logger.warning("license.decode_error: %s", e)
295
- return LicenseCheckResult(
296
- status=LicenseStatus.INVALID,
297
- message="Failed to decode license",
298
- )
299
-
300
- now = datetime.now(UTC)
301
- days_until_expiry = (license.expires_at - now).days
302
-
303
- if license.is_expired():
304
- if license.is_in_grace_period():
305
- grace_end = license.expires_at + timedelta(days=GRACE_PERIOD_DAYS)
306
- grace_days = (grace_end - now).days
307
- logger.warning(
308
- "license.grace_period license_id=%s days_remaining=%d",
309
- license.license_id,
310
- grace_days,
311
- )
312
- return LicenseCheckResult(
313
- status=LicenseStatus.GRACE_PERIOD,
314
- license=license,
315
- message=f"License expired, {grace_days} days of grace period remaining",
316
- days_until_expiry=days_until_expiry,
317
- grace_days_remaining=grace_days,
318
- )
319
- else:
320
- logger.warning("license.expired license_id=%s", license.license_id)
321
- return LicenseCheckResult(
322
- status=LicenseStatus.EXPIRED,
323
- license=license,
324
- message="License has expired",
325
- days_until_expiry=days_until_expiry,
326
- )
327
-
328
- logger.debug(
329
- "license.valid license_id=%s tier=%s expires_in=%d",
330
- license.license_id,
331
- license.tier.value,
332
- days_until_expiry,
333
- )
334
- return LicenseCheckResult(
335
- status=LicenseStatus.VALID,
336
- license=license,
337
- days_until_expiry=days_until_expiry,
338
- )
339
-
340
- def check_cached(self, tenant_id: str, token: str) -> LicenseCheckResult:
341
- """Check license with caching to reduce validation overhead."""
342
- cache_key = f"{tenant_id}:{hashlib.sha256(token.encode()).hexdigest()[:16]}"
343
- now = time.time()
344
-
345
- if cache_key in self._cache:
346
- cached_time, cached_result = self._cache[cache_key]
347
- if now - cached_time < LICENSE_CHECK_INTERVAL:
348
- return cached_result
349
-
350
- result = self.validate_token(token)
351
- self._cache[cache_key] = (now, result)
352
- return result
353
-
354
- def clear_cache(self, tenant_id: str | None = None) -> None:
355
- """Clear license validation cache."""
356
- if tenant_id:
357
- self._cache = {
358
- k: v for k, v in self._cache.items() if not k.startswith(f"{tenant_id}:")
359
- }
360
- else:
361
- self._cache.clear()
362
-
363
-
364
- def require_feature(
365
- license_result: LicenseCheckResult,
366
- feature: EnterpriseFeature,
367
- ) -> tuple[bool, str | None]:
368
- """Check if a license allows access to a feature.
369
-
370
- Returns:
371
- Tuple of (allowed, error_message)
372
- """
373
- if license_result.status == LicenseStatus.MISSING:
374
- return False, "Enterprise license required"
375
-
376
- if license_result.status == LicenseStatus.INVALID:
377
- return False, "Invalid license"
378
-
379
- if license_result.status == LicenseStatus.EXPIRED:
380
- return False, "License has expired"
381
-
382
- if not license_result.license:
383
- return False, "No license data"
384
-
385
- if not license_result.license.has_feature(feature):
386
- return False, f"Feature {feature.value} not included in license"
387
-
388
- return True, None
389
-
390
-
391
- def require_enterprise(
392
- license_result: LicenseCheckResult,
393
- ) -> tuple[bool, str | None]:
394
- """Check if license is enterprise tier.
395
-
396
- Returns:
397
- Tuple of (allowed, error_message)
398
- """
399
- if license_result.status not in (LicenseStatus.VALID, LicenseStatus.GRACE_PERIOD):
400
- return False, "Enterprise license required"
401
-
402
- if not license_result.license:
403
- return False, "No license data"
404
-
405
- if license_result.license.tier != LicenseTier.ENTERPRISE:
406
- return False, "Enterprise tier license required"
407
-
408
- return True, None
portal/enterprise/rbac.py DELETED
@@ -1,276 +0,0 @@
1
- """Role-Based Access Control (RBAC) for enterprise portal.
2
-
3
- Security controls:
4
- - ASVS V8.2.1: Function-level authorization
5
- - Deterministic role mapping from SAML attributes
6
- - No default admin accounts
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import logging
12
- from dataclasses import dataclass, field
13
- from enum import Enum
14
- from typing import TYPE_CHECKING
15
-
16
- from kekkai_core import redact
17
-
18
- if TYPE_CHECKING:
19
- pass
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
-
24
- class Permission(Enum):
25
- """Available permissions in the system."""
26
-
27
- # Viewer permissions
28
- VIEW_FINDINGS = "view_findings"
29
- VIEW_DASHBOARD = "view_dashboard"
30
- VIEW_REPORTS = "view_reports"
31
-
32
- # Analyst permissions
33
- CREATE_UPLOAD = "create_upload"
34
- UPDATE_FINDING_STATUS = "update_finding_status"
35
- EXPORT_FINDINGS = "export_findings"
36
-
37
- # Admin permissions
38
- MANAGE_USERS = "manage_users"
39
- MANAGE_INTEGRATIONS = "manage_integrations"
40
- VIEW_AUDIT_LOGS = "view_audit_logs"
41
-
42
- # Tenant Admin permissions
43
- MANAGE_TENANT = "manage_tenant"
44
- MANAGE_SAML_CONFIG = "manage_saml_config"
45
- ROTATE_API_KEY = "rotate_api_key"
46
- DELETE_TENANT = "delete_tenant"
47
-
48
-
49
- class Role(Enum):
50
- """Available roles in the system."""
51
-
52
- VIEWER = "viewer"
53
- ANALYST = "analyst"
54
- ADMIN = "admin"
55
- TENANT_ADMIN = "tenant_admin"
56
-
57
-
58
- # Permission matrix: defines which roles have which permissions
59
- ROLE_PERMISSIONS: dict[Role, frozenset[Permission]] = {
60
- Role.VIEWER: frozenset(
61
- {
62
- Permission.VIEW_FINDINGS,
63
- Permission.VIEW_DASHBOARD,
64
- Permission.VIEW_REPORTS,
65
- }
66
- ),
67
- Role.ANALYST: frozenset(
68
- {
69
- Permission.VIEW_FINDINGS,
70
- Permission.VIEW_DASHBOARD,
71
- Permission.VIEW_REPORTS,
72
- Permission.CREATE_UPLOAD,
73
- Permission.UPDATE_FINDING_STATUS,
74
- Permission.EXPORT_FINDINGS,
75
- }
76
- ),
77
- Role.ADMIN: frozenset(
78
- {
79
- Permission.VIEW_FINDINGS,
80
- Permission.VIEW_DASHBOARD,
81
- Permission.VIEW_REPORTS,
82
- Permission.CREATE_UPLOAD,
83
- Permission.UPDATE_FINDING_STATUS,
84
- Permission.EXPORT_FINDINGS,
85
- Permission.MANAGE_USERS,
86
- Permission.MANAGE_INTEGRATIONS,
87
- Permission.VIEW_AUDIT_LOGS,
88
- }
89
- ),
90
- Role.TENANT_ADMIN: frozenset(
91
- {
92
- Permission.VIEW_FINDINGS,
93
- Permission.VIEW_DASHBOARD,
94
- Permission.VIEW_REPORTS,
95
- Permission.CREATE_UPLOAD,
96
- Permission.UPDATE_FINDING_STATUS,
97
- Permission.EXPORT_FINDINGS,
98
- Permission.MANAGE_USERS,
99
- Permission.MANAGE_INTEGRATIONS,
100
- Permission.VIEW_AUDIT_LOGS,
101
- Permission.MANAGE_TENANT,
102
- Permission.MANAGE_SAML_CONFIG,
103
- Permission.ROTATE_API_KEY,
104
- Permission.DELETE_TENANT,
105
- }
106
- ),
107
- }
108
-
109
- # SAML attribute to role mapping
110
- DEFAULT_ROLE_MAPPING: dict[str, Role] = {
111
- "viewer": Role.VIEWER,
112
- "analyst": Role.ANALYST,
113
- "admin": Role.ADMIN,
114
- "tenant_admin": Role.TENANT_ADMIN,
115
- "tenant-admin": Role.TENANT_ADMIN,
116
- "tenantadmin": Role.TENANT_ADMIN,
117
- }
118
-
119
-
120
- @dataclass(frozen=True)
121
- class AuthorizationResult:
122
- """Result of an authorization check."""
123
-
124
- allowed: bool
125
- role: Role | None = None
126
- permission: Permission | None = None
127
- reason: str | None = None
128
-
129
-
130
- @dataclass
131
- class UserContext:
132
- """Context for the current user session."""
133
-
134
- user_id: str
135
- tenant_id: str
136
- role: Role
137
- email: str | None = None
138
- display_name: str | None = None
139
- session_id: str | None = None
140
- permissions: frozenset[Permission] = field(default_factory=frozenset)
141
-
142
- def __post_init__(self) -> None:
143
- if not self.permissions:
144
- object.__setattr__(self, "permissions", ROLE_PERMISSIONS.get(self.role, frozenset()))
145
-
146
-
147
- class RBACManager:
148
- """Manages role-based access control decisions."""
149
-
150
- def __init__(
151
- self,
152
- role_mapping: dict[str, Role] | None = None,
153
- ) -> None:
154
- self._role_mapping = role_mapping or DEFAULT_ROLE_MAPPING.copy()
155
-
156
- def map_role_from_attribute(self, role_attribute: str) -> Role | None:
157
- """Map a SAML role attribute to a system role.
158
-
159
- Uses deterministic mapping - no user-configurable escalation.
160
- """
161
- normalized = role_attribute.lower().strip()
162
- return self._role_mapping.get(normalized)
163
-
164
- def map_role_from_attributes(self, attributes: dict[str, list[str]]) -> Role:
165
- """Map role from SAML attributes, defaulting to viewer."""
166
- role_attrs = attributes.get("role", []) + attributes.get("roles", [])
167
- role_attrs += attributes.get("group", []) + attributes.get("groups", [])
168
-
169
- for attr in role_attrs:
170
- mapped = self.map_role_from_attribute(attr)
171
- if mapped:
172
- return mapped
173
-
174
- return Role.VIEWER
175
-
176
- def get_permissions(self, role: Role) -> frozenset[Permission]:
177
- """Get all permissions for a role."""
178
- return ROLE_PERMISSIONS.get(role, frozenset())
179
-
180
- def has_permission(self, role: Role, permission: Permission) -> bool:
181
- """Check if a role has a specific permission."""
182
- return permission in ROLE_PERMISSIONS.get(role, frozenset())
183
-
184
- def authorize(
185
- self,
186
- user_context: UserContext,
187
- required_permission: Permission,
188
- resource_tenant_id: str | None = None,
189
- ) -> AuthorizationResult:
190
- """Authorize an action for a user.
191
-
192
- Args:
193
- user_context: Current user context with role
194
- required_permission: Permission needed for the action
195
- resource_tenant_id: Tenant owning the resource (for cross-tenant checks)
196
-
197
- Returns:
198
- AuthorizationResult indicating if access is allowed
199
- """
200
- if resource_tenant_id and resource_tenant_id != user_context.tenant_id:
201
- logger.warning(
202
- "authz.denied.cross_tenant user_id=%s tenant=%s target_tenant=%s permission=%s",
203
- redact(user_context.user_id),
204
- user_context.tenant_id,
205
- resource_tenant_id,
206
- required_permission.value,
207
- )
208
- return AuthorizationResult(
209
- allowed=False,
210
- role=user_context.role,
211
- permission=required_permission,
212
- reason="Cross-tenant access denied",
213
- )
214
-
215
- if not self.has_permission(user_context.role, required_permission):
216
- logger.warning(
217
- "authz.denied.permission user_id=%s tenant=%s role=%s permission=%s",
218
- redact(user_context.user_id),
219
- user_context.tenant_id,
220
- user_context.role.value,
221
- required_permission.value,
222
- )
223
- return AuthorizationResult(
224
- allowed=False,
225
- role=user_context.role,
226
- permission=required_permission,
227
- reason=f"Role {user_context.role.value} lacks {required_permission.value}",
228
- )
229
-
230
- logger.debug(
231
- "authz.allowed user_id=%s tenant=%s role=%s permission=%s",
232
- redact(user_context.user_id),
233
- user_context.tenant_id,
234
- user_context.role.value,
235
- required_permission.value,
236
- )
237
- return AuthorizationResult(
238
- allowed=True,
239
- role=user_context.role,
240
- permission=required_permission,
241
- )
242
-
243
- def create_user_context(
244
- self,
245
- user_id: str,
246
- tenant_id: str,
247
- role: Role,
248
- email: str | None = None,
249
- display_name: str | None = None,
250
- session_id: str | None = None,
251
- ) -> UserContext:
252
- """Create a user context with permissions derived from role."""
253
- return UserContext(
254
- user_id=user_id,
255
- tenant_id=tenant_id,
256
- role=role,
257
- email=email,
258
- display_name=display_name,
259
- session_id=session_id,
260
- permissions=self.get_permissions(role),
261
- )
262
-
263
-
264
- def require_permission(permission: Permission) -> AuthorizationResult:
265
- """Decorator helper to check permission before function execution.
266
-
267
- Usage in web handlers:
268
- result = require_permission(Permission.CREATE_UPLOAD)
269
- if not result.allowed:
270
- return unauthorized_response(result.reason)
271
- """
272
- return AuthorizationResult(
273
- allowed=False,
274
- permission=permission,
275
- reason=f"Permission {permission.value} required",
276
- )