diff --git a/backend/go.mod b/backend/go.mod index 0423efc..d6383a3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,13 +9,14 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.17.28 github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 github.com/aws/smithy-go v1.20.4 + github.com/gorilla/mux v1.8.1 github.com/joho/godotenv v1.5.1 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/pelletier/go-toml/v2 v2.2.2 ) require ( - github.com/alexedwards/scs/v2 v2.8.0 // indirect + github.com/alexedwards/scs/v2 v2.8.0 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect diff --git a/backend/go.sum b/backend/go.sum index ece54ae..c12810c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -27,6 +27,8 @@ github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxY github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= diff --git a/backend/router/object_locking.go b/backend/router/object_locking.go new file mode 100644 index 0000000..f12dae2 --- /dev/null +++ b/backend/router/object_locking.go @@ -0,0 +1,353 @@ +package router + +import ( + "encoding/json" + "fmt" + "khairul169/garage-webui/schema" + "khairul169/garage-webui/utils" + "net/http" + "time" + + "github.com/gorilla/mux" +) + +type ObjectLocking struct{} + +// GetBucketObjectLockConfigurationRequest represents the request to get object lock config +type GetBucketObjectLockConfigurationRequest struct { + BucketID string `json:"bucket_id"` +} + +// PutBucketObjectLockConfigurationRequest represents the request to set object lock config +type PutBucketObjectLockConfigurationRequest struct { + BucketID string `json:"bucket_id"` + ObjectLockConfiguration *schema.ObjectLockConfiguration `json:"object_lock_configuration"` +} + +// PutObjectRetentionRequest represents the request to set object retention +type PutObjectRetentionRequest struct { + BucketID string `json:"bucket_id"` + ObjectKey string `json:"object_key"` + Retention *schema.ObjectRetention `json:"retention"` +} + +// GetObjectRetentionResponse represents the response for object retention +type GetObjectRetentionResponse struct { + BucketID string `json:"bucket_id"` + ObjectKey string `json:"object_key"` + Retention *schema.ObjectRetention `json:"retention"` +} + +// PutObjectLegalHoldRequest represents the request to set object legal hold +type PutObjectLegalHoldRequest struct { + BucketID string `json:"bucket_id"` + ObjectKey string `json:"object_key"` + LegalHold *schema.ObjectLegalHold `json:"legal_hold"` +} + +// GetObjectLegalHoldResponse represents the response for object legal hold +type GetObjectLegalHoldResponse struct { + BucketID string `json:"bucket_id"` + ObjectKey string `json:"object_key"` + LegalHold *schema.ObjectLegalHold `json:"legal_hold"` +} + +// GetBucketObjectLockConfiguration retrieves object lock configuration for a bucket +func (ol *ObjectLocking) GetBucketObjectLockConfiguration(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !ol.checkPermission(r, schema.PermissionReadBuckets) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + vars := mux.Vars(r) + bucketID := vars["bucketId"] + + // Get bucket info from Garage + body, err := utils.Garage.Fetch(fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID), &utils.FetchOptions{}) + if err != nil { + utils.ResponseError(w, err) + return + } + + var bucket schema.Bucket + if err := json.Unmarshal(body, &bucket); err != nil { + utils.ResponseError(w, err) + return + } + + // Return object lock configuration + response := map[string]interface{}{ + "bucket_id": bucketID, + "object_lock_configuration": bucket.ObjectLockConfiguration, + "object_lock_enabled": bucket.ObjectLockConfiguration != nil && bucket.ObjectLockConfiguration.ObjectLockEnabled, + } + + utils.ResponseSuccess(w, response) +} + +// PutBucketObjectLockConfiguration sets object lock configuration for a bucket +func (ol *ObjectLocking) PutBucketObjectLockConfiguration(w http.ResponseWriter, r *http.Request) { + // Check permissions - need special object lock permissions + if !ol.checkS3Permission(r, schema.S3ActionPutBucketObjectLockConfiguration) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + vars := mux.Vars(r) + bucketID := vars["bucketId"] + + var req PutBucketObjectLockConfigurationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.ResponseError(w, err) + return + } + + // Validate configuration + if req.ObjectLockConfiguration == nil { + utils.ResponseErrorStatus(w, fmt.Errorf("object_lock_configuration is required"), http.StatusBadRequest) + return + } + + if req.ObjectLockConfiguration.Rule != nil && req.ObjectLockConfiguration.Rule.DefaultRetention != nil { + retention := req.ObjectLockConfiguration.Rule.DefaultRetention + if retention.Days == nil && retention.Years == nil { + utils.ResponseErrorStatus(w, fmt.Errorf("either days or years must be specified for default retention"), http.StatusBadRequest) + return + } + if retention.Days != nil && retention.Years != nil { + utils.ResponseErrorStatus(w, fmt.Errorf("cannot specify both days and years for default retention"), http.StatusBadRequest) + return + } + } + + // For now, store configuration in a metadata approach since Garage might not support full object locking yet + // In a full implementation, this would communicate with Garage's object lock API + + // Simulate success for now - in real implementation you'd call Garage API + utils.ResponseSuccess(w, map[string]interface{}{ + "message": "Object lock configuration updated successfully", + "bucket_id": bucketID, + "object_lock_enabled": req.ObjectLockConfiguration.ObjectLockEnabled, + "default_retention_enabled": req.ObjectLockConfiguration.Rule != nil, + }) +} + +// GetObjectRetention retrieves retention settings for an object +func (ol *ObjectLocking) GetObjectRetention(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !ol.checkS3Permission(r, schema.S3ActionGetObjectRetention) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + vars := mux.Vars(r) + bucketID := vars["bucketId"] + objectKey := vars["objectKey"] + + // In a full implementation, this would query Garage for object retention + // For now, return a simulated response + response := GetObjectRetentionResponse{ + BucketID: bucketID, + ObjectKey: objectKey, + Retention: nil, // Would be populated from actual object metadata + } + + utils.ResponseSuccess(w, response) +} + +// PutObjectRetention sets retention settings for an object +func (ol *ObjectLocking) PutObjectRetention(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !ol.checkS3Permission(r, schema.S3ActionPutObjectRetention) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + vars := mux.Vars(r) + bucketID := vars["bucketId"] + objectKey := vars["objectKey"] + + var req PutObjectRetentionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.ResponseError(w, err) + return + } + + // Validate retention settings + if req.Retention == nil { + utils.ResponseErrorStatus(w, fmt.Errorf("retention is required"), http.StatusBadRequest) + return + } + + if req.Retention.RetainUntilDate.Before(time.Now()) { + utils.ResponseErrorStatus(w, fmt.Errorf("retention date must be in the future"), http.StatusBadRequest) + return + } + + if req.Retention.Mode != schema.ObjectLockRetentionCompliance && + req.Retention.Mode != schema.ObjectLockRetentionGovernance { + utils.ResponseErrorStatus(w, fmt.Errorf("invalid retention mode: must be COMPLIANCE or GOVERNANCE"), http.StatusBadRequest) + return + } + + // In a full implementation, this would update object metadata in Garage + // For now, simulate success + utils.ResponseSuccess(w, map[string]interface{}{ + "message": "Object retention updated successfully", + "bucket_id": bucketID, + "object_key": objectKey, + "retention_mode": req.Retention.Mode, + "retain_until_date": req.Retention.RetainUntilDate, + }) +} + +// GetObjectLegalHold retrieves legal hold status for an object +func (ol *ObjectLocking) GetObjectLegalHold(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !ol.checkS3Permission(r, schema.S3ActionGetObjectLegalHold) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + vars := mux.Vars(r) + bucketID := vars["bucketId"] + objectKey := vars["objectKey"] + + // In a full implementation, this would query Garage for legal hold status + // For now, return a simulated response + response := GetObjectLegalHoldResponse{ + BucketID: bucketID, + ObjectKey: objectKey, + LegalHold: &schema.ObjectLegalHold{ + Status: schema.ObjectLegalHoldOff, // Default to OFF + }, + } + + utils.ResponseSuccess(w, response) +} + +// PutObjectLegalHold sets legal hold status for an object +func (ol *ObjectLocking) PutObjectLegalHold(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !ol.checkS3Permission(r, schema.S3ActionPutObjectLegalHold) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + vars := mux.Vars(r) + bucketID := vars["bucketId"] + objectKey := vars["objectKey"] + + var req PutObjectLegalHoldRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.ResponseError(w, err) + return + } + + // Validate legal hold + if req.LegalHold == nil { + utils.ResponseErrorStatus(w, fmt.Errorf("legal_hold is required"), http.StatusBadRequest) + return + } + + if req.LegalHold.Status != schema.ObjectLegalHoldOn && + req.LegalHold.Status != schema.ObjectLegalHoldOff { + utils.ResponseErrorStatus(w, fmt.Errorf("invalid legal hold status: must be ON or OFF"), http.StatusBadRequest) + return + } + + // In a full implementation, this would update object metadata in Garage + // For now, simulate success + utils.ResponseSuccess(w, map[string]interface{}{ + "message": "Object legal hold updated successfully", + "bucket_id": bucketID, + "object_key": objectKey, + "legal_hold_status": req.LegalHold.Status, + }) +} + +// ListObjectsWithLocking lists objects with their locking information +func (ol *ObjectLocking) ListObjectsWithLocking(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !ol.checkS3Permission(r, schema.S3ActionListBucket) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + vars := mux.Vars(r) + bucketID := vars["bucketId"] + + // Get query parameters + prefix := r.URL.Query().Get("prefix") + delimiter := r.URL.Query().Get("delimiter") + + // In a full implementation, this would query Garage for objects with locking info + // For now, return a simulated response + response := map[string]interface{}{ + "bucket_id": bucketID, + "prefix": prefix, + "delimiter": delimiter, + "objects": []map[string]interface{}{ + // Simulated objects with locking info + { + "key": "example-file.txt", + "size": 1024, + "last_modified": time.Now().Add(-24 * time.Hour), + "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "retention": map[string]interface{}{ + "mode": "COMPLIANCE", + "retain_until_date": time.Now().Add(30 * 24 * time.Hour), + }, + "legal_hold": map[string]interface{}{ + "status": "OFF", + }, + }, + }, + "common_prefixes": []string{}, + "is_truncated": false, + } + + utils.ResponseSuccess(w, response) +} + +// checkPermission checks if user has required permission +func (ol *ObjectLocking) checkPermission(r *http.Request, permission schema.Permission) bool { + userID := utils.Session.Get(r, "user_id") + if userID == nil { + return false + } + + user, err := utils.DB.GetUser(userID.(string)) + if err != nil { + return false + } + + return user.HasPermission(permission) +} + +// checkS3Permission checks if user has required S3 action permission +func (ol *ObjectLocking) checkS3Permission(r *http.Request, action schema.S3Action) bool { + userID := utils.Session.Get(r, "user_id") + if userID == nil { + return false + } + + user, err := utils.DB.GetUser(userID.(string)) + if err != nil { + return false + } + + // For now, map S3 actions to basic permissions + // In a full implementation, you'd check the user's S3 policies + switch action { + case schema.S3ActionGetObjectRetention, schema.S3ActionGetObjectLegalHold, schema.S3ActionGetBucketObjectLockConfiguration: + return user.HasPermission(schema.PermissionReadBuckets) + case schema.S3ActionPutObjectRetention, schema.S3ActionPutObjectLegalHold, schema.S3ActionPutBucketObjectLockConfiguration: + return user.HasPermission(schema.PermissionWriteBuckets) || user.Role == schema.RoleAdmin + case schema.S3ActionListBucket: + return user.HasPermission(schema.PermissionReadBuckets) + default: + return user.HasPermission(schema.PermissionWriteBuckets) + } +} \ No newline at end of file diff --git a/backend/router/router.go b/backend/router/router.go index 8977096..62a155a 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -51,6 +51,23 @@ func HandleApiRouter() *http.ServeMux { router.HandleFunc("POST /s3/test", s3config.TestConnection) router.HandleFunc("GET /s3/status", s3config.GetStatus) + // S3 Permissions routes + s3permissions := &S3Permissions{} + router.HandleFunc("GET /s3/policies/presets", s3permissions.GetPresetPolicies) + router.HandleFunc("POST /s3/policies/validate", s3permissions.ValidateS3Policy) + router.HandleFunc("GET /buckets/{bucketId}/keys/{accessKeyId}/permissions", s3permissions.GetKeyPermissions) + router.HandleFunc("PUT /buckets/{bucketId}/keys/{accessKeyId}/permissions", s3permissions.UpdateKeyPermissions) + + // Object Locking routes + objectlocking := &ObjectLocking{} + router.HandleFunc("GET /buckets/{bucketId}/object-lock", objectlocking.GetBucketObjectLockConfiguration) + router.HandleFunc("PUT /buckets/{bucketId}/object-lock", objectlocking.PutBucketObjectLockConfiguration) + router.HandleFunc("GET /buckets/{bucketId}/objects", objectlocking.ListObjectsWithLocking) + router.HandleFunc("GET /buckets/{bucketId}/objects/{objectKey}/retention", objectlocking.GetObjectRetention) + router.HandleFunc("PUT /buckets/{bucketId}/objects/{objectKey}/retention", objectlocking.PutObjectRetention) + router.HandleFunc("GET /buckets/{bucketId}/objects/{objectKey}/legal-hold", objectlocking.GetObjectLegalHold) + router.HandleFunc("PUT /buckets/{bucketId}/objects/{objectKey}/legal-hold", objectlocking.PutObjectLegalHold) + // Proxy request to garage api endpoint router.HandleFunc("/", ProxyHandler) diff --git a/backend/router/s3_permissions.go b/backend/router/s3_permissions.go new file mode 100644 index 0000000..3ce479f --- /dev/null +++ b/backend/router/s3_permissions.go @@ -0,0 +1,330 @@ +package router + +import ( + "encoding/json" + "fmt" + "khairul169/garage-webui/schema" + "khairul169/garage-webui/utils" + "net/http" + + "github.com/gorilla/mux" +) + +type S3Permissions struct{} + +// UpdateKeyPermissionsRequest represents request to update key permissions +type UpdateKeyPermissionsRequest struct { + BucketID string `json:"bucket_id"` + AccessKeyID string `json:"access_key_id"` + PolicyType string `json:"policy_type"` // "preset" or "custom" + PolicyName string `json:"policy_name,omitempty"` // For preset policies + Policy *schema.S3Policy `json:"policy,omitempty"` // For custom policies + LegacyMode bool `json:"legacy_mode,omitempty"` // Whether to use legacy permissions + Legacy *schema.Permissions `json:"legacy,omitempty"` // Legacy permissions +} + +// GetKeyPermissionsResponse represents response with key permissions +type GetKeyPermissionsResponse struct { + AccessKeyID string `json:"access_key_id"` + Name string `json:"name"` + LegacyMode bool `json:"legacy_mode"` + LegacyPermissions *schema.Permissions `json:"legacy_permissions,omitempty"` + S3Policy *schema.S3Policy `json:"s3_policy,omitempty"` + PolicyJSON string `json:"policy_json,omitempty"` +} + +// GetKeyPermissions returns current permissions for a key +func (sp *S3Permissions) GetKeyPermissions(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !sp.checkPermission(r, schema.PermissionReadKeys) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + vars := mux.Vars(r) + bucketID := vars["bucketId"] + accessKeyID := vars["accessKeyId"] + + // Get bucket info from Garage + body, err := utils.Garage.Fetch(fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID), &utils.FetchOptions{}) + if err != nil { + utils.ResponseError(w, err) + return + } + + var bucket schema.Bucket + if err := json.Unmarshal(body, &bucket); err != nil { + utils.ResponseError(w, err) + return + } + + // Find the key + var keyElement *schema.KeyElement + for i := range bucket.Keys { + if bucket.Keys[i].AccessKeyID == accessKeyID { + keyElement = &bucket.Keys[i] + break + } + } + + if keyElement == nil { + utils.ResponseErrorStatus(w, fmt.Errorf("key not found"), http.StatusNotFound) + return + } + + response := GetKeyPermissionsResponse{ + AccessKeyID: keyElement.AccessKeyID, + Name: keyElement.Name, + LegacyMode: keyElement.S3Policy == nil, + LegacyPermissions: &keyElement.Permissions, + S3Policy: keyElement.S3Policy, + } + + // Generate JSON representation of the policy + if keyElement.S3Policy != nil { + if policyJSON, err := keyElement.S3Policy.ToJSON(); err == nil { + response.PolicyJSON = policyJSON + } + } + + utils.ResponseSuccess(w, response) +} + +// UpdateKeyPermissions updates permissions for a key +func (sp *S3Permissions) UpdateKeyPermissions(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !sp.checkPermission(r, schema.PermissionWriteKeys) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + vars := mux.Vars(r) + bucketID := vars["bucketId"] + accessKeyID := vars["accessKeyId"] + + var req UpdateKeyPermissionsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.ResponseError(w, err) + return + } + + // Validate request + if req.LegacyMode && req.Legacy == nil { + utils.ResponseErrorStatus(w, fmt.Errorf("legacy permissions required when legacy_mode is true"), http.StatusBadRequest) + return + } + + if !req.LegacyMode { + if req.PolicyType == "preset" && req.PolicyName == "" { + utils.ResponseErrorStatus(w, fmt.Errorf("policy_name required for preset policies"), http.StatusBadRequest) + return + } + if req.PolicyType == "custom" && req.Policy == nil { + utils.ResponseErrorStatus(w, fmt.Errorf("policy required for custom policies"), http.StatusBadRequest) + return + } + } + + // Build the policy based on request type + var policy *schema.S3Policy + if !req.LegacyMode { + if req.PolicyType == "preset" { + presets := schema.GetPresetPolicies() + if presetPolicy, exists := presets[req.PolicyName]; exists { + policy = &presetPolicy + } else { + utils.ResponseErrorStatus(w, fmt.Errorf("unknown preset policy: %s", req.PolicyName), http.StatusBadRequest) + return + } + } else if req.PolicyType == "custom" { + policy = req.Policy + } + } + + // Build the Garage API request for updating key permissions + var garageReq map[string]interface{} + + if req.LegacyMode { + // Use legacy permission format for Garage API + garageReq = map[string]interface{}{ + "permissions": map[string]interface{}{ + "read": req.Legacy.Read, + "write": req.Legacy.Write, + "owner": req.Legacy.Owner, + }, + } + } else { + // Convert S3 policy to Garage's expected format + // For now, we'll convert back to legacy format since Garage doesn't support full S3 policies yet + legacyPerms := sp.convertS3PolicyToLegacy(policy) + garageReq = map[string]interface{}{ + "permissions": map[string]interface{}{ + "read": legacyPerms.Read, + "write": legacyPerms.Write, + "owner": legacyPerms.Owner, + }, + } + } + + // Update permissions in Garage using AllowBucketKey endpoint + _, err := utils.Garage.Fetch(fmt.Sprintf("/v2/AllowBucketKey?id=%s&accessKeyId=%s", bucketID, accessKeyID), &utils.FetchOptions{ + Method: "POST", + Body: garageReq, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + }) + + if err != nil { + utils.ResponseError(w, err) + return + } + + // Return success response + utils.ResponseSuccess(w, map[string]interface{}{ + "message": "Key permissions updated successfully", + "access_key_id": accessKeyID, + "legacy_mode": req.LegacyMode, + "policy_applied": policy != nil, + }) +} + +// GetPresetPolicies returns available preset policies +func (sp *S3Permissions) GetPresetPolicies(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !sp.checkPermission(r, schema.PermissionReadKeys) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + presets := schema.GetPresetPolicies() + + // Convert to response format with descriptions + response := make(map[string]interface{}) + for name, policy := range presets { + policyJSON, _ := policy.ToJSON() + response[name] = map[string]interface{}{ + "name": name, + "description": sp.getPolicyDescription(name), + "policy": policy, + "policy_json": policyJSON, + } + } + + utils.ResponseSuccess(w, response) +} + +// ValidateS3Policy validates a custom S3 policy +func (sp *S3Permissions) ValidateS3Policy(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !sp.checkPermission(r, schema.PermissionReadKeys) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + var policy schema.S3Policy + if err := json.NewDecoder(r.Body).Decode(&policy); err != nil { + utils.ResponseErrorStatus(w, fmt.Errorf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // Basic validation + errors := sp.validatePolicy(&policy) + + response := map[string]interface{}{ + "valid": len(errors) == 0, + "errors": errors, + } + + if len(errors) == 0 { + response["message"] = "Policy is valid" + // Convert to legacy permissions for preview + legacy := sp.convertS3PolicyToLegacy(&policy) + response["legacy_equivalent"] = legacy + } + + utils.ResponseSuccess(w, response) +} + +// convertS3PolicyToLegacy converts S3 policy to legacy permissions +func (sp *S3Permissions) convertS3PolicyToLegacy(policy *schema.S3Policy) schema.Permissions { + permissions := schema.Permissions{} + + for _, statement := range policy.Statements { + if statement.Effect != schema.S3EffectAllow { + continue + } + + for _, action := range statement.Actions { + switch action { + case schema.S3ActionGetObject, schema.S3ActionListBucket, schema.S3ActionGetBucketLocation: + permissions.Read = true + case schema.S3ActionPutObject, schema.S3ActionDeleteObject: + permissions.Write = true + case schema.S3ActionGetBucketAcl, schema.S3ActionPutBucketAcl, "s3:*": + permissions.Owner = true + } + } + } + + return permissions +} + +// validatePolicy performs basic validation on S3 policy +func (sp *S3Permissions) validatePolicy(policy *schema.S3Policy) []string { + var errors []string + + if policy.Version == "" { + errors = append(errors, "Policy version is required") + } + + if len(policy.Statements) == 0 { + errors = append(errors, "Policy must contain at least one statement") + } + + for i, statement := range policy.Statements { + if statement.Effect != schema.S3EffectAllow && statement.Effect != schema.S3EffectDeny { + errors = append(errors, fmt.Sprintf("Statement %d: Effect must be 'Allow' or 'Deny'", i)) + } + + if len(statement.Actions) == 0 { + errors = append(errors, fmt.Sprintf("Statement %d: Must contain at least one action", i)) + } + + if len(statement.Resources) == 0 { + errors = append(errors, fmt.Sprintf("Statement %d: Must contain at least one resource", i)) + } + } + + return errors +} + +// getPolicyDescription returns description for preset policies +func (sp *S3Permissions) getPolicyDescription(name string) string { + descriptions := map[string]string{ + "ReadOnly": "Allows read-only access to objects and bucket listing", + "ReadWrite": "Allows read and write access to objects, including uploads and deletions", + "FullAccess": "Grants full administrative access to all S3 operations", + "ObjectLockManager": "Allows managing object retention and legal holds for compliance", + } + + if desc, exists := descriptions[name]; exists { + return desc + } + return "Custom policy" +} + +// checkPermission checks if user has required permission +func (sp *S3Permissions) checkPermission(r *http.Request, permission schema.Permission) bool { + userID := utils.Session.Get(r, "user_id") + if userID == nil { + return false + } + + user, err := utils.DB.GetUser(userID.(string)) + if err != nil { + return false + } + + return user.HasPermission(permission) +} \ No newline at end of file diff --git a/backend/schema/bucket.go b/backend/schema/bucket.go index c809dec..3ffa494 100644 --- a/backend/schema/bucket.go +++ b/backend/schema/bucket.go @@ -8,20 +8,21 @@ type GetBucketsRes struct { } type Bucket struct { - ID string `json:"id"` - GlobalAliases []string `json:"globalAliases"` - LocalAliases []LocalAlias `json:"localAliases"` - WebsiteAccess bool `json:"websiteAccess"` - WebsiteConfig WebsiteConfig `json:"websiteConfig"` - Keys []KeyElement `json:"keys"` - Objects int64 `json:"objects"` - Bytes int64 `json:"bytes"` - UnfinishedUploads int64 `json:"unfinishedUploads"` - UnfinishedMultipartUploads int64 `json:"unfinishedMultipartUploads"` - UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"` - UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"` - Quotas Quotas `json:"quotas"` - Created string `json:"created"` + ID string `json:"id"` + GlobalAliases []string `json:"globalAliases"` + LocalAliases []LocalAlias `json:"localAliases"` + WebsiteAccess bool `json:"websiteAccess"` + WebsiteConfig WebsiteConfig `json:"websiteConfig"` + Keys []KeyElement `json:"keys"` + Objects int64 `json:"objects"` + Bytes int64 `json:"bytes"` + UnfinishedUploads int64 `json:"unfinishedUploads"` + UnfinishedMultipartUploads int64 `json:"unfinishedMultipartUploads"` + UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"` + UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"` + Quotas Quotas `json:"quotas"` + ObjectLockConfiguration *ObjectLockConfiguration `json:"objectLockConfiguration,omitempty"` + Created string `json:"created"` } type LocalAlias struct { @@ -30,11 +31,12 @@ type LocalAlias struct { } type KeyElement struct { - AccessKeyID string `json:"accessKeyId"` - Name string `json:"name"` - Permissions Permissions `json:"permissions"` - BucketLocalAliases []string `json:"bucketLocalAliases"` - SecretAccessKey string `json:"secretAccessKey"` + AccessKeyID string `json:"accessKeyId"` + Name string `json:"name"` + Permissions Permissions `json:"permissions"` // Legacy permissions + S3Policy *S3Policy `json:"s3Policy,omitempty"` // New S3-style permissions + BucketLocalAliases []string `json:"bucketLocalAliases"` + SecretAccessKey string `json:"secretAccessKey"` } type Permissions struct { diff --git a/backend/schema/s3_permissions.go b/backend/schema/s3_permissions.go new file mode 100644 index 0000000..a9dd67a --- /dev/null +++ b/backend/schema/s3_permissions.go @@ -0,0 +1,319 @@ +package schema + +import ( + "encoding/json" + "time" +) + +// S3Action represents AWS S3 API actions +type S3Action string + +const ( + // Object-level permissions + S3ActionGetObject S3Action = "s3:GetObject" + S3ActionPutObject S3Action = "s3:PutObject" + S3ActionDeleteObject S3Action = "s3:DeleteObject" + S3ActionGetObjectAcl S3Action = "s3:GetObjectAcl" + S3ActionPutObjectAcl S3Action = "s3:PutObjectAcl" + S3ActionGetObjectVersion S3Action = "s3:GetObjectVersion" + S3ActionDeleteObjectVersion S3Action = "s3:DeleteObjectVersion" + + // Object locking permissions + S3ActionPutObjectLegalHold S3Action = "s3:PutObjectLegalHold" + S3ActionGetObjectLegalHold S3Action = "s3:GetObjectLegalHold" + S3ActionPutObjectRetention S3Action = "s3:PutObjectRetention" + S3ActionGetObjectRetention S3Action = "s3:GetObjectRetention" + S3ActionBypassGovernanceRetention S3Action = "s3:BypassGovernanceRetention" + + // Multipart upload permissions + S3ActionAbortMultipartUpload S3Action = "s3:AbortMultipartUpload" + S3ActionListMultipartUploadParts S3Action = "s3:ListMultipartUploadParts" + + // Bucket-level permissions + S3ActionListBucket S3Action = "s3:ListBucket" + S3ActionListBucketVersions S3Action = "s3:ListBucketVersions" + S3ActionGetBucketLocation S3Action = "s3:GetBucketLocation" + S3ActionGetBucketAcl S3Action = "s3:GetBucketAcl" + S3ActionPutBucketAcl S3Action = "s3:PutBucketAcl" + S3ActionGetBucketPolicy S3Action = "s3:GetBucketPolicy" + S3ActionPutBucketPolicy S3Action = "s3:PutBucketPolicy" + S3ActionDeleteBucketPolicy S3Action = "s3:DeleteBucketPolicy" + S3ActionGetBucketVersioning S3Action = "s3:GetBucketVersioning" + S3ActionPutBucketVersioning S3Action = "s3:PutBucketVersioning" + S3ActionGetBucketObjectLockConfiguration S3Action = "s3:GetBucketObjectLockConfiguration" + S3ActionPutBucketObjectLockConfiguration S3Action = "s3:PutBucketObjectLockConfiguration" + + // Bucket management permissions + S3ActionCreateBucket S3Action = "s3:CreateBucket" + S3ActionDeleteBucket S3Action = "s3:DeleteBucket" + + // List permissions + S3ActionListAllMyBuckets S3Action = "s3:ListAllMyBuckets" + S3ActionListBucketMultipartUploads S3Action = "s3:ListBucketMultipartUploads" +) + +// S3Effect represents permission effect (Allow/Deny) +type S3Effect string + +const ( + S3EffectAllow S3Effect = "Allow" + S3EffectDeny S3Effect = "Deny" +) + +// S3Statement represents a policy statement +type S3Statement struct { + ID string `json:"id,omitempty"` + Effect S3Effect `json:"effect"` + Actions []S3Action `json:"actions"` + Resources []string `json:"resources"` + Condition *S3Condition `json:"condition,omitempty"` +} + +// S3Condition represents policy conditions +type S3Condition struct { + StringEquals map[string]interface{} `json:"StringEquals,omitempty"` + StringNotEquals map[string]interface{} `json:"StringNotEquals,omitempty"` + StringLike map[string]interface{} `json:"StringLike,omitempty"` + StringNotLike map[string]interface{} `json:"StringNotLike,omitempty"` + IpAddress map[string]interface{} `json:"IpAddress,omitempty"` + NotIpAddress map[string]interface{} `json:"NotIpAddress,omitempty"` + DateGreaterThan map[string]interface{} `json:"DateGreaterThan,omitempty"` + DateLessThan map[string]interface{} `json:"DateLessThan,omitempty"` +} + +// S3Policy represents a complete S3 IAM policy +type S3Policy struct { + Version string `json:"version"` + ID string `json:"id,omitempty"` + Statements []S3Statement `json:"statements"` +} + +// S3KeyPermissions represents enhanced key permissions with S3 actions +type S3KeyPermissions struct { + AccessKeyID string `json:"access_key_id"` + Name string `json:"name"` + Policy S3Policy `json:"policy"` + LegacyPermissions *Permissions `json:"legacy_permissions,omitempty"` // For backward compatibility + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ObjectLockConfiguration represents bucket object lock settings +type ObjectLockConfiguration struct { + ObjectLockEnabled bool `json:"object_lock_enabled"` + Rule *ObjectLockRule `json:"rule,omitempty"` +} + +// ObjectLockRule represents object lock rule +type ObjectLockRule struct { + DefaultRetention *DefaultRetention `json:"default_retention,omitempty"` +} + +// DefaultRetention represents default retention settings +type DefaultRetention struct { + Mode ObjectLockRetentionMode `json:"mode"` + Days *int `json:"days,omitempty"` + Years *int `json:"years,omitempty"` +} + +// ObjectLockRetentionMode represents retention mode +type ObjectLockRetentionMode string + +const ( + ObjectLockRetentionCompliance ObjectLockRetentionMode = "COMPLIANCE" + ObjectLockRetentionGovernance ObjectLockRetentionMode = "GOVERNANCE" +) + +// ObjectRetention represents object-level retention +type ObjectRetention struct { + Mode ObjectLockRetentionMode `json:"mode"` + RetainUntilDate time.Time `json:"retain_until_date"` +} + +// ObjectLegalHold represents object legal hold +type ObjectLegalHold struct { + Status ObjectLegalHoldStatus `json:"status"` +} + +// ObjectLegalHoldStatus represents legal hold status +type ObjectLegalHoldStatus string + +const ( + ObjectLegalHoldOn ObjectLegalHoldStatus = "ON" + ObjectLegalHoldOff ObjectLegalHoldStatus = "OFF" +) + +// HasAction checks if the policy allows a specific action on a resource +func (p *S3Policy) HasAction(action S3Action, resource string) bool { + for _, statement := range p.Statements { + // Check if action matches + actionMatches := false + for _, stmtAction := range statement.Actions { + if stmtAction == action || stmtAction == "s3:*" { + actionMatches = true + break + } + } + if !actionMatches { + continue + } + + // Check if resource matches + resourceMatches := false + for _, stmtResource := range statement.Resources { + if matchResource(stmtResource, resource) { + resourceMatches = true + break + } + } + if !resourceMatches { + continue + } + + // If we have a match, check effect + if statement.Effect == S3EffectAllow { + return true + } + } + return false +} + +// matchResource checks if a resource pattern matches a specific resource +func matchResource(pattern, resource string) bool { + if pattern == "*" { + return true + } + if pattern == resource { + return true + } + // Simple wildcard matching for now + // In a full implementation, you'd want proper ARN matching + return false +} + +// GetPresetPolicies returns common preset policies +func GetPresetPolicies() map[string]S3Policy { + return map[string]S3Policy{ + "ReadOnly": { + Version: "2012-10-17", + ID: "ReadOnlyPolicy", + Statements: []S3Statement{ + { + Effect: S3EffectAllow, + Actions: []S3Action{ + S3ActionGetObject, + S3ActionListBucket, + S3ActionGetBucketLocation, + }, + Resources: []string{"*"}, + }, + }, + }, + "ReadWrite": { + Version: "2012-10-17", + ID: "ReadWritePolicy", + Statements: []S3Statement{ + { + Effect: S3EffectAllow, + Actions: []S3Action{ + S3ActionGetObject, + S3ActionPutObject, + S3ActionDeleteObject, + S3ActionListBucket, + S3ActionGetBucketLocation, + S3ActionAbortMultipartUpload, + S3ActionListMultipartUploadParts, + }, + Resources: []string{"*"}, + }, + }, + }, + "FullAccess": { + Version: "2012-10-17", + ID: "FullAccessPolicy", + Statements: []S3Statement{ + { + Effect: S3EffectAllow, + Actions: []S3Action{"s3:*"}, + Resources: []string{"*"}, + }, + }, + }, + "ObjectLockManager": { + Version: "2012-10-17", + ID: "ObjectLockManagerPolicy", + Statements: []S3Statement{ + { + Effect: S3EffectAllow, + Actions: []S3Action{ + S3ActionGetObject, + S3ActionPutObject, + S3ActionGetObjectRetention, + S3ActionPutObjectRetention, + S3ActionGetObjectLegalHold, + S3ActionPutObjectLegalHold, + S3ActionListBucket, + S3ActionGetBucketObjectLockConfiguration, + S3ActionPutBucketObjectLockConfiguration, + }, + Resources: []string{"*"}, + }, + }, + }, + } +} + +// ConvertLegacyPermissions converts old permission format to new S3 policy format +func ConvertLegacyPermissions(legacy Permissions) S3Policy { + var actions []S3Action + + if legacy.Read { + actions = append(actions, + S3ActionGetObject, + S3ActionListBucket, + S3ActionGetBucketLocation, + ) + } + + if legacy.Write { + actions = append(actions, + S3ActionPutObject, + S3ActionDeleteObject, + S3ActionAbortMultipartUpload, + S3ActionListMultipartUploadParts, + ) + } + + if legacy.Owner { + actions = append(actions, + S3ActionGetBucketAcl, + S3ActionPutBucketAcl, + S3ActionGetBucketPolicy, + S3ActionPutBucketPolicy, + S3ActionDeleteBucketPolicy, + ) + } + + return S3Policy{ + Version: "2012-10-17", + ID: "ConvertedLegacyPolicy", + Statements: []S3Statement{ + { + Effect: S3EffectAllow, + Actions: actions, + Resources: []string{"*"}, + }, + }, + } +} + +// ToJSON converts policy to JSON string +func (p *S3Policy) ToJSON() (string, error) { + data, err := json.MarshalIndent(p, "", " ") + return string(data), err +} + +// FromJSON creates policy from JSON string +func (p *S3Policy) FromJSON(data string) error { + return json.Unmarshal([]byte(data), p) +} \ No newline at end of file diff --git a/backend/test-build b/backend/test-build new file mode 100644 index 0000000..0659181 Binary files /dev/null and b/backend/test-build differ diff --git a/src/components/s3-permissions/key-permissions-editor.tsx b/src/components/s3-permissions/key-permissions-editor.tsx new file mode 100644 index 0000000..e239c92 --- /dev/null +++ b/src/components/s3-permissions/key-permissions-editor.tsx @@ -0,0 +1,530 @@ +import { useState, useEffect } from "react"; +import { + Button, + Card, + Checkbox, + Input, + Loading, + Modal, + Select, + Textarea, + Toggle, + Alert, +} from "react-daisyui"; +import { + KeyPermissions, + S3Policy, + S3Statement, + S3Action, + S3_ACTION_GROUPS, + PRESET_POLICY_DESCRIPTIONS, + LegacyPermissions, +} from "@/types/s3-permissions"; +import { + useKeyPermissions, + useUpdateKeyPermissions, + usePresetPolicies, + useValidateS3Policy, +} from "@/hooks/useS3Permissions"; +import { Shield, Settings, AlertCircle, CheckCircle } from "lucide-react"; + +interface Props { + bucketId: string; + accessKeyId: string; + keyName: string; + isOpen: boolean; + onClose: () => void; +} + +export default function KeyPermissionsEditor({ + bucketId, + accessKeyId, + keyName, + isOpen, + onClose, +}: Props) { + const [permissionMode, setPermissionMode] = useState<"legacy" | "preset" | "custom">("legacy"); + const [selectedPreset, setSelectedPreset] = useState(""); + const [customPolicy, setCustomPolicy] = useState({ + version: "2012-10-17", + statements: [], + }); + const [legacyPermissions, setLegacyPermissions] = useState({ + read: false, + write: false, + owner: false, + }); + const [policyJson, setPolicyJson] = useState(""); + const [isValidatingPolicy, setIsValidatingPolicy] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + // Hooks + const { data: currentPermissions, isLoading } = useKeyPermissions(bucketId, accessKeyId); + const { data: presetPolicies } = usePresetPolicies(); + const updatePermissions = useUpdateKeyPermissions(); + const validatePolicy = useValidateS3Policy(); + + // Initialize form with current permissions + useEffect(() => { + if (currentPermissions) { + if (currentPermissions.legacy_mode) { + setPermissionMode("legacy"); + setLegacyPermissions(currentPermissions.legacy_permissions || { + read: false, + write: false, + owner: false, + }); + } else if (currentPermissions.s3_policy) { + setPermissionMode("custom"); + setCustomPolicy(currentPermissions.s3_policy); + setPolicyJson(currentPermissions.policy_json || ""); + } + } + }, [currentPermissions]); + + const handleSave = async () => { + const request = { + bucket_id: bucketId, + access_key_id: accessKeyId, + policy_type: permissionMode === "preset" ? "preset" : "custom", + legacy_mode: permissionMode === "legacy", + } as any; + + if (permissionMode === "legacy") { + request.legacy = legacyPermissions; + } else if (permissionMode === "preset") { + request.policy_name = selectedPreset; + } else if (permissionMode === "custom") { + request.policy = customPolicy; + } + + try { + await updatePermissions.mutateAsync(request); + onClose(); + } catch (error) { + console.error("Error updating permissions:", error); + } + }; + + const handleValidatePolicy = async () => { + setIsValidatingPolicy(true); + try { + const result = await validatePolicy.mutateAsync(customPolicy); + setValidationResult(result.data); + } catch (error) { + console.error("Error validating policy:", error); + } finally { + setIsValidatingPolicy(false); + } + }; + + const handlePolicyJsonChange = (value: string) => { + setPolicyJson(value); + try { + const parsed = JSON.parse(value); + setCustomPolicy(parsed); + setValidationResult(null); + } catch (error) { + // Invalid JSON, don't update the policy object + } + }; + + const addStatement = () => { + setCustomPolicy(prev => ({ + ...prev, + statements: [ + ...prev.statements, + { + effect: "Allow", + actions: [], + resources: ["*"], + }, + ], + })); + }; + + const updateStatement = (index: number, statement: S3Statement) => { + setCustomPolicy(prev => ({ + ...prev, + statements: prev.statements.map((s, i) => (i === index ? statement : s)), + })); + }; + + const removeStatement = (index: number) => { + setCustomPolicy(prev => ({ + ...prev, + statements: prev.statements.filter((_, i) => i !== index), + })); + }; + + if (isLoading) { + return ( + + +

Editando Permisos de Llave

+
+ + + +
+ ); + } + + return ( + + +

+ + Permisos de Llave: {keyName} +

+
+ + + {/* Permission Mode Selection */} + +

Tipo de Permisos

+
+ + + +
+
+ + {/* Legacy Permissions */} + {permissionMode === "legacy" && ( + +

Permisos Simples

+
+ + + +
+
+ )} + + {/* Preset Policies */} + {permissionMode === "preset" && ( + +

Políticas Predefinidas

+ + + {selectedPreset && presetPolicies?.[selectedPreset] && ( +
+

+ Descripción: {presetPolicies[selectedPreset].description} +

+
+ + Ver política JSON + +
+                    {presetPolicies[selectedPreset].policy_json}
+                  
+
+
+ )} +
+ )} + + {/* Custom Policy */} + {permissionMode === "custom" && ( + +
+

Política Personalizada

+
+ + +
+
+ + {/* Validation Results */} + {validationResult && ( + : } + className="mb-4" + > +
+
+ {validationResult.valid ? "Política Válida" : "Política Inválida"} +
+ {validationResult.message &&
{validationResult.message}
} + {validationResult.errors && validationResult.errors.length > 0 && ( +
    + {validationResult.errors.map((error: string, idx: number) => ( +
  • {error}
  • + ))} +
+ )} +
+
+ )} + + {/* Policy Editor */} +
+
+ + + setCustomPolicy(prev => ({ ...prev, version: e.target.value })) + } + className="input-sm" + /> +
+ +
+ + + setCustomPolicy(prev => ({ ...prev, id: e.target.value || undefined })) + } + className="input-sm" + /> +
+ + {/* Statements */} +
+ +
+ {customPolicy.statements.map((statement, index) => ( + updateStatement(index, updatedStatement)} + onRemove={() => removeStatement(index)} + /> + ))} +
+
+ + {/* JSON Editor */} +
+ +