From b765dc6c88d4f3983ae44d1e80e64ce1a6c8bab6 Mon Sep 17 00:00:00 2001 From: Keiran Date: Thu, 7 Aug 2025 18:42:34 +0100 Subject: [PATCH] implement a bucket policy management system heavily "inspired" by aws s3 --- README.md | 77 ++++++++++++ cmd/termcloud/main.go | 15 ++- examples/bucket-policy.json | 40 +++++++ internal/db/policy.go | 214 ++++++++++++++++++++++++++++++++++ internal/db/schema.sql | 8 ++ internal/handlers/handlers.go | 121 +++++++++++++++++++ 6 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 examples/bucket-policy.json create mode 100644 internal/db/policy.go diff --git a/README.md b/README.md index c5ba7c6..152bae5 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,83 @@ All API endpoints require `X-API-Key` header. - `GET /api/v1/user` - Get user info and usage stats +**Bucket Policies:** +- `PUT /api/v1/buckets/:bucket/policy` - Set bucket policy `{"policy": "json-policy-string"}` +- `GET /api/v1/buckets/:bucket/policy` - Get bucket policy +- `DELETE /api/v1/buckets/:bucket/policy` - Delete bucket policy + +## Bucket Policies + +Bucket policies use JSON format similar to AWS S3 IAM policies to control access to buckets and objects. + +### Policy Structure + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "StatementId", + "Effect": "Allow|Deny", + "Principal": { + "User": ["username1", "username2"] + }, + "Action": [ + "termcloud:GetObject", + "termcloud:PutObject", + "termcloud:DeleteObject", + "termcloud:ListObjects" + ], + "Resource": [ + "arn:termcloud:s3:::bucket-name/*" + ] + } + ] +} +``` + +### Supported Actions + +- `termcloud:GetObject` - Download files +- `termcloud:PutObject` - Upload files +- `termcloud:DeleteObject` - Delete files +- `termcloud:ListObjects` - List files in bucket +- `termcloud:GetBucket` - Get bucket info +- `termcloud:DeleteBucket` - Delete bucket +- `*` - All actions + +### Policy Examples + +**Read-only access:** +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"User": ["john"]}, + "Action": ["termcloud:GetObject", "termcloud:ListObjects"], + "Resource": ["arn:termcloud:s3:::my-bucket/*"] + } + ] +} +``` + +**Deny delete operations:** +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Principal": {"User": ["*"]}, + "Action": ["termcloud:DeleteObject"], + "Resource": ["arn:termcloud:s3:::my-bucket/*"] + } + ] +} +``` + ### Example Usage ```bash diff --git a/cmd/termcloud/main.go b/cmd/termcloud/main.go index cea881e..6614ce1 100644 --- a/cmd/termcloud/main.go +++ b/cmd/termcloud/main.go @@ -50,10 +50,17 @@ func main() { api.GET("/buckets", h.ListBucketsHandler) api.POST("/buckets", h.CreateBucketHandler) api.DELETE("/buckets/:bucket", h.DeleteBucketHandler) - api.GET("/buckets/:bucket/objects", h.ListObjectsHandler) - api.PUT("/buckets/:bucket/objects/*", h.UploadObjectHandler) - api.GET("/buckets/:bucket/objects/*", h.GetObjectHandler) - api.DELETE("/buckets/:bucket/objects/*", h.DeleteObjectHandler) + + api.PUT("/buckets/:bucket/policy", h.SetBucketPolicyHandler) + api.GET("/buckets/:bucket/policy", h.GetBucketPolicyHandler) + api.DELETE("/buckets/:bucket/policy", h.DeleteBucketPolicyHandler) + + bucketRoutes := api.Group("/buckets/:bucket") + bucketRoutes.Use(h.PolicyEnforcementMiddleware) + bucketRoutes.GET("/objects", h.ListObjectsHandler) + bucketRoutes.PUT("/objects/*", h.UploadObjectHandler) + bucketRoutes.GET("/objects/*", h.GetObjectHandler) + bucketRoutes.DELETE("/objects/*", h.DeleteObjectHandler) e.Logger.Fatal(e.Start(":" + cfg.Port)) } diff --git a/examples/bucket-policy.json b/examples/bucket-policy.json new file mode 100644 index 0000000..7aa794d --- /dev/null +++ b/examples/bucket-policy.json @@ -0,0 +1,40 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadOnlyAccess", + "Effect": "Allow", + "Principal": { + "User": ["john", "jane"] + }, + "Action": [ + "termcloud:GetObject", + "termcloud:ListObjects" + ], + "Resource": [ + "arn:termcloud:s3:::my-bucket/*", + "arn:termcloud:s3:::my-bucket" + ] + }, + { + "Sid": "DenyDeleteOperations", + "Effect": "Deny", + "Principal": { + "User": ["*"] + }, + "Action": [ + "termcloud:DeleteObject", + "termcloud:DeleteBucket" + ], + "Resource": [ + "arn:termcloud:s3:::my-bucket/*", + "arn:termcloud:s3:::my-bucket" + ], + "Condition": { + "StringNotEquals": { + "termcloud:username": "admin" + } + } + } + ] +} \ No newline at end of file diff --git a/internal/db/policy.go b/internal/db/policy.go new file mode 100644 index 0000000..cbee8e8 --- /dev/null +++ b/internal/db/policy.go @@ -0,0 +1,214 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +type BucketPolicy struct { + Version string `json:"Version"` + Statement []PolicyStatement `json:"Statement"` +} + +type PolicyStatement struct { + Sid string `json:"Sid,omitempty"` + Effect string `json:"Effect"` + Principal map[string]any `json:"Principal,omitempty"` + Action []string `json:"Action"` + Resource []string `json:"Resource"` + Condition map[string]any `json:"Condition,omitempty"` +} + +type BucketPolicyRecord struct { + ID int64 `json:"id"` + BucketID int64 `json:"bucketId"` + Policy string `json:"policy"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func ValidateBucketPolicy(policyJSON string) (*BucketPolicy, error) { + var policy BucketPolicy + if err := json.Unmarshal([]byte(policyJSON), &policy); err != nil { + return nil, fmt.Errorf("invalid JSON format: %w", err) + } + + if policy.Version == "" { + policy.Version = "2012-10-17" + } + + if len(policy.Statement) == 0 { + return nil, fmt.Errorf("policy must contain at least one statement") + } + + for i, stmt := range policy.Statement { + if err := validateStatement(stmt); err != nil { + return nil, fmt.Errorf("statement %d: %w", i, err) + } + } + + return &policy, nil +} + +func validateStatement(stmt PolicyStatement) error { + if stmt.Effect != "Allow" && stmt.Effect != "Deny" { + return fmt.Errorf("effect must be 'Allow' or 'Deny'") + } + + if len(stmt.Action) == 0 { + return fmt.Errorf("statement must specify at least one action") + } + + validActions := map[string]bool{ + "termcloud:GetObject": true, + "termcloud:PutObject": true, + "termcloud:DeleteObject": true, + "termcloud:ListObjects": true, + "termcloud:GetBucket": true, + "termcloud:DeleteBucket": true, + "*": true, + } + + for _, action := range stmt.Action { + if !validActions[action] && !strings.HasSuffix(action, "*") { + return fmt.Errorf("invalid action: %s", action) + } + } + + if len(stmt.Resource) == 0 { + return fmt.Errorf("statement must specify at least one resource") + } + + return nil +} + +func (s *BucketService) SetBucketPolicy(ctx context.Context, bucketID int64, policyJSON string) error { + if _, err := ValidateBucketPolicy(policyJSON); err != nil { + return fmt.Errorf("invalid policy: %w", err) + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO bucket_policies (bucket_id, policy) + VALUES ($1, $2) + ON CONFLICT (bucket_id) + DO UPDATE SET policy = $2, updated_at = NOW()`, + bucketID, policyJSON) + if err != nil { + return fmt.Errorf("failed to set bucket policy: %w", err) + } + + return nil +} + +func (s *BucketService) GetBucketPolicy(ctx context.Context, bucketID int64) (*BucketPolicyRecord, error) { + var record BucketPolicyRecord + err := s.pool.QueryRow(ctx, ` + SELECT id, bucket_id, policy, updated_at + FROM bucket_policies WHERE bucket_id = $1`, + bucketID).Scan(&record.ID, &record.BucketID, &record.Policy, &record.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to get bucket policy: %w", err) + } + + return &record, nil +} + +func (s *BucketService) DeleteBucketPolicy(ctx context.Context, bucketID int64) error { + _, err := s.pool.Exec(ctx, "DELETE FROM bucket_policies WHERE bucket_id = $1", bucketID) + if err != nil { + return fmt.Errorf("failed to delete bucket policy: %w", err) + } + + return nil +} + +func (s *BucketService) EvaluatePolicy(ctx context.Context, bucketID int64, action, resource, principal string) (bool, error) { + policyRecord, err := s.GetBucketPolicy(ctx, bucketID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return true, nil + } + return false, err + } + + policy, err := ValidateBucketPolicy(policyRecord.Policy) + if err != nil { + return false, fmt.Errorf("invalid stored policy: %w", err) + } + + for _, stmt := range policy.Statement { + if matchesStatement(stmt, action, resource, principal) { + return stmt.Effect == "Allow", nil + } + } + + return true, nil +} + +func matchesStatement(stmt PolicyStatement, action, resource, principal string) bool { + if !matchesAction(stmt.Action, action) { + return false + } + + if !matchesResource(stmt.Resource, resource) { + return false + } + + if stmt.Principal != nil && !matchesPrincipal(stmt.Principal, principal) { + return false + } + + return true +} + +func matchesAction(allowedActions []string, action string) bool { + for _, allowed := range allowedActions { + if allowed == "*" || allowed == action { + return true + } + if strings.HasSuffix(allowed, "*") { + prefix := strings.TrimSuffix(allowed, "*") + if strings.HasPrefix(action, prefix) { + return true + } + } + } + return false +} + +func matchesResource(allowedResources []string, resource string) bool { + for _, allowed := range allowedResources { + if allowed == "*" || allowed == resource { + return true + } + if strings.HasSuffix(allowed, "*") { + prefix := strings.TrimSuffix(allowed, "*") + if strings.HasPrefix(resource, prefix) { + return true + } + } + } + return false +} + +func matchesPrincipal(allowedPrincipal map[string]any, principal string) bool { + if allowedPrincipal["*"] != nil { + return true + } + + if users, ok := allowedPrincipal["User"].([]any); ok { + for _, user := range users { + if userStr, ok := user.(string); ok && userStr == principal { + return true + } + } + } + + if userStr, ok := allowedPrincipal["User"].(string); ok && userStr == principal { + return true + } + + return false +} diff --git a/internal/db/schema.sql b/internal/db/schema.sql index c46c7f4..e51df0e 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -28,8 +28,16 @@ CREATE TABLE IF NOT EXISTS objects ( UNIQUE (bucket_id, key) ); +CREATE TABLE IF NOT EXISTS bucket_policies ( + id SERIAL PRIMARY KEY, + bucket_id INT NOT NULL REFERENCES buckets(id) ON DELETE CASCADE UNIQUE, + policy JSONB NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + CREATE INDEX IF NOT EXISTS idx_buckets_owner_id ON buckets (owner_id); CREATE INDEX IF NOT EXISTS idx_users_api_key ON users (api_key); +CREATE INDEX IF NOT EXISTS idx_bucket_policies_bucket_id ON bucket_policies (bucket_id); CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_key ON objects (bucket_id, key); CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_last_modified ON objects (bucket_id, last_modified DESC); CREATE INDEX IF NOT EXISTS idx_objects_custom_metadata_gin ON objects USING GIN (custom_metadata); diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 5851457..4714990 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -220,3 +220,124 @@ func (h *Handlers) GetUserInfoHandler(c echo.Context) error { "bucketCount": len(buckets), }) } + +func (h *Handlers) SetBucketPolicyHandler(c echo.Context) error { + user := c.Get("user").(*db.User) + bucketName := c.Param("bucket") + + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return c.JSON(404, map[string]string{"error": "Bucket not found"}) + } + return c.JSON(500, map[string]string{"error": "Failed to get bucket"}) + } + + var req struct { + Policy string `json:"policy"` + } + if err := c.Bind(&req); err != nil { + return c.JSON(400, map[string]string{"error": "Invalid request body"}) + } + + if err := h.bucketService.SetBucketPolicy(context.Background(), bucket.ID, req.Policy); err != nil { + if strings.Contains(err.Error(), "invalid policy") { + return c.JSON(400, map[string]string{"error": err.Error()}) + } + return c.JSON(500, map[string]string{"error": "Failed to set bucket policy"}) + } + + return c.JSON(200, map[string]string{"message": "Bucket policy set successfully"}) +} + +func (h *Handlers) GetBucketPolicyHandler(c echo.Context) error { + user := c.Get("user").(*db.User) + bucketName := c.Param("bucket") + + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return c.JSON(404, map[string]string{"error": "Bucket not found"}) + } + return c.JSON(500, map[string]string{"error": "Failed to get bucket"}) + } + + policyRecord, err := h.bucketService.GetBucketPolicy(context.Background(), bucket.ID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return c.JSON(404, map[string]string{"error": "No policy found for bucket"}) + } + return c.JSON(500, map[string]string{"error": "Failed to get bucket policy"}) + } + + return c.JSON(200, policyRecord) +} + +func (h *Handlers) DeleteBucketPolicyHandler(c echo.Context) error { + user := c.Get("user").(*db.User) + bucketName := c.Param("bucket") + + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return c.JSON(404, map[string]string{"error": "Bucket not found"}) + } + return c.JSON(500, map[string]string{"error": "Failed to get bucket"}) + } + + if err := h.bucketService.DeleteBucketPolicy(context.Background(), bucket.ID); err != nil { + return c.JSON(500, map[string]string{"error": "Failed to delete bucket policy"}) + } + + return c.JSON(200, map[string]string{"message": "Bucket policy deleted successfully"}) +} + +func (h *Handlers) PolicyEnforcementMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + user := c.Get("user").(*db.User) + bucketName := c.Param("bucket") + + if bucketName == "" { + return next(c) + } + + bucket, err := h.bucketService.GetBucket(context.Background(), bucketName, user.ID) + if err != nil { + return next(c) + } + + action := mapHTTPMethodToAction(c.Request().Method, c.Path()) + resource := fmt.Sprintf("arn:termcloud:s3:::%s/*", bucketName) + principal := user.Username + + allowed, err := h.bucketService.EvaluatePolicy(context.Background(), bucket.ID, action, resource, principal) + if err != nil { + return c.JSON(500, map[string]string{"error": "Policy evaluation failed"}) + } + + if !allowed { + return c.JSON(403, map[string]string{"error": "Access denied by bucket policy"}) + } + + return next(c) + } +} + +func mapHTTPMethodToAction(method, path string) string { + switch method { + case "GET": + if strings.Contains(path, "/objects") && !strings.HasSuffix(path, "/objects") { + return "termcloud:GetObject" + } + return "termcloud:ListObjects" + case "PUT": + return "termcloud:PutObject" + case "DELETE": + if strings.Contains(path, "/objects") { + return "termcloud:DeleteObject" + } + return "termcloud:DeleteBucket" + default: + return "termcloud:GetBucket" + } +}