Trying to implement S3 policies and object locking

This commit is contained in:
Aluisco Ricardo 2025-09-25 18:07:53 -03:00
parent 15a350370c
commit a0fb18192b
13 changed files with 2631 additions and 22 deletions

View File

@ -9,13 +9,14 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.17.28 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/aws-sdk-go-v2/service/s3 v1.59.0
github.com/aws/smithy-go v1.20.4 github.com/aws/smithy-go v1.20.4
github.com/gorilla/mux v1.8.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/pelletier/go-toml/v2 v2.2.2 github.com/pelletier/go-toml/v2 v2.2.2
) )
require ( 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/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/configsources v1.3.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect

View File

@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=

View File

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

View File

@ -51,6 +51,23 @@ func HandleApiRouter() *http.ServeMux {
router.HandleFunc("POST /s3/test", s3config.TestConnection) router.HandleFunc("POST /s3/test", s3config.TestConnection)
router.HandleFunc("GET /s3/status", s3config.GetStatus) 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 // Proxy request to garage api endpoint
router.HandleFunc("/", ProxyHandler) router.HandleFunc("/", ProxyHandler)

View File

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

View File

@ -21,6 +21,7 @@ type Bucket struct {
UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"` UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"` UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
Quotas Quotas `json:"quotas"` Quotas Quotas `json:"quotas"`
ObjectLockConfiguration *ObjectLockConfiguration `json:"objectLockConfiguration,omitempty"`
Created string `json:"created"` Created string `json:"created"`
} }
@ -32,7 +33,8 @@ type LocalAlias struct {
type KeyElement struct { type KeyElement struct {
AccessKeyID string `json:"accessKeyId"` AccessKeyID string `json:"accessKeyId"`
Name string `json:"name"` Name string `json:"name"`
Permissions Permissions `json:"permissions"` Permissions Permissions `json:"permissions"` // Legacy permissions
S3Policy *S3Policy `json:"s3Policy,omitempty"` // New S3-style permissions
BucketLocalAliases []string `json:"bucketLocalAliases"` BucketLocalAliases []string `json:"bucketLocalAliases"`
SecretAccessKey string `json:"secretAccessKey"` SecretAccessKey string `json:"secretAccessKey"`
} }

View File

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

BIN
backend/test-build Normal file

Binary file not shown.

View File

@ -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<string>("");
const [customPolicy, setCustomPolicy] = useState<S3Policy>({
version: "2012-10-17",
statements: [],
});
const [legacyPermissions, setLegacyPermissions] = useState<LegacyPermissions>({
read: false,
write: false,
owner: false,
});
const [policyJson, setPolicyJson] = useState<string>("");
const [isValidatingPolicy, setIsValidatingPolicy] = useState(false);
const [validationResult, setValidationResult] = useState<any>(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 (
<Modal open={isOpen} onClickBackdrop={onClose}>
<Modal.Header>
<h3>Editando Permisos de Llave</h3>
</Modal.Header>
<Modal.Body className="text-center py-8">
<Loading />
</Modal.Body>
</Modal>
);
}
return (
<Modal open={isOpen} onClickBackdrop={onClose} className="w-11/12 max-w-4xl">
<Modal.Header>
<h3 className="flex items-center gap-2">
<Shield size={20} />
Permisos de Llave: {keyName}
</h3>
</Modal.Header>
<Modal.Body className="space-y-6">
{/* Permission Mode Selection */}
<Card className="bg-base-200 p-4">
<h4 className="font-semibold mb-3">Tipo de Permisos</h4>
<div className="space-y-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="permission-mode"
value="legacy"
checked={permissionMode === "legacy"}
onChange={(e) => setPermissionMode(e.target.value as any)}
className="radio radio-primary"
/>
<span>Permisos Simples (Lectura/Escritura/Propietario)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="permission-mode"
value="preset"
checked={permissionMode === "preset"}
onChange={(e) => setPermissionMode(e.target.value as any)}
className="radio radio-primary"
/>
<span>Políticas Predefinidas (AWS S3)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="permission-mode"
value="custom"
checked={permissionMode === "custom"}
onChange={(e) => setPermissionMode(e.target.value as any)}
className="radio radio-primary"
/>
<span>Política Personalizada (Avanzado)</span>
</label>
</div>
</Card>
{/* Legacy Permissions */}
{permissionMode === "legacy" && (
<Card className="bg-base-100 p-4">
<h4 className="font-semibold mb-3">Permisos Simples</h4>
<div className="space-y-3">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={legacyPermissions.read}
onChange={(e) =>
setLegacyPermissions(prev => ({ ...prev, read: e.target.checked }))
}
/>
<span>Lectura - Permite descargar objetos y listar el bucket</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={legacyPermissions.write}
onChange={(e) =>
setLegacyPermissions(prev => ({ ...prev, write: e.target.checked }))
}
/>
<span>Escritura - Permite subir y eliminar objetos</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={legacyPermissions.owner}
onChange={(e) =>
setLegacyPermissions(prev => ({ ...prev, owner: e.target.checked }))
}
/>
<span>Propietario - Permisos administrativos completos</span>
</label>
</div>
</Card>
)}
{/* Preset Policies */}
{permissionMode === "preset" && (
<Card className="bg-base-100 p-4">
<h4 className="font-semibold mb-3">Políticas Predefinidas</h4>
<Select
value={selectedPreset}
onChange={(e) => setSelectedPreset(e.target.value)}
className="w-full mb-3"
>
<option value="">Seleccionar política...</option>
{presetPolicies &&
Object.entries(presetPolicies).map(([name, policy]) => (
<option key={name} value={name}>
{name} - {policy.description}
</option>
))}
</Select>
{selectedPreset && presetPolicies?.[selectedPreset] && (
<div className="bg-base-200 p-3 rounded">
<p className="text-sm mb-2">
<strong>Descripción:</strong> {presetPolicies[selectedPreset].description}
</p>
<details>
<summary className="cursor-pointer text-sm font-medium">
Ver política JSON
</summary>
<pre className="text-xs mt-2 bg-base-300 p-2 rounded overflow-x-auto">
{presetPolicies[selectedPreset].policy_json}
</pre>
</details>
</div>
)}
</Card>
)}
{/* Custom Policy */}
{permissionMode === "custom" && (
<Card className="bg-base-100 p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold">Política Personalizada</h4>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleValidatePolicy}
disabled={isValidatingPolicy}
>
{isValidatingPolicy ? <Loading size="xs" /> : "Validar"}
</Button>
<Button size="sm" onClick={addStatement}>
Agregar Declaración
</Button>
</div>
</div>
{/* Validation Results */}
{validationResult && (
<Alert
status={validationResult.valid ? "success" : "error"}
icon={validationResult.valid ? <CheckCircle /> : <AlertCircle />}
className="mb-4"
>
<div>
<div className="font-bold">
{validationResult.valid ? "Política Válida" : "Política Inválida"}
</div>
{validationResult.message && <div className="text-sm">{validationResult.message}</div>}
{validationResult.errors && validationResult.errors.length > 0 && (
<ul className="text-sm mt-1 list-disc list-inside">
{validationResult.errors.map((error: string, idx: number) => (
<li key={idx}>{error}</li>
))}
</ul>
)}
</div>
</Alert>
)}
{/* Policy Editor */}
<div className="space-y-4">
<div>
<label className="label">
<span className="label-text">Versión</span>
</label>
<Input
value={customPolicy.version}
onChange={(e) =>
setCustomPolicy(prev => ({ ...prev, version: e.target.value }))
}
className="input-sm"
/>
</div>
<div>
<label className="label">
<span className="label-text">ID de Política (Opcional)</span>
</label>
<Input
value={customPolicy.id || ""}
onChange={(e) =>
setCustomPolicy(prev => ({ ...prev, id: e.target.value || undefined }))
}
className="input-sm"
/>
</div>
{/* Statements */}
<div>
<label className="label">
<span className="label-text">Declaraciones</span>
</label>
<div className="space-y-3">
{customPolicy.statements.map((statement, index) => (
<StatementEditor
key={index}
statement={statement}
onChange={(updatedStatement) => updateStatement(index, updatedStatement)}
onRemove={() => removeStatement(index)}
/>
))}
</div>
</div>
{/* JSON Editor */}
<div>
<label className="label">
<span className="label-text">Editor JSON (Avanzado)</span>
</label>
<Textarea
value={policyJson || JSON.stringify(customPolicy, null, 2)}
onChange={(e) => handlePolicyJsonChange(e.target.value)}
className="font-mono text-sm"
rows={10}
/>
</div>
</div>
</Card>
)}
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose} variant="outline">
Cancelar
</Button>
<Button
onClick={handleSave}
loading={updatePermissions.isPending}
disabled={
permissionMode === "preset" && !selectedPreset ||
updatePermissions.isPending
}
>
Guardar Permisos
</Button>
</Modal.Actions>
</Modal>
);
}
// Statement Editor Component
interface StatementEditorProps {
statement: S3Statement;
onChange: (statement: S3Statement) => void;
onRemove: () => void;
}
function StatementEditor({ statement, onChange, onRemove }: StatementEditorProps) {
const [showAdvanced, setShowAdvanced] = useState(false);
const handleActionToggle = (action: S3Action, checked: boolean) => {
const newActions = checked
? [...statement.actions, action]
: statement.actions.filter(a => a !== action);
onChange({ ...statement, actions: newActions });
};
return (
<Card className="bg-base-200 p-4">
<div className="flex items-center justify-between mb-3">
<h5 className="font-medium">Declaración</h5>
<Button size="xs" variant="outline" color="error" onClick={onRemove}>
Eliminar
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">
<span className="label-text">Efecto</span>
</label>
<Select
value={statement.effect}
onChange={(e) => onChange({ ...statement, effect: e.target.value as any })}
className="select-sm"
>
<option value="Allow">Permitir</option>
<option value="Deny">Denegar</option>
</Select>
</div>
<div>
<label className="label">
<span className="label-text">Recursos</span>
</label>
<Input
value={statement.resources.join(", ")}
onChange={(e) =>
onChange({
...statement,
resources: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
})
}
className="input-sm"
placeholder="*, arn:aws:s3:::bucket/*"
/>
</div>
</div>
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<label className="label">
<span className="label-text">Acciones</span>
</label>
<Button
size="xs"
variant="outline"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? "Vista Simple" : "Vista Avanzada"}
</Button>
</div>
{showAdvanced ? (
<Textarea
value={statement.actions.join("\n")}
onChange={(e) =>
onChange({
...statement,
actions: e.target.value.split("\n").filter(Boolean) as S3Action[],
})
}
className="textarea-sm font-mono"
rows={5}
placeholder="s3:GetObject&#10;s3:PutObject&#10;s3:ListBucket"
/>
) : (
<div className="space-y-3">
{Object.entries(S3_ACTION_GROUPS).map(([groupName, actions]) => (
<div key={groupName}>
<h6 className="text-sm font-medium mb-1">{groupName}</h6>
<div className="grid grid-cols-1 md:grid-cols-2 gap-1">
{actions.map((action) => (
<label key={action} className="flex items-center gap-2 cursor-pointer text-sm">
<Checkbox
size="xs"
checked={statement.actions.includes(action)}
onChange={(e) => handleActionToggle(action, e.target.checked)}
/>
<span className="font-mono">{action}</span>
</label>
))}
</div>
</div>
))}
</div>
)}
</div>
</Card>
);
}

View File

@ -0,0 +1,558 @@
import { useState } from "react";
import {
Button,
Card,
Input,
Loading,
Modal,
Select,
Toggle,
Alert,
Badge,
Table,
} from "react-daisyui";
import {
ObjectLockConfiguration,
ObjectRetention,
ObjectLegalHold,
ObjectWithLocking,
DefaultRetention,
ObjectLockRetentionMode,
ObjectLegalHoldStatus,
} from "@/types/s3-permissions";
import {
useBucketObjectLockConfiguration,
useUpdateBucketObjectLockConfiguration,
useObjectsWithLocking,
useUpdateObjectRetention,
useUpdateObjectLegalHold,
} from "@/hooks/useS3Permissions";
import {
Lock,
Unlock,
Shield,
Settings,
AlertTriangle,
Calendar,
FileText,
Clock
} from "lucide-react";
interface Props {
bucketId: string;
bucketName: string;
isOpen: boolean;
onClose: () => void;
}
export default function ObjectLockingManager({ bucketId, bucketName, isOpen, onClose }: Props) {
const [activeTab, setActiveTab] = useState<"config" | "objects">("config");
const [showRetentionModal, setShowRetentionModal] = useState(false);
const [showLegalHoldModal, setShowLegalHoldModal] = useState(false);
const [selectedObject, setSelectedObject] = useState<ObjectWithLocking | null>(null);
// Hooks
const { data: lockConfig, isLoading } = useBucketObjectLockConfiguration(bucketId);
const updateLockConfig = useUpdateBucketObjectLockConfiguration();
const { data: objectsData } = useObjectsWithLocking(bucketId);
const updateRetention = useUpdateObjectRetention();
const updateLegalHold = useUpdateObjectLegalHold();
const handleConfigUpdate = async (config: ObjectLockConfiguration) => {
try {
await updateLockConfig.mutateAsync({ bucketId, config });
} catch (error) {
console.error("Error updating object lock configuration:", error);
}
};
if (isLoading) {
return (
<Modal open={isOpen} onClickBackdrop={onClose} className="w-11/12 max-w-6xl">
<Modal.Header>
<h3>Object Lock - {bucketName}</h3>
</Modal.Header>
<Modal.Body className="text-center py-8">
<Loading />
</Modal.Body>
</Modal>
);
}
return (
<>
<Modal open={isOpen} onClickBackdrop={onClose} className="w-11/12 max-w-6xl">
<Modal.Header>
<h3 className="flex items-center gap-2">
<Lock size={20} />
Object Lock - {bucketName}
</h3>
</Modal.Header>
<Modal.Body className="space-y-6">
{/* Tab Navigation */}
<div className="tabs tabs-boxed">
<button
className={`tab ${activeTab === "config" ? "tab-active" : ""}`}
onClick={() => setActiveTab("config")}
>
<Settings size={16} className="mr-2" />
Configuración
</button>
<button
className={`tab ${activeTab === "objects" ? "tab-active" : ""}`}
onClick={() => setActiveTab("objects")}
>
<FileText size={16} className="mr-2" />
Objetos Bloqueados
</button>
</div>
{/* Configuration Tab */}
{activeTab === "config" && (
<BucketLockConfiguration
config={lockConfig?.object_lock_configuration}
onUpdate={handleConfigUpdate}
isUpdating={updateLockConfig.isPending}
/>
)}
{/* Objects Tab */}
{activeTab === "objects" && (
<ObjectsWithLockingTable
objects={objectsData?.objects || []}
onEditRetention={(obj) => {
setSelectedObject(obj);
setShowRetentionModal(true);
}}
onEditLegalHold={(obj) => {
setSelectedObject(obj);
setShowLegalHoldModal(true);
}}
/>
)}
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose}>Cerrar</Button>
</Modal.Actions>
</Modal>
{/* Retention Modal */}
{showRetentionModal && selectedObject && (
<RetentionEditor
bucketId={bucketId}
object={selectedObject}
onClose={() => {
setShowRetentionModal(false);
setSelectedObject(null);
}}
/>
)}
{/* Legal Hold Modal */}
{showLegalHoldModal && selectedObject && (
<LegalHoldEditor
bucketId={bucketId}
object={selectedObject}
onClose={() => {
setShowLegalHoldModal(false);
setSelectedObject(null);
}}
/>
)}
</>
);
}
// Bucket Lock Configuration Component
interface BucketLockConfigurationProps {
config?: ObjectLockConfiguration;
onUpdate: (config: ObjectLockConfiguration) => void;
isUpdating: boolean;
}
function BucketLockConfiguration({ config, onUpdate, isUpdating }: BucketLockConfigurationProps) {
const [enabled, setEnabled] = useState(config?.object_lock_enabled || false);
const [hasDefaultRetention, setHasDefaultRetention] = useState(!!config?.rule?.default_retention);
const [defaultRetention, setDefaultRetention] = useState<DefaultRetention>({
mode: "COMPLIANCE",
days: 30,
});
const handleSave = () => {
const newConfig: ObjectLockConfiguration = {
object_lock_enabled: enabled,
rule: hasDefaultRetention ? { default_retention: defaultRetention } : undefined,
};
onUpdate(newConfig);
};
return (
<div className="space-y-6">
{/* Object Lock Status */}
<Card className="bg-base-100 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold flex items-center gap-2">
<Shield size={18} />
Estado de Object Lock
</h4>
<p className="text-sm text-base-content/60 mt-1">
{enabled ? "Object Lock está habilitado en este bucket" : "Object Lock está deshabilitado"}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">Deshabilitado</span>
<Toggle
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
<span className="text-sm">Habilitado</span>
</div>
</div>
{enabled && (
<Alert status="warning" className="mt-4">
<AlertTriangle size={16} />
<div>
<div className="font-bold">Importante</div>
<div className="text-sm">
Una vez habilitado, Object Lock no se puede deshabilitar. Los objetos pueden tener
retención y legal holds aplicados.
</div>
</div>
</Alert>
)}
</Card>
{/* Default Retention Configuration */}
{enabled && (
<Card className="bg-base-100 p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="font-semibold flex items-center gap-2">
<Clock size={18} />
Retención por Defecto
</h4>
<p className="text-sm text-base-content/60 mt-1">
Configurar retención automática para nuevos objetos
</p>
</div>
<Toggle
checked={hasDefaultRetention}
onChange={(e) => setHasDefaultRetention(e.target.checked)}
/>
</div>
{hasDefaultRetention && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">
<span className="label-text">Modo de Retención</span>
</label>
<Select
value={defaultRetention.mode}
onChange={(e) =>
setDefaultRetention(prev => ({
...prev,
mode: e.target.value as ObjectLockRetentionMode,
}))
}
>
<option value="COMPLIANCE">Compliance</option>
<option value="GOVERNANCE">Governance</option>
</Select>
</div>
<div>
<label className="label">
<span className="label-text">Duración (Días)</span>
</label>
<Input
type="number"
value={defaultRetention.days || ""}
onChange={(e) =>
setDefaultRetention(prev => ({
...prev,
days: parseInt(e.target.value) || undefined,
years: undefined,
}))
}
placeholder="Ej: 30"
/>
</div>
<div>
<label className="label">
<span className="label-text">O Años</span>
</label>
<Input
type="number"
value={defaultRetention.years || ""}
onChange={(e) =>
setDefaultRetention(prev => ({
...prev,
years: parseInt(e.target.value) || undefined,
days: undefined,
}))
}
placeholder="Ej: 1"
/>
</div>
</div>
)}
{hasDefaultRetention && (
<div className="mt-4 bg-base-200 p-3 rounded">
<div className="text-sm space-y-1">
<div><strong>Compliance:</strong> Los objetos no pueden ser eliminados hasta que expire la retención</div>
<div><strong>Governance:</strong> Usuarios con permisos especiales pueden eliminar objetos antes</div>
</div>
</div>
)}
</Card>
)}
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
loading={isUpdating}
disabled={isUpdating}
color="primary"
>
Guardar Configuración
</Button>
</div>
</div>
);
}
// Objects with Locking Table Component
interface ObjectsWithLockingTableProps {
objects: ObjectWithLocking[];
onEditRetention: (obj: ObjectWithLocking) => void;
onEditLegalHold: (obj: ObjectWithLocking) => void;
}
function ObjectsWithLockingTable({
objects,
onEditRetention,
onEditLegalHold
}: ObjectsWithLockingTableProps) {
if (objects.length === 0) {
return (
<Card className="bg-base-100 p-8 text-center">
<FileText size={48} className="mx-auto text-base-content/40 mb-4" />
<h4 className="font-semibold mb-2">No hay objetos con Object Lock</h4>
<p className="text-base-content/60">
Los objetos aparecerán aquí una vez que tengan configuraciones de retención o legal holds
</p>
</Card>
);
}
return (
<div className="overflow-x-auto">
<Table>
<Table.Head>
<span>Objeto</span>
<span>Tamaño</span>
<span>Retención</span>
<span>Legal Hold</span>
<span>Acciones</span>
</Table.Head>
<Table.Body>
{objects.map((obj) => (
<tr key={obj.key}>
<td>
<div className="font-mono text-sm">{obj.key}</div>
<div className="text-xs text-base-content/60">
Modificado: {new Date(obj.last_modified).toLocaleString()}
</div>
</td>
<td className="whitespace-nowrap">
{(obj.size / 1024 / 1024).toFixed(2)} MB
</td>
<td>
{obj.retention ? (
<div className="space-y-1">
<Badge color={obj.retention.mode === "COMPLIANCE" ? "error" : "warning"}>
{obj.retention.mode}
</Badge>
<div className="text-xs">
Hasta: {new Date(obj.retention.retain_until_date).toLocaleString()}
</div>
</div>
) : (
<span className="text-base-content/60">Sin retención</span>
)}
</td>
<td>
<Badge color={obj.legal_hold?.status === "ON" ? "error" : "success"}>
{obj.legal_hold?.status === "ON" ? "Activo" : "Inactivo"}
</Badge>
</td>
<td>
<div className="flex gap-2">
<Button
size="xs"
variant="outline"
onClick={() => onEditRetention(obj)}
>
<Calendar size={12} />
Retención
</Button>
<Button
size="xs"
variant="outline"
onClick={() => onEditLegalHold(obj)}
>
<Shield size={12} />
Legal Hold
</Button>
</div>
</td>
</tr>
))}
</Table.Body>
</Table>
</div>
);
}
// Retention Editor Modal
interface RetentionEditorProps {
bucketId: string;
object: ObjectWithLocking;
onClose: () => void;
}
function RetentionEditor({ bucketId, object, onClose }: RetentionEditorProps) {
const [mode, setMode] = useState<ObjectLockRetentionMode>("COMPLIANCE");
const [date, setDate] = useState("");
const updateRetention = useUpdateObjectRetention();
const handleSave = async () => {
const retention: ObjectRetention = {
mode,
retain_until_date: date,
};
try {
await updateRetention.mutateAsync({
bucketId,
objectKey: object.key,
retention,
});
onClose();
} catch (error) {
console.error("Error updating retention:", error);
}
};
return (
<Modal open onClickBackdrop={onClose}>
<Modal.Header>
<h3>Configurar Retención - {object.key}</h3>
</Modal.Header>
<Modal.Body className="space-y-4">
<div>
<label className="label">
<span className="label-text">Modo de Retención</span>
</label>
<Select value={mode} onChange={(e) => setMode(e.target.value as ObjectLockRetentionMode)}>
<option value="COMPLIANCE">Compliance</option>
<option value="GOVERNANCE">Governance</option>
</Select>
</div>
<div>
<label className="label">
<span className="label-text">Fecha de Retención</span>
</label>
<Input
type="datetime-local"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose} variant="outline">
Cancelar
</Button>
<Button onClick={handleSave} loading={updateRetention.isPending}>
Guardar
</Button>
</Modal.Actions>
</Modal>
);
}
// Legal Hold Editor Modal
interface LegalHoldEditorProps {
bucketId: string;
object: ObjectWithLocking;
onClose: () => void;
}
function LegalHoldEditor({ bucketId, object, onClose }: LegalHoldEditorProps) {
const [status, setStatus] = useState<ObjectLegalHoldStatus>("OFF");
const updateLegalHold = useUpdateObjectLegalHold();
const handleSave = async () => {
const legalHold: ObjectLegalHold = { status };
try {
await updateLegalHold.mutateAsync({
bucketId,
objectKey: object.key,
legalHold,
});
onClose();
} catch (error) {
console.error("Error updating legal hold:", error);
}
};
return (
<Modal open onClickBackdrop={onClose}>
<Modal.Header>
<h3>Configurar Legal Hold - {object.key}</h3>
</Modal.Header>
<Modal.Body className="space-y-4">
<div>
<label className="label">
<span className="label-text">Estado de Legal Hold</span>
</label>
<Select value={status} onChange={(e) => setStatus(e.target.value as ObjectLegalHoldStatus)}>
<option value="OFF">Desactivado</option>
<option value="ON">Activado</option>
</Select>
</div>
<Alert status="info">
<div className="text-sm">
<div className="font-bold">Legal Hold</div>
<div>
Cuando está activado, el objeto no puede ser eliminado independientemente
de su configuración de retención.
</div>
</div>
</Alert>
</Modal.Body>
<Modal.Actions>
<Button onClick={onClose} variant="outline">
Cancelar
</Button>
<Button onClick={handleSave} loading={updateLegalHold.isPending}>
Guardar
</Button>
</Modal.Actions>
</Modal>
);
}

View File

@ -0,0 +1,230 @@
import api from "@/lib/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
KeyPermissions,
UpdateKeyPermissionsRequest,
PresetPolicy,
ValidatePolicyResponse,
S3Policy,
ObjectLockConfiguration,
ObjectRetention,
ObjectLegalHold,
ListObjectsWithLockingResponse,
} from "@/types/s3-permissions";
import { toast } from "sonner";
// Key Permissions hooks
export const useKeyPermissions = (bucketId: string, accessKeyId: string) => {
return useQuery({
queryKey: ["key-permissions", bucketId, accessKeyId],
queryFn: async () => {
const response = await api.get<KeyPermissions>(
`/buckets/${bucketId}/keys/${accessKeyId}/permissions`
);
return response?.data;
},
enabled: !!bucketId && !!accessKeyId,
});
};
export const useUpdateKeyPermissions = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateKeyPermissionsRequest) =>
api.put(`/buckets/${data.bucket_id}/keys/${data.access_key_id}/permissions`, {
body: data,
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["key-permissions", variables.bucket_id, variables.access_key_id],
});
queryClient.invalidateQueries({
queryKey: ["buckets"],
});
toast.success("Permisos de llave actualizados exitosamente");
},
onError: (error: any) => {
toast.error(error.message || "Error al actualizar permisos de llave");
},
});
};
// Preset Policies hooks
export const usePresetPolicies = () => {
return useQuery({
queryKey: ["preset-policies"],
queryFn: async () => {
const response = await api.get<Record<string, PresetPolicy>>("/s3/policies/presets");
return response?.data || {};
},
});
};
export const useValidateS3Policy = () => {
return useMutation({
mutationFn: (policy: S3Policy) =>
api.post<ValidatePolicyResponse>("/s3/policies/validate", { body: policy }),
onError: (error: any) => {
toast.error(error.message || "Error al validar política");
},
});
};
// Object Locking hooks
export const useBucketObjectLockConfiguration = (bucketId: string) => {
return useQuery({
queryKey: ["bucket-object-lock", bucketId],
queryFn: async () => {
const response = await api.get<{
bucket_id: string;
object_lock_configuration: ObjectLockConfiguration;
object_lock_enabled: boolean;
}>(`/buckets/${bucketId}/object-lock`);
return response?.data;
},
enabled: !!bucketId,
});
};
export const useUpdateBucketObjectLockConfiguration = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ bucketId, config }: { bucketId: string; config: ObjectLockConfiguration }) =>
api.put(`/buckets/${bucketId}/object-lock`, {
body: {
bucket_id: bucketId,
object_lock_configuration: config,
},
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["bucket-object-lock", variables.bucketId],
});
queryClient.invalidateQueries({
queryKey: ["buckets"],
});
toast.success("Configuración de Object Lock actualizada exitosamente");
},
onError: (error: any) => {
toast.error(error.message || "Error al actualizar configuración de Object Lock");
},
});
};
export const useObjectsWithLocking = (
bucketId: string,
options?: { prefix?: string; delimiter?: string }
) => {
return useQuery({
queryKey: ["objects-with-locking", bucketId, options],
queryFn: async () => {
const response = await api.get<ListObjectsWithLockingResponse>(`/buckets/${bucketId}/objects`, {
params: options,
});
return response?.data;
},
enabled: !!bucketId,
});
};
export const useObjectRetention = (bucketId: string, objectKey: string) => {
return useQuery({
queryKey: ["object-retention", bucketId, objectKey],
queryFn: async () => {
const response = await api.get<{
bucket_id: string;
object_key: string;
retention: ObjectRetention;
}>(`/buckets/${bucketId}/objects/${encodeURIComponent(objectKey)}/retention`);
return response?.data;
},
enabled: !!bucketId && !!objectKey,
});
};
export const useUpdateObjectRetention = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
bucketId,
objectKey,
retention,
}: {
bucketId: string;
objectKey: string;
retention: ObjectRetention;
}) =>
api.put(`/buckets/${bucketId}/objects/${encodeURIComponent(objectKey)}/retention`, {
body: {
bucket_id: bucketId,
object_key: objectKey,
retention,
},
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["object-retention", variables.bucketId, variables.objectKey],
});
queryClient.invalidateQueries({
queryKey: ["objects-with-locking", variables.bucketId],
});
toast.success("Retención de objeto actualizada exitosamente");
},
onError: (error: any) => {
toast.error(error.message || "Error al actualizar retención de objeto");
},
});
};
export const useObjectLegalHold = (bucketId: string, objectKey: string) => {
return useQuery({
queryKey: ["object-legal-hold", bucketId, objectKey],
queryFn: async () => {
const response = await api.get<{
bucket_id: string;
object_key: string;
legal_hold: ObjectLegalHold;
}>(`/buckets/${bucketId}/objects/${encodeURIComponent(objectKey)}/legal-hold`);
return response?.data;
},
enabled: !!bucketId && !!objectKey,
});
};
export const useUpdateObjectLegalHold = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
bucketId,
objectKey,
legalHold,
}: {
bucketId: string;
objectKey: string;
legalHold: ObjectLegalHold;
}) =>
api.put(`/buckets/${bucketId}/objects/${encodeURIComponent(objectKey)}/legal-hold`, {
body: {
bucket_id: bucketId,
object_key: objectKey,
legal_hold: legalHold,
},
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["object-legal-hold", variables.bucketId, variables.objectKey],
});
queryClient.invalidateQueries({
queryKey: ["objects-with-locking", variables.bucketId],
});
toast.success("Legal Hold actualizado exitosamente");
},
onError: (error: any) => {
toast.error(error.message || "Error al actualizar Legal Hold");
},
});
};

View File

@ -1,15 +1,20 @@
import { useDenyKey } from "../hooks"; import { useDenyKey } from "../hooks";
import { Card, Checkbox, Table } from "react-daisyui"; import { Card, Checkbox, Table } from "react-daisyui";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { Trash } from "lucide-react"; import { Trash, Shield, Lock, Settings } from "lucide-react";
import AllowKeyDialog from "./allow-key-dialog"; import AllowKeyDialog from "./allow-key-dialog";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { handleError } from "@/lib/utils"; import { handleError } from "@/lib/utils";
import { useBucketContext } from "../context"; import { useBucketContext } from "../context";
import KeyPermissionsEditor from "@/components/s3-permissions/key-permissions-editor";
import ObjectLockingManager from "@/components/s3-permissions/object-locking-manager";
const PermissionsTab = () => { const PermissionsTab = () => {
const { bucket, refetch } = useBucketContext(); const { bucket, refetch } = useBucketContext();
const [selectedKey, setSelectedKey] = useState<{accessKeyId: string, name: string} | null>(null);
const [showKeyEditor, setShowKeyEditor] = useState(false);
const [showObjectLocking, setShowObjectLocking] = useState(false);
const denyKey = useDenyKey(bucket.id, { const denyKey = useDenyKey(bucket.id, {
onSuccess: () => { onSuccess: () => {
@ -37,11 +42,26 @@ const PermissionsTab = () => {
} }
}; };
const onEditPermissions = (key: any) => {
setSelectedKey({
accessKeyId: key.accessKeyId,
name: key.name || key.accessKeyId?.substring(0, 8)
});
setShowKeyEditor(true);
};
return ( return (
<div> <div>
<Card className="card-body"> <Card className="card-body">
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<Card.Title className="flex-1 truncate">Access Keys</Card.Title> <Card.Title className="flex-1 truncate">Access Keys</Card.Title>
<Button
icon={Lock}
onClick={() => setShowObjectLocking(true)}
className="btn-outline btn-sm"
>
Object Lock
</Button>
<AllowKeyDialog currentKeys={keys?.map((key) => key.accessKeyId)} /> <AllowKeyDialog currentKeys={keys?.map((key) => key.accessKeyId)} />
</div> </div>
@ -54,6 +74,7 @@ const PermissionsTab = () => {
<span>Read</span> <span>Read</span>
<span>Write</span> <span>Write</span>
<span>Owner</span> <span>Owner</span>
<span>S3 Permisos</span>
<span /> <span />
</Table.Head> </Table.Head>
@ -84,6 +105,15 @@ const PermissionsTab = () => {
className="cursor-default" className="cursor-default"
/> />
</span> </span>
<span>
<Button
icon={Shield}
onClick={() => onEditPermissions(key)}
className="btn-outline btn-xs"
>
Editar
</Button>
</span>
<Button <Button
icon={Trash} icon={Trash}
onClick={() => onRemove(key.accessKeyId)} onClick={() => onRemove(key.accessKeyId)}
@ -94,6 +124,31 @@ const PermissionsTab = () => {
</Table> </Table>
</div> </div>
</Card> </Card>
{/* S3 Permissions Editor Modal */}
{showKeyEditor && selectedKey && (
<KeyPermissionsEditor
bucketId={bucket.id}
accessKeyId={selectedKey.accessKeyId}
keyName={selectedKey.name}
isOpen={showKeyEditor}
onClose={() => {
setShowKeyEditor(false);
setSelectedKey(null);
refetch(); // Refresh bucket data after permission changes
}}
/>
)}
{/* Object Locking Manager Modal */}
{showObjectLocking && (
<ObjectLockingManager
bucketId={bucket.id}
bucketName={bucket.globalAliases?.[0] || bucket.id}
isOpen={showObjectLocking}
onClose={() => setShowObjectLocking(false)}
/>
)}
</div> </div>
); );
}; };

212
src/types/s3-permissions.ts Normal file
View File

@ -0,0 +1,212 @@
// S3 Action types matching the backend
export type S3Action =
// Object-level permissions
| "s3:GetObject"
| "s3:PutObject"
| "s3:DeleteObject"
| "s3:GetObjectAcl"
| "s3:PutObjectAcl"
| "s3:GetObjectVersion"
| "s3:DeleteObjectVersion"
// Object locking permissions
| "s3:PutObjectLegalHold"
| "s3:GetObjectLegalHold"
| "s3:PutObjectRetention"
| "s3:GetObjectRetention"
| "s3:BypassGovernanceRetention"
// Multipart upload permissions
| "s3:AbortMultipartUpload"
| "s3:ListMultipartUploadParts"
// Bucket-level permissions
| "s3:ListBucket"
| "s3:ListBucketVersions"
| "s3:GetBucketLocation"
| "s3:GetBucketAcl"
| "s3:PutBucketAcl"
| "s3:GetBucketPolicy"
| "s3:PutBucketPolicy"
| "s3:DeleteBucketPolicy"
| "s3:GetBucketVersioning"
| "s3:PutBucketVersioning"
| "s3:GetBucketObjectLockConfiguration"
| "s3:PutBucketObjectLockConfiguration"
// Bucket management permissions
| "s3:CreateBucket"
| "s3:DeleteBucket"
// List permissions
| "s3:ListAllMyBuckets"
| "s3:ListBucketMultipartUploads"
// Wildcard
| "s3:*";
export type S3Effect = "Allow" | "Deny";
export interface S3Condition {
StringEquals?: Record<string, any>;
StringNotEquals?: Record<string, any>;
StringLike?: Record<string, any>;
StringNotLike?: Record<string, any>;
IpAddress?: Record<string, any>;
NotIpAddress?: Record<string, any>;
DateGreaterThan?: Record<string, any>;
DateLessThan?: Record<string, any>;
}
export interface S3Statement {
id?: string;
effect: S3Effect;
actions: S3Action[];
resources: string[];
condition?: S3Condition;
}
export interface S3Policy {
version: string;
id?: string;
statements: S3Statement[];
}
export interface LegacyPermissions {
read: boolean;
write: boolean;
owner: boolean;
}
export interface KeyPermissions {
access_key_id: string;
name: string;
legacy_mode: boolean;
legacy_permissions?: LegacyPermissions;
s3_policy?: S3Policy;
policy_json?: string;
}
export interface UpdateKeyPermissionsRequest {
bucket_id: string;
access_key_id: string;
policy_type: "preset" | "custom";
policy_name?: string;
policy?: S3Policy;
legacy_mode?: boolean;
legacy?: LegacyPermissions;
}
export interface PresetPolicy {
name: string;
description: string;
policy: S3Policy;
policy_json: string;
}
export interface ValidatePolicyResponse {
valid: boolean;
errors: string[];
message?: string;
legacy_equivalent?: LegacyPermissions;
}
// Object Locking types
export type ObjectLockRetentionMode = "COMPLIANCE" | "GOVERNANCE";
export type ObjectLegalHoldStatus = "ON" | "OFF";
export interface DefaultRetention {
mode: ObjectLockRetentionMode;
days?: number;
years?: number;
}
export interface ObjectLockRule {
default_retention?: DefaultRetention;
}
export interface ObjectLockConfiguration {
object_lock_enabled: boolean;
rule?: ObjectLockRule;
}
export interface ObjectRetention {
mode: ObjectLockRetentionMode;
retain_until_date: string;
}
export interface ObjectLegalHold {
status: ObjectLegalHoldStatus;
}
export interface ObjectWithLocking {
key: string;
size: number;
last_modified: string;
etag: string;
retention?: ObjectRetention;
legal_hold?: ObjectLegalHold;
}
export interface ListObjectsWithLockingResponse {
bucket_id: string;
prefix?: string;
delimiter?: string;
objects: ObjectWithLocking[];
common_prefixes: string[];
is_truncated: boolean;
}
// Permission presets for easy UI selection
export const S3_ACTION_GROUPS = {
"Object Read": [
"s3:GetObject",
"s3:GetObjectAcl",
"s3:GetObjectVersion"
] as S3Action[],
"Object Write": [
"s3:PutObject",
"s3:PutObjectAcl",
"s3:DeleteObject",
"s3:DeleteObjectVersion"
] as S3Action[],
"Object Locking": [
"s3:GetObjectRetention",
"s3:PutObjectRetention",
"s3:GetObjectLegalHold",
"s3:PutObjectLegalHold",
"s3:BypassGovernanceRetention"
] as S3Action[],
"Bucket Read": [
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:GetBucketAcl",
"s3:GetBucketPolicy",
"s3:GetBucketVersioning",
"s3:GetBucketObjectLockConfiguration"
] as S3Action[],
"Bucket Write": [
"s3:PutBucketAcl",
"s3:PutBucketPolicy",
"s3:DeleteBucketPolicy",
"s3:PutBucketVersioning",
"s3:PutBucketObjectLockConfiguration"
] as S3Action[],
"Multipart Uploads": [
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts",
"s3:ListBucketMultipartUploads"
] as S3Action[]
} as const;
export const PRESET_POLICY_DESCRIPTIONS = {
"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"
} as const;