Update IAM controlsd and ad new S3 actions

This commit is contained in:
2025-11-30 23:58:21 +08:00
parent 53326f4e41
commit 590a39ca80
7 changed files with 320 additions and 25 deletions

View File

@@ -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",
} }

View File

@@ -15,7 +15,7 @@ class IamError(RuntimeError):
"""Raised when authentication or authorization fails.""" """Raised when authentication or authorization fails."""
S3_ACTIONS = {"list", "read", "write", "delete", "share", "policy"} 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",

View File

@@ -185,6 +185,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, # Only admins can view metrics
"csrf_token": generate_csrf, "csrf_token": generate_csrf,
} }
@@ -336,9 +337,28 @@ def bucket_detail(bucket_name: str):
except IamError: except IamError:
can_manage_versioning = False can_manage_versioning = False
# Check replication permission
can_manage_replication = False
if principal:
try:
_iam().authorize(principal, bucket_name, "replication")
can_manage_replication = True
except IamError:
can_manage_replication = False
# Check if user is admin (can configure replication settings, not just toggle)
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 info - don't compute sync status here (it's slow), let JS fetch it async # Replication info - don't compute sync status here (it's slow), let JS fetch it async
replication_rule = _replication().get_rule(bucket_name) replication_rule = _replication().get_rule(bucket_name)
connections = _connections().list() # Load connections for admin, or for non-admin if there's an existing rule (to show target name)
connections = _connections().list() if (is_replication_admin or replication_rule) else []
return render_template( return render_template(
"bucket_detail.html", "bucket_detail.html",
@@ -349,6 +369,8 @@ def bucket_detail(bucket_name: str):
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,
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,
@@ -1171,17 +1193,52 @@ 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"))
# Check if user is admin (required for create/delete operations)
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":
# Admin only - remove configuration entirely
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")
else: elif action == "pause":
# Users can pause - just set enabled=False
rule = _replication().get_rule(bucket_name)
if rule:
rule.enabled = False
_replication().set_rule(rule)
flash("Replication paused", "info")
else:
flash("No replication configuration to pause", "warning")
elif action == "resume":
# Users can resume - just set enabled=True
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":
# Admin only - create new configuration
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
@@ -1208,6 +1265,8 @@ def update_bucket_replication(bucket_name: str):
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"))
@@ -1217,7 +1276,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
@@ -1257,6 +1316,13 @@ def connections_dashboard():
def metrics_dashboard(): def metrics_dashboard():
principal = _current_principal() principal = _current_principal()
# Metrics are restricted to admin users
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"))
cpu_percent = psutil.cpu_percent(interval=0.1) cpu_percent = psutil.cpu_percent(interval=0.1)
memory = psutil.virtual_memory() memory = psutil.virtual_memory()

69
docs.md
View File

@@ -102,6 +102,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": {...}}}`.
@@ -177,6 +217,19 @@ s3.complete_multipart_upload(
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 **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.
### 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
- **Source Instance**: The MyFSIO instance where you upload files. It runs the replication worker. - **Source Instance**: The MyFSIO instance where you upload files. It runs the replication worker.
@@ -253,13 +306,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 +325,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):

View File

@@ -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>

View File

@@ -59,11 +59,13 @@
Permissions Permissions
</button> </button>
</li> </li>
{% if can_manage_replication %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link {{ 'active' if active_tab == 'replication' else '' }}" id="replication-tab" data-bs-toggle="tab" data-bs-target="#replication-pane" type="button" role="tab" aria-controls="replication-pane" aria-selected="{{ 'true' if active_tab == 'replication' else 'false' }}"> <button class="nav-link {{ 'active' if active_tab == 'replication' else '' }}" id="replication-tab" data-bs-toggle="tab" data-bs-target="#replication-pane" type="button" role="tab" aria-controls="replication-pane" aria-selected="{{ 'true' if active_tab == 'replication' else 'false' }}">
Replication Replication
</button> </button>
</li> </li>
{% endif %}
</ul> </ul>
<!-- Tab Content --> <!-- Tab Content -->
@@ -658,6 +660,7 @@
</div> </div>
</div> </div>
{% if can_manage_replication %}
<!-- Replication Tab --> <!-- Replication Tab -->
<div class="tab-pane fade {{ 'show active' if active_tab == 'replication' else '' }}" id="replication-pane" role="tabpanel" aria-labelledby="replication-tab" tabindex="0"> <div class="tab-pane fade {{ 'show active' if active_tab == 'replication' else '' }}" id="replication-pane" role="tabpanel" aria-labelledby="replication-tab" tabindex="0">
<div class="row g-4"> <div class="row g-4">
@@ -671,8 +674,8 @@
<span class="fw-semibold">Replication Configuration</span> <span class="fw-semibold">Replication Configuration</span>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if replication_rule %} {% if replication_rule and replication_rule.enabled %}
<!-- Replication Enabled State --> <!-- Replication Active State -->
<div class="alert alert-success d-flex align-items-center mb-4" role="alert"> <div class="alert alert-success d-flex align-items-center mb-4" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-check-circle-fill flex-shrink-0 me-2" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-check-circle-fill flex-shrink-0 me-2" 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"/>
@@ -830,17 +833,103 @@
</svg> </svg>
Refresh Refresh
</a> </a>
<!-- Pause button - available to users with replication permission -->
<form method="POST" action="{{ url_for('ui.update_bucket_replication', bucket_name=bucket_name) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="action" value="pause">
<button type="submit" class="btn btn-outline-warning">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
</svg>
Pause Replication
</button>
</form>
{% if is_replication_admin %}
<!-- Delete button - admin only -->
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#disableReplicationModal"> <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#disableReplicationModal">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/> <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/> <path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg> </svg>
Disable Replication Remove Configuration
</button> </button>
{% endif %}
</div>
{% elif replication_rule and not replication_rule.enabled %}
<!-- Replication Paused State -->
<div class="alert alert-warning d-flex align-items-center mb-4" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="flex-shrink-0 me-2" viewBox="0 0 16 16">
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
</svg>
<div>
<strong>Replication Paused</strong>
Replication is configured but currently paused. New uploads will not be replicated until resumed.
</div>
</div>
<!-- Show target info even when paused -->
<h6 class="text-muted text-uppercase small mb-3">Replication Target</h6>
<div class="card border mb-4">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<div class="rounded-circle bg-warning bg-opacity-10 d-flex align-items-center justify-content-center" style="width: 48px; height: 48px;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
</svg>
</div>
</div>
<div class="col">
{% set target_conn = connections | selectattr("id", "equalto", replication_rule.target_connection_id) | first %}
<div class="fw-semibold">{{ target_conn.name if target_conn else 'Unknown Connection' }}</div>
<div class="text-muted small">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5 4a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1H5zm-.5 2.5A.5.5 0 0 1 5 6h6a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zM5 8a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1H5zm0 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1H5z"/>
<path d="M2 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2zm10-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"/>
</svg>
{{ replication_rule.target_bucket }}
</div>
</div>
<div class="col-auto">
<span class="badge bg-warning-subtle text-warning px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
</svg>
Paused
</span>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<!-- Resume button -->
<form method="POST" action="{{ url_for('ui.update_bucket_replication', bucket_name=bucket_name) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="action" value="resume">
<button type="submit" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/>
</svg>
Resume Replication
</button>
</form>
{% if is_replication_admin %}
<!-- Delete button - admin only -->
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#disableReplicationModal">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
Remove Configuration
</button>
{% endif %}
</div> </div>
{% else %} {% else %}
<!-- Replication Setup State --> <!-- Replication Not Configured State -->
<div class="text-center py-4"> <div class="text-center py-4">
<div class="rounded-circle bg-body-tertiary d-inline-flex align-items-center justify-content-center mb-3" style="width: 64px; height: 64px;"> <div class="rounded-circle bg-body-tertiary d-inline-flex align-items-center justify-content-center mb-3" style="width: 64px; height: 64px;">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="text-muted" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
@@ -848,11 +937,16 @@
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/> <path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg> </svg>
</div> </div>
{% if is_replication_admin %}
<h5 class="mb-2">Set Up Replication</h5> <h5 class="mb-2">Set Up Replication</h5>
<p class="text-muted mb-4">Automatically copy new objects from this bucket to a bucket in another S3-compatible service.</p> <p class="text-muted mb-4">Automatically copy new objects from this bucket to a bucket in another S3-compatible service.</p>
{% else %}
<h5 class="mb-2">Replication Not Configured</h5>
<p class="text-muted mb-4">An administrator needs to configure replication settings for this bucket before you can enable it.</p>
{% endif %}
</div> </div>
{% if connections %} {% if is_replication_admin and connections %}
<form method="POST" action="{{ url_for('ui.update_bucket_replication', bucket_name=bucket_name) }}"> <form method="POST" action="{{ url_for('ui.update_bucket_replication', bucket_name=bucket_name) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="action" value="create"> <input type="hidden" name="action" value="create">
@@ -904,7 +998,7 @@
Enable Replication Enable Replication
</button> </button>
</form> </form>
{% else %} {% elif is_replication_admin %}
<div class="alert alert-warning d-flex align-items-start" role="alert"> <div class="alert alert-warning d-flex align-items-start" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="flex-shrink-0 me-2 mt-1" 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"/> <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"/>
@@ -969,6 +1063,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -509,7 +509,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: [
@@ -543,7 +543,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: [