mirror of
https://github.com/ferdzo/fs.git
synced 2026-06-04 03:06:47 +00:00
Defer multi-delete authorization to handler
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -3,13 +3,18 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -78,19 +83,24 @@ func withAuthContext(req *http.Request, accessKeyID string) *http.Request {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createDeleteUser(t *testing.T, authSvc *auth.Service, prefix string) {
|
func createDeleteUser(t *testing.T, authSvc *auth.Service, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
createDeleteUserWithStatements(t, authSvc, []models.AuthPolicyStatement{
|
||||||
|
{
|
||||||
|
Effect: "allow",
|
||||||
|
Actions: []string{"s3:DeleteObject"},
|
||||||
|
Bucket: "test-bucket",
|
||||||
|
Prefix: prefix,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDeleteUserWithStatements(t *testing.T, authSvc *auth.Service, statements []models.AuthPolicyStatement) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, err := authSvc.CreateUser(auth.CreateUserInput{
|
_, err := authSvc.CreateUser(auth.CreateUserInput{
|
||||||
AccessKeyID: "delete-user",
|
AccessKeyID: "delete-user",
|
||||||
SecretKey: "delete-secret-1",
|
SecretKey: "delete-secret-1",
|
||||||
Policy: models.AuthPolicy{
|
Policy: models.AuthPolicy{
|
||||||
Statements: []models.AuthPolicyStatement{
|
Statements: statements,
|
||||||
{
|
|
||||||
Effect: "allow",
|
|
||||||
Actions: []string{"s3:DeleteObject"},
|
|
||||||
Bucket: "test-bucket",
|
|
||||||
Prefix: prefix,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -163,3 +173,111 @@ func TestMultiDeleteAllowsScopedKeys(t *testing.T) {
|
|||||||
t.Fatalf("allowed object should be deleted, got err=%v", err)
|
t.Fatalf("allowed object should be deleted, got err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMultiDeleteRouteAuthorizesKeysAfterMiddleware(t *testing.T) {
|
||||||
|
handler, svc, authSvc := newAuthorizedDeleteHandler(t)
|
||||||
|
handler.setupRoutes()
|
||||||
|
if err := svc.CreateBucket("test-bucket"); err != nil {
|
||||||
|
t.Fatalf("create bucket: %v", err)
|
||||||
|
}
|
||||||
|
createDeleteUserWithStatements(t, authSvc, []models.AuthPolicyStatement{
|
||||||
|
{Effect: "allow", Actions: []string{"s3:DeleteObject"}, Bucket: "test-bucket", Prefix: "allowed/"},
|
||||||
|
{Effect: "deny", Actions: []string{"s3:DeleteObject"}, Bucket: "test-bucket", Prefix: "private/"},
|
||||||
|
})
|
||||||
|
putTestObject(t, svc, "allowed/file.txt")
|
||||||
|
putTestObject(t, svc, "private/file.txt")
|
||||||
|
|
||||||
|
body := `<Delete><Object><Key>allowed/file.txt</Key></Object><Object><Key>private/file.txt</Key></Object></Delete>`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/test-bucket?delete", strings.NewReader(body))
|
||||||
|
signTestSigV4Request(t, req, "delete-user", "delete-secret-1")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected status: got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
responseBody := rec.Body.String()
|
||||||
|
if !strings.Contains(responseBody, "allowed/file.txt") || !strings.Contains(responseBody, "<Deleted>") {
|
||||||
|
t.Fatalf("expected allowed key deletion, body=%s", responseBody)
|
||||||
|
}
|
||||||
|
if !strings.Contains(responseBody, "private/file.txt") || !strings.Contains(responseBody, "AccessDenied") {
|
||||||
|
t.Fatalf("expected per-key AccessDenied, body=%s", responseBody)
|
||||||
|
}
|
||||||
|
if _, err := svc.HeadObject("test-bucket", "allowed/file.txt"); !errors.Is(err, metadata.ErrObjectNotFound) {
|
||||||
|
t.Fatalf("allowed object should be deleted, got err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.HeadObject("test-bucket", "private/file.txt"); err != nil {
|
||||||
|
t.Fatalf("private object should remain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signTestSigV4Request(t *testing.T, req *http.Request, accessKeyID, secretKey string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
amzDate := time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
date := amzDate[:8]
|
||||||
|
region := "us-east-1"
|
||||||
|
serviceName := "s3"
|
||||||
|
scope := strings.Join([]string{date, region, serviceName, "aws4_request"}, "/")
|
||||||
|
signedHeaders := []string{"host", "x-amz-content-sha256", "x-amz-date"}
|
||||||
|
signedHeadersRaw := strings.Join(signedHeaders, ";")
|
||||||
|
payloadHash := "UNSIGNED-PAYLOAD"
|
||||||
|
|
||||||
|
req.Header.Set("x-amz-date", amzDate)
|
||||||
|
req.Header.Set("x-amz-content-sha256", payloadHash)
|
||||||
|
canonicalRequest := strings.Join([]string{
|
||||||
|
req.Method,
|
||||||
|
req.URL.EscapedPath(),
|
||||||
|
canonicalTestQuery(req.URL.RawQuery),
|
||||||
|
"host:" + strings.TrimSpace(req.Host) + "\n" +
|
||||||
|
"x-amz-content-sha256:" + payloadHash + "\n" +
|
||||||
|
"x-amz-date:" + amzDate + "\n",
|
||||||
|
signedHeadersRaw,
|
||||||
|
payloadHash,
|
||||||
|
}, "\n")
|
||||||
|
canonicalHash := sha256.Sum256([]byte(canonicalRequest))
|
||||||
|
stringToSign := strings.Join([]string{
|
||||||
|
"AWS4-HMAC-SHA256",
|
||||||
|
amzDate,
|
||||||
|
scope,
|
||||||
|
hex.EncodeToString(canonicalHash[:]),
|
||||||
|
}, "\n")
|
||||||
|
signingKey := testHMAC(testHMAC(testHMAC(testHMAC([]byte("AWS4"+secretKey), date), region), serviceName), "aws4_request")
|
||||||
|
signature := hex.EncodeToString(testHMAC(signingKey, stringToSign))
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 "+
|
||||||
|
"Credential="+accessKeyID+"/"+scope+", "+
|
||||||
|
"SignedHeaders="+signedHeadersRaw+", "+
|
||||||
|
"Signature="+signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalTestQuery(rawQuery string) string {
|
||||||
|
values, _ := url.ParseQuery(rawQuery)
|
||||||
|
pairs := make([]string, 0)
|
||||||
|
for key, valueList := range values {
|
||||||
|
if len(valueList) == 0 {
|
||||||
|
pairs = append(pairs, awsTestQueryEscape(key)+"=")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, value := range valueList {
|
||||||
|
pairs = append(pairs, awsTestQueryEscape(key)+"="+awsTestQueryEscape(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(pairs)
|
||||||
|
return strings.Join(pairs, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
func awsTestQueryEscape(value string) string {
|
||||||
|
encoded := url.QueryEscape(value)
|
||||||
|
encoded = strings.ReplaceAll(encoded, "+", "%20")
|
||||||
|
encoded = strings.ReplaceAll(encoded, "*", "%2A")
|
||||||
|
encoded = strings.ReplaceAll(encoded, "%7E", "~")
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHMAC(key []byte, value string) []byte {
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
_, _ = mac.Write([]byte(value))
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ type RequestTarget struct {
|
|||||||
Prefix string
|
Prefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequiresHandlerAuthorization(r *http.Request) bool {
|
||||||
|
if r == nil || r.URL == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
_, isDelete := r.URL.Query()["delete"]
|
||||||
|
return isDelete
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func resolveTarget(r *http.Request) RequestTarget {
|
func resolveTarget(r *http.Request) RequestTarget {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|||||||
@@ -188,6 +188,13 @@ func (s *Service) AuthenticateRequest(r *http.Request) (RequestContext, error) {
|
|||||||
AuthType: authType,
|
AuthType: authType,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
if RequiresHandlerAuthorization(r) {
|
||||||
|
return RequestContext{
|
||||||
|
Authenticated: true,
|
||||||
|
AccessKeyID: identity.AccessKeyID,
|
||||||
|
AuthType: authType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
policy, err := s.store.GetAuthPolicy(identity.AccessKeyID)
|
policy, err := s.store.GetAuthPolicy(identity.AccessKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user