kekkai-cli 1.0.5__py3-none-any.whl → 1.1.1__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 +789 -19
- kekkai/compliance/__init__.py +68 -0
- kekkai/compliance/hipaa.py +235 -0
- kekkai/compliance/mappings.py +136 -0
- kekkai/compliance/owasp.py +517 -0
- kekkai/compliance/owasp_agentic.py +267 -0
- kekkai/compliance/pci_dss.py +205 -0
- kekkai/compliance/soc2.py +209 -0
- kekkai/dojo.py +91 -14
- kekkai/dojo_import.py +9 -1
- kekkai/fix/__init__.py +47 -0
- kekkai/fix/audit.py +278 -0
- kekkai/fix/differ.py +427 -0
- kekkai/fix/engine.py +500 -0
- kekkai/fix/prompts.py +251 -0
- kekkai/output.py +10 -12
- kekkai/report/__init__.py +41 -0
- kekkai/report/compliance_matrix.py +98 -0
- kekkai/report/generator.py +365 -0
- kekkai/report/html.py +69 -0
- kekkai/report/pdf.py +63 -0
- kekkai/report/unified.py +226 -0
- kekkai/scanners/container.py +33 -3
- kekkai/scanners/gitleaks.py +3 -1
- kekkai/scanners/semgrep.py +1 -1
- kekkai/scanners/trivy.py +1 -1
- kekkai/threatflow/model_adapter.py +143 -1
- kekkai/triage/__init__.py +54 -1
- kekkai/triage/loader.py +196 -0
- kekkai_cli-1.1.1.dist-info/METADATA +379 -0
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.1.dist-info}/RECORD +34 -33
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.1.dist-info}/entry_points.txt +0 -1
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.1.dist-info}/top_level.txt +0 -1
- kekkai_cli-1.0.5.dist-info/METADATA +0 -135
- portal/__init__.py +0 -19
- portal/api.py +0 -155
- portal/auth.py +0 -103
- portal/enterprise/__init__.py +0 -32
- portal/enterprise/audit.py +0 -435
- portal/enterprise/licensing.py +0 -342
- 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 -384
- {kekkai_cli-1.0.5.dist-info → kekkai_cli-1.1.1.dist-info}/WHEEL +0 -0
portal/enterprise/licensing.py
DELETED
|
@@ -1,342 +0,0 @@
|
|
|
1
|
-
"""Enterprise license validation and feature gating.
|
|
2
|
-
|
|
3
|
-
Security controls:
|
|
4
|
-
- Server-side license enforcement
|
|
5
|
-
- Signed license tokens to prevent tampering
|
|
6
|
-
- Grace period handling for expiration
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import base64
|
|
12
|
-
import hashlib
|
|
13
|
-
import hmac
|
|
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
|
-
if TYPE_CHECKING:
|
|
23
|
-
pass
|
|
24
|
-
|
|
25
|
-
logger = logging.getLogger(__name__)
|
|
26
|
-
|
|
27
|
-
GRACE_PERIOD_DAYS = 7
|
|
28
|
-
LICENSE_CHECK_INTERVAL = 3600
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class LicenseStatus(Enum):
|
|
32
|
-
"""License validation status."""
|
|
33
|
-
|
|
34
|
-
VALID = "valid"
|
|
35
|
-
EXPIRED = "expired"
|
|
36
|
-
GRACE_PERIOD = "grace_period"
|
|
37
|
-
INVALID = "invalid"
|
|
38
|
-
MISSING = "missing"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class LicenseTier(Enum):
|
|
42
|
-
"""License tier levels."""
|
|
43
|
-
|
|
44
|
-
COMMUNITY = "community"
|
|
45
|
-
PROFESSIONAL = "professional"
|
|
46
|
-
ENTERPRISE = "enterprise"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class EnterpriseFeature(Enum):
|
|
50
|
-
"""Features available in enterprise tier."""
|
|
51
|
-
|
|
52
|
-
SSO_SAML = "sso_saml"
|
|
53
|
-
RBAC = "rbac"
|
|
54
|
-
AUDIT_LOGGING = "audit_logging"
|
|
55
|
-
CUSTOM_BRANDING = "custom_branding"
|
|
56
|
-
API_RATE_LIMIT_INCREASE = "api_rate_limit_increase"
|
|
57
|
-
PRIORITY_SUPPORT = "priority_support"
|
|
58
|
-
SLA_GUARANTEE = "sla_guarantee"
|
|
59
|
-
MULTI_REGION = "multi_region"
|
|
60
|
-
ADVANCED_REPORTS = "advanced_reports"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
TIER_FEATURES: dict[LicenseTier, frozenset[EnterpriseFeature]] = {
|
|
64
|
-
LicenseTier.COMMUNITY: frozenset(),
|
|
65
|
-
LicenseTier.PROFESSIONAL: frozenset(
|
|
66
|
-
{
|
|
67
|
-
EnterpriseFeature.AUDIT_LOGGING,
|
|
68
|
-
EnterpriseFeature.API_RATE_LIMIT_INCREASE,
|
|
69
|
-
}
|
|
70
|
-
),
|
|
71
|
-
LicenseTier.ENTERPRISE: frozenset(
|
|
72
|
-
{
|
|
73
|
-
EnterpriseFeature.SSO_SAML,
|
|
74
|
-
EnterpriseFeature.RBAC,
|
|
75
|
-
EnterpriseFeature.AUDIT_LOGGING,
|
|
76
|
-
EnterpriseFeature.CUSTOM_BRANDING,
|
|
77
|
-
EnterpriseFeature.API_RATE_LIMIT_INCREASE,
|
|
78
|
-
EnterpriseFeature.PRIORITY_SUPPORT,
|
|
79
|
-
EnterpriseFeature.SLA_GUARANTEE,
|
|
80
|
-
EnterpriseFeature.MULTI_REGION,
|
|
81
|
-
EnterpriseFeature.ADVANCED_REPORTS,
|
|
82
|
-
}
|
|
83
|
-
),
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
@dataclass(frozen=True)
|
|
88
|
-
class EnterpriseLicense:
|
|
89
|
-
"""Represents an enterprise license."""
|
|
90
|
-
|
|
91
|
-
license_id: str
|
|
92
|
-
tenant_id: str
|
|
93
|
-
tier: LicenseTier
|
|
94
|
-
issued_at: datetime
|
|
95
|
-
expires_at: datetime
|
|
96
|
-
features: frozenset[EnterpriseFeature] = field(default_factory=frozenset)
|
|
97
|
-
max_users: int = 0
|
|
98
|
-
max_projects: int = 0
|
|
99
|
-
metadata: dict[str, Any] = field(default_factory=dict)
|
|
100
|
-
|
|
101
|
-
def is_expired(self) -> bool:
|
|
102
|
-
"""Check if license is expired (without grace period)."""
|
|
103
|
-
return datetime.now(UTC) > self.expires_at
|
|
104
|
-
|
|
105
|
-
def is_in_grace_period(self) -> bool:
|
|
106
|
-
"""Check if license is in grace period."""
|
|
107
|
-
now = datetime.now(UTC)
|
|
108
|
-
if now <= self.expires_at:
|
|
109
|
-
return False
|
|
110
|
-
grace_end = self.expires_at + timedelta(days=GRACE_PERIOD_DAYS)
|
|
111
|
-
return now <= grace_end
|
|
112
|
-
|
|
113
|
-
def has_feature(self, feature: EnterpriseFeature) -> bool:
|
|
114
|
-
"""Check if license includes a specific feature."""
|
|
115
|
-
tier_features = TIER_FEATURES.get(self.tier, frozenset())
|
|
116
|
-
return feature in self.features or feature in tier_features
|
|
117
|
-
|
|
118
|
-
def to_dict(self) -> dict[str, Any]:
|
|
119
|
-
return {
|
|
120
|
-
"license_id": self.license_id,
|
|
121
|
-
"tenant_id": self.tenant_id,
|
|
122
|
-
"tier": self.tier.value,
|
|
123
|
-
"issued_at": self.issued_at.isoformat(),
|
|
124
|
-
"expires_at": self.expires_at.isoformat(),
|
|
125
|
-
"features": [f.value for f in self.features],
|
|
126
|
-
"max_users": self.max_users,
|
|
127
|
-
"max_projects": self.max_projects,
|
|
128
|
-
"metadata": self.metadata,
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
@classmethod
|
|
132
|
-
def from_dict(cls, data: dict[str, Any]) -> EnterpriseLicense:
|
|
133
|
-
features = frozenset(EnterpriseFeature(f) for f in data.get("features", []))
|
|
134
|
-
return cls(
|
|
135
|
-
license_id=str(data["license_id"]),
|
|
136
|
-
tenant_id=str(data["tenant_id"]),
|
|
137
|
-
tier=LicenseTier(data["tier"]),
|
|
138
|
-
issued_at=datetime.fromisoformat(str(data["issued_at"])),
|
|
139
|
-
expires_at=datetime.fromisoformat(str(data["expires_at"])),
|
|
140
|
-
features=features,
|
|
141
|
-
max_users=int(data.get("max_users", 0)),
|
|
142
|
-
max_projects=int(data.get("max_projects", 0)),
|
|
143
|
-
metadata=data.get("metadata", {}),
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
@dataclass
|
|
148
|
-
class LicenseCheckResult:
|
|
149
|
-
"""Result of a license check."""
|
|
150
|
-
|
|
151
|
-
status: LicenseStatus
|
|
152
|
-
license: EnterpriseLicense | None = None
|
|
153
|
-
message: str | None = None
|
|
154
|
-
days_until_expiry: int | None = None
|
|
155
|
-
grace_days_remaining: int | None = None
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
class LicenseValidator:
|
|
159
|
-
"""Validates enterprise licenses."""
|
|
160
|
-
|
|
161
|
-
def __init__(self, signing_key: str) -> None:
|
|
162
|
-
self._signing_key = signing_key
|
|
163
|
-
self._cache: dict[str, tuple[float, LicenseCheckResult]] = {}
|
|
164
|
-
|
|
165
|
-
def create_license_token(self, license: EnterpriseLicense) -> str:
|
|
166
|
-
"""Create a signed license token.
|
|
167
|
-
|
|
168
|
-
The token format is: base64(payload).signature
|
|
169
|
-
"""
|
|
170
|
-
payload = license.to_dict()
|
|
171
|
-
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
172
|
-
payload_b64 = base64.urlsafe_b64encode(payload_json.encode()).decode().rstrip("=")
|
|
173
|
-
|
|
174
|
-
signature = hmac.new(
|
|
175
|
-
self._signing_key.encode(),
|
|
176
|
-
payload_b64.encode(),
|
|
177
|
-
hashlib.sha256,
|
|
178
|
-
).hexdigest()
|
|
179
|
-
|
|
180
|
-
return f"{payload_b64}.{signature}"
|
|
181
|
-
|
|
182
|
-
def validate_token(self, token: str) -> LicenseCheckResult:
|
|
183
|
-
"""Validate a license token and return the license if valid.
|
|
184
|
-
|
|
185
|
-
Args:
|
|
186
|
-
token: The signed license token
|
|
187
|
-
|
|
188
|
-
Returns:
|
|
189
|
-
LicenseCheckResult with status and license details
|
|
190
|
-
"""
|
|
191
|
-
if not token:
|
|
192
|
-
return LicenseCheckResult(
|
|
193
|
-
status=LicenseStatus.MISSING,
|
|
194
|
-
message="No license token provided",
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
parts = token.split(".")
|
|
198
|
-
if len(parts) != 2:
|
|
199
|
-
logger.warning("license.invalid_format")
|
|
200
|
-
return LicenseCheckResult(
|
|
201
|
-
status=LicenseStatus.INVALID,
|
|
202
|
-
message="Invalid license token format",
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
payload_b64, signature = parts
|
|
206
|
-
|
|
207
|
-
expected_sig = hmac.new(
|
|
208
|
-
self._signing_key.encode(),
|
|
209
|
-
payload_b64.encode(),
|
|
210
|
-
hashlib.sha256,
|
|
211
|
-
).hexdigest()
|
|
212
|
-
|
|
213
|
-
if not hmac.compare_digest(signature, expected_sig):
|
|
214
|
-
logger.warning("license.invalid_signature")
|
|
215
|
-
return LicenseCheckResult(
|
|
216
|
-
status=LicenseStatus.INVALID,
|
|
217
|
-
message="Invalid license signature",
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
try:
|
|
221
|
-
padding = 4 - len(payload_b64) % 4
|
|
222
|
-
if padding != 4:
|
|
223
|
-
payload_b64 += "=" * padding
|
|
224
|
-
payload_json = base64.urlsafe_b64decode(payload_b64).decode()
|
|
225
|
-
payload = json.loads(payload_json)
|
|
226
|
-
license = EnterpriseLicense.from_dict(payload)
|
|
227
|
-
except (ValueError, json.JSONDecodeError, KeyError) as e:
|
|
228
|
-
logger.warning("license.decode_error: %s", e)
|
|
229
|
-
return LicenseCheckResult(
|
|
230
|
-
status=LicenseStatus.INVALID,
|
|
231
|
-
message="Failed to decode license",
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
now = datetime.now(UTC)
|
|
235
|
-
days_until_expiry = (license.expires_at - now).days
|
|
236
|
-
|
|
237
|
-
if license.is_expired():
|
|
238
|
-
if license.is_in_grace_period():
|
|
239
|
-
grace_end = license.expires_at + timedelta(days=GRACE_PERIOD_DAYS)
|
|
240
|
-
grace_days = (grace_end - now).days
|
|
241
|
-
logger.warning(
|
|
242
|
-
"license.grace_period license_id=%s days_remaining=%d",
|
|
243
|
-
license.license_id,
|
|
244
|
-
grace_days,
|
|
245
|
-
)
|
|
246
|
-
return LicenseCheckResult(
|
|
247
|
-
status=LicenseStatus.GRACE_PERIOD,
|
|
248
|
-
license=license,
|
|
249
|
-
message=f"License expired, {grace_days} days of grace period remaining",
|
|
250
|
-
days_until_expiry=days_until_expiry,
|
|
251
|
-
grace_days_remaining=grace_days,
|
|
252
|
-
)
|
|
253
|
-
else:
|
|
254
|
-
logger.warning("license.expired license_id=%s", license.license_id)
|
|
255
|
-
return LicenseCheckResult(
|
|
256
|
-
status=LicenseStatus.EXPIRED,
|
|
257
|
-
license=license,
|
|
258
|
-
message="License has expired",
|
|
259
|
-
days_until_expiry=days_until_expiry,
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
logger.debug(
|
|
263
|
-
"license.valid license_id=%s tier=%s expires_in=%d",
|
|
264
|
-
license.license_id,
|
|
265
|
-
license.tier.value,
|
|
266
|
-
days_until_expiry,
|
|
267
|
-
)
|
|
268
|
-
return LicenseCheckResult(
|
|
269
|
-
status=LicenseStatus.VALID,
|
|
270
|
-
license=license,
|
|
271
|
-
days_until_expiry=days_until_expiry,
|
|
272
|
-
)
|
|
273
|
-
|
|
274
|
-
def check_cached(self, tenant_id: str, token: str) -> LicenseCheckResult:
|
|
275
|
-
"""Check license with caching to reduce validation overhead."""
|
|
276
|
-
cache_key = f"{tenant_id}:{hashlib.sha256(token.encode()).hexdigest()[:16]}"
|
|
277
|
-
now = time.time()
|
|
278
|
-
|
|
279
|
-
if cache_key in self._cache:
|
|
280
|
-
cached_time, cached_result = self._cache[cache_key]
|
|
281
|
-
if now - cached_time < LICENSE_CHECK_INTERVAL:
|
|
282
|
-
return cached_result
|
|
283
|
-
|
|
284
|
-
result = self.validate_token(token)
|
|
285
|
-
self._cache[cache_key] = (now, result)
|
|
286
|
-
return result
|
|
287
|
-
|
|
288
|
-
def clear_cache(self, tenant_id: str | None = None) -> None:
|
|
289
|
-
"""Clear license validation cache."""
|
|
290
|
-
if tenant_id:
|
|
291
|
-
self._cache = {
|
|
292
|
-
k: v for k, v in self._cache.items() if not k.startswith(f"{tenant_id}:")
|
|
293
|
-
}
|
|
294
|
-
else:
|
|
295
|
-
self._cache.clear()
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def require_feature(
|
|
299
|
-
license_result: LicenseCheckResult,
|
|
300
|
-
feature: EnterpriseFeature,
|
|
301
|
-
) -> tuple[bool, str | None]:
|
|
302
|
-
"""Check if a license allows access to a feature.
|
|
303
|
-
|
|
304
|
-
Returns:
|
|
305
|
-
Tuple of (allowed, error_message)
|
|
306
|
-
"""
|
|
307
|
-
if license_result.status == LicenseStatus.MISSING:
|
|
308
|
-
return False, "Enterprise license required"
|
|
309
|
-
|
|
310
|
-
if license_result.status == LicenseStatus.INVALID:
|
|
311
|
-
return False, "Invalid license"
|
|
312
|
-
|
|
313
|
-
if license_result.status == LicenseStatus.EXPIRED:
|
|
314
|
-
return False, "License has expired"
|
|
315
|
-
|
|
316
|
-
if not license_result.license:
|
|
317
|
-
return False, "No license data"
|
|
318
|
-
|
|
319
|
-
if not license_result.license.has_feature(feature):
|
|
320
|
-
return False, f"Feature {feature.value} not included in license"
|
|
321
|
-
|
|
322
|
-
return True, None
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def require_enterprise(
|
|
326
|
-
license_result: LicenseCheckResult,
|
|
327
|
-
) -> tuple[bool, str | None]:
|
|
328
|
-
"""Check if license is enterprise tier.
|
|
329
|
-
|
|
330
|
-
Returns:
|
|
331
|
-
Tuple of (allowed, error_message)
|
|
332
|
-
"""
|
|
333
|
-
if license_result.status not in (LicenseStatus.VALID, LicenseStatus.GRACE_PERIOD):
|
|
334
|
-
return False, "Enterprise license required"
|
|
335
|
-
|
|
336
|
-
if not license_result.license:
|
|
337
|
-
return False, "No license data"
|
|
338
|
-
|
|
339
|
-
if license_result.license.tier != LicenseTier.ENTERPRISE:
|
|
340
|
-
return False, "Enterprise tier license required"
|
|
341
|
-
|
|
342
|
-
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
|
-
)
|