diff --git a/app/bucket_policies.py b/app/bucket_policies.py index ce2b894..73a28b7 100644 --- a/app/bucket_policies.py +++ b/app/bucket_policies.py @@ -11,17 +11,51 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence RESOURCE_PREFIX = "arn:aws:s3:::" ACTION_ALIASES = { - "s3:getobject": "read", - "s3:getobjectversion": "read", + # List actions "s3:listbucket": "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: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:deleteobjectversion": "delete", "s3:deletebucket": "delete", + "s3:deleteobjecttagging": "delete", + # Share actions (ACL) "s3:putobjectacl": "share", + "s3:putbucketacl": "share", + "s3:getbucketacl": "share", + # Policy actions "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", } diff --git a/app/iam.py b/app/iam.py index 1fe8d28..d8c3c8d 100644 --- a/app/iam.py +++ b/app/iam.py @@ -15,7 +15,7 @@ class IamError(RuntimeError): """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:list_users", "iam:create_user", @@ -26,22 +26,59 @@ IAM_ACTIONS = { ALLOWED_ACTIONS = (S3_ACTIONS | IAM_ACTIONS) | {"iam:*"} ACTION_ALIASES = { + # List actions "list": "list", "s3:listbucket": "list", "s3:listallmybuckets": "list", + "s3:listbucketversions": "list", + "s3:listmultipartuploads": "list", + "s3:listparts": "list", + # Read actions "read": "read", "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 "write": "write", "s3:putobject": "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", "s3:deleteobject": "delete", + "s3:deleteobjectversion": "delete", "s3:deletebucket": "delete", + "s3:deleteobjecttagging": "delete", + # Share actions (ACL) "share": "share", "s3:putobjectacl": "share", + "s3:putbucketacl": "share", + "s3:getbucketacl": "share", + # Policy actions "policy": "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:createuser": "iam:create_user", "iam:deleteuser": "iam:delete_user", diff --git a/app/ui.py b/app/ui.py index 3fe71c0..e68c60e 100644 --- a/app/ui.py +++ b/app/ui.py @@ -185,6 +185,7 @@ def inject_nav_state() -> dict[str, Any]: return { "principal": principal, "can_manage_iam": can_manage, + "can_view_metrics": can_manage, # Only admins can view metrics "csrf_token": generate_csrf, } @@ -336,9 +337,28 @@ def bucket_detail(bucket_name: str): except IamError: 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_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( "bucket_detail.html", @@ -349,6 +369,8 @@ def bucket_detail(bucket_name: str): bucket_policy=bucket_policy, can_edit_policy=can_edit_policy, can_manage_versioning=can_manage_versioning, + can_manage_replication=can_manage_replication, + is_replication_admin=is_replication_admin, default_policy=default_policy, versioning_enabled=versioning_enabled, replication_rule=replication_rule, @@ -1171,17 +1193,52 @@ def delete_connection(connection_id: str): def update_bucket_replication(bucket_name: str): principal = _current_principal() try: - _authorize_ui(principal, bucket_name, "write") + _authorize_ui(principal, bucket_name, "replication") except IamError as exc: flash(str(exc), "danger") 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") 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) - flash("Replication disabled", "info") - else: + flash("Replication configuration removed", "info") + 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 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") else: 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")) @@ -1217,7 +1276,7 @@ def get_replication_status(bucket_name: str): """Async endpoint to fetch replication sync status without blocking page load.""" principal = _current_principal() try: - _authorize_ui(principal, bucket_name, "read") + _authorize_ui(principal, bucket_name, "replication") except IamError: return jsonify({"error": "Access denied"}), 403 @@ -1257,6 +1316,13 @@ def connections_dashboard(): def metrics_dashboard(): 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) memory = psutil.virtual_memory() diff --git a/docs.md b/docs.md index 016ffbb..906cd2e 100644 --- a/docs.md +++ b/docs.md @@ -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. +### 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 - **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. +### 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 - **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. - Click **Add Connection**. -3. **Enable Replication**: +3. **Enable Replication** (Admin): - Navigate to **Buckets** and select the source bucket. - Switch to the **Replication** tab. - Select the `Secondary Site` connection. - Enter the target bucket name (`backup-bucket`). - Click **Enable Replication**. + Once configured, users with `replication` permission on this bucket can pause/resume replication without needing access to connection details. + ### Verification 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 ``` +### 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) To set up two-way replication (Server A ↔ Server B): diff --git a/templates/base.html b/templates/base.html index fa12e58..67f71b5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -51,22 +51,18 @@ + {% if can_manage_iam %} {% endif %} + {% endif %} {% if principal %} + {% if can_manage_replication %} + {% endif %} @@ -658,6 +660,7 @@ + {% if can_manage_replication %}
@@ -671,8 +674,8 @@ Replication Configuration
- {% if replication_rule %} - + {% if replication_rule and replication_rule.enabled %} + + + {% elif replication_rule and not replication_rule.enabled %} + + + + +
Replication Target
+
+
+
+
+
+ + + + +
+
+
+ {% set target_conn = connections | selectattr("id", "equalto", replication_rule.target_connection_id) | first %} +
{{ target_conn.name if target_conn else 'Unknown Connection' }}
+
+ + + + + {{ replication_rule.target_bucket }} +
+
+
+ + + + + Paused + +
+
+
+
+ +
+ +
+ + + +
+ {% if is_replication_admin %} + + + {% endif %}
{% else %} - +
@@ -848,11 +937,16 @@
+ {% if is_replication_admin %}
Set Up Replication

Automatically copy new objects from this bucket to a bucket in another S3-compatible service.

+ {% else %} +
Replication Not Configured
+

An administrator needs to configure replication settings for this bucket before you can enable it.

+ {% endif %}
- {% if connections %} + {% if is_replication_admin and connections %}
@@ -904,7 +998,7 @@ Enable Replication
- {% else %} + {% elif is_replication_admin %}
+ {% endif %} diff --git a/templates/iam.html b/templates/iam.html index 51c2186..8cb1e11 100644 --- a/templates/iam.html +++ b/templates/iam.html @@ -509,7 +509,7 @@ full: [ { 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: [ @@ -543,7 +543,7 @@ full: [ { 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: [