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.
- kekkai/cli.py +238 -36
- kekkai/dojo_import.py +9 -1
- kekkai/output.py +2 -3
- kekkai/report/unified.py +226 -0
- kekkai/triage/__init__.py +54 -1
- kekkai/triage/fix_screen.py +232 -0
- kekkai/triage/loader.py +196 -0
- kekkai/triage/screens.py +1 -0
- kekkai_cli-2.0.0.dist-info/METADATA +317 -0
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/RECORD +13 -28
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/entry_points.txt +0 -1
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/top_level.txt +0 -1
- kekkai_cli-1.1.0.dist-info/METADATA +0 -359
- portal/__init__.py +0 -19
- portal/api.py +0 -155
- portal/auth.py +0 -103
- portal/enterprise/__init__.py +0 -45
- portal/enterprise/audit.py +0 -435
- portal/enterprise/licensing.py +0 -408
- portal/enterprise/rbac.py +0 -276
- portal/enterprise/saml.py +0 -595
- portal/ops/__init__.py +0 -53
- portal/ops/backup.py +0 -553
- portal/ops/log_shipper.py +0 -469
- portal/ops/monitoring.py +0 -517
- portal/ops/restore.py +0 -469
- portal/ops/secrets.py +0 -408
- portal/ops/upgrade.py +0 -591
- portal/tenants.py +0 -340
- portal/uploads.py +0 -259
- portal/web.py +0 -393
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/WHEEL +0 -0
portal/enterprise/licensing.py
DELETED
|
@@ -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
|
-
)
|