5 Commits

9 changed files with 504 additions and 170 deletions

View File

@@ -1,9 +1,10 @@
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 from dataclasses import dataclass, field
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
@@ -11,14 +12,71 @@ 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
"s3:listbucket": "list", "s3:listbucket": "list",
"s3:listallmybuckets": "list", "s3:listallmybuckets": "list",
"s3:listbucketversions": "list", "s3:listbucketversions": "list",
"s3:listmultipartuploads": "list", "s3:listmultipartuploads": "list",
"s3:listparts": "list", "s3:listparts": "list",
# Read actions
"s3:getobject": "read", "s3:getobject": "read",
"s3:getobjectversion": "read", "s3:getobjectversion": "read",
"s3:getobjecttagging": "read", "s3:getobjecttagging": "read",
@@ -27,7 +85,6 @@ ACTION_ALIASES = {
"s3:getbucketversioning": "read", "s3:getbucketversioning": "read",
"s3:headobject": "read", "s3:headobject": "read",
"s3:headbucket": "read", "s3:headbucket": "read",
# Write actions
"s3:putobject": "write", "s3:putobject": "write",
"s3:createbucket": "write", "s3:createbucket": "write",
"s3:putobjecttagging": "write", "s3:putobjecttagging": "write",
@@ -37,26 +94,30 @@ ACTION_ALIASES = {
"s3:completemultipartupload": "write", "s3:completemultipartupload": "write",
"s3:abortmultipartupload": "write", "s3:abortmultipartupload": "write",
"s3:copyobject": "write", "s3:copyobject": "write",
# Delete actions
"s3:deleteobject": "delete", "s3:deleteobject": "delete",
"s3:deleteobjectversion": "delete", "s3:deleteobjectversion": "delete",
"s3:deletebucket": "delete", "s3:deletebucket": "delete",
"s3:deleteobjecttagging": "delete", "s3:deleteobjecttagging": "delete",
# Share actions (ACL)
"s3:putobjectacl": "share", "s3:putobjectacl": "share",
"s3:putbucketacl": "share", "s3:putbucketacl": "share",
"s3:getbucketacl": "share", "s3:getbucketacl": "share",
# Policy actions
"s3:putbucketpolicy": "policy", "s3:putbucketpolicy": "policy",
"s3:getbucketpolicy": "policy", "s3:getbucketpolicy": "policy",
"s3:deletebucketpolicy": "policy", "s3:deletebucketpolicy": "policy",
# Replication actions
"s3:getreplicationconfiguration": "replication", "s3:getreplicationconfiguration": "replication",
"s3:putreplicationconfiguration": "replication", "s3:putreplicationconfiguration": "replication",
"s3:deletereplicationconfiguration": "replication", "s3:deletereplicationconfiguration": "replication",
"s3:replicateobject": "replication", "s3:replicateobject": "replication",
"s3:replicatetags": "replication", "s3:replicatetags": "replication",
"s3:replicatedelete": "replication", "s3:replicatedelete": "replication",
"s3:getlifecycleconfiguration": "lifecycle",
"s3:putlifecycleconfiguration": "lifecycle",
"s3:deletelifecycleconfiguration": "lifecycle",
"s3:getbucketlifecycle": "lifecycle",
"s3:putbucketlifecycle": "lifecycle",
"s3:getbucketcors": "cors",
"s3:putbucketcors": "cors",
"s3:deletebucketcors": "cors",
} }
@@ -135,18 +196,16 @@ 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]]
# Performance: Pre-compiled regex patterns for resource matching conditions: Dict[str, Dict[str, List[str]]] = field(default_factory=dict)
_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
@@ -173,11 +232,21 @@ 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."""
@@ -219,6 +288,7 @@ 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 []
@@ -230,6 +300,8 @@ 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"
@@ -294,6 +366,7 @@ 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"),
@@ -301,6 +374,24 @@ 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

View File

@@ -337,4 +337,6 @@ 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,
} }

View File

@@ -15,7 +15,7 @@ class IamError(RuntimeError):
"""Raised when authentication or authorization fails.""" """Raised when authentication or authorization fails."""
S3_ACTIONS = {"list", "read", "write", "delete", "share", "policy", "replication"} S3_ACTIONS = {"list", "read", "write", "delete", "share", "policy", "replication", "lifecycle", "cors"}
IAM_ACTIONS = { IAM_ACTIONS = {
"iam:list_users", "iam:list_users",
"iam:create_user", "iam:create_user",
@@ -71,6 +71,16 @@ ACTION_ALIASES = {
"s3:replicateobject": "replication", "s3:replicateobject": "replication",
"s3:replicatetags": "replication", "s3:replicatetags": "replication",
"s3:replicatedelete": "replication", "s3:replicatedelete": "replication",
"lifecycle": "lifecycle",
"s3:getlifecycleconfiguration": "lifecycle",
"s3:putlifecycleconfiguration": "lifecycle",
"s3:deletelifecycleconfiguration": "lifecycle",
"s3:getbucketlifecycle": "lifecycle",
"s3:putbucketlifecycle": "lifecycle",
"cors": "cors",
"s3:getbucketcors": "cors",
"s3:putbucketcors": "cors",
"s3:deletebucketcors": "cors",
"iam:listusers": "iam:list_users", "iam:listusers": "iam:list_users",
"iam:createuser": "iam:create_user", "iam:createuser": "iam:create_user",
"iam:deleteuser": "iam:delete_user", "iam:deleteuser": "iam:delete_user",

View File

@@ -53,6 +53,20 @@ 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"]
@@ -380,7 +394,8 @@ 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_decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action) policy_context = _build_policy_context()
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")
@@ -407,11 +422,13 @@ 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")

View File

@@ -5,8 +5,10 @@ import json
import uuid import uuid
import psutil import psutil
import shutil import shutil
from datetime import datetime, timezone as dt_timezone
from typing import Any from typing import Any
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
from zoneinfo import ZoneInfo
import boto3 import boto3
import requests import requests
@@ -39,6 +41,20 @@ from .storage import ObjectStorage, StorageError
ui_bp = Blueprint("ui", __name__, template_folder="../templates", url_prefix="/ui") ui_bp = Blueprint("ui", __name__, template_folder="../templates", url_prefix="/ui")
def _format_datetime_display(dt: datetime) -> str:
"""Format a datetime for display using the configured timezone."""
display_tz = current_app.config.get("DISPLAY_TIMEZONE", "UTC")
if display_tz and display_tz != "UTC":
try:
tz = ZoneInfo(display_tz)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=dt_timezone.utc)
dt = dt.astimezone(tz)
except (KeyError, ValueError):
pass
return dt.strftime("%b %d, %Y %H:%M")
def _storage() -> ObjectStorage: def _storage() -> ObjectStorage:
return current_app.extensions["object_storage"] return current_app.extensions["object_storage"]
@@ -62,6 +78,20 @@ 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"]
@@ -172,7 +202,8 @@ 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
decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action) policy_context = _build_policy_context()
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":
@@ -350,6 +381,23 @@ def bucket_detail(bucket_name: str):
can_edit_policy = True can_edit_policy = True
except IamError: except IamError:
can_edit_policy = False can_edit_policy = False
can_manage_lifecycle = False
if principal:
try:
_iam().authorize(principal, bucket_name, "lifecycle")
can_manage_lifecycle = True
except IamError:
can_manage_lifecycle = False
can_manage_cors = False
if principal:
try:
_iam().authorize(principal, bucket_name, "cors")
can_manage_cors = True
except IamError:
can_manage_cors = False
try: try:
versioning_enabled = storage.is_versioning_enabled(bucket_name) versioning_enabled = storage.is_versioning_enabled(bucket_name)
except StorageError: except StorageError:
@@ -421,6 +469,8 @@ def bucket_detail(bucket_name: str):
bucket_policy_text=policy_text, bucket_policy_text=policy_text,
bucket_policy=bucket_policy, bucket_policy=bucket_policy,
can_edit_policy=can_edit_policy, can_edit_policy=can_edit_policy,
can_manage_lifecycle=can_manage_lifecycle,
can_manage_cors=can_manage_cors,
can_manage_versioning=can_manage_versioning, can_manage_versioning=can_manage_versioning,
can_manage_replication=can_manage_replication, can_manage_replication=can_manage_replication,
can_manage_encryption=can_manage_encryption, can_manage_encryption=can_manage_encryption,
@@ -484,7 +534,7 @@ def list_bucket_objects(bucket_name: str):
"key": obj.key, "key": obj.key,
"size": obj.size, "size": obj.size,
"last_modified": obj.last_modified.isoformat(), "last_modified": obj.last_modified.isoformat(),
"last_modified_display": obj.last_modified.strftime("%b %d, %Y %H:%M"), "last_modified_display": _format_datetime_display(obj.last_modified),
"etag": obj.etag, "etag": obj.etag,
}) })
@@ -582,7 +632,7 @@ def stream_bucket_objects(bucket_name: str):
"key": obj.key, "key": obj.key,
"size": obj.size, "size": obj.size,
"last_modified": obj.last_modified.isoformat(), "last_modified": obj.last_modified.isoformat(),
"last_modified_display": obj.last_modified.strftime("%b %d, %Y %H:%M"), "last_modified_display": _format_datetime_display(obj.last_modified),
"etag": obj.etag, "etag": obj.etag,
}) + "\n" }) + "\n"
@@ -2097,7 +2147,7 @@ def metrics_api():
def bucket_lifecycle(bucket_name: str): def bucket_lifecycle(bucket_name: str):
principal = _current_principal() principal = _current_principal()
try: try:
_authorize_ui(principal, bucket_name, "policy") _authorize_ui(principal, bucket_name, "lifecycle")
except IamError as exc: except IamError as exc:
return jsonify({"error": str(exc)}), 403 return jsonify({"error": str(exc)}), 403
@@ -2150,7 +2200,7 @@ def bucket_lifecycle(bucket_name: str):
def get_lifecycle_history(bucket_name: str): def get_lifecycle_history(bucket_name: str):
principal = _current_principal() principal = _current_principal()
try: try:
_authorize_ui(principal, bucket_name, "policy") _authorize_ui(principal, bucket_name, "lifecycle")
except IamError: except IamError:
return jsonify({"error": "Access denied"}), 403 return jsonify({"error": "Access denied"}), 403
@@ -2181,7 +2231,7 @@ def get_lifecycle_history(bucket_name: str):
def bucket_cors(bucket_name: str): def bucket_cors(bucket_name: str):
principal = _current_principal() principal = _current_principal()
try: try:
_authorize_ui(principal, bucket_name, "policy") _authorize_ui(principal, bucket_name, "cors")
except IamError as exc: except IamError as exc:
return jsonify({"error": str(exc)}), 403 return jsonify({"error": str(exc)}), 403

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
APP_VERSION = "0.2.1" APP_VERSION = "0.2.2"
def get_version() -> str: def get_version() -> str:

233
docs.md
View File

@@ -602,6 +602,10 @@ fi
## 4. Authentication & IAM ## 4. Authentication & IAM
MyFSIO implements a comprehensive Identity and Access Management (IAM) system that controls who can access your buckets and what operations they can perform. The system supports both simple action-based permissions and AWS-compatible policy syntax.
### Getting Started
1. On first boot, `data/.myfsio.sys/config/iam.json` is seeded with `localadmin / localadmin` that has wildcard access. 1. On first boot, `data/.myfsio.sys/config/iam.json` is seeded with `localadmin / localadmin` that has wildcard access.
2. Sign into the UI using those credentials, then open **IAM**: 2. Sign into the UI using those credentials, then open **IAM**:
- **Create user**: supply a display name and optional JSON inline policy array. - **Create user**: supply a display name and optional JSON inline policy array.
@@ -609,48 +613,241 @@ fi
- **Policy editor**: select a user, paste an array of objects (`{"bucket": "*", "actions": ["list", "read"]}`), and submit. Alias support includes AWS-style verbs (e.g., `s3:GetObject`). - **Policy editor**: select a user, paste an array of objects (`{"bucket": "*", "actions": ["list", "read"]}`), and submit. Alias support includes AWS-style verbs (e.g., `s3:GetObject`).
3. Wildcard action `iam:*` is supported for admin user definitions. 3. Wildcard action `iam:*` is supported for admin user definitions.
The API expects every request to include `X-Access-Key` and `X-Secret-Key` headers. The UI persists them in the Flask session after login. ### Authentication
The API expects every request to include authentication headers. The UI persists them in the Flask session after login.
| Header | Description |
| --- | --- |
| `X-Access-Key` | The user's access key identifier |
| `X-Secret-Key` | The user's secret key for signing |
**Security Features:**
- **Lockout Protection**: After `AUTH_MAX_ATTEMPTS` (default: 5) failed login attempts, the account is locked for `AUTH_LOCKOUT_MINUTES` (default: 15 minutes).
- **Session Management**: UI sessions remain valid for `SESSION_LIFETIME_DAYS` (default: 30 days).
- **Hot Reload**: IAM configuration changes take effect immediately without restart.
### Permission Model
MyFSIO uses a two-layer permission model:
1. **IAM User Policies** Define what a user can do across the system (stored in `iam.json`)
2. **Bucket Policies** Define who can access a specific bucket (stored in `bucket_policies.json`)
Both layers are evaluated for each request. A user must have permission in their IAM policy AND the bucket policy must allow the action (or have no explicit deny).
### Available IAM Actions ### Available IAM Actions
#### S3 Actions (Bucket/Object Operations)
| Action | Description | AWS Aliases | | Action | Description | AWS Aliases |
| --- | --- | --- | | --- | --- | --- |
| `list` | List buckets and objects | `s3:ListBucket`, `s3:ListAllMyBuckets`, `s3:ListBucketVersions`, `s3:ListMultipartUploads`, `s3:ListParts` | | `list` | List buckets and objects | `s3:ListBucket`, `s3:ListAllMyBuckets`, `s3:ListBucketVersions`, `s3:ListMultipartUploads`, `s3:ListParts` |
| `read` | Download objects | `s3:GetObject`, `s3:GetObjectVersion`, `s3:GetObjectTagging`, `s3:HeadObject`, `s3:HeadBucket` | | `read` | Download objects, get metadata | `s3:GetObject`, `s3:GetObjectVersion`, `s3:GetObjectTagging`, `s3:GetObjectVersionTagging`, `s3:GetObjectAcl`, `s3:GetBucketVersioning`, `s3:HeadObject`, `s3:HeadBucket` |
| `write` | Upload objects, create buckets | `s3:PutObject`, `s3:CreateBucket`, `s3:CreateMultipartUpload`, `s3:UploadPart`, `s3:CompleteMultipartUpload`, `s3:AbortMultipartUpload`, `s3:CopyObject` | | `write` | Upload objects, create buckets, manage tags | `s3:PutObject`, `s3:CreateBucket`, `s3:PutObjectTagging`, `s3:PutBucketVersioning`, `s3:CreateMultipartUpload`, `s3:UploadPart`, `s3:CompleteMultipartUpload`, `s3:AbortMultipartUpload`, `s3:CopyObject` |
| `delete` | Remove objects and buckets | `s3:DeleteObject`, `s3:DeleteObjectVersion`, `s3:DeleteBucket` | | `delete` | Remove objects, versions, and buckets | `s3:DeleteObject`, `s3:DeleteObjectVersion`, `s3:DeleteBucket`, `s3:DeleteObjectTagging` |
| `share` | Manage ACLs | `s3:PutObjectAcl`, `s3:PutBucketAcl`, `s3:GetBucketAcl` | | `share` | Manage Access Control Lists (ACLs) | `s3:PutObjectAcl`, `s3:PutBucketAcl`, `s3:GetBucketAcl` |
| `policy` | Manage bucket policies | `s3:PutBucketPolicy`, `s3:GetBucketPolicy`, `s3:DeleteBucketPolicy` | | `policy` | Manage bucket policies | `s3:PutBucketPolicy`, `s3:GetBucketPolicy`, `s3:DeleteBucketPolicy` |
| `replication` | Configure and manage replication | `s3:GetReplicationConfiguration`, `s3:PutReplicationConfiguration`, `s3:ReplicateObject`, `s3:ReplicateTags`, `s3:ReplicateDelete` | | `lifecycle` | Manage lifecycle rules | `s3:GetLifecycleConfiguration`, `s3:PutLifecycleConfiguration`, `s3:DeleteLifecycleConfiguration`, `s3:GetBucketLifecycle`, `s3:PutBucketLifecycle` |
| `iam:list_users` | View IAM users | `iam:ListUsers` | | `cors` | Manage CORS configuration | `s3:GetBucketCors`, `s3:PutBucketCors`, `s3:DeleteBucketCors` |
| `iam:create_user` | Create IAM users | `iam:CreateUser` | | `replication` | Configure and manage replication | `s3:GetReplicationConfiguration`, `s3:PutReplicationConfiguration`, `s3:DeleteReplicationConfiguration`, `s3:ReplicateObject`, `s3:ReplicateTags`, `s3:ReplicateDelete` |
#### IAM Actions (User Management)
| Action | Description | AWS Aliases |
| --- | --- | --- |
| `iam:list_users` | View all IAM users and their policies | `iam:ListUsers` |
| `iam:create_user` | Create new IAM users | `iam:CreateUser` |
| `iam:delete_user` | Delete IAM users | `iam:DeleteUser` | | `iam:delete_user` | Delete IAM users | `iam:DeleteUser` |
| `iam:rotate_key` | Rotate user secrets | `iam:RotateAccessKey` | | `iam:rotate_key` | Rotate user secret keys | `iam:RotateAccessKey` |
| `iam:update_policy` | Modify user policies | `iam:PutUserPolicy` | | `iam:update_policy` | Modify user policies | `iam:PutUserPolicy` |
| `iam:*` | All IAM actions (admin wildcard) | — | | `iam:*` | **Admin wildcard** grants all IAM actions | — |
### Example Policies #### Wildcards
| Wildcard | Scope | Description |
| --- | --- | --- |
| `*` (in actions) | All S3 actions | Grants `list`, `read`, `write`, `delete`, `share`, `policy`, `lifecycle`, `cors`, `replication` |
| `iam:*` | All IAM actions | Grants all `iam:*` actions for user management |
| `*` (in bucket) | All buckets | Policy applies to every bucket |
### IAM Policy Structure
User policies are stored as a JSON array of policy objects. Each object specifies a bucket and the allowed actions:
**Full Control (admin):**
```json ```json
[{"bucket": "*", "actions": ["list", "read", "write", "delete", "share", "policy", "replication", "iam:*"]}] [
{
"bucket": "<bucket-name-or-wildcard>",
"actions": ["<action1>", "<action2>", ...]
}
]
``` ```
**Read-Only:** **Fields:**
- `bucket`: The bucket name (case-insensitive) or `*` for all buckets
- `actions`: Array of action strings (simple names or AWS aliases)
### Example User Policies
**Full Administrator (complete system access):**
```json
[{"bucket": "*", "actions": ["list", "read", "write", "delete", "share", "policy", "lifecycle", "cors", "replication", "iam:*"]}]
```
**Read-Only User (browse and download only):**
```json ```json
[{"bucket": "*", "actions": ["list", "read"]}] [{"bucket": "*", "actions": ["list", "read"]}]
``` ```
**Single Bucket Access (no listing other buckets):** **Single Bucket Full Access (no access to other buckets):**
```json ```json
[{"bucket": "user-bucket", "actions": ["read", "write", "delete"]}] [{"bucket": "user-bucket", "actions": ["list", "read", "write", "delete"]}]
``` ```
**Bucket Access with Replication:** **Multiple Bucket Access (different permissions per bucket):**
```json ```json
[{"bucket": "my-bucket", "actions": ["list", "read", "write", "delete", "replication"]}] [
{"bucket": "public-data", "actions": ["list", "read"]},
{"bucket": "my-uploads", "actions": ["list", "read", "write", "delete"]},
{"bucket": "team-shared", "actions": ["list", "read", "write"]}
]
``` ```
**IAM Manager (manage users but no data access):**
```json
[{"bucket": "*", "actions": ["iam:list_users", "iam:create_user", "iam:delete_user", "iam:rotate_key", "iam:update_policy"]}]
```
**Replication Operator (manage replication only):**
```json
[{"bucket": "*", "actions": ["list", "read", "replication"]}]
```
**Lifecycle Manager (configure object expiration):**
```json
[{"bucket": "*", "actions": ["list", "lifecycle"]}]
```
**CORS Administrator (configure cross-origin access):**
```json
[{"bucket": "*", "actions": ["cors"]}]
```
**Bucket Administrator (full bucket config, no IAM access):**
```json
[{"bucket": "my-bucket", "actions": ["list", "read", "write", "delete", "policy", "lifecycle", "cors"]}]
```
**Upload-Only User (write but cannot read back):**
```json
[{"bucket": "drop-box", "actions": ["write"]}]
```
**Backup Operator (read, list, and replicate):**
```json
[{"bucket": "*", "actions": ["list", "read", "replication"]}]
```
### Using AWS-Style Action Names
You can use AWS S3 action names instead of simple names. They are automatically normalized:
```json
[
{
"bucket": "my-bucket",
"actions": [
"s3:ListBucket",
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
]
}
]
```
This is equivalent to:
```json
[{"bucket": "my-bucket", "actions": ["list", "read", "write", "delete"]}]
```
### Managing Users via API
```bash
# List all users (requires iam:list_users)
curl http://localhost:5000/iam/users \
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
# Create a new user (requires iam:create_user)
curl -X POST http://localhost:5000/iam/users \
-H "Content-Type: application/json" \
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
-d '{
"display_name": "New User",
"policies": [{"bucket": "*", "actions": ["list", "read"]}]
}'
# Rotate user secret (requires iam:rotate_key)
curl -X POST http://localhost:5000/iam/users/<access-key>/rotate \
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
# Update user policies (requires iam:update_policy)
curl -X PUT http://localhost:5000/iam/users/<access-key>/policies \
-H "Content-Type: application/json" \
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
-d '[{"bucket": "*", "actions": ["list", "read", "write"]}]'
# Delete a user (requires iam:delete_user)
curl -X DELETE http://localhost:5000/iam/users/<access-key> \
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
```
### Permission Precedence
When a request is made, permissions are evaluated in this order:
1. **Authentication** Verify the access key and secret key are valid
2. **Lockout Check** Ensure the account is not locked due to failed attempts
3. **IAM Policy Check** Verify the user has the required action for the target bucket
4. **Bucket Policy Check** If a bucket policy exists, verify it allows the action
A request is allowed only if:
- The IAM policy grants the action, AND
- The bucket policy allows the action (or no bucket policy exists)
### Common Permission Scenarios
| Scenario | Required Actions |
| --- | --- |
| Browse bucket contents | `list` |
| Download a file | `read` |
| Upload a file | `write` |
| Delete a file | `delete` |
| Generate presigned URL (GET) | `read` |
| Generate presigned URL (PUT) | `write` |
| Generate presigned URL (DELETE) | `delete` |
| Enable versioning | `write` (includes `s3:PutBucketVersioning`) |
| View bucket policy | `policy` |
| Modify bucket policy | `policy` |
| Configure lifecycle rules | `lifecycle` |
| View lifecycle rules | `lifecycle` |
| Configure CORS | `cors` |
| View CORS rules | `cors` |
| Configure replication | `replication` (admin-only for creation) |
| Pause/resume replication | `replication` |
| Manage other users | `iam:*` or specific `iam:` actions |
| Set bucket quotas | `iam:*` or `iam:list_users` (admin feature) |
### Security Best Practices
1. **Principle of Least Privilege** Grant only the permissions users need
2. **Avoid Wildcards** Use specific bucket names instead of `*` when possible
3. **Rotate Secrets Regularly** Use the rotate key feature periodically
4. **Separate Admin Accounts** Don't use admin accounts for daily operations
5. **Monitor Failed Logins** Check logs for repeated authentication failures
6. **Use Bucket Policies for Fine-Grained Control** Combine with IAM for defense in depth
## 5. Bucket Policies & Presets ## 5. Bucket Policies & Presets
- **Storage**: Policies are persisted in `data/.myfsio.sys/config/bucket_policies.json` under `{"policies": {"bucket": {...}}}`. - **Storage**: Policies are persisted in `data/.myfsio.sys/config/bucket_policies.json` under `{"policies": {"bucket": {...}}}`.

View File

@@ -1,4 +1,4 @@
(function() { (function () {
'use strict'; 'use strict';
const { formatBytes, escapeHtml, fallbackCopy, setupJsonAutoIndent } = window.BucketDetailUtils || { const { formatBytes, escapeHtml, fallbackCopy, setupJsonAutoIndent } = window.BucketDetailUtils || {
@@ -23,7 +23,7 @@
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
}, },
fallbackCopy: () => false, fallbackCopy: () => false,
setupJsonAutoIndent: () => {} setupJsonAutoIndent: () => { }
}; };
setupJsonAutoIndent(document.getElementById('policyDocument')); setupJsonAutoIndent(document.getElementById('policyDocument'));
@@ -548,7 +548,7 @@
} else if (msg.type === 'done') { } else if (msg.type === 'done') {
streamingComplete = true; streamingComplete = true;
} }
} catch (e) {} } catch (e) { }
} }
flushPendingStreamObjects(); flushPendingStreamObjects();
@@ -559,9 +559,6 @@
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);
@@ -640,10 +637,6 @@
} }
} }
if (typeof updateLoadMoreButton === 'function') {
updateLoadMoreButton();
}
refreshVirtualList(); refreshVirtualList();
renderBreadcrumb(currentPrefix); renderBreadcrumb(currentPrefix);
@@ -739,24 +732,11 @@
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 => {
@@ -785,10 +765,6 @@
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();
@@ -2064,7 +2040,7 @@
uploadCancelled = true; uploadCancelled = true;
activeXHRs.forEach(xhr => { activeXHRs.forEach(xhr => {
try { xhr.abort(); } catch {} try { xhr.abort(); } catch { }
}); });
activeXHRs = []; activeXHRs = [];
@@ -2073,7 +2049,7 @@
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value; const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
try { try {
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } }); await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
} catch {} } catch { }
activeMultipartUpload = null; activeMultipartUpload = null;
} }
@@ -2299,7 +2275,7 @@
if (!uploadCancelled) { if (!uploadCancelled) {
try { try {
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } }); await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
} catch {} } catch { }
} }
activeMultipartUpload = null; activeMultipartUpload = null;
throw err; throw err;
@@ -3201,7 +3177,7 @@
const loadLifecycleRules = async () => { const loadLifecycleRules = async () => {
if (!lifecycleUrl || !lifecycleRulesBody) return; if (!lifecycleUrl || !lifecycleRulesBody) return;
lifecycleRulesBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2" role="status"></div>Loading...</td></tr>'; lifecycleRulesBody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2" role="status"></div>Loading...</td></tr>';
try { try {
const resp = await fetch(lifecycleUrl); const resp = await fetch(lifecycleUrl);
const data = await resp.json(); const data = await resp.json();
@@ -3209,19 +3185,20 @@
lifecycleRules = data.rules || []; lifecycleRules = data.rules || [];
renderLifecycleRules(); renderLifecycleRules();
} catch (err) { } catch (err) {
lifecycleRulesBody.innerHTML = `<tr><td colspan="6" class="text-center text-danger py-4">${escapeHtml(err.message)}</td></tr>`; lifecycleRulesBody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-4">${escapeHtml(err.message)}</td></tr>`;
} }
}; };
const renderLifecycleRules = () => { const renderLifecycleRules = () => {
if (!lifecycleRulesBody) return; if (!lifecycleRulesBody) return;
if (lifecycleRules.length === 0) { if (lifecycleRules.length === 0) {
lifecycleRulesBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">No lifecycle rules configured</td></tr>'; lifecycleRulesBody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No lifecycle rules configured</td></tr>';
return; return;
} }
lifecycleRulesBody.innerHTML = lifecycleRules.map((rule, idx) => { lifecycleRulesBody.innerHTML = lifecycleRules.map((rule, idx) => {
const expiration = rule.Expiration?.Days ? `${rule.Expiration.Days}d` : '-'; const expiration = rule.Expiration?.Days ? `${rule.Expiration.Days}d` : '-';
const noncurrent = rule.NoncurrentVersionExpiration?.NoncurrentDays ? `${rule.NoncurrentVersionExpiration.NoncurrentDays}d` : '-'; const noncurrent = rule.NoncurrentVersionExpiration?.NoncurrentDays ? `${rule.NoncurrentVersionExpiration.NoncurrentDays}d` : '-';
const abortMpu = rule.AbortIncompleteMultipartUpload?.DaysAfterInitiation ? `${rule.AbortIncompleteMultipartUpload.DaysAfterInitiation}d` : '-';
const statusClass = rule.Status === 'Enabled' ? 'bg-success' : 'bg-secondary'; const statusClass = rule.Status === 'Enabled' ? 'bg-success' : 'bg-secondary';
return `<tr> return `<tr>
<td><code class="small">${escapeHtml(rule.ID || '')}</code></td> <td><code class="small">${escapeHtml(rule.ID || '')}</code></td>
@@ -3229,6 +3206,7 @@
<td><span class="badge ${statusClass}">${escapeHtml(rule.Status)}</span></td> <td><span class="badge ${statusClass}">${escapeHtml(rule.Status)}</span></td>
<td class="small">${expiration}</td> <td class="small">${expiration}</td>
<td class="small">${noncurrent}</td> <td class="small">${noncurrent}</td>
<td class="small">${abortMpu}</td>
<td class="text-end"> <td class="text-end">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" onclick="editLifecycleRule(${idx})" title="Edit rule"> <button class="btn btn-outline-secondary" onclick="editLifecycleRule(${idx})" title="Edit rule">
@@ -3514,7 +3492,7 @@
}); });
}); });
document.getElementById('objects-table')?.addEventListener('show.bs.dropdown', function(e) { document.getElementById('objects-table')?.addEventListener('show.bs.dropdown', function (e) {
const dropdown = e.target.closest('.dropdown'); const dropdown = e.target.closest('.dropdown');
const menu = dropdown?.querySelector('.dropdown-menu'); const menu = dropdown?.querySelector('.dropdown-menu');
const btn = e.target; const btn = e.target;
@@ -3813,18 +3791,18 @@
var form = document.getElementById(formId); var form = document.getElementById(formId);
if (!form) return; if (!form) return;
form.addEventListener('submit', function(e) { form.addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
window.UICore.submitFormAjax(form, { window.UICore.submitFormAjax(form, {
successMessage: options.successMessage || 'Operation completed', successMessage: options.successMessage || 'Operation completed',
onSuccess: function(data) { onSuccess: function (data) {
if (options.onSuccess) options.onSuccess(data); if (options.onSuccess) options.onSuccess(data);
if (options.closeModal) { if (options.closeModal) {
var modal = bootstrap.Modal.getInstance(document.getElementById(options.closeModal)); var modal = bootstrap.Modal.getInstance(document.getElementById(options.closeModal));
if (modal) modal.hide(); if (modal) modal.hide();
} }
if (options.reload) { if (options.reload) {
setTimeout(function() { location.reload(); }, 500); setTimeout(function () { location.reload(); }, 500);
} }
} }
}); });
@@ -3879,11 +3857,11 @@
var newForm = document.getElementById('enableVersioningForm'); var newForm = document.getElementById('enableVersioningForm');
if (newForm) { if (newForm) {
newForm.setAttribute('action', window.BucketDetailConfig?.endpoints?.versioning || ''); newForm.setAttribute('action', window.BucketDetailConfig?.endpoints?.versioning || '');
newForm.addEventListener('submit', function(e) { newForm.addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
window.UICore.submitFormAjax(newForm, { window.UICore.submitFormAjax(newForm, {
successMessage: 'Versioning enabled', successMessage: 'Versioning enabled',
onSuccess: function() { onSuccess: function () {
updateVersioningBadge(true); updateVersioningBadge(true);
updateVersioningCard(true); updateVersioningCard(true);
} }
@@ -3973,7 +3951,7 @@
'<p class="mb-0 small">No bucket policy is attached. Access is controlled by IAM policies only.</p></div>'; '<p class="mb-0 small">No bucket policy is attached. Access is controlled by IAM policies only.</p></div>';
} }
} }
document.querySelectorAll('.preset-btn').forEach(function(btn) { document.querySelectorAll('.preset-btn').forEach(function (btn) {
btn.classList.remove('active'); btn.classList.remove('active');
if (btn.dataset.preset === preset) btn.classList.add('active'); if (btn.dataset.preset === preset) btn.classList.add('active');
}); });
@@ -3987,7 +3965,7 @@
interceptForm('enableVersioningForm', { interceptForm('enableVersioningForm', {
successMessage: 'Versioning enabled', successMessage: 'Versioning enabled',
onSuccess: function(data) { onSuccess: function (data) {
updateVersioningBadge(true); updateVersioningBadge(true);
updateVersioningCard(true); updateVersioningCard(true);
} }
@@ -3996,7 +3974,7 @@
interceptForm('suspendVersioningForm', { interceptForm('suspendVersioningForm', {
successMessage: 'Versioning suspended', successMessage: 'Versioning suspended',
closeModal: 'suspendVersioningModal', closeModal: 'suspendVersioningModal',
onSuccess: function(data) { onSuccess: function (data) {
updateVersioningBadge(false); updateVersioningBadge(false);
updateVersioningCard(false); updateVersioningCard(false);
} }
@@ -4004,21 +3982,21 @@
interceptForm('encryptionForm', { interceptForm('encryptionForm', {
successMessage: 'Encryption settings saved', successMessage: 'Encryption settings saved',
onSuccess: function(data) { onSuccess: function (data) {
updateEncryptionCard(data.enabled !== false, data.algorithm || 'AES256'); updateEncryptionCard(data.enabled !== false, data.algorithm || 'AES256');
} }
}); });
interceptForm('quotaForm', { interceptForm('quotaForm', {
successMessage: 'Quota settings saved', successMessage: 'Quota settings saved',
onSuccess: function(data) { onSuccess: function (data) {
updateQuotaCard(data.has_quota, data.max_bytes, data.max_objects); updateQuotaCard(data.has_quota, data.max_bytes, data.max_objects);
} }
}); });
interceptForm('bucketPolicyForm', { interceptForm('bucketPolicyForm', {
successMessage: 'Bucket policy saved', successMessage: 'Bucket policy saved',
onSuccess: function(data) { onSuccess: function (data) {
var policyModeEl = document.getElementById('policyMode'); var policyModeEl = document.getElementById('policyMode');
var policyPresetEl = document.getElementById('policyPreset'); var policyPresetEl = document.getElementById('policyPreset');
var preset = policyModeEl && policyModeEl.value === 'delete' ? 'private' : var preset = policyModeEl && policyModeEl.value === 'delete' ? 'private' :
@@ -4029,11 +4007,11 @@
var deletePolicyForm = document.getElementById('deletePolicyForm'); var deletePolicyForm = document.getElementById('deletePolicyForm');
if (deletePolicyForm) { if (deletePolicyForm) {
deletePolicyForm.addEventListener('submit', function(e) { deletePolicyForm.addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
window.UICore.submitFormAjax(deletePolicyForm, { window.UICore.submitFormAjax(deletePolicyForm, {
successMessage: 'Bucket policy deleted', successMessage: 'Bucket policy deleted',
onSuccess: function(data) { onSuccess: function (data) {
var modal = bootstrap.Modal.getInstance(document.getElementById('deletePolicyModal')); var modal = bootstrap.Modal.getInstance(document.getElementById('deletePolicyModal'));
if (modal) modal.hide(); if (modal) modal.hide();
updatePolicyCard(false, 'private'); updatePolicyCard(false, 'private');
@@ -4046,13 +4024,13 @@
var disableEncBtn = document.getElementById('disableEncryptionBtn'); var disableEncBtn = document.getElementById('disableEncryptionBtn');
if (disableEncBtn) { if (disableEncBtn) {
disableEncBtn.addEventListener('click', function() { disableEncBtn.addEventListener('click', function () {
var form = document.getElementById('encryptionForm'); var form = document.getElementById('encryptionForm');
if (!form) return; if (!form) return;
document.getElementById('encryptionAction').value = 'disable'; document.getElementById('encryptionAction').value = 'disable';
window.UICore.submitFormAjax(form, { window.UICore.submitFormAjax(form, {
successMessage: 'Encryption disabled', successMessage: 'Encryption disabled',
onSuccess: function(data) { onSuccess: function (data) {
document.getElementById('encryptionAction').value = 'enable'; document.getElementById('encryptionAction').value = 'enable';
updateEncryptionCard(false, null); updateEncryptionCard(false, null);
} }
@@ -4062,13 +4040,13 @@
var removeQuotaBtn = document.getElementById('removeQuotaBtn'); var removeQuotaBtn = document.getElementById('removeQuotaBtn');
if (removeQuotaBtn) { if (removeQuotaBtn) {
removeQuotaBtn.addEventListener('click', function() { removeQuotaBtn.addEventListener('click', function () {
var form = document.getElementById('quotaForm'); var form = document.getElementById('quotaForm');
if (!form) return; if (!form) return;
document.getElementById('quotaAction').value = 'remove'; document.getElementById('quotaAction').value = 'remove';
window.UICore.submitFormAjax(form, { window.UICore.submitFormAjax(form, {
successMessage: 'Quota removed', successMessage: 'Quota removed',
onSuccess: function(data) { onSuccess: function (data) {
document.getElementById('quotaAction').value = 'set'; document.getElementById('quotaAction').value = 'set';
updateQuotaCard(false, null, null); updateQuotaCard(false, null, null);
} }
@@ -4082,8 +4060,8 @@
fetch(window.location.pathname + '?tab=replication', { fetch(window.location.pathname + '?tab=replication', {
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}) })
.then(function(resp) { return resp.text(); }) .then(function (resp) { return resp.text(); })
.then(function(html) { .then(function (html) {
var parser = new DOMParser(); var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html'); var doc = parser.parseFromString(html, 'text/html');
var newPane = doc.getElementById('replication-pane'); var newPane = doc.getElementById('replication-pane');
@@ -4093,20 +4071,20 @@
initReplicationStats(); initReplicationStats();
} }
}) })
.catch(function(err) { .catch(function (err) {
console.error('Failed to reload replication pane:', err); console.error('Failed to reload replication pane:', err);
}); });
} }
function initReplicationForms() { function initReplicationForms() {
document.querySelectorAll('form[action*="replication"]').forEach(function(form) { document.querySelectorAll('form[action*="replication"]').forEach(function (form) {
if (form.dataset.ajaxBound) return; if (form.dataset.ajaxBound) return;
form.dataset.ajaxBound = 'true'; form.dataset.ajaxBound = 'true';
var actionInput = form.querySelector('input[name="action"]'); var actionInput = form.querySelector('input[name="action"]');
if (!actionInput) return; if (!actionInput) return;
var action = actionInput.value; var action = actionInput.value;
form.addEventListener('submit', function(e) { form.addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
var msg = action === 'pause' ? 'Replication paused' : var msg = action === 'pause' ? 'Replication paused' :
action === 'resume' ? 'Replication resumed' : action === 'resume' ? 'Replication resumed' :
@@ -4114,7 +4092,7 @@
action === 'create' ? 'Replication configured' : 'Operation completed'; action === 'create' ? 'Replication configured' : 'Operation completed';
window.UICore.submitFormAjax(form, { window.UICore.submitFormAjax(form, {
successMessage: msg, successMessage: msg,
onSuccess: function(data) { onSuccess: function (data) {
var modal = bootstrap.Modal.getInstance(document.getElementById('disableReplicationModal')); var modal = bootstrap.Modal.getInstance(document.getElementById('disableReplicationModal'));
if (modal) modal.hide(); if (modal) modal.hide();
reloadReplicationPane(); reloadReplicationPane();
@@ -4136,14 +4114,14 @@
var bytesEl = statsContainer.querySelector('[data-stat="bytes"]'); var bytesEl = statsContainer.querySelector('[data-stat="bytes"]');
fetch(statusEndpoint) fetch(statusEndpoint)
.then(function(resp) { return resp.json(); }) .then(function (resp) { return resp.json(); })
.then(function(data) { .then(function (data) {
if (syncedEl) syncedEl.textContent = data.objects_synced || 0; if (syncedEl) syncedEl.textContent = data.objects_synced || 0;
if (pendingEl) pendingEl.textContent = data.objects_pending || 0; if (pendingEl) pendingEl.textContent = data.objects_pending || 0;
if (orphanedEl) orphanedEl.textContent = data.objects_orphaned || 0; if (orphanedEl) orphanedEl.textContent = data.objects_orphaned || 0;
if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced || 0); if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced || 0);
}) })
.catch(function(err) { .catch(function (err) {
console.error('Failed to load replication stats:', err); console.error('Failed to load replication stats:', err);
}); });
} }
@@ -4153,10 +4131,10 @@
var deleteBucketForm = document.getElementById('deleteBucketForm'); var deleteBucketForm = document.getElementById('deleteBucketForm');
if (deleteBucketForm) { if (deleteBucketForm) {
deleteBucketForm.addEventListener('submit', function(e) { deleteBucketForm.addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
window.UICore.submitFormAjax(deleteBucketForm, { window.UICore.submitFormAjax(deleteBucketForm, {
onSuccess: function() { onSuccess: function () {
sessionStorage.setItem('flashMessage', JSON.stringify({ title: 'Bucket deleted', variant: 'success' })); sessionStorage.setItem('flashMessage', JSON.stringify({ title: 'Bucket deleted', variant: 'success' }));
window.location.href = window.BucketDetailConfig?.endpoints?.bucketsOverview || '/ui/buckets'; window.location.href = window.BucketDetailConfig?.endpoints?.bucketsOverview || '/ui/buckets';
} }

View File

@@ -67,12 +67,14 @@
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if can_edit_policy %} {% if can_manage_lifecycle %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link {{ 'active' if active_tab == 'lifecycle' else '' }}" id="lifecycle-tab" data-bs-toggle="tab" data-bs-target="#lifecycle-pane" type="button" role="tab" aria-controls="lifecycle-pane" aria-selected="{{ 'true' if active_tab == 'lifecycle' else 'false' }}"> <button class="nav-link {{ 'active' if active_tab == 'lifecycle' else '' }}" id="lifecycle-tab" data-bs-toggle="tab" data-bs-target="#lifecycle-pane" type="button" role="tab" aria-controls="lifecycle-pane" aria-selected="{{ 'true' if active_tab == 'lifecycle' else 'false' }}">
Lifecycle Lifecycle
</button> </button>
</li> </li>
{% endif %}
{% if can_manage_cors %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link {{ 'active' if active_tab == 'cors' else '' }}" id="cors-tab" data-bs-toggle="tab" data-bs-target="#cors-pane" type="button" role="tab" aria-controls="cors-pane" aria-selected="{{ 'true' if active_tab == 'cors' else 'false' }}"> <button class="nav-link {{ 'active' if active_tab == 'cors' else '' }}" id="cors-tab" data-bs-toggle="tab" data-bs-target="#cors-pane" type="button" role="tab" aria-controls="cors-pane" aria-selected="{{ 'true' if active_tab == 'cors' else 'false' }}">
CORS CORS
@@ -187,20 +189,6 @@
</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>
@@ -1574,12 +1562,13 @@
<th>Status</th> <th>Status</th>
<th>Expiration</th> <th>Expiration</th>
<th>Noncurrent</th> <th>Noncurrent</th>
<th>Abort MPU</th>
<th class="text-end">Actions</th> <th class="text-end">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="lifecycle-rules-body"> <tbody id="lifecycle-rules-body">
<tr> <tr>
<td colspan="6" class="text-center text-muted py-4"> <td colspan="7" class="text-center text-muted py-4">
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
Loading... Loading...
</td> </td>