kekkai-cli 1.1.0__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 +124 -33
- kekkai/dojo_import.py +9 -1
- kekkai/output.py +1 -1
- kekkai/report/unified.py +226 -0
- kekkai/triage/__init__.py +54 -1
- kekkai/triage/loader.py +196 -0
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-1.1.1.dist-info}/METADATA +33 -13
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-1.1.1.dist-info}/RECORD +11 -27
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-1.1.1.dist-info}/entry_points.txt +0 -1
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-1.1.1.dist-info}/top_level.txt +0 -1
- 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-1.1.1.dist-info}/WHEEL +0 -0
portal/tenants.py
DELETED
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
"""Tenant management for multi-tenant portal.
|
|
2
|
-
|
|
3
|
-
Security controls:
|
|
4
|
-
- ASVS V8.4.1: Cross-tenant controls via tenant_id boundary
|
|
5
|
-
- ASVS V8.2.2: Data-specific authorization via product/engagement mapping
|
|
6
|
-
- ASVS V6.8.2: SAML configuration for enterprise tenants
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import hashlib
|
|
12
|
-
import hmac
|
|
13
|
-
import json
|
|
14
|
-
import logging
|
|
15
|
-
import secrets
|
|
16
|
-
import string
|
|
17
|
-
from dataclasses import asdict, dataclass
|
|
18
|
-
from enum import Enum
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
from typing import TYPE_CHECKING, Any
|
|
21
|
-
|
|
22
|
-
if TYPE_CHECKING:
|
|
23
|
-
pass
|
|
24
|
-
|
|
25
|
-
logger = logging.getLogger(__name__)
|
|
26
|
-
|
|
27
|
-
API_KEY_LENGTH = 32
|
|
28
|
-
API_KEY_PREFIX = "kek_"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class AuthMethod(Enum):
|
|
32
|
-
"""Authentication methods for tenants."""
|
|
33
|
-
|
|
34
|
-
API_KEY = "api_key"
|
|
35
|
-
SAML = "saml"
|
|
36
|
-
BOTH = "both"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@dataclass(frozen=True)
|
|
40
|
-
class SAMLTenantConfig:
|
|
41
|
-
"""SAML configuration for a tenant."""
|
|
42
|
-
|
|
43
|
-
entity_id: str
|
|
44
|
-
sso_url: str
|
|
45
|
-
certificate: str
|
|
46
|
-
slo_url: str | None = None
|
|
47
|
-
certificate_fingerprint: str | None = None
|
|
48
|
-
name_id_format: str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
|
49
|
-
session_lifetime: int = 28800
|
|
50
|
-
role_attribute: str = "role"
|
|
51
|
-
default_role: str = "viewer"
|
|
52
|
-
|
|
53
|
-
def to_dict(self) -> dict[str, Any]:
|
|
54
|
-
return {
|
|
55
|
-
"entity_id": self.entity_id,
|
|
56
|
-
"sso_url": self.sso_url,
|
|
57
|
-
"certificate": self.certificate,
|
|
58
|
-
"slo_url": self.slo_url,
|
|
59
|
-
"certificate_fingerprint": self.certificate_fingerprint,
|
|
60
|
-
"name_id_format": self.name_id_format,
|
|
61
|
-
"session_lifetime": self.session_lifetime,
|
|
62
|
-
"role_attribute": self.role_attribute,
|
|
63
|
-
"default_role": self.default_role,
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
@classmethod
|
|
67
|
-
def from_dict(cls, data: dict[str, Any]) -> SAMLTenantConfig:
|
|
68
|
-
return cls(
|
|
69
|
-
entity_id=str(data["entity_id"]),
|
|
70
|
-
sso_url=str(data["sso_url"]),
|
|
71
|
-
certificate=str(data["certificate"]),
|
|
72
|
-
slo_url=data.get("slo_url"),
|
|
73
|
-
certificate_fingerprint=data.get("certificate_fingerprint"),
|
|
74
|
-
name_id_format=data.get(
|
|
75
|
-
"name_id_format",
|
|
76
|
-
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
77
|
-
),
|
|
78
|
-
session_lifetime=int(data.get("session_lifetime", 28800)),
|
|
79
|
-
role_attribute=str(data.get("role_attribute", "role")),
|
|
80
|
-
default_role=str(data.get("default_role", "viewer")),
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
@dataclass(frozen=True)
|
|
85
|
-
class Tenant:
|
|
86
|
-
"""Represents a portal tenant with DefectDojo product mapping."""
|
|
87
|
-
|
|
88
|
-
id: str
|
|
89
|
-
name: str
|
|
90
|
-
api_key_hash: str
|
|
91
|
-
dojo_product_id: int
|
|
92
|
-
dojo_engagement_id: int
|
|
93
|
-
enabled: bool = True
|
|
94
|
-
max_upload_size_mb: int = 10
|
|
95
|
-
auth_method: AuthMethod = AuthMethod.API_KEY
|
|
96
|
-
saml_config: SAMLTenantConfig | None = None
|
|
97
|
-
license_token: str | None = None
|
|
98
|
-
default_role: str = "viewer"
|
|
99
|
-
|
|
100
|
-
def to_dict(self) -> dict[str, object]:
|
|
101
|
-
result = asdict(self)
|
|
102
|
-
result["auth_method"] = self.auth_method.value
|
|
103
|
-
if self.saml_config:
|
|
104
|
-
result["saml_config"] = self.saml_config.to_dict()
|
|
105
|
-
return result
|
|
106
|
-
|
|
107
|
-
@classmethod
|
|
108
|
-
def from_dict(cls, data: dict[str, object]) -> Tenant:
|
|
109
|
-
dojo_product = data["dojo_product_id"]
|
|
110
|
-
dojo_engagement = data["dojo_engagement_id"]
|
|
111
|
-
max_size = data.get("max_upload_size_mb", 10)
|
|
112
|
-
|
|
113
|
-
auth_method_str = data.get("auth_method", "api_key")
|
|
114
|
-
auth_method = AuthMethod(str(auth_method_str))
|
|
115
|
-
|
|
116
|
-
saml_config = None
|
|
117
|
-
saml_data = data.get("saml_config")
|
|
118
|
-
if saml_data and isinstance(saml_data, dict):
|
|
119
|
-
saml_config = SAMLTenantConfig.from_dict(saml_data)
|
|
120
|
-
|
|
121
|
-
return cls(
|
|
122
|
-
id=str(data["id"]),
|
|
123
|
-
name=str(data["name"]),
|
|
124
|
-
api_key_hash=str(data["api_key_hash"]),
|
|
125
|
-
dojo_product_id=int(str(dojo_product)),
|
|
126
|
-
dojo_engagement_id=int(str(dojo_engagement)),
|
|
127
|
-
enabled=bool(data.get("enabled", True)),
|
|
128
|
-
max_upload_size_mb=int(str(max_size)),
|
|
129
|
-
auth_method=auth_method,
|
|
130
|
-
saml_config=saml_config,
|
|
131
|
-
license_token=data.get("license_token"), # type: ignore[arg-type]
|
|
132
|
-
default_role=str(data.get("default_role", "viewer")),
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
def is_enterprise(self) -> bool:
|
|
136
|
-
"""Check if tenant has enterprise features enabled."""
|
|
137
|
-
return self.auth_method in (AuthMethod.SAML, AuthMethod.BOTH)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def generate_api_key() -> str:
|
|
141
|
-
"""Generate a secure random API key with prefix."""
|
|
142
|
-
alphabet = string.ascii_letters + string.digits
|
|
143
|
-
key = "".join(secrets.choice(alphabet) for _ in range(API_KEY_LENGTH))
|
|
144
|
-
return f"{API_KEY_PREFIX}{key}"
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def hash_api_key(api_key: str) -> str:
|
|
148
|
-
"""Hash an API key using SHA-256 for secure storage."""
|
|
149
|
-
return hashlib.sha256(api_key.encode()).hexdigest()
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def verify_api_key(api_key: str, api_key_hash: str) -> bool:
|
|
153
|
-
"""Verify an API key against its hash using constant-time comparison."""
|
|
154
|
-
computed_hash = hash_api_key(api_key)
|
|
155
|
-
return hmac.compare_digest(computed_hash, api_key_hash)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
class TenantStore:
|
|
159
|
-
"""File-based tenant storage for MVP.
|
|
160
|
-
|
|
161
|
-
Easily swappable for database storage in production.
|
|
162
|
-
"""
|
|
163
|
-
|
|
164
|
-
def __init__(self, store_path: Path) -> None:
|
|
165
|
-
self._store_path = store_path
|
|
166
|
-
self._tenants: dict[str, Tenant] = {}
|
|
167
|
-
self._load()
|
|
168
|
-
|
|
169
|
-
def _load(self) -> None:
|
|
170
|
-
"""Load tenants from storage file."""
|
|
171
|
-
if not self._store_path.exists():
|
|
172
|
-
self._tenants = {}
|
|
173
|
-
return
|
|
174
|
-
|
|
175
|
-
try:
|
|
176
|
-
data = json.loads(self._store_path.read_text())
|
|
177
|
-
self._tenants = {
|
|
178
|
-
tid: Tenant.from_dict(tdata) for tid, tdata in data.get("tenants", {}).items()
|
|
179
|
-
}
|
|
180
|
-
except (json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
181
|
-
logger.warning("Failed to load tenant store: %s", exc)
|
|
182
|
-
self._tenants = {}
|
|
183
|
-
|
|
184
|
-
def _save(self) -> None:
|
|
185
|
-
"""Persist tenants to storage file."""
|
|
186
|
-
self._store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
-
data = {"tenants": {tid: t.to_dict() for tid, t in self._tenants.items()}}
|
|
188
|
-
self._store_path.write_text(json.dumps(data, indent=2))
|
|
189
|
-
|
|
190
|
-
def get_by_id(self, tenant_id: str) -> Tenant | None:
|
|
191
|
-
"""Get tenant by ID."""
|
|
192
|
-
return self._tenants.get(tenant_id)
|
|
193
|
-
|
|
194
|
-
def get_by_api_key(self, api_key: str) -> Tenant | None:
|
|
195
|
-
"""Find tenant by API key (constant-time for each tenant)."""
|
|
196
|
-
for tenant in self._tenants.values():
|
|
197
|
-
if verify_api_key(api_key, tenant.api_key_hash):
|
|
198
|
-
return tenant
|
|
199
|
-
return None
|
|
200
|
-
|
|
201
|
-
def create(
|
|
202
|
-
self,
|
|
203
|
-
tenant_id: str,
|
|
204
|
-
name: str,
|
|
205
|
-
dojo_product_id: int,
|
|
206
|
-
dojo_engagement_id: int,
|
|
207
|
-
max_upload_size_mb: int = 10,
|
|
208
|
-
auth_method: AuthMethod = AuthMethod.API_KEY,
|
|
209
|
-
saml_config: SAMLTenantConfig | None = None,
|
|
210
|
-
license_token: str | None = None,
|
|
211
|
-
default_role: str = "viewer",
|
|
212
|
-
) -> tuple[Tenant, str]:
|
|
213
|
-
"""Create a new tenant and return (tenant, api_key).
|
|
214
|
-
|
|
215
|
-
The plaintext API key is only returned once during creation.
|
|
216
|
-
"""
|
|
217
|
-
if tenant_id in self._tenants:
|
|
218
|
-
raise ValueError(f"Tenant {tenant_id} already exists")
|
|
219
|
-
|
|
220
|
-
api_key = generate_api_key()
|
|
221
|
-
tenant = Tenant(
|
|
222
|
-
id=tenant_id,
|
|
223
|
-
name=name,
|
|
224
|
-
api_key_hash=hash_api_key(api_key),
|
|
225
|
-
dojo_product_id=dojo_product_id,
|
|
226
|
-
dojo_engagement_id=dojo_engagement_id,
|
|
227
|
-
enabled=True,
|
|
228
|
-
max_upload_size_mb=max_upload_size_mb,
|
|
229
|
-
auth_method=auth_method,
|
|
230
|
-
saml_config=saml_config,
|
|
231
|
-
license_token=license_token,
|
|
232
|
-
default_role=default_role,
|
|
233
|
-
)
|
|
234
|
-
self._tenants[tenant_id] = tenant
|
|
235
|
-
self._save()
|
|
236
|
-
|
|
237
|
-
logger.info("Created tenant: %s", tenant_id)
|
|
238
|
-
return tenant, api_key
|
|
239
|
-
|
|
240
|
-
def update(self, tenant: Tenant) -> None:
|
|
241
|
-
"""Update an existing tenant."""
|
|
242
|
-
if tenant.id not in self._tenants:
|
|
243
|
-
raise ValueError(f"Tenant {tenant.id} does not exist")
|
|
244
|
-
self._tenants[tenant.id] = tenant
|
|
245
|
-
self._save()
|
|
246
|
-
logger.info("Updated tenant: %s", tenant.id)
|
|
247
|
-
|
|
248
|
-
def delete(self, tenant_id: str) -> bool:
|
|
249
|
-
"""Delete a tenant by ID."""
|
|
250
|
-
if tenant_id not in self._tenants:
|
|
251
|
-
return False
|
|
252
|
-
del self._tenants[tenant_id]
|
|
253
|
-
self._save()
|
|
254
|
-
logger.info("Deleted tenant: %s", tenant_id)
|
|
255
|
-
return True
|
|
256
|
-
|
|
257
|
-
def list_all(self) -> list[Tenant]:
|
|
258
|
-
"""List all tenants."""
|
|
259
|
-
return list(self._tenants.values())
|
|
260
|
-
|
|
261
|
-
def rotate_api_key(self, tenant_id: str) -> str | None:
|
|
262
|
-
"""Generate a new API key for a tenant."""
|
|
263
|
-
tenant = self._tenants.get(tenant_id)
|
|
264
|
-
if not tenant:
|
|
265
|
-
return None
|
|
266
|
-
|
|
267
|
-
new_api_key = generate_api_key()
|
|
268
|
-
updated = Tenant(
|
|
269
|
-
id=tenant.id,
|
|
270
|
-
name=tenant.name,
|
|
271
|
-
api_key_hash=hash_api_key(new_api_key),
|
|
272
|
-
dojo_product_id=tenant.dojo_product_id,
|
|
273
|
-
dojo_engagement_id=tenant.dojo_engagement_id,
|
|
274
|
-
enabled=tenant.enabled,
|
|
275
|
-
max_upload_size_mb=tenant.max_upload_size_mb,
|
|
276
|
-
auth_method=tenant.auth_method,
|
|
277
|
-
saml_config=tenant.saml_config,
|
|
278
|
-
license_token=tenant.license_token,
|
|
279
|
-
default_role=tenant.default_role,
|
|
280
|
-
)
|
|
281
|
-
self._tenants[tenant_id] = updated
|
|
282
|
-
self._save()
|
|
283
|
-
|
|
284
|
-
logger.info("Rotated API key for tenant: %s", tenant_id)
|
|
285
|
-
return new_api_key
|
|
286
|
-
|
|
287
|
-
def update_saml_config(
|
|
288
|
-
self,
|
|
289
|
-
tenant_id: str,
|
|
290
|
-
saml_config: SAMLTenantConfig,
|
|
291
|
-
auth_method: AuthMethod = AuthMethod.SAML,
|
|
292
|
-
) -> Tenant | None:
|
|
293
|
-
"""Update SAML configuration for a tenant."""
|
|
294
|
-
tenant = self._tenants.get(tenant_id)
|
|
295
|
-
if not tenant:
|
|
296
|
-
return None
|
|
297
|
-
|
|
298
|
-
updated = Tenant(
|
|
299
|
-
id=tenant.id,
|
|
300
|
-
name=tenant.name,
|
|
301
|
-
api_key_hash=tenant.api_key_hash,
|
|
302
|
-
dojo_product_id=tenant.dojo_product_id,
|
|
303
|
-
dojo_engagement_id=tenant.dojo_engagement_id,
|
|
304
|
-
enabled=tenant.enabled,
|
|
305
|
-
max_upload_size_mb=tenant.max_upload_size_mb,
|
|
306
|
-
auth_method=auth_method,
|
|
307
|
-
saml_config=saml_config,
|
|
308
|
-
license_token=tenant.license_token,
|
|
309
|
-
default_role=tenant.default_role,
|
|
310
|
-
)
|
|
311
|
-
self._tenants[tenant_id] = updated
|
|
312
|
-
self._save()
|
|
313
|
-
|
|
314
|
-
logger.info("Updated SAML config for tenant: %s", tenant_id)
|
|
315
|
-
return updated
|
|
316
|
-
|
|
317
|
-
def update_license(self, tenant_id: str, license_token: str) -> Tenant | None:
|
|
318
|
-
"""Update license token for a tenant."""
|
|
319
|
-
tenant = self._tenants.get(tenant_id)
|
|
320
|
-
if not tenant:
|
|
321
|
-
return None
|
|
322
|
-
|
|
323
|
-
updated = Tenant(
|
|
324
|
-
id=tenant.id,
|
|
325
|
-
name=tenant.name,
|
|
326
|
-
api_key_hash=tenant.api_key_hash,
|
|
327
|
-
dojo_product_id=tenant.dojo_product_id,
|
|
328
|
-
dojo_engagement_id=tenant.dojo_engagement_id,
|
|
329
|
-
enabled=tenant.enabled,
|
|
330
|
-
max_upload_size_mb=tenant.max_upload_size_mb,
|
|
331
|
-
auth_method=tenant.auth_method,
|
|
332
|
-
saml_config=tenant.saml_config,
|
|
333
|
-
license_token=license_token,
|
|
334
|
-
default_role=tenant.default_role,
|
|
335
|
-
)
|
|
336
|
-
self._tenants[tenant_id] = updated
|
|
337
|
-
self._save()
|
|
338
|
-
|
|
339
|
-
logger.info("Updated license for tenant: %s", tenant_id)
|
|
340
|
-
return updated
|
portal/uploads.py
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
"""File upload handling with validation and security controls.
|
|
2
|
-
|
|
3
|
-
Security controls:
|
|
4
|
-
- ASVS V5.2.2: Validate file uploads (type, content)
|
|
5
|
-
- ASVS V5.2.4: Enforce file size limits
|
|
6
|
-
- Files stored outside web root in secure temp location
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import hashlib
|
|
12
|
-
import json
|
|
13
|
-
import logging
|
|
14
|
-
import os
|
|
15
|
-
import re
|
|
16
|
-
import secrets
|
|
17
|
-
import tempfile
|
|
18
|
-
from dataclasses import dataclass
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
from typing import TYPE_CHECKING
|
|
21
|
-
|
|
22
|
-
from kekkai_core import redact
|
|
23
|
-
|
|
24
|
-
if TYPE_CHECKING:
|
|
25
|
-
from .tenants import Tenant
|
|
26
|
-
|
|
27
|
-
logger = logging.getLogger(__name__)
|
|
28
|
-
|
|
29
|
-
ALLOWED_EXTENSIONS = frozenset({".json", ".sarif"})
|
|
30
|
-
ALLOWED_CONTENT_TYPES = frozenset(
|
|
31
|
-
{
|
|
32
|
-
"application/json",
|
|
33
|
-
"application/sarif+json",
|
|
34
|
-
"application/octet-stream",
|
|
35
|
-
}
|
|
36
|
-
)
|
|
37
|
-
DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024
|
|
38
|
-
MIN_FILE_SIZE = 2
|
|
39
|
-
FILENAME_PATTERN = re.compile(r"^[\w\-. ]{1,255}$")
|
|
40
|
-
UPLOAD_ID_LENGTH = 24
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@dataclass(frozen=True)
|
|
44
|
-
class UploadResult:
|
|
45
|
-
"""Result of upload validation/processing."""
|
|
46
|
-
|
|
47
|
-
success: bool
|
|
48
|
-
upload_id: str | None = None
|
|
49
|
-
file_path: Path | None = None
|
|
50
|
-
file_hash: str | None = None
|
|
51
|
-
error: str | None = None
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@dataclass(frozen=True)
|
|
55
|
-
class UploadValidation:
|
|
56
|
-
"""Validated upload metadata."""
|
|
57
|
-
|
|
58
|
-
filename: str
|
|
59
|
-
extension: str
|
|
60
|
-
content_type: str
|
|
61
|
-
size: int
|
|
62
|
-
content: bytes
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def validate_upload(
|
|
66
|
-
filename: str | None,
|
|
67
|
-
content_type: str | None,
|
|
68
|
-
content: bytes,
|
|
69
|
-
tenant: Tenant,
|
|
70
|
-
) -> UploadResult:
|
|
71
|
-
"""Validate an uploaded file.
|
|
72
|
-
|
|
73
|
-
Args:
|
|
74
|
-
filename: Original filename
|
|
75
|
-
content_type: MIME type from request
|
|
76
|
-
content: File content bytes
|
|
77
|
-
tenant: Tenant performing upload (for size limits)
|
|
78
|
-
|
|
79
|
-
Returns:
|
|
80
|
-
UploadResult with error if validation fails
|
|
81
|
-
"""
|
|
82
|
-
if not filename:
|
|
83
|
-
return UploadResult(success=False, error="Missing filename")
|
|
84
|
-
|
|
85
|
-
safe_filename = _sanitize_filename(filename)
|
|
86
|
-
if not safe_filename:
|
|
87
|
-
logger.warning("upload.invalid_filename original=%s", redact(filename))
|
|
88
|
-
return UploadResult(success=False, error="Invalid filename")
|
|
89
|
-
|
|
90
|
-
extension = _get_extension(safe_filename)
|
|
91
|
-
if extension not in ALLOWED_EXTENSIONS:
|
|
92
|
-
logger.warning(
|
|
93
|
-
"upload.invalid_extension filename=%s extension=%s",
|
|
94
|
-
redact(safe_filename),
|
|
95
|
-
extension,
|
|
96
|
-
)
|
|
97
|
-
return UploadResult(
|
|
98
|
-
success=False,
|
|
99
|
-
error=f"Invalid file type. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
max_size = tenant.max_upload_size_mb * 1024 * 1024
|
|
103
|
-
if len(content) > max_size:
|
|
104
|
-
logger.warning(
|
|
105
|
-
"upload.size_exceeded tenant=%s size=%d max=%d",
|
|
106
|
-
tenant.id,
|
|
107
|
-
len(content),
|
|
108
|
-
max_size,
|
|
109
|
-
)
|
|
110
|
-
return UploadResult(
|
|
111
|
-
success=False,
|
|
112
|
-
error=f"File too large. Maximum: {tenant.max_upload_size_mb}MB",
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
if len(content) < MIN_FILE_SIZE:
|
|
116
|
-
return UploadResult(success=False, error="File is empty or too small")
|
|
117
|
-
|
|
118
|
-
if not _validate_json_content(content):
|
|
119
|
-
logger.warning(
|
|
120
|
-
"upload.invalid_json tenant=%s filename=%s", tenant.id, redact(safe_filename)
|
|
121
|
-
)
|
|
122
|
-
return UploadResult(success=False, error="Invalid JSON content")
|
|
123
|
-
|
|
124
|
-
return UploadResult(success=True)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def process_upload(
|
|
128
|
-
filename: str,
|
|
129
|
-
content: bytes,
|
|
130
|
-
tenant: Tenant,
|
|
131
|
-
upload_dir: Path | None = None,
|
|
132
|
-
) -> UploadResult:
|
|
133
|
-
"""Process and store a validated upload.
|
|
134
|
-
|
|
135
|
-
Files are stored outside web root in a secure temp directory.
|
|
136
|
-
|
|
137
|
-
Args:
|
|
138
|
-
filename: Sanitized filename
|
|
139
|
-
content: Validated file content
|
|
140
|
-
tenant: Tenant performing upload
|
|
141
|
-
upload_dir: Override upload directory (for testing)
|
|
142
|
-
|
|
143
|
-
Returns:
|
|
144
|
-
UploadResult with file path and hash if successful
|
|
145
|
-
"""
|
|
146
|
-
validation = validate_upload(filename, None, content, tenant)
|
|
147
|
-
if not validation.success:
|
|
148
|
-
return validation
|
|
149
|
-
|
|
150
|
-
safe_filename = _sanitize_filename(filename)
|
|
151
|
-
if not safe_filename:
|
|
152
|
-
return UploadResult(success=False, error="Invalid filename")
|
|
153
|
-
|
|
154
|
-
upload_id = _generate_upload_id()
|
|
155
|
-
file_hash = hashlib.sha256(content).hexdigest()
|
|
156
|
-
|
|
157
|
-
base_dir = upload_dir or _get_upload_dir()
|
|
158
|
-
tenant_dir = base_dir / tenant.id
|
|
159
|
-
tenant_dir.mkdir(parents=True, exist_ok=True)
|
|
160
|
-
|
|
161
|
-
extension = _get_extension(safe_filename)
|
|
162
|
-
stored_filename = f"{upload_id}{extension}"
|
|
163
|
-
file_path = tenant_dir / stored_filename
|
|
164
|
-
|
|
165
|
-
file_path.write_bytes(content)
|
|
166
|
-
os.chmod(file_path, 0o600)
|
|
167
|
-
|
|
168
|
-
logger.info(
|
|
169
|
-
"upload.stored tenant=%s upload_id=%s hash=%s size=%d",
|
|
170
|
-
tenant.id,
|
|
171
|
-
upload_id,
|
|
172
|
-
file_hash[:16],
|
|
173
|
-
len(content),
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
return UploadResult(
|
|
177
|
-
success=True,
|
|
178
|
-
upload_id=upload_id,
|
|
179
|
-
file_path=file_path,
|
|
180
|
-
file_hash=file_hash,
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def get_upload_path(tenant: Tenant, upload_id: str, upload_dir: Path | None = None) -> Path | None:
|
|
185
|
-
"""Get the path to an upload file for a specific tenant.
|
|
186
|
-
|
|
187
|
-
Enforces tenant boundary - only returns path if upload belongs to tenant.
|
|
188
|
-
"""
|
|
189
|
-
if not _is_valid_upload_id(upload_id):
|
|
190
|
-
return None
|
|
191
|
-
|
|
192
|
-
base_dir = upload_dir or _get_upload_dir()
|
|
193
|
-
tenant_dir = base_dir / tenant.id
|
|
194
|
-
|
|
195
|
-
for ext in ALLOWED_EXTENSIONS:
|
|
196
|
-
file_path = tenant_dir / f"{upload_id}{ext}"
|
|
197
|
-
if file_path.exists() and file_path.is_file():
|
|
198
|
-
resolved = file_path.resolve()
|
|
199
|
-
if resolved.is_relative_to(tenant_dir.resolve()):
|
|
200
|
-
return resolved
|
|
201
|
-
return None
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def delete_upload(tenant: Tenant, upload_id: str, upload_dir: Path | None = None) -> bool:
|
|
205
|
-
"""Delete an upload file."""
|
|
206
|
-
file_path = get_upload_path(tenant, upload_id, upload_dir)
|
|
207
|
-
if file_path and file_path.exists():
|
|
208
|
-
file_path.unlink()
|
|
209
|
-
logger.info("upload.deleted tenant=%s upload_id=%s", tenant.id, upload_id)
|
|
210
|
-
return True
|
|
211
|
-
return False
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _sanitize_filename(filename: str) -> str | None:
|
|
215
|
-
"""Sanitize filename to prevent path traversal."""
|
|
216
|
-
basename = Path(filename).name
|
|
217
|
-
if not basename or ".." in basename:
|
|
218
|
-
return None
|
|
219
|
-
basename = basename.replace("\x00", "")
|
|
220
|
-
if not FILENAME_PATTERN.match(basename):
|
|
221
|
-
cleaned = re.sub(r"[^\w\-. ]", "_", basename)
|
|
222
|
-
if not cleaned or len(cleaned) > 255:
|
|
223
|
-
return None
|
|
224
|
-
return cleaned
|
|
225
|
-
return basename
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def _get_extension(filename: str) -> str:
|
|
229
|
-
"""Get lowercase file extension."""
|
|
230
|
-
return Path(filename).suffix.lower()
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def _validate_json_content(content: bytes) -> bool:
|
|
234
|
-
"""Validate that content is valid JSON."""
|
|
235
|
-
try:
|
|
236
|
-
json.loads(content.decode("utf-8"))
|
|
237
|
-
return True
|
|
238
|
-
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
239
|
-
return False
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def _generate_upload_id() -> str:
|
|
243
|
-
"""Generate a secure random upload ID."""
|
|
244
|
-
return secrets.token_urlsafe(UPLOAD_ID_LENGTH)[:UPLOAD_ID_LENGTH]
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def _is_valid_upload_id(upload_id: str) -> bool:
|
|
248
|
-
"""Validate upload ID format."""
|
|
249
|
-
if not upload_id or len(upload_id) != UPLOAD_ID_LENGTH:
|
|
250
|
-
return False
|
|
251
|
-
return all(c.isalnum() or c in "-_" for c in upload_id)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def _get_upload_dir() -> Path:
|
|
255
|
-
"""Get the secure upload directory outside web root."""
|
|
256
|
-
base = os.environ.get("PORTAL_UPLOAD_DIR")
|
|
257
|
-
if base:
|
|
258
|
-
return Path(base)
|
|
259
|
-
return Path(tempfile.gettempdir()) / "kekkai-portal-uploads"
|