Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2745ff2ee | |||
| 9165e365e6 | |||
| 01e26754e8 | |||
| b592fa9fdb | |||
| cd9734b398 | |||
| 90893cac27 | |||
| 6e659902bd | |||
| 39a707ecbc | |||
| 4199f8e6c7 | |||
| adc6770273 | |||
| f5451c162b | |||
| aab9ef696a | |||
| be48f59452 | |||
| 86c04f85f6 | |||
| 28cb656d94 | |||
| 992d9eccd9 | |||
| 40f3192c5c | |||
| 2498b950f6 | |||
| 97435f15e5 | |||
| 3c44152fc6 | |||
| 97860669ec | |||
| 4a5dd76286 | |||
| d2dc293722 | |||
| 397515edce | |||
| 563bb8fa6a | |||
| 980fced7e4 | |||
| 5ccf53b688 | |||
| 4d4256830a | |||
| 137e3b7b68 | |||
| bae5009ec4 | |||
| 114e684cb8 | |||
| 5d161c1d92 | |||
| f160827b41 | |||
| 9368715b16 | |||
| 453ac6ea30 | |||
| 804f46d11e | |||
| 766dbb18be | |||
| 590a39ca80 | |||
| 53326f4e41 |
@@ -8,7 +8,7 @@ MyFSIO is a batteries-included, Flask-based recreation of Amazon S3 and IAM work
|
|||||||
- **IAM + access keys:** Users, access keys, key rotation, and bucket-scoped actions (`list/read/write/delete/policy`) now live in `data/.myfsio.sys/config/iam.json` and are editable from the IAM dashboard.
|
- **IAM + access keys:** Users, access keys, key rotation, and bucket-scoped actions (`list/read/write/delete/policy`) now live in `data/.myfsio.sys/config/iam.json` and are editable from the IAM dashboard.
|
||||||
- **Bucket policies + hot reload:** `data/.myfsio.sys/config/bucket_policies.json` uses AWS' policy grammar (Version `2012-10-17`) with a built-in watcher, so editing the JSON file applies immediately. The UI also ships Public/Private/Custom presets for faster edits.
|
- **Bucket policies + hot reload:** `data/.myfsio.sys/config/bucket_policies.json` uses AWS' policy grammar (Version `2012-10-17`) with a built-in watcher, so editing the JSON file applies immediately. The UI also ships Public/Private/Custom presets for faster edits.
|
||||||
- **Presigned URLs everywhere:** Signature Version 4 presigned URLs respect IAM + bucket policies and replace the now-removed "share link" feature for public access scenarios.
|
- **Presigned URLs everywhere:** Signature Version 4 presigned URLs respect IAM + bucket policies and replace the now-removed "share link" feature for public access scenarios.
|
||||||
- **Modern UI:** Responsive tables, quick filters, preview sidebar, object-level delete buttons, a presign modal, and an inline JSON policy editor that respects dark mode keep bucket management friendly.
|
- **Modern UI:** Responsive tables, quick filters, preview sidebar, object-level delete buttons, a presign modal, and an inline JSON policy editor that respects dark mode keep bucket management friendly. The object browser supports folder navigation, infinite scroll pagination, bulk operations, and automatic retry on load failures.
|
||||||
- **Tests & health:** `/healthz` for smoke checks and `pytest` coverage for IAM, CRUD, presign, and policy flows.
|
- **Tests & health:** `/healthz` for smoke checks and `pytest` coverage for IAM, CRUD, presign, and policy flows.
|
||||||
|
|
||||||
## Architecture at a Glance
|
## Architecture at a Glance
|
||||||
@@ -86,7 +86,7 @@ Presigned URLs follow the AWS CLI playbook:
|
|||||||
| `AWS_REGION` | `us-east-1` | Region used in Signature V4 scope |
|
| `AWS_REGION` | `us-east-1` | Region used in Signature V4 scope |
|
||||||
| `AWS_SERVICE` | `s3` | Service used in Signature V4 scope |
|
| `AWS_SERVICE` | `s3` | Service used in Signature V4 scope |
|
||||||
|
|
||||||
> Buckets now live directly under `data/` while system metadata (versions, IAM, bucket policies, multipart uploads, etc.) lives in `data/.myfsio.sys`. Existing installs can keep their environment variables, but the defaults now match MinIO's `data/.system` pattern for easier bind-mounting.
|
> Buckets now live directly under `data/` while system metadata (versions, IAM, bucket policies, multipart uploads, etc.) lives in `data/.myfsio.sys`.
|
||||||
|
|
||||||
## API Cheatsheet (IAM headers required)
|
## API Cheatsheet (IAM headers required)
|
||||||
|
|
||||||
|
|||||||
104
app/__init__.py
104
app/__init__.py
@@ -2,13 +2,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from flask import Flask, g, has_request_context, redirect, render_template, request, url_for
|
from flask import Flask, g, has_request_context, redirect, render_template, request, url_for
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
@@ -18,14 +19,42 @@ from werkzeug.middleware.proxy_fix import ProxyFix
|
|||||||
from .bucket_policies import BucketPolicyStore
|
from .bucket_policies import BucketPolicyStore
|
||||||
from .config import AppConfig
|
from .config import AppConfig
|
||||||
from .connections import ConnectionStore
|
from .connections import ConnectionStore
|
||||||
|
from .encryption import EncryptionManager
|
||||||
from .extensions import limiter, csrf
|
from .extensions import limiter, csrf
|
||||||
from .iam import IamService
|
from .iam import IamService
|
||||||
|
from .kms import KMSManager
|
||||||
from .replication import ReplicationManager
|
from .replication import ReplicationManager
|
||||||
from .secret_store import EphemeralSecretStore
|
from .secret_store import EphemeralSecretStore
|
||||||
from .storage import ObjectStorage
|
from .storage import ObjectStorage
|
||||||
from .version import get_version
|
from .version import get_version
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_config_file(active_path: Path, legacy_paths: List[Path]) -> Path:
|
||||||
|
"""Migrate config file from legacy locations to the active path.
|
||||||
|
|
||||||
|
Checks each legacy path in order and moves the first one found to the active path.
|
||||||
|
This ensures backward compatibility for users upgrading from older versions.
|
||||||
|
"""
|
||||||
|
active_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if active_path.exists():
|
||||||
|
return active_path
|
||||||
|
|
||||||
|
for legacy_path in legacy_paths:
|
||||||
|
if legacy_path.exists():
|
||||||
|
try:
|
||||||
|
shutil.move(str(legacy_path), str(active_path))
|
||||||
|
except OSError:
|
||||||
|
shutil.copy2(legacy_path, active_path)
|
||||||
|
try:
|
||||||
|
legacy_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
return active_path
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
test_config: Optional[Dict[str, Any]] = None,
|
test_config: Optional[Dict[str, Any]] = None,
|
||||||
*,
|
*,
|
||||||
@@ -71,13 +100,46 @@ def create_app(
|
|||||||
bucket_policies = BucketPolicyStore(Path(app.config["BUCKET_POLICY_PATH"]))
|
bucket_policies = BucketPolicyStore(Path(app.config["BUCKET_POLICY_PATH"]))
|
||||||
secret_store = EphemeralSecretStore(default_ttl=app.config.get("SECRET_TTL_SECONDS", 300))
|
secret_store = EphemeralSecretStore(default_ttl=app.config.get("SECRET_TTL_SECONDS", 300))
|
||||||
|
|
||||||
# Initialize Replication components
|
storage_root = Path(app.config["STORAGE_ROOT"])
|
||||||
connections_path = Path(app.config["STORAGE_ROOT"]) / ".connections.json"
|
config_dir = storage_root / ".myfsio.sys" / "config"
|
||||||
replication_rules_path = Path(app.config["STORAGE_ROOT"]) / ".replication_rules.json"
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
connections_path = _migrate_config_file(
|
||||||
|
active_path=config_dir / "connections.json",
|
||||||
|
legacy_paths=[
|
||||||
|
storage_root / ".myfsio.sys" / "connections.json",
|
||||||
|
storage_root / ".connections.json",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
replication_rules_path = _migrate_config_file(
|
||||||
|
active_path=config_dir / "replication_rules.json",
|
||||||
|
legacy_paths=[
|
||||||
|
storage_root / ".myfsio.sys" / "replication_rules.json",
|
||||||
|
storage_root / ".replication_rules.json",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
connections = ConnectionStore(connections_path)
|
connections = ConnectionStore(connections_path)
|
||||||
replication = ReplicationManager(storage, connections, replication_rules_path)
|
replication = ReplicationManager(storage, connections, replication_rules_path)
|
||||||
|
|
||||||
|
encryption_config = {
|
||||||
|
"encryption_enabled": app.config.get("ENCRYPTION_ENABLED", False),
|
||||||
|
"encryption_master_key_path": app.config.get("ENCRYPTION_MASTER_KEY_PATH"),
|
||||||
|
"default_encryption_algorithm": app.config.get("DEFAULT_ENCRYPTION_ALGORITHM", "AES256"),
|
||||||
|
}
|
||||||
|
encryption_manager = EncryptionManager(encryption_config)
|
||||||
|
|
||||||
|
kms_manager = None
|
||||||
|
if app.config.get("KMS_ENABLED", False):
|
||||||
|
kms_keys_path = Path(app.config.get("KMS_KEYS_PATH", ""))
|
||||||
|
kms_master_key_path = Path(app.config.get("ENCRYPTION_MASTER_KEY_PATH", ""))
|
||||||
|
kms_manager = KMSManager(kms_keys_path, kms_master_key_path)
|
||||||
|
encryption_manager.set_kms_provider(kms_manager)
|
||||||
|
|
||||||
|
if app.config.get("ENCRYPTION_ENABLED", False):
|
||||||
|
from .encrypted_storage import EncryptedObjectStorage
|
||||||
|
storage = EncryptedObjectStorage(storage, encryption_manager)
|
||||||
|
|
||||||
app.extensions["object_storage"] = storage
|
app.extensions["object_storage"] = storage
|
||||||
app.extensions["iam"] = iam
|
app.extensions["iam"] = iam
|
||||||
app.extensions["bucket_policies"] = bucket_policies
|
app.extensions["bucket_policies"] = bucket_policies
|
||||||
@@ -85,6 +147,8 @@ def create_app(
|
|||||||
app.extensions["limiter"] = limiter
|
app.extensions["limiter"] = limiter
|
||||||
app.extensions["connections"] = connections
|
app.extensions["connections"] = connections
|
||||||
app.extensions["replication"] = replication
|
app.extensions["replication"] = replication
|
||||||
|
app.extensions["encryption"] = encryption_manager
|
||||||
|
app.extensions["kms"] = kms_manager
|
||||||
|
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
@@ -107,21 +171,33 @@ def create_app(
|
|||||||
|
|
||||||
@app.template_filter("timestamp_to_datetime")
|
@app.template_filter("timestamp_to_datetime")
|
||||||
def timestamp_to_datetime(value: float) -> str:
|
def timestamp_to_datetime(value: float) -> str:
|
||||||
"""Format Unix timestamp as human-readable datetime."""
|
"""Format Unix timestamp as human-readable datetime in configured timezone."""
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone as dt_timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
if not value:
|
if not value:
|
||||||
return "Never"
|
return "Never"
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromtimestamp(value)
|
dt_utc = datetime.fromtimestamp(value, dt_timezone.utc)
|
||||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
display_tz = app.config.get("DISPLAY_TIMEZONE", "UTC")
|
||||||
|
if display_tz and display_tz != "UTC":
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(display_tz)
|
||||||
|
dt_local = dt_utc.astimezone(tz)
|
||||||
|
return dt_local.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
pass
|
||||||
|
return dt_utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
if include_api:
|
if include_api:
|
||||||
from .s3_api import s3_api_bp
|
from .s3_api import s3_api_bp
|
||||||
|
from .kms_api import kms_api_bp
|
||||||
|
|
||||||
app.register_blueprint(s3_api_bp)
|
app.register_blueprint(s3_api_bp)
|
||||||
|
app.register_blueprint(kms_api_bp)
|
||||||
csrf.exempt(s3_api_bp)
|
csrf.exempt(s3_api_bp)
|
||||||
|
csrf.exempt(kms_api_bp)
|
||||||
|
|
||||||
if include_ui:
|
if include_ui:
|
||||||
from .ui import ui_bp
|
from .ui import ui_bp
|
||||||
@@ -158,14 +234,12 @@ def create_ui_app(test_config: Optional[Dict[str, Any]] = None) -> Flask:
|
|||||||
|
|
||||||
def _configure_cors(app: Flask) -> None:
|
def _configure_cors(app: Flask) -> None:
|
||||||
origins = app.config.get("CORS_ORIGINS", ["*"])
|
origins = app.config.get("CORS_ORIGINS", ["*"])
|
||||||
methods = app.config.get("CORS_METHODS", ["GET", "PUT", "POST", "DELETE", "OPTIONS"])
|
methods = app.config.get("CORS_METHODS", ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD"])
|
||||||
allow_headers = app.config.get(
|
allow_headers = app.config.get("CORS_ALLOW_HEADERS", ["*"])
|
||||||
"CORS_ALLOW_HEADERS",
|
expose_headers = app.config.get("CORS_EXPOSE_HEADERS", ["*"])
|
||||||
["Content-Type", "X-Access-Key", "X-Secret-Key", "X-Amz-Date", "X-Amz-SignedHeaders"],
|
|
||||||
)
|
|
||||||
CORS(
|
CORS(
|
||||||
app,
|
app,
|
||||||
resources={r"/*": {"origins": origins, "methods": methods, "allow_headers": allow_headers}},
|
resources={r"/*": {"origins": origins, "methods": methods, "allow_headers": allow_headers, "expose_headers": expose_headers}},
|
||||||
supports_credentials=True,
|
supports_credentials=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -173,7 +247,7 @@ def _configure_cors(app: Flask) -> None:
|
|||||||
class _RequestContextFilter(logging.Filter):
|
class _RequestContextFilter(logging.Filter):
|
||||||
"""Inject request-specific attributes into log records."""
|
"""Inject request-specific attributes into log records."""
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover - simple boilerplate
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
if has_request_context():
|
if has_request_context():
|
||||||
record.request_id = getattr(g, "request_id", "-")
|
record.request_id = getattr(g, "request_id", "-")
|
||||||
record.path = request.path
|
record.path = request.path
|
||||||
|
|||||||
@@ -11,17 +11,51 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence
|
|||||||
RESOURCE_PREFIX = "arn:aws:s3:::"
|
RESOURCE_PREFIX = "arn:aws:s3:::"
|
||||||
|
|
||||||
ACTION_ALIASES = {
|
ACTION_ALIASES = {
|
||||||
"s3:getobject": "read",
|
# List actions
|
||||||
"s3:getobjectversion": "read",
|
|
||||||
"s3:listbucket": "list",
|
"s3:listbucket": "list",
|
||||||
"s3:listallmybuckets": "list",
|
"s3:listallmybuckets": "list",
|
||||||
|
"s3:listbucketversions": "list",
|
||||||
|
"s3:listmultipartuploads": "list",
|
||||||
|
"s3:listparts": "list",
|
||||||
|
# Read actions
|
||||||
|
"s3:getobject": "read",
|
||||||
|
"s3:getobjectversion": "read",
|
||||||
|
"s3:getobjecttagging": "read",
|
||||||
|
"s3:getobjectversiontagging": "read",
|
||||||
|
"s3:getobjectacl": "read",
|
||||||
|
"s3:getbucketversioning": "read",
|
||||||
|
"s3:headobject": "read",
|
||||||
|
"s3:headbucket": "read",
|
||||||
|
# Write actions
|
||||||
"s3:putobject": "write",
|
"s3:putobject": "write",
|
||||||
"s3:createbucket": "write",
|
"s3:createbucket": "write",
|
||||||
|
"s3:putobjecttagging": "write",
|
||||||
|
"s3:putbucketversioning": "write",
|
||||||
|
"s3:createmultipartupload": "write",
|
||||||
|
"s3:uploadpart": "write",
|
||||||
|
"s3:completemultipartupload": "write",
|
||||||
|
"s3:abortmultipartupload": "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",
|
||||||
|
# Share actions (ACL)
|
||||||
"s3:putobjectacl": "share",
|
"s3:putobjectacl": "share",
|
||||||
|
"s3:putbucketacl": "share",
|
||||||
|
"s3:getbucketacl": "share",
|
||||||
|
# Policy actions
|
||||||
"s3:putbucketpolicy": "policy",
|
"s3:putbucketpolicy": "policy",
|
||||||
|
"s3:getbucketpolicy": "policy",
|
||||||
|
"s3:deletebucketpolicy": "policy",
|
||||||
|
# Replication actions
|
||||||
|
"s3:getreplicationconfiguration": "replication",
|
||||||
|
"s3:putreplicationconfiguration": "replication",
|
||||||
|
"s3:deletereplicationconfiguration": "replication",
|
||||||
|
"s3:replicateobject": "replication",
|
||||||
|
"s3:replicatetags": "replication",
|
||||||
|
"s3:replicatedelete": "replication",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -154,7 +188,6 @@ class BucketPolicyStore:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def evaluate(
|
def evaluate(
|
||||||
self,
|
self,
|
||||||
access_key: Optional[str],
|
access_key: Optional[str],
|
||||||
@@ -195,7 +228,6 @@ class BucketPolicyStore:
|
|||||||
self._policies.pop(bucket, None)
|
self._policies.pop(bucket, None)
|
||||||
self._persist()
|
self._persist()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
try:
|
try:
|
||||||
content = self.policy_path.read_text(encoding='utf-8')
|
content = self.policy_path.read_text(encoding='utf-8')
|
||||||
|
|||||||
149
app/config.py
149
app/config.py
@@ -50,6 +50,7 @@ class AppConfig:
|
|||||||
aws_service: str
|
aws_service: str
|
||||||
ui_enforce_bucket_policies: bool
|
ui_enforce_bucket_policies: bool
|
||||||
log_level: str
|
log_level: str
|
||||||
|
log_to_file: bool
|
||||||
log_path: Path
|
log_path: Path
|
||||||
log_max_bytes: int
|
log_max_bytes: int
|
||||||
log_backup_count: int
|
log_backup_count: int
|
||||||
@@ -58,6 +59,7 @@ class AppConfig:
|
|||||||
cors_origins: list[str]
|
cors_origins: list[str]
|
||||||
cors_methods: list[str]
|
cors_methods: list[str]
|
||||||
cors_allow_headers: list[str]
|
cors_allow_headers: list[str]
|
||||||
|
cors_expose_headers: list[str]
|
||||||
session_lifetime_days: int
|
session_lifetime_days: int
|
||||||
auth_max_attempts: int
|
auth_max_attempts: int
|
||||||
auth_lockout_minutes: int
|
auth_lockout_minutes: int
|
||||||
@@ -66,6 +68,12 @@ class AppConfig:
|
|||||||
stream_chunk_size: int
|
stream_chunk_size: int
|
||||||
multipart_min_part_size: int
|
multipart_min_part_size: int
|
||||||
bucket_stats_cache_ttl: int
|
bucket_stats_cache_ttl: int
|
||||||
|
encryption_enabled: bool
|
||||||
|
encryption_master_key_path: Path
|
||||||
|
kms_enabled: bool
|
||||||
|
kms_keys_path: Path
|
||||||
|
default_encryption_algorithm: str
|
||||||
|
display_timezone: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig":
|
def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig":
|
||||||
@@ -104,19 +112,19 @@ class AppConfig:
|
|||||||
iam_env_override = "IAM_CONFIG" in overrides or "IAM_CONFIG" in os.environ
|
iam_env_override = "IAM_CONFIG" in overrides or "IAM_CONFIG" in os.environ
|
||||||
bucket_policy_override = "BUCKET_POLICY_PATH" in overrides or "BUCKET_POLICY_PATH" in os.environ
|
bucket_policy_override = "BUCKET_POLICY_PATH" in overrides or "BUCKET_POLICY_PATH" in os.environ
|
||||||
|
|
||||||
default_iam_path = PROJECT_ROOT / "data" / ".myfsio.sys" / "config" / "iam.json"
|
default_iam_path = storage_root / ".myfsio.sys" / "config" / "iam.json"
|
||||||
default_bucket_policy_path = PROJECT_ROOT / "data" / ".myfsio.sys" / "config" / "bucket_policies.json"
|
default_bucket_policy_path = storage_root / ".myfsio.sys" / "config" / "bucket_policies.json"
|
||||||
|
|
||||||
iam_config_path = Path(_get("IAM_CONFIG", default_iam_path)).resolve()
|
iam_config_path = Path(_get("IAM_CONFIG", default_iam_path)).resolve()
|
||||||
bucket_policy_path = Path(_get("BUCKET_POLICY_PATH", default_bucket_policy_path)).resolve()
|
bucket_policy_path = Path(_get("BUCKET_POLICY_PATH", default_bucket_policy_path)).resolve()
|
||||||
|
|
||||||
iam_config_path = _prepare_config_file(
|
iam_config_path = _prepare_config_file(
|
||||||
iam_config_path,
|
iam_config_path,
|
||||||
legacy_path=None if iam_env_override else PROJECT_ROOT / "data" / "iam.json",
|
legacy_path=None if iam_env_override else storage_root / "iam.json",
|
||||||
)
|
)
|
||||||
bucket_policy_path = _prepare_config_file(
|
bucket_policy_path = _prepare_config_file(
|
||||||
bucket_policy_path,
|
bucket_policy_path,
|
||||||
legacy_path=None if bucket_policy_override else PROJECT_ROOT / "data" / "bucket_policies.json",
|
legacy_path=None if bucket_policy_override else storage_root / "bucket_policies.json",
|
||||||
)
|
)
|
||||||
api_base_url = _get("API_BASE_URL", None)
|
api_base_url = _get("API_BASE_URL", None)
|
||||||
if api_base_url:
|
if api_base_url:
|
||||||
@@ -126,7 +134,8 @@ class AppConfig:
|
|||||||
aws_service = str(_get("AWS_SERVICE", "s3"))
|
aws_service = str(_get("AWS_SERVICE", "s3"))
|
||||||
enforce_ui_policies = str(_get("UI_ENFORCE_BUCKET_POLICIES", "0")).lower() in {"1", "true", "yes", "on"}
|
enforce_ui_policies = str(_get("UI_ENFORCE_BUCKET_POLICIES", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
log_level = str(_get("LOG_LEVEL", "INFO")).upper()
|
log_level = str(_get("LOG_LEVEL", "INFO")).upper()
|
||||||
log_dir = Path(_get("LOG_DIR", PROJECT_ROOT / "logs")).resolve()
|
log_to_file = str(_get("LOG_TO_FILE", "1")).lower() in {"1", "true", "yes", "on"}
|
||||||
|
log_dir = Path(_get("LOG_DIR", storage_root.parent / "logs")).resolve()
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
log_path = log_dir / str(_get("LOG_FILE", "app.log"))
|
log_path = log_dir / str(_get("LOG_FILE", "app.log"))
|
||||||
log_max_bytes = int(_get("LOG_MAX_BYTES", 5 * 1024 * 1024))
|
log_max_bytes = int(_get("LOG_MAX_BYTES", 5 * 1024 * 1024))
|
||||||
@@ -141,20 +150,19 @@ class AppConfig:
|
|||||||
return parts or default
|
return parts or default
|
||||||
|
|
||||||
cors_origins = _csv(str(_get("CORS_ORIGINS", "*")), ["*"])
|
cors_origins = _csv(str(_get("CORS_ORIGINS", "*")), ["*"])
|
||||||
cors_methods = _csv(str(_get("CORS_METHODS", "GET,PUT,POST,DELETE,OPTIONS")), ["GET", "PUT", "POST", "DELETE", "OPTIONS"])
|
cors_methods = _csv(str(_get("CORS_METHODS", "GET,PUT,POST,DELETE,OPTIONS,HEAD")), ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD"])
|
||||||
cors_allow_headers = _csv(str(_get("CORS_ALLOW_HEADERS", "Content-Type,X-Access-Key,X-Secret-Key,X-Amz-Algorithm,X-Amz-Credential,X-Amz-Date,X-Amz-Expires,X-Amz-SignedHeaders,X-Amz-Signature")), [
|
cors_allow_headers = _csv(str(_get("CORS_ALLOW_HEADERS", "*")), ["*"])
|
||||||
"Content-Type",
|
cors_expose_headers = _csv(str(_get("CORS_EXPOSE_HEADERS", "*")), ["*"])
|
||||||
"X-Access-Key",
|
|
||||||
"X-Secret-Key",
|
|
||||||
"X-Amz-Algorithm",
|
|
||||||
"X-Amz-Credential",
|
|
||||||
"X-Amz-Date",
|
|
||||||
"X-Amz-Expires",
|
|
||||||
"X-Amz-SignedHeaders",
|
|
||||||
"X-Amz-Signature",
|
|
||||||
])
|
|
||||||
session_lifetime_days = int(_get("SESSION_LIFETIME_DAYS", 30))
|
session_lifetime_days = int(_get("SESSION_LIFETIME_DAYS", 30))
|
||||||
bucket_stats_cache_ttl = int(_get("BUCKET_STATS_CACHE_TTL", 60)) # Default 60 seconds
|
bucket_stats_cache_ttl = int(_get("BUCKET_STATS_CACHE_TTL", 60))
|
||||||
|
|
||||||
|
encryption_enabled = str(_get("ENCRYPTION_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
|
encryption_keys_dir = storage_root / ".myfsio.sys" / "keys"
|
||||||
|
encryption_master_key_path = Path(_get("ENCRYPTION_MASTER_KEY_PATH", encryption_keys_dir / "master.key")).resolve()
|
||||||
|
kms_enabled = str(_get("KMS_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
|
kms_keys_path = Path(_get("KMS_KEYS_PATH", encryption_keys_dir / "kms_keys.json")).resolve()
|
||||||
|
default_encryption_algorithm = str(_get("DEFAULT_ENCRYPTION_ALGORITHM", "AES256"))
|
||||||
|
display_timezone = str(_get("DISPLAY_TIMEZONE", "UTC"))
|
||||||
|
|
||||||
return cls(storage_root=storage_root,
|
return cls(storage_root=storage_root,
|
||||||
max_upload_size=max_upload_size,
|
max_upload_size=max_upload_size,
|
||||||
@@ -167,6 +175,7 @@ class AppConfig:
|
|||||||
aws_service=aws_service,
|
aws_service=aws_service,
|
||||||
ui_enforce_bucket_policies=enforce_ui_policies,
|
ui_enforce_bucket_policies=enforce_ui_policies,
|
||||||
log_level=log_level,
|
log_level=log_level,
|
||||||
|
log_to_file=log_to_file,
|
||||||
log_path=log_path,
|
log_path=log_path,
|
||||||
log_max_bytes=log_max_bytes,
|
log_max_bytes=log_max_bytes,
|
||||||
log_backup_count=log_backup_count,
|
log_backup_count=log_backup_count,
|
||||||
@@ -175,6 +184,7 @@ class AppConfig:
|
|||||||
cors_origins=cors_origins,
|
cors_origins=cors_origins,
|
||||||
cors_methods=cors_methods,
|
cors_methods=cors_methods,
|
||||||
cors_allow_headers=cors_allow_headers,
|
cors_allow_headers=cors_allow_headers,
|
||||||
|
cors_expose_headers=cors_expose_headers,
|
||||||
session_lifetime_days=session_lifetime_days,
|
session_lifetime_days=session_lifetime_days,
|
||||||
auth_max_attempts=auth_max_attempts,
|
auth_max_attempts=auth_max_attempts,
|
||||||
auth_lockout_minutes=auth_lockout_minutes,
|
auth_lockout_minutes=auth_lockout_minutes,
|
||||||
@@ -182,7 +192,100 @@ class AppConfig:
|
|||||||
secret_ttl_seconds=secret_ttl_seconds,
|
secret_ttl_seconds=secret_ttl_seconds,
|
||||||
stream_chunk_size=stream_chunk_size,
|
stream_chunk_size=stream_chunk_size,
|
||||||
multipart_min_part_size=multipart_min_part_size,
|
multipart_min_part_size=multipart_min_part_size,
|
||||||
bucket_stats_cache_ttl=bucket_stats_cache_ttl)
|
bucket_stats_cache_ttl=bucket_stats_cache_ttl,
|
||||||
|
encryption_enabled=encryption_enabled,
|
||||||
|
encryption_master_key_path=encryption_master_key_path,
|
||||||
|
kms_enabled=kms_enabled,
|
||||||
|
kms_keys_path=kms_keys_path,
|
||||||
|
default_encryption_algorithm=default_encryption_algorithm,
|
||||||
|
display_timezone=display_timezone)
|
||||||
|
|
||||||
|
def validate_and_report(self) -> list[str]:
|
||||||
|
"""Validate configuration and return a list of warnings/issues.
|
||||||
|
|
||||||
|
Call this at startup to detect potential misconfigurations before
|
||||||
|
the application fully commits to running.
|
||||||
|
"""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_file = self.storage_root / ".write_test"
|
||||||
|
test_file.touch()
|
||||||
|
test_file.unlink()
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
issues.append(f"CRITICAL: STORAGE_ROOT '{self.storage_root}' is not writable: {e}")
|
||||||
|
|
||||||
|
storage_str = str(self.storage_root).lower()
|
||||||
|
if "/tmp" in storage_str or "\\temp" in storage_str or "appdata\\local\\temp" in storage_str:
|
||||||
|
issues.append(f"WARNING: STORAGE_ROOT '{self.storage_root}' appears to be a temporary directory. Data may be lost on reboot!")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.iam_config_path.relative_to(self.storage_root)
|
||||||
|
except ValueError:
|
||||||
|
issues.append(f"WARNING: IAM_CONFIG '{self.iam_config_path}' is outside STORAGE_ROOT '{self.storage_root}'. Consider setting IAM_CONFIG explicitly or ensuring paths are aligned.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.bucket_policy_path.relative_to(self.storage_root)
|
||||||
|
except ValueError:
|
||||||
|
issues.append(f"WARNING: BUCKET_POLICY_PATH '{self.bucket_policy_path}' is outside STORAGE_ROOT '{self.storage_root}'. Consider setting BUCKET_POLICY_PATH explicitly.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
test_log = self.log_path.parent / ".write_test"
|
||||||
|
test_log.touch()
|
||||||
|
test_log.unlink()
|
||||||
|
except (OSError, PermissionError) as e:
|
||||||
|
issues.append(f"WARNING: Log directory '{self.log_path.parent}' is not writable: {e}")
|
||||||
|
|
||||||
|
log_str = str(self.log_path).lower()
|
||||||
|
if "/tmp" in log_str or "\\temp" in log_str or "appdata\\local\\temp" in log_str:
|
||||||
|
issues.append(f"WARNING: LOG_DIR '{self.log_path.parent}' appears to be a temporary directory. Logs may be lost on reboot!")
|
||||||
|
|
||||||
|
if self.encryption_enabled:
|
||||||
|
try:
|
||||||
|
self.encryption_master_key_path.relative_to(self.storage_root)
|
||||||
|
except ValueError:
|
||||||
|
issues.append(f"WARNING: ENCRYPTION_MASTER_KEY_PATH '{self.encryption_master_key_path}' is outside STORAGE_ROOT. Ensure proper backup procedures.")
|
||||||
|
|
||||||
|
if self.kms_enabled:
|
||||||
|
try:
|
||||||
|
self.kms_keys_path.relative_to(self.storage_root)
|
||||||
|
except ValueError:
|
||||||
|
issues.append(f"WARNING: KMS_KEYS_PATH '{self.kms_keys_path}' is outside STORAGE_ROOT. Ensure proper backup procedures.")
|
||||||
|
|
||||||
|
if self.secret_key == "dev-secret-key":
|
||||||
|
issues.append("WARNING: Using default SECRET_KEY. Set SECRET_KEY environment variable for production.")
|
||||||
|
|
||||||
|
if "*" in self.cors_origins:
|
||||||
|
issues.append("INFO: CORS_ORIGINS is set to '*'. Consider restricting to specific domains in production.")
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def print_startup_summary(self) -> None:
|
||||||
|
"""Print a summary of the configuration at startup."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("MyFSIO Configuration Summary")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f" STORAGE_ROOT: {self.storage_root}")
|
||||||
|
print(f" IAM_CONFIG: {self.iam_config_path}")
|
||||||
|
print(f" BUCKET_POLICY: {self.bucket_policy_path}")
|
||||||
|
print(f" LOG_PATH: {self.log_path}")
|
||||||
|
if self.api_base_url:
|
||||||
|
print(f" API_BASE_URL: {self.api_base_url}")
|
||||||
|
if self.encryption_enabled:
|
||||||
|
print(f" ENCRYPTION: Enabled (Master key: {self.encryption_master_key_path})")
|
||||||
|
if self.kms_enabled:
|
||||||
|
print(f" KMS: Enabled (Keys: {self.kms_keys_path})")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
issues = self.validate_and_report()
|
||||||
|
if issues:
|
||||||
|
print("\nConfiguration Issues Detected:")
|
||||||
|
for issue in issues:
|
||||||
|
print(f" • {issue}")
|
||||||
|
print()
|
||||||
|
else:
|
||||||
|
print(" ✓ Configuration validated successfully\n")
|
||||||
|
|
||||||
def to_flask_config(self) -> Dict[str, Any]:
|
def to_flask_config(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -204,6 +307,7 @@ class AppConfig:
|
|||||||
"MULTIPART_MIN_PART_SIZE": self.multipart_min_part_size,
|
"MULTIPART_MIN_PART_SIZE": self.multipart_min_part_size,
|
||||||
"BUCKET_STATS_CACHE_TTL": self.bucket_stats_cache_ttl,
|
"BUCKET_STATS_CACHE_TTL": self.bucket_stats_cache_ttl,
|
||||||
"LOG_LEVEL": self.log_level,
|
"LOG_LEVEL": self.log_level,
|
||||||
|
"LOG_TO_FILE": self.log_to_file,
|
||||||
"LOG_FILE": str(self.log_path),
|
"LOG_FILE": str(self.log_path),
|
||||||
"LOG_MAX_BYTES": self.log_max_bytes,
|
"LOG_MAX_BYTES": self.log_max_bytes,
|
||||||
"LOG_BACKUP_COUNT": self.log_backup_count,
|
"LOG_BACKUP_COUNT": self.log_backup_count,
|
||||||
@@ -212,5 +316,12 @@ class AppConfig:
|
|||||||
"CORS_ORIGINS": self.cors_origins,
|
"CORS_ORIGINS": self.cors_origins,
|
||||||
"CORS_METHODS": self.cors_methods,
|
"CORS_METHODS": self.cors_methods,
|
||||||
"CORS_ALLOW_HEADERS": self.cors_allow_headers,
|
"CORS_ALLOW_HEADERS": self.cors_allow_headers,
|
||||||
|
"CORS_EXPOSE_HEADERS": self.cors_expose_headers,
|
||||||
"SESSION_LIFETIME_DAYS": self.session_lifetime_days,
|
"SESSION_LIFETIME_DAYS": self.session_lifetime_days,
|
||||||
|
"ENCRYPTION_ENABLED": self.encryption_enabled,
|
||||||
|
"ENCRYPTION_MASTER_KEY_PATH": str(self.encryption_master_key_path),
|
||||||
|
"KMS_ENABLED": self.kms_enabled,
|
||||||
|
"KMS_KEYS_PATH": str(self.kms_keys_path),
|
||||||
|
"DEFAULT_ENCRYPTION_ALGORITHM": self.default_encryption_algorithm,
|
||||||
|
"DISPLAY_TIMEZONE": self.display_timezone,
|
||||||
}
|
}
|
||||||
|
|||||||
279
app/encrypted_storage.py
Normal file
279
app/encrypted_storage.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""Encrypted storage layer that wraps ObjectStorage with encryption support."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, BinaryIO, Dict, Optional
|
||||||
|
|
||||||
|
from .encryption import EncryptionManager, EncryptionMetadata, EncryptionError
|
||||||
|
from .storage import ObjectStorage, ObjectMeta, StorageError
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptedObjectStorage:
|
||||||
|
"""Object storage with transparent server-side encryption.
|
||||||
|
|
||||||
|
This class wraps ObjectStorage and provides transparent encryption/decryption
|
||||||
|
of objects based on bucket encryption configuration.
|
||||||
|
|
||||||
|
Encryption is applied when:
|
||||||
|
1. Bucket has default encryption configured (SSE-S3 or SSE-KMS)
|
||||||
|
2. Client explicitly requests encryption via headers
|
||||||
|
|
||||||
|
The encryption metadata is stored alongside object metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STREAMING_THRESHOLD = 64 * 1024
|
||||||
|
|
||||||
|
def __init__(self, storage: ObjectStorage, encryption_manager: EncryptionManager):
|
||||||
|
self.storage = storage
|
||||||
|
self.encryption = encryption_manager
|
||||||
|
|
||||||
|
@property
|
||||||
|
def root(self) -> Path:
|
||||||
|
return self.storage.root
|
||||||
|
|
||||||
|
def _should_encrypt(self, bucket_name: str,
|
||||||
|
server_side_encryption: str | None = None) -> tuple[bool, str, str | None]:
|
||||||
|
"""Determine if object should be encrypted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (should_encrypt, algorithm, kms_key_id)
|
||||||
|
"""
|
||||||
|
if not self.encryption.enabled:
|
||||||
|
return False, "", None
|
||||||
|
|
||||||
|
if server_side_encryption:
|
||||||
|
if server_side_encryption == "AES256":
|
||||||
|
return True, "AES256", None
|
||||||
|
elif server_side_encryption.startswith("aws:kms"):
|
||||||
|
parts = server_side_encryption.split(":")
|
||||||
|
kms_key_id = parts[2] if len(parts) > 2 else None
|
||||||
|
return True, "aws:kms", kms_key_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
encryption_config = self.storage.get_bucket_encryption(bucket_name)
|
||||||
|
if encryption_config and encryption_config.get("Rules"):
|
||||||
|
rule = encryption_config["Rules"][0]
|
||||||
|
# AWS format: Rules[].ApplyServerSideEncryptionByDefault.SSEAlgorithm
|
||||||
|
sse_default = rule.get("ApplyServerSideEncryptionByDefault", {})
|
||||||
|
algorithm = sse_default.get("SSEAlgorithm", "AES256")
|
||||||
|
kms_key_id = sse_default.get("KMSMasterKeyID")
|
||||||
|
return True, algorithm, kms_key_id
|
||||||
|
except StorageError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False, "", None
|
||||||
|
|
||||||
|
def _is_encrypted(self, metadata: Dict[str, str]) -> bool:
|
||||||
|
"""Check if object is encrypted based on its metadata."""
|
||||||
|
return "x-amz-server-side-encryption" in metadata
|
||||||
|
|
||||||
|
def put_object(
|
||||||
|
self,
|
||||||
|
bucket_name: str,
|
||||||
|
object_key: str,
|
||||||
|
stream: BinaryIO,
|
||||||
|
*,
|
||||||
|
metadata: Optional[Dict[str, str]] = None,
|
||||||
|
server_side_encryption: Optional[str] = None,
|
||||||
|
kms_key_id: Optional[str] = None,
|
||||||
|
) -> ObjectMeta:
|
||||||
|
"""Store an object, optionally with encryption.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bucket_name: Name of the bucket
|
||||||
|
object_key: Key for the object
|
||||||
|
stream: Binary stream of object data
|
||||||
|
metadata: Optional user metadata
|
||||||
|
server_side_encryption: Encryption algorithm ("AES256" or "aws:kms")
|
||||||
|
kms_key_id: KMS key ID (for aws:kms encryption)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ObjectMeta with object information
|
||||||
|
"""
|
||||||
|
should_encrypt, algorithm, detected_kms_key = self._should_encrypt(
|
||||||
|
bucket_name, server_side_encryption
|
||||||
|
)
|
||||||
|
|
||||||
|
if kms_key_id is None:
|
||||||
|
kms_key_id = detected_kms_key
|
||||||
|
|
||||||
|
if should_encrypt:
|
||||||
|
data = stream.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ciphertext, enc_metadata = self.encryption.encrypt_object(
|
||||||
|
data,
|
||||||
|
algorithm=algorithm,
|
||||||
|
kms_key_id=kms_key_id,
|
||||||
|
context={"bucket": bucket_name, "key": object_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
combined_metadata = metadata.copy() if metadata else {}
|
||||||
|
combined_metadata.update(enc_metadata.to_dict())
|
||||||
|
|
||||||
|
encrypted_stream = io.BytesIO(ciphertext)
|
||||||
|
result = self.storage.put_object(
|
||||||
|
bucket_name,
|
||||||
|
object_key,
|
||||||
|
encrypted_stream,
|
||||||
|
metadata=combined_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.metadata = combined_metadata
|
||||||
|
return result
|
||||||
|
|
||||||
|
except EncryptionError as exc:
|
||||||
|
raise StorageError(f"Encryption failed: {exc}") from exc
|
||||||
|
else:
|
||||||
|
return self.storage.put_object(
|
||||||
|
bucket_name,
|
||||||
|
object_key,
|
||||||
|
stream,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_object_data(self, bucket_name: str, object_key: str) -> tuple[bytes, Dict[str, str]]:
|
||||||
|
"""Get object data, decrypting if necessary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (data, metadata)
|
||||||
|
"""
|
||||||
|
path = self.storage.get_object_path(bucket_name, object_key)
|
||||||
|
metadata = self.storage.get_object_metadata(bucket_name, object_key)
|
||||||
|
|
||||||
|
with path.open("rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
enc_metadata = EncryptionMetadata.from_dict(metadata)
|
||||||
|
if enc_metadata:
|
||||||
|
try:
|
||||||
|
data = self.encryption.decrypt_object(
|
||||||
|
data,
|
||||||
|
enc_metadata,
|
||||||
|
context={"bucket": bucket_name, "key": object_key},
|
||||||
|
)
|
||||||
|
except EncryptionError as exc:
|
||||||
|
raise StorageError(f"Decryption failed: {exc}") from exc
|
||||||
|
|
||||||
|
clean_metadata = {
|
||||||
|
k: v for k, v in metadata.items()
|
||||||
|
if not k.startswith("x-amz-encryption")
|
||||||
|
and k != "x-amz-encrypted-data-key"
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, clean_metadata
|
||||||
|
|
||||||
|
def get_object_stream(self, bucket_name: str, object_key: str) -> tuple[BinaryIO, Dict[str, str], int]:
|
||||||
|
"""Get object as a stream, decrypting if necessary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (stream, metadata, original_size)
|
||||||
|
"""
|
||||||
|
data, metadata = self.get_object_data(bucket_name, object_key)
|
||||||
|
return io.BytesIO(data), metadata, len(data)
|
||||||
|
|
||||||
|
def list_buckets(self):
|
||||||
|
return self.storage.list_buckets()
|
||||||
|
|
||||||
|
def bucket_exists(self, bucket_name: str) -> bool:
|
||||||
|
return self.storage.bucket_exists(bucket_name)
|
||||||
|
|
||||||
|
def create_bucket(self, bucket_name: str) -> None:
|
||||||
|
return self.storage.create_bucket(bucket_name)
|
||||||
|
|
||||||
|
def delete_bucket(self, bucket_name: str) -> None:
|
||||||
|
return self.storage.delete_bucket(bucket_name)
|
||||||
|
|
||||||
|
def bucket_stats(self, bucket_name: str, cache_ttl: int = 60):
|
||||||
|
return self.storage.bucket_stats(bucket_name, cache_ttl)
|
||||||
|
|
||||||
|
def list_objects(self, bucket_name: str, **kwargs):
|
||||||
|
return self.storage.list_objects(bucket_name, **kwargs)
|
||||||
|
|
||||||
|
def list_objects_all(self, bucket_name: str):
|
||||||
|
return self.storage.list_objects_all(bucket_name)
|
||||||
|
|
||||||
|
def get_object_path(self, bucket_name: str, object_key: str):
|
||||||
|
return self.storage.get_object_path(bucket_name, object_key)
|
||||||
|
|
||||||
|
def get_object_metadata(self, bucket_name: str, object_key: str):
|
||||||
|
return self.storage.get_object_metadata(bucket_name, object_key)
|
||||||
|
|
||||||
|
def delete_object(self, bucket_name: str, object_key: str) -> None:
|
||||||
|
return self.storage.delete_object(bucket_name, object_key)
|
||||||
|
|
||||||
|
def purge_object(self, bucket_name: str, object_key: str) -> None:
|
||||||
|
return self.storage.purge_object(bucket_name, object_key)
|
||||||
|
|
||||||
|
def is_versioning_enabled(self, bucket_name: str) -> bool:
|
||||||
|
return self.storage.is_versioning_enabled(bucket_name)
|
||||||
|
|
||||||
|
def set_bucket_versioning(self, bucket_name: str, enabled: bool) -> None:
|
||||||
|
return self.storage.set_bucket_versioning(bucket_name, enabled)
|
||||||
|
|
||||||
|
def get_bucket_tags(self, bucket_name: str):
|
||||||
|
return self.storage.get_bucket_tags(bucket_name)
|
||||||
|
|
||||||
|
def set_bucket_tags(self, bucket_name: str, tags):
|
||||||
|
return self.storage.set_bucket_tags(bucket_name, tags)
|
||||||
|
|
||||||
|
def get_bucket_cors(self, bucket_name: str):
|
||||||
|
return self.storage.get_bucket_cors(bucket_name)
|
||||||
|
|
||||||
|
def set_bucket_cors(self, bucket_name: str, rules):
|
||||||
|
return self.storage.set_bucket_cors(bucket_name, rules)
|
||||||
|
|
||||||
|
def get_bucket_encryption(self, bucket_name: str):
|
||||||
|
return self.storage.get_bucket_encryption(bucket_name)
|
||||||
|
|
||||||
|
def set_bucket_encryption(self, bucket_name: str, config_payload):
|
||||||
|
return self.storage.set_bucket_encryption(bucket_name, config_payload)
|
||||||
|
|
||||||
|
def get_bucket_lifecycle(self, bucket_name: str):
|
||||||
|
return self.storage.get_bucket_lifecycle(bucket_name)
|
||||||
|
|
||||||
|
def set_bucket_lifecycle(self, bucket_name: str, rules):
|
||||||
|
return self.storage.set_bucket_lifecycle(bucket_name, rules)
|
||||||
|
|
||||||
|
def get_object_tags(self, bucket_name: str, object_key: str):
|
||||||
|
return self.storage.get_object_tags(bucket_name, object_key)
|
||||||
|
|
||||||
|
def set_object_tags(self, bucket_name: str, object_key: str, tags):
|
||||||
|
return self.storage.set_object_tags(bucket_name, object_key, tags)
|
||||||
|
|
||||||
|
def delete_object_tags(self, bucket_name: str, object_key: str):
|
||||||
|
return self.storage.delete_object_tags(bucket_name, object_key)
|
||||||
|
|
||||||
|
def list_object_versions(self, bucket_name: str, object_key: str):
|
||||||
|
return self.storage.list_object_versions(bucket_name, object_key)
|
||||||
|
|
||||||
|
def restore_object_version(self, bucket_name: str, object_key: str, version_id: str):
|
||||||
|
return self.storage.restore_object_version(bucket_name, object_key, version_id)
|
||||||
|
|
||||||
|
def list_orphaned_objects(self, bucket_name: str):
|
||||||
|
return self.storage.list_orphaned_objects(bucket_name)
|
||||||
|
|
||||||
|
def initiate_multipart_upload(self, bucket_name: str, object_key: str, *, metadata=None) -> str:
|
||||||
|
return self.storage.initiate_multipart_upload(bucket_name, object_key, metadata=metadata)
|
||||||
|
|
||||||
|
def upload_multipart_part(self, bucket_name: str, upload_id: str, part_number: int, stream: BinaryIO) -> str:
|
||||||
|
return self.storage.upload_multipart_part(bucket_name, upload_id, part_number, stream)
|
||||||
|
|
||||||
|
def complete_multipart_upload(self, bucket_name: str, upload_id: str, ordered_parts):
|
||||||
|
return self.storage.complete_multipart_upload(bucket_name, upload_id, ordered_parts)
|
||||||
|
|
||||||
|
def abort_multipart_upload(self, bucket_name: str, upload_id: str) -> None:
|
||||||
|
return self.storage.abort_multipart_upload(bucket_name, upload_id)
|
||||||
|
|
||||||
|
def list_multipart_parts(self, bucket_name: str, upload_id: str):
|
||||||
|
return self.storage.list_multipart_parts(bucket_name, upload_id)
|
||||||
|
|
||||||
|
def get_bucket_quota(self, bucket_name: str):
|
||||||
|
return self.storage.get_bucket_quota(bucket_name)
|
||||||
|
|
||||||
|
def set_bucket_quota(self, bucket_name: str, *, max_bytes=None, max_objects=None):
|
||||||
|
return self.storage.set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects)
|
||||||
|
|
||||||
|
def _compute_etag(self, path: Path) -> str:
|
||||||
|
return self.storage._compute_etag(path)
|
||||||
395
app/encryption.py
Normal file
395
app/encryption.py
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
"""Encryption providers for server-side and client-side encryption."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, BinaryIO, Dict, Generator, Optional
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptionError(Exception):
|
||||||
|
"""Raised when encryption/decryption fails."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EncryptionResult:
|
||||||
|
"""Result of encrypting data."""
|
||||||
|
ciphertext: bytes
|
||||||
|
nonce: bytes
|
||||||
|
key_id: str
|
||||||
|
encrypted_data_key: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EncryptionMetadata:
|
||||||
|
"""Metadata stored with encrypted objects."""
|
||||||
|
algorithm: str
|
||||||
|
key_id: str
|
||||||
|
nonce: bytes
|
||||||
|
encrypted_data_key: bytes
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, str]:
|
||||||
|
return {
|
||||||
|
"x-amz-server-side-encryption": self.algorithm,
|
||||||
|
"x-amz-encryption-key-id": self.key_id,
|
||||||
|
"x-amz-encryption-nonce": base64.b64encode(self.nonce).decode(),
|
||||||
|
"x-amz-encrypted-data-key": base64.b64encode(self.encrypted_data_key).decode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, str]) -> Optional["EncryptionMetadata"]:
|
||||||
|
algorithm = data.get("x-amz-server-side-encryption")
|
||||||
|
if not algorithm:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return cls(
|
||||||
|
algorithm=algorithm,
|
||||||
|
key_id=data.get("x-amz-encryption-key-id", "local"),
|
||||||
|
nonce=base64.b64decode(data.get("x-amz-encryption-nonce", "")),
|
||||||
|
encrypted_data_key=base64.b64decode(data.get("x-amz-encrypted-data-key", "")),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptionProvider:
|
||||||
|
"""Base class for encryption providers."""
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
||||||
|
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def generate_data_key(self) -> tuple[bytes, bytes]:
|
||||||
|
"""Generate a data key and its encrypted form.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (plaintext_key, encrypted_key)
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class LocalKeyEncryption(EncryptionProvider):
|
||||||
|
"""SSE-S3 style encryption using a local master key.
|
||||||
|
|
||||||
|
Uses envelope encryption:
|
||||||
|
1. Generate a unique data key for each object
|
||||||
|
2. Encrypt the data with the data key (AES-256-GCM)
|
||||||
|
3. Encrypt the data key with the master key
|
||||||
|
4. Store the encrypted data key alongside the ciphertext
|
||||||
|
"""
|
||||||
|
|
||||||
|
KEY_ID = "local"
|
||||||
|
|
||||||
|
def __init__(self, master_key_path: Path):
|
||||||
|
self.master_key_path = master_key_path
|
||||||
|
self._master_key: bytes | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def master_key(self) -> bytes:
|
||||||
|
if self._master_key is None:
|
||||||
|
self._master_key = self._load_or_create_master_key()
|
||||||
|
return self._master_key
|
||||||
|
|
||||||
|
def _load_or_create_master_key(self) -> bytes:
|
||||||
|
"""Load master key from file or generate a new one."""
|
||||||
|
if self.master_key_path.exists():
|
||||||
|
try:
|
||||||
|
return base64.b64decode(self.master_key_path.read_text().strip())
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Failed to load master key: {exc}") from exc
|
||||||
|
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
try:
|
||||||
|
self.master_key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.master_key_path.write_text(base64.b64encode(key).decode())
|
||||||
|
except OSError as exc:
|
||||||
|
raise EncryptionError(f"Failed to save master key: {exc}") from exc
|
||||||
|
return key
|
||||||
|
|
||||||
|
def _encrypt_data_key(self, data_key: bytes) -> bytes:
|
||||||
|
"""Encrypt the data key with the master key."""
|
||||||
|
aesgcm = AESGCM(self.master_key)
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
encrypted = aesgcm.encrypt(nonce, data_key, None)
|
||||||
|
return nonce + encrypted
|
||||||
|
|
||||||
|
def _decrypt_data_key(self, encrypted_data_key: bytes) -> bytes:
|
||||||
|
"""Decrypt the data key using the master key."""
|
||||||
|
if len(encrypted_data_key) < 12 + 32 + 16: # nonce + key + tag
|
||||||
|
raise EncryptionError("Invalid encrypted data key")
|
||||||
|
aesgcm = AESGCM(self.master_key)
|
||||||
|
nonce = encrypted_data_key[:12]
|
||||||
|
ciphertext = encrypted_data_key[12:]
|
||||||
|
try:
|
||||||
|
return aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Failed to decrypt data key: {exc}") from exc
|
||||||
|
|
||||||
|
def generate_data_key(self) -> tuple[bytes, bytes]:
|
||||||
|
"""Generate a data key and its encrypted form."""
|
||||||
|
plaintext_key = secrets.token_bytes(32)
|
||||||
|
encrypted_key = self._encrypt_data_key(plaintext_key)
|
||||||
|
return plaintext_key, encrypted_key
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
||||||
|
"""Encrypt data using envelope encryption."""
|
||||||
|
data_key, encrypted_data_key = self.generate_data_key()
|
||||||
|
|
||||||
|
aesgcm = AESGCM(data_key)
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
||||||
|
|
||||||
|
return EncryptionResult(
|
||||||
|
ciphertext=ciphertext,
|
||||||
|
nonce=nonce,
|
||||||
|
key_id=self.KEY_ID,
|
||||||
|
encrypted_data_key=encrypted_data_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
||||||
|
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
||||||
|
"""Decrypt data using envelope encryption."""
|
||||||
|
# Decrypt the data key
|
||||||
|
data_key = self._decrypt_data_key(encrypted_data_key)
|
||||||
|
|
||||||
|
# Decrypt the data
|
||||||
|
aesgcm = AESGCM(data_key)
|
||||||
|
try:
|
||||||
|
return aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Failed to decrypt data: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingEncryptor:
|
||||||
|
"""Encrypts/decrypts data in streaming fashion for large files.
|
||||||
|
|
||||||
|
For large files, we encrypt in chunks. Each chunk is encrypted with the
|
||||||
|
same data key but a unique nonce derived from the base nonce + chunk index.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHUNK_SIZE = 64 * 1024
|
||||||
|
HEADER_SIZE = 4
|
||||||
|
|
||||||
|
def __init__(self, provider: EncryptionProvider, chunk_size: int = CHUNK_SIZE):
|
||||||
|
self.provider = provider
|
||||||
|
self.chunk_size = chunk_size
|
||||||
|
|
||||||
|
def _derive_chunk_nonce(self, base_nonce: bytes, chunk_index: int) -> bytes:
|
||||||
|
"""Derive a unique nonce for each chunk."""
|
||||||
|
# XOR the base nonce with the chunk index
|
||||||
|
nonce_int = int.from_bytes(base_nonce, "big")
|
||||||
|
derived = nonce_int ^ chunk_index
|
||||||
|
return derived.to_bytes(12, "big")
|
||||||
|
|
||||||
|
def encrypt_stream(self, stream: BinaryIO,
|
||||||
|
context: Dict[str, str] | None = None) -> tuple[BinaryIO, EncryptionMetadata]:
|
||||||
|
"""Encrypt a stream and return encrypted stream + metadata."""
|
||||||
|
|
||||||
|
data_key, encrypted_data_key = self.provider.generate_data_key()
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
aesgcm = AESGCM(data_key)
|
||||||
|
encrypted_chunks = []
|
||||||
|
chunk_index = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
chunk = stream.read(self.chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
chunk_nonce = self._derive_chunk_nonce(base_nonce, chunk_index)
|
||||||
|
encrypted_chunk = aesgcm.encrypt(chunk_nonce, chunk, None)
|
||||||
|
|
||||||
|
size_prefix = len(encrypted_chunk).to_bytes(self.HEADER_SIZE, "big")
|
||||||
|
encrypted_chunks.append(size_prefix + encrypted_chunk)
|
||||||
|
chunk_index += 1
|
||||||
|
|
||||||
|
header = chunk_index.to_bytes(4, "big")
|
||||||
|
encrypted_data = header + b"".join(encrypted_chunks)
|
||||||
|
|
||||||
|
metadata = EncryptionMetadata(
|
||||||
|
algorithm="AES256",
|
||||||
|
key_id=self.provider.KEY_ID if hasattr(self.provider, "KEY_ID") else "local",
|
||||||
|
nonce=base_nonce,
|
||||||
|
encrypted_data_key=encrypted_data_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
return io.BytesIO(encrypted_data), metadata
|
||||||
|
|
||||||
|
def decrypt_stream(self, stream: BinaryIO, metadata: EncryptionMetadata) -> BinaryIO:
|
||||||
|
"""Decrypt a stream using the provided metadata."""
|
||||||
|
if isinstance(self.provider, LocalKeyEncryption):
|
||||||
|
data_key = self.provider._decrypt_data_key(metadata.encrypted_data_key)
|
||||||
|
else:
|
||||||
|
raise EncryptionError("Unsupported provider for streaming decryption")
|
||||||
|
|
||||||
|
aesgcm = AESGCM(data_key)
|
||||||
|
base_nonce = metadata.nonce
|
||||||
|
|
||||||
|
chunk_count_bytes = stream.read(4)
|
||||||
|
if len(chunk_count_bytes) < 4:
|
||||||
|
raise EncryptionError("Invalid encrypted stream: missing header")
|
||||||
|
chunk_count = int.from_bytes(chunk_count_bytes, "big")
|
||||||
|
|
||||||
|
decrypted_chunks = []
|
||||||
|
for chunk_index in range(chunk_count):
|
||||||
|
size_bytes = stream.read(self.HEADER_SIZE)
|
||||||
|
if len(size_bytes) < self.HEADER_SIZE:
|
||||||
|
raise EncryptionError(f"Invalid encrypted stream: truncated at chunk {chunk_index}")
|
||||||
|
chunk_size = int.from_bytes(size_bytes, "big")
|
||||||
|
|
||||||
|
encrypted_chunk = stream.read(chunk_size)
|
||||||
|
if len(encrypted_chunk) < chunk_size:
|
||||||
|
raise EncryptionError(f"Invalid encrypted stream: incomplete chunk {chunk_index}")
|
||||||
|
|
||||||
|
chunk_nonce = self._derive_chunk_nonce(base_nonce, chunk_index)
|
||||||
|
try:
|
||||||
|
decrypted_chunk = aesgcm.decrypt(chunk_nonce, encrypted_chunk, None)
|
||||||
|
decrypted_chunks.append(decrypted_chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Failed to decrypt chunk {chunk_index}: {exc}") from exc
|
||||||
|
|
||||||
|
return io.BytesIO(b"".join(decrypted_chunks))
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptionManager:
|
||||||
|
"""Manages encryption providers and operations."""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]):
|
||||||
|
self.config = config
|
||||||
|
self._local_provider: LocalKeyEncryption | None = None
|
||||||
|
self._kms_provider: Any = None # Set by KMS module
|
||||||
|
self._streaming_encryptor: StreamingEncryptor | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self.config.get("encryption_enabled", False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_algorithm(self) -> str:
|
||||||
|
return self.config.get("default_encryption_algorithm", "AES256")
|
||||||
|
|
||||||
|
def get_local_provider(self) -> LocalKeyEncryption:
|
||||||
|
if self._local_provider is None:
|
||||||
|
key_path = Path(self.config.get("encryption_master_key_path", "data/.myfsio.sys/keys/master.key"))
|
||||||
|
self._local_provider = LocalKeyEncryption(key_path)
|
||||||
|
return self._local_provider
|
||||||
|
|
||||||
|
def set_kms_provider(self, kms_provider: Any) -> None:
|
||||||
|
"""Set the KMS provider (injected from kms module)."""
|
||||||
|
self._kms_provider = kms_provider
|
||||||
|
|
||||||
|
def get_provider(self, algorithm: str, kms_key_id: str | None = None) -> EncryptionProvider:
|
||||||
|
"""Get the appropriate encryption provider for the algorithm."""
|
||||||
|
if algorithm == "AES256":
|
||||||
|
return self.get_local_provider()
|
||||||
|
elif algorithm == "aws:kms":
|
||||||
|
if self._kms_provider is None:
|
||||||
|
raise EncryptionError("KMS is not configured")
|
||||||
|
return self._kms_provider.get_provider(kms_key_id)
|
||||||
|
else:
|
||||||
|
raise EncryptionError(f"Unsupported encryption algorithm: {algorithm}")
|
||||||
|
|
||||||
|
def get_streaming_encryptor(self) -> StreamingEncryptor:
|
||||||
|
if self._streaming_encryptor is None:
|
||||||
|
self._streaming_encryptor = StreamingEncryptor(self.get_local_provider())
|
||||||
|
return self._streaming_encryptor
|
||||||
|
|
||||||
|
def encrypt_object(self, data: bytes, algorithm: str = "AES256",
|
||||||
|
kms_key_id: str | None = None,
|
||||||
|
context: Dict[str, str] | None = None) -> tuple[bytes, EncryptionMetadata]:
|
||||||
|
"""Encrypt object data."""
|
||||||
|
provider = self.get_provider(algorithm, kms_key_id)
|
||||||
|
result = provider.encrypt(data, context)
|
||||||
|
|
||||||
|
metadata = EncryptionMetadata(
|
||||||
|
algorithm=algorithm,
|
||||||
|
key_id=result.key_id,
|
||||||
|
nonce=result.nonce,
|
||||||
|
encrypted_data_key=result.encrypted_data_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.ciphertext, metadata
|
||||||
|
|
||||||
|
def decrypt_object(self, ciphertext: bytes, metadata: EncryptionMetadata,
|
||||||
|
context: Dict[str, str] | None = None) -> bytes:
|
||||||
|
"""Decrypt object data."""
|
||||||
|
provider = self.get_provider(metadata.algorithm, metadata.key_id)
|
||||||
|
return provider.decrypt(
|
||||||
|
ciphertext,
|
||||||
|
metadata.nonce,
|
||||||
|
metadata.encrypted_data_key,
|
||||||
|
metadata.key_id,
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
def encrypt_stream(self, stream: BinaryIO, algorithm: str = "AES256",
|
||||||
|
context: Dict[str, str] | None = None) -> tuple[BinaryIO, EncryptionMetadata]:
|
||||||
|
"""Encrypt a stream for large files."""
|
||||||
|
encryptor = self.get_streaming_encryptor()
|
||||||
|
return encryptor.encrypt_stream(stream, context)
|
||||||
|
|
||||||
|
def decrypt_stream(self, stream: BinaryIO, metadata: EncryptionMetadata) -> BinaryIO:
|
||||||
|
"""Decrypt a stream."""
|
||||||
|
encryptor = self.get_streaming_encryptor()
|
||||||
|
return encryptor.decrypt_stream(stream, metadata)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientEncryptionHelper:
|
||||||
|
"""Helpers for client-side encryption.
|
||||||
|
|
||||||
|
Client-side encryption is performed by the client, but this helper
|
||||||
|
provides key generation and materials for clients that need them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_client_key() -> Dict[str, str]:
|
||||||
|
"""Generate a new client encryption key."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
return {
|
||||||
|
"key": base64.b64encode(key).decode(),
|
||||||
|
"algorithm": "AES-256-GCM",
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encrypt_with_key(plaintext: bytes, key_b64: str) -> Dict[str, str]:
|
||||||
|
"""Encrypt data with a client-provided key."""
|
||||||
|
key = base64.b64decode(key_b64)
|
||||||
|
if len(key) != 32:
|
||||||
|
raise EncryptionError("Key must be 256 bits (32 bytes)")
|
||||||
|
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ciphertext": base64.b64encode(ciphertext).decode(),
|
||||||
|
"nonce": base64.b64encode(nonce).decode(),
|
||||||
|
"algorithm": "AES-256-GCM",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decrypt_with_key(ciphertext_b64: str, nonce_b64: str, key_b64: str) -> bytes:
|
||||||
|
"""Decrypt data with a client-provided key."""
|
||||||
|
key = base64.b64decode(key_b64)
|
||||||
|
nonce = base64.b64decode(nonce_b64)
|
||||||
|
ciphertext = base64.b64decode(ciphertext_b64)
|
||||||
|
|
||||||
|
if len(key) != 32:
|
||||||
|
raise EncryptionError("Key must be 256 bits (32 bytes)")
|
||||||
|
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
try:
|
||||||
|
return aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Decryption failed: {exc}") from exc
|
||||||
@@ -129,6 +129,25 @@ class EntityTooLargeError(AppError):
|
|||||||
status_code: int = 413
|
status_code: int = 413
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QuotaExceededAppError(AppError):
|
||||||
|
"""Bucket quota exceeded."""
|
||||||
|
code: str = "QuotaExceeded"
|
||||||
|
message: str = "The bucket quota has been exceeded"
|
||||||
|
status_code: int = 403
|
||||||
|
quota: Optional[Dict[str, Any]] = None
|
||||||
|
usage: Optional[Dict[str, int]] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.quota or self.usage:
|
||||||
|
self.details = {}
|
||||||
|
if self.quota:
|
||||||
|
self.details["quota"] = self.quota
|
||||||
|
if self.usage:
|
||||||
|
self.details["usage"] = self.usage
|
||||||
|
super().__post_init__()
|
||||||
|
|
||||||
|
|
||||||
def handle_app_error(error: AppError) -> Response:
|
def handle_app_error(error: AppError) -> Response:
|
||||||
"""Handle application errors with appropriate response format."""
|
"""Handle application errors with appropriate response format."""
|
||||||
log_extra = {"error_code": error.code}
|
log_extra = {"error_code": error.code}
|
||||||
@@ -163,5 +182,6 @@ def register_error_handlers(app):
|
|||||||
ObjectNotFoundError, InvalidObjectKeyError,
|
ObjectNotFoundError, InvalidObjectKeyError,
|
||||||
AccessDeniedError, InvalidCredentialsError,
|
AccessDeniedError, InvalidCredentialsError,
|
||||||
MalformedRequestError, InvalidArgumentError, EntityTooLargeError,
|
MalformedRequestError, InvalidArgumentError, EntityTooLargeError,
|
||||||
|
QuotaExceededAppError,
|
||||||
]:
|
]:
|
||||||
app.register_error_handler(error_class, handle_app_error)
|
app.register_error_handler(error_class, handle_app_error)
|
||||||
|
|||||||
51
app/iam.py
51
app/iam.py
@@ -6,7 +6,7 @@ import math
|
|||||||
import secrets
|
import secrets
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Deque, Dict, Iterable, List, Optional, Sequence, Set
|
from typing import Any, Deque, Dict, Iterable, List, Optional, Sequence, Set
|
||||||
|
|
||||||
@@ -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"}
|
S3_ACTIONS = {"list", "read", "write", "delete", "share", "policy", "replication"}
|
||||||
IAM_ACTIONS = {
|
IAM_ACTIONS = {
|
||||||
"iam:list_users",
|
"iam:list_users",
|
||||||
"iam:create_user",
|
"iam:create_user",
|
||||||
@@ -26,22 +26,59 @@ IAM_ACTIONS = {
|
|||||||
ALLOWED_ACTIONS = (S3_ACTIONS | IAM_ACTIONS) | {"iam:*"}
|
ALLOWED_ACTIONS = (S3_ACTIONS | IAM_ACTIONS) | {"iam:*"}
|
||||||
|
|
||||||
ACTION_ALIASES = {
|
ACTION_ALIASES = {
|
||||||
|
# List actions
|
||||||
"list": "list",
|
"list": "list",
|
||||||
"s3:listbucket": "list",
|
"s3:listbucket": "list",
|
||||||
"s3:listallmybuckets": "list",
|
"s3:listallmybuckets": "list",
|
||||||
|
"s3:listbucketversions": "list",
|
||||||
|
"s3:listmultipartuploads": "list",
|
||||||
|
"s3:listparts": "list",
|
||||||
|
# Read actions
|
||||||
"read": "read",
|
"read": "read",
|
||||||
"s3:getobject": "read",
|
"s3:getobject": "read",
|
||||||
"s3:getobjectversion": "read",
|
"s3:getobjectversion": "read",
|
||||||
|
"s3:getobjecttagging": "read",
|
||||||
|
"s3:getobjectversiontagging": "read",
|
||||||
|
"s3:getobjectacl": "read",
|
||||||
|
"s3:getbucketversioning": "read",
|
||||||
|
"s3:headobject": "read",
|
||||||
|
"s3:headbucket": "read",
|
||||||
|
# Write actions
|
||||||
"write": "write",
|
"write": "write",
|
||||||
"s3:putobject": "write",
|
"s3:putobject": "write",
|
||||||
"s3:createbucket": "write",
|
"s3:createbucket": "write",
|
||||||
|
"s3:putobjecttagging": "write",
|
||||||
|
"s3:putbucketversioning": "write",
|
||||||
|
"s3:createmultipartupload": "write",
|
||||||
|
"s3:uploadpart": "write",
|
||||||
|
"s3:completemultipartupload": "write",
|
||||||
|
"s3:abortmultipartupload": "write",
|
||||||
|
"s3:copyobject": "write",
|
||||||
|
# Delete actions
|
||||||
"delete": "delete",
|
"delete": "delete",
|
||||||
"s3:deleteobject": "delete",
|
"s3:deleteobject": "delete",
|
||||||
|
"s3:deleteobjectversion": "delete",
|
||||||
"s3:deletebucket": "delete",
|
"s3:deletebucket": "delete",
|
||||||
|
"s3:deleteobjecttagging": "delete",
|
||||||
|
# Share actions (ACL)
|
||||||
"share": "share",
|
"share": "share",
|
||||||
"s3:putobjectacl": "share",
|
"s3:putobjectacl": "share",
|
||||||
|
"s3:putbucketacl": "share",
|
||||||
|
"s3:getbucketacl": "share",
|
||||||
|
# Policy actions
|
||||||
"policy": "policy",
|
"policy": "policy",
|
||||||
"s3:putbucketpolicy": "policy",
|
"s3:putbucketpolicy": "policy",
|
||||||
|
"s3:getbucketpolicy": "policy",
|
||||||
|
"s3:deletebucketpolicy": "policy",
|
||||||
|
# Replication actions
|
||||||
|
"replication": "replication",
|
||||||
|
"s3:getreplicationconfiguration": "replication",
|
||||||
|
"s3:putreplicationconfiguration": "replication",
|
||||||
|
"s3:deletereplicationconfiguration": "replication",
|
||||||
|
"s3:replicateobject": "replication",
|
||||||
|
"s3:replicatetags": "replication",
|
||||||
|
"s3:replicatedelete": "replication",
|
||||||
|
# IAM actions
|
||||||
"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",
|
||||||
@@ -88,7 +125,6 @@ class IamService:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ---------------------- authz helpers ----------------------
|
|
||||||
def authenticate(self, access_key: str, secret_key: str) -> Principal:
|
def authenticate(self, access_key: str, secret_key: str) -> Principal:
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
access_key = (access_key or "").strip()
|
access_key = (access_key or "").strip()
|
||||||
@@ -112,7 +148,7 @@ class IamService:
|
|||||||
return
|
return
|
||||||
attempts = self._failed_attempts.setdefault(access_key, deque())
|
attempts = self._failed_attempts.setdefault(access_key, deque())
|
||||||
self._prune_attempts(attempts)
|
self._prune_attempts(attempts)
|
||||||
attempts.append(datetime.now())
|
attempts.append(datetime.now(timezone.utc))
|
||||||
|
|
||||||
def _clear_failed_attempts(self, access_key: str) -> None:
|
def _clear_failed_attempts(self, access_key: str) -> None:
|
||||||
if not access_key:
|
if not access_key:
|
||||||
@@ -120,7 +156,7 @@ class IamService:
|
|||||||
self._failed_attempts.pop(access_key, None)
|
self._failed_attempts.pop(access_key, None)
|
||||||
|
|
||||||
def _prune_attempts(self, attempts: Deque[datetime]) -> None:
|
def _prune_attempts(self, attempts: Deque[datetime]) -> None:
|
||||||
cutoff = datetime.now() - self.auth_lockout_window
|
cutoff = datetime.now(timezone.utc) - self.auth_lockout_window
|
||||||
while attempts and attempts[0] < cutoff:
|
while attempts and attempts[0] < cutoff:
|
||||||
attempts.popleft()
|
attempts.popleft()
|
||||||
|
|
||||||
@@ -141,7 +177,7 @@ class IamService:
|
|||||||
if len(attempts) < self.auth_max_attempts:
|
if len(attempts) < self.auth_max_attempts:
|
||||||
return 0
|
return 0
|
||||||
oldest = attempts[0]
|
oldest = attempts[0]
|
||||||
elapsed = (datetime.now() - oldest).total_seconds()
|
elapsed = (datetime.now(timezone.utc) - oldest).total_seconds()
|
||||||
return int(max(0, self.auth_lockout_window.total_seconds() - elapsed))
|
return int(max(0, self.auth_lockout_window.total_seconds() - elapsed))
|
||||||
|
|
||||||
def principal_for_key(self, access_key: str) -> Principal:
|
def principal_for_key(self, access_key: str) -> Principal:
|
||||||
@@ -181,7 +217,6 @@ class IamService:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# ---------------------- management helpers ----------------------
|
|
||||||
def list_users(self) -> List[Dict[str, Any]]:
|
def list_users(self) -> List[Dict[str, Any]]:
|
||||||
listing: List[Dict[str, Any]] = []
|
listing: List[Dict[str, Any]] = []
|
||||||
for access_key, record in self._users.items():
|
for access_key, record in self._users.items():
|
||||||
@@ -254,7 +289,6 @@ class IamService:
|
|||||||
self._save()
|
self._save()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
# ---------------------- config helpers ----------------------
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
try:
|
try:
|
||||||
self._last_load_time = self.config_path.stat().st_mtime
|
self._last_load_time = self.config_path.stat().st_mtime
|
||||||
@@ -300,7 +334,6 @@ class IamService:
|
|||||||
except (OSError, PermissionError) as e:
|
except (OSError, PermissionError) as e:
|
||||||
raise IamError(f"Cannot save IAM config: {e}")
|
raise IamError(f"Cannot save IAM config: {e}")
|
||||||
|
|
||||||
# ---------------------- insight helpers ----------------------
|
|
||||||
def config_summary(self) -> Dict[str, Any]:
|
def config_summary(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"path": str(self.config_path),
|
"path": str(self.config_path),
|
||||||
|
|||||||
344
app/kms.py
Normal file
344
app/kms.py
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
"""Key Management Service (KMS) for encryption key management."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
from .encryption import EncryptionError, EncryptionProvider, EncryptionResult
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KMSKey:
|
||||||
|
"""Represents a KMS encryption key."""
|
||||||
|
key_id: str
|
||||||
|
description: str
|
||||||
|
created_at: str
|
||||||
|
enabled: bool = True
|
||||||
|
key_material: bytes = field(default_factory=lambda: b"", repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def arn(self) -> str:
|
||||||
|
return f"arn:aws:kms:local:000000000000:key/{self.key_id}"
|
||||||
|
|
||||||
|
def to_dict(self, include_key: bool = False) -> Dict[str, Any]:
|
||||||
|
data = {
|
||||||
|
"KeyId": self.key_id,
|
||||||
|
"Arn": self.arn,
|
||||||
|
"Description": self.description,
|
||||||
|
"CreationDate": self.created_at,
|
||||||
|
"Enabled": self.enabled,
|
||||||
|
"KeyState": "Enabled" if self.enabled else "Disabled",
|
||||||
|
"KeyUsage": "ENCRYPT_DECRYPT",
|
||||||
|
"KeySpec": "SYMMETRIC_DEFAULT",
|
||||||
|
}
|
||||||
|
if include_key:
|
||||||
|
data["KeyMaterial"] = base64.b64encode(self.key_material).decode()
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "KMSKey":
|
||||||
|
key_material = b""
|
||||||
|
if "KeyMaterial" in data:
|
||||||
|
key_material = base64.b64decode(data["KeyMaterial"])
|
||||||
|
return cls(
|
||||||
|
key_id=data["KeyId"],
|
||||||
|
description=data.get("Description", ""),
|
||||||
|
created_at=data.get("CreationDate", datetime.now(timezone.utc).isoformat()),
|
||||||
|
enabled=data.get("Enabled", True),
|
||||||
|
key_material=key_material,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KMSEncryptionProvider(EncryptionProvider):
|
||||||
|
"""Encryption provider using a specific KMS key."""
|
||||||
|
|
||||||
|
def __init__(self, kms: "KMSManager", key_id: str):
|
||||||
|
self.kms = kms
|
||||||
|
self.key_id = key_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def KEY_ID(self) -> str:
|
||||||
|
return self.key_id
|
||||||
|
|
||||||
|
def generate_data_key(self) -> tuple[bytes, bytes]:
|
||||||
|
"""Generate a data key encrypted with the KMS key."""
|
||||||
|
return self.kms.generate_data_key(self.key_id)
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
||||||
|
"""Encrypt data using envelope encryption with KMS."""
|
||||||
|
data_key, encrypted_data_key = self.generate_data_key()
|
||||||
|
|
||||||
|
aesgcm = AESGCM(data_key)
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext,
|
||||||
|
json.dumps(context).encode() if context else None)
|
||||||
|
|
||||||
|
return EncryptionResult(
|
||||||
|
ciphertext=ciphertext,
|
||||||
|
nonce=nonce,
|
||||||
|
key_id=self.key_id,
|
||||||
|
encrypted_data_key=encrypted_data_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
||||||
|
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
||||||
|
"""Decrypt data using envelope encryption with KMS."""
|
||||||
|
# Note: Data key is encrypted without context (AAD), so we decrypt without context
|
||||||
|
data_key = self.kms.decrypt_data_key(key_id, encrypted_data_key, context=None)
|
||||||
|
|
||||||
|
aesgcm = AESGCM(data_key)
|
||||||
|
try:
|
||||||
|
return aesgcm.decrypt(nonce, ciphertext,
|
||||||
|
json.dumps(context).encode() if context else None)
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Failed to decrypt data: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class KMSManager:
|
||||||
|
"""Manages KMS keys and operations.
|
||||||
|
|
||||||
|
This is a local implementation that mimics AWS KMS functionality.
|
||||||
|
Keys are stored encrypted on disk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, keys_path: Path, master_key_path: Path):
|
||||||
|
self.keys_path = keys_path
|
||||||
|
self.master_key_path = master_key_path
|
||||||
|
self._keys: Dict[str, KMSKey] = {}
|
||||||
|
self._master_key: bytes | None = None
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def master_key(self) -> bytes:
|
||||||
|
"""Load or create the master key for encrypting KMS keys."""
|
||||||
|
if self._master_key is None:
|
||||||
|
if self.master_key_path.exists():
|
||||||
|
self._master_key = base64.b64decode(
|
||||||
|
self.master_key_path.read_text().strip()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._master_key = secrets.token_bytes(32)
|
||||||
|
self.master_key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.master_key_path.write_text(
|
||||||
|
base64.b64encode(self._master_key).decode()
|
||||||
|
)
|
||||||
|
return self._master_key
|
||||||
|
|
||||||
|
def _load_keys(self) -> None:
|
||||||
|
"""Load keys from disk."""
|
||||||
|
if self._loaded:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.keys_path.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(self.keys_path.read_text(encoding="utf-8"))
|
||||||
|
for key_data in data.get("keys", []):
|
||||||
|
key = KMSKey.from_dict(key_data)
|
||||||
|
if key_data.get("EncryptedKeyMaterial"):
|
||||||
|
encrypted = base64.b64decode(key_data["EncryptedKeyMaterial"])
|
||||||
|
key.key_material = self._decrypt_key_material(encrypted)
|
||||||
|
self._keys[key.key_id] = key
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._loaded = True
|
||||||
|
|
||||||
|
def _save_keys(self) -> None:
|
||||||
|
"""Save keys to disk (with encrypted key material)."""
|
||||||
|
keys_data = []
|
||||||
|
for key in self._keys.values():
|
||||||
|
data = key.to_dict(include_key=False)
|
||||||
|
encrypted = self._encrypt_key_material(key.key_material)
|
||||||
|
data["EncryptedKeyMaterial"] = base64.b64encode(encrypted).decode()
|
||||||
|
keys_data.append(data)
|
||||||
|
|
||||||
|
self.keys_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.keys_path.write_text(
|
||||||
|
json.dumps({"keys": keys_data}, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _encrypt_key_material(self, key_material: bytes) -> bytes:
|
||||||
|
"""Encrypt key material with the master key."""
|
||||||
|
aesgcm = AESGCM(self.master_key)
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, key_material, None)
|
||||||
|
return nonce + ciphertext
|
||||||
|
|
||||||
|
def _decrypt_key_material(self, encrypted: bytes) -> bytes:
|
||||||
|
"""Decrypt key material with the master key."""
|
||||||
|
aesgcm = AESGCM(self.master_key)
|
||||||
|
nonce = encrypted[:12]
|
||||||
|
ciphertext = encrypted[12:]
|
||||||
|
return aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
|
||||||
|
def create_key(self, description: str = "", key_id: str | None = None) -> KMSKey:
|
||||||
|
"""Create a new KMS key."""
|
||||||
|
self._load_keys()
|
||||||
|
|
||||||
|
if key_id is None:
|
||||||
|
key_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
if key_id in self._keys:
|
||||||
|
raise EncryptionError(f"Key already exists: {key_id}")
|
||||||
|
|
||||||
|
key = KMSKey(
|
||||||
|
key_id=key_id,
|
||||||
|
description=description,
|
||||||
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
enabled=True,
|
||||||
|
key_material=secrets.token_bytes(32),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._keys[key_id] = key
|
||||||
|
self._save_keys()
|
||||||
|
return key
|
||||||
|
|
||||||
|
def get_key(self, key_id: str) -> KMSKey | None:
|
||||||
|
"""Get a key by ID."""
|
||||||
|
self._load_keys()
|
||||||
|
return self._keys.get(key_id)
|
||||||
|
|
||||||
|
def list_keys(self) -> List[KMSKey]:
|
||||||
|
"""List all keys."""
|
||||||
|
self._load_keys()
|
||||||
|
return list(self._keys.values())
|
||||||
|
|
||||||
|
def enable_key(self, key_id: str) -> None:
|
||||||
|
"""Enable a key."""
|
||||||
|
self._load_keys()
|
||||||
|
key = self._keys.get(key_id)
|
||||||
|
if not key:
|
||||||
|
raise EncryptionError(f"Key not found: {key_id}")
|
||||||
|
key.enabled = True
|
||||||
|
self._save_keys()
|
||||||
|
|
||||||
|
def disable_key(self, key_id: str) -> None:
|
||||||
|
"""Disable a key."""
|
||||||
|
self._load_keys()
|
||||||
|
key = self._keys.get(key_id)
|
||||||
|
if not key:
|
||||||
|
raise EncryptionError(f"Key not found: {key_id}")
|
||||||
|
key.enabled = False
|
||||||
|
self._save_keys()
|
||||||
|
|
||||||
|
def delete_key(self, key_id: str) -> None:
|
||||||
|
"""Delete a key (schedule for deletion in real KMS)."""
|
||||||
|
self._load_keys()
|
||||||
|
if key_id not in self._keys:
|
||||||
|
raise EncryptionError(f"Key not found: {key_id}")
|
||||||
|
del self._keys[key_id]
|
||||||
|
self._save_keys()
|
||||||
|
|
||||||
|
def encrypt(self, key_id: str, plaintext: bytes,
|
||||||
|
context: Dict[str, str] | None = None) -> bytes:
|
||||||
|
"""Encrypt data directly with a KMS key."""
|
||||||
|
self._load_keys()
|
||||||
|
key = self._keys.get(key_id)
|
||||||
|
if not key:
|
||||||
|
raise EncryptionError(f"Key not found: {key_id}")
|
||||||
|
if not key.enabled:
|
||||||
|
raise EncryptionError(f"Key is disabled: {key_id}")
|
||||||
|
|
||||||
|
aesgcm = AESGCM(key.key_material)
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
aad = json.dumps(context).encode() if context else None
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
|
||||||
|
|
||||||
|
key_id_bytes = key_id.encode("utf-8")
|
||||||
|
return len(key_id_bytes).to_bytes(2, "big") + key_id_bytes + nonce + ciphertext
|
||||||
|
|
||||||
|
def decrypt(self, ciphertext: bytes,
|
||||||
|
context: Dict[str, str] | None = None) -> tuple[bytes, str]:
|
||||||
|
"""Decrypt data directly with a KMS key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (plaintext, key_id)
|
||||||
|
"""
|
||||||
|
self._load_keys()
|
||||||
|
|
||||||
|
key_id_len = int.from_bytes(ciphertext[:2], "big")
|
||||||
|
key_id = ciphertext[2:2 + key_id_len].decode("utf-8")
|
||||||
|
rest = ciphertext[2 + key_id_len:]
|
||||||
|
|
||||||
|
key = self._keys.get(key_id)
|
||||||
|
if not key:
|
||||||
|
raise EncryptionError(f"Key not found: {key_id}")
|
||||||
|
if not key.enabled:
|
||||||
|
raise EncryptionError(f"Key is disabled: {key_id}")
|
||||||
|
|
||||||
|
nonce = rest[:12]
|
||||||
|
encrypted = rest[12:]
|
||||||
|
|
||||||
|
aesgcm = AESGCM(key.key_material)
|
||||||
|
aad = json.dumps(context).encode() if context else None
|
||||||
|
try:
|
||||||
|
plaintext = aesgcm.decrypt(nonce, encrypted, aad)
|
||||||
|
return plaintext, key_id
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Decryption failed: {exc}") from exc
|
||||||
|
|
||||||
|
def generate_data_key(self, key_id: str,
|
||||||
|
context: Dict[str, str] | None = None) -> tuple[bytes, bytes]:
|
||||||
|
"""Generate a data key and return both plaintext and encrypted versions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (plaintext_key, encrypted_key)
|
||||||
|
"""
|
||||||
|
self._load_keys()
|
||||||
|
key = self._keys.get(key_id)
|
||||||
|
if not key:
|
||||||
|
raise EncryptionError(f"Key not found: {key_id}")
|
||||||
|
if not key.enabled:
|
||||||
|
raise EncryptionError(f"Key is disabled: {key_id}")
|
||||||
|
|
||||||
|
plaintext_key = secrets.token_bytes(32)
|
||||||
|
|
||||||
|
encrypted_key = self.encrypt(key_id, plaintext_key, context)
|
||||||
|
|
||||||
|
return plaintext_key, encrypted_key
|
||||||
|
|
||||||
|
def decrypt_data_key(self, key_id: str, encrypted_key: bytes,
|
||||||
|
context: Dict[str, str] | None = None) -> bytes:
|
||||||
|
"""Decrypt a data key."""
|
||||||
|
plaintext, _ = self.decrypt(encrypted_key, context)
|
||||||
|
return plaintext
|
||||||
|
|
||||||
|
def get_provider(self, key_id: str | None = None) -> KMSEncryptionProvider:
|
||||||
|
"""Get an encryption provider for a specific key."""
|
||||||
|
self._load_keys()
|
||||||
|
|
||||||
|
if key_id is None:
|
||||||
|
if not self._keys:
|
||||||
|
key = self.create_key("Default KMS Key")
|
||||||
|
key_id = key.key_id
|
||||||
|
else:
|
||||||
|
key_id = next(iter(self._keys.keys()))
|
||||||
|
|
||||||
|
if key_id not in self._keys:
|
||||||
|
raise EncryptionError(f"Key not found: {key_id}")
|
||||||
|
|
||||||
|
return KMSEncryptionProvider(self, key_id)
|
||||||
|
|
||||||
|
def re_encrypt(self, ciphertext: bytes, destination_key_id: str,
|
||||||
|
source_context: Dict[str, str] | None = None,
|
||||||
|
destination_context: Dict[str, str] | None = None) -> bytes:
|
||||||
|
"""Re-encrypt data with a different key."""
|
||||||
|
|
||||||
|
plaintext, source_key_id = self.decrypt(ciphertext, source_context)
|
||||||
|
|
||||||
|
return self.encrypt(destination_key_id, plaintext, destination_context)
|
||||||
|
|
||||||
|
def generate_random(self, num_bytes: int = 32) -> bytes:
|
||||||
|
"""Generate cryptographically secure random bytes."""
|
||||||
|
if num_bytes < 1 or num_bytes > 1024:
|
||||||
|
raise EncryptionError("Number of bytes must be between 1 and 1024")
|
||||||
|
return secrets.token_bytes(num_bytes)
|
||||||
445
app/kms_api.py
Normal file
445
app/kms_api.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
"""KMS and encryption API endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, current_app, jsonify, request
|
||||||
|
|
||||||
|
from .encryption import ClientEncryptionHelper, EncryptionError
|
||||||
|
from .extensions import limiter
|
||||||
|
from .iam import IamError
|
||||||
|
|
||||||
|
kms_api_bp = Blueprint("kms_api", __name__, url_prefix="/kms")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_principal():
|
||||||
|
"""Require authentication for KMS operations."""
|
||||||
|
from .s3_api import _require_principal as s3_require_principal
|
||||||
|
return s3_require_principal()
|
||||||
|
|
||||||
|
|
||||||
|
def _kms():
|
||||||
|
"""Get KMS manager from app extensions."""
|
||||||
|
return current_app.extensions.get("kms")
|
||||||
|
|
||||||
|
|
||||||
|
def _encryption():
|
||||||
|
"""Get encryption manager from app extensions."""
|
||||||
|
return current_app.extensions.get("encryption")
|
||||||
|
|
||||||
|
|
||||||
|
def _error_response(code: str, message: str, status: int) -> tuple[Dict[str, Any], int]:
|
||||||
|
return {"__type": code, "message": message}, status
|
||||||
|
|
||||||
|
@kms_api_bp.route("/keys", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("30 per minute")
|
||||||
|
def list_or_create_keys():
|
||||||
|
"""List all KMS keys or create a new key."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
key_id = payload.get("KeyId") or payload.get("key_id")
|
||||||
|
description = payload.get("Description") or payload.get("description", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = kms.create_key(description=description, key_id=key_id)
|
||||||
|
current_app.logger.info(
|
||||||
|
"KMS key created",
|
||||||
|
extra={"key_id": key.key_id, "principal": principal.access_key},
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
"KeyMetadata": key.to_dict(),
|
||||||
|
})
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("KMSInternalException", str(exc), 400)
|
||||||
|
|
||||||
|
keys = kms.list_keys()
|
||||||
|
return jsonify({
|
||||||
|
"Keys": [{"KeyId": k.key_id, "KeyArn": k.arn} for k in keys],
|
||||||
|
"Truncated": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@kms_api_bp.route("/keys/<key_id>", methods=["GET", "DELETE"])
|
||||||
|
@limiter.limit("30 per minute")
|
||||||
|
def get_or_delete_key(key_id: str):
|
||||||
|
"""Get or delete a specific KMS key."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
if request.method == "DELETE":
|
||||||
|
try:
|
||||||
|
kms.delete_key(key_id)
|
||||||
|
current_app.logger.info(
|
||||||
|
"KMS key deleted",
|
||||||
|
extra={"key_id": key_id, "principal": principal.access_key},
|
||||||
|
)
|
||||||
|
return Response(status=204)
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("NotFoundException", str(exc), 404)
|
||||||
|
|
||||||
|
key = kms.get_key(key_id)
|
||||||
|
if not key:
|
||||||
|
return _error_response("NotFoundException", f"Key not found: {key_id}", 404)
|
||||||
|
|
||||||
|
return jsonify({"KeyMetadata": key.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@kms_api_bp.route("/keys/<key_id>/enable", methods=["POST"])
|
||||||
|
@limiter.limit("30 per minute")
|
||||||
|
def enable_key(key_id: str):
|
||||||
|
"""Enable a KMS key."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
kms.enable_key(key_id)
|
||||||
|
current_app.logger.info(
|
||||||
|
"KMS key enabled",
|
||||||
|
extra={"key_id": key_id, "principal": principal.access_key},
|
||||||
|
)
|
||||||
|
return Response(status=200)
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("NotFoundException", str(exc), 404)
|
||||||
|
|
||||||
|
|
||||||
|
@kms_api_bp.route("/keys/<key_id>/disable", methods=["POST"])
|
||||||
|
@limiter.limit("30 per minute")
|
||||||
|
def disable_key(key_id: str):
|
||||||
|
"""Disable a KMS key."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
kms.disable_key(key_id)
|
||||||
|
current_app.logger.info(
|
||||||
|
"KMS key disabled",
|
||||||
|
extra={"key_id": key_id, "principal": principal.access_key},
|
||||||
|
)
|
||||||
|
return Response(status=200)
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("NotFoundException", str(exc), 404)
|
||||||
|
|
||||||
|
@kms_api_bp.route("/encrypt", methods=["POST"])
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
def encrypt_data():
|
||||||
|
"""Encrypt data using a KMS key."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
key_id = payload.get("KeyId")
|
||||||
|
plaintext_b64 = payload.get("Plaintext")
|
||||||
|
context = payload.get("EncryptionContext")
|
||||||
|
|
||||||
|
if not key_id:
|
||||||
|
return _error_response("ValidationException", "KeyId is required", 400)
|
||||||
|
if not plaintext_b64:
|
||||||
|
return _error_response("ValidationException", "Plaintext is required", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plaintext = base64.b64decode(plaintext_b64)
|
||||||
|
except Exception:
|
||||||
|
return _error_response("ValidationException", "Plaintext must be base64 encoded", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ciphertext = kms.encrypt(key_id, plaintext, context)
|
||||||
|
return jsonify({
|
||||||
|
"CiphertextBlob": base64.b64encode(ciphertext).decode(),
|
||||||
|
"KeyId": key_id,
|
||||||
|
"EncryptionAlgorithm": "SYMMETRIC_DEFAULT",
|
||||||
|
})
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("KMSInternalException", str(exc), 400)
|
||||||
|
|
||||||
|
|
||||||
|
@kms_api_bp.route("/decrypt", methods=["POST"])
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
def decrypt_data():
|
||||||
|
"""Decrypt data using a KMS key."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
ciphertext_b64 = payload.get("CiphertextBlob")
|
||||||
|
context = payload.get("EncryptionContext")
|
||||||
|
|
||||||
|
if not ciphertext_b64:
|
||||||
|
return _error_response("ValidationException", "CiphertextBlob is required", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ciphertext = base64.b64decode(ciphertext_b64)
|
||||||
|
except Exception:
|
||||||
|
return _error_response("ValidationException", "CiphertextBlob must be base64 encoded", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plaintext, key_id = kms.decrypt(ciphertext, context)
|
||||||
|
return jsonify({
|
||||||
|
"Plaintext": base64.b64encode(plaintext).decode(),
|
||||||
|
"KeyId": key_id,
|
||||||
|
"EncryptionAlgorithm": "SYMMETRIC_DEFAULT",
|
||||||
|
})
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("InvalidCiphertextException", str(exc), 400)
|
||||||
|
|
||||||
|
|
||||||
|
@kms_api_bp.route("/generate-data-key", methods=["POST"])
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
def generate_data_key():
|
||||||
|
"""Generate a data encryption key."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
key_id = payload.get("KeyId")
|
||||||
|
context = payload.get("EncryptionContext")
|
||||||
|
key_spec = payload.get("KeySpec", "AES_256")
|
||||||
|
|
||||||
|
if not key_id:
|
||||||
|
return _error_response("ValidationException", "KeyId is required", 400)
|
||||||
|
|
||||||
|
if key_spec not in {"AES_256", "AES_128"}:
|
||||||
|
return _error_response("ValidationException", "KeySpec must be AES_256 or AES_128", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plaintext_key, encrypted_key = kms.generate_data_key(key_id, context)
|
||||||
|
|
||||||
|
if key_spec == "AES_128":
|
||||||
|
plaintext_key = plaintext_key[:16]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"Plaintext": base64.b64encode(plaintext_key).decode(),
|
||||||
|
"CiphertextBlob": base64.b64encode(encrypted_key).decode(),
|
||||||
|
"KeyId": key_id,
|
||||||
|
})
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("KMSInternalException", str(exc), 400)
|
||||||
|
|
||||||
|
|
||||||
|
@kms_api_bp.route("/generate-data-key-without-plaintext", methods=["POST"])
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
def generate_data_key_without_plaintext():
|
||||||
|
"""Generate a data encryption key without returning the plaintext."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
key_id = payload.get("KeyId")
|
||||||
|
context = payload.get("EncryptionContext")
|
||||||
|
|
||||||
|
if not key_id:
|
||||||
|
return _error_response("ValidationException", "KeyId is required", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, encrypted_key = kms.generate_data_key(key_id, context)
|
||||||
|
return jsonify({
|
||||||
|
"CiphertextBlob": base64.b64encode(encrypted_key).decode(),
|
||||||
|
"KeyId": key_id,
|
||||||
|
})
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("KMSInternalException", str(exc), 400)
|
||||||
|
|
||||||
|
|
||||||
|
@kms_api_bp.route("/re-encrypt", methods=["POST"])
|
||||||
|
@limiter.limit("30 per minute")
|
||||||
|
def re_encrypt():
|
||||||
|
"""Re-encrypt data with a different key."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
ciphertext_b64 = payload.get("CiphertextBlob")
|
||||||
|
destination_key_id = payload.get("DestinationKeyId")
|
||||||
|
source_context = payload.get("SourceEncryptionContext")
|
||||||
|
destination_context = payload.get("DestinationEncryptionContext")
|
||||||
|
|
||||||
|
if not ciphertext_b64:
|
||||||
|
return _error_response("ValidationException", "CiphertextBlob is required", 400)
|
||||||
|
if not destination_key_id:
|
||||||
|
return _error_response("ValidationException", "DestinationKeyId is required", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ciphertext = base64.b64decode(ciphertext_b64)
|
||||||
|
except Exception:
|
||||||
|
return _error_response("ValidationException", "CiphertextBlob must be base64 encoded", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plaintext, source_key_id = kms.decrypt(ciphertext, source_context)
|
||||||
|
new_ciphertext = kms.encrypt(destination_key_id, plaintext, destination_context)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"CiphertextBlob": base64.b64encode(new_ciphertext).decode(),
|
||||||
|
"SourceKeyId": source_key_id,
|
||||||
|
"KeyId": destination_key_id,
|
||||||
|
})
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("KMSInternalException", str(exc), 400)
|
||||||
|
|
||||||
|
|
||||||
|
@kms_api_bp.route("/generate-random", methods=["POST"])
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
def generate_random():
|
||||||
|
"""Generate random bytes."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
num_bytes = payload.get("NumberOfBytes", 32)
|
||||||
|
|
||||||
|
try:
|
||||||
|
num_bytes = int(num_bytes)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _error_response("ValidationException", "NumberOfBytes must be an integer", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
random_bytes = kms.generate_random(num_bytes)
|
||||||
|
return jsonify({
|
||||||
|
"Plaintext": base64.b64encode(random_bytes).decode(),
|
||||||
|
})
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("ValidationException", str(exc), 400)
|
||||||
|
|
||||||
|
@kms_api_bp.route("/client/generate-key", methods=["POST"])
|
||||||
|
@limiter.limit("30 per minute")
|
||||||
|
def generate_client_key():
|
||||||
|
"""Generate a client-side encryption key."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
key_info = ClientEncryptionHelper.generate_client_key()
|
||||||
|
return jsonify(key_info)
|
||||||
|
|
||||||
|
|
||||||
|
@kms_api_bp.route("/client/encrypt", methods=["POST"])
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
def client_encrypt():
|
||||||
|
"""Encrypt data using client-side encryption."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
plaintext_b64 = payload.get("Plaintext")
|
||||||
|
key_b64 = payload.get("Key")
|
||||||
|
|
||||||
|
if not plaintext_b64 or not key_b64:
|
||||||
|
return _error_response("ValidationException", "Plaintext and Key are required", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plaintext = base64.b64decode(plaintext_b64)
|
||||||
|
result = ClientEncryptionHelper.encrypt_with_key(plaintext, key_b64)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as exc:
|
||||||
|
return _error_response("EncryptionError", str(exc), 400)
|
||||||
|
|
||||||
|
|
||||||
|
@kms_api_bp.route("/client/decrypt", methods=["POST"])
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
def client_decrypt():
|
||||||
|
"""Decrypt data using client-side encryption."""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
ciphertext_b64 = payload.get("Ciphertext") or payload.get("ciphertext")
|
||||||
|
nonce_b64 = payload.get("Nonce") or payload.get("nonce")
|
||||||
|
key_b64 = payload.get("Key") or payload.get("key")
|
||||||
|
|
||||||
|
if not ciphertext_b64 or not nonce_b64 or not key_b64:
|
||||||
|
return _error_response("ValidationException", "Ciphertext, Nonce, and Key are required", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plaintext = ClientEncryptionHelper.decrypt_with_key(ciphertext_b64, nonce_b64, key_b64)
|
||||||
|
return jsonify({
|
||||||
|
"Plaintext": base64.b64encode(plaintext).decode(),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
return _error_response("DecryptionError", str(exc), 400)
|
||||||
|
|
||||||
|
@kms_api_bp.route("/materials/<key_id>", methods=["POST"])
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
def get_encryption_materials(key_id: str):
|
||||||
|
"""Get encryption materials for client-side S3 encryption.
|
||||||
|
|
||||||
|
This is used by S3 encryption clients that want to use KMS for
|
||||||
|
key management but perform encryption client-side.
|
||||||
|
"""
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
kms = _kms()
|
||||||
|
if not kms:
|
||||||
|
return _error_response("KMSNotEnabled", "KMS is not configured", 400)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
context = payload.get("EncryptionContext")
|
||||||
|
|
||||||
|
try:
|
||||||
|
plaintext_key, encrypted_key = kms.generate_data_key(key_id, context)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"PlaintextKey": base64.b64encode(plaintext_key).decode(),
|
||||||
|
"EncryptedKey": base64.b64encode(encrypted_key).decode(),
|
||||||
|
"KeyId": key_id,
|
||||||
|
"Algorithm": "AES-256-GCM",
|
||||||
|
"KeyWrapAlgorithm": "kms",
|
||||||
|
})
|
||||||
|
except EncryptionError as exc:
|
||||||
|
return _error_response("KMSInternalException", str(exc), 400)
|
||||||
@@ -22,6 +22,8 @@ from .storage import ObjectStorage, StorageError
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REPLICATION_USER_AGENT = "S3ReplicationAgent/1.0"
|
REPLICATION_USER_AGENT = "S3ReplicationAgent/1.0"
|
||||||
|
REPLICATION_CONNECT_TIMEOUT = 5
|
||||||
|
REPLICATION_READ_TIMEOUT = 30
|
||||||
|
|
||||||
REPLICATION_MODE_NEW_ONLY = "new_only"
|
REPLICATION_MODE_NEW_ONLY = "new_only"
|
||||||
REPLICATION_MODE_ALL = "all"
|
REPLICATION_MODE_ALL = "all"
|
||||||
@@ -30,10 +32,10 @@ REPLICATION_MODE_ALL = "all"
|
|||||||
@dataclass
|
@dataclass
|
||||||
class ReplicationStats:
|
class ReplicationStats:
|
||||||
"""Statistics for replication operations - computed dynamically."""
|
"""Statistics for replication operations - computed dynamically."""
|
||||||
objects_synced: int = 0 # Objects that exist in both source and destination
|
objects_synced: int = 0
|
||||||
objects_pending: int = 0 # Objects in source but not in destination
|
objects_pending: int = 0
|
||||||
objects_orphaned: int = 0 # Objects in destination but not in source (will be deleted)
|
objects_orphaned: int = 0
|
||||||
bytes_synced: int = 0 # Total bytes synced to destination
|
bytes_synced: int = 0
|
||||||
last_sync_at: Optional[float] = None
|
last_sync_at: Optional[float] = None
|
||||||
last_sync_key: Optional[str] = None
|
last_sync_key: Optional[str] = None
|
||||||
|
|
||||||
@@ -83,7 +85,6 @@ class ReplicationRule:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "ReplicationRule":
|
def from_dict(cls, data: dict) -> "ReplicationRule":
|
||||||
stats_data = data.pop("stats", {})
|
stats_data = data.pop("stats", {})
|
||||||
# Handle old rules without mode/created_at
|
|
||||||
if "mode" not in data:
|
if "mode" not in data:
|
||||||
data["mode"] = REPLICATION_MODE_NEW_ONLY
|
data["mode"] = REPLICATION_MODE_NEW_ONLY
|
||||||
if "created_at" not in data:
|
if "created_at" not in data:
|
||||||
@@ -121,6 +122,33 @@ class ReplicationManager:
|
|||||||
with open(self.rules_path, "w") as f:
|
with open(self.rules_path, "w") as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
def check_endpoint_health(self, connection: RemoteConnection) -> bool:
|
||||||
|
"""Check if a remote endpoint is reachable and responsive.
|
||||||
|
|
||||||
|
Returns True if endpoint is healthy, False otherwise.
|
||||||
|
Uses short timeouts to prevent blocking.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config = Config(
|
||||||
|
user_agent_extra=REPLICATION_USER_AGENT,
|
||||||
|
connect_timeout=REPLICATION_CONNECT_TIMEOUT,
|
||||||
|
read_timeout=REPLICATION_READ_TIMEOUT,
|
||||||
|
retries={'max_attempts': 1}
|
||||||
|
)
|
||||||
|
s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=connection.endpoint_url,
|
||||||
|
aws_access_key_id=connection.access_key,
|
||||||
|
aws_secret_access_key=connection.secret_key,
|
||||||
|
region_name=connection.region,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
s3.list_buckets()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Endpoint health check failed for {connection.name} ({connection.endpoint_url}): {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def get_rule(self, bucket_name: str) -> Optional[ReplicationRule]:
|
def get_rule(self, bucket_name: str) -> Optional[ReplicationRule]:
|
||||||
return self._rules.get(bucket_name)
|
return self._rules.get(bucket_name)
|
||||||
|
|
||||||
@@ -151,14 +179,12 @@ class ReplicationManager:
|
|||||||
|
|
||||||
connection = self.connections.get(rule.target_connection_id)
|
connection = self.connections.get(rule.target_connection_id)
|
||||||
if not connection:
|
if not connection:
|
||||||
return rule.stats # Return cached stats if connection unavailable
|
return rule.stats
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get source objects
|
source_objects = self.storage.list_objects_all(bucket_name)
|
||||||
source_objects = self.storage.list_objects(bucket_name)
|
|
||||||
source_keys = {obj.key: obj.size for obj in source_objects}
|
source_keys = {obj.key: obj.size for obj in source_objects}
|
||||||
|
|
||||||
# Get destination objects
|
|
||||||
s3 = boto3.client(
|
s3 = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
endpoint_url=connection.endpoint_url,
|
endpoint_url=connection.endpoint_url,
|
||||||
@@ -178,24 +204,18 @@ class ReplicationManager:
|
|||||||
bytes_synced += obj.get('Size', 0)
|
bytes_synced += obj.get('Size', 0)
|
||||||
except ClientError as e:
|
except ClientError as e:
|
||||||
if e.response['Error']['Code'] == 'NoSuchBucket':
|
if e.response['Error']['Code'] == 'NoSuchBucket':
|
||||||
# Destination bucket doesn't exist yet
|
|
||||||
dest_keys = set()
|
dest_keys = set()
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Compute stats
|
synced = source_keys.keys() & dest_keys
|
||||||
synced = source_keys.keys() & dest_keys # Objects in both
|
orphaned = dest_keys - source_keys.keys()
|
||||||
orphaned = dest_keys - source_keys.keys() # In dest but not source
|
|
||||||
|
|
||||||
# For "new_only" mode, we can't determine pending since we don't know
|
|
||||||
# which objects existed before replication was enabled. Only "all" mode
|
|
||||||
# should show pending (objects that should be replicated but aren't yet).
|
|
||||||
if rule.mode == REPLICATION_MODE_ALL:
|
if rule.mode == REPLICATION_MODE_ALL:
|
||||||
pending = source_keys.keys() - dest_keys # In source but not dest
|
pending = source_keys.keys() - dest_keys
|
||||||
else:
|
else:
|
||||||
pending = set() # New-only mode: don't show pre-existing as pending
|
pending = set()
|
||||||
|
|
||||||
# Update cached stats with computed values
|
|
||||||
rule.stats.objects_synced = len(synced)
|
rule.stats.objects_synced = len(synced)
|
||||||
rule.stats.objects_pending = len(pending)
|
rule.stats.objects_pending = len(pending)
|
||||||
rule.stats.objects_orphaned = len(orphaned)
|
rule.stats.objects_orphaned = len(orphaned)
|
||||||
@@ -205,7 +225,7 @@ class ReplicationManager:
|
|||||||
|
|
||||||
except (ClientError, StorageError) as e:
|
except (ClientError, StorageError) as e:
|
||||||
logger.error(f"Failed to compute sync status for {bucket_name}: {e}")
|
logger.error(f"Failed to compute sync status for {bucket_name}: {e}")
|
||||||
return rule.stats # Return cached stats on error
|
return rule.stats
|
||||||
|
|
||||||
def replicate_existing_objects(self, bucket_name: str) -> None:
|
def replicate_existing_objects(self, bucket_name: str) -> None:
|
||||||
"""Trigger replication for all existing objects in a bucket."""
|
"""Trigger replication for all existing objects in a bucket."""
|
||||||
@@ -218,8 +238,12 @@ class ReplicationManager:
|
|||||||
logger.warning(f"Cannot replicate existing objects: Connection {rule.target_connection_id} not found")
|
logger.warning(f"Cannot replicate existing objects: Connection {rule.target_connection_id} not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not self.check_endpoint_health(connection):
|
||||||
|
logger.warning(f"Cannot replicate existing objects: Endpoint {connection.name} ({connection.endpoint_url}) is not reachable")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
objects = self.storage.list_objects(bucket_name)
|
objects = self.storage.list_objects_all(bucket_name)
|
||||||
logger.info(f"Starting replication of {len(objects)} existing objects from {bucket_name}")
|
logger.info(f"Starting replication of {len(objects)} existing objects from {bucket_name}")
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
self._executor.submit(self._replicate_task, bucket_name, obj.key, rule, connection, "write")
|
self._executor.submit(self._replicate_task, bucket_name, obj.key, rule, connection, "write")
|
||||||
@@ -255,6 +279,10 @@ class ReplicationManager:
|
|||||||
logger.warning(f"Replication skipped for {bucket_name}/{object_key}: Connection {rule.target_connection_id} not found")
|
logger.warning(f"Replication skipped for {bucket_name}/{object_key}: Connection {rule.target_connection_id} not found")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not self.check_endpoint_health(connection):
|
||||||
|
logger.warning(f"Replication skipped for {bucket_name}/{object_key}: Endpoint {connection.name} ({connection.endpoint_url}) is not reachable")
|
||||||
|
return
|
||||||
|
|
||||||
self._executor.submit(self._replicate_task, bucket_name, object_key, rule, connection, action)
|
self._executor.submit(self._replicate_task, bucket_name, object_key, rule, connection, action)
|
||||||
|
|
||||||
def _replicate_task(self, bucket_name: str, object_key: str, rule: ReplicationRule, conn: RemoteConnection, action: str) -> None:
|
def _replicate_task(self, bucket_name: str, object_key: str, rule: ReplicationRule, conn: RemoteConnection, action: str) -> None:
|
||||||
@@ -271,13 +299,26 @@ class ReplicationManager:
|
|||||||
|
|
||||||
file_size = 0
|
file_size = 0
|
||||||
try:
|
try:
|
||||||
config = Config(user_agent_extra=REPLICATION_USER_AGENT)
|
config = Config(
|
||||||
|
user_agent_extra=REPLICATION_USER_AGENT,
|
||||||
|
connect_timeout=REPLICATION_CONNECT_TIMEOUT,
|
||||||
|
read_timeout=REPLICATION_READ_TIMEOUT,
|
||||||
|
retries={'max_attempts': 2},
|
||||||
|
signature_version='s3v4',
|
||||||
|
s3={
|
||||||
|
'addressing_style': 'path',
|
||||||
|
},
|
||||||
|
# Disable SDK automatic checksums - they cause SignatureDoesNotMatch errors
|
||||||
|
# with S3-compatible servers that don't support CRC32 checksum headers
|
||||||
|
request_checksum_calculation='when_required',
|
||||||
|
response_checksum_validation='when_required',
|
||||||
|
)
|
||||||
s3 = boto3.client(
|
s3 = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
endpoint_url=conn.endpoint_url,
|
endpoint_url=conn.endpoint_url,
|
||||||
aws_access_key_id=conn.access_key,
|
aws_access_key_id=conn.access_key,
|
||||||
aws_secret_access_key=conn.secret_key,
|
aws_secret_access_key=conn.secret_key,
|
||||||
region_name=conn.region,
|
region_name=conn.region or 'us-east-1',
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -296,54 +337,59 @@ class ReplicationManager:
|
|||||||
logger.error(f"Source object not found: {bucket_name}/{object_key}")
|
logger.error(f"Source object not found: {bucket_name}/{object_key}")
|
||||||
return
|
return
|
||||||
|
|
||||||
metadata = self.storage.get_object_metadata(bucket_name, object_key)
|
# Don't replicate metadata - destination server will generate its own
|
||||||
|
# __etag__ and __size__. Replicating them causes signature mismatches when they have None/empty values.
|
||||||
|
|
||||||
extra_args = {}
|
|
||||||
if metadata:
|
|
||||||
extra_args["Metadata"] = metadata
|
|
||||||
|
|
||||||
# Guess content type to prevent corruption/wrong handling
|
|
||||||
content_type, _ = mimetypes.guess_type(path)
|
content_type, _ = mimetypes.guess_type(path)
|
||||||
file_size = path.stat().st_size
|
file_size = path.stat().st_size
|
||||||
|
|
||||||
logger.info(f"Replicating {bucket_name}/{object_key}: Size={file_size}, ContentType={content_type}")
|
logger.info(f"Replicating {bucket_name}/{object_key}: Size={file_size}, ContentType={content_type}")
|
||||||
|
|
||||||
|
def do_put_object() -> None:
|
||||||
|
"""Helper to upload object.
|
||||||
|
|
||||||
|
Reads the file content into memory first to avoid signature calculation
|
||||||
|
issues with certain binary file types (like GIFs) when streaming.
|
||||||
|
Do NOT set ContentLength explicitly - boto3 calculates it from the bytes
|
||||||
|
and setting it manually can cause SignatureDoesNotMatch errors.
|
||||||
|
"""
|
||||||
|
file_content = path.read_bytes()
|
||||||
|
put_kwargs = {
|
||||||
|
"Bucket": rule.target_bucket,
|
||||||
|
"Key": object_key,
|
||||||
|
"Body": file_content,
|
||||||
|
}
|
||||||
|
if content_type:
|
||||||
|
put_kwargs["ContentType"] = content_type
|
||||||
|
s3.put_object(**put_kwargs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with path.open("rb") as f:
|
do_put_object()
|
||||||
s3.put_object(
|
|
||||||
Bucket=rule.target_bucket,
|
|
||||||
Key=object_key,
|
|
||||||
Body=f,
|
|
||||||
ContentLength=file_size,
|
|
||||||
ContentType=content_type or "application/octet-stream",
|
|
||||||
Metadata=metadata or {}
|
|
||||||
)
|
|
||||||
except (ClientError, S3UploadFailedError) as e:
|
except (ClientError, S3UploadFailedError) as e:
|
||||||
is_no_bucket = False
|
error_code = None
|
||||||
if isinstance(e, ClientError):
|
if isinstance(e, ClientError):
|
||||||
if e.response['Error']['Code'] == 'NoSuchBucket':
|
error_code = e.response['Error']['Code']
|
||||||
is_no_bucket = True
|
|
||||||
elif isinstance(e, S3UploadFailedError):
|
elif isinstance(e, S3UploadFailedError):
|
||||||
if "NoSuchBucket" in str(e):
|
if "NoSuchBucket" in str(e):
|
||||||
is_no_bucket = True
|
error_code = 'NoSuchBucket'
|
||||||
|
|
||||||
if is_no_bucket:
|
if error_code == 'NoSuchBucket':
|
||||||
logger.info(f"Target bucket {rule.target_bucket} not found. Attempting to create it.")
|
logger.info(f"Target bucket {rule.target_bucket} not found. Attempting to create it.")
|
||||||
|
bucket_ready = False
|
||||||
try:
|
try:
|
||||||
s3.create_bucket(Bucket=rule.target_bucket)
|
s3.create_bucket(Bucket=rule.target_bucket)
|
||||||
# Retry upload
|
bucket_ready = True
|
||||||
with path.open("rb") as f:
|
logger.info(f"Created target bucket {rule.target_bucket}")
|
||||||
s3.put_object(
|
except ClientError as bucket_err:
|
||||||
Bucket=rule.target_bucket,
|
if bucket_err.response['Error']['Code'] in ('BucketAlreadyExists', 'BucketAlreadyOwnedByYou'):
|
||||||
Key=object_key,
|
logger.debug(f"Bucket {rule.target_bucket} already exists (created by another thread)")
|
||||||
Body=f,
|
bucket_ready = True
|
||||||
ContentLength=file_size,
|
else:
|
||||||
ContentType=content_type or "application/octet-stream",
|
logger.error(f"Failed to create target bucket {rule.target_bucket}: {bucket_err}")
|
||||||
Metadata=metadata or {}
|
raise e
|
||||||
)
|
|
||||||
except Exception as create_err:
|
if bucket_ready:
|
||||||
logger.error(f"Failed to create target bucket {rule.target_bucket}: {create_err}")
|
do_put_object()
|
||||||
raise e # Raise original error
|
|
||||||
else:
|
else:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
@@ -354,3 +400,4 @@ class ReplicationManager:
|
|||||||
logger.error(f"Replication failed for {bucket_name}/{object_key}: {e}")
|
logger.error(f"Replication failed for {bucket_name}/{object_key}: {e}")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Unexpected error during replication for {bucket_name}/{object_key}")
|
logger.exception(f"Unexpected error during replication for {bucket_name}/{object_key}")
|
||||||
|
|
||||||
|
|||||||
260
app/s3_api.py
260
app/s3_api.py
@@ -8,7 +8,7 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
from urllib.parse import quote, urlencode, urlparse
|
from urllib.parse import quote, urlencode, urlparse, unquote
|
||||||
from xml.etree.ElementTree import Element, SubElement, tostring, fromstring, ParseError
|
from xml.etree.ElementTree import Element, SubElement, tostring, fromstring, ParseError
|
||||||
|
|
||||||
from flask import Blueprint, Response, current_app, jsonify, request, g
|
from flask import Blueprint, Response, current_app, jsonify, request, g
|
||||||
@@ -18,12 +18,10 @@ from .bucket_policies import BucketPolicyStore
|
|||||||
from .extensions import limiter
|
from .extensions import limiter
|
||||||
from .iam import IamError, Principal
|
from .iam import IamError, Principal
|
||||||
from .replication import ReplicationManager
|
from .replication import ReplicationManager
|
||||||
from .storage import ObjectStorage, StorageError
|
from .storage import ObjectStorage, StorageError, QuotaExceededError
|
||||||
|
|
||||||
s3_api_bp = Blueprint("s3_api", __name__)
|
s3_api_bp = Blueprint("s3_api", __name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------- helpers ----------------------
|
|
||||||
def _storage() -> ObjectStorage:
|
def _storage() -> ObjectStorage:
|
||||||
return current_app.extensions["object_storage"]
|
return current_app.extensions["object_storage"]
|
||||||
|
|
||||||
@@ -68,9 +66,26 @@ def _get_signature_key(key: str, date_stamp: str, region_name: str, service_name
|
|||||||
return k_signing
|
return k_signing
|
||||||
|
|
||||||
|
|
||||||
|
def _get_canonical_uri(req: Any) -> str:
|
||||||
|
"""Get the canonical URI for SigV4 signature verification.
|
||||||
|
|
||||||
|
AWS SigV4 requires the canonical URI to be URL-encoded exactly as the client
|
||||||
|
sent it. Flask/Werkzeug automatically URL-decodes request.path, so we need
|
||||||
|
to get the raw path from the environ.
|
||||||
|
|
||||||
|
The canonical URI should have each path segment URL-encoded (with '/' preserved),
|
||||||
|
and the encoding should match what the client used when signing.
|
||||||
|
"""
|
||||||
|
raw_uri = req.environ.get('RAW_URI') or req.environ.get('REQUEST_URI')
|
||||||
|
|
||||||
|
if raw_uri:
|
||||||
|
path = raw_uri.split('?')[0]
|
||||||
|
return path
|
||||||
|
|
||||||
|
return quote(req.path, safe="/-_.~")
|
||||||
|
|
||||||
|
|
||||||
def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
||||||
# Parse Authorization header
|
|
||||||
# AWS4-HMAC-SHA256 Credential=AKIA.../20230101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=...
|
|
||||||
match = re.match(
|
match = re.match(
|
||||||
r"AWS4-HMAC-SHA256 Credential=([^/]+)/([^/]+)/([^/]+)/([^/]+)/aws4_request, SignedHeaders=([^,]+), Signature=(.+)",
|
r"AWS4-HMAC-SHA256 Credential=([^/]+)/([^/]+)/([^/]+)/([^/]+)/aws4_request, SignedHeaders=([^,]+), Signature=(.+)",
|
||||||
auth_header,
|
auth_header,
|
||||||
@@ -79,17 +94,13 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
access_key, date_stamp, region, service, signed_headers_str, signature = match.groups()
|
access_key, date_stamp, region, service, signed_headers_str, signature = match.groups()
|
||||||
|
|
||||||
# Get secret key
|
|
||||||
secret_key = _iam().get_secret_key(access_key)
|
secret_key = _iam().get_secret_key(access_key)
|
||||||
if not secret_key:
|
if not secret_key:
|
||||||
raise IamError("Invalid access key")
|
raise IamError("Invalid access key")
|
||||||
|
|
||||||
# Canonical Request
|
|
||||||
method = req.method
|
method = req.method
|
||||||
canonical_uri = quote(req.path, safe="/-_.~")
|
canonical_uri = _get_canonical_uri(req)
|
||||||
|
|
||||||
# Canonical Query String
|
|
||||||
query_args = []
|
query_args = []
|
||||||
for key, value in req.args.items(multi=True):
|
for key, value in req.args.items(multi=True):
|
||||||
query_args.append((key, value))
|
query_args.append((key, value))
|
||||||
@@ -100,7 +111,6 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
|
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
|
||||||
canonical_query_string = "&".join(canonical_query_parts)
|
canonical_query_string = "&".join(canonical_query_parts)
|
||||||
|
|
||||||
# Canonical Headers
|
|
||||||
signed_headers_list = signed_headers_str.split(";")
|
signed_headers_list = signed_headers_str.split(";")
|
||||||
canonical_headers_parts = []
|
canonical_headers_parts = []
|
||||||
for header in signed_headers_list:
|
for header in signed_headers_list:
|
||||||
@@ -112,18 +122,22 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
canonical_headers_parts.append(f"{header.lower()}:{header_val}\n")
|
canonical_headers_parts.append(f"{header.lower()}:{header_val}\n")
|
||||||
canonical_headers = "".join(canonical_headers_parts)
|
canonical_headers = "".join(canonical_headers_parts)
|
||||||
|
|
||||||
# Payload Hash
|
|
||||||
payload_hash = req.headers.get("X-Amz-Content-Sha256")
|
payload_hash = req.headers.get("X-Amz-Content-Sha256")
|
||||||
if not payload_hash:
|
if not payload_hash:
|
||||||
payload_hash = hashlib.sha256(req.get_data()).hexdigest()
|
payload_hash = hashlib.sha256(req.get_data()).hexdigest()
|
||||||
|
|
||||||
canonical_request = f"{method}\n{canonical_uri}\n{canonical_query_string}\n{canonical_headers}\n{signed_headers_str}\n{payload_hash}"
|
canonical_request = f"{method}\n{canonical_uri}\n{canonical_query_string}\n{canonical_headers}\n{signed_headers_str}\n{payload_hash}"
|
||||||
|
|
||||||
# String to Sign
|
# Debug logging for signature issues
|
||||||
amz_date = req.headers.get("X-Amz-Date")
|
import logging
|
||||||
if not amz_date:
|
logger = logging.getLogger(__name__)
|
||||||
amz_date = req.headers.get("Date")
|
logger.debug(f"SigV4 Debug - Method: {method}, URI: {canonical_uri}")
|
||||||
|
logger.debug(f"SigV4 Debug - Payload hash from header: {req.headers.get('X-Amz-Content-Sha256')}")
|
||||||
|
logger.debug(f"SigV4 Debug - Signed headers: {signed_headers_str}")
|
||||||
|
logger.debug(f"SigV4 Debug - Content-Type: {req.headers.get('Content-Type')}")
|
||||||
|
logger.debug(f"SigV4 Debug - Content-Length: {req.headers.get('Content-Length')}")
|
||||||
|
|
||||||
|
amz_date = req.headers.get("X-Amz-Date") or req.headers.get("Date")
|
||||||
if not amz_date:
|
if not amz_date:
|
||||||
raise IamError("Missing Date header")
|
raise IamError("Missing Date header")
|
||||||
|
|
||||||
@@ -134,13 +148,12 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
time_diff = abs((now - request_time).total_seconds())
|
time_diff = abs((now - request_time).total_seconds())
|
||||||
if time_diff > 900: # 15 minutes
|
if time_diff > 900:
|
||||||
raise IamError("Request timestamp too old or too far in the future")
|
raise IamError("Request timestamp too old or too far in the future")
|
||||||
|
|
||||||
required_headers = {'host', 'x-amz-date'}
|
required_headers = {'host', 'x-amz-date'}
|
||||||
signed_headers_set = set(signed_headers_str.split(';'))
|
signed_headers_set = set(signed_headers_str.split(';'))
|
||||||
if not required_headers.issubset(signed_headers_set):
|
if not required_headers.issubset(signed_headers_set):
|
||||||
# Some clients might sign 'date' instead of 'x-amz-date'
|
|
||||||
if 'date' in signed_headers_set:
|
if 'date' in signed_headers_set:
|
||||||
required_headers.remove('x-amz-date')
|
required_headers.remove('x-amz-date')
|
||||||
required_headers.add('date')
|
required_headers.add('date')
|
||||||
@@ -154,6 +167,24 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
if not hmac.compare_digest(calculated_signature, signature):
|
if not hmac.compare_digest(calculated_signature, signature):
|
||||||
|
# Debug logging for signature mismatch
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Signature mismatch for {req.path}")
|
||||||
|
logger.error(f" Content-Type: {req.headers.get('Content-Type')}")
|
||||||
|
logger.error(f" Content-Length: {req.headers.get('Content-Length')}")
|
||||||
|
logger.error(f" X-Amz-Content-Sha256: {req.headers.get('X-Amz-Content-Sha256')}")
|
||||||
|
logger.error(f" Canonical URI: {canonical_uri}")
|
||||||
|
logger.error(f" Signed headers: {signed_headers_str}")
|
||||||
|
# Log each signed header's value
|
||||||
|
for h in signed_headers_list:
|
||||||
|
logger.error(f" Header '{h}': {repr(req.headers.get(h))}")
|
||||||
|
logger.error(f" Expected sig: {signature[:16]}...")
|
||||||
|
logger.error(f" Calculated sig: {calculated_signature[:16]}...")
|
||||||
|
# Log first part of canonical request to compare
|
||||||
|
logger.error(f" Canonical request hash: {hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()[:16]}...")
|
||||||
|
# Log the full canonical request for debugging
|
||||||
|
logger.error(f" Canonical request:\n{canonical_request[:500]}...")
|
||||||
raise IamError("SignatureDoesNotMatch")
|
raise IamError("SignatureDoesNotMatch")
|
||||||
|
|
||||||
return _iam().get_principal(access_key)
|
return _iam().get_principal(access_key)
|
||||||
@@ -187,11 +218,9 @@ def _verify_sigv4_query(req: Any) -> Principal | None:
|
|||||||
if not secret_key:
|
if not secret_key:
|
||||||
raise IamError("Invalid access key")
|
raise IamError("Invalid access key")
|
||||||
|
|
||||||
# Canonical Request
|
|
||||||
method = req.method
|
method = req.method
|
||||||
canonical_uri = quote(req.path, safe="/-_.~")
|
canonical_uri = _get_canonical_uri(req)
|
||||||
|
|
||||||
# Canonical Query String
|
|
||||||
query_args = []
|
query_args = []
|
||||||
for key, value in req.args.items(multi=True):
|
for key, value in req.args.items(multi=True):
|
||||||
if key != "X-Amz-Signature":
|
if key != "X-Amz-Signature":
|
||||||
@@ -203,7 +232,6 @@ def _verify_sigv4_query(req: Any) -> Principal | None:
|
|||||||
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
|
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
|
||||||
canonical_query_string = "&".join(canonical_query_parts)
|
canonical_query_string = "&".join(canonical_query_parts)
|
||||||
|
|
||||||
# Canonical Headers
|
|
||||||
signed_headers_list = signed_headers_str.split(";")
|
signed_headers_list = signed_headers_str.split(";")
|
||||||
canonical_headers_parts = []
|
canonical_headers_parts = []
|
||||||
for header in signed_headers_list:
|
for header in signed_headers_list:
|
||||||
@@ -212,7 +240,6 @@ def _verify_sigv4_query(req: Any) -> Principal | None:
|
|||||||
canonical_headers_parts.append(f"{header}:{val}\n")
|
canonical_headers_parts.append(f"{header}:{val}\n")
|
||||||
canonical_headers = "".join(canonical_headers_parts)
|
canonical_headers = "".join(canonical_headers_parts)
|
||||||
|
|
||||||
# Payload Hash
|
|
||||||
payload_hash = "UNSIGNED-PAYLOAD"
|
payload_hash = "UNSIGNED-PAYLOAD"
|
||||||
|
|
||||||
canonical_request = "\n".join([
|
canonical_request = "\n".join([
|
||||||
@@ -224,7 +251,6 @@ def _verify_sigv4_query(req: Any) -> Principal | None:
|
|||||||
payload_hash
|
payload_hash
|
||||||
])
|
])
|
||||||
|
|
||||||
# String to Sign
|
|
||||||
algorithm = "AWS4-HMAC-SHA256"
|
algorithm = "AWS4-HMAC-SHA256"
|
||||||
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
||||||
hashed_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
|
hashed_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
|
||||||
@@ -235,7 +261,6 @@ def _verify_sigv4_query(req: Any) -> Principal | None:
|
|||||||
hashed_request
|
hashed_request
|
||||||
])
|
])
|
||||||
|
|
||||||
# Signature
|
|
||||||
signing_key = _get_signature_key(secret_key, date_stamp, region, service)
|
signing_key = _get_signature_key(secret_key, date_stamp, region, service)
|
||||||
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
@@ -493,7 +518,6 @@ def _generate_presigned_url(
|
|||||||
}
|
}
|
||||||
canonical_query = _encode_query_params(query_params)
|
canonical_query = _encode_query_params(query_params)
|
||||||
|
|
||||||
# Determine host and scheme from config or request
|
|
||||||
api_base = current_app.config.get("API_BASE_URL")
|
api_base = current_app.config.get("API_BASE_URL")
|
||||||
if api_base:
|
if api_base:
|
||||||
parsed = urlparse(api_base)
|
parsed = urlparse(api_base)
|
||||||
@@ -784,6 +808,7 @@ def _apply_object_headers(
|
|||||||
metadata: Dict[str, str] | None,
|
metadata: Dict[str, str] | None,
|
||||||
etag: str,
|
etag: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if file_stat is not None:
|
||||||
response.headers["Content-Length"] = str(file_stat.st_size)
|
response.headers["Content-Length"] = str(file_stat.st_size)
|
||||||
response.headers["Last-Modified"] = http_date(file_stat.st_mtime)
|
response.headers["Last-Modified"] = http_date(file_stat.st_mtime)
|
||||||
response.headers["ETag"] = f'"{etag}"'
|
response.headers["ETag"] = f'"{etag}"'
|
||||||
@@ -802,6 +827,7 @@ def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None:
|
|||||||
"acl": _bucket_acl_handler,
|
"acl": _bucket_acl_handler,
|
||||||
"versions": _bucket_list_versions_handler,
|
"versions": _bucket_list_versions_handler,
|
||||||
"lifecycle": _bucket_lifecycle_handler,
|
"lifecycle": _bucket_lifecycle_handler,
|
||||||
|
"quota": _bucket_quota_handler,
|
||||||
}
|
}
|
||||||
requested = [key for key in handlers if key in request.args]
|
requested = [key for key in handlers if key in request.args]
|
||||||
if not requested:
|
if not requested:
|
||||||
@@ -851,7 +877,6 @@ def _bucket_versioning_handler(bucket_name: str) -> Response:
|
|||||||
current_app.logger.info("Bucket versioning updated", extra={"bucket": bucket_name, "status": status})
|
current_app.logger.info("Bucket versioning updated", extra={"bucket": bucket_name, "status": status})
|
||||||
return Response(status=200)
|
return Response(status=200)
|
||||||
|
|
||||||
# GET
|
|
||||||
try:
|
try:
|
||||||
enabled = storage.is_versioning_enabled(bucket_name)
|
enabled = storage.is_versioning_enabled(bucket_name)
|
||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
@@ -887,7 +912,7 @@ def _bucket_tagging_handler(bucket_name: str) -> Response:
|
|||||||
return _error_response("NoSuchBucket", str(exc), 404)
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
current_app.logger.info("Bucket tags deleted", extra={"bucket": bucket_name})
|
current_app.logger.info("Bucket tags deleted", extra={"bucket": bucket_name})
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
# PUT
|
|
||||||
payload = request.get_data(cache=False) or b""
|
payload = request.get_data(cache=False) or b""
|
||||||
try:
|
try:
|
||||||
tags = _parse_tagging_document(payload)
|
tags = _parse_tagging_document(payload)
|
||||||
@@ -912,7 +937,6 @@ def _object_tagging_handler(bucket_name: str, object_key: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
# For tagging, we use read permission for GET, write for PUT/DELETE
|
|
||||||
action = "read" if request.method == "GET" else "write"
|
action = "read" if request.method == "GET" else "write"
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, action, object_key=object_key)
|
_authorize_action(principal, bucket_name, action, object_key=object_key)
|
||||||
@@ -1091,10 +1115,8 @@ def _bucket_location_handler(bucket_name: str) -> Response:
|
|||||||
if not storage.bucket_exists(bucket_name):
|
if not storage.bucket_exists(bucket_name):
|
||||||
return _error_response("NoSuchBucket", "Bucket does not exist", 404)
|
return _error_response("NoSuchBucket", "Bucket does not exist", 404)
|
||||||
|
|
||||||
# Return the configured AWS_REGION
|
|
||||||
region = current_app.config.get("AWS_REGION", "us-east-1")
|
region = current_app.config.get("AWS_REGION", "us-east-1")
|
||||||
root = Element("LocationConstraint")
|
root = Element("LocationConstraint")
|
||||||
# AWS returns empty for us-east-1, but we'll be explicit
|
|
||||||
root.text = region if region != "us-east-1" else None
|
root.text = region if region != "us-east-1" else None
|
||||||
return _xml_response(root)
|
return _xml_response(root)
|
||||||
|
|
||||||
@@ -1114,13 +1136,11 @@ def _bucket_acl_handler(bucket_name: str) -> Response:
|
|||||||
return _error_response("NoSuchBucket", "Bucket does not exist", 404)
|
return _error_response("NoSuchBucket", "Bucket does not exist", 404)
|
||||||
|
|
||||||
if request.method == "PUT":
|
if request.method == "PUT":
|
||||||
# We don't fully implement ACLs, but we accept the request for compatibility
|
# Accept canned ACL headers for S3 compatibility (not fully implemented)
|
||||||
# Check for canned ACL header
|
|
||||||
canned_acl = request.headers.get("x-amz-acl", "private")
|
canned_acl = request.headers.get("x-amz-acl", "private")
|
||||||
current_app.logger.info("Bucket ACL set (canned)", extra={"bucket": bucket_name, "acl": canned_acl})
|
current_app.logger.info("Bucket ACL set (canned)", extra={"bucket": bucket_name, "acl": canned_acl})
|
||||||
return Response(status=200)
|
return Response(status=200)
|
||||||
|
|
||||||
# GET - Return a basic ACL document showing full control for owner
|
|
||||||
root = Element("AccessControlPolicy")
|
root = Element("AccessControlPolicy")
|
||||||
owner = SubElement(root, "Owner")
|
owner = SubElement(root, "Owner")
|
||||||
SubElement(owner, "ID").text = principal.access_key if principal else "anonymous"
|
SubElement(owner, "ID").text = principal.access_key if principal else "anonymous"
|
||||||
@@ -1153,7 +1173,7 @@ def _bucket_list_versions_handler(bucket_name: str) -> Response:
|
|||||||
storage = _storage()
|
storage = _storage()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
objects = storage.list_objects(bucket_name)
|
objects = storage.list_objects_all(bucket_name)
|
||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
return _error_response("NoSuchBucket", str(exc), 404)
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
|
|
||||||
@@ -1168,7 +1188,6 @@ def _bucket_list_versions_handler(bucket_name: str) -> Response:
|
|||||||
if key_marker:
|
if key_marker:
|
||||||
objects = [obj for obj in objects if obj.key > key_marker]
|
objects = [obj for obj in objects if obj.key > key_marker]
|
||||||
|
|
||||||
# Build XML response
|
|
||||||
root = Element("ListVersionsResult", xmlns="http://s3.amazonaws.com/doc/2006-03-01/")
|
root = Element("ListVersionsResult", xmlns="http://s3.amazonaws.com/doc/2006-03-01/")
|
||||||
SubElement(root, "Name").text = bucket_name
|
SubElement(root, "Name").text = bucket_name
|
||||||
SubElement(root, "Prefix").text = prefix
|
SubElement(root, "Prefix").text = prefix
|
||||||
@@ -1186,10 +1205,9 @@ def _bucket_list_versions_handler(bucket_name: str) -> Response:
|
|||||||
is_truncated = True
|
is_truncated = True
|
||||||
break
|
break
|
||||||
|
|
||||||
# Current version
|
|
||||||
version = SubElement(root, "Version")
|
version = SubElement(root, "Version")
|
||||||
SubElement(version, "Key").text = obj.key
|
SubElement(version, "Key").text = obj.key
|
||||||
SubElement(version, "VersionId").text = "null" # Current version ID
|
SubElement(version, "VersionId").text = "null"
|
||||||
SubElement(version, "IsLatest").text = "true"
|
SubElement(version, "IsLatest").text = "true"
|
||||||
SubElement(version, "LastModified").text = obj.last_modified.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
SubElement(version, "LastModified").text = obj.last_modified.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
SubElement(version, "ETag").text = f'"{obj.etag}"'
|
SubElement(version, "ETag").text = f'"{obj.etag}"'
|
||||||
@@ -1203,7 +1221,6 @@ def _bucket_list_versions_handler(bucket_name: str) -> Response:
|
|||||||
version_count += 1
|
version_count += 1
|
||||||
next_key_marker = obj.key
|
next_key_marker = obj.key
|
||||||
|
|
||||||
# Get historical versions
|
|
||||||
try:
|
try:
|
||||||
versions = storage.list_object_versions(bucket_name, obj.key)
|
versions = storage.list_object_versions(bucket_name, obj.key)
|
||||||
for v in versions:
|
for v in versions:
|
||||||
@@ -1287,14 +1304,12 @@ def _render_lifecycle_config(config: list) -> Element:
|
|||||||
rule_el = SubElement(root, "Rule")
|
rule_el = SubElement(root, "Rule")
|
||||||
SubElement(rule_el, "ID").text = rule.get("ID", "")
|
SubElement(rule_el, "ID").text = rule.get("ID", "")
|
||||||
|
|
||||||
# Filter
|
|
||||||
filter_el = SubElement(rule_el, "Filter")
|
filter_el = SubElement(rule_el, "Filter")
|
||||||
if rule.get("Prefix"):
|
if rule.get("Prefix"):
|
||||||
SubElement(filter_el, "Prefix").text = rule.get("Prefix", "")
|
SubElement(filter_el, "Prefix").text = rule.get("Prefix", "")
|
||||||
|
|
||||||
SubElement(rule_el, "Status").text = rule.get("Status", "Enabled")
|
SubElement(rule_el, "Status").text = rule.get("Status", "Enabled")
|
||||||
|
|
||||||
# Expiration
|
|
||||||
if "Expiration" in rule:
|
if "Expiration" in rule:
|
||||||
exp = rule["Expiration"]
|
exp = rule["Expiration"]
|
||||||
exp_el = SubElement(rule_el, "Expiration")
|
exp_el = SubElement(rule_el, "Expiration")
|
||||||
@@ -1305,14 +1320,12 @@ def _render_lifecycle_config(config: list) -> Element:
|
|||||||
if exp.get("ExpiredObjectDeleteMarker"):
|
if exp.get("ExpiredObjectDeleteMarker"):
|
||||||
SubElement(exp_el, "ExpiredObjectDeleteMarker").text = "true"
|
SubElement(exp_el, "ExpiredObjectDeleteMarker").text = "true"
|
||||||
|
|
||||||
# NoncurrentVersionExpiration
|
|
||||||
if "NoncurrentVersionExpiration" in rule:
|
if "NoncurrentVersionExpiration" in rule:
|
||||||
nve = rule["NoncurrentVersionExpiration"]
|
nve = rule["NoncurrentVersionExpiration"]
|
||||||
nve_el = SubElement(rule_el, "NoncurrentVersionExpiration")
|
nve_el = SubElement(rule_el, "NoncurrentVersionExpiration")
|
||||||
if "NoncurrentDays" in nve:
|
if "NoncurrentDays" in nve:
|
||||||
SubElement(nve_el, "NoncurrentDays").text = str(nve["NoncurrentDays"])
|
SubElement(nve_el, "NoncurrentDays").text = str(nve["NoncurrentDays"])
|
||||||
|
|
||||||
# AbortIncompleteMultipartUpload
|
|
||||||
if "AbortIncompleteMultipartUpload" in rule:
|
if "AbortIncompleteMultipartUpload" in rule:
|
||||||
aimu = rule["AbortIncompleteMultipartUpload"]
|
aimu = rule["AbortIncompleteMultipartUpload"]
|
||||||
aimu_el = SubElement(rule_el, "AbortIncompleteMultipartUpload")
|
aimu_el = SubElement(rule_el, "AbortIncompleteMultipartUpload")
|
||||||
@@ -1336,29 +1349,24 @@ def _parse_lifecycle_config(payload: bytes) -> list:
|
|||||||
for rule_el in root.findall("{*}Rule") or root.findall("Rule"):
|
for rule_el in root.findall("{*}Rule") or root.findall("Rule"):
|
||||||
rule: dict = {}
|
rule: dict = {}
|
||||||
|
|
||||||
# ID
|
|
||||||
id_el = rule_el.find("{*}ID") or rule_el.find("ID")
|
id_el = rule_el.find("{*}ID") or rule_el.find("ID")
|
||||||
if id_el is not None and id_el.text:
|
if id_el is not None and id_el.text:
|
||||||
rule["ID"] = id_el.text.strip()
|
rule["ID"] = id_el.text.strip()
|
||||||
|
|
||||||
# Filter/Prefix
|
|
||||||
filter_el = rule_el.find("{*}Filter") or rule_el.find("Filter")
|
filter_el = rule_el.find("{*}Filter") or rule_el.find("Filter")
|
||||||
if filter_el is not None:
|
if filter_el is not None:
|
||||||
prefix_el = filter_el.find("{*}Prefix") or filter_el.find("Prefix")
|
prefix_el = filter_el.find("{*}Prefix") or filter_el.find("Prefix")
|
||||||
if prefix_el is not None and prefix_el.text:
|
if prefix_el is not None and prefix_el.text:
|
||||||
rule["Prefix"] = prefix_el.text
|
rule["Prefix"] = prefix_el.text
|
||||||
|
|
||||||
# Legacy Prefix (outside Filter)
|
|
||||||
if "Prefix" not in rule:
|
if "Prefix" not in rule:
|
||||||
prefix_el = rule_el.find("{*}Prefix") or rule_el.find("Prefix")
|
prefix_el = rule_el.find("{*}Prefix") or rule_el.find("Prefix")
|
||||||
if prefix_el is not None:
|
if prefix_el is not None:
|
||||||
rule["Prefix"] = prefix_el.text or ""
|
rule["Prefix"] = prefix_el.text or ""
|
||||||
|
|
||||||
# Status
|
|
||||||
status_el = rule_el.find("{*}Status") or rule_el.find("Status")
|
status_el = rule_el.find("{*}Status") or rule_el.find("Status")
|
||||||
rule["Status"] = (status_el.text or "Enabled").strip() if status_el is not None else "Enabled"
|
rule["Status"] = (status_el.text or "Enabled").strip() if status_el is not None else "Enabled"
|
||||||
|
|
||||||
# Expiration
|
|
||||||
exp_el = rule_el.find("{*}Expiration") or rule_el.find("Expiration")
|
exp_el = rule_el.find("{*}Expiration") or rule_el.find("Expiration")
|
||||||
if exp_el is not None:
|
if exp_el is not None:
|
||||||
expiration: dict = {}
|
expiration: dict = {}
|
||||||
@@ -1374,7 +1382,6 @@ def _parse_lifecycle_config(payload: bytes) -> list:
|
|||||||
if expiration:
|
if expiration:
|
||||||
rule["Expiration"] = expiration
|
rule["Expiration"] = expiration
|
||||||
|
|
||||||
# NoncurrentVersionExpiration
|
|
||||||
nve_el = rule_el.find("{*}NoncurrentVersionExpiration") or rule_el.find("NoncurrentVersionExpiration")
|
nve_el = rule_el.find("{*}NoncurrentVersionExpiration") or rule_el.find("NoncurrentVersionExpiration")
|
||||||
if nve_el is not None:
|
if nve_el is not None:
|
||||||
nve: dict = {}
|
nve: dict = {}
|
||||||
@@ -1384,7 +1391,6 @@ def _parse_lifecycle_config(payload: bytes) -> list:
|
|||||||
if nve:
|
if nve:
|
||||||
rule["NoncurrentVersionExpiration"] = nve
|
rule["NoncurrentVersionExpiration"] = nve
|
||||||
|
|
||||||
# AbortIncompleteMultipartUpload
|
|
||||||
aimu_el = rule_el.find("{*}AbortIncompleteMultipartUpload") or rule_el.find("AbortIncompleteMultipartUpload")
|
aimu_el = rule_el.find("{*}AbortIncompleteMultipartUpload") or rule_el.find("AbortIncompleteMultipartUpload")
|
||||||
if aimu_el is not None:
|
if aimu_el is not None:
|
||||||
aimu: dict = {}
|
aimu: dict = {}
|
||||||
@@ -1399,6 +1405,85 @@ def _parse_lifecycle_config(payload: bytes) -> list:
|
|||||||
return rules
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_quota_handler(bucket_name: str) -> Response:
|
||||||
|
"""Handle bucket quota configuration (GET/PUT/DELETE /<bucket>?quota)."""
|
||||||
|
if request.method not in {"GET", "PUT", "DELETE"}:
|
||||||
|
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
||||||
|
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
_authorize_action(principal, bucket_name, "policy")
|
||||||
|
except IamError as exc:
|
||||||
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
|
|
||||||
|
storage = _storage()
|
||||||
|
|
||||||
|
if not storage.bucket_exists(bucket_name):
|
||||||
|
return _error_response("NoSuchBucket", "Bucket does not exist", 404)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
quota = storage.get_bucket_quota(bucket_name)
|
||||||
|
if not quota:
|
||||||
|
return _error_response("NoSuchQuotaConfiguration", "No quota configuration found", 404)
|
||||||
|
|
||||||
|
stats = storage.bucket_stats(bucket_name)
|
||||||
|
return jsonify({
|
||||||
|
"quota": quota,
|
||||||
|
"usage": {
|
||||||
|
"bytes": stats.get("bytes", 0),
|
||||||
|
"objects": stats.get("objects", 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if request.method == "DELETE":
|
||||||
|
try:
|
||||||
|
storage.set_bucket_quota(bucket_name, max_size_bytes=None, max_objects=None)
|
||||||
|
except StorageError as exc:
|
||||||
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
|
current_app.logger.info("Bucket quota deleted", extra={"bucket": bucket_name})
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
# PUT
|
||||||
|
payload = request.get_json(silent=True)
|
||||||
|
if not payload:
|
||||||
|
return _error_response("MalformedRequest", "Request body must be JSON with quota limits", 400)
|
||||||
|
|
||||||
|
max_size_bytes = payload.get("max_size_bytes")
|
||||||
|
max_objects = payload.get("max_objects")
|
||||||
|
|
||||||
|
if max_size_bytes is None and max_objects is None:
|
||||||
|
return _error_response("InvalidArgument", "At least one of max_size_bytes or max_objects is required", 400)
|
||||||
|
|
||||||
|
if max_size_bytes is not None:
|
||||||
|
try:
|
||||||
|
max_size_bytes = int(max_size_bytes)
|
||||||
|
if max_size_bytes < 0:
|
||||||
|
raise ValueError("must be non-negative")
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return _error_response("InvalidArgument", f"max_size_bytes {exc}", 400)
|
||||||
|
|
||||||
|
if max_objects is not None:
|
||||||
|
try:
|
||||||
|
max_objects = int(max_objects)
|
||||||
|
if max_objects < 0:
|
||||||
|
raise ValueError("must be non-negative")
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return _error_response("InvalidArgument", f"max_objects {exc}", 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage.set_bucket_quota(bucket_name, max_size_bytes=max_size_bytes, max_objects=max_objects)
|
||||||
|
except StorageError as exc:
|
||||||
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
|
|
||||||
|
current_app.logger.info(
|
||||||
|
"Bucket quota updated",
|
||||||
|
extra={"bucket": bucket_name, "max_size_bytes": max_size_bytes, "max_objects": max_objects}
|
||||||
|
)
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
def _bulk_delete_handler(bucket_name: str) -> Response:
|
def _bulk_delete_handler(bucket_name: str) -> Response:
|
||||||
principal, error = _require_principal()
|
principal, error = _require_principal()
|
||||||
if error:
|
if error:
|
||||||
@@ -1481,7 +1566,6 @@ def _bulk_delete_handler(bucket_name: str) -> Response:
|
|||||||
return _xml_response(result, status=200)
|
return _xml_response(result, status=200)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------- routes ----------------------
|
|
||||||
@s3_api_bp.get("/")
|
@s3_api_bp.get("/")
|
||||||
@limiter.limit("60 per minute")
|
@limiter.limit("60 per minute")
|
||||||
def list_buckets() -> Response:
|
def list_buckets() -> Response:
|
||||||
@@ -1559,7 +1643,6 @@ def bucket_handler(bucket_name: str) -> Response:
|
|||||||
current_app.logger.info("Bucket deleted", extra={"bucket": bucket_name})
|
current_app.logger.info("Bucket deleted", extra={"bucket": bucket_name})
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
# GET - list objects (supports both ListObjects and ListObjectsV2)
|
|
||||||
principal, error = _require_principal()
|
principal, error = _require_principal()
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "list")
|
_authorize_action(principal, bucket_name, "list")
|
||||||
@@ -1567,18 +1650,12 @@ def bucket_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
try:
|
|
||||||
objects = storage.list_objects(bucket_name)
|
|
||||||
except StorageError as exc:
|
|
||||||
return _error_response("NoSuchBucket", str(exc), 404)
|
|
||||||
|
|
||||||
# Check if this is ListObjectsV2 (list-type=2)
|
|
||||||
list_type = request.args.get("list-type")
|
list_type = request.args.get("list-type")
|
||||||
prefix = request.args.get("prefix", "")
|
prefix = request.args.get("prefix", "")
|
||||||
delimiter = request.args.get("delimiter", "")
|
delimiter = request.args.get("delimiter", "")
|
||||||
max_keys = min(int(request.args.get("max-keys", current_app.config["UI_PAGE_SIZE"])), 1000)
|
max_keys = min(int(request.args.get("max-keys", current_app.config["UI_PAGE_SIZE"])), 1000)
|
||||||
|
|
||||||
# Pagination markers
|
|
||||||
marker = request.args.get("marker", "") # ListObjects v1
|
marker = request.args.get("marker", "") # ListObjects v1
|
||||||
continuation_token = request.args.get("continuation-token", "") # ListObjectsV2
|
continuation_token = request.args.get("continuation-token", "") # ListObjectsV2
|
||||||
start_after = request.args.get("start-after", "") # ListObjectsV2
|
start_after = request.args.get("start-after", "") # ListObjectsV2
|
||||||
@@ -1598,11 +1675,17 @@ def bucket_handler(bucket_name: str) -> Response:
|
|||||||
else:
|
else:
|
||||||
effective_start = marker
|
effective_start = marker
|
||||||
|
|
||||||
if prefix:
|
fetch_keys = max_keys * 10 if delimiter else max_keys
|
||||||
objects = [obj for obj in objects if obj.key.startswith(prefix)]
|
try:
|
||||||
|
list_result = storage.list_objects(
|
||||||
if effective_start:
|
bucket_name,
|
||||||
objects = [obj for obj in objects if obj.key > effective_start]
|
max_keys=fetch_keys,
|
||||||
|
continuation_token=effective_start or None,
|
||||||
|
prefix=prefix or None,
|
||||||
|
)
|
||||||
|
objects = list_result.objects
|
||||||
|
except StorageError as exc:
|
||||||
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
|
|
||||||
common_prefixes: list[str] = []
|
common_prefixes: list[str] = []
|
||||||
filtered_objects: list = []
|
filtered_objects: list = []
|
||||||
@@ -1611,7 +1694,6 @@ def bucket_handler(bucket_name: str) -> Response:
|
|||||||
for obj in objects:
|
for obj in objects:
|
||||||
key_after_prefix = obj.key[len(prefix):] if prefix else obj.key
|
key_after_prefix = obj.key[len(prefix):] if prefix else obj.key
|
||||||
if delimiter in key_after_prefix:
|
if delimiter in key_after_prefix:
|
||||||
# This is a "folder" - extract the common prefix
|
|
||||||
common_prefix = prefix + key_after_prefix.split(delimiter)[0] + delimiter
|
common_prefix = prefix + key_after_prefix.split(delimiter)[0] + delimiter
|
||||||
if common_prefix not in seen_prefixes:
|
if common_prefix not in seen_prefixes:
|
||||||
seen_prefixes.add(common_prefix)
|
seen_prefixes.add(common_prefix)
|
||||||
@@ -1622,7 +1704,7 @@ def bucket_handler(bucket_name: str) -> Response:
|
|||||||
common_prefixes = sorted(common_prefixes)
|
common_prefixes = sorted(common_prefixes)
|
||||||
|
|
||||||
total_items = len(objects) + len(common_prefixes)
|
total_items = len(objects) + len(common_prefixes)
|
||||||
is_truncated = total_items > max_keys
|
is_truncated = total_items > max_keys or list_result.is_truncated
|
||||||
|
|
||||||
if len(objects) >= max_keys:
|
if len(objects) >= max_keys:
|
||||||
objects = objects[:max_keys]
|
objects = objects[:max_keys]
|
||||||
@@ -1709,7 +1791,6 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
if "tagging" in request.args:
|
if "tagging" in request.args:
|
||||||
return _object_tagging_handler(bucket_name, object_key)
|
return _object_tagging_handler(bucket_name, object_key)
|
||||||
|
|
||||||
# Multipart Uploads
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if "uploads" in request.args:
|
if "uploads" in request.args:
|
||||||
return _initiate_multipart_upload(bucket_name, object_key)
|
return _initiate_multipart_upload(bucket_name, object_key)
|
||||||
@@ -1748,6 +1829,8 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
stream,
|
stream,
|
||||||
metadata=metadata or None,
|
metadata=metadata or None,
|
||||||
)
|
)
|
||||||
|
except QuotaExceededError as exc:
|
||||||
|
return _error_response("QuotaExceeded", str(exc), 403)
|
||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
message = str(exc)
|
message = str(exc)
|
||||||
if "Bucket" in message:
|
if "Bucket" in message:
|
||||||
@@ -1760,9 +1843,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
response = Response(status=200)
|
response = Response(status=200)
|
||||||
response.headers["ETag"] = f'"{meta.etag}"'
|
response.headers["ETag"] = f'"{meta.etag}"'
|
||||||
|
|
||||||
# Trigger replication if not a replication request
|
if "S3ReplicationAgent" not in request.headers.get("User-Agent", ""):
|
||||||
user_agent = request.headers.get("User-Agent", "")
|
|
||||||
if "S3ReplicationAgent" not in user_agent:
|
|
||||||
_replication_manager().trigger_replication(bucket_name, object_key, action="write")
|
_replication_manager().trigger_replication(bucket_name, object_key, action="write")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -1779,19 +1860,42 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
return _error_response("NoSuchKey", str(exc), 404)
|
return _error_response("NoSuchKey", str(exc), 404)
|
||||||
metadata = storage.get_object_metadata(bucket_name, object_key)
|
metadata = storage.get_object_metadata(bucket_name, object_key)
|
||||||
stat = path.stat()
|
mimetype = mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
mimetype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
||||||
etag = storage._compute_etag(path)
|
is_encrypted = "x-amz-server-side-encryption" in metadata
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
|
if is_encrypted and hasattr(storage, 'get_object_data'):
|
||||||
|
try:
|
||||||
|
data, clean_metadata = storage.get_object_data(bucket_name, object_key)
|
||||||
|
response = Response(data, mimetype=mimetype)
|
||||||
|
logged_bytes = len(data)
|
||||||
|
response.headers["Content-Length"] = len(data)
|
||||||
|
etag = hashlib.md5(data).hexdigest()
|
||||||
|
except StorageError as exc:
|
||||||
|
return _error_response("InternalError", str(exc), 500)
|
||||||
|
else:
|
||||||
|
stat = path.stat()
|
||||||
response = Response(_stream_file(path), mimetype=mimetype, direct_passthrough=True)
|
response = Response(_stream_file(path), mimetype=mimetype, direct_passthrough=True)
|
||||||
logged_bytes = stat.st_size
|
logged_bytes = stat.st_size
|
||||||
|
etag = storage._compute_etag(path)
|
||||||
else:
|
else:
|
||||||
|
if is_encrypted and hasattr(storage, 'get_object_data'):
|
||||||
|
try:
|
||||||
|
data, _ = storage.get_object_data(bucket_name, object_key)
|
||||||
response = Response(status=200)
|
response = Response(status=200)
|
||||||
|
response.headers["Content-Length"] = len(data)
|
||||||
|
etag = hashlib.md5(data).hexdigest()
|
||||||
|
except StorageError as exc:
|
||||||
|
return _error_response("InternalError", str(exc), 500)
|
||||||
|
else:
|
||||||
|
stat = path.stat()
|
||||||
|
response = Response(status=200)
|
||||||
|
etag = storage._compute_etag(path)
|
||||||
response.headers["Content-Type"] = mimetype
|
response.headers["Content-Type"] = mimetype
|
||||||
logged_bytes = 0
|
logged_bytes = 0
|
||||||
|
|
||||||
_apply_object_headers(response, file_stat=stat, metadata=metadata, etag=etag)
|
_apply_object_headers(response, file_stat=path.stat() if not is_encrypted else None, metadata=metadata, etag=etag)
|
||||||
action = "Object read" if request.method == "GET" else "Object head"
|
action = "Object read" if request.method == "GET" else "Object head"
|
||||||
current_app.logger.info(action, extra={"bucket": bucket_name, "key": object_key, "bytes": logged_bytes})
|
current_app.logger.info(action, extra={"bucket": bucket_name, "key": object_key, "bytes": logged_bytes})
|
||||||
return response
|
return response
|
||||||
@@ -1805,7 +1909,6 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
storage.delete_object(bucket_name, object_key)
|
storage.delete_object(bucket_name, object_key)
|
||||||
current_app.logger.info("Object deleted", extra={"bucket": bucket_name, "key": object_key})
|
current_app.logger.info("Object deleted", extra={"bucket": bucket_name, "key": object_key})
|
||||||
|
|
||||||
# Trigger replication if not a replication request
|
|
||||||
user_agent = request.headers.get("User-Agent", "")
|
user_agent = request.headers.get("User-Agent", "")
|
||||||
if "S3ReplicationAgent" not in user_agent:
|
if "S3ReplicationAgent" not in user_agent:
|
||||||
_replication_manager().trigger_replication(bucket_name, object_key, action="delete")
|
_replication_manager().trigger_replication(bucket_name, object_key, action="delete")
|
||||||
@@ -2086,7 +2189,6 @@ class AwsChunkedDecoder:
|
|||||||
self.chunk_remaining -= len(chunk)
|
self.chunk_remaining -= len(chunk)
|
||||||
|
|
||||||
if self.chunk_remaining == 0:
|
if self.chunk_remaining == 0:
|
||||||
# Read CRLF after chunk data
|
|
||||||
crlf = self.stream.read(2)
|
crlf = self.stream.read(2)
|
||||||
if crlf != b"\r\n":
|
if crlf != b"\r\n":
|
||||||
raise IOError("Malformed chunk: missing CRLF")
|
raise IOError("Malformed chunk: missing CRLF")
|
||||||
@@ -2105,7 +2207,6 @@ class AwsChunkedDecoder:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
line_str = line.decode("ascii").strip()
|
line_str = line.decode("ascii").strip()
|
||||||
# Handle chunk-signature extension if present (e.g. "1000;chunk-signature=...")
|
|
||||||
if ";" in line_str:
|
if ";" in line_str:
|
||||||
line_str = line_str.split(";")[0]
|
line_str = line_str.split(";")[0]
|
||||||
chunk_size = int(line_str, 16)
|
chunk_size = int(line_str, 16)
|
||||||
@@ -2226,6 +2327,8 @@ def _complete_multipart_upload(bucket_name: str, object_key: str) -> Response:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
meta = _storage().complete_multipart_upload(bucket_name, upload_id, parts)
|
meta = _storage().complete_multipart_upload(bucket_name, upload_id, parts)
|
||||||
|
except QuotaExceededError as exc:
|
||||||
|
return _error_response("QuotaExceeded", str(exc), 403)
|
||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
if "NoSuchBucket" in str(exc):
|
if "NoSuchBucket" in str(exc):
|
||||||
return _error_response("NoSuchBucket", str(exc), 404)
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
@@ -2259,7 +2362,6 @@ def _abort_multipart_upload(bucket_name: str, object_key: str) -> Response:
|
|||||||
try:
|
try:
|
||||||
_storage().abort_multipart_upload(bucket_name, upload_id)
|
_storage().abort_multipart_upload(bucket_name, upload_id)
|
||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
# Abort is idempotent, but if bucket missing...
|
|
||||||
if "Bucket does not exist" in str(exc):
|
if "Bucket does not exist" in str(exc):
|
||||||
return _error_response("NoSuchBucket", str(exc), 404)
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
|
|
||||||
@@ -2269,7 +2371,6 @@ def _abort_multipart_upload(bucket_name: str, object_key: str) -> Response:
|
|||||||
@s3_api_bp.before_request
|
@s3_api_bp.before_request
|
||||||
def resolve_principal():
|
def resolve_principal():
|
||||||
g.principal = None
|
g.principal = None
|
||||||
# Try SigV4
|
|
||||||
try:
|
try:
|
||||||
if ("Authorization" in request.headers and request.headers["Authorization"].startswith("AWS4-HMAC-SHA256")) or \
|
if ("Authorization" in request.headers and request.headers["Authorization"].startswith("AWS4-HMAC-SHA256")) or \
|
||||||
(request.args.get("X-Amz-Algorithm") == "AWS4-HMAC-SHA256"):
|
(request.args.get("X-Amz-Algorithm") == "AWS4-HMAC-SHA256"):
|
||||||
@@ -2278,7 +2379,6 @@ def resolve_principal():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try simple auth headers (internal/testing)
|
|
||||||
access_key = request.headers.get("X-Access-Key")
|
access_key = request.headers.get("X-Access-Key")
|
||||||
secret_key = request.headers.get("X-Secret-Key")
|
secret_key = request.headers.get("X-Secret-Key")
|
||||||
if access_key and secret_key:
|
if access_key and secret_key:
|
||||||
|
|||||||
529
app/storage.py
529
app/storage.py
@@ -75,6 +75,15 @@ class StorageError(RuntimeError):
|
|||||||
"""Raised when the storage layer encounters an unrecoverable problem."""
|
"""Raised when the storage layer encounters an unrecoverable problem."""
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaExceededError(StorageError):
|
||||||
|
"""Raised when an operation would exceed bucket quota limits."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, quota: Dict[str, Any], usage: Dict[str, int]):
|
||||||
|
super().__init__(message)
|
||||||
|
self.quota = quota
|
||||||
|
self.usage = usage
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ObjectMeta:
|
class ObjectMeta:
|
||||||
key: str
|
key: str
|
||||||
@@ -90,6 +99,15 @@ class BucketMeta:
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ListObjectsResult:
|
||||||
|
"""Paginated result for object listing."""
|
||||||
|
objects: List[ObjectMeta]
|
||||||
|
is_truncated: bool
|
||||||
|
next_continuation_token: Optional[str]
|
||||||
|
total_count: Optional[int] = None # Total objects in bucket (from stats cache)
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> datetime:
|
def _utcnow() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -110,11 +128,13 @@ class ObjectStorage:
|
|||||||
BUCKET_VERSIONS_DIR = "versions"
|
BUCKET_VERSIONS_DIR = "versions"
|
||||||
MULTIPART_MANIFEST = "manifest.json"
|
MULTIPART_MANIFEST = "manifest.json"
|
||||||
BUCKET_CONFIG_FILE = ".bucket.json"
|
BUCKET_CONFIG_FILE = ".bucket.json"
|
||||||
|
KEY_INDEX_CACHE_TTL = 30
|
||||||
|
|
||||||
def __init__(self, root: Path) -> None:
|
def __init__(self, root: Path) -> None:
|
||||||
self.root = Path(root)
|
self.root = Path(root)
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
self._ensure_system_roots()
|
self._ensure_system_roots()
|
||||||
|
self._object_cache: Dict[str, tuple[Dict[str, ObjectMeta], float]] = {}
|
||||||
|
|
||||||
def list_buckets(self) -> List[BucketMeta]:
|
def list_buckets(self) -> List[BucketMeta]:
|
||||||
buckets: List[BucketMeta] = []
|
buckets: List[BucketMeta] = []
|
||||||
@@ -124,7 +144,7 @@ class ObjectStorage:
|
|||||||
buckets.append(
|
buckets.append(
|
||||||
BucketMeta(
|
BucketMeta(
|
||||||
name=bucket.name,
|
name=bucket.name,
|
||||||
created_at=datetime.fromtimestamp(stat.st_ctime),
|
created_at=datetime.fromtimestamp(stat.st_ctime, timezone.utc),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return buckets
|
return buckets
|
||||||
@@ -169,16 +189,36 @@ class ObjectStorage:
|
|||||||
|
|
||||||
object_count = 0
|
object_count = 0
|
||||||
total_bytes = 0
|
total_bytes = 0
|
||||||
|
version_count = 0
|
||||||
|
version_bytes = 0
|
||||||
|
|
||||||
for path in bucket_path.rglob("*"):
|
for path in bucket_path.rglob("*"):
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
rel = path.relative_to(bucket_path)
|
rel = path.relative_to(bucket_path)
|
||||||
if rel.parts and rel.parts[0] in self.INTERNAL_FOLDERS:
|
if not rel.parts:
|
||||||
continue
|
continue
|
||||||
|
top_folder = rel.parts[0]
|
||||||
|
if top_folder not in self.INTERNAL_FOLDERS:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
object_count += 1
|
object_count += 1
|
||||||
total_bytes += stat.st_size
|
total_bytes += stat.st_size
|
||||||
|
|
||||||
stats = {"objects": object_count, "bytes": total_bytes}
|
versions_root = self._bucket_versions_root(bucket_name)
|
||||||
|
if versions_root.exists():
|
||||||
|
for path in versions_root.rglob("*.bin"):
|
||||||
|
if path.is_file():
|
||||||
|
stat = path.stat()
|
||||||
|
version_count += 1
|
||||||
|
version_bytes += stat.st_size
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"objects": object_count,
|
||||||
|
"bytes": total_bytes,
|
||||||
|
"version_count": version_count,
|
||||||
|
"version_bytes": version_bytes,
|
||||||
|
"total_objects": object_count + version_count,
|
||||||
|
"total_bytes": total_bytes + version_bytes,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -210,31 +250,76 @@ class ObjectStorage:
|
|||||||
self._remove_tree(self._system_bucket_root(bucket_path.name))
|
self._remove_tree(self._system_bucket_root(bucket_path.name))
|
||||||
self._remove_tree(self._multipart_bucket_root(bucket_path.name))
|
self._remove_tree(self._multipart_bucket_root(bucket_path.name))
|
||||||
|
|
||||||
def list_objects(self, bucket_name: str) -> List[ObjectMeta]:
|
def list_objects(
|
||||||
|
self,
|
||||||
|
bucket_name: str,
|
||||||
|
*,
|
||||||
|
max_keys: int = 1000,
|
||||||
|
continuation_token: Optional[str] = None,
|
||||||
|
prefix: Optional[str] = None,
|
||||||
|
) -> ListObjectsResult:
|
||||||
|
"""List objects in a bucket with pagination support.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bucket_name: Name of the bucket
|
||||||
|
max_keys: Maximum number of objects to return (default 1000)
|
||||||
|
continuation_token: Token from previous request for pagination
|
||||||
|
prefix: Filter objects by key prefix
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ListObjectsResult with objects, truncation status, and continuation token
|
||||||
|
"""
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
if not bucket_path.exists():
|
if not bucket_path.exists():
|
||||||
raise StorageError("Bucket does not exist")
|
raise StorageError("Bucket does not exist")
|
||||||
bucket_id = bucket_path.name
|
bucket_id = bucket_path.name
|
||||||
|
|
||||||
|
object_cache = self._get_object_cache(bucket_id, bucket_path)
|
||||||
|
|
||||||
|
all_keys = sorted(object_cache.keys())
|
||||||
|
|
||||||
|
if prefix:
|
||||||
|
all_keys = [k for k in all_keys if k.startswith(prefix)]
|
||||||
|
|
||||||
|
total_count = len(all_keys)
|
||||||
|
start_index = 0
|
||||||
|
if continuation_token:
|
||||||
|
try:
|
||||||
|
import bisect
|
||||||
|
start_index = bisect.bisect_right(all_keys, continuation_token)
|
||||||
|
if start_index >= total_count:
|
||||||
|
return ListObjectsResult(
|
||||||
|
objects=[],
|
||||||
|
is_truncated=False,
|
||||||
|
next_continuation_token=None,
|
||||||
|
total_count=total_count,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
end_index = start_index + max_keys
|
||||||
|
keys_slice = all_keys[start_index:end_index]
|
||||||
|
is_truncated = end_index < total_count
|
||||||
|
|
||||||
objects: List[ObjectMeta] = []
|
objects: List[ObjectMeta] = []
|
||||||
for path in bucket_path.rglob("*"):
|
for key in keys_slice:
|
||||||
if path.is_file():
|
obj = object_cache.get(key)
|
||||||
stat = path.stat()
|
if obj:
|
||||||
rel = path.relative_to(bucket_path)
|
objects.append(obj)
|
||||||
if rel.parts and rel.parts[0] in self.INTERNAL_FOLDERS:
|
|
||||||
continue
|
next_token = keys_slice[-1] if is_truncated and keys_slice else None
|
||||||
metadata = self._read_metadata(bucket_id, rel)
|
|
||||||
objects.append(
|
return ListObjectsResult(
|
||||||
ObjectMeta(
|
objects=objects,
|
||||||
key=str(rel.as_posix()),
|
is_truncated=is_truncated,
|
||||||
size=stat.st_size,
|
next_continuation_token=next_token,
|
||||||
last_modified=datetime.fromtimestamp(stat.st_mtime),
|
total_count=total_count,
|
||||||
etag=self._compute_etag(path),
|
|
||||||
metadata=metadata or None,
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
objects.sort(key=lambda meta: meta.key)
|
def list_objects_all(self, bucket_name: str) -> List[ObjectMeta]:
|
||||||
return objects
|
"""List all objects in a bucket (no pagination). Use with caution for large buckets."""
|
||||||
|
result = self.list_objects(bucket_name, max_keys=100000)
|
||||||
|
return result.objects
|
||||||
|
|
||||||
def put_object(
|
def put_object(
|
||||||
self,
|
self,
|
||||||
@@ -243,6 +328,7 @@ class ObjectStorage:
|
|||||||
stream: BinaryIO,
|
stream: BinaryIO,
|
||||||
*,
|
*,
|
||||||
metadata: Optional[Dict[str, str]] = None,
|
metadata: Optional[Dict[str, str]] = None,
|
||||||
|
enforce_quota: bool = True,
|
||||||
) -> ObjectMeta:
|
) -> ObjectMeta:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
if not bucket_path.exists():
|
if not bucket_path.exists():
|
||||||
@@ -253,26 +339,62 @@ class ObjectStorage:
|
|||||||
destination = bucket_path / safe_key
|
destination = bucket_path / safe_key
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if self._is_versioning_enabled(bucket_path) and destination.exists():
|
is_overwrite = destination.exists()
|
||||||
|
existing_size = destination.stat().st_size if is_overwrite else 0
|
||||||
|
|
||||||
|
if self._is_versioning_enabled(bucket_path) and is_overwrite:
|
||||||
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
||||||
|
|
||||||
|
tmp_dir = self._system_root_path() / self.SYSTEM_TMP_DIR
|
||||||
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = tmp_dir / f"{uuid.uuid4().hex}.tmp"
|
||||||
|
|
||||||
|
try:
|
||||||
checksum = hashlib.md5()
|
checksum = hashlib.md5()
|
||||||
with destination.open("wb") as target:
|
with tmp_path.open("wb") as target:
|
||||||
shutil.copyfileobj(_HashingReader(stream, checksum), target)
|
shutil.copyfileobj(_HashingReader(stream, checksum), target)
|
||||||
|
|
||||||
|
new_size = tmp_path.stat().st_size
|
||||||
|
|
||||||
|
if enforce_quota:
|
||||||
|
size_delta = new_size - existing_size
|
||||||
|
object_delta = 0 if is_overwrite else 1
|
||||||
|
|
||||||
|
quota_check = self.check_quota(
|
||||||
|
bucket_name,
|
||||||
|
additional_bytes=max(0, size_delta),
|
||||||
|
additional_objects=object_delta,
|
||||||
|
)
|
||||||
|
if not quota_check["allowed"]:
|
||||||
|
raise QuotaExceededError(
|
||||||
|
quota_check["message"] or "Quota exceeded",
|
||||||
|
quota_check["quota"],
|
||||||
|
quota_check["usage"],
|
||||||
|
)
|
||||||
|
|
||||||
|
shutil.move(str(tmp_path), str(destination))
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
stat = destination.stat()
|
stat = destination.stat()
|
||||||
if metadata:
|
etag = checksum.hexdigest()
|
||||||
self._write_metadata(bucket_id, safe_key, metadata)
|
|
||||||
else:
|
internal_meta = {"__etag__": etag, "__size__": str(stat.st_size)}
|
||||||
self._delete_metadata(bucket_id, safe_key)
|
combined_meta = {**internal_meta, **(metadata or {})}
|
||||||
|
self._write_metadata(bucket_id, safe_key, combined_meta)
|
||||||
|
|
||||||
self._invalidate_bucket_stats_cache(bucket_id)
|
self._invalidate_bucket_stats_cache(bucket_id)
|
||||||
|
self._invalidate_object_cache(bucket_id)
|
||||||
|
|
||||||
return ObjectMeta(
|
return ObjectMeta(
|
||||||
key=safe_key.as_posix(),
|
key=safe_key.as_posix(),
|
||||||
size=stat.st_size,
|
size=stat.st_size,
|
||||||
last_modified=datetime.fromtimestamp(stat.st_mtime),
|
last_modified=datetime.fromtimestamp(stat.st_mtime, timezone.utc),
|
||||||
etag=checksum.hexdigest(),
|
etag=etag,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -289,6 +411,25 @@ class ObjectStorage:
|
|||||||
safe_key = self._sanitize_object_key(object_key)
|
safe_key = self._sanitize_object_key(object_key)
|
||||||
return self._read_metadata(bucket_path.name, safe_key) or {}
|
return self._read_metadata(bucket_path.name, safe_key) or {}
|
||||||
|
|
||||||
|
def _cleanup_empty_parents(self, path: Path, stop_at: Path) -> None:
|
||||||
|
"""Remove empty parent directories up to (but not including) stop_at.
|
||||||
|
|
||||||
|
On Windows/OneDrive, directories may be locked briefly after file deletion.
|
||||||
|
This method retries with a small delay to handle that case.
|
||||||
|
"""
|
||||||
|
for parent in path.parents:
|
||||||
|
if parent == stop_at:
|
||||||
|
break
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
if parent.exists() and not any(parent.iterdir()):
|
||||||
|
parent.rmdir()
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(0.1)
|
||||||
|
break
|
||||||
|
|
||||||
def delete_object(self, bucket_name: str, object_key: str) -> None:
|
def delete_object(self, bucket_name: str, object_key: str) -> None:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
path = self._object_path(bucket_name, object_key)
|
path = self._object_path(bucket_name, object_key)
|
||||||
@@ -303,12 +444,8 @@ class ObjectStorage:
|
|||||||
self._delete_metadata(bucket_id, rel)
|
self._delete_metadata(bucket_id, rel)
|
||||||
|
|
||||||
self._invalidate_bucket_stats_cache(bucket_id)
|
self._invalidate_bucket_stats_cache(bucket_id)
|
||||||
|
self._invalidate_object_cache(bucket_id)
|
||||||
for parent in path.parents:
|
self._cleanup_empty_parents(path, bucket_path)
|
||||||
if parent == bucket_path:
|
|
||||||
break
|
|
||||||
if parent.exists() and not any(parent.iterdir()):
|
|
||||||
parent.rmdir()
|
|
||||||
|
|
||||||
def purge_object(self, bucket_name: str, object_key: str) -> None:
|
def purge_object(self, bucket_name: str, object_key: str) -> None:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
@@ -328,14 +465,9 @@ class ObjectStorage:
|
|||||||
if legacy_version_dir.exists():
|
if legacy_version_dir.exists():
|
||||||
shutil.rmtree(legacy_version_dir, ignore_errors=True)
|
shutil.rmtree(legacy_version_dir, ignore_errors=True)
|
||||||
|
|
||||||
# Invalidate bucket stats cache
|
|
||||||
self._invalidate_bucket_stats_cache(bucket_id)
|
self._invalidate_bucket_stats_cache(bucket_id)
|
||||||
|
self._invalidate_object_cache(bucket_id)
|
||||||
for parent in target.parents:
|
self._cleanup_empty_parents(target, bucket_path)
|
||||||
if parent == bucket_path:
|
|
||||||
break
|
|
||||||
if parent.exists() and not any(parent.iterdir()):
|
|
||||||
parent.rmdir()
|
|
||||||
|
|
||||||
def is_versioning_enabled(self, bucket_name: str) -> bool:
|
def is_versioning_enabled(self, bucket_name: str) -> bool:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
@@ -413,6 +545,121 @@ class ObjectStorage:
|
|||||||
bucket_path = self._require_bucket_path(bucket_name)
|
bucket_path = self._require_bucket_path(bucket_name)
|
||||||
self._set_bucket_config_entry(bucket_path.name, "lifecycle", rules)
|
self._set_bucket_config_entry(bucket_path.name, "lifecycle", rules)
|
||||||
|
|
||||||
|
def get_bucket_quota(self, bucket_name: str) -> Dict[str, Any]:
|
||||||
|
"""Get quota configuration for bucket.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'max_bytes' and 'max_objects' (None if unlimited).
|
||||||
|
"""
|
||||||
|
bucket_path = self._require_bucket_path(bucket_name)
|
||||||
|
config = self._read_bucket_config(bucket_path.name)
|
||||||
|
quota = config.get("quota")
|
||||||
|
if isinstance(quota, dict):
|
||||||
|
return {
|
||||||
|
"max_bytes": quota.get("max_bytes"),
|
||||||
|
"max_objects": quota.get("max_objects"),
|
||||||
|
}
|
||||||
|
return {"max_bytes": None, "max_objects": None}
|
||||||
|
|
||||||
|
def set_bucket_quota(
|
||||||
|
self,
|
||||||
|
bucket_name: str,
|
||||||
|
*,
|
||||||
|
max_bytes: Optional[int] = None,
|
||||||
|
max_objects: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set quota limits for a bucket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bucket_name: Name of the bucket
|
||||||
|
max_bytes: Maximum total size in bytes (None to remove limit)
|
||||||
|
max_objects: Maximum number of objects (None to remove limit)
|
||||||
|
"""
|
||||||
|
bucket_path = self._require_bucket_path(bucket_name)
|
||||||
|
|
||||||
|
if max_bytes is None and max_objects is None:
|
||||||
|
self._set_bucket_config_entry(bucket_path.name, "quota", None)
|
||||||
|
return
|
||||||
|
|
||||||
|
quota: Dict[str, Any] = {}
|
||||||
|
if max_bytes is not None:
|
||||||
|
if max_bytes < 0:
|
||||||
|
raise StorageError("max_bytes must be non-negative")
|
||||||
|
quota["max_bytes"] = max_bytes
|
||||||
|
if max_objects is not None:
|
||||||
|
if max_objects < 0:
|
||||||
|
raise StorageError("max_objects must be non-negative")
|
||||||
|
quota["max_objects"] = max_objects
|
||||||
|
|
||||||
|
self._set_bucket_config_entry(bucket_path.name, "quota", quota)
|
||||||
|
|
||||||
|
def check_quota(
|
||||||
|
self,
|
||||||
|
bucket_name: str,
|
||||||
|
additional_bytes: int = 0,
|
||||||
|
additional_objects: int = 0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Check if an operation would exceed bucket quota.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bucket_name: Name of the bucket
|
||||||
|
additional_bytes: Bytes that would be added
|
||||||
|
additional_objects: Objects that would be added
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'allowed' (bool), 'quota' (current limits),
|
||||||
|
'usage' (current usage), and 'message' (if not allowed).
|
||||||
|
"""
|
||||||
|
quota = self.get_bucket_quota(bucket_name)
|
||||||
|
if not quota:
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"quota": None,
|
||||||
|
"usage": None,
|
||||||
|
"message": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = self.bucket_stats(bucket_name)
|
||||||
|
current_bytes = stats.get("total_bytes", stats.get("bytes", 0))
|
||||||
|
current_objects = stats.get("total_objects", stats.get("objects", 0))
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"allowed": True,
|
||||||
|
"quota": quota,
|
||||||
|
"usage": {
|
||||||
|
"bytes": current_bytes,
|
||||||
|
"objects": current_objects,
|
||||||
|
"version_count": stats.get("version_count", 0),
|
||||||
|
"version_bytes": stats.get("version_bytes", 0),
|
||||||
|
},
|
||||||
|
"message": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
max_bytes_limit = quota.get("max_bytes")
|
||||||
|
max_objects = quota.get("max_objects")
|
||||||
|
|
||||||
|
if max_bytes_limit is not None:
|
||||||
|
projected_bytes = current_bytes + additional_bytes
|
||||||
|
if projected_bytes > max_bytes_limit:
|
||||||
|
result["allowed"] = False
|
||||||
|
result["message"] = (
|
||||||
|
f"Quota exceeded: adding {additional_bytes} bytes would result in "
|
||||||
|
f"{projected_bytes} bytes, exceeding limit of {max_bytes_limit} bytes"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if max_objects is not None:
|
||||||
|
projected_objects = current_objects + additional_objects
|
||||||
|
if projected_objects > max_objects:
|
||||||
|
result["allowed"] = False
|
||||||
|
result["message"] = (
|
||||||
|
f"Quota exceeded: adding {additional_objects} objects would result in "
|
||||||
|
f"{projected_objects} objects, exceeding limit of {max_objects} objects"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def get_object_tags(self, bucket_name: str, object_key: str) -> List[Dict[str, str]]:
|
def get_object_tags(self, bucket_name: str, object_key: str) -> List[Dict[str, str]]:
|
||||||
"""Get tags for an object."""
|
"""Get tags for an object."""
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
@@ -529,10 +776,11 @@ class ObjectStorage:
|
|||||||
else:
|
else:
|
||||||
self._delete_metadata(bucket_id, safe_key)
|
self._delete_metadata(bucket_id, safe_key)
|
||||||
stat = destination.stat()
|
stat = destination.stat()
|
||||||
|
self._invalidate_bucket_stats_cache(bucket_id)
|
||||||
return ObjectMeta(
|
return ObjectMeta(
|
||||||
key=safe_key.as_posix(),
|
key=safe_key.as_posix(),
|
||||||
size=stat.st_size,
|
size=stat.st_size,
|
||||||
last_modified=datetime.fromtimestamp(stat.st_mtime),
|
last_modified=datetime.fromtimestamp(stat.st_mtime, timezone.utc),
|
||||||
etag=self._compute_etag(destination),
|
etag=self._compute_etag(destination),
|
||||||
metadata=metadata or None,
|
metadata=metadata or None,
|
||||||
)
|
)
|
||||||
@@ -635,14 +883,12 @@ class ObjectStorage:
|
|||||||
raise StorageError("part_number must be >= 1")
|
raise StorageError("part_number must be >= 1")
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
|
|
||||||
# Get the upload root directory
|
|
||||||
upload_root = self._multipart_dir(bucket_path.name, upload_id)
|
upload_root = self._multipart_dir(bucket_path.name, upload_id)
|
||||||
if not upload_root.exists():
|
if not upload_root.exists():
|
||||||
upload_root = self._legacy_multipart_dir(bucket_path.name, upload_id)
|
upload_root = self._legacy_multipart_dir(bucket_path.name, upload_id)
|
||||||
if not upload_root.exists():
|
if not upload_root.exists():
|
||||||
raise StorageError("Multipart upload not found")
|
raise StorageError("Multipart upload not found")
|
||||||
|
|
||||||
# Write the part data first (can happen concurrently)
|
|
||||||
checksum = hashlib.md5()
|
checksum = hashlib.md5()
|
||||||
part_filename = f"part-{part_number:05d}.part"
|
part_filename = f"part-{part_number:05d}.part"
|
||||||
part_path = upload_root / part_filename
|
part_path = upload_root / part_filename
|
||||||
@@ -654,13 +900,11 @@ class ObjectStorage:
|
|||||||
"filename": part_filename,
|
"filename": part_filename,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Update manifest with file locking to prevent race conditions
|
|
||||||
manifest_path = upload_root / self.MULTIPART_MANIFEST
|
manifest_path = upload_root / self.MULTIPART_MANIFEST
|
||||||
lock_path = upload_root / ".manifest.lock"
|
lock_path = upload_root / ".manifest.lock"
|
||||||
|
|
||||||
with lock_path.open("w") as lock_file:
|
with lock_path.open("w") as lock_file:
|
||||||
with _file_lock(lock_file):
|
with _file_lock(lock_file):
|
||||||
# Re-read manifest under lock to get latest state
|
|
||||||
try:
|
try:
|
||||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
@@ -677,6 +921,7 @@ class ObjectStorage:
|
|||||||
bucket_name: str,
|
bucket_name: str,
|
||||||
upload_id: str,
|
upload_id: str,
|
||||||
ordered_parts: List[Dict[str, Any]],
|
ordered_parts: List[Dict[str, Any]],
|
||||||
|
enforce_quota: bool = True,
|
||||||
) -> ObjectMeta:
|
) -> ObjectMeta:
|
||||||
if not ordered_parts:
|
if not ordered_parts:
|
||||||
raise StorageError("parts list required")
|
raise StorageError("parts list required")
|
||||||
@@ -687,6 +932,7 @@ class ObjectStorage:
|
|||||||
if not parts_map:
|
if not parts_map:
|
||||||
raise StorageError("No uploaded parts found")
|
raise StorageError("No uploaded parts found")
|
||||||
validated: List[tuple[int, Dict[str, Any]]] = []
|
validated: List[tuple[int, Dict[str, Any]]] = []
|
||||||
|
total_size = 0
|
||||||
for part in ordered_parts:
|
for part in ordered_parts:
|
||||||
raw_number = part.get("part_number")
|
raw_number = part.get("part_number")
|
||||||
if raw_number is None:
|
if raw_number is None:
|
||||||
@@ -706,10 +952,31 @@ class ObjectStorage:
|
|||||||
if supplied_etag and record.get("etag") and supplied_etag.strip('"') != record["etag"]:
|
if supplied_etag and record.get("etag") and supplied_etag.strip('"') != record["etag"]:
|
||||||
raise StorageError(f"ETag mismatch for part {number}")
|
raise StorageError(f"ETag mismatch for part {number}")
|
||||||
validated.append((number, record))
|
validated.append((number, record))
|
||||||
|
total_size += record.get("size", 0)
|
||||||
validated.sort(key=lambda entry: entry[0])
|
validated.sort(key=lambda entry: entry[0])
|
||||||
|
|
||||||
safe_key = self._sanitize_object_key(manifest["object_key"])
|
safe_key = self._sanitize_object_key(manifest["object_key"])
|
||||||
destination = bucket_path / safe_key
|
destination = bucket_path / safe_key
|
||||||
|
|
||||||
|
is_overwrite = destination.exists()
|
||||||
|
existing_size = destination.stat().st_size if is_overwrite else 0
|
||||||
|
|
||||||
|
if enforce_quota:
|
||||||
|
size_delta = total_size - existing_size
|
||||||
|
object_delta = 0 if is_overwrite else 1
|
||||||
|
|
||||||
|
quota_check = self.check_quota(
|
||||||
|
bucket_name,
|
||||||
|
additional_bytes=max(0, size_delta),
|
||||||
|
additional_objects=object_delta,
|
||||||
|
)
|
||||||
|
if not quota_check["allowed"]:
|
||||||
|
raise QuotaExceededError(
|
||||||
|
quota_check["message"] or "Quota exceeded",
|
||||||
|
quota_check["quota"],
|
||||||
|
quota_check["usage"],
|
||||||
|
)
|
||||||
|
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock"
|
lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock"
|
||||||
@@ -755,7 +1022,7 @@ class ObjectStorage:
|
|||||||
return ObjectMeta(
|
return ObjectMeta(
|
||||||
key=safe_key.as_posix(),
|
key=safe_key.as_posix(),
|
||||||
size=stat.st_size,
|
size=stat.st_size,
|
||||||
last_modified=datetime.fromtimestamp(stat.st_mtime),
|
last_modified=datetime.fromtimestamp(stat.st_mtime, timezone.utc),
|
||||||
etag=checksum.hexdigest(),
|
etag=checksum.hexdigest(),
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
@@ -853,6 +1120,172 @@ class ObjectStorage:
|
|||||||
def _legacy_multipart_dir(self, bucket_name: str, upload_id: str) -> Path:
|
def _legacy_multipart_dir(self, bucket_name: str, upload_id: str) -> Path:
|
||||||
return self._legacy_multipart_bucket_root(bucket_name) / upload_id
|
return self._legacy_multipart_bucket_root(bucket_name) / upload_id
|
||||||
|
|
||||||
|
def _fast_list_keys(self, bucket_path: Path) -> List[str]:
|
||||||
|
"""Fast directory walk using os.scandir instead of pathlib.rglob.
|
||||||
|
|
||||||
|
This is significantly faster for large directories (10K+ files).
|
||||||
|
Returns just the keys (for backward compatibility).
|
||||||
|
"""
|
||||||
|
return list(self._build_object_cache(bucket_path).keys())
|
||||||
|
|
||||||
|
def _build_object_cache(self, bucket_path: Path) -> Dict[str, ObjectMeta]:
|
||||||
|
"""Build a complete object metadata cache for a bucket.
|
||||||
|
|
||||||
|
Uses os.scandir for fast directory walking and a persistent etag index.
|
||||||
|
"""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
bucket_id = bucket_path.name
|
||||||
|
objects: Dict[str, ObjectMeta] = {}
|
||||||
|
bucket_str = str(bucket_path)
|
||||||
|
bucket_len = len(bucket_str) + 1
|
||||||
|
|
||||||
|
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
||||||
|
meta_cache: Dict[str, str] = {}
|
||||||
|
index_mtime: float = 0
|
||||||
|
|
||||||
|
if etag_index_path.exists():
|
||||||
|
try:
|
||||||
|
index_mtime = etag_index_path.stat().st_mtime
|
||||||
|
with open(etag_index_path, 'r', encoding='utf-8') as f:
|
||||||
|
meta_cache = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
meta_cache = {}
|
||||||
|
|
||||||
|
meta_root = self._bucket_meta_root(bucket_id)
|
||||||
|
needs_rebuild = False
|
||||||
|
|
||||||
|
if meta_root.exists() and index_mtime > 0:
|
||||||
|
def check_newer(dir_path: str) -> bool:
|
||||||
|
try:
|
||||||
|
with os.scandir(dir_path) as it:
|
||||||
|
for entry in it:
|
||||||
|
if entry.is_dir(follow_symlinks=False):
|
||||||
|
if check_newer(entry.path):
|
||||||
|
return True
|
||||||
|
elif entry.is_file(follow_symlinks=False) and entry.name.endswith('.meta.json'):
|
||||||
|
if entry.stat().st_mtime > index_mtime:
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
needs_rebuild = check_newer(str(meta_root))
|
||||||
|
elif not meta_cache:
|
||||||
|
needs_rebuild = True
|
||||||
|
|
||||||
|
if needs_rebuild and meta_root.exists():
|
||||||
|
meta_str = str(meta_root)
|
||||||
|
meta_len = len(meta_str) + 1
|
||||||
|
meta_files: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def collect_meta_files(dir_path: str) -> None:
|
||||||
|
try:
|
||||||
|
with os.scandir(dir_path) as it:
|
||||||
|
for entry in it:
|
||||||
|
if entry.is_dir(follow_symlinks=False):
|
||||||
|
collect_meta_files(entry.path)
|
||||||
|
elif entry.is_file(follow_symlinks=False) and entry.name.endswith('.meta.json'):
|
||||||
|
rel = entry.path[meta_len:]
|
||||||
|
key = rel[:-10].replace(os.sep, '/')
|
||||||
|
meta_files.append((key, entry.path))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
collect_meta_files(meta_str)
|
||||||
|
|
||||||
|
def read_meta_file(item: tuple[str, str]) -> tuple[str, str | None]:
|
||||||
|
key, path = item
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
etag_marker = b'"__etag__"'
|
||||||
|
idx = content.find(etag_marker)
|
||||||
|
if idx != -1:
|
||||||
|
start = content.find(b'"', idx + len(etag_marker) + 1)
|
||||||
|
if start != -1:
|
||||||
|
end = content.find(b'"', start + 1)
|
||||||
|
if end != -1:
|
||||||
|
return key, content[start+1:end].decode('utf-8')
|
||||||
|
return key, None
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return key, None
|
||||||
|
|
||||||
|
if meta_files:
|
||||||
|
meta_cache = {}
|
||||||
|
with ThreadPoolExecutor(max_workers=min(64, len(meta_files))) as executor:
|
||||||
|
for key, etag in executor.map(read_meta_file, meta_files):
|
||||||
|
if etag:
|
||||||
|
meta_cache[key] = etag
|
||||||
|
|
||||||
|
try:
|
||||||
|
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(meta_cache, f)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def scan_dir(dir_path: str) -> None:
|
||||||
|
try:
|
||||||
|
with os.scandir(dir_path) as it:
|
||||||
|
for entry in it:
|
||||||
|
if entry.is_dir(follow_symlinks=False):
|
||||||
|
rel_start = entry.path[bucket_len:].split(os.sep)[0] if len(entry.path) > bucket_len else entry.name
|
||||||
|
if rel_start in self.INTERNAL_FOLDERS:
|
||||||
|
continue
|
||||||
|
scan_dir(entry.path)
|
||||||
|
elif entry.is_file(follow_symlinks=False):
|
||||||
|
rel = entry.path[bucket_len:]
|
||||||
|
first_part = rel.split(os.sep)[0] if os.sep in rel else rel
|
||||||
|
if first_part in self.INTERNAL_FOLDERS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = rel.replace(os.sep, '/')
|
||||||
|
try:
|
||||||
|
stat = entry.stat()
|
||||||
|
|
||||||
|
etag = meta_cache.get(key)
|
||||||
|
|
||||||
|
if not etag:
|
||||||
|
etag = f'"{stat.st_size}-{int(stat.st_mtime)}"'
|
||||||
|
|
||||||
|
objects[key] = ObjectMeta(
|
||||||
|
key=key,
|
||||||
|
size=stat.st_size,
|
||||||
|
last_modified=datetime.fromtimestamp(stat.st_mtime, timezone.utc),
|
||||||
|
etag=etag,
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
scan_dir(bucket_str)
|
||||||
|
return objects
|
||||||
|
|
||||||
|
def _get_object_cache(self, bucket_id: str, bucket_path: Path) -> Dict[str, ObjectMeta]:
|
||||||
|
"""Get cached object metadata for a bucket, refreshing if stale."""
|
||||||
|
now = time.time()
|
||||||
|
cached = self._object_cache.get(bucket_id)
|
||||||
|
|
||||||
|
if cached:
|
||||||
|
objects, timestamp = cached
|
||||||
|
if now - timestamp < self.KEY_INDEX_CACHE_TTL:
|
||||||
|
return objects
|
||||||
|
|
||||||
|
objects = self._build_object_cache(bucket_path)
|
||||||
|
self._object_cache[bucket_id] = (objects, now)
|
||||||
|
return objects
|
||||||
|
|
||||||
|
def _invalidate_object_cache(self, bucket_id: str) -> None:
|
||||||
|
"""Invalidate the object cache and etag index for a bucket."""
|
||||||
|
self._object_cache.pop(bucket_id, None)
|
||||||
|
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
||||||
|
try:
|
||||||
|
etag_index_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _ensure_system_roots(self) -> None:
|
def _ensure_system_roots(self) -> None:
|
||||||
for path in (
|
for path in (
|
||||||
self._system_root_path(),
|
self._system_root_path(),
|
||||||
|
|||||||
426
app/ui.py
426
app/ui.py
@@ -6,7 +6,7 @@ import uuid
|
|||||||
import psutil
|
import psutil
|
||||||
import shutil
|
import shutil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
import requests
|
import requests
|
||||||
@@ -30,6 +30,7 @@ from .bucket_policies import BucketPolicyStore
|
|||||||
from .connections import ConnectionStore, RemoteConnection
|
from .connections import ConnectionStore, RemoteConnection
|
||||||
from .extensions import limiter
|
from .extensions import limiter
|
||||||
from .iam import IamError
|
from .iam import IamError
|
||||||
|
from .kms import KMSManager
|
||||||
from .replication import ReplicationManager, ReplicationRule
|
from .replication import ReplicationManager, ReplicationRule
|
||||||
from .secret_store import EphemeralSecretStore
|
from .secret_store import EphemeralSecretStore
|
||||||
from .storage import ObjectStorage, StorageError
|
from .storage import ObjectStorage, StorageError
|
||||||
@@ -50,6 +51,9 @@ def _iam():
|
|||||||
return current_app.extensions["iam"]
|
return current_app.extensions["iam"]
|
||||||
|
|
||||||
|
|
||||||
|
def _kms() -> KMSManager | None:
|
||||||
|
return current_app.extensions.get("kms")
|
||||||
|
|
||||||
|
|
||||||
def _bucket_policies() -> BucketPolicyStore:
|
def _bucket_policies() -> BucketPolicyStore:
|
||||||
store: BucketPolicyStore = current_app.extensions["bucket_policies"]
|
store: BucketPolicyStore = current_app.extensions["bucket_policies"]
|
||||||
@@ -185,6 +189,7 @@ def inject_nav_state() -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"principal": principal,
|
"principal": principal,
|
||||||
"can_manage_iam": can_manage,
|
"can_manage_iam": can_manage,
|
||||||
|
"can_view_metrics": can_manage,
|
||||||
"csrf_token": generate_csrf,
|
"csrf_token": generate_csrf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,9 +260,9 @@ def buckets_overview():
|
|||||||
visible_buckets.append({
|
visible_buckets.append({
|
||||||
"meta": bucket,
|
"meta": bucket,
|
||||||
"summary": {
|
"summary": {
|
||||||
"objects": stats["objects"],
|
"objects": stats["total_objects"],
|
||||||
"total_bytes": stats["bytes"],
|
"total_bytes": stats["total_bytes"],
|
||||||
"human_size": _format_bytes(stats["bytes"]),
|
"human_size": _format_bytes(stats["total_bytes"]),
|
||||||
},
|
},
|
||||||
"access_label": access_label,
|
"access_label": access_label,
|
||||||
"access_badge": access_badge,
|
"access_badge": access_badge,
|
||||||
@@ -289,7 +294,8 @@ def bucket_detail(bucket_name: str):
|
|||||||
storage = _storage()
|
storage = _storage()
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "list")
|
_authorize_ui(principal, bucket_name, "list")
|
||||||
objects = storage.list_objects(bucket_name)
|
if not storage.bucket_exists(bucket_name):
|
||||||
|
raise StorageError("Bucket does not exist")
|
||||||
except (StorageError, IamError) as exc:
|
except (StorageError, IamError) as exc:
|
||||||
flash(_friendly_error_message(exc), "danger")
|
flash(_friendly_error_message(exc), "danger")
|
||||||
return redirect(url_for("ui.buckets_overview"))
|
return redirect(url_for("ui.buckets_overview"))
|
||||||
@@ -336,26 +342,124 @@ def bucket_detail(bucket_name: str):
|
|||||||
except IamError:
|
except IamError:
|
||||||
can_manage_versioning = False
|
can_manage_versioning = False
|
||||||
|
|
||||||
# Replication info - don't compute sync status here (it's slow), let JS fetch it async
|
can_manage_replication = False
|
||||||
|
if principal:
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, bucket_name, "replication")
|
||||||
|
can_manage_replication = True
|
||||||
|
except IamError:
|
||||||
|
can_manage_replication = False
|
||||||
|
|
||||||
|
is_replication_admin = False
|
||||||
|
if principal:
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
|
is_replication_admin = True
|
||||||
|
except IamError:
|
||||||
|
is_replication_admin = False
|
||||||
|
|
||||||
replication_rule = _replication().get_rule(bucket_name)
|
replication_rule = _replication().get_rule(bucket_name)
|
||||||
connections = _connections().list()
|
connections = _connections().list() if (is_replication_admin or replication_rule) else []
|
||||||
|
|
||||||
|
encryption_config = storage.get_bucket_encryption(bucket_name)
|
||||||
|
kms_manager = _kms()
|
||||||
|
kms_keys = kms_manager.list_keys() if kms_manager else []
|
||||||
|
kms_enabled = current_app.config.get("KMS_ENABLED", False)
|
||||||
|
encryption_enabled = current_app.config.get("ENCRYPTION_ENABLED", False)
|
||||||
|
can_manage_encryption = can_manage_versioning # Same as other bucket properties
|
||||||
|
|
||||||
|
bucket_quota = storage.get_bucket_quota(bucket_name)
|
||||||
|
bucket_stats = storage.bucket_stats(bucket_name)
|
||||||
|
can_manage_quota = False
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
|
can_manage_quota = True
|
||||||
|
except IamError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
objects_api_url = url_for("ui.list_bucket_objects", bucket_name=bucket_name)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"bucket_detail.html",
|
"bucket_detail.html",
|
||||||
bucket_name=bucket_name,
|
bucket_name=bucket_name,
|
||||||
objects=objects,
|
objects_api_url=objects_api_url,
|
||||||
principal=principal,
|
principal=principal,
|
||||||
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_versioning=can_manage_versioning,
|
can_manage_versioning=can_manage_versioning,
|
||||||
|
can_manage_replication=can_manage_replication,
|
||||||
|
can_manage_encryption=can_manage_encryption,
|
||||||
|
is_replication_admin=is_replication_admin,
|
||||||
default_policy=default_policy,
|
default_policy=default_policy,
|
||||||
versioning_enabled=versioning_enabled,
|
versioning_enabled=versioning_enabled,
|
||||||
replication_rule=replication_rule,
|
replication_rule=replication_rule,
|
||||||
connections=connections,
|
connections=connections,
|
||||||
|
encryption_config=encryption_config,
|
||||||
|
kms_keys=kms_keys,
|
||||||
|
kms_enabled=kms_enabled,
|
||||||
|
encryption_enabled=encryption_enabled,
|
||||||
|
bucket_quota=bucket_quota,
|
||||||
|
bucket_stats=bucket_stats,
|
||||||
|
can_manage_quota=can_manage_quota,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ui_bp.get("/buckets/<bucket_name>/objects")
|
||||||
|
def list_bucket_objects(bucket_name: str):
|
||||||
|
"""API endpoint for paginated object listing."""
|
||||||
|
principal = _current_principal()
|
||||||
|
storage = _storage()
|
||||||
|
try:
|
||||||
|
_authorize_ui(principal, bucket_name, "list")
|
||||||
|
except IamError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 403
|
||||||
|
|
||||||
|
max_keys = min(int(request.args.get("max_keys", 1000)), 10000)
|
||||||
|
continuation_token = request.args.get("continuation_token") or None
|
||||||
|
prefix = request.args.get("prefix") or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = storage.list_objects(
|
||||||
|
bucket_name,
|
||||||
|
max_keys=max_keys,
|
||||||
|
continuation_token=continuation_token,
|
||||||
|
prefix=prefix,
|
||||||
|
)
|
||||||
|
except StorageError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
versioning_enabled = storage.is_versioning_enabled(bucket_name)
|
||||||
|
except StorageError:
|
||||||
|
versioning_enabled = False
|
||||||
|
|
||||||
|
objects_data = []
|
||||||
|
for obj in result.objects:
|
||||||
|
objects_data.append({
|
||||||
|
"key": obj.key,
|
||||||
|
"size": obj.size,
|
||||||
|
"last_modified": obj.last_modified.isoformat(),
|
||||||
|
"last_modified_display": obj.last_modified.strftime("%b %d, %Y %H:%M"),
|
||||||
|
"etag": obj.etag,
|
||||||
|
"metadata": obj.metadata or {},
|
||||||
|
"preview_url": url_for("ui.object_preview", bucket_name=bucket_name, object_key=obj.key),
|
||||||
|
"download_url": url_for("ui.object_preview", bucket_name=bucket_name, object_key=obj.key) + "?download=1",
|
||||||
|
"presign_endpoint": url_for("ui.object_presign", bucket_name=bucket_name, object_key=obj.key),
|
||||||
|
"delete_endpoint": url_for("ui.delete_object", bucket_name=bucket_name, object_key=obj.key),
|
||||||
|
"versions_endpoint": url_for("ui.object_versions", bucket_name=bucket_name, object_key=obj.key),
|
||||||
|
"restore_template": url_for("ui.restore_object_version", bucket_name=bucket_name, object_key=obj.key, version_id="VERSION_ID_PLACEHOLDER"),
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"objects": objects_data,
|
||||||
|
"is_truncated": result.is_truncated,
|
||||||
|
"next_continuation_token": result.next_continuation_token,
|
||||||
|
"total_count": result.total_count,
|
||||||
|
"versioning_enabled": versioning_enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.post("/buckets/<bucket_name>/upload")
|
@ui_bp.post("/buckets/<bucket_name>/upload")
|
||||||
@limiter.limit("30 per minute")
|
@limiter.limit("30 per minute")
|
||||||
def upload_object(bucket_name: str):
|
def upload_object(bucket_name: str):
|
||||||
@@ -626,32 +730,30 @@ def bulk_download_objects(bucket_name: str):
|
|||||||
unique_keys = list(dict.fromkeys(cleaned))
|
unique_keys = list(dict.fromkeys(cleaned))
|
||||||
storage = _storage()
|
storage = _storage()
|
||||||
|
|
||||||
# Check permissions for all keys first (or at least bucket read)
|
# Verify permission to read bucket contents
|
||||||
# We'll check bucket read once, then object read for each if needed?
|
|
||||||
# _authorize_ui checks bucket level if object_key is None, but we need to check each object if fine-grained policies exist.
|
|
||||||
# For simplicity/performance, we check bucket list/read.
|
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "read")
|
_authorize_ui(principal, bucket_name, "read")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return jsonify({"error": str(exc)}), 403
|
return jsonify({"error": str(exc)}), 403
|
||||||
|
|
||||||
# Create ZIP
|
# Create ZIP archive of selected objects
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
for key in unique_keys:
|
for key in unique_keys:
|
||||||
try:
|
try:
|
||||||
# Verify individual object permission if needed?
|
|
||||||
# _authorize_ui(principal, bucket_name, "read", object_key=key)
|
|
||||||
# This might be slow for many objects. Assuming bucket read is enough for now or we accept the overhead.
|
|
||||||
# Let's skip individual check for bulk speed, assuming bucket read implies object read unless denied.
|
|
||||||
# But strictly we should check. Let's check.
|
|
||||||
_authorize_ui(principal, bucket_name, "read", object_key=key)
|
_authorize_ui(principal, bucket_name, "read", object_key=key)
|
||||||
|
|
||||||
|
metadata = storage.get_object_metadata(bucket_name, key)
|
||||||
|
is_encrypted = "x-amz-server-side-encryption" in metadata
|
||||||
|
|
||||||
|
if is_encrypted and hasattr(storage, 'get_object_data'):
|
||||||
|
data, _ = storage.get_object_data(bucket_name, key)
|
||||||
|
zf.writestr(key, data)
|
||||||
|
else:
|
||||||
path = storage.get_object_path(bucket_name, key)
|
path = storage.get_object_path(bucket_name, key)
|
||||||
# Use the key as the filename in the zip
|
|
||||||
zf.write(path, arcname=key)
|
zf.write(path, arcname=key)
|
||||||
except (StorageError, IamError):
|
except (StorageError, IamError):
|
||||||
# Skip files we can't read or don't exist
|
# Skip objects that can't be accessed
|
||||||
continue
|
continue
|
||||||
|
|
||||||
buffer.seek(0)
|
buffer.seek(0)
|
||||||
@@ -691,13 +793,34 @@ def purge_object_versions(bucket_name: str, object_key: str):
|
|||||||
@ui_bp.get("/buckets/<bucket_name>/objects/<path:object_key>/preview")
|
@ui_bp.get("/buckets/<bucket_name>/objects/<path:object_key>/preview")
|
||||||
def object_preview(bucket_name: str, object_key: str) -> Response:
|
def object_preview(bucket_name: str, object_key: str) -> Response:
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
|
storage = _storage()
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "read", object_key=object_key)
|
_authorize_ui(principal, bucket_name, "read", object_key=object_key)
|
||||||
path = _storage().get_object_path(bucket_name, object_key)
|
path = storage.get_object_path(bucket_name, object_key)
|
||||||
|
metadata = storage.get_object_metadata(bucket_name, object_key)
|
||||||
except (StorageError, IamError) as exc:
|
except (StorageError, IamError) as exc:
|
||||||
status = 403 if isinstance(exc, IamError) else 404
|
status = 403 if isinstance(exc, IamError) else 404
|
||||||
return Response(str(exc), status=status)
|
return Response(str(exc), status=status)
|
||||||
|
|
||||||
download = request.args.get("download") == "1"
|
download = request.args.get("download") == "1"
|
||||||
|
|
||||||
|
# Check if object is encrypted and needs decryption
|
||||||
|
is_encrypted = "x-amz-server-side-encryption" in metadata
|
||||||
|
if is_encrypted and hasattr(storage, 'get_object_data'):
|
||||||
|
try:
|
||||||
|
data, _ = storage.get_object_data(bucket_name, object_key)
|
||||||
|
import io
|
||||||
|
import mimetypes
|
||||||
|
mimetype = mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
|
return send_file(
|
||||||
|
io.BytesIO(data),
|
||||||
|
mimetype=mimetype,
|
||||||
|
as_attachment=download,
|
||||||
|
download_name=path.name
|
||||||
|
)
|
||||||
|
except StorageError as exc:
|
||||||
|
return Response(f"Decryption failed: {exc}", status=500)
|
||||||
|
|
||||||
return send_file(path, as_attachment=download, download_name=path.name)
|
return send_file(path, as_attachment=download, download_name=path.name)
|
||||||
|
|
||||||
|
|
||||||
@@ -712,12 +835,16 @@ def object_presign(bucket_name: str, object_key: str):
|
|||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return jsonify({"error": str(exc)}), 403
|
return jsonify({"error": str(exc)}), 403
|
||||||
|
|
||||||
connection_url = "http://127.0.0.1:5000"
|
api_base = current_app.config.get("API_BASE_URL") or "http://127.0.0.1:5000"
|
||||||
url = f"{connection_url}/presign/{bucket_name}/{object_key}"
|
api_base = api_base.rstrip("/")
|
||||||
|
encoded_key = quote(object_key, safe="/")
|
||||||
|
url = f"{api_base}/presign/{bucket_name}/{encoded_key}"
|
||||||
|
|
||||||
|
# Use API base URL for forwarded headers so presigned URLs point to API, not UI
|
||||||
|
parsed_api = urlparse(api_base)
|
||||||
headers = _api_headers()
|
headers = _api_headers()
|
||||||
headers["X-Forwarded-Host"] = request.host
|
headers["X-Forwarded-Host"] = parsed_api.netloc or "127.0.0.1:5000"
|
||||||
headers["X-Forwarded-Proto"] = request.scheme
|
headers["X-Forwarded-Proto"] = parsed_api.scheme or "http"
|
||||||
headers["X-Forwarded-For"] = request.remote_addr or "127.0.0.1"
|
headers["X-Forwarded-For"] = request.remote_addr or "127.0.0.1"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -853,6 +980,124 @@ def update_bucket_versioning(bucket_name: str):
|
|||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
|
||||||
|
@ui_bp.post("/buckets/<bucket_name>/quota")
|
||||||
|
def update_bucket_quota(bucket_name: str):
|
||||||
|
"""Update bucket quota configuration (admin only)."""
|
||||||
|
principal = _current_principal()
|
||||||
|
|
||||||
|
# Quota management is admin-only
|
||||||
|
is_admin = False
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
|
is_admin = True
|
||||||
|
except IamError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
flash("Only administrators can manage bucket quotas", "danger")
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
action = request.form.get("action", "set")
|
||||||
|
|
||||||
|
if action == "remove":
|
||||||
|
try:
|
||||||
|
_storage().set_bucket_quota(bucket_name, max_bytes=None, max_objects=None)
|
||||||
|
flash("Bucket quota removed", "info")
|
||||||
|
except StorageError as exc:
|
||||||
|
flash(_friendly_error_message(exc), "danger")
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
# Parse quota values
|
||||||
|
max_mb_str = request.form.get("max_mb", "").strip()
|
||||||
|
max_objects_str = request.form.get("max_objects", "").strip()
|
||||||
|
|
||||||
|
max_bytes = None
|
||||||
|
max_objects = None
|
||||||
|
|
||||||
|
if max_mb_str:
|
||||||
|
try:
|
||||||
|
max_mb = int(max_mb_str)
|
||||||
|
if max_mb < 1:
|
||||||
|
raise ValueError("Size must be at least 1 MB")
|
||||||
|
max_bytes = max_mb * 1024 * 1024 # Convert MB to bytes
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(f"Invalid size value: {exc}", "danger")
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
if max_objects_str:
|
||||||
|
try:
|
||||||
|
max_objects = int(max_objects_str)
|
||||||
|
if max_objects < 0:
|
||||||
|
raise ValueError("Object count must be non-negative")
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(f"Invalid object count: {exc}", "danger")
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
_storage().set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects)
|
||||||
|
if max_bytes is None and max_objects is None:
|
||||||
|
flash("Bucket quota removed", "info")
|
||||||
|
else:
|
||||||
|
flash("Bucket quota updated", "success")
|
||||||
|
except StorageError as exc:
|
||||||
|
flash(_friendly_error_message(exc), "danger")
|
||||||
|
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
|
||||||
|
@ui_bp.post("/buckets/<bucket_name>/encryption")
|
||||||
|
def update_bucket_encryption(bucket_name: str):
|
||||||
|
"""Update bucket default encryption configuration."""
|
||||||
|
principal = _current_principal()
|
||||||
|
try:
|
||||||
|
_authorize_ui(principal, bucket_name, "write")
|
||||||
|
except IamError as exc:
|
||||||
|
flash(_friendly_error_message(exc), "danger")
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
action = request.form.get("action", "enable")
|
||||||
|
|
||||||
|
if action == "disable":
|
||||||
|
try:
|
||||||
|
_storage().set_bucket_encryption(bucket_name, None)
|
||||||
|
flash("Default encryption disabled", "info")
|
||||||
|
except StorageError as exc:
|
||||||
|
flash(_friendly_error_message(exc), "danger")
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
algorithm = request.form.get("algorithm", "AES256")
|
||||||
|
kms_key_id = request.form.get("kms_key_id", "").strip() or None
|
||||||
|
|
||||||
|
if algorithm not in ("AES256", "aws:kms"):
|
||||||
|
flash("Invalid encryption algorithm", "danger")
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
# Build encryption configuration in AWS S3 format
|
||||||
|
encryption_config: dict[str, Any] = {
|
||||||
|
"Rules": [
|
||||||
|
{
|
||||||
|
"ApplyServerSideEncryptionByDefault": {
|
||||||
|
"SSEAlgorithm": algorithm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if algorithm == "aws:kms" and kms_key_id:
|
||||||
|
encryption_config["Rules"][0]["ApplyServerSideEncryptionByDefault"]["KMSMasterKeyID"] = kms_key_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
_storage().set_bucket_encryption(bucket_name, encryption_config)
|
||||||
|
if algorithm == "aws:kms":
|
||||||
|
flash("Default KMS encryption enabled", "success")
|
||||||
|
else:
|
||||||
|
flash("Default AES-256 encryption enabled", "success")
|
||||||
|
except StorageError as exc:
|
||||||
|
flash(_friendly_error_message(exc), "danger")
|
||||||
|
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="properties"))
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.get("/iam")
|
@ui_bp.get("/iam")
|
||||||
def iam_dashboard():
|
def iam_dashboard():
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
@@ -1003,7 +1248,6 @@ def delete_iam_user(access_key: str):
|
|||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
if access_key == principal.access_key:
|
if access_key == principal.access_key:
|
||||||
# Self-deletion
|
|
||||||
try:
|
try:
|
||||||
_iam().delete_user(access_key)
|
_iam().delete_user(access_key)
|
||||||
session.pop("credentials", None)
|
session.pop("credentials", None)
|
||||||
@@ -1085,6 +1329,9 @@ def create_connection():
|
|||||||
|
|
||||||
@ui_bp.post("/connections/test")
|
@ui_bp.post("/connections/test")
|
||||||
def test_connection():
|
def test_connection():
|
||||||
|
from botocore.config import Config as BotoConfig
|
||||||
|
from botocore.exceptions import ConnectTimeoutError, EndpointConnectionError, ReadTimeoutError
|
||||||
|
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
try:
|
try:
|
||||||
_iam().authorize(principal, None, "iam:list_users")
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
@@ -1101,18 +1348,32 @@ def test_connection():
|
|||||||
return jsonify({"status": "error", "message": "Missing credentials"}), 400
|
return jsonify({"status": "error", "message": "Missing credentials"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
config = BotoConfig(
|
||||||
|
connect_timeout=5,
|
||||||
|
read_timeout=10,
|
||||||
|
retries={'max_attempts': 1}
|
||||||
|
)
|
||||||
s3 = boto3.client(
|
s3 = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
endpoint_url=endpoint,
|
endpoint_url=endpoint,
|
||||||
aws_access_key_id=access_key,
|
aws_access_key_id=access_key,
|
||||||
aws_secret_access_key=secret_key,
|
aws_secret_access_key=secret_key,
|
||||||
region_name=region,
|
region_name=region,
|
||||||
|
config=config,
|
||||||
)
|
)
|
||||||
# Try to list buckets to verify credentials and endpoint
|
|
||||||
s3.list_buckets()
|
s3.list_buckets()
|
||||||
return jsonify({"status": "ok", "message": "Connection successful"})
|
return jsonify({"status": "ok", "message": "Connection successful"})
|
||||||
|
except (ConnectTimeoutError, ReadTimeoutError):
|
||||||
|
return jsonify({"status": "error", "message": f"Connection timed out - endpoint may be down or unreachable: {endpoint}"}), 400
|
||||||
|
except EndpointConnectionError:
|
||||||
|
return jsonify({"status": "error", "message": f"Could not connect to endpoint: {endpoint}"}), 400
|
||||||
|
except ClientError as e:
|
||||||
|
error_code = e.response.get('Error', {}).get('Code', 'Unknown')
|
||||||
|
error_msg = e.response.get('Error', {}).get('Message', str(e))
|
||||||
|
return jsonify({"status": "error", "message": f"Connection failed ({error_code}): {error_msg}"}), 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"status": "error", "message": str(e)}), 400
|
return jsonify({"status": "error", "message": f"Connection failed: {str(e)}"}), 400
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.post("/connections/<connection_id>/update")
|
@ui_bp.post("/connections/<connection_id>/update")
|
||||||
@@ -1168,17 +1429,47 @@ def delete_connection(connection_id: str):
|
|||||||
def update_bucket_replication(bucket_name: str):
|
def update_bucket_replication(bucket_name: str):
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "write")
|
_authorize_ui(principal, bucket_name, "replication")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
||||||
|
|
||||||
|
is_admin = False
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
|
is_admin = True
|
||||||
|
except IamError:
|
||||||
|
is_admin = False
|
||||||
|
|
||||||
action = request.form.get("action")
|
action = request.form.get("action")
|
||||||
|
|
||||||
if action == "delete":
|
if action == "delete":
|
||||||
|
if not is_admin:
|
||||||
|
flash("Only administrators can remove replication configuration", "danger")
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
||||||
_replication().delete_rule(bucket_name)
|
_replication().delete_rule(bucket_name)
|
||||||
flash("Replication disabled", "info")
|
flash("Replication configuration removed", "info")
|
||||||
|
elif action == "pause":
|
||||||
|
rule = _replication().get_rule(bucket_name)
|
||||||
|
if rule:
|
||||||
|
rule.enabled = False
|
||||||
|
_replication().set_rule(rule)
|
||||||
|
flash("Replication paused", "info")
|
||||||
else:
|
else:
|
||||||
|
flash("No replication configuration to pause", "warning")
|
||||||
|
elif action == "resume":
|
||||||
|
rule = _replication().get_rule(bucket_name)
|
||||||
|
if rule:
|
||||||
|
rule.enabled = True
|
||||||
|
_replication().set_rule(rule)
|
||||||
|
flash("Replication resumed", "success")
|
||||||
|
else:
|
||||||
|
flash("No replication configuration to resume", "warning")
|
||||||
|
elif action == "create":
|
||||||
|
if not is_admin:
|
||||||
|
flash("Only administrators can configure replication settings", "danger")
|
||||||
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
||||||
|
|
||||||
from .replication import REPLICATION_MODE_NEW_ONLY, REPLICATION_MODE_ALL
|
from .replication import REPLICATION_MODE_NEW_ONLY, REPLICATION_MODE_ALL
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -1199,12 +1490,13 @@ def update_bucket_replication(bucket_name: str):
|
|||||||
)
|
)
|
||||||
_replication().set_rule(rule)
|
_replication().set_rule(rule)
|
||||||
|
|
||||||
# If mode is "all", trigger replication of existing objects
|
|
||||||
if replication_mode == REPLICATION_MODE_ALL:
|
if replication_mode == REPLICATION_MODE_ALL:
|
||||||
_replication().replicate_existing_objects(bucket_name)
|
_replication().replicate_existing_objects(bucket_name)
|
||||||
flash("Replication configured. Existing objects are being replicated in the background.", "success")
|
flash("Replication configured. Existing objects are being replicated in the background.", "success")
|
||||||
else:
|
else:
|
||||||
flash("Replication configured. Only new uploads will be replicated.", "success")
|
flash("Replication configured. Only new uploads will be replicated.", "success")
|
||||||
|
else:
|
||||||
|
flash("Invalid action", "danger")
|
||||||
|
|
||||||
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
|
||||||
|
|
||||||
@@ -1214,7 +1506,7 @@ def get_replication_status(bucket_name: str):
|
|||||||
"""Async endpoint to fetch replication sync status without blocking page load."""
|
"""Async endpoint to fetch replication sync status without blocking page load."""
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "read")
|
_authorize_ui(principal, bucket_name, "replication")
|
||||||
except IamError:
|
except IamError:
|
||||||
return jsonify({"error": "Access denied"}), 403
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
@@ -1222,10 +1514,31 @@ def get_replication_status(bucket_name: str):
|
|||||||
if not rule:
|
if not rule:
|
||||||
return jsonify({"error": "No replication rule"}), 404
|
return jsonify({"error": "No replication rule"}), 404
|
||||||
|
|
||||||
# This is the slow operation - compute sync status by comparing buckets
|
connection = _connections().get(rule.target_connection_id)
|
||||||
|
endpoint_healthy = False
|
||||||
|
endpoint_error = None
|
||||||
|
if connection:
|
||||||
|
endpoint_healthy = _replication().check_endpoint_health(connection)
|
||||||
|
if not endpoint_healthy:
|
||||||
|
endpoint_error = f"Cannot reach endpoint: {connection.endpoint_url}"
|
||||||
|
else:
|
||||||
|
endpoint_error = "Target connection not found"
|
||||||
|
|
||||||
|
stats = None
|
||||||
|
if endpoint_healthy:
|
||||||
stats = _replication().get_sync_status(bucket_name)
|
stats = _replication().get_sync_status(bucket_name)
|
||||||
|
|
||||||
if not stats:
|
if not stats:
|
||||||
return jsonify({"error": "Failed to compute status"}), 500
|
return jsonify({
|
||||||
|
"objects_synced": 0,
|
||||||
|
"objects_pending": 0,
|
||||||
|
"objects_orphaned": 0,
|
||||||
|
"bytes_synced": 0,
|
||||||
|
"last_sync_at": rule.stats.last_sync_at if rule.stats else None,
|
||||||
|
"last_sync_key": rule.stats.last_sync_key if rule.stats else None,
|
||||||
|
"endpoint_healthy": endpoint_healthy,
|
||||||
|
"endpoint_error": endpoint_error,
|
||||||
|
})
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"objects_synced": stats.objects_synced,
|
"objects_synced": stats.objects_synced,
|
||||||
@@ -1234,6 +1547,28 @@ def get_replication_status(bucket_name: str):
|
|||||||
"bytes_synced": stats.bytes_synced,
|
"bytes_synced": stats.bytes_synced,
|
||||||
"last_sync_at": stats.last_sync_at,
|
"last_sync_at": stats.last_sync_at,
|
||||||
"last_sync_key": stats.last_sync_key,
|
"last_sync_key": stats.last_sync_key,
|
||||||
|
"endpoint_healthy": endpoint_healthy,
|
||||||
|
"endpoint_error": endpoint_error,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ui_bp.get("/connections/<connection_id>/health")
|
||||||
|
def check_connection_health(connection_id: str):
|
||||||
|
"""Check if a connection endpoint is reachable."""
|
||||||
|
principal = _current_principal()
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
|
except IamError:
|
||||||
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
conn = _connections().get(connection_id)
|
||||||
|
if not conn:
|
||||||
|
return jsonify({"healthy": False, "error": "Connection not found"}), 404
|
||||||
|
|
||||||
|
healthy = _replication().check_endpoint_health(conn)
|
||||||
|
return jsonify({
|
||||||
|
"healthy": healthy,
|
||||||
|
"error": None if healthy else f"Cannot reach endpoint: {conn.endpoint_url}"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1254,6 +1589,15 @@ def connections_dashboard():
|
|||||||
def metrics_dashboard():
|
def metrics_dashboard():
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, None, "iam:list_users")
|
||||||
|
except IamError:
|
||||||
|
flash("Access denied: Metrics require admin permissions", "danger")
|
||||||
|
return redirect(url_for("ui.buckets_overview"))
|
||||||
|
|
||||||
|
from app.version import APP_VERSION
|
||||||
|
import time
|
||||||
|
|
||||||
cpu_percent = psutil.cpu_percent(interval=0.1)
|
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||||
memory = psutil.virtual_memory()
|
memory = psutil.virtual_memory()
|
||||||
|
|
||||||
@@ -1266,13 +1610,18 @@ def metrics_dashboard():
|
|||||||
|
|
||||||
total_objects = 0
|
total_objects = 0
|
||||||
total_bytes_used = 0
|
total_bytes_used = 0
|
||||||
|
total_versions = 0
|
||||||
|
|
||||||
# Note: Uses cached stats from storage layer to improve performance
|
|
||||||
cache_ttl = current_app.config.get("BUCKET_STATS_CACHE_TTL", 60)
|
cache_ttl = current_app.config.get("BUCKET_STATS_CACHE_TTL", 60)
|
||||||
for bucket in buckets:
|
for bucket in buckets:
|
||||||
stats = storage.bucket_stats(bucket.name, cache_ttl=cache_ttl)
|
stats = storage.bucket_stats(bucket.name, cache_ttl=cache_ttl)
|
||||||
total_objects += stats["objects"]
|
total_objects += stats.get("total_objects", stats.get("objects", 0))
|
||||||
total_bytes_used += stats["bytes"]
|
total_bytes_used += stats.get("total_bytes", stats.get("bytes", 0))
|
||||||
|
total_versions += stats.get("version_count", 0)
|
||||||
|
|
||||||
|
boot_time = psutil.boot_time()
|
||||||
|
uptime_seconds = time.time() - boot_time
|
||||||
|
uptime_days = int(uptime_seconds / 86400)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"metrics.html",
|
"metrics.html",
|
||||||
@@ -1293,8 +1642,11 @@ def metrics_dashboard():
|
|||||||
app={
|
app={
|
||||||
"buckets": total_buckets,
|
"buckets": total_buckets,
|
||||||
"objects": total_objects,
|
"objects": total_objects,
|
||||||
|
"versions": total_versions,
|
||||||
"storage_used": _format_bytes(total_bytes_used),
|
"storage_used": _format_bytes(total_bytes_used),
|
||||||
"storage_raw": total_bytes_used,
|
"storage_raw": total_bytes_used,
|
||||||
|
"version": APP_VERSION,
|
||||||
|
"uptime_days": uptime_days,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Central location for the application version string."""
|
"""Central location for the application version string."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
APP_VERSION = "0.1.2"
|
APP_VERSION = "0.1.8"
|
||||||
|
|
||||||
|
|
||||||
def get_version() -> str:
|
def get_version() -> str:
|
||||||
|
|||||||
789
docs.md
789
docs.md
@@ -33,6 +33,63 @@ python run.py --mode api # API only (port 5000)
|
|||||||
python run.py --mode ui # UI only (port 5100)
|
python run.py --mode ui # UI only (port 5100)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Configuration validation
|
||||||
|
|
||||||
|
Validate your configuration before deploying:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show configuration summary
|
||||||
|
python run.py --show-config
|
||||||
|
./myfsio --show-config
|
||||||
|
|
||||||
|
# Validate and check for issues (exits with code 1 if critical issues found)
|
||||||
|
python run.py --check-config
|
||||||
|
./myfsio --check-config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux Installation (Recommended for Production)
|
||||||
|
|
||||||
|
For production deployments on Linux, use the provided installation script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download the binary and install script
|
||||||
|
# Then run the installer with sudo:
|
||||||
|
sudo ./scripts/install.sh --binary ./myfsio
|
||||||
|
|
||||||
|
# Or with custom paths:
|
||||||
|
sudo ./scripts/install.sh \
|
||||||
|
--binary ./myfsio \
|
||||||
|
--install-dir /opt/myfsio \
|
||||||
|
--data-dir /mnt/storage/myfsio \
|
||||||
|
--log-dir /var/log/myfsio \
|
||||||
|
--api-url https://s3.example.com \
|
||||||
|
--user myfsio
|
||||||
|
|
||||||
|
# Non-interactive mode (for automation):
|
||||||
|
sudo ./scripts/install.sh --binary ./myfsio -y
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer will:
|
||||||
|
1. Create a dedicated system user
|
||||||
|
2. Set up directories with proper permissions
|
||||||
|
3. Generate a secure `SECRET_KEY`
|
||||||
|
4. Create an environment file at `/opt/myfsio/myfsio.env`
|
||||||
|
5. Install and configure a systemd service
|
||||||
|
|
||||||
|
After installation:
|
||||||
|
```bash
|
||||||
|
sudo systemctl start myfsio # Start the service
|
||||||
|
sudo systemctl enable myfsio # Enable on boot
|
||||||
|
sudo systemctl status myfsio # Check status
|
||||||
|
sudo journalctl -u myfsio -f # View logs
|
||||||
|
```
|
||||||
|
|
||||||
|
To uninstall:
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/uninstall.sh # Full removal
|
||||||
|
sudo ./scripts/uninstall.sh --keep-data # Keep data directory
|
||||||
|
```
|
||||||
|
|
||||||
### Docker quickstart
|
### Docker quickstart
|
||||||
|
|
||||||
The repo now ships a `Dockerfile` so you can run both services in one container:
|
The repo now ships a `Dockerfile` so you can run both services in one container:
|
||||||
@@ -69,19 +126,97 @@ The repo now tracks a human-friendly release string inside `app/version.py` (see
|
|||||||
|
|
||||||
## 3. Configuration Reference
|
## 3. Configuration Reference
|
||||||
|
|
||||||
|
All configuration is done via environment variables. The table below lists every supported variable.
|
||||||
|
|
||||||
|
### Core Settings
|
||||||
|
|
||||||
| Variable | Default | Notes |
|
| Variable | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `STORAGE_ROOT` | `<repo>/data` | Filesystem home for all buckets/objects. |
|
| `STORAGE_ROOT` | `<repo>/data` | Filesystem home for all buckets/objects. |
|
||||||
| `MAX_UPLOAD_SIZE` | `1073741824` | Bytes. Caps incoming uploads in both API + UI. |
|
| `MAX_UPLOAD_SIZE` | `1073741824` (1 GiB) | Bytes. Caps incoming uploads in both API + UI. |
|
||||||
| `UI_PAGE_SIZE` | `100` | `MaxKeys` hint shown in listings. |
|
| `UI_PAGE_SIZE` | `100` | `MaxKeys` hint shown in listings. |
|
||||||
| `SECRET_KEY` | `dev-secret-key` | Flask session key for UI auth. |
|
| `SECRET_KEY` | Auto-generated | Flask session key. Auto-generates and persists if not set. **Set explicitly in production.** |
|
||||||
| `IAM_CONFIG` | `<repo>/data/.myfsio.sys/config/iam.json` | Stores users, secrets, and inline policies. |
|
| `API_BASE_URL` | `None` | Public URL for presigned URLs. Required behind proxies. |
|
||||||
| `BUCKET_POLICY_PATH` | `<repo>/data/.myfsio.sys/config/bucket_policies.json` | Bucket policy store (auto hot-reload). |
|
|
||||||
| `API_BASE_URL` | `None` | Used by the UI to hit API endpoints (presign/policy). If unset, the UI will auto-detect the host or use `X-Forwarded-*` headers. |
|
|
||||||
| `AWS_REGION` | `us-east-1` | Region embedded in SigV4 credential scope. |
|
| `AWS_REGION` | `us-east-1` | Region embedded in SigV4 credential scope. |
|
||||||
| `AWS_SERVICE` | `s3` | Service string for SigV4. |
|
| `AWS_SERVICE` | `s3` | Service string for SigV4. |
|
||||||
|
|
||||||
Set env vars (or pass overrides to `create_app`) to point the servers at custom paths.
|
### IAM & Security
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `IAM_CONFIG` | `data/.myfsio.sys/config/iam.json` | Stores users, secrets, and inline policies. |
|
||||||
|
| `BUCKET_POLICY_PATH` | `data/.myfsio.sys/config/bucket_policies.json` | Bucket policy store (auto hot-reload). |
|
||||||
|
| `AUTH_MAX_ATTEMPTS` | `5` | Failed login attempts before lockout. |
|
||||||
|
| `AUTH_LOCKOUT_MINUTES` | `15` | Lockout duration after max failed attempts. |
|
||||||
|
| `SESSION_LIFETIME_DAYS` | `30` | How long UI sessions remain valid. |
|
||||||
|
| `SECRET_TTL_SECONDS` | `300` | TTL for ephemeral secrets (presigned URLs). |
|
||||||
|
| `UI_ENFORCE_BUCKET_POLICIES` | `false` | Whether the UI should enforce bucket policies. |
|
||||||
|
|
||||||
|
### CORS (Cross-Origin Resource Sharing)
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CORS_ORIGINS` | `*` | Comma-separated allowed origins. Use specific domains in production. |
|
||||||
|
| `CORS_METHODS` | `GET,PUT,POST,DELETE,OPTIONS,HEAD` | Allowed HTTP methods. |
|
||||||
|
| `CORS_ALLOW_HEADERS` | `*` | Allowed request headers. |
|
||||||
|
| `CORS_EXPOSE_HEADERS` | `*` | Response headers visible to browsers (e.g., `ETag`). |
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `RATE_LIMIT_DEFAULT` | `200 per minute` | Default rate limit for API endpoints. |
|
||||||
|
| `RATE_LIMIT_STORAGE_URI` | `memory://` | Storage backend for rate limits. Use `redis://host:port` for distributed setups. |
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `LOG_LEVEL` | `INFO` | Log verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR`. |
|
||||||
|
| `LOG_TO_FILE` | `true` | Enable file logging. |
|
||||||
|
| `LOG_DIR` | `<repo>/logs` | Directory for log files. |
|
||||||
|
| `LOG_FILE` | `app.log` | Log filename. |
|
||||||
|
| `LOG_MAX_BYTES` | `5242880` (5 MB) | Max log file size before rotation. |
|
||||||
|
| `LOG_BACKUP_COUNT` | `3` | Number of rotated log files to keep. |
|
||||||
|
|
||||||
|
### Encryption
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ENCRYPTION_ENABLED` | `false` | Enable server-side encryption support. |
|
||||||
|
| `ENCRYPTION_MASTER_KEY_PATH` | `data/.myfsio.sys/keys/master.key` | Path to the master encryption key file. |
|
||||||
|
| `DEFAULT_ENCRYPTION_ALGORITHM` | `AES256` | Default algorithm for new encrypted objects. |
|
||||||
|
| `KMS_ENABLED` | `false` | Enable KMS key management for encryption. |
|
||||||
|
| `KMS_KEYS_PATH` | `data/.myfsio.sys/keys/kms_keys.json` | Path to store KMS key metadata. |
|
||||||
|
|
||||||
|
### Performance Tuning
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `STREAM_CHUNK_SIZE` | `65536` (64 KB) | Chunk size for streaming large files. |
|
||||||
|
| `MULTIPART_MIN_PART_SIZE` | `5242880` (5 MB) | Minimum part size for multipart uploads. |
|
||||||
|
| `BUCKET_STATS_CACHE_TTL` | `60` | Seconds to cache bucket statistics. |
|
||||||
|
| `BULK_DELETE_MAX_KEYS` | `500` | Maximum keys per bulk delete request. |
|
||||||
|
|
||||||
|
### Server Settings
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `APP_HOST` | `0.0.0.0` | Network interface to bind to. |
|
||||||
|
| `APP_PORT` | `5000` | API server port (UI uses 5100). |
|
||||||
|
| `FLASK_DEBUG` | `0` | Enable Flask debug mode. **Never enable in production.** |
|
||||||
|
|
||||||
|
### Production Checklist
|
||||||
|
|
||||||
|
Before deploying to production, ensure you:
|
||||||
|
|
||||||
|
1. **Set `SECRET_KEY`** - Use a strong, unique value (e.g., `openssl rand -base64 32`)
|
||||||
|
2. **Restrict CORS** - Set `CORS_ORIGINS` to your specific domains instead of `*`
|
||||||
|
3. **Configure `API_BASE_URL`** - Required for correct presigned URLs behind proxies
|
||||||
|
4. **Enable HTTPS** - Use a reverse proxy (nginx, Cloudflare) with TLS termination
|
||||||
|
5. **Review rate limits** - Adjust `RATE_LIMIT_DEFAULT` based on your needs
|
||||||
|
6. **Secure master keys** - Back up `ENCRYPTION_MASTER_KEY_PATH` if using encryption
|
||||||
|
7. **Use `--prod` flag** - Runs with Waitress instead of Flask dev server
|
||||||
|
|
||||||
### Proxy Configuration
|
### Proxy Configuration
|
||||||
|
|
||||||
@@ -91,6 +226,334 @@ If running behind a reverse proxy (e.g., Nginx, Cloudflare, or a tunnel), ensure
|
|||||||
|
|
||||||
The application automatically trusts these headers to generate correct presigned URLs (e.g., `https://s3.example.com/...` instead of `http://127.0.0.1:5000/...`). Alternatively, you can explicitly set `API_BASE_URL` to your public endpoint.
|
The application automatically trusts these headers to generate correct presigned URLs (e.g., `https://s3.example.com/...` instead of `http://127.0.0.1:5000/...`). Alternatively, you can explicitly set `API_BASE_URL` to your public endpoint.
|
||||||
|
|
||||||
|
## 4. Upgrading and Updates
|
||||||
|
|
||||||
|
### Version Checking
|
||||||
|
|
||||||
|
The application version is tracked in `app/version.py` and exposed via:
|
||||||
|
- **Health endpoint:** `GET /healthz` returns JSON with `version` field
|
||||||
|
- **Metrics dashboard:** Navigate to `/ui/metrics` to see the running version in the System Status card
|
||||||
|
|
||||||
|
To check your current version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API health endpoint
|
||||||
|
curl http://localhost:5000/healthz
|
||||||
|
|
||||||
|
# Or inspect version.py directly
|
||||||
|
cat app/version.py | grep APP_VERSION
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-Update Backup Procedures
|
||||||
|
|
||||||
|
**Always backup before upgrading to prevent data loss:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop the application
|
||||||
|
# Ctrl+C if running in terminal, or:
|
||||||
|
docker stop myfsio # if using Docker
|
||||||
|
|
||||||
|
# 2. Backup configuration files (CRITICAL)
|
||||||
|
mkdir -p backups/$(date +%Y%m%d_%H%M%S)
|
||||||
|
cp -r data/.myfsio.sys/config backups/$(date +%Y%m%d_%H%M%S)/
|
||||||
|
|
||||||
|
# 3. Backup all data (optional but recommended)
|
||||||
|
tar -czf backups/data_$(date +%Y%m%d_%H%M%S).tar.gz data/
|
||||||
|
|
||||||
|
# 4. Backup logs for audit trail
|
||||||
|
cp -r logs backups/$(date +%Y%m%d_%H%M%S)/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows PowerShell:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Create timestamped backup
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||||
|
New-Item -ItemType Directory -Path "backups\$timestamp" -Force
|
||||||
|
|
||||||
|
# Backup configs
|
||||||
|
Copy-Item -Recurse "data\.myfsio.sys\config" "backups\$timestamp\"
|
||||||
|
|
||||||
|
# Backup entire data directory
|
||||||
|
Compress-Archive -Path "data\" -DestinationPath "backups\data_$timestamp.zip"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical files to backup:**
|
||||||
|
- `data/.myfsio.sys/config/iam.json` – User accounts and access keys
|
||||||
|
- `data/.myfsio.sys/config/bucket_policies.json` – Bucket access policies
|
||||||
|
- `data/.myfsio.sys/config/kms_keys.json` – Encryption keys (if using KMS)
|
||||||
|
- `data/.myfsio.sys/config/secret_store.json` – Application secrets
|
||||||
|
|
||||||
|
### Update Procedures
|
||||||
|
|
||||||
|
#### Source Installation Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Backup (see above)
|
||||||
|
# 2. Pull latest code
|
||||||
|
git fetch origin
|
||||||
|
git checkout main # or your target branch/tag
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 3. Check for dependency changes
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 4. Review CHANGELOG/release notes for breaking changes
|
||||||
|
cat CHANGELOG.md # if available
|
||||||
|
|
||||||
|
# 5. Run migration scripts (if any)
|
||||||
|
# python scripts/migrate_vX_to_vY.py # example
|
||||||
|
|
||||||
|
# 6. Restart application
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Backup (see above)
|
||||||
|
# 2. Pull/rebuild image
|
||||||
|
docker pull yourregistry/myfsio:latest
|
||||||
|
# OR rebuild from source:
|
||||||
|
docker build -t myfsio:latest .
|
||||||
|
|
||||||
|
# 3. Stop and remove old container
|
||||||
|
docker stop myfsio
|
||||||
|
docker rm myfsio
|
||||||
|
|
||||||
|
# 4. Start new container with same volumes
|
||||||
|
docker run -d \
|
||||||
|
--name myfsio \
|
||||||
|
-p 5000:5000 -p 5100:5100 \
|
||||||
|
-v "$(pwd)/data:/app/data" \
|
||||||
|
-v "$(pwd)/logs:/app/logs" \
|
||||||
|
-e SECRET_KEY="your-secret" \
|
||||||
|
myfsio:latest
|
||||||
|
|
||||||
|
# 5. Verify health
|
||||||
|
curl http://localhost:5000/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version Compatibility Checks
|
||||||
|
|
||||||
|
Before upgrading across major versions, verify compatibility:
|
||||||
|
|
||||||
|
| From Version | To Version | Breaking Changes | Migration Required |
|
||||||
|
|--------------|------------|------------------|-------------------|
|
||||||
|
| 0.1.x | 0.2.x | None expected | No |
|
||||||
|
| 0.1.6 | 0.1.7 | None | No |
|
||||||
|
| < 0.1.0 | >= 0.1.0 | New IAM config format | Yes - run migration script |
|
||||||
|
|
||||||
|
**Automatic compatibility detection:**
|
||||||
|
|
||||||
|
The application will log warnings on startup if config files need migration:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARNING: IAM config format is outdated (v1). Please run: python scripts/migrate_iam.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual compatibility check:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compare version schemas
|
||||||
|
python -c "from app.version import APP_VERSION; print(f'Running: {APP_VERSION}')"
|
||||||
|
python scripts/check_compatibility.py data/.myfsio.sys/config/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Steps for Breaking Changes
|
||||||
|
|
||||||
|
When release notes indicate breaking changes, follow these steps:
|
||||||
|
|
||||||
|
#### Config Format Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Backup first (critical!)
|
||||||
|
cp data/.myfsio.sys/config/iam.json data/.myfsio.sys/config/iam.json.backup
|
||||||
|
|
||||||
|
# 2. Run provided migration script
|
||||||
|
python scripts/migrate_iam_v1_to_v2.py
|
||||||
|
|
||||||
|
# 3. Validate migration
|
||||||
|
python scripts/validate_config.py
|
||||||
|
|
||||||
|
# 4. Test with read-only mode first (if available)
|
||||||
|
# python run.py --read-only
|
||||||
|
|
||||||
|
# 5. Restart normally
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Database/Storage Schema Changes
|
||||||
|
|
||||||
|
If object metadata format changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Run storage migration script
|
||||||
|
python scripts/migrate_storage.py --dry-run # preview changes
|
||||||
|
|
||||||
|
# 2. Apply migration
|
||||||
|
python scripts/migrate_storage.py --apply
|
||||||
|
|
||||||
|
# 3. Verify integrity
|
||||||
|
python scripts/verify_storage.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### IAM Policy Updates
|
||||||
|
|
||||||
|
If IAM action names change (e.g., `s3:Get` → `s3:GetObject`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Migration script will update all policies
|
||||||
|
python scripts/migrate_policies.py \
|
||||||
|
--input data/.myfsio.sys/config/iam.json \
|
||||||
|
--backup data/.myfsio.sys/config/iam.json.v1
|
||||||
|
|
||||||
|
# Review changes before committing
|
||||||
|
python scripts/diff_policies.py \
|
||||||
|
data/.myfsio.sys/config/iam.json.v1 \
|
||||||
|
data/.myfsio.sys/config/iam.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback Procedures
|
||||||
|
|
||||||
|
If an update causes issues, rollback to the previous version:
|
||||||
|
|
||||||
|
#### Quick Rollback (Source)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop application
|
||||||
|
# Ctrl+C or kill process
|
||||||
|
|
||||||
|
# 2. Revert code
|
||||||
|
git checkout <previous-version-tag>
|
||||||
|
# OR
|
||||||
|
git reset --hard HEAD~1
|
||||||
|
|
||||||
|
# 3. Restore configs from backup
|
||||||
|
cp backups/20241213_103000/config/* data/.myfsio.sys/config/
|
||||||
|
|
||||||
|
# 4. Downgrade dependencies if needed
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 5. Restart
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop current container
|
||||||
|
docker stop myfsio
|
||||||
|
docker rm myfsio
|
||||||
|
|
||||||
|
# 2. Start previous version
|
||||||
|
docker run -d \
|
||||||
|
--name myfsio \
|
||||||
|
-p 5000:5000 -p 5100:5100 \
|
||||||
|
-v "$(pwd)/data:/app/data" \
|
||||||
|
-v "$(pwd)/logs:/app/logs" \
|
||||||
|
-e SECRET_KEY="your-secret" \
|
||||||
|
myfsio:0.1.3 # specify previous version tag
|
||||||
|
|
||||||
|
# 3. Verify
|
||||||
|
curl http://localhost:5000/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Emergency Config Restore
|
||||||
|
|
||||||
|
If only config is corrupted but code is fine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop app
|
||||||
|
# Restore from latest backup
|
||||||
|
cp backups/20241213_103000/config/iam.json data/.myfsio.sys/config/
|
||||||
|
cp backups/20241213_103000/config/bucket_policies.json data/.myfsio.sys/config/
|
||||||
|
|
||||||
|
# Restart app
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blue-Green Deployment (Zero Downtime)
|
||||||
|
|
||||||
|
For production environments requiring zero downtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Run new version on different port (e.g., 5001/5101)
|
||||||
|
APP_PORT=5001 UI_PORT=5101 python run.py &
|
||||||
|
|
||||||
|
# 2. Health check new instance
|
||||||
|
curl http://localhost:5001/healthz
|
||||||
|
|
||||||
|
# 3. Update load balancer to route to new ports
|
||||||
|
|
||||||
|
# 4. Monitor for issues
|
||||||
|
|
||||||
|
# 5. Gracefully stop old instance
|
||||||
|
kill -SIGTERM <old-pid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Post-Update Verification
|
||||||
|
|
||||||
|
After any update, verify functionality:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Health check
|
||||||
|
curl http://localhost:5000/healthz
|
||||||
|
|
||||||
|
# 2. Login to UI
|
||||||
|
open http://localhost:5100/ui
|
||||||
|
|
||||||
|
# 3. Test IAM authentication
|
||||||
|
curl -H "X-Amz-Security-Token: <your-access-key>:<your-secret>" \
|
||||||
|
http://localhost:5000/
|
||||||
|
|
||||||
|
# 4. Test presigned URL generation
|
||||||
|
# Via UI or API
|
||||||
|
|
||||||
|
# 5. Check logs for errors
|
||||||
|
tail -n 100 logs/myfsio.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automated Update Scripts
|
||||||
|
|
||||||
|
Create a custom update script for your environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# update.sh - Automated update with rollback capability
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
VERSION_NEW="$1"
|
||||||
|
BACKUP_DIR="backups/$(date +%Y%m%d_%H%M%S)"
|
||||||
|
|
||||||
|
echo "Creating backup..."
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
cp -r data/.myfsio.sys/config "$BACKUP_DIR/"
|
||||||
|
|
||||||
|
echo "Updating to version $VERSION_NEW..."
|
||||||
|
git fetch origin
|
||||||
|
git checkout "v$VERSION_NEW"
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
echo "Starting application..."
|
||||||
|
python run.py &
|
||||||
|
APP_PID=$!
|
||||||
|
|
||||||
|
# Wait and health check
|
||||||
|
sleep 5
|
||||||
|
if curl -f http://localhost:5000/healthz; then
|
||||||
|
echo "Update successful!"
|
||||||
|
else
|
||||||
|
echo "Health check failed, rolling back..."
|
||||||
|
kill $APP_PID
|
||||||
|
git checkout -
|
||||||
|
cp -r "$BACKUP_DIR/config/*" data/.myfsio.sys/config/
|
||||||
|
python run.py &
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
## 4. Authentication & IAM
|
## 4. Authentication & IAM
|
||||||
|
|
||||||
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.
|
||||||
@@ -102,6 +565,46 @@ The application automatically trusts these headers to generate correct presigned
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
### Available IAM Actions
|
||||||
|
|
||||||
|
| Action | Description | AWS Aliases |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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` |
|
||||||
|
| `write` | Upload objects, create buckets | `s3:PutObject`, `s3:CreateBucket`, `s3:CreateMultipartUpload`, `s3:UploadPart`, `s3:CompleteMultipartUpload`, `s3:AbortMultipartUpload`, `s3:CopyObject` |
|
||||||
|
| `delete` | Remove objects and buckets | `s3:DeleteObject`, `s3:DeleteObjectVersion`, `s3:DeleteBucket` |
|
||||||
|
| `share` | Manage ACLs | `s3:PutObjectAcl`, `s3:PutBucketAcl`, `s3:GetBucketAcl` |
|
||||||
|
| `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` |
|
||||||
|
| `iam:list_users` | View IAM users | `iam:ListUsers` |
|
||||||
|
| `iam:create_user` | Create IAM users | `iam:CreateUser` |
|
||||||
|
| `iam:delete_user` | Delete IAM users | `iam:DeleteUser` |
|
||||||
|
| `iam:rotate_key` | Rotate user secrets | `iam:RotateAccessKey` |
|
||||||
|
| `iam:update_policy` | Modify user policies | `iam:PutUserPolicy` |
|
||||||
|
| `iam:*` | All IAM actions (admin wildcard) | — |
|
||||||
|
|
||||||
|
### Example Policies
|
||||||
|
|
||||||
|
**Full Control (admin):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "*", "actions": ["list", "read", "write", "delete", "share", "policy", "replication", "iam:*"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Read-Only:**
|
||||||
|
```json
|
||||||
|
[{"bucket": "*", "actions": ["list", "read"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Single Bucket Access (no listing other buckets):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "user-bucket", "actions": ["read", "write", "delete"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bucket Access with Replication:**
|
||||||
|
```json
|
||||||
|
[{"bucket": "my-bucket", "actions": ["list", "read", "write", "delete", "replication"]}]
|
||||||
|
```
|
||||||
|
|
||||||
## 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": {...}}}`.
|
||||||
@@ -132,6 +635,48 @@ curl -X PUT http://127.0.0.1:5000/bucket-policy/test \
|
|||||||
|
|
||||||
The UI will reflect this change as soon as the request completes thanks to the hot reload.
|
The UI will reflect this change as soon as the request completes thanks to the hot reload.
|
||||||
|
|
||||||
|
### UI Object Browser
|
||||||
|
|
||||||
|
The bucket detail page includes a powerful object browser with the following features:
|
||||||
|
|
||||||
|
#### Folder Navigation
|
||||||
|
|
||||||
|
Objects with forward slashes (`/`) in their keys are displayed as a folder hierarchy. Click a folder row to navigate into it. A breadcrumb navigation bar shows your current path and allows quick navigation back to parent folders or the root.
|
||||||
|
|
||||||
|
#### Pagination & Infinite Scroll
|
||||||
|
|
||||||
|
- Objects load in configurable batches (50, 100, 150, 200, or 250 per page)
|
||||||
|
- Scroll to the bottom to automatically load more objects (infinite scroll)
|
||||||
|
- A **Load more** button is available as a fallback for touch devices or when infinite scroll doesn't trigger
|
||||||
|
- The footer shows the current load status (e.g., "Showing 100 of 500 objects")
|
||||||
|
|
||||||
|
#### Bulk Operations
|
||||||
|
|
||||||
|
- Select multiple objects using checkboxes
|
||||||
|
- **Bulk Delete**: Delete multiple objects at once
|
||||||
|
- **Bulk Download**: Download selected objects as individual files
|
||||||
|
|
||||||
|
#### Search & Filter
|
||||||
|
|
||||||
|
Use the search box to filter objects by name in real-time. The filter applies to the currently loaded objects.
|
||||||
|
|
||||||
|
#### Error Handling
|
||||||
|
|
||||||
|
If object loading fails (e.g., network error), a friendly error message is displayed with a **Retry** button to attempt loading again.
|
||||||
|
|
||||||
|
#### Object Preview
|
||||||
|
|
||||||
|
Click any object row to view its details in the preview sidebar:
|
||||||
|
- File size and last modified date
|
||||||
|
- ETag (content hash)
|
||||||
|
- Custom metadata (if present)
|
||||||
|
- Download and presign (share link) buttons
|
||||||
|
- Version history (when versioning is enabled)
|
||||||
|
|
||||||
|
#### Drag & Drop Upload
|
||||||
|
|
||||||
|
Drag files directly onto the objects table to upload them to the current bucket and folder path.
|
||||||
|
|
||||||
## 6. Presigned URLs
|
## 6. Presigned URLs
|
||||||
|
|
||||||
- Trigger from the UI using the **Presign** button after selecting an object.
|
- Trigger from the UI using the **Presign** button after selecting an object.
|
||||||
@@ -173,9 +718,207 @@ s3.complete_multipart_upload(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. Site Replication
|
## 7. Encryption
|
||||||
|
|
||||||
MyFSIO supports **Site Replication**, allowing you to automatically copy new objects from one MyFSIO instance (Source) to another (Target). This is useful for disaster recovery, data locality, or backups.
|
MyFSIO supports **server-side encryption at rest** to protect your data. When enabled, objects are encrypted using AES-256-GCM before being written to disk.
|
||||||
|
|
||||||
|
### Encryption Types
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| **AES-256 (SSE-S3)** | Server-managed encryption using a local master key |
|
||||||
|
| **KMS (SSE-KMS)** | Encryption using customer-managed keys via the built-in KMS |
|
||||||
|
|
||||||
|
### Enabling Encryption
|
||||||
|
|
||||||
|
#### 1. Set Environment Variables
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# PowerShell
|
||||||
|
$env:ENCRYPTION_ENABLED = "true"
|
||||||
|
$env:KMS_ENABLED = "true" # Optional, for KMS key management
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bash
|
||||||
|
export ENCRYPTION_ENABLED=true
|
||||||
|
export KMS_ENABLED=true
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Configure Bucket Default Encryption (UI)
|
||||||
|
|
||||||
|
1. Navigate to your bucket in the UI
|
||||||
|
2. Click the **Properties** tab
|
||||||
|
3. Find the **Default Encryption** card
|
||||||
|
4. Click **Enable Encryption**
|
||||||
|
5. Choose algorithm:
|
||||||
|
- **AES-256**: Uses the server's master key
|
||||||
|
- **aws:kms**: Uses a KMS-managed key (select from dropdown)
|
||||||
|
6. Save changes
|
||||||
|
|
||||||
|
Once enabled, all **new objects** uploaded to the bucket will be automatically encrypted.
|
||||||
|
|
||||||
|
### KMS Key Management
|
||||||
|
|
||||||
|
When `KMS_ENABLED=true`, you can manage encryption keys via the KMS API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new KMS key
|
||||||
|
curl -X POST http://localhost:5000/kms/keys \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
|
-d '{"alias": "my-key", "description": "Production encryption key"}'
|
||||||
|
|
||||||
|
# List all keys
|
||||||
|
curl http://localhost:5000/kms/keys \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
|
||||||
|
# Get key details
|
||||||
|
curl http://localhost:5000/kms/keys/{key-id} \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
|
||||||
|
# Rotate a key (creates new key material)
|
||||||
|
curl -X POST http://localhost:5000/kms/keys/{key-id}/rotate \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
|
||||||
|
# Disable/Enable a key
|
||||||
|
curl -X POST http://localhost:5000/kms/keys/{key-id}/disable \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
|
||||||
|
curl -X POST http://localhost:5000/kms/keys/{key-id}/enable \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
|
||||||
|
# Schedule key deletion (30-day waiting period)
|
||||||
|
curl -X DELETE http://localhost:5000/kms/keys/{key-id}?waiting_period_days=30 \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Envelope Encryption**: Each object is encrypted with a unique Data Encryption Key (DEK)
|
||||||
|
2. **Key Wrapping**: The DEK is encrypted (wrapped) by the master key or KMS key
|
||||||
|
3. **Storage**: The encrypted DEK is stored alongside the encrypted object
|
||||||
|
4. **Decryption**: On read, the DEK is unwrapped and used to decrypt the object
|
||||||
|
|
||||||
|
### Client-Side Encryption
|
||||||
|
|
||||||
|
For additional security, you can use client-side encryption. The `ClientEncryptionHelper` class provides utilities:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.encryption import ClientEncryptionHelper
|
||||||
|
|
||||||
|
# Generate a client-side key
|
||||||
|
key = ClientEncryptionHelper.generate_key()
|
||||||
|
key_b64 = ClientEncryptionHelper.key_to_base64(key)
|
||||||
|
|
||||||
|
# Encrypt before upload
|
||||||
|
plaintext = b"sensitive data"
|
||||||
|
encrypted, metadata = ClientEncryptionHelper.encrypt_for_upload(plaintext, key)
|
||||||
|
|
||||||
|
# Upload with metadata headers
|
||||||
|
# x-amz-meta-x-amz-key: <wrapped-key>
|
||||||
|
# x-amz-meta-x-amz-iv: <iv>
|
||||||
|
# x-amz-meta-x-amz-matdesc: <material-description>
|
||||||
|
|
||||||
|
# Decrypt after download
|
||||||
|
decrypted = ClientEncryptionHelper.decrypt_from_download(encrypted, metadata, key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
|
||||||
|
- **Existing objects are NOT encrypted** - Only new uploads after enabling encryption are encrypted
|
||||||
|
- **Master key security** - The master key file (`master.key`) should be backed up securely and protected
|
||||||
|
- **Key rotation** - Rotating a KMS key creates new key material; existing objects remain encrypted with the old material
|
||||||
|
- **Disabled keys** - Objects encrypted with a disabled key cannot be decrypted until the key is re-enabled
|
||||||
|
- **Deleted keys** - Once a key is deleted (after the waiting period), objects encrypted with it are permanently inaccessible
|
||||||
|
|
||||||
|
### Verifying Encryption
|
||||||
|
|
||||||
|
To verify an object is encrypted:
|
||||||
|
1. Check the raw file in `data/<bucket>/` - it should be unreadable binary
|
||||||
|
2. Look for `.meta` files containing encryption metadata
|
||||||
|
3. Download via the API/UI - the object should be automatically decrypted
|
||||||
|
|
||||||
|
## 8. Bucket Quotas
|
||||||
|
|
||||||
|
MyFSIO supports **storage quotas** to limit how much data a bucket can hold. Quotas are enforced on uploads and multipart completions.
|
||||||
|
|
||||||
|
### Quota Types
|
||||||
|
|
||||||
|
| Limit | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **Max Size (MB)** | Maximum total storage in megabytes (includes current objects + archived versions) |
|
||||||
|
| **Max Objects** | Maximum number of objects (includes current objects + archived versions) |
|
||||||
|
|
||||||
|
### Managing Quotas (Admin Only)
|
||||||
|
|
||||||
|
Quota management is restricted to administrators (users with `iam:*` or `iam:list_users` permissions).
|
||||||
|
|
||||||
|
#### Via UI
|
||||||
|
|
||||||
|
1. Navigate to your bucket in the UI
|
||||||
|
2. Click the **Properties** tab
|
||||||
|
3. Find the **Storage Quota** card
|
||||||
|
4. Enter limits:
|
||||||
|
- **Max Size (MB)**: Leave empty for unlimited
|
||||||
|
- **Max Objects**: Leave empty for unlimited
|
||||||
|
5. Click **Update Quota**
|
||||||
|
|
||||||
|
To remove a quota, click **Remove Quota**.
|
||||||
|
|
||||||
|
#### Via API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set quota (max 100MB, max 1000 objects)
|
||||||
|
curl -X PUT "http://localhost:5000/bucket/<bucket>?quota" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
|
-d '{"max_bytes": 104857600, "max_objects": 1000}'
|
||||||
|
|
||||||
|
# Get current quota
|
||||||
|
curl "http://localhost:5000/bucket/<bucket>?quota" \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
|
||||||
|
# Remove quota
|
||||||
|
curl -X PUT "http://localhost:5000/bucket/<bucket>?quota" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
|
-d '{"max_bytes": null, "max_objects": null}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quota Behavior
|
||||||
|
|
||||||
|
- **Version Counting**: When versioning is enabled, archived versions count toward the quota
|
||||||
|
- **Enforcement Points**: Quotas are checked during `PUT` object and `CompleteMultipartUpload` operations
|
||||||
|
- **Error Response**: When quota is exceeded, the API returns `HTTP 400` with error code `QuotaExceeded`
|
||||||
|
- **Visibility**: All users can view quota usage in the bucket detail page, but only admins can modify quotas
|
||||||
|
|
||||||
|
### Example Error
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Error>
|
||||||
|
<Code>QuotaExceeded</Code>
|
||||||
|
<Message>Bucket quota exceeded: storage limit reached</Message>
|
||||||
|
<BucketName>my-bucket</BucketName>
|
||||||
|
</Error>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Site Replication
|
||||||
|
|
||||||
|
### Permission Model
|
||||||
|
|
||||||
|
Replication uses a two-tier permission system:
|
||||||
|
|
||||||
|
| Role | Capabilities |
|
||||||
|
|------|--------------|
|
||||||
|
| **Admin** (users with `iam:*` permissions) | Create/delete replication rules, configure connections and target buckets |
|
||||||
|
| **Users** (with `replication` permission) | Enable/disable (pause/resume) existing replication rules |
|
||||||
|
|
||||||
|
> **Note:** The Replication tab is hidden for users without the `replication` permission on the bucket.
|
||||||
|
|
||||||
|
This separation allows administrators to pre-configure where data should replicate, while allowing authorized users to toggle replication on/off without accessing connection credentials.
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
@@ -253,13 +996,15 @@ Now, configure the primary instance to replicate to the target.
|
|||||||
- **Secret Key**: The secret you generated on the Target.
|
- **Secret Key**: The secret you generated on the Target.
|
||||||
- Click **Add Connection**.
|
- Click **Add Connection**.
|
||||||
|
|
||||||
3. **Enable Replication**:
|
3. **Enable Replication** (Admin):
|
||||||
- Navigate to **Buckets** and select the source bucket.
|
- Navigate to **Buckets** and select the source bucket.
|
||||||
- Switch to the **Replication** tab.
|
- Switch to the **Replication** tab.
|
||||||
- Select the `Secondary Site` connection.
|
- Select the `Secondary Site` connection.
|
||||||
- Enter the target bucket name (`backup-bucket`).
|
- Enter the target bucket name (`backup-bucket`).
|
||||||
- Click **Enable Replication**.
|
- Click **Enable Replication**.
|
||||||
|
|
||||||
|
Once configured, users with `replication` permission on this bucket can pause/resume replication without needing access to connection details.
|
||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
|
|
||||||
1. Upload a file to the source bucket.
|
1. Upload a file to the source bucket.
|
||||||
@@ -270,6 +1015,18 @@ Now, configure the primary instance to replicate to the target.
|
|||||||
aws --endpoint-url http://target-server:5002 s3 ls s3://backup-bucket
|
aws --endpoint-url http://target-server:5002 s3 ls s3://backup-bucket
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Pausing and Resuming Replication
|
||||||
|
|
||||||
|
Users with the `replication` permission (but not admin rights) can pause and resume existing replication rules:
|
||||||
|
|
||||||
|
1. Navigate to the bucket's **Replication** tab.
|
||||||
|
2. If replication is **Active**, click **Pause Replication** to temporarily stop syncing.
|
||||||
|
3. If replication is **Paused**, click **Resume Replication** to continue syncing.
|
||||||
|
|
||||||
|
When paused, new objects uploaded to the source will not replicate until replication is resumed. Objects uploaded while paused will be replicated once resumed.
|
||||||
|
|
||||||
|
> **Note:** Only admins can create new replication rules, change the target connection/bucket, or delete rules entirely.
|
||||||
|
|
||||||
### Bidirectional Replication (Active-Active)
|
### Bidirectional Replication (Active-Active)
|
||||||
|
|
||||||
To set up two-way replication (Server A ↔ Server B):
|
To set up two-way replication (Server A ↔ Server B):
|
||||||
@@ -285,7 +1042,7 @@ To set up two-way replication (Server A ↔ Server B):
|
|||||||
|
|
||||||
**Note**: Deleting a bucket will automatically remove its associated replication configuration.
|
**Note**: Deleting a bucket will automatically remove its associated replication configuration.
|
||||||
|
|
||||||
## 7. Running Tests
|
## 11. Running Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest -q
|
pytest -q
|
||||||
@@ -295,7 +1052,7 @@ The suite now includes a boto3 integration test that spins up a live HTTP server
|
|||||||
|
|
||||||
The suite covers bucket CRUD, presigned downloads, bucket policy enforcement, and regression tests for anonymous reads when a Public policy is attached.
|
The suite covers bucket CRUD, presigned downloads, bucket policy enforcement, and regression tests for anonymous reads when a Public policy is attached.
|
||||||
|
|
||||||
## 8. Troubleshooting
|
## 12. Troubleshooting
|
||||||
|
|
||||||
| Symptom | Likely Cause | Fix |
|
| Symptom | Likely Cause | Fix |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -304,7 +1061,7 @@ The suite covers bucket CRUD, presigned downloads, bucket policy enforcement, an
|
|||||||
| Presign modal errors with 403 | IAM user lacks `read/write/delete` for target bucket or bucket policy denies | Update IAM inline policies or remove conflicting deny statements. |
|
| Presign modal errors with 403 | IAM user lacks `read/write/delete` for target bucket or bucket policy denies | Update IAM inline policies or remove conflicting deny statements. |
|
||||||
| Large upload rejected immediately | File exceeds `MAX_UPLOAD_SIZE` | Increase env var or shrink object. |
|
| Large upload rejected immediately | File exceeds `MAX_UPLOAD_SIZE` | Increase env var or shrink object. |
|
||||||
|
|
||||||
## 9. API Matrix
|
## 13. API Matrix
|
||||||
|
|
||||||
```
|
```
|
||||||
GET / # List buckets
|
GET / # List buckets
|
||||||
@@ -318,10 +1075,6 @@ POST /presign/<bucket>/<key> # Generate SigV4 URL
|
|||||||
GET /bucket-policy/<bucket> # Fetch policy
|
GET /bucket-policy/<bucket> # Fetch policy
|
||||||
PUT /bucket-policy/<bucket> # Upsert policy
|
PUT /bucket-policy/<bucket> # Upsert policy
|
||||||
DELETE /bucket-policy/<bucket> # Delete policy
|
DELETE /bucket-policy/<bucket> # Delete policy
|
||||||
|
GET /<bucket>?quota # Get bucket quota
|
||||||
|
PUT /<bucket>?quota # Set bucket quota (admin only)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 10. Next Steps
|
|
||||||
|
|
||||||
- Tailor IAM + policy JSON files for team-ready presets.
|
|
||||||
- Wrap `run_api.py` with gunicorn or another WSGI server for long-running workloads.
|
|
||||||
- Extend `bucket_policies.json` to cover Deny statements that simulate production security controls.
|
|
||||||
|
|||||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
norecursedirs = data .git __pycache__ .venv
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
Flask>=3.0.2
|
Flask>=3.1.2
|
||||||
Flask-Limiter>=3.5.0
|
Flask-Limiter>=4.1.1
|
||||||
Flask-Cors>=4.0.0
|
Flask-Cors>=6.0.2
|
||||||
Flask-WTF>=1.2.1
|
Flask-WTF>=1.2.2
|
||||||
pytest>=7.4
|
pytest>=9.0.2
|
||||||
requests>=2.31
|
requests>=2.32.5
|
||||||
boto3>=1.34
|
boto3>=1.42.14
|
||||||
waitress>=2.1.2
|
waitress>=3.0.2
|
||||||
psutil>=5.9.0
|
psutil>=7.1.3
|
||||||
|
cryptography>=46.0.3
|
||||||
37
run.py
37
run.py
@@ -8,6 +8,7 @@ import warnings
|
|||||||
from multiprocessing import Process
|
from multiprocessing import Process
|
||||||
|
|
||||||
from app import create_api_app, create_ui_app
|
from app import create_api_app, create_ui_app
|
||||||
|
from app.config import AppConfig
|
||||||
|
|
||||||
|
|
||||||
def _server_host() -> str:
|
def _server_host() -> str:
|
||||||
@@ -55,12 +56,48 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument("--ui-port", type=int, default=5100)
|
parser.add_argument("--ui-port", type=int, default=5100)
|
||||||
parser.add_argument("--prod", action="store_true", help="Run in production mode using Waitress")
|
parser.add_argument("--prod", action="store_true", help="Run in production mode using Waitress")
|
||||||
parser.add_argument("--dev", action="store_true", help="Force development mode (Flask dev server)")
|
parser.add_argument("--dev", action="store_true", help="Force development mode (Flask dev server)")
|
||||||
|
parser.add_argument("--check-config", action="store_true", help="Validate configuration and exit")
|
||||||
|
parser.add_argument("--show-config", action="store_true", help="Show configuration summary and exit")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Handle config check/show modes
|
||||||
|
if args.check_config or args.show_config:
|
||||||
|
config = AppConfig.from_env()
|
||||||
|
config.print_startup_summary()
|
||||||
|
if args.check_config:
|
||||||
|
issues = config.validate_and_report()
|
||||||
|
critical = [i for i in issues if i.startswith("CRITICAL:")]
|
||||||
|
sys.exit(1 if critical else 0)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
# Default to production mode when running as compiled binary
|
# Default to production mode when running as compiled binary
|
||||||
# unless --dev is explicitly passed
|
# unless --dev is explicitly passed
|
||||||
prod_mode = args.prod or (_is_frozen() and not args.dev)
|
prod_mode = args.prod or (_is_frozen() and not args.dev)
|
||||||
|
|
||||||
|
# Validate configuration before starting
|
||||||
|
config = AppConfig.from_env()
|
||||||
|
|
||||||
|
# Show startup summary only on first run (when marker file doesn't exist)
|
||||||
|
first_run_marker = config.storage_root / ".myfsio.sys" / ".initialized"
|
||||||
|
is_first_run = not first_run_marker.exists()
|
||||||
|
|
||||||
|
if is_first_run:
|
||||||
|
config.print_startup_summary()
|
||||||
|
|
||||||
|
# Check for critical issues that should prevent startup
|
||||||
|
issues = config.validate_and_report()
|
||||||
|
critical_issues = [i for i in issues if i.startswith("CRITICAL:")]
|
||||||
|
if critical_issues:
|
||||||
|
print("ABORTING: Critical configuration issues detected. Fix them before starting.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Create the marker file to indicate successful first run
|
||||||
|
try:
|
||||||
|
first_run_marker.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
first_run_marker.write_text(f"Initialized on {__import__('datetime').datetime.now().isoformat()}\n")
|
||||||
|
except OSError:
|
||||||
|
pass # Non-critical, just skip marker creation
|
||||||
|
|
||||||
if prod_mode:
|
if prod_mode:
|
||||||
print("Running in production mode (Waitress)")
|
print("Running in production mode (Waitress)")
|
||||||
else:
|
else:
|
||||||
|
|||||||
370
scripts/install.sh
Normal file
370
scripts/install.sh
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# MyFSIO Installation Script
|
||||||
|
# This script sets up MyFSIO for production use on Linux systems.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./install.sh [OPTIONS]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --install-dir DIR Installation directory (default: /opt/myfsio)
|
||||||
|
# --data-dir DIR Data directory (default: /var/lib/myfsio)
|
||||||
|
# --log-dir DIR Log directory (default: /var/log/myfsio)
|
||||||
|
# --user USER System user to run as (default: myfsio)
|
||||||
|
# --port PORT API port (default: 5000)
|
||||||
|
# --ui-port PORT UI port (default: 5100)
|
||||||
|
# --api-url URL Public API URL (for presigned URLs behind proxy)
|
||||||
|
# --no-systemd Skip systemd service creation
|
||||||
|
# --binary PATH Path to myfsio binary (will download if not provided)
|
||||||
|
# -y, --yes Skip confirmation prompts
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/myfsio"
|
||||||
|
DATA_DIR="/var/lib/myfsio"
|
||||||
|
LOG_DIR="/var/log/myfsio"
|
||||||
|
SERVICE_USER="myfsio"
|
||||||
|
API_PORT="5000"
|
||||||
|
UI_PORT="5100"
|
||||||
|
API_URL=""
|
||||||
|
SKIP_SYSTEMD=false
|
||||||
|
BINARY_PATH=""
|
||||||
|
AUTO_YES=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--install-dir)
|
||||||
|
INSTALL_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--data-dir)
|
||||||
|
DATA_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--log-dir)
|
||||||
|
LOG_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--user)
|
||||||
|
SERVICE_USER="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--port)
|
||||||
|
API_PORT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ui-port)
|
||||||
|
UI_PORT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--api-url)
|
||||||
|
API_URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--no-systemd)
|
||||||
|
SKIP_SYSTEMD=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--binary)
|
||||||
|
BINARY_PATH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-y|--yes)
|
||||||
|
AUTO_YES=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
head -30 "$0" | tail -25
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo " MyFSIO Installation Script"
|
||||||
|
echo " S3-Compatible Object Storage"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
echo "Documentation: https://go.jzwsite.com/myfsio"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "Error: This script must be run as root (use sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 1: Review Installation Configuration"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
echo " Install directory: $INSTALL_DIR"
|
||||||
|
echo " Data directory: $DATA_DIR"
|
||||||
|
echo " Log directory: $LOG_DIR"
|
||||||
|
echo " Service user: $SERVICE_USER"
|
||||||
|
echo " API port: $API_PORT"
|
||||||
|
echo " UI port: $UI_PORT"
|
||||||
|
if [[ -n "$API_URL" ]]; then
|
||||||
|
echo " Public API URL: $API_URL"
|
||||||
|
fi
|
||||||
|
if [[ -n "$BINARY_PATH" ]]; then
|
||||||
|
echo " Binary path: $BINARY_PATH"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$AUTO_YES" != true ]]; then
|
||||||
|
read -p "Do you want to proceed with these settings? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Installation cancelled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 2: Creating System User"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
if id "$SERVICE_USER" &>/dev/null; then
|
||||||
|
echo " [OK] User '$SERVICE_USER' already exists"
|
||||||
|
else
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||||
|
echo " [OK] Created user '$SERVICE_USER'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 3: Creating Directories"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
echo " [OK] Created $INSTALL_DIR"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
echo " [OK] Created $DATA_DIR"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
echo " [OK] Created $LOG_DIR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 4: Installing Binary"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
if [[ -n "$BINARY_PATH" ]]; then
|
||||||
|
if [[ -f "$BINARY_PATH" ]]; then
|
||||||
|
cp "$BINARY_PATH" "$INSTALL_DIR/myfsio"
|
||||||
|
echo " [OK] Copied binary from $BINARY_PATH"
|
||||||
|
else
|
||||||
|
echo " [ERROR] Binary not found at $BINARY_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [[ -f "./myfsio" ]]; then
|
||||||
|
cp "./myfsio" "$INSTALL_DIR/myfsio"
|
||||||
|
echo " [OK] Copied binary from ./myfsio"
|
||||||
|
else
|
||||||
|
echo " [ERROR] No binary provided."
|
||||||
|
echo " Use --binary PATH or place 'myfsio' in current directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x "$INSTALL_DIR/myfsio"
|
||||||
|
echo " [OK] Set executable permissions"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 5: Generating Secret Key"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
SECRET_KEY=$(openssl rand -base64 32)
|
||||||
|
echo " [OK] Generated secure SECRET_KEY"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 6: Creating Configuration File"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
cat > "$INSTALL_DIR/myfsio.env" << EOF
|
||||||
|
# MyFSIO Configuration
|
||||||
|
# Generated by install.sh on $(date)
|
||||||
|
# Documentation: https://go.jzwsite.com/myfsio
|
||||||
|
|
||||||
|
# Storage paths
|
||||||
|
STORAGE_ROOT=$DATA_DIR
|
||||||
|
LOG_DIR=$LOG_DIR
|
||||||
|
|
||||||
|
# Network
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=$API_PORT
|
||||||
|
|
||||||
|
# Security - CHANGE IN PRODUCTION
|
||||||
|
SECRET_KEY=$SECRET_KEY
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# Public URL (set this if behind a reverse proxy)
|
||||||
|
$(if [[ -n "$API_URL" ]]; then echo "API_BASE_URL=$API_URL"; else echo "# API_BASE_URL=https://s3.example.com"; fi)
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_TO_FILE=true
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
RATE_LIMIT_DEFAULT=200 per minute
|
||||||
|
|
||||||
|
# Optional: Encryption (uncomment to enable)
|
||||||
|
# ENCRYPTION_ENABLED=true
|
||||||
|
# KMS_ENABLED=true
|
||||||
|
EOF
|
||||||
|
chmod 600 "$INSTALL_DIR/myfsio.env"
|
||||||
|
echo " [OK] Created $INSTALL_DIR/myfsio.env"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 7: Setting Permissions"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
|
||||||
|
echo " [OK] Set ownership for $INSTALL_DIR"
|
||||||
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR"
|
||||||
|
echo " [OK] Set ownership for $DATA_DIR"
|
||||||
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$LOG_DIR"
|
||||||
|
echo " [OK] Set ownership for $LOG_DIR"
|
||||||
|
|
||||||
|
if [[ "$SKIP_SYSTEMD" != true ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 8: Creating Systemd Service"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
cat > /etc/systemd/system/myfsio.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=MyFSIO S3-Compatible Storage
|
||||||
|
Documentation=https://go.jzwsite.com/myfsio
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$SERVICE_USER
|
||||||
|
Group=$SERVICE_USER
|
||||||
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
EnvironmentFile=$INSTALL_DIR/myfsio.env
|
||||||
|
ExecStart=$INSTALL_DIR/myfsio
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=$DATA_DIR $LOG_DIR
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
# Resource limits (adjust as needed)
|
||||||
|
# LimitNOFILE=65535
|
||||||
|
# MemoryMax=2G
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo " [OK] Created /etc/systemd/system/myfsio.service"
|
||||||
|
echo " [OK] Reloaded systemd daemon"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 8: Skipping Systemd Service (--no-systemd flag used)"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo " Installation Complete!"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$SKIP_SYSTEMD" != true ]]; then
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 9: Start the Service"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$AUTO_YES" != true ]]; then
|
||||||
|
read -p "Would you like to start MyFSIO now? [Y/n] " -n 1 -r
|
||||||
|
echo
|
||||||
|
START_SERVICE=true
|
||||||
|
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||||
|
START_SERVICE=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
START_SERVICE=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$START_SERVICE" == true ]]; then
|
||||||
|
echo " Starting MyFSIO service..."
|
||||||
|
systemctl start myfsio
|
||||||
|
echo " [OK] Service started"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Would you like to enable MyFSIO to start on boot? [Y/n] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||||
|
systemctl enable myfsio
|
||||||
|
echo " [OK] Service enabled on boot"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
echo " Service Status:"
|
||||||
|
echo " ---------------"
|
||||||
|
if systemctl is-active --quiet myfsio; then
|
||||||
|
echo " [OK] MyFSIO is running"
|
||||||
|
else
|
||||||
|
echo " [WARNING] MyFSIO may not have started correctly"
|
||||||
|
echo " Check logs with: journalctl -u myfsio -f"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " [SKIPPED] Service not started"
|
||||||
|
echo ""
|
||||||
|
echo " To start manually, run:"
|
||||||
|
echo " sudo systemctl start myfsio"
|
||||||
|
echo ""
|
||||||
|
echo " To enable on boot, run:"
|
||||||
|
echo " sudo systemctl enable myfsio"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo " Summary"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
echo "Access Points:"
|
||||||
|
echo " API: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$API_PORT"
|
||||||
|
echo " UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$UI_PORT/ui"
|
||||||
|
echo ""
|
||||||
|
echo "Default Credentials:"
|
||||||
|
echo " Username: localadmin"
|
||||||
|
echo " Password: localadmin"
|
||||||
|
echo " [!] WARNING: Change these immediately after first login!"
|
||||||
|
echo ""
|
||||||
|
echo "Configuration Files:"
|
||||||
|
echo " Environment: $INSTALL_DIR/myfsio.env"
|
||||||
|
echo " IAM Users: $DATA_DIR/.myfsio.sys/config/iam.json"
|
||||||
|
echo " Bucket Policies: $DATA_DIR/.myfsio.sys/config/bucket_policies.json"
|
||||||
|
echo ""
|
||||||
|
echo "Useful Commands:"
|
||||||
|
echo " Check status: sudo systemctl status myfsio"
|
||||||
|
echo " View logs: sudo journalctl -u myfsio -f"
|
||||||
|
echo " Restart: sudo systemctl restart myfsio"
|
||||||
|
echo " Stop: sudo systemctl stop myfsio"
|
||||||
|
echo ""
|
||||||
|
echo "Documentation: https://go.jzwsite.com/myfsio"
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo " Thank you for installing MyFSIO!"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
244
scripts/uninstall.sh
Normal file
244
scripts/uninstall.sh
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# MyFSIO Uninstall Script
|
||||||
|
# This script removes MyFSIO from your system.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./uninstall.sh [OPTIONS]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --keep-data Don't remove data directory
|
||||||
|
# --keep-logs Don't remove log directory
|
||||||
|
# --install-dir DIR Installation directory (default: /opt/myfsio)
|
||||||
|
# --data-dir DIR Data directory (default: /var/lib/myfsio)
|
||||||
|
# --log-dir DIR Log directory (default: /var/log/myfsio)
|
||||||
|
# --user USER System user (default: myfsio)
|
||||||
|
# -y, --yes Skip confirmation prompts
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/myfsio"
|
||||||
|
DATA_DIR="/var/lib/myfsio"
|
||||||
|
LOG_DIR="/var/log/myfsio"
|
||||||
|
SERVICE_USER="myfsio"
|
||||||
|
KEEP_DATA=false
|
||||||
|
KEEP_LOGS=false
|
||||||
|
AUTO_YES=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--keep-data)
|
||||||
|
KEEP_DATA=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--keep-logs)
|
||||||
|
KEEP_LOGS=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--install-dir)
|
||||||
|
INSTALL_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--data-dir)
|
||||||
|
DATA_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--log-dir)
|
||||||
|
LOG_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--user)
|
||||||
|
SERVICE_USER="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-y|--yes)
|
||||||
|
AUTO_YES=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
head -20 "$0" | tail -15
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo " MyFSIO Uninstallation Script"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
echo "Documentation: https://go.jzwsite.com/myfsio"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "Error: This script must be run as root (use sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 1: Review What Will Be Removed"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
echo "The following items will be removed:"
|
||||||
|
echo ""
|
||||||
|
echo " Install directory: $INSTALL_DIR"
|
||||||
|
if [[ "$KEEP_DATA" != true ]]; then
|
||||||
|
echo " Data directory: $DATA_DIR (ALL YOUR DATA WILL BE DELETED!)"
|
||||||
|
else
|
||||||
|
echo " Data directory: $DATA_DIR (WILL BE KEPT)"
|
||||||
|
fi
|
||||||
|
if [[ "$KEEP_LOGS" != true ]]; then
|
||||||
|
echo " Log directory: $LOG_DIR"
|
||||||
|
else
|
||||||
|
echo " Log directory: $LOG_DIR (WILL BE KEPT)"
|
||||||
|
fi
|
||||||
|
echo " Systemd service: /etc/systemd/system/myfsio.service"
|
||||||
|
echo " System user: $SERVICE_USER"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$AUTO_YES" != true ]]; then
|
||||||
|
echo "WARNING: This action cannot be undone!"
|
||||||
|
echo ""
|
||||||
|
read -p "Are you sure you want to uninstall MyFSIO? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Uninstallation cancelled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$KEEP_DATA" != true ]]; then
|
||||||
|
echo ""
|
||||||
|
read -p "This will DELETE ALL YOUR DATA. Type 'DELETE' to confirm: " CONFIRM
|
||||||
|
if [[ "$CONFIRM" != "DELETE" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Uninstallation cancelled."
|
||||||
|
echo "Tip: Use --keep-data to preserve your data directory"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 2: Stopping Service"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
if systemctl is-active --quiet myfsio 2>/dev/null; then
|
||||||
|
systemctl stop myfsio
|
||||||
|
echo " [OK] Stopped myfsio service"
|
||||||
|
else
|
||||||
|
echo " [SKIP] Service not running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 3: Disabling Service"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
if systemctl is-enabled --quiet myfsio 2>/dev/null; then
|
||||||
|
systemctl disable myfsio
|
||||||
|
echo " [OK] Disabled myfsio service"
|
||||||
|
else
|
||||||
|
echo " [SKIP] Service not enabled"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 4: Removing Systemd Service File"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
if [[ -f /etc/systemd/system/myfsio.service ]]; then
|
||||||
|
rm -f /etc/systemd/system/myfsio.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo " [OK] Removed /etc/systemd/system/myfsio.service"
|
||||||
|
echo " [OK] Reloaded systemd daemon"
|
||||||
|
else
|
||||||
|
echo " [SKIP] Service file not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 5: Removing Installation Directory"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
if [[ -d "$INSTALL_DIR" ]]; then
|
||||||
|
rm -rf "$INSTALL_DIR"
|
||||||
|
echo " [OK] Removed $INSTALL_DIR"
|
||||||
|
else
|
||||||
|
echo " [SKIP] Directory not found: $INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 6: Removing Data Directory"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
if [[ "$KEEP_DATA" != true ]]; then
|
||||||
|
if [[ -d "$DATA_DIR" ]]; then
|
||||||
|
rm -rf "$DATA_DIR"
|
||||||
|
echo " [OK] Removed $DATA_DIR"
|
||||||
|
else
|
||||||
|
echo " [SKIP] Directory not found: $DATA_DIR"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " [KEPT] Data preserved at: $DATA_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 7: Removing Log Directory"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
if [[ "$KEEP_LOGS" != true ]]; then
|
||||||
|
if [[ -d "$LOG_DIR" ]]; then
|
||||||
|
rm -rf "$LOG_DIR"
|
||||||
|
echo " [OK] Removed $LOG_DIR"
|
||||||
|
else
|
||||||
|
echo " [SKIP] Directory not found: $LOG_DIR"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " [KEPT] Logs preserved at: $LOG_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo "STEP 8: Removing System User"
|
||||||
|
echo "------------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
if id "$SERVICE_USER" &>/dev/null; then
|
||||||
|
userdel "$SERVICE_USER" 2>/dev/null || true
|
||||||
|
echo " [OK] Removed user '$SERVICE_USER'"
|
||||||
|
else
|
||||||
|
echo " [SKIP] User not found: $SERVICE_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo " Uninstallation Complete!"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$KEEP_DATA" == true ]]; then
|
||||||
|
echo "Your data has been preserved at: $DATA_DIR"
|
||||||
|
echo ""
|
||||||
|
echo "To reinstall MyFSIO with existing data, run:"
|
||||||
|
echo " curl -fsSL https://go.jzwsite.com/myfsio-install | sudo bash"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$KEEP_LOGS" == true ]]; then
|
||||||
|
echo "Your logs have been preserved at: $LOG_DIR"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Thank you for using MyFSIO."
|
||||||
|
echo "Documentation: https://go.jzwsite.com/myfsio"
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
@@ -66,8 +66,28 @@ html {
|
|||||||
color: var(--myfsio-muted) !important;
|
color: var(--myfsio-muted) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-responsive { border-radius: 0.5rem; overflow: hidden; }
|
.table-responsive {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
.message-stack { position: sticky; top: 1rem; z-index: 100; }
|
.message-stack { position: sticky; top: 1rem; z-index: 100; }
|
||||||
|
|
||||||
|
/* Mobile-friendly table improvements */
|
||||||
|
.table-responsive table {
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive table th,
|
||||||
|
.table-responsive table td {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow text wrapping for description columns */
|
||||||
|
.table-responsive table td.text-wrap {
|
||||||
|
white-space: normal;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
code { font-size: 0.85rem; }
|
code { font-size: 0.85rem; }
|
||||||
|
|
||||||
code {
|
code {
|
||||||
@@ -389,19 +409,46 @@ code {
|
|||||||
.bucket-table th:last-child { white-space: nowrap; }
|
.bucket-table th:last-child { white-space: nowrap; }
|
||||||
|
|
||||||
.object-key {
|
.object-key {
|
||||||
word-break: break-word;
|
max-width: 0;
|
||||||
max-width: 32rem;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-key .fw-medium {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-key .text-muted {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-card { top: 1rem; }
|
.preview-card { top: 1rem; }
|
||||||
|
|
||||||
.preview-stage {
|
.preview-stage {
|
||||||
min-height: 260px;
|
|
||||||
background-color: var(--myfsio-preview-bg);
|
background-color: var(--myfsio-preview-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-color: var(--myfsio-card-border) !important;
|
border-color: var(--myfsio-card-border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-stage:has(#preview-placeholder:not(.d-none)) {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage:has(#preview-image:not(.d-none)),
|
||||||
|
.preview-stage:has(#preview-video:not(.d-none)),
|
||||||
|
.preview-stage:has(#preview-iframe:not(.d-none)) {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#preview-placeholder {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-progress-stack {
|
.upload-progress-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -504,6 +551,22 @@ code {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.objects-table-container thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objects-table-container thead th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid var(--myfsio-card-border);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .objects-table-container thead th {
|
||||||
|
background-color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-group form { display: inline; }
|
.btn-group form { display: inline; }
|
||||||
|
|
||||||
.font-monospace { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; }
|
.font-monospace { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; }
|
||||||
@@ -928,6 +991,19 @@ pre code {
|
|||||||
background-color: var(--myfsio-hover-bg) !important;
|
background-color: var(--myfsio-hover-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-row {
|
||||||
|
background-color: var(--myfsio-section-bg);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-row:hover {
|
||||||
|
background-color: var(--myfsio-hover-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-row td:first-child {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-group-sm .btn {
|
.btn-group-sm .btn {
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -1511,6 +1587,41 @@ pre code {
|
|||||||
position: relative !important;
|
position: relative !important;
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure tables are scrollable on mobile */
|
||||||
|
.card-body .table-responsive {
|
||||||
|
margin: -1rem;
|
||||||
|
padding: 0;
|
||||||
|
width: calc(100% + 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body .table-responsive table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IAM users table mobile adjustments */
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better touch scrolling indicator */
|
||||||
|
.table-responsive::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 20px;
|
||||||
|
background: linear-gradient(to left, var(--myfsio-card-bg), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive:not(:hover)::after {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
|
|||||||
@@ -51,22 +51,18 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('ui.buckets_overview') }}">Buckets</a>
|
<a class="nav-link" href="{{ url_for('ui.buckets_overview') }}">Buckets</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if can_manage_iam %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if not can_manage_iam %}nav-link-muted{% endif %}" href="{{ url_for('ui.iam_dashboard') }}">
|
<a class="nav-link" href="{{ url_for('ui.iam_dashboard') }}">IAM</a>
|
||||||
IAM
|
|
||||||
{% if not can_manage_iam %}<span class="badge ms-2 text-bg-warning">Restricted</span>{% endif %}
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if not can_manage_iam %}nav-link-muted{% endif %}" href="{{ url_for('ui.connections_dashboard') }}">
|
<a class="nav-link" href="{{ url_for('ui.connections_dashboard') }}">Connections</a>
|
||||||
Connections
|
|
||||||
{% if not can_manage_iam %}<span class="badge ms-2 text-bg-warning">Restricted</span>{% endif %}
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('ui.metrics_dashboard') }}">Metrics</a>
|
<a class="nav-link" href="{{ url_for('ui.metrics_dashboard') }}">Metrics</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% if principal %}
|
{% if principal %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('ui.docs_page') }}">Docs</a>
|
<a class="nav-link" href="{{ url_for('ui.docs_page') }}">Docs</a>
|
||||||
@@ -203,7 +199,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Toast utility
|
|
||||||
window.showToast = function(message, title = 'Notification', type = 'info') {
|
window.showToast = function(message, title = 'Notification', type = 'info') {
|
||||||
const toastEl = document.getElementById('liveToast');
|
const toastEl = document.getElementById('liveToast');
|
||||||
const toastTitle = document.getElementById('toastTitle');
|
const toastTitle = document.getElementById('toastTitle');
|
||||||
@@ -212,7 +208,6 @@
|
|||||||
toastTitle.textContent = title;
|
toastTitle.textContent = title;
|
||||||
toastMessage.textContent = message;
|
toastMessage.textContent = message;
|
||||||
|
|
||||||
// Reset classes
|
|
||||||
toastEl.classList.remove('text-bg-primary', 'text-bg-success', 'text-bg-danger', 'text-bg-warning');
|
toastEl.classList.remove('text-bg-primary', 'text-bg-success', 'text-bg-danger', 'text-bg-warning');
|
||||||
|
|
||||||
if (type === 'success') toastEl.classList.add('text-bg-success');
|
if (type === 'success') toastEl.classList.add('text-bg-success');
|
||||||
@@ -225,13 +220,11 @@
|
|||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
// Show flashed messages as toasts
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
// Map Flask categories to Toast types
|
|
||||||
// Flask: success, danger, warning, info
|
|
||||||
// Toast: success, error, warning, info
|
|
||||||
var type = "{{ category }}";
|
var type = "{{ category }}";
|
||||||
if (type === "danger") type = "error";
|
if (type === "danger") type = "error";
|
||||||
window.showToast({{ message | tojson | safe }}, "Notification", type);
|
window.showToast({{ message | tojson | safe }}, "Notification", type);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,7 @@
|
|||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
// Search functionality
|
|
||||||
const searchInput = document.getElementById('bucket-search');
|
const searchInput = document.getElementById('bucket-search');
|
||||||
const bucketItems = document.querySelectorAll('.bucket-item');
|
const bucketItems = document.querySelectorAll('.bucket-item');
|
||||||
const noBucketsMsg = document.querySelector('.text-center.py-5'); // The "No buckets found" empty state
|
const noBucketsMsg = document.querySelector('.text-center.py-5'); // The "No buckets found" empty state
|
||||||
@@ -153,7 +153,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// View toggle functionality
|
|
||||||
const viewGrid = document.getElementById('view-grid');
|
const viewGrid = document.getElementById('view-grid');
|
||||||
const viewList = document.getElementById('view-list');
|
const viewList = document.getElementById('view-list');
|
||||||
const container = document.getElementById('buckets-container');
|
const container = document.getElementById('buckets-container');
|
||||||
@@ -168,8 +167,7 @@
|
|||||||
});
|
});
|
||||||
cards.forEach(card => {
|
cards.forEach(card => {
|
||||||
card.classList.remove('h-100');
|
card.classList.remove('h-100');
|
||||||
// Optional: Add flex-row to card-body content if we want a horizontal layout
|
|
||||||
// For now, full-width stacked cards is a good list view
|
|
||||||
});
|
});
|
||||||
localStorage.setItem('bucket-view-pref', 'list');
|
localStorage.setItem('bucket-view-pref', 'list');
|
||||||
} else {
|
} else {
|
||||||
@@ -188,7 +186,6 @@
|
|||||||
viewGrid.addEventListener('change', () => setView('grid'));
|
viewGrid.addEventListener('change', () => setView('grid'));
|
||||||
viewList.addEventListener('change', () => setView('list'));
|
viewList.addEventListener('change', () => setView('list'));
|
||||||
|
|
||||||
// Restore preference
|
|
||||||
const pref = localStorage.getItem('bucket-view-pref');
|
const pref = localStorage.getItem('bucket-view-pref');
|
||||||
if (pref === 'list') {
|
if (pref === 'list') {
|
||||||
viewList.checked = true;
|
viewList.checked = true;
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th scope="col" style="width: 50px;">Status</th>
|
||||||
<th scope="col">Name</th>
|
<th scope="col">Name</th>
|
||||||
<th scope="col">Endpoint</th>
|
<th scope="col">Endpoint</th>
|
||||||
<th scope="col">Region</th>
|
<th scope="col">Region</th>
|
||||||
@@ -113,7 +114,12 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for conn in connections %}
|
{% for conn in connections %}
|
||||||
<tr>
|
<tr data-connection-id="{{ conn.id }}">
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="connection-status" data-status="checking" title="Checking...">
|
||||||
|
<span class="spinner-border spinner-border-sm text-muted" role="status" style="width: 12px; height: 12px;"></span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<div class="connection-icon">
|
<div class="connection-icon">
|
||||||
@@ -181,7 +187,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Connection Modal -->
|
|
||||||
<div class="modal fade" id="editConnectionModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="editConnectionModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -247,7 +252,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Connection Modal -->
|
|
||||||
<div class="modal fade" id="deleteConnectionModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="deleteConnectionModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -297,14 +301,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test Connection Logic
|
|
||||||
async function testConnection(formId, resultId) {
|
async function testConnection(formId, resultId) {
|
||||||
const form = document.getElementById(formId);
|
const form = document.getElementById(formId);
|
||||||
const resultDiv = document.getElementById(resultId);
|
const resultDiv = document.getElementById(resultId);
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const data = Object.fromEntries(formData.entries());
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
resultDiv.innerHTML = '<div class="text-info"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Testing...</div>';
|
resultDiv.innerHTML = '<div class="text-info"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Testing connection...</div>';
|
||||||
|
|
||||||
|
// Use AbortController to timeout client-side after 20 seconds
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 20000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("{{ url_for('ui.test_connection') }}", {
|
const response = await fetch("{{ url_for('ui.test_connection') }}", {
|
||||||
@@ -313,17 +320,44 @@
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-CSRFToken": "{{ csrf_token() }}"
|
"X-CSRFToken": "{{ csrf_token() }}"
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
resultDiv.innerHTML = `<div class="text-success"><i class="bi bi-check-circle"></i> ${result.message}</div>`;
|
resultDiv.innerHTML = `<div class="text-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||||
|
</svg>
|
||||||
|
${result.message}
|
||||||
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
resultDiv.innerHTML = `<div class="text-danger"><i class="bi bi-exclamation-circle"></i> ${result.message}</div>`;
|
resultDiv.innerHTML = `<div class="text-danger">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||||
|
</svg>
|
||||||
|
${result.message}
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
resultDiv.innerHTML = `<div class="text-danger"><i class="bi bi-exclamation-circle"></i> Connection failed</div>`;
|
clearTimeout(timeoutId);
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
resultDiv.innerHTML = `<div class="text-danger">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||||
|
</svg>
|
||||||
|
Connection test timed out - endpoint may be unreachable
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
resultDiv.innerHTML = `<div class="text-danger">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||||
|
</svg>
|
||||||
|
Connection failed: Network error
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +369,6 @@
|
|||||||
testConnection('editConnectionForm', 'editTestResult');
|
testConnection('editConnectionForm', 'editTestResult');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modal Event Listeners
|
|
||||||
const editModal = document.getElementById('editConnectionModal');
|
const editModal = document.getElementById('editConnectionModal');
|
||||||
editModal.addEventListener('show.bs.modal', event => {
|
editModal.addEventListener('show.bs.modal', event => {
|
||||||
const button = event.relatedTarget;
|
const button = event.relatedTarget;
|
||||||
@@ -362,5 +395,54 @@
|
|||||||
const form = document.getElementById('deleteConnectionForm');
|
const form = document.getElementById('deleteConnectionForm');
|
||||||
form.action = "{{ url_for('ui.delete_connection', connection_id='CONN_ID') }}".replace('CONN_ID', id);
|
form.action = "{{ url_for('ui.delete_connection', connection_id='CONN_ID') }}".replace('CONN_ID', id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check connection health for each connection in the table
|
||||||
|
// Uses staggered requests to avoid overwhelming the server
|
||||||
|
async function checkConnectionHealth(connectionId, statusEl) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||||
|
|
||||||
|
const response = await fetch(`/ui/connections/${connectionId}/health`, {
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.healthy) {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||||
|
</svg>`;
|
||||||
|
statusEl.setAttribute('data-status', 'healthy');
|
||||||
|
statusEl.setAttribute('title', 'Connected');
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
||||||
|
</svg>`;
|
||||||
|
statusEl.setAttribute('data-status', 'unhealthy');
|
||||||
|
statusEl.setAttribute('title', data.error || 'Unreachable');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
|
||||||
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
||||||
|
</svg>`;
|
||||||
|
statusEl.setAttribute('data-status', 'unknown');
|
||||||
|
statusEl.setAttribute('title', 'Could not check status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stagger health checks to avoid all requests at once
|
||||||
|
const connectionRows = document.querySelectorAll('tr[data-connection-id]');
|
||||||
|
connectionRows.forEach((row, index) => {
|
||||||
|
const connectionId = row.getAttribute('data-connection-id');
|
||||||
|
const statusEl = row.querySelector('.connection-status');
|
||||||
|
if (statusEl) {
|
||||||
|
// Stagger requests by 200ms each
|
||||||
|
setTimeout(() => checkConnectionHealth(connectionId, statusEl), index * 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -47,16 +47,16 @@ python run.py --mode ui
|
|||||||
<table class="table table-sm table-bordered small mb-0">
|
<table class="table table-sm table-bordered small mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Variable</th>
|
<th style="min-width: 180px;">Variable</th>
|
||||||
<th>Default</th>
|
<th style="min-width: 120px;">Default</th>
|
||||||
<th>Description</th>
|
<th class="text-wrap" style="min-width: 250px;">Description</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>API_BASE_URL</code></td>
|
<td><code>API_BASE_URL</code></td>
|
||||||
<td><code>http://127.0.0.1:5000</code></td>
|
<td><code>None</code></td>
|
||||||
<td>The public URL of the API. <strong>Required</strong> if running behind a proxy or if the UI and API are on different domains. Ensures presigned URLs are generated correctly.</td>
|
<td>The public URL of the API. <strong>Required</strong> if running behind a proxy. Ensures presigned URLs are generated correctly.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>STORAGE_ROOT</code></td>
|
<td><code>STORAGE_ROOT</code></td>
|
||||||
@@ -65,13 +65,13 @@ python run.py --mode ui
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>MAX_UPLOAD_SIZE</code></td>
|
<td><code>MAX_UPLOAD_SIZE</code></td>
|
||||||
<td><code>5 GB</code></td>
|
<td><code>1 GB</code></td>
|
||||||
<td>Max request body size.</td>
|
<td>Max request body size in bytes.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>SECRET_KEY</code></td>
|
<td><code>SECRET_KEY</code></td>
|
||||||
<td>(Random)</td>
|
<td>(Auto-generated)</td>
|
||||||
<td>Flask session key. Set this in production.</td>
|
<td>Flask session key. Auto-generates if not set. <strong>Set explicitly in production.</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>APP_HOST</code></td>
|
<td><code>APP_HOST</code></td>
|
||||||
@@ -81,11 +81,81 @@ python run.py --mode ui
|
|||||||
<tr>
|
<tr>
|
||||||
<td><code>APP_PORT</code></td>
|
<td><code>APP_PORT</code></td>
|
||||||
<td><code>5000</code></td>
|
<td><code>5000</code></td>
|
||||||
<td>Listen port.</td>
|
<td>Listen port (UI uses 5100).</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="table-secondary">
|
||||||
|
<td colspan="3" class="fw-semibold">CORS Settings</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>CORS_ORIGINS</code></td>
|
||||||
|
<td><code>*</code></td>
|
||||||
|
<td>Allowed origins. <strong>Restrict in production.</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>CORS_METHODS</code></td>
|
||||||
|
<td><code>GET,PUT,POST,DELETE,OPTIONS,HEAD</code></td>
|
||||||
|
<td>Allowed HTTP methods.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>CORS_ALLOW_HEADERS</code></td>
|
||||||
|
<td><code>*</code></td>
|
||||||
|
<td>Allowed request headers.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>CORS_EXPOSE_HEADERS</code></td>
|
||||||
|
<td><code>*</code></td>
|
||||||
|
<td>Response headers visible to browsers (e.g., <code>ETag</code>).</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="table-secondary">
|
||||||
|
<td colspan="3" class="fw-semibold">Security Settings</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>AUTH_MAX_ATTEMPTS</code></td>
|
||||||
|
<td><code>5</code></td>
|
||||||
|
<td>Failed login attempts before lockout.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>AUTH_LOCKOUT_MINUTES</code></td>
|
||||||
|
<td><code>15</code></td>
|
||||||
|
<td>Lockout duration after max failed attempts.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>RATE_LIMIT_DEFAULT</code></td>
|
||||||
|
<td><code>200 per minute</code></td>
|
||||||
|
<td>Default API rate limit.</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="table-secondary">
|
||||||
|
<td colspan="3" class="fw-semibold">Encryption Settings</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ENCRYPTION_ENABLED</code></td>
|
||||||
|
<td><code>false</code></td>
|
||||||
|
<td>Enable server-side encryption support.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>KMS_ENABLED</code></td>
|
||||||
|
<td><code>false</code></td>
|
||||||
|
<td>Enable KMS key management for encryption.</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="table-secondary">
|
||||||
|
<td colspan="3" class="fw-semibold">Logging Settings</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>LOG_LEVEL</code></td>
|
||||||
|
<td><code>INFO</code></td>
|
||||||
|
<td>Log verbosity: DEBUG, INFO, WARNING, ERROR.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>LOG_TO_FILE</code></td>
|
||||||
|
<td><code>true</code></td>
|
||||||
|
<td>Enable file logging.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="alert alert-warning mt-3 mb-0 small">
|
||||||
|
<strong>Production Checklist:</strong> Set <code>SECRET_KEY</code>, restrict <code>CORS_ORIGINS</code>, configure <code>API_BASE_URL</code>, enable HTTPS via reverse proxy, and use <code>--prod</code> flag.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article id="background" class="card shadow-sm docs-section">
|
<article id="background" class="card shadow-sm docs-section">
|
||||||
@@ -130,7 +200,7 @@ WorkingDirectory=/opt/myfsio
|
|||||||
ExecStart=/opt/myfsio/myfsio
|
ExecStart=/opt/myfsio/myfsio
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=MYFSIO_DATA_DIR=/var/lib/myfsio
|
Environment=STORAGE_ROOT=/var/lib/myfsio
|
||||||
Environment=API_BASE_URL=https://s3.example.com
|
Environment=API_BASE_URL=https://s3.example.com
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
@@ -185,6 +255,15 @@ sudo journalctl -u myfsio -f # View logs</code></pre>
|
|||||||
<li>Progress rows highlight retries, throughput, and completion even if you close the modal.</li>
|
<li>Progress rows highlight retries, throughput, and completion even if you close the modal.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="h6 text-uppercase text-muted">Object browser</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Navigate folder hierarchies using breadcrumbs. Objects with <code>/</code> in keys display as folders.</li>
|
||||||
|
<li>Infinite scroll loads more objects automatically. Choose batch size (50–250) from the footer dropdown.</li>
|
||||||
|
<li>Bulk select objects for multi-delete or multi-download. Filter by name using the search box.</li>
|
||||||
|
<li>If loading fails, click <strong>Retry</strong> to attempt again—no page refresh needed.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="h6 text-uppercase text-muted">Object details</h3>
|
<h3 class="h6 text-uppercase text-muted">Object details</h3>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -408,10 +487,172 @@ s3.complete_multipart_upload(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
<article id="quotas" class="card shadow-sm docs-section">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span class="docs-section-kicker">10</span>
|
||||||
|
<h2 class="h4 mb-0">Bucket Quotas</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">Limit how much data a bucket can hold using storage quotas. Quotas are enforced on uploads and multipart completions.</p>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Quota Types</h3>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm table-bordered small">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Limit</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Max Size (MB)</strong></td>
|
||||||
|
<td>Maximum total storage in megabytes (includes current objects + archived versions)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Max Objects</strong></td>
|
||||||
|
<td>Maximum number of objects (includes current objects + archived versions)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Managing Quotas (Admin Only)</h3>
|
||||||
|
<p class="small text-muted">Quota management is restricted to administrators (users with <code>iam:*</code> permissions).</p>
|
||||||
|
<ol class="docs-steps mb-3">
|
||||||
|
<li>Navigate to your bucket → <strong>Properties</strong> tab → <strong>Storage Quota</strong> card.</li>
|
||||||
|
<li>Enter limits: <strong>Max Size (MB)</strong> and/or <strong>Max Objects</strong>. Leave empty for unlimited.</li>
|
||||||
|
<li>Click <strong>Update Quota</strong> to save, or <strong>Remove Quota</strong> to clear limits.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">API Usage</h3>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># Set quota (max 100MB, max 1000 objects)
|
||||||
|
curl -X PUT "{{ api_base }}/bucket/<bucket>?quota" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
|
||||||
|
-d '{"max_bytes": 104857600, "max_objects": 1000}'
|
||||||
|
|
||||||
|
# Get current quota
|
||||||
|
curl "{{ api_base }}/bucket/<bucket>?quota" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
|
||||||
|
|
||||||
|
# Remove quota
|
||||||
|
curl -X PUT "{{ api_base }}/bucket/<bucket>?quota" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
|
||||||
|
-d '{"max_bytes": null, "max_objects": null}'</code></pre>
|
||||||
|
|
||||||
|
<div class="alert alert-light border mb-0">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-info-circle text-muted mt-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||||
|
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong>Version Counting:</strong> When versioning is enabled, archived versions count toward the quota. The quota is checked against total storage, not just current objects.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article id="encryption" class="card shadow-sm docs-section">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span class="docs-section-kicker">11</span>
|
||||||
|
<h2 class="h4 mb-0">Encryption</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">Protect data at rest with server-side encryption using AES-256-GCM. Objects are encrypted before being written to disk and decrypted transparently on read.</p>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Encryption Types</h3>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm table-bordered small">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>AES-256 (SSE-S3)</strong></td>
|
||||||
|
<td>Server-managed encryption using a local master key</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>KMS (SSE-KMS)</strong></td>
|
||||||
|
<td>Encryption using customer-managed keys via the built-in KMS</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Enabling Encryption</h3>
|
||||||
|
<ol class="docs-steps mb-3">
|
||||||
|
<li>
|
||||||
|
<strong>Set environment variables:</strong>
|
||||||
|
<pre class="mb-2"><code class="language-bash"># PowerShell
|
||||||
|
$env:ENCRYPTION_ENABLED = "true"
|
||||||
|
$env:KMS_ENABLED = "true" # Optional
|
||||||
|
python run.py
|
||||||
|
|
||||||
|
# Bash
|
||||||
|
export ENCRYPTION_ENABLED=true
|
||||||
|
export KMS_ENABLED=true
|
||||||
|
python run.py</code></pre>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Configure bucket encryption:</strong> Navigate to your bucket → <strong>Properties</strong> tab → <strong>Default Encryption</strong> card → Click <strong>Enable Encryption</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Choose algorithm:</strong> Select <strong>AES-256</strong> for server-managed keys or <strong>aws:kms</strong> to use a KMS-managed key.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="alert alert-warning border-warning bg-warning-subtle mb-3">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle mt-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
|
||||||
|
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong>Important:</strong> Only <em>new uploads</em> after enabling encryption will be encrypted. Existing objects remain unencrypted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">KMS Key Management</h3>
|
||||||
|
<p class="small text-muted">When <code>KMS_ENABLED=true</code>, manage encryption keys via the API:</p>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># Create a new KMS key
|
||||||
|
curl -X POST {{ api_base }}/kms/keys \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
|
||||||
|
-d '{"alias": "my-key", "description": "Production key"}'
|
||||||
|
|
||||||
|
# List all keys
|
||||||
|
curl {{ api_base }}/kms/keys \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
|
||||||
|
|
||||||
|
# Rotate a key (creates new key material)
|
||||||
|
curl -X POST {{ api_base }}/kms/keys/{key-id}/rotate \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
|
||||||
|
|
||||||
|
# Disable/Enable a key
|
||||||
|
curl -X POST {{ api_base }}/kms/keys/{key-id}/disable \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
|
||||||
|
|
||||||
|
# Schedule key deletion (30-day waiting period)
|
||||||
|
curl -X DELETE "{{ api_base }}/kms/keys/{key-id}?waiting_period_days=30" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"</code></pre>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">How It Works</h3>
|
||||||
|
<p class="small text-muted mb-0">
|
||||||
|
<strong>Envelope Encryption:</strong> Each object is encrypted with a unique Data Encryption Key (DEK). The DEK is then encrypted (wrapped) by the master key or KMS key and stored alongside the ciphertext. On read, the DEK is unwrapped and used to decrypt the object transparently.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<article id="troubleshooting" class="card shadow-sm docs-section">
|
<article id="troubleshooting" class="card shadow-sm docs-section">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<span class="docs-section-kicker">09</span>
|
<span class="docs-section-kicker">12</span>
|
||||||
<h2 class="h4 mb-0">Troubleshooting & tips</h2>
|
<h2 class="h4 mb-0">Troubleshooting & tips</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -468,6 +709,8 @@ s3.complete_multipart_upload(
|
|||||||
<li><a href="#api">REST endpoints</a></li>
|
<li><a href="#api">REST endpoints</a></li>
|
||||||
<li><a href="#examples">API Examples</a></li>
|
<li><a href="#examples">API Examples</a></li>
|
||||||
<li><a href="#replication">Site Replication</a></li>
|
<li><a href="#replication">Site Replication</a></li>
|
||||||
|
<li><a href="#quotas">Bucket Quotas</a></li>
|
||||||
|
<li><a href="#encryption">Encryption</a></li>
|
||||||
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="docs-sidebar-callouts">
|
<div class="docs-sidebar-callouts">
|
||||||
|
|||||||
@@ -203,7 +203,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create User Modal -->
|
|
||||||
<div class="modal fade" id="createUserModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="createUserModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -252,7 +251,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Policy Editor Modal -->
|
|
||||||
<div class="modal fade" id="policyEditorModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="policyEditorModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -303,7 +301,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit User Modal -->
|
|
||||||
<div class="modal fade" id="editUserModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="editUserModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -338,7 +335,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete User Modal -->
|
|
||||||
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -382,7 +378,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rotate Secret Modal -->
|
|
||||||
<div class="modal fade" id="rotateSecretModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="rotateSecretModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -487,7 +482,6 @@
|
|||||||
const iamUsersData = document.getElementById('iamUsersJson');
|
const iamUsersData = document.getElementById('iamUsersJson');
|
||||||
const users = iamUsersData ? JSON.parse(iamUsersData.textContent || '[]') : [];
|
const users = iamUsersData ? JSON.parse(iamUsersData.textContent || '[]') : [];
|
||||||
|
|
||||||
// Policy Editor Logic
|
|
||||||
const policyModalEl = document.getElementById('policyEditorModal');
|
const policyModalEl = document.getElementById('policyEditorModal');
|
||||||
const policyModal = new bootstrap.Modal(policyModalEl);
|
const policyModal = new bootstrap.Modal(policyModalEl);
|
||||||
const userLabelEl = document.getElementById('policyEditorUserLabel');
|
const userLabelEl = document.getElementById('policyEditorUserLabel');
|
||||||
@@ -509,7 +503,7 @@
|
|||||||
full: [
|
full: [
|
||||||
{
|
{
|
||||||
bucket: '*',
|
bucket: '*',
|
||||||
actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'iam:list_users', 'iam:*'],
|
actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'iam:list_users', 'iam:*'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
readonly: [
|
readonly: [
|
||||||
@@ -534,7 +528,6 @@
|
|||||||
button.addEventListener('click', () => applyTemplate(button.dataset.policyTemplate));
|
button.addEventListener('click', () => applyTemplate(button.dataset.policyTemplate));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create User modal template buttons
|
|
||||||
const createUserPoliciesEl = document.getElementById('createUserPolicies');
|
const createUserPoliciesEl = document.getElementById('createUserPolicies');
|
||||||
const createTemplateButtons = document.querySelectorAll('[data-create-policy-template]');
|
const createTemplateButtons = document.querySelectorAll('[data-create-policy-template]');
|
||||||
|
|
||||||
@@ -543,7 +536,7 @@
|
|||||||
full: [
|
full: [
|
||||||
{
|
{
|
||||||
bucket: '*',
|
bucket: '*',
|
||||||
actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'iam:list_users', 'iam:*'],
|
actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'iam:list_users', 'iam:*'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
readonly: [
|
readonly: [
|
||||||
@@ -591,7 +584,6 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit User Logic
|
|
||||||
const editUserModal = new bootstrap.Modal(document.getElementById('editUserModal'));
|
const editUserModal = new bootstrap.Modal(document.getElementById('editUserModal'));
|
||||||
const editUserForm = document.getElementById('editUserForm');
|
const editUserForm = document.getElementById('editUserForm');
|
||||||
const editUserDisplayName = document.getElementById('editUserDisplayName');
|
const editUserDisplayName = document.getElementById('editUserDisplayName');
|
||||||
@@ -606,7 +598,6 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete User Logic
|
|
||||||
const deleteUserModal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
|
const deleteUserModal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
|
||||||
const deleteUserForm = document.getElementById('deleteUserForm');
|
const deleteUserForm = document.getElementById('deleteUserForm');
|
||||||
const deleteUserLabel = document.getElementById('deleteUserLabel');
|
const deleteUserLabel = document.getElementById('deleteUserLabel');
|
||||||
@@ -628,7 +619,6 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rotate Secret Logic
|
|
||||||
const rotateSecretModal = new bootstrap.Modal(document.getElementById('rotateSecretModal'));
|
const rotateSecretModal = new bootstrap.Modal(document.getElementById('rotateSecretModal'));
|
||||||
const rotateUserLabel = document.getElementById('rotateUserLabel');
|
const rotateUserLabel = document.getElementById('rotateUserLabel');
|
||||||
const confirmRotateBtn = document.getElementById('confirmRotateBtn');
|
const confirmRotateBtn = document.getElementById('confirmRotateBtn');
|
||||||
@@ -645,7 +635,6 @@
|
|||||||
currentRotateKey = btn.dataset.rotateUser;
|
currentRotateKey = btn.dataset.rotateUser;
|
||||||
rotateUserLabel.textContent = currentRotateKey;
|
rotateUserLabel.textContent = currentRotateKey;
|
||||||
|
|
||||||
// Reset Modal State
|
|
||||||
rotateSecretConfirm.classList.remove('d-none');
|
rotateSecretConfirm.classList.remove('d-none');
|
||||||
rotateSecretResult.classList.add('d-none');
|
rotateSecretResult.classList.add('d-none');
|
||||||
confirmRotateBtn.classList.remove('d-none');
|
confirmRotateBtn.classList.remove('d-none');
|
||||||
@@ -680,7 +669,6 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
newSecretKeyInput.value = data.secret_key;
|
newSecretKeyInput.value = data.secret_key;
|
||||||
|
|
||||||
// Show Result
|
|
||||||
rotateSecretConfirm.classList.add('d-none');
|
rotateSecretConfirm.classList.add('d-none');
|
||||||
rotateSecretResult.classList.remove('d-none');
|
rotateSecretResult.classList.remove('d-none');
|
||||||
confirmRotateBtn.classList.add('d-none');
|
confirmRotateBtn.classList.add('d-none');
|
||||||
@@ -688,7 +676,9 @@
|
|||||||
rotateDoneBtn.classList.remove('d-none');
|
rotateDoneBtn.classList.remove('d-none');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
if (window.showToast) {
|
||||||
|
window.showToast(err.message, 'Error', 'danger');
|
||||||
|
}
|
||||||
rotateSecretModal.hide();
|
rotateSecretModal.hide();
|
||||||
} finally {
|
} finally {
|
||||||
confirmRotateBtn.disabled = false;
|
confirmRotateBtn.disabled = false;
|
||||||
|
|||||||
@@ -126,7 +126,6 @@
|
|||||||
<div class="card shadow-sm border-0">
|
<div class="card shadow-sm border-0">
|
||||||
<div class="card-header bg-transparent border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
|
<div class="card-header bg-transparent border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
|
||||||
<h5 class="card-title mb-0 fw-semibold">System Overview</h5>
|
<h5 class="card-title mb-0 fw-semibold">System Overview</h5>
|
||||||
<span class="badge bg-primary-subtle text-primary">Live</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -233,14 +232,14 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-check-circle-fill me-1" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-check-circle-fill me-1" viewBox="0 0 16 16">
|
||||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Healthy
|
v{{ app.version }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="card-title fw-bold mb-3">System Status</h4>
|
<h4 class="card-title fw-bold mb-3">System Status</h4>
|
||||||
<p class="card-text opacity-90 mb-4">All systems operational. Your storage infrastructure is running smoothly with no detected issues.</p>
|
<p class="card-text opacity-90 mb-4">All systems operational. Your storage infrastructure is running smoothly with no detected issues.</p>
|
||||||
<div class="d-flex gap-4">
|
<div class="d-flex gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="h3 fw-bold mb-0">99.9%</div>
|
<div class="h3 fw-bold mb-0">{{ app.uptime_days }}d</div>
|
||||||
<small class="opacity-75">Uptime</small>
|
<small class="opacity-75">Uptime</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def test_unicode_bucket_and_object_names(tmp_path: Path):
|
|||||||
assert storage.get_object_path("unicode-test", key).exists()
|
assert storage.get_object_path("unicode-test", key).exists()
|
||||||
|
|
||||||
# Verify listing
|
# Verify listing
|
||||||
objects = storage.list_objects("unicode-test")
|
objects = storage.list_objects_all("unicode-test")
|
||||||
assert any(o.key == key for o in objects)
|
assert any(o.key == key for o in objects)
|
||||||
|
|
||||||
def test_special_characters_in_metadata(tmp_path: Path):
|
def test_special_characters_in_metadata(tmp_path: Path):
|
||||||
|
|||||||
763
tests/test_encryption.py
Normal file
763
tests/test_encryption.py
Normal file
@@ -0,0 +1,763 @@
|
|||||||
|
"""Tests for encryption functionality."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocalKeyEncryption:
|
||||||
|
"""Tests for LocalKeyEncryption provider."""
|
||||||
|
|
||||||
|
def test_create_master_key(self, tmp_path):
|
||||||
|
"""Test that master key is created if it doesn't exist."""
|
||||||
|
from app.encryption import LocalKeyEncryption
|
||||||
|
|
||||||
|
key_path = tmp_path / "keys" / "master.key"
|
||||||
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
|
||||||
|
# Access master key to trigger creation
|
||||||
|
key = provider.master_key
|
||||||
|
|
||||||
|
assert key_path.exists()
|
||||||
|
assert len(key) == 32 # 256-bit key
|
||||||
|
|
||||||
|
def test_load_existing_master_key(self, tmp_path):
|
||||||
|
"""Test loading an existing master key."""
|
||||||
|
from app.encryption import LocalKeyEncryption
|
||||||
|
|
||||||
|
key_path = tmp_path / "master.key"
|
||||||
|
original_key = secrets.token_bytes(32)
|
||||||
|
key_path.write_text(base64.b64encode(original_key).decode())
|
||||||
|
|
||||||
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
loaded_key = provider.master_key
|
||||||
|
|
||||||
|
assert loaded_key == original_key
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_roundtrip(self, tmp_path):
|
||||||
|
"""Test that data can be encrypted and decrypted correctly."""
|
||||||
|
from app.encryption import LocalKeyEncryption
|
||||||
|
|
||||||
|
key_path = tmp_path / "master.key"
|
||||||
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
|
||||||
|
plaintext = b"Hello, World! This is a test message."
|
||||||
|
|
||||||
|
# Encrypt
|
||||||
|
result = provider.encrypt(plaintext)
|
||||||
|
|
||||||
|
assert result.ciphertext != plaintext
|
||||||
|
assert result.key_id == "local"
|
||||||
|
assert len(result.nonce) == 12
|
||||||
|
assert len(result.encrypted_data_key) > 0
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
decrypted = provider.decrypt(
|
||||||
|
result.ciphertext,
|
||||||
|
result.nonce,
|
||||||
|
result.encrypted_data_key,
|
||||||
|
result.key_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
def test_different_data_keys_per_encryption(self, tmp_path):
|
||||||
|
"""Test that each encryption uses a different data key."""
|
||||||
|
from app.encryption import LocalKeyEncryption
|
||||||
|
|
||||||
|
key_path = tmp_path / "master.key"
|
||||||
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
|
||||||
|
plaintext = b"Same message"
|
||||||
|
|
||||||
|
result1 = provider.encrypt(plaintext)
|
||||||
|
result2 = provider.encrypt(plaintext)
|
||||||
|
|
||||||
|
# Different encrypted data keys
|
||||||
|
assert result1.encrypted_data_key != result2.encrypted_data_key
|
||||||
|
# Different nonces
|
||||||
|
assert result1.nonce != result2.nonce
|
||||||
|
# Different ciphertexts
|
||||||
|
assert result1.ciphertext != result2.ciphertext
|
||||||
|
|
||||||
|
def test_generate_data_key(self, tmp_path):
|
||||||
|
"""Test data key generation."""
|
||||||
|
from app.encryption import LocalKeyEncryption
|
||||||
|
|
||||||
|
key_path = tmp_path / "master.key"
|
||||||
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
|
||||||
|
plaintext_key, encrypted_key = provider.generate_data_key()
|
||||||
|
|
||||||
|
assert len(plaintext_key) == 32
|
||||||
|
assert len(encrypted_key) > 32 # nonce + ciphertext + tag
|
||||||
|
|
||||||
|
# Verify we can decrypt the key
|
||||||
|
decrypted_key = provider._decrypt_data_key(encrypted_key)
|
||||||
|
assert decrypted_key == plaintext_key
|
||||||
|
|
||||||
|
def test_decrypt_with_wrong_key_fails(self, tmp_path):
|
||||||
|
"""Test that decryption fails with wrong master key."""
|
||||||
|
from app.encryption import LocalKeyEncryption, EncryptionError
|
||||||
|
|
||||||
|
# Create two providers with different keys
|
||||||
|
key_path1 = tmp_path / "master1.key"
|
||||||
|
key_path2 = tmp_path / "master2.key"
|
||||||
|
|
||||||
|
provider1 = LocalKeyEncryption(key_path1)
|
||||||
|
provider2 = LocalKeyEncryption(key_path2)
|
||||||
|
|
||||||
|
# Encrypt with provider1
|
||||||
|
plaintext = b"Secret message"
|
||||||
|
result = provider1.encrypt(plaintext)
|
||||||
|
|
||||||
|
# Try to decrypt with provider2
|
||||||
|
with pytest.raises(EncryptionError):
|
||||||
|
provider2.decrypt(
|
||||||
|
result.ciphertext,
|
||||||
|
result.nonce,
|
||||||
|
result.encrypted_data_key,
|
||||||
|
result.key_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncryptionMetadata:
|
||||||
|
"""Tests for EncryptionMetadata class."""
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""Test converting metadata to dictionary."""
|
||||||
|
from app.encryption import EncryptionMetadata
|
||||||
|
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
encrypted_key = secrets.token_bytes(60)
|
||||||
|
|
||||||
|
metadata = EncryptionMetadata(
|
||||||
|
algorithm="AES256",
|
||||||
|
key_id="local",
|
||||||
|
nonce=nonce,
|
||||||
|
encrypted_data_key=encrypted_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = metadata.to_dict()
|
||||||
|
|
||||||
|
assert result["x-amz-server-side-encryption"] == "AES256"
|
||||||
|
assert result["x-amz-encryption-key-id"] == "local"
|
||||||
|
assert base64.b64decode(result["x-amz-encryption-nonce"]) == nonce
|
||||||
|
assert base64.b64decode(result["x-amz-encrypted-data-key"]) == encrypted_key
|
||||||
|
|
||||||
|
def test_from_dict(self):
|
||||||
|
"""Test creating metadata from dictionary."""
|
||||||
|
from app.encryption import EncryptionMetadata
|
||||||
|
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
encrypted_key = secrets.token_bytes(60)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"x-amz-server-side-encryption": "AES256",
|
||||||
|
"x-amz-encryption-key-id": "local",
|
||||||
|
"x-amz-encryption-nonce": base64.b64encode(nonce).decode(),
|
||||||
|
"x-amz-encrypted-data-key": base64.b64encode(encrypted_key).decode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = EncryptionMetadata.from_dict(data)
|
||||||
|
|
||||||
|
assert metadata is not None
|
||||||
|
assert metadata.algorithm == "AES256"
|
||||||
|
assert metadata.key_id == "local"
|
||||||
|
assert metadata.nonce == nonce
|
||||||
|
assert metadata.encrypted_data_key == encrypted_key
|
||||||
|
|
||||||
|
def test_from_dict_returns_none_for_unencrypted(self):
|
||||||
|
"""Test that from_dict returns None for unencrypted objects."""
|
||||||
|
from app.encryption import EncryptionMetadata
|
||||||
|
|
||||||
|
data = {"some-other-key": "value"}
|
||||||
|
|
||||||
|
metadata = EncryptionMetadata.from_dict(data)
|
||||||
|
|
||||||
|
assert metadata is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamingEncryptor:
|
||||||
|
"""Tests for streaming encryption."""
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_stream(self, tmp_path):
|
||||||
|
"""Test streaming encryption and decryption."""
|
||||||
|
from app.encryption import LocalKeyEncryption, StreamingEncryptor
|
||||||
|
|
||||||
|
key_path = tmp_path / "master.key"
|
||||||
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
encryptor = StreamingEncryptor(provider, chunk_size=1024)
|
||||||
|
|
||||||
|
# Create test data
|
||||||
|
original_data = b"A" * 5000 + b"B" * 5000 + b"C" * 5000 # 15KB
|
||||||
|
stream = io.BytesIO(original_data)
|
||||||
|
|
||||||
|
# Encrypt
|
||||||
|
encrypted_stream, metadata = encryptor.encrypt_stream(stream)
|
||||||
|
encrypted_data = encrypted_stream.read()
|
||||||
|
|
||||||
|
assert encrypted_data != original_data
|
||||||
|
assert metadata.algorithm == "AES256"
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
encrypted_stream = io.BytesIO(encrypted_data)
|
||||||
|
decrypted_stream = encryptor.decrypt_stream(encrypted_stream, metadata)
|
||||||
|
decrypted_data = decrypted_stream.read()
|
||||||
|
|
||||||
|
assert decrypted_data == original_data
|
||||||
|
|
||||||
|
def test_encrypt_small_data(self, tmp_path):
|
||||||
|
"""Test encrypting data smaller than chunk size."""
|
||||||
|
from app.encryption import LocalKeyEncryption, StreamingEncryptor
|
||||||
|
|
||||||
|
key_path = tmp_path / "master.key"
|
||||||
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
encryptor = StreamingEncryptor(provider, chunk_size=1024)
|
||||||
|
|
||||||
|
original_data = b"Small data"
|
||||||
|
stream = io.BytesIO(original_data)
|
||||||
|
|
||||||
|
encrypted_stream, metadata = encryptor.encrypt_stream(stream)
|
||||||
|
encrypted_stream.seek(0)
|
||||||
|
|
||||||
|
decrypted_stream = encryptor.decrypt_stream(encrypted_stream, metadata)
|
||||||
|
decrypted_data = decrypted_stream.read()
|
||||||
|
|
||||||
|
assert decrypted_data == original_data
|
||||||
|
|
||||||
|
def test_encrypt_empty_data(self, tmp_path):
|
||||||
|
"""Test encrypting empty data."""
|
||||||
|
from app.encryption import LocalKeyEncryption, StreamingEncryptor
|
||||||
|
|
||||||
|
key_path = tmp_path / "master.key"
|
||||||
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
encryptor = StreamingEncryptor(provider)
|
||||||
|
|
||||||
|
stream = io.BytesIO(b"")
|
||||||
|
|
||||||
|
encrypted_stream, metadata = encryptor.encrypt_stream(stream)
|
||||||
|
encrypted_stream.seek(0)
|
||||||
|
|
||||||
|
decrypted_stream = encryptor.decrypt_stream(encrypted_stream, metadata)
|
||||||
|
decrypted_data = decrypted_stream.read()
|
||||||
|
|
||||||
|
assert decrypted_data == b""
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncryptionManager:
|
||||||
|
"""Tests for EncryptionManager."""
|
||||||
|
|
||||||
|
def test_encryption_disabled_by_default(self, tmp_path):
|
||||||
|
"""Test that encryption is disabled by default."""
|
||||||
|
from app.encryption import EncryptionManager
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"encryption_enabled": False,
|
||||||
|
"encryption_master_key_path": str(tmp_path / "master.key"),
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = EncryptionManager(config)
|
||||||
|
|
||||||
|
assert not manager.enabled
|
||||||
|
|
||||||
|
def test_encryption_enabled(self, tmp_path):
|
||||||
|
"""Test enabling encryption."""
|
||||||
|
from app.encryption import EncryptionManager
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"encryption_enabled": True,
|
||||||
|
"encryption_master_key_path": str(tmp_path / "master.key"),
|
||||||
|
"default_encryption_algorithm": "AES256",
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = EncryptionManager(config)
|
||||||
|
|
||||||
|
assert manager.enabled
|
||||||
|
assert manager.default_algorithm == "AES256"
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_object(self, tmp_path):
|
||||||
|
"""Test encrypting and decrypting an object."""
|
||||||
|
from app.encryption import EncryptionManager
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"encryption_enabled": True,
|
||||||
|
"encryption_master_key_path": str(tmp_path / "master.key"),
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = EncryptionManager(config)
|
||||||
|
|
||||||
|
plaintext = b"Object data to encrypt"
|
||||||
|
|
||||||
|
ciphertext, metadata = manager.encrypt_object(plaintext)
|
||||||
|
|
||||||
|
assert ciphertext != plaintext
|
||||||
|
assert metadata.algorithm == "AES256"
|
||||||
|
|
||||||
|
decrypted = manager.decrypt_object(ciphertext, metadata)
|
||||||
|
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientEncryptionHelper:
|
||||||
|
"""Tests for client-side encryption helpers."""
|
||||||
|
|
||||||
|
def test_generate_client_key(self):
|
||||||
|
"""Test generating a client encryption key."""
|
||||||
|
from app.encryption import ClientEncryptionHelper
|
||||||
|
|
||||||
|
key_info = ClientEncryptionHelper.generate_client_key()
|
||||||
|
|
||||||
|
assert "key" in key_info
|
||||||
|
assert key_info["algorithm"] == "AES-256-GCM"
|
||||||
|
assert "created_at" in key_info
|
||||||
|
|
||||||
|
# Verify key is 256 bits
|
||||||
|
key = base64.b64decode(key_info["key"])
|
||||||
|
assert len(key) == 32
|
||||||
|
|
||||||
|
def test_encrypt_with_key(self):
|
||||||
|
"""Test encrypting data with a client key."""
|
||||||
|
from app.encryption import ClientEncryptionHelper
|
||||||
|
|
||||||
|
key = base64.b64encode(secrets.token_bytes(32)).decode()
|
||||||
|
plaintext = b"Client-side encrypted data"
|
||||||
|
|
||||||
|
result = ClientEncryptionHelper.encrypt_with_key(plaintext, key)
|
||||||
|
|
||||||
|
assert "ciphertext" in result
|
||||||
|
assert "nonce" in result
|
||||||
|
assert result["algorithm"] == "AES-256-GCM"
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_with_key(self):
|
||||||
|
"""Test round-trip client-side encryption."""
|
||||||
|
from app.encryption import ClientEncryptionHelper
|
||||||
|
|
||||||
|
key = base64.b64encode(secrets.token_bytes(32)).decode()
|
||||||
|
plaintext = b"Client-side encrypted data"
|
||||||
|
|
||||||
|
encrypted = ClientEncryptionHelper.encrypt_with_key(plaintext, key)
|
||||||
|
|
||||||
|
decrypted = ClientEncryptionHelper.decrypt_with_key(
|
||||||
|
encrypted["ciphertext"],
|
||||||
|
encrypted["nonce"],
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
def test_wrong_key_fails(self):
|
||||||
|
"""Test that decryption with wrong key fails."""
|
||||||
|
from app.encryption import ClientEncryptionHelper, EncryptionError
|
||||||
|
|
||||||
|
key1 = base64.b64encode(secrets.token_bytes(32)).decode()
|
||||||
|
key2 = base64.b64encode(secrets.token_bytes(32)).decode()
|
||||||
|
plaintext = b"Secret data"
|
||||||
|
|
||||||
|
encrypted = ClientEncryptionHelper.encrypt_with_key(plaintext, key1)
|
||||||
|
|
||||||
|
with pytest.raises(EncryptionError):
|
||||||
|
ClientEncryptionHelper.decrypt_with_key(
|
||||||
|
encrypted["ciphertext"],
|
||||||
|
encrypted["nonce"],
|
||||||
|
key2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestKMSManager:
|
||||||
|
"""Tests for KMS key management."""
|
||||||
|
|
||||||
|
def test_create_key(self, tmp_path):
|
||||||
|
"""Test creating a KMS key."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
key = kms.create_key("Test key", key_id="test-key-1")
|
||||||
|
|
||||||
|
assert key.key_id == "test-key-1"
|
||||||
|
assert key.description == "Test key"
|
||||||
|
assert key.enabled
|
||||||
|
assert keys_path.exists()
|
||||||
|
|
||||||
|
def test_list_keys(self, tmp_path):
|
||||||
|
"""Test listing KMS keys."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
kms.create_key("Key 1", key_id="key-1")
|
||||||
|
kms.create_key("Key 2", key_id="key-2")
|
||||||
|
|
||||||
|
keys = kms.list_keys()
|
||||||
|
|
||||||
|
assert len(keys) == 2
|
||||||
|
key_ids = {k.key_id for k in keys}
|
||||||
|
assert "key-1" in key_ids
|
||||||
|
assert "key-2" in key_ids
|
||||||
|
|
||||||
|
def test_get_key(self, tmp_path):
|
||||||
|
"""Test getting a specific key."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
kms.create_key("Test key", key_id="test-key")
|
||||||
|
|
||||||
|
key = kms.get_key("test-key")
|
||||||
|
|
||||||
|
assert key is not None
|
||||||
|
assert key.key_id == "test-key"
|
||||||
|
|
||||||
|
# Non-existent key
|
||||||
|
assert kms.get_key("non-existent") is None
|
||||||
|
|
||||||
|
def test_enable_disable_key(self, tmp_path):
|
||||||
|
"""Test enabling and disabling keys."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
kms.create_key("Test key", key_id="test-key")
|
||||||
|
|
||||||
|
# Initially enabled
|
||||||
|
assert kms.get_key("test-key").enabled
|
||||||
|
|
||||||
|
# Disable
|
||||||
|
kms.disable_key("test-key")
|
||||||
|
assert not kms.get_key("test-key").enabled
|
||||||
|
|
||||||
|
# Enable
|
||||||
|
kms.enable_key("test-key")
|
||||||
|
assert kms.get_key("test-key").enabled
|
||||||
|
|
||||||
|
def test_delete_key(self, tmp_path):
|
||||||
|
"""Test deleting a key."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
kms.create_key("Test key", key_id="test-key")
|
||||||
|
assert kms.get_key("test-key") is not None
|
||||||
|
|
||||||
|
kms.delete_key("test-key")
|
||||||
|
assert kms.get_key("test-key") is None
|
||||||
|
|
||||||
|
def test_encrypt_decrypt(self, tmp_path):
|
||||||
|
"""Test KMS encrypt and decrypt."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
key = kms.create_key("Test key", key_id="test-key")
|
||||||
|
|
||||||
|
plaintext = b"Secret data to encrypt"
|
||||||
|
|
||||||
|
ciphertext = kms.encrypt("test-key", plaintext)
|
||||||
|
|
||||||
|
assert ciphertext != plaintext
|
||||||
|
|
||||||
|
decrypted, key_id = kms.decrypt(ciphertext)
|
||||||
|
|
||||||
|
assert decrypted == plaintext
|
||||||
|
assert key_id == "test-key"
|
||||||
|
|
||||||
|
def test_encrypt_with_context(self, tmp_path):
|
||||||
|
"""Test encryption with encryption context."""
|
||||||
|
from app.kms import KMSManager, EncryptionError
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
kms.create_key("Test key", key_id="test-key")
|
||||||
|
|
||||||
|
plaintext = b"Secret data"
|
||||||
|
context = {"bucket": "test-bucket", "key": "test-key"}
|
||||||
|
|
||||||
|
ciphertext = kms.encrypt("test-key", plaintext, context)
|
||||||
|
|
||||||
|
# Decrypt with same context succeeds
|
||||||
|
decrypted, _ = kms.decrypt(ciphertext, context)
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
# Decrypt with different context fails
|
||||||
|
with pytest.raises(EncryptionError):
|
||||||
|
kms.decrypt(ciphertext, {"different": "context"})
|
||||||
|
|
||||||
|
def test_generate_data_key(self, tmp_path):
|
||||||
|
"""Test generating a data key."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
kms.create_key("Test key", key_id="test-key")
|
||||||
|
|
||||||
|
plaintext_key, encrypted_key = kms.generate_data_key("test-key")
|
||||||
|
|
||||||
|
assert len(plaintext_key) == 32
|
||||||
|
assert len(encrypted_key) > 0
|
||||||
|
|
||||||
|
# Decrypt the encrypted key
|
||||||
|
decrypted_key = kms.decrypt_data_key("test-key", encrypted_key)
|
||||||
|
|
||||||
|
assert decrypted_key == plaintext_key
|
||||||
|
|
||||||
|
def test_disabled_key_cannot_encrypt(self, tmp_path):
|
||||||
|
"""Test that disabled keys cannot be used for encryption."""
|
||||||
|
from app.kms import KMSManager, EncryptionError
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
kms.create_key("Test key", key_id="test-key")
|
||||||
|
kms.disable_key("test-key")
|
||||||
|
|
||||||
|
with pytest.raises(EncryptionError, match="disabled"):
|
||||||
|
kms.encrypt("test-key", b"data")
|
||||||
|
|
||||||
|
def test_re_encrypt(self, tmp_path):
|
||||||
|
"""Test re-encrypting data with a different key."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
kms.create_key("Key 1", key_id="key-1")
|
||||||
|
kms.create_key("Key 2", key_id="key-2")
|
||||||
|
|
||||||
|
plaintext = b"Data to re-encrypt"
|
||||||
|
|
||||||
|
# Encrypt with key-1
|
||||||
|
ciphertext1 = kms.encrypt("key-1", plaintext)
|
||||||
|
|
||||||
|
# Re-encrypt with key-2
|
||||||
|
ciphertext2 = kms.re_encrypt(ciphertext1, "key-2")
|
||||||
|
|
||||||
|
# Decrypt with key-2
|
||||||
|
decrypted, key_id = kms.decrypt(ciphertext2)
|
||||||
|
|
||||||
|
assert decrypted == plaintext
|
||||||
|
assert key_id == "key-2"
|
||||||
|
|
||||||
|
def test_generate_random(self, tmp_path):
|
||||||
|
"""Test generating random bytes."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
random1 = kms.generate_random(32)
|
||||||
|
random2 = kms.generate_random(32)
|
||||||
|
|
||||||
|
assert len(random1) == 32
|
||||||
|
assert len(random2) == 32
|
||||||
|
assert random1 != random2 # Very unlikely to be equal
|
||||||
|
|
||||||
|
def test_keys_persist_across_instances(self, tmp_path):
|
||||||
|
"""Test that keys persist and can be loaded by new instances."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
# Create key with first instance
|
||||||
|
kms1 = KMSManager(keys_path, master_key_path)
|
||||||
|
kms1.create_key("Test key", key_id="test-key")
|
||||||
|
|
||||||
|
plaintext = b"Persistent encryption test"
|
||||||
|
ciphertext = kms1.encrypt("test-key", plaintext)
|
||||||
|
|
||||||
|
# Create new instance and verify key works
|
||||||
|
kms2 = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
|
decrypted, key_id = kms2.decrypt(ciphertext)
|
||||||
|
|
||||||
|
assert decrypted == plaintext
|
||||||
|
assert key_id == "test-key"
|
||||||
|
|
||||||
|
|
||||||
|
class TestKMSEncryptionProvider:
|
||||||
|
"""Tests for KMS encryption provider."""
|
||||||
|
|
||||||
|
def test_kms_encryption_provider(self, tmp_path):
|
||||||
|
"""Test using KMS as an encryption provider."""
|
||||||
|
from app.kms import KMSManager
|
||||||
|
|
||||||
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
kms.create_key("Test key", key_id="test-key")
|
||||||
|
|
||||||
|
provider = kms.get_provider("test-key")
|
||||||
|
|
||||||
|
plaintext = b"Data encrypted with KMS provider"
|
||||||
|
|
||||||
|
result = provider.encrypt(plaintext)
|
||||||
|
|
||||||
|
assert result.key_id == "test-key"
|
||||||
|
assert result.ciphertext != plaintext
|
||||||
|
|
||||||
|
decrypted = provider.decrypt(
|
||||||
|
result.ciphertext,
|
||||||
|
result.nonce,
|
||||||
|
result.encrypted_data_key,
|
||||||
|
result.key_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncryptedStorage:
|
||||||
|
"""Tests for encrypted storage layer."""
|
||||||
|
|
||||||
|
def test_put_and_get_encrypted_object(self, tmp_path):
|
||||||
|
"""Test storing and retrieving an encrypted object."""
|
||||||
|
from app.storage import ObjectStorage
|
||||||
|
from app.encryption import EncryptionManager
|
||||||
|
from app.encrypted_storage import EncryptedObjectStorage
|
||||||
|
|
||||||
|
storage_root = tmp_path / "storage"
|
||||||
|
storage = ObjectStorage(storage_root)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"encryption_enabled": True,
|
||||||
|
"encryption_master_key_path": str(tmp_path / "master.key"),
|
||||||
|
"default_encryption_algorithm": "AES256",
|
||||||
|
}
|
||||||
|
encryption = EncryptionManager(config)
|
||||||
|
|
||||||
|
encrypted_storage = EncryptedObjectStorage(storage, encryption)
|
||||||
|
|
||||||
|
# Create bucket with encryption config
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
storage.set_bucket_encryption("test-bucket", {
|
||||||
|
"Rules": [{"SSEAlgorithm": "AES256"}]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Put object
|
||||||
|
original_data = b"This is secret data that should be encrypted"
|
||||||
|
stream = io.BytesIO(original_data)
|
||||||
|
|
||||||
|
meta = encrypted_storage.put_object(
|
||||||
|
"test-bucket",
|
||||||
|
"secret.txt",
|
||||||
|
stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta is not None
|
||||||
|
|
||||||
|
# Verify file on disk is encrypted (not plaintext)
|
||||||
|
file_path = storage_root / "test-bucket" / "secret.txt"
|
||||||
|
stored_data = file_path.read_bytes()
|
||||||
|
assert stored_data != original_data
|
||||||
|
|
||||||
|
# Get object - should be decrypted
|
||||||
|
data, metadata = encrypted_storage.get_object_data("test-bucket", "secret.txt")
|
||||||
|
|
||||||
|
assert data == original_data
|
||||||
|
|
||||||
|
def test_no_encryption_without_config(self, tmp_path):
|
||||||
|
"""Test that objects are not encrypted without bucket config."""
|
||||||
|
from app.storage import ObjectStorage
|
||||||
|
from app.encryption import EncryptionManager
|
||||||
|
from app.encrypted_storage import EncryptedObjectStorage
|
||||||
|
|
||||||
|
storage_root = tmp_path / "storage"
|
||||||
|
storage = ObjectStorage(storage_root)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"encryption_enabled": True,
|
||||||
|
"encryption_master_key_path": str(tmp_path / "master.key"),
|
||||||
|
}
|
||||||
|
encryption = EncryptionManager(config)
|
||||||
|
|
||||||
|
encrypted_storage = EncryptedObjectStorage(storage, encryption)
|
||||||
|
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
# No encryption config
|
||||||
|
|
||||||
|
original_data = b"Unencrypted data"
|
||||||
|
stream = io.BytesIO(original_data)
|
||||||
|
|
||||||
|
encrypted_storage.put_object("test-bucket", "plain.txt", stream)
|
||||||
|
|
||||||
|
# Verify file on disk is NOT encrypted
|
||||||
|
file_path = storage_root / "test-bucket" / "plain.txt"
|
||||||
|
stored_data = file_path.read_bytes()
|
||||||
|
assert stored_data == original_data
|
||||||
|
|
||||||
|
def test_explicit_encryption_request(self, tmp_path):
|
||||||
|
"""Test explicitly requesting encryption."""
|
||||||
|
from app.storage import ObjectStorage
|
||||||
|
from app.encryption import EncryptionManager
|
||||||
|
from app.encrypted_storage import EncryptedObjectStorage
|
||||||
|
|
||||||
|
storage_root = tmp_path / "storage"
|
||||||
|
storage = ObjectStorage(storage_root)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"encryption_enabled": True,
|
||||||
|
"encryption_master_key_path": str(tmp_path / "master.key"),
|
||||||
|
}
|
||||||
|
encryption = EncryptionManager(config)
|
||||||
|
|
||||||
|
encrypted_storage = EncryptedObjectStorage(storage, encryption)
|
||||||
|
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
|
||||||
|
original_data = b"Explicitly encrypted data"
|
||||||
|
stream = io.BytesIO(original_data)
|
||||||
|
|
||||||
|
# Request encryption explicitly
|
||||||
|
encrypted_storage.put_object(
|
||||||
|
"test-bucket",
|
||||||
|
"encrypted.txt",
|
||||||
|
stream,
|
||||||
|
server_side_encryption="AES256",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify file is encrypted
|
||||||
|
file_path = storage_root / "test-bucket" / "encrypted.txt"
|
||||||
|
stored_data = file_path.read_bytes()
|
||||||
|
assert stored_data != original_data
|
||||||
|
|
||||||
|
# Get object - should be decrypted
|
||||||
|
data, _ = encrypted_storage.get_object_data("test-bucket", "encrypted.txt")
|
||||||
|
assert data == original_data
|
||||||
506
tests/test_kms_api.py
Normal file
506
tests/test_kms_api.py
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
"""Tests for KMS API endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def kms_client(tmp_path):
|
||||||
|
"""Create a test client with KMS enabled."""
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
app = create_app({
|
||||||
|
"TESTING": True,
|
||||||
|
"STORAGE_ROOT": str(tmp_path / "storage"),
|
||||||
|
"IAM_CONFIG": str(tmp_path / "iam.json"),
|
||||||
|
"BUCKET_POLICY_PATH": str(tmp_path / "policies.json"),
|
||||||
|
"ENCRYPTION_ENABLED": True,
|
||||||
|
"KMS_ENABLED": True,
|
||||||
|
"ENCRYPTION_MASTER_KEY_PATH": str(tmp_path / "master.key"),
|
||||||
|
"KMS_KEYS_PATH": str(tmp_path / "kms_keys.json"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create default IAM config with admin user
|
||||||
|
iam_config = {
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"access_key": "test-access-key",
|
||||||
|
"secret_key": "test-secret-key",
|
||||||
|
"display_name": "Test User",
|
||||||
|
"permissions": ["*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
(tmp_path / "iam.json").write_text(json.dumps(iam_config))
|
||||||
|
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers():
|
||||||
|
"""Get authentication headers."""
|
||||||
|
return {
|
||||||
|
"X-Access-Key": "test-access-key",
|
||||||
|
"X-Secret-Key": "test-secret-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestKMSKeyManagement:
|
||||||
|
"""Tests for KMS key management endpoints."""
|
||||||
|
|
||||||
|
def test_create_key(self, kms_client, auth_headers):
|
||||||
|
"""Test creating a KMS key."""
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/keys",
|
||||||
|
json={"Description": "Test encryption key"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert "KeyMetadata" in data
|
||||||
|
assert data["KeyMetadata"]["Description"] == "Test encryption key"
|
||||||
|
assert data["KeyMetadata"]["Enabled"] is True
|
||||||
|
assert "KeyId" in data["KeyMetadata"]
|
||||||
|
|
||||||
|
def test_create_key_with_custom_id(self, kms_client, auth_headers):
|
||||||
|
"""Test creating a key with a custom ID."""
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/keys",
|
||||||
|
json={"KeyId": "my-custom-key", "Description": "Custom key"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert data["KeyMetadata"]["KeyId"] == "my-custom-key"
|
||||||
|
|
||||||
|
def test_list_keys(self, kms_client, auth_headers):
|
||||||
|
"""Test listing KMS keys."""
|
||||||
|
# Create some keys
|
||||||
|
kms_client.post("/kms/keys", json={"Description": "Key 1"}, headers=auth_headers)
|
||||||
|
kms_client.post("/kms/keys", json={"Description": "Key 2"}, headers=auth_headers)
|
||||||
|
|
||||||
|
response = kms_client.get("/kms/keys", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert "Keys" in data
|
||||||
|
assert len(data["Keys"]) == 2
|
||||||
|
|
||||||
|
def test_get_key(self, kms_client, auth_headers):
|
||||||
|
"""Test getting a specific key."""
|
||||||
|
# Create a key
|
||||||
|
create_response = kms_client.post(
|
||||||
|
"/kms/keys",
|
||||||
|
json={"KeyId": "test-key", "Description": "Test key"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = kms_client.get("/kms/keys/test-key", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert data["KeyMetadata"]["KeyId"] == "test-key"
|
||||||
|
assert data["KeyMetadata"]["Description"] == "Test key"
|
||||||
|
|
||||||
|
def test_get_nonexistent_key(self, kms_client, auth_headers):
|
||||||
|
"""Test getting a key that doesn't exist."""
|
||||||
|
response = kms_client.get("/kms/keys/nonexistent", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_key(self, kms_client, auth_headers):
|
||||||
|
"""Test deleting a key."""
|
||||||
|
# Create a key
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
|
# Delete it
|
||||||
|
response = kms_client.delete("/kms/keys/test-key", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
get_response = kms_client.get("/kms/keys/test-key", headers=auth_headers)
|
||||||
|
assert get_response.status_code == 404
|
||||||
|
|
||||||
|
def test_enable_disable_key(self, kms_client, auth_headers):
|
||||||
|
"""Test enabling and disabling a key."""
|
||||||
|
# Create a key
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
|
# Disable
|
||||||
|
response = kms_client.post("/kms/keys/test-key/disable", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify disabled
|
||||||
|
get_response = kms_client.get("/kms/keys/test-key", headers=auth_headers)
|
||||||
|
assert get_response.get_json()["KeyMetadata"]["Enabled"] is False
|
||||||
|
|
||||||
|
# Enable
|
||||||
|
response = kms_client.post("/kms/keys/test-key/enable", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify enabled
|
||||||
|
get_response = kms_client.get("/kms/keys/test-key", headers=auth_headers)
|
||||||
|
assert get_response.get_json()["KeyMetadata"]["Enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestKMSEncryption:
|
||||||
|
"""Tests for KMS encryption operations."""
|
||||||
|
|
||||||
|
def test_encrypt_decrypt(self, kms_client, auth_headers):
|
||||||
|
"""Test encrypting and decrypting data."""
|
||||||
|
# Create a key
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
|
plaintext = b"Hello, World!"
|
||||||
|
plaintext_b64 = base64.b64encode(plaintext).decode()
|
||||||
|
|
||||||
|
# Encrypt
|
||||||
|
encrypt_response = kms_client.post(
|
||||||
|
"/kms/encrypt",
|
||||||
|
json={"KeyId": "test-key", "Plaintext": plaintext_b64},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert encrypt_response.status_code == 200
|
||||||
|
encrypt_data = encrypt_response.get_json()
|
||||||
|
|
||||||
|
assert "CiphertextBlob" in encrypt_data
|
||||||
|
assert encrypt_data["KeyId"] == "test-key"
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
decrypt_response = kms_client.post(
|
||||||
|
"/kms/decrypt",
|
||||||
|
json={"CiphertextBlob": encrypt_data["CiphertextBlob"]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decrypt_response.status_code == 200
|
||||||
|
decrypt_data = decrypt_response.get_json()
|
||||||
|
|
||||||
|
decrypted = base64.b64decode(decrypt_data["Plaintext"])
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
def test_encrypt_with_context(self, kms_client, auth_headers):
|
||||||
|
"""Test encryption with encryption context."""
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
|
plaintext = b"Contextualized data"
|
||||||
|
plaintext_b64 = base64.b64encode(plaintext).decode()
|
||||||
|
context = {"purpose": "testing", "bucket": "my-bucket"}
|
||||||
|
|
||||||
|
# Encrypt with context
|
||||||
|
encrypt_response = kms_client.post(
|
||||||
|
"/kms/encrypt",
|
||||||
|
json={
|
||||||
|
"KeyId": "test-key",
|
||||||
|
"Plaintext": plaintext_b64,
|
||||||
|
"EncryptionContext": context,
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert encrypt_response.status_code == 200
|
||||||
|
ciphertext = encrypt_response.get_json()["CiphertextBlob"]
|
||||||
|
|
||||||
|
# Decrypt with same context succeeds
|
||||||
|
decrypt_response = kms_client.post(
|
||||||
|
"/kms/decrypt",
|
||||||
|
json={
|
||||||
|
"CiphertextBlob": ciphertext,
|
||||||
|
"EncryptionContext": context,
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decrypt_response.status_code == 200
|
||||||
|
|
||||||
|
# Decrypt with wrong context fails
|
||||||
|
wrong_context_response = kms_client.post(
|
||||||
|
"/kms/decrypt",
|
||||||
|
json={
|
||||||
|
"CiphertextBlob": ciphertext,
|
||||||
|
"EncryptionContext": {"wrong": "context"},
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert wrong_context_response.status_code == 400
|
||||||
|
|
||||||
|
def test_encrypt_missing_key_id(self, kms_client, auth_headers):
|
||||||
|
"""Test encryption without KeyId."""
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/encrypt",
|
||||||
|
json={"Plaintext": base64.b64encode(b"data").decode()},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "KeyId is required" in response.get_json()["message"]
|
||||||
|
|
||||||
|
def test_encrypt_missing_plaintext(self, kms_client, auth_headers):
|
||||||
|
"""Test encryption without Plaintext."""
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/encrypt",
|
||||||
|
json={"KeyId": "test-key"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Plaintext is required" in response.get_json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestKMSDataKey:
|
||||||
|
"""Tests for KMS data key generation."""
|
||||||
|
|
||||||
|
def test_generate_data_key(self, kms_client, auth_headers):
|
||||||
|
"""Test generating a data key."""
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/generate-data-key",
|
||||||
|
json={"KeyId": "test-key"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert "Plaintext" in data
|
||||||
|
assert "CiphertextBlob" in data
|
||||||
|
assert data["KeyId"] == "test-key"
|
||||||
|
|
||||||
|
# Verify plaintext key is 256 bits (32 bytes)
|
||||||
|
plaintext_key = base64.b64decode(data["Plaintext"])
|
||||||
|
assert len(plaintext_key) == 32
|
||||||
|
|
||||||
|
def test_generate_data_key_aes_128(self, kms_client, auth_headers):
|
||||||
|
"""Test generating an AES-128 data key."""
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/generate-data-key",
|
||||||
|
json={"KeyId": "test-key", "KeySpec": "AES_128"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
# Verify plaintext key is 128 bits (16 bytes)
|
||||||
|
plaintext_key = base64.b64decode(data["Plaintext"])
|
||||||
|
assert len(plaintext_key) == 16
|
||||||
|
|
||||||
|
def test_generate_data_key_without_plaintext(self, kms_client, auth_headers):
|
||||||
|
"""Test generating a data key without plaintext."""
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/generate-data-key-without-plaintext",
|
||||||
|
json={"KeyId": "test-key"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert "CiphertextBlob" in data
|
||||||
|
assert "Plaintext" not in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestKMSReEncrypt:
|
||||||
|
"""Tests for KMS re-encryption."""
|
||||||
|
|
||||||
|
def test_re_encrypt(self, kms_client, auth_headers):
|
||||||
|
"""Test re-encrypting data with a different key."""
|
||||||
|
# Create two keys
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "key-1"}, headers=auth_headers)
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "key-2"}, headers=auth_headers)
|
||||||
|
|
||||||
|
# Encrypt with key-1
|
||||||
|
plaintext = b"Data to re-encrypt"
|
||||||
|
encrypt_response = kms_client.post(
|
||||||
|
"/kms/encrypt",
|
||||||
|
json={
|
||||||
|
"KeyId": "key-1",
|
||||||
|
"Plaintext": base64.b64encode(plaintext).decode(),
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
ciphertext = encrypt_response.get_json()["CiphertextBlob"]
|
||||||
|
|
||||||
|
# Re-encrypt with key-2
|
||||||
|
re_encrypt_response = kms_client.post(
|
||||||
|
"/kms/re-encrypt",
|
||||||
|
json={
|
||||||
|
"CiphertextBlob": ciphertext,
|
||||||
|
"DestinationKeyId": "key-2",
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert re_encrypt_response.status_code == 200
|
||||||
|
data = re_encrypt_response.get_json()
|
||||||
|
|
||||||
|
assert data["SourceKeyId"] == "key-1"
|
||||||
|
assert data["KeyId"] == "key-2"
|
||||||
|
|
||||||
|
# Verify new ciphertext can be decrypted
|
||||||
|
decrypt_response = kms_client.post(
|
||||||
|
"/kms/decrypt",
|
||||||
|
json={"CiphertextBlob": data["CiphertextBlob"]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
decrypted = base64.b64decode(decrypt_response.get_json()["Plaintext"])
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
|
||||||
|
class TestKMSRandom:
|
||||||
|
"""Tests for random number generation."""
|
||||||
|
|
||||||
|
def test_generate_random(self, kms_client, auth_headers):
|
||||||
|
"""Test generating random bytes."""
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/generate-random",
|
||||||
|
json={"NumberOfBytes": 64},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
random_bytes = base64.b64decode(data["Plaintext"])
|
||||||
|
assert len(random_bytes) == 64
|
||||||
|
|
||||||
|
def test_generate_random_default_size(self, kms_client, auth_headers):
|
||||||
|
"""Test generating random bytes with default size."""
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/generate-random",
|
||||||
|
json={},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
random_bytes = base64.b64decode(data["Plaintext"])
|
||||||
|
assert len(random_bytes) == 32 # Default is 32 bytes
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientSideEncryption:
|
||||||
|
"""Tests for client-side encryption helpers."""
|
||||||
|
|
||||||
|
def test_generate_client_key(self, kms_client, auth_headers):
|
||||||
|
"""Test generating a client encryption key."""
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/client/generate-key",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert "key" in data
|
||||||
|
assert data["algorithm"] == "AES-256-GCM"
|
||||||
|
|
||||||
|
key = base64.b64decode(data["key"])
|
||||||
|
assert len(key) == 32
|
||||||
|
|
||||||
|
def test_client_encrypt_decrypt(self, kms_client, auth_headers):
|
||||||
|
"""Test client-side encryption and decryption."""
|
||||||
|
# Generate a key
|
||||||
|
key_response = kms_client.post("/kms/client/generate-key", headers=auth_headers)
|
||||||
|
key = key_response.get_json()["key"]
|
||||||
|
|
||||||
|
# Encrypt
|
||||||
|
plaintext = b"Client-side encrypted data"
|
||||||
|
encrypt_response = kms_client.post(
|
||||||
|
"/kms/client/encrypt",
|
||||||
|
json={
|
||||||
|
"Plaintext": base64.b64encode(plaintext).decode(),
|
||||||
|
"Key": key,
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert encrypt_response.status_code == 200
|
||||||
|
encrypted = encrypt_response.get_json()
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
decrypt_response = kms_client.post(
|
||||||
|
"/kms/client/decrypt",
|
||||||
|
json={
|
||||||
|
"Ciphertext": encrypted["ciphertext"],
|
||||||
|
"Nonce": encrypted["nonce"],
|
||||||
|
"Key": key,
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decrypt_response.status_code == 200
|
||||||
|
decrypted = base64.b64decode(decrypt_response.get_json()["Plaintext"])
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncryptionMaterials:
|
||||||
|
"""Tests for S3 encryption materials endpoint."""
|
||||||
|
|
||||||
|
def test_get_encryption_materials(self, kms_client, auth_headers):
|
||||||
|
"""Test getting encryption materials for client-side S3 encryption."""
|
||||||
|
# Create a key
|
||||||
|
kms_client.post("/kms/keys", json={"KeyId": "s3-key"}, headers=auth_headers)
|
||||||
|
|
||||||
|
response = kms_client.post(
|
||||||
|
"/kms/materials/s3-key",
|
||||||
|
json={},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
|
||||||
|
assert "PlaintextKey" in data
|
||||||
|
assert "EncryptedKey" in data
|
||||||
|
assert data["KeyId"] == "s3-key"
|
||||||
|
assert data["Algorithm"] == "AES-256-GCM"
|
||||||
|
|
||||||
|
# Verify key is 256 bits
|
||||||
|
key = base64.b64decode(data["PlaintextKey"])
|
||||||
|
assert len(key) == 32
|
||||||
|
|
||||||
|
|
||||||
|
class TestKMSAuthentication:
|
||||||
|
"""Tests for KMS authentication requirements."""
|
||||||
|
|
||||||
|
def test_unauthenticated_request_fails(self, kms_client):
|
||||||
|
"""Test that unauthenticated requests are rejected."""
|
||||||
|
response = kms_client.get("/kms/keys")
|
||||||
|
|
||||||
|
# Should fail with 403 (no credentials)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_invalid_credentials_fail(self, kms_client):
|
||||||
|
"""Test that invalid credentials are rejected."""
|
||||||
|
response = kms_client.get(
|
||||||
|
"/kms/keys",
|
||||||
|
headers={
|
||||||
|
"X-Access-Key": "wrong-key",
|
||||||
|
"X-Secret-Key": "wrong-secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
@@ -116,7 +116,12 @@ def test_path_traversal_in_key(client, signer):
|
|||||||
|
|
||||||
def test_storage_path_traversal(app):
|
def test_storage_path_traversal(app):
|
||||||
storage = app.extensions["object_storage"]
|
storage = app.extensions["object_storage"]
|
||||||
from app.storage import StorageError
|
from app.storage import StorageError, ObjectStorage
|
||||||
|
from app.encrypted_storage import EncryptedObjectStorage
|
||||||
|
|
||||||
|
# Get the underlying ObjectStorage if wrapped
|
||||||
|
if isinstance(storage, EncryptedObjectStorage):
|
||||||
|
storage = storage.storage
|
||||||
|
|
||||||
with pytest.raises(StorageError, match="Object key contains parent directory references"):
|
with pytest.raises(StorageError, match="Object key contains parent directory references"):
|
||||||
storage._sanitize_object_key("folder/../file.txt")
|
storage._sanitize_object_key("folder/../file.txt")
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ def test_bucket_config_filename_allowed(tmp_path):
|
|||||||
storage.create_bucket("demo")
|
storage.create_bucket("demo")
|
||||||
storage.put_object("demo", ".bucket.json", io.BytesIO(b"{}"))
|
storage.put_object("demo", ".bucket.json", io.BytesIO(b"{}"))
|
||||||
|
|
||||||
objects = storage.list_objects("demo")
|
objects = storage.list_objects_all("demo")
|
||||||
assert any(meta.key == ".bucket.json" for meta in objects)
|
assert any(meta.key == ".bucket.json" for meta in objects)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def test_bulk_delete_json_route(tmp_path: Path):
|
|||||||
assert set(payload["deleted"]) == {"first.txt", "missing.txt"}
|
assert set(payload["deleted"]) == {"first.txt", "missing.txt"}
|
||||||
assert payload["errors"] == []
|
assert payload["errors"] == []
|
||||||
|
|
||||||
listing = storage.list_objects("demo")
|
listing = storage.list_objects_all("demo")
|
||||||
assert {meta.key for meta in listing} == {"second.txt"}
|
assert {meta.key for meta in listing} == {"second.txt"}
|
||||||
|
|
||||||
|
|
||||||
@@ -92,5 +92,5 @@ def test_bulk_delete_validation(tmp_path: Path):
|
|||||||
assert limit_response.status_code == 400
|
assert limit_response.status_code == 400
|
||||||
assert limit_response.get_json()["status"] == "error"
|
assert limit_response.get_json()["status"] == "error"
|
||||||
|
|
||||||
still_there = storage.list_objects("demo")
|
still_there = storage.list_objects_all("demo")
|
||||||
assert {meta.key for meta in still_there} == {"keep.txt"}
|
assert {meta.key for meta in still_there} == {"keep.txt"}
|
||||||
|
|||||||
268
tests/test_ui_encryption.py
Normal file
268
tests/test_ui_encryption.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""Tests for UI-based encryption configuration."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def get_csrf_token(response):
|
||||||
|
"""Extract CSRF token from response HTML."""
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
import re
|
||||||
|
match = re.search(r'name="csrf_token"\s+value="([^"]+)"', html)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_encryption_app(tmp_path: Path, *, kms_enabled: bool = True):
|
||||||
|
"""Create an app with encryption enabled."""
|
||||||
|
storage_root = tmp_path / "data"
|
||||||
|
iam_config = tmp_path / "iam.json"
|
||||||
|
bucket_policies = tmp_path / "bucket_policies.json"
|
||||||
|
iam_payload = {
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"access_key": "test",
|
||||||
|
"secret_key": "secret",
|
||||||
|
"display_name": "Test User",
|
||||||
|
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy"]}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"access_key": "readonly",
|
||||||
|
"secret_key": "secret",
|
||||||
|
"display_name": "Read Only User",
|
||||||
|
"policies": [{"bucket": "*", "actions": ["list", "read"]}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
iam_config.write_text(json.dumps(iam_payload))
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"TESTING": True,
|
||||||
|
"STORAGE_ROOT": storage_root,
|
||||||
|
"IAM_CONFIG": iam_config,
|
||||||
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
|
"API_BASE_URL": "http://testserver",
|
||||||
|
"SECRET_KEY": "testing",
|
||||||
|
"ENCRYPTION_ENABLED": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if kms_enabled:
|
||||||
|
config["KMS_ENABLED"] = True
|
||||||
|
config["KMS_KEYS_PATH"] = str(tmp_path / "kms_keys.json")
|
||||||
|
config["ENCRYPTION_MASTER_KEY_PATH"] = str(tmp_path / "master.key")
|
||||||
|
|
||||||
|
app = create_app(config)
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class TestUIBucketEncryption:
|
||||||
|
"""Test bucket encryption configuration via UI."""
|
||||||
|
|
||||||
|
def test_bucket_detail_shows_encryption_card(self, tmp_path):
|
||||||
|
"""Encryption card should be visible on bucket detail page."""
|
||||||
|
app = _make_encryption_app(tmp_path)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
# Login first
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "Default Encryption" in html
|
||||||
|
assert "Encryption Algorithm" in html or "Default encryption disabled" in html
|
||||||
|
|
||||||
|
def test_enable_aes256_encryption(self, tmp_path):
|
||||||
|
"""Should be able to enable AES-256 encryption."""
|
||||||
|
app = _make_encryption_app(tmp_path)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
# Login
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
# Get CSRF token
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
|
# Enable AES-256 encryption
|
||||||
|
response = client.post(
|
||||||
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
# Should see success message or enabled state
|
||||||
|
assert "AES-256" in html or "encryption enabled" in html.lower()
|
||||||
|
|
||||||
|
def test_enable_kms_encryption(self, tmp_path):
|
||||||
|
"""Should be able to enable KMS encryption."""
|
||||||
|
app = _make_encryption_app(tmp_path, kms_enabled=True)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
# Create a KMS key first
|
||||||
|
with app.app_context():
|
||||||
|
kms = app.extensions.get("kms")
|
||||||
|
if kms:
|
||||||
|
key = kms.create_key("test-key")
|
||||||
|
key_id = key.key_id
|
||||||
|
else:
|
||||||
|
pytest.skip("KMS not available")
|
||||||
|
|
||||||
|
# Login
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
# Get CSRF token
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
|
# Enable KMS encryption
|
||||||
|
response = client.post(
|
||||||
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "aws:kms",
|
||||||
|
"kms_key_id": key_id,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "KMS" in html or "encryption enabled" in html.lower()
|
||||||
|
|
||||||
|
def test_disable_encryption(self, tmp_path):
|
||||||
|
"""Should be able to disable encryption."""
|
||||||
|
app = _make_encryption_app(tmp_path)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
# Login
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
# First enable encryption
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now disable it
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
"action": "disable",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "disabled" in html.lower() or "Default encryption disabled" in html
|
||||||
|
|
||||||
|
def test_invalid_algorithm_rejected(self, tmp_path):
|
||||||
|
"""Invalid encryption algorithm should be rejected."""
|
||||||
|
app = _make_encryption_app(tmp_path)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
# Login
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "INVALID",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "Invalid" in html or "danger" in html
|
||||||
|
|
||||||
|
def test_encryption_persists_in_config(self, tmp_path):
|
||||||
|
"""Encryption config should persist in bucket config."""
|
||||||
|
app = _make_encryption_app(tmp_path)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
# Login
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
# Enable encryption
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify it's stored
|
||||||
|
with app.app_context():
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
config = storage.get_bucket_encryption("test-bucket")
|
||||||
|
|
||||||
|
assert "Rules" in config
|
||||||
|
assert len(config["Rules"]) == 1
|
||||||
|
assert config["Rules"][0]["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"] == "AES256"
|
||||||
|
|
||||||
|
|
||||||
|
class TestUIEncryptionWithoutPermission:
|
||||||
|
"""Test encryption UI when user lacks permissions."""
|
||||||
|
|
||||||
|
def test_readonly_user_cannot_change_encryption(self, tmp_path):
|
||||||
|
"""Read-only user should not be able to change encryption settings."""
|
||||||
|
app = _make_encryption_app(tmp_path)
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
# Login as readonly user
|
||||||
|
client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
# This should fail or be rejected
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf_token,
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should either redirect with error or show permission denied
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
# Should contain error about permission denied
|
||||||
|
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower()
|
||||||
183
tests/test_ui_pagination.py
Normal file
183
tests/test_ui_pagination.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""Tests for UI pagination of bucket objects."""
|
||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(tmp_path: Path):
|
||||||
|
"""Create an app for testing."""
|
||||||
|
storage_root = tmp_path / "data"
|
||||||
|
iam_config = tmp_path / "iam.json"
|
||||||
|
bucket_policies = tmp_path / "bucket_policies.json"
|
||||||
|
iam_payload = {
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"access_key": "test",
|
||||||
|
"secret_key": "secret",
|
||||||
|
"display_name": "Test User",
|
||||||
|
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy"]}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
iam_config.write_text(json.dumps(iam_payload))
|
||||||
|
|
||||||
|
flask_app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
|
"STORAGE_ROOT": storage_root,
|
||||||
|
"IAM_CONFIG": iam_config,
|
||||||
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return flask_app
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaginatedObjectListing:
|
||||||
|
"""Test paginated object listing API."""
|
||||||
|
|
||||||
|
def test_objects_api_returns_paginated_results(self, tmp_path):
|
||||||
|
"""Objects API should return paginated results."""
|
||||||
|
app = _make_app(tmp_path)
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
|
||||||
|
# Create 10 test objects
|
||||||
|
for i in range(10):
|
||||||
|
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
# Login first
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
# Request first page of 3 objects
|
||||||
|
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
data = resp.get_json()
|
||||||
|
assert len(data["objects"]) == 3
|
||||||
|
assert data["is_truncated"] is True
|
||||||
|
assert data["next_continuation_token"] is not None
|
||||||
|
assert data["total_count"] == 10
|
||||||
|
|
||||||
|
def test_objects_api_pagination_continuation(self, tmp_path):
|
||||||
|
"""Objects API should support continuation tokens."""
|
||||||
|
app = _make_app(tmp_path)
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
|
||||||
|
# Create 5 test objects
|
||||||
|
for i in range(5):
|
||||||
|
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
# Get first page
|
||||||
|
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=2")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
|
||||||
|
first_page_keys = [obj["key"] for obj in data["objects"]]
|
||||||
|
assert len(first_page_keys) == 2
|
||||||
|
assert data["is_truncated"] is True
|
||||||
|
|
||||||
|
# Get second page
|
||||||
|
token = data["next_continuation_token"]
|
||||||
|
resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
|
||||||
|
second_page_keys = [obj["key"] for obj in data["objects"]]
|
||||||
|
assert len(second_page_keys) == 2
|
||||||
|
|
||||||
|
# No overlap between pages
|
||||||
|
assert set(first_page_keys).isdisjoint(set(second_page_keys))
|
||||||
|
|
||||||
|
def test_objects_api_prefix_filter(self, tmp_path):
|
||||||
|
"""Objects API should support prefix filtering."""
|
||||||
|
app = _make_app(tmp_path)
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
|
||||||
|
# Create objects with different prefixes
|
||||||
|
storage.put_object("test-bucket", "logs/access.log", BytesIO(b"log"))
|
||||||
|
storage.put_object("test-bucket", "logs/error.log", BytesIO(b"log"))
|
||||||
|
storage.put_object("test-bucket", "data/file.txt", BytesIO(b"data"))
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
# Filter by prefix
|
||||||
|
resp = client.get("/ui/buckets/test-bucket/objects?prefix=logs/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
|
||||||
|
keys = [obj["key"] for obj in data["objects"]]
|
||||||
|
assert all(k.startswith("logs/") for k in keys)
|
||||||
|
assert len(keys) == 2
|
||||||
|
|
||||||
|
def test_objects_api_requires_authentication(self, tmp_path):
|
||||||
|
"""Objects API should require login."""
|
||||||
|
app = _make_app(tmp_path)
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
# Don't login
|
||||||
|
resp = client.get("/ui/buckets/test-bucket/objects")
|
||||||
|
# Should redirect to login
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert "/ui/login" in resp.headers.get("Location", "")
|
||||||
|
|
||||||
|
def test_objects_api_returns_object_metadata(self, tmp_path):
|
||||||
|
"""Objects API should return complete object metadata."""
|
||||||
|
app = _make_app(tmp_path)
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
storage.put_object("test-bucket", "test.txt", BytesIO(b"test content"))
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
resp = client.get("/ui/buckets/test-bucket/objects")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
|
||||||
|
assert len(data["objects"]) == 1
|
||||||
|
obj = data["objects"][0]
|
||||||
|
|
||||||
|
# Check all expected fields
|
||||||
|
assert obj["key"] == "test.txt"
|
||||||
|
assert obj["size"] == 12 # len("test content")
|
||||||
|
assert "last_modified" in obj
|
||||||
|
assert "last_modified_display" in obj
|
||||||
|
assert "etag" in obj
|
||||||
|
assert "preview_url" in obj
|
||||||
|
assert "download_url" in obj
|
||||||
|
assert "delete_endpoint" in obj
|
||||||
|
|
||||||
|
def test_bucket_detail_page_loads_without_objects(self, tmp_path):
|
||||||
|
"""Bucket detail page should load even with many objects."""
|
||||||
|
app = _make_app(tmp_path)
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
|
||||||
|
# Create many objects
|
||||||
|
for i in range(100):
|
||||||
|
storage.put_object("test-bucket", f"file{i:03d}.txt", BytesIO(b"x"))
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
# The page should load quickly (objects loaded via JS)
|
||||||
|
resp = client.get("/ui/buckets/test-bucket")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
html = resp.data.decode("utf-8")
|
||||||
|
# Should have the JavaScript loading infrastructure
|
||||||
|
assert "loadObjects" in html or "objectsApiUrl" in html
|
||||||
@@ -70,8 +70,12 @@ def test_ui_bucket_policy_enforcement_toggle(tmp_path: Path, enforce: bool):
|
|||||||
assert b"Access denied by bucket policy" in response.data
|
assert b"Access denied by bucket policy" in response.data
|
||||||
else:
|
else:
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert b"vid.mp4" in response.data
|
|
||||||
assert b"Access denied by bucket policy" not in response.data
|
assert b"Access denied by bucket policy" not in response.data
|
||||||
|
# Objects are now loaded via async API - check the objects endpoint
|
||||||
|
objects_response = client.get("/ui/buckets/testbucket/objects")
|
||||||
|
assert objects_response.status_code == 200
|
||||||
|
data = objects_response.get_json()
|
||||||
|
assert any(obj["key"] == "vid.mp4" for obj in data["objects"])
|
||||||
|
|
||||||
|
|
||||||
def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
||||||
@@ -109,5 +113,9 @@ def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
|||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert b"vid.mp4" in response.data
|
|
||||||
assert b"Access denied by bucket policy" not in response.data
|
assert b"Access denied by bucket policy" not in response.data
|
||||||
|
# Objects are now loaded via async API - check the objects endpoint
|
||||||
|
objects_response = client.get("/ui/buckets/testbucket/objects")
|
||||||
|
assert objects_response.status_code == 200
|
||||||
|
data = objects_response.get_json()
|
||||||
|
assert any(obj["key"] == "vid.mp4" for obj in data["objects"])
|
||||||
|
|||||||
Reference in New Issue
Block a user