Compare commits
12 Commits
ba694cb717
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 899db3421b | |||
| caf01d6ada | |||
| bb366cb4cd | |||
| a2745ff2ee | |||
| 28cb656d94 | |||
| 3c44152fc6 | |||
| 397515edce | |||
| 980fced7e4 | |||
| bae5009ec4 | |||
| 233780617f | |||
| fd8fb21517 | |||
| c6cbe822e1 |
@@ -1,10 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from fnmatch import fnmatch, translate
|
from fnmatch import fnmatch, translate
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
||||||
@@ -12,65 +11,6 @@ from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
|||||||
|
|
||||||
RESOURCE_PREFIX = "arn:aws:s3:::"
|
RESOURCE_PREFIX = "arn:aws:s3:::"
|
||||||
|
|
||||||
|
|
||||||
def _match_string_like(value: str, pattern: str) -> bool:
|
|
||||||
regex = translate(pattern)
|
|
||||||
return bool(re.match(regex, value, re.IGNORECASE))
|
|
||||||
|
|
||||||
|
|
||||||
def _ip_in_cidr(ip_str: str, cidr: str) -> bool:
|
|
||||||
try:
|
|
||||||
ip = ipaddress.ip_address(ip_str)
|
|
||||||
network = ipaddress.ip_network(cidr, strict=False)
|
|
||||||
return ip in network
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _evaluate_condition_operator(
|
|
||||||
operator: str,
|
|
||||||
condition_key: str,
|
|
||||||
condition_values: List[str],
|
|
||||||
context: Dict[str, Any],
|
|
||||||
) -> bool:
|
|
||||||
context_value = context.get(condition_key)
|
|
||||||
op_lower = operator.lower()
|
|
||||||
if_exists = op_lower.endswith("ifexists")
|
|
||||||
if if_exists:
|
|
||||||
op_lower = op_lower[:-8]
|
|
||||||
|
|
||||||
if context_value is None:
|
|
||||||
return if_exists
|
|
||||||
|
|
||||||
context_value_str = str(context_value)
|
|
||||||
context_value_lower = context_value_str.lower()
|
|
||||||
|
|
||||||
if op_lower == "stringequals":
|
|
||||||
return context_value_str in condition_values
|
|
||||||
elif op_lower == "stringnotequals":
|
|
||||||
return context_value_str not in condition_values
|
|
||||||
elif op_lower == "stringequalsignorecase":
|
|
||||||
return context_value_lower in [v.lower() for v in condition_values]
|
|
||||||
elif op_lower == "stringnotequalsignorecase":
|
|
||||||
return context_value_lower not in [v.lower() for v in condition_values]
|
|
||||||
elif op_lower == "stringlike":
|
|
||||||
return any(_match_string_like(context_value_str, p) for p in condition_values)
|
|
||||||
elif op_lower == "stringnotlike":
|
|
||||||
return not any(_match_string_like(context_value_str, p) for p in condition_values)
|
|
||||||
elif op_lower == "ipaddress":
|
|
||||||
return any(_ip_in_cidr(context_value_str, cidr) for cidr in condition_values)
|
|
||||||
elif op_lower == "notipaddress":
|
|
||||||
return not any(_ip_in_cidr(context_value_str, cidr) for cidr in condition_values)
|
|
||||||
elif op_lower == "bool":
|
|
||||||
bool_val = context_value_lower in ("true", "1", "yes")
|
|
||||||
return str(bool_val).lower() in [v.lower() for v in condition_values]
|
|
||||||
elif op_lower == "null":
|
|
||||||
is_null = context_value is None or context_value == ""
|
|
||||||
expected_null = condition_values[0].lower() in ("true", "1", "yes") if condition_values else True
|
|
||||||
return is_null == expected_null
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
ACTION_ALIASES = {
|
ACTION_ALIASES = {
|
||||||
# List actions
|
# List actions
|
||||||
"s3:listbucket": "list",
|
"s3:listbucket": "list",
|
||||||
@@ -195,16 +135,18 @@ class BucketPolicyStatement:
|
|||||||
principals: List[str] | str
|
principals: List[str] | str
|
||||||
actions: List[str]
|
actions: List[str]
|
||||||
resources: List[Tuple[str | None, str | None]]
|
resources: List[Tuple[str | None, str | None]]
|
||||||
conditions: Dict[str, Dict[str, List[str]]] = field(default_factory=dict)
|
# Performance: Pre-compiled regex patterns for resource matching
|
||||||
_compiled_patterns: List[Tuple[str | None, Optional[Pattern[str]]]] | None = None
|
_compiled_patterns: List[Tuple[str | None, Optional[Pattern[str]]]] | None = None
|
||||||
|
|
||||||
def _get_compiled_patterns(self) -> List[Tuple[str | None, Optional[Pattern[str]]]]:
|
def _get_compiled_patterns(self) -> List[Tuple[str | None, Optional[Pattern[str]]]]:
|
||||||
|
"""Lazily compile fnmatch patterns to regex for faster matching."""
|
||||||
if self._compiled_patterns is None:
|
if self._compiled_patterns is None:
|
||||||
self._compiled_patterns = []
|
self._compiled_patterns = []
|
||||||
for resource_bucket, key_pattern in self.resources:
|
for resource_bucket, key_pattern in self.resources:
|
||||||
if key_pattern is None:
|
if key_pattern is None:
|
||||||
self._compiled_patterns.append((resource_bucket, None))
|
self._compiled_patterns.append((resource_bucket, None))
|
||||||
else:
|
else:
|
||||||
|
# Convert fnmatch pattern to regex
|
||||||
regex_pattern = translate(key_pattern)
|
regex_pattern = translate(key_pattern)
|
||||||
self._compiled_patterns.append((resource_bucket, re.compile(regex_pattern)))
|
self._compiled_patterns.append((resource_bucket, re.compile(regex_pattern)))
|
||||||
return self._compiled_patterns
|
return self._compiled_patterns
|
||||||
@@ -231,21 +173,11 @@ class BucketPolicyStatement:
|
|||||||
if not key:
|
if not key:
|
||||||
return True
|
return True
|
||||||
continue
|
continue
|
||||||
|
# Performance: Use pre-compiled regex instead of fnmatch
|
||||||
if compiled_pattern.match(key):
|
if compiled_pattern.match(key):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def matches_condition(self, context: Optional[Dict[str, Any]]) -> bool:
|
|
||||||
if not self.conditions:
|
|
||||||
return True
|
|
||||||
if context is None:
|
|
||||||
context = {}
|
|
||||||
for operator, key_values in self.conditions.items():
|
|
||||||
for condition_key, condition_values in key_values.items():
|
|
||||||
if not _evaluate_condition_operator(operator, condition_key, condition_values, context):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class BucketPolicyStore:
|
class BucketPolicyStore:
|
||||||
"""Loads bucket policies from disk and evaluates statements."""
|
"""Loads bucket policies from disk and evaluates statements."""
|
||||||
@@ -287,7 +219,6 @@ class BucketPolicyStore:
|
|||||||
bucket: Optional[str],
|
bucket: Optional[str],
|
||||||
object_key: Optional[str],
|
object_key: Optional[str],
|
||||||
action: str,
|
action: str,
|
||||||
context: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
bucket = (bucket or "").lower()
|
bucket = (bucket or "").lower()
|
||||||
statements = self._policies.get(bucket) or []
|
statements = self._policies.get(bucket) or []
|
||||||
@@ -299,8 +230,6 @@ class BucketPolicyStore:
|
|||||||
continue
|
continue
|
||||||
if not statement.matches_resource(bucket, object_key):
|
if not statement.matches_resource(bucket, object_key):
|
||||||
continue
|
continue
|
||||||
if not statement.matches_condition(context):
|
|
||||||
continue
|
|
||||||
if statement.effect == "deny":
|
if statement.effect == "deny":
|
||||||
return "deny"
|
return "deny"
|
||||||
decision = "allow"
|
decision = "allow"
|
||||||
@@ -365,7 +294,6 @@ class BucketPolicyStore:
|
|||||||
if not resources:
|
if not resources:
|
||||||
continue
|
continue
|
||||||
effect = statement.get("Effect", "Allow").lower()
|
effect = statement.get("Effect", "Allow").lower()
|
||||||
conditions = self._normalize_conditions(statement.get("Condition", {}))
|
|
||||||
statements.append(
|
statements.append(
|
||||||
BucketPolicyStatement(
|
BucketPolicyStatement(
|
||||||
sid=statement.get("Sid"),
|
sid=statement.get("Sid"),
|
||||||
@@ -373,24 +301,6 @@ class BucketPolicyStore:
|
|||||||
principals=principals,
|
principals=principals,
|
||||||
actions=actions or ["*"],
|
actions=actions or ["*"],
|
||||||
resources=resources,
|
resources=resources,
|
||||||
conditions=conditions,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return statements
|
return statements
|
||||||
|
|
||||||
def _normalize_conditions(self, condition_block: Dict[str, Any]) -> Dict[str, Dict[str, List[str]]]:
|
|
||||||
if not condition_block or not isinstance(condition_block, dict):
|
|
||||||
return {}
|
|
||||||
normalized: Dict[str, Dict[str, List[str]]] = {}
|
|
||||||
for operator, key_values in condition_block.items():
|
|
||||||
if not isinstance(key_values, dict):
|
|
||||||
continue
|
|
||||||
normalized[operator] = {}
|
|
||||||
for cond_key, cond_values in key_values.items():
|
|
||||||
if isinstance(cond_values, str):
|
|
||||||
normalized[operator][cond_key] = [cond_values]
|
|
||||||
elif isinstance(cond_values, list):
|
|
||||||
normalized[operator][cond_key] = [str(v) for v in cond_values]
|
|
||||||
else:
|
|
||||||
normalized[operator][cond_key] = [str(cond_values)]
|
|
||||||
return normalized
|
|
||||||
@@ -337,6 +337,4 @@ class AppConfig:
|
|||||||
"KMS_KEYS_PATH": str(self.kms_keys_path),
|
"KMS_KEYS_PATH": str(self.kms_keys_path),
|
||||||
"DEFAULT_ENCRYPTION_ALGORITHM": self.default_encryption_algorithm,
|
"DEFAULT_ENCRYPTION_ALGORITHM": self.default_encryption_algorithm,
|
||||||
"DISPLAY_TIMEZONE": self.display_timezone,
|
"DISPLAY_TIMEZONE": self.display_timezone,
|
||||||
"LIFECYCLE_ENABLED": self.lifecycle_enabled,
|
|
||||||
"LIFECYCLE_INTERVAL_SECONDS": self.lifecycle_interval_seconds,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,20 +53,6 @@ def _bucket_policies() -> BucketPolicyStore:
|
|||||||
return store
|
return store
|
||||||
|
|
||||||
|
|
||||||
def _build_policy_context() -> Dict[str, Any]:
|
|
||||||
ctx: Dict[str, Any] = {}
|
|
||||||
if request.headers.get("Referer"):
|
|
||||||
ctx["aws:Referer"] = request.headers.get("Referer")
|
|
||||||
if request.access_route:
|
|
||||||
ctx["aws:SourceIp"] = request.access_route[0]
|
|
||||||
elif request.remote_addr:
|
|
||||||
ctx["aws:SourceIp"] = request.remote_addr
|
|
||||||
ctx["aws:SecureTransport"] = str(request.is_secure).lower()
|
|
||||||
if request.headers.get("User-Agent"):
|
|
||||||
ctx["aws:UserAgent"] = request.headers.get("User-Agent")
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
def _object_lock() -> ObjectLockService:
|
def _object_lock() -> ObjectLockService:
|
||||||
return current_app.extensions["object_lock"]
|
return current_app.extensions["object_lock"]
|
||||||
|
|
||||||
@@ -394,8 +380,7 @@ def _authorize_action(principal: Principal | None, bucket_name: str | None, acti
|
|||||||
policy_decision = None
|
policy_decision = None
|
||||||
access_key = principal.access_key if principal else None
|
access_key = principal.access_key if principal else None
|
||||||
if bucket_name:
|
if bucket_name:
|
||||||
policy_context = _build_policy_context()
|
policy_decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action)
|
||||||
policy_decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action, policy_context)
|
|
||||||
if policy_decision == "deny":
|
if policy_decision == "deny":
|
||||||
raise IamError("Access denied by bucket policy")
|
raise IamError("Access denied by bucket policy")
|
||||||
|
|
||||||
@@ -422,13 +407,11 @@ def _authorize_action(principal: Principal | None, bucket_name: str | None, acti
|
|||||||
def _enforce_bucket_policy(principal: Principal | None, bucket_name: str | None, object_key: str | None, action: str) -> None:
|
def _enforce_bucket_policy(principal: Principal | None, bucket_name: str | None, object_key: str | None, action: str) -> None:
|
||||||
if not bucket_name:
|
if not bucket_name:
|
||||||
return
|
return
|
||||||
policy_context = _build_policy_context()
|
|
||||||
decision = _bucket_policies().evaluate(
|
decision = _bucket_policies().evaluate(
|
||||||
principal.access_key if principal else None,
|
principal.access_key if principal else None,
|
||||||
bucket_name,
|
bucket_name,
|
||||||
object_key,
|
object_key,
|
||||||
action,
|
action,
|
||||||
policy_context,
|
|
||||||
)
|
)
|
||||||
if decision == "deny":
|
if decision == "deny":
|
||||||
raise IamError("Access denied by bucket policy")
|
raise IamError("Access denied by bucket policy")
|
||||||
|
|||||||
17
app/ui.py
17
app/ui.py
@@ -62,20 +62,6 @@ def _bucket_policies() -> BucketPolicyStore:
|
|||||||
return store
|
return store
|
||||||
|
|
||||||
|
|
||||||
def _build_policy_context() -> dict[str, Any]:
|
|
||||||
ctx: dict[str, Any] = {}
|
|
||||||
if request.headers.get("Referer"):
|
|
||||||
ctx["aws:Referer"] = request.headers.get("Referer")
|
|
||||||
if request.access_route:
|
|
||||||
ctx["aws:SourceIp"] = request.access_route[0]
|
|
||||||
elif request.remote_addr:
|
|
||||||
ctx["aws:SourceIp"] = request.remote_addr
|
|
||||||
ctx["aws:SecureTransport"] = str(request.is_secure).lower()
|
|
||||||
if request.headers.get("User-Agent"):
|
|
||||||
ctx["aws:UserAgent"] = request.headers.get("User-Agent")
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
def _connections() -> ConnectionStore:
|
def _connections() -> ConnectionStore:
|
||||||
return current_app.extensions["connections"]
|
return current_app.extensions["connections"]
|
||||||
|
|
||||||
@@ -186,8 +172,7 @@ def _authorize_ui(principal, bucket_name: str | None, action: str, *, object_key
|
|||||||
enforce_bucket_policies = current_app.config.get("UI_ENFORCE_BUCKET_POLICIES", True)
|
enforce_bucket_policies = current_app.config.get("UI_ENFORCE_BUCKET_POLICIES", True)
|
||||||
if bucket_name and enforce_bucket_policies:
|
if bucket_name and enforce_bucket_policies:
|
||||||
access_key = principal.access_key if principal else None
|
access_key = principal.access_key if principal else None
|
||||||
policy_context = _build_policy_context()
|
decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action)
|
||||||
decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action, policy_context)
|
|
||||||
if decision == "deny":
|
if decision == "deny":
|
||||||
raise IamError("Access denied by bucket policy")
|
raise IamError("Access denied by bucket policy")
|
||||||
if not iam_allowed and decision != "allow":
|
if not iam_allowed and decision != "allow":
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
APP_VERSION = "0.2.2"
|
APP_VERSION = "0.2.1"
|
||||||
|
|
||||||
|
|
||||||
def get_version() -> str:
|
def get_version() -> str:
|
||||||
|
|||||||
@@ -559,6 +559,9 @@
|
|||||||
if (loadMoreStatus) {
|
if (loadMoreStatus) {
|
||||||
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
||||||
}
|
}
|
||||||
|
if (typeof updateLoadMoreButton === 'function') {
|
||||||
|
updateLoadMoreButton();
|
||||||
|
}
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
renderBreadcrumb(currentPrefix);
|
renderBreadcrumb(currentPrefix);
|
||||||
|
|
||||||
@@ -637,6 +640,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof updateLoadMoreButton === 'function') {
|
||||||
|
updateLoadMoreButton();
|
||||||
|
}
|
||||||
|
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
renderBreadcrumb(currentPrefix);
|
renderBreadcrumb(currentPrefix);
|
||||||
|
|
||||||
@@ -732,11 +739,24 @@
|
|||||||
|
|
||||||
const scrollSentinel = document.getElementById('scroll-sentinel');
|
const scrollSentinel = document.getElementById('scroll-sentinel');
|
||||||
const scrollContainer = document.querySelector('.objects-table-container');
|
const scrollContainer = document.querySelector('.objects-table-container');
|
||||||
|
const loadMoreBtn = document.getElementById('load-more-btn');
|
||||||
|
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true });
|
scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadMoreBtn?.addEventListener('click', () => {
|
||||||
|
if (hasMoreObjects && !isLoadingObjects) {
|
||||||
|
loadObjects(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateLoadMoreButton() {
|
||||||
|
if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.classList.toggle('d-none', !hasMoreObjects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (scrollSentinel && scrollContainer) {
|
if (scrollSentinel && scrollContainer) {
|
||||||
const containerObserver = new IntersectionObserver((entries) => {
|
const containerObserver = new IntersectionObserver((entries) => {
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
@@ -765,6 +785,10 @@
|
|||||||
viewportObserver.observe(scrollSentinel);
|
viewportObserver.observe(scrollSentinel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pageSizeSelect = document.getElementById('page-size-select');
|
||||||
|
pageSizeSelect?.addEventListener('change', (e) => {
|
||||||
|
pageSize = parseInt(e.target.value, 10);
|
||||||
|
});
|
||||||
|
|
||||||
if (objectsApiUrl) {
|
if (objectsApiUrl) {
|
||||||
loadObjects();
|
loadObjects();
|
||||||
|
|||||||
@@ -187,6 +187,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<span id="load-more-status" class="text-muted"></span>
|
<span id="load-more-status" class="text-muted"></span>
|
||||||
<span id="folder-view-status" class="text-muted d-none"></span>
|
<span id="folder-view-status" class="text-muted d-none"></span>
|
||||||
|
<button id="load-more-btn" class="btn btn-link btn-sm p-0 d-none" style="font-size: 0.75rem;">Load more</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<span class="text-muted">Batch</span>
|
||||||
|
<select id="page-size-select" class="form-select form-select-sm py-0" style="width: auto; font-size: 0.75rem;" title="Number of objects to load per batch">
|
||||||
|
<option value="1000">1K</option>
|
||||||
|
<option value="5000" selected>5K</option>
|
||||||
|
<option value="10000">10K</option>
|
||||||
|
<option value="25000">25K</option>
|
||||||
|
<option value="50000">50K</option>
|
||||||
|
<option value="75000">75K</option>
|
||||||
|
<option value="100000">100K</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-muted">per batch</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user