snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
This commit is contained in:
695
weed/s3api/AmazonS3.xsd
Normal file
695
weed/s3api/AmazonS3.xsd
Normal file
@@ -0,0 +1,695 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsd:schema
|
||||
xmlns:tns="http://s3.amazonaws.com/doc/2006-03-01/"
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
elementFormDefault="qualified"
|
||||
targetNamespace="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
|
||||
<xsd:element name="CreateBucket">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="AccessControlList" type="tns:AccessControlList" minOccurs="0"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType name="MetadataEntry">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Name" type="xsd:string"/>
|
||||
<xsd:element name="Value" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:element name="CreateBucketResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="CreateBucketReturn" type="tns:CreateBucketResult"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType name="Status">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Code" type="xsd:int"/>
|
||||
<xsd:element name="Description" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="Result">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Status" type="tns:Status"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="CreateBucketResult">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="BucketName" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:element name="DeleteBucket">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="DeleteBucketResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="DeleteBucketResponse" type="tns:Status"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType name="BucketLoggingStatus">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="LoggingEnabled" type="tns:LoggingSettings" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="LoggingSettings">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="TargetBucket" type="xsd:string"/>
|
||||
<xsd:element name="TargetPrefix" type="xsd:string"/>
|
||||
<xsd:element name="TargetGrants" type="tns:AccessControlList" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:element name="GetBucketLoggingStatus">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="GetBucketLoggingStatusResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="GetBucketLoggingStatusResponse" type="tns:BucketLoggingStatus"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="SetBucketLoggingStatus">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="BucketLoggingStatus" type="tns:BucketLoggingStatus"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="SetBucketLoggingStatusResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence/>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="GetObjectAccessControlPolicy">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="GetObjectAccessControlPolicyResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="GetObjectAccessControlPolicyResponse" type="tns:AccessControlPolicy"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="GetBucketAccessControlPolicy">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="GetBucketAccessControlPolicyResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="GetBucketAccessControlPolicyResponse" type="tns:AccessControlPolicy"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType abstract="true" name="Grantee"/>
|
||||
|
||||
<xsd:complexType name="User" abstract="true">
|
||||
<xsd:complexContent>
|
||||
<xsd:extension base="tns:Grantee"/>
|
||||
</xsd:complexContent>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="AmazonCustomerByEmail">
|
||||
<xsd:complexContent>
|
||||
<xsd:extension base="tns:User">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="EmailAddress" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:extension>
|
||||
</xsd:complexContent>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="CanonicalUser">
|
||||
<xsd:complexContent>
|
||||
<xsd:extension base="tns:User">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="ID" type="xsd:string"/>
|
||||
<xsd:element name="DisplayName" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:extension>
|
||||
</xsd:complexContent>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="Group">
|
||||
<xsd:complexContent>
|
||||
<xsd:extension base="tns:Grantee">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="URI" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:extension>
|
||||
</xsd:complexContent>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:simpleType name="Permission">
|
||||
<xsd:restriction base="xsd:string">
|
||||
<xsd:enumeration value="READ"/>
|
||||
<xsd:enumeration value="WRITE"/>
|
||||
<xsd:enumeration value="READ_ACP"/>
|
||||
<xsd:enumeration value="WRITE_ACP"/>
|
||||
<xsd:enumeration value="FULL_CONTROL"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:simpleType name="StorageClass">
|
||||
<xsd:restriction base="xsd:string">
|
||||
<xsd:enumeration value="STANDARD"/>
|
||||
<xsd:enumeration value="REDUCED_REDUNDANCY"/>
|
||||
<xsd:enumeration value="GLACIER"/>
|
||||
<xsd:enumeration value="UNKNOWN"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:complexType name="Grant">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Grantee" type="tns:Grantee"/>
|
||||
<xsd:element name="Permission" type="tns:Permission"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="AccessControlList">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Grant" type="tns:Grant" minOccurs="0" maxOccurs="100"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="CreateBucketConfiguration">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="LocationConstraint" type="tns:LocationConstraint"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="LocationConstraint">
|
||||
<xsd:simpleContent>
|
||||
<xsd:extension base="xsd:string"/>
|
||||
</xsd:simpleContent>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="AccessControlPolicy">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Owner" type="tns:CanonicalUser"/>
|
||||
<xsd:element name="AccessControlList" type="tns:AccessControlList"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:element name="SetObjectAccessControlPolicy">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element name="AccessControlList" type="tns:AccessControlList"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="SetObjectAccessControlPolicyResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence/>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="SetBucketAccessControlPolicy">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="AccessControlList" type="tns:AccessControlList" minOccurs="0"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="SetBucketAccessControlPolicyResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence/>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="GetObject">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element name="GetMetadata" type="xsd:boolean"/>
|
||||
<xsd:element name="GetData" type="xsd:boolean"/>
|
||||
<xsd:element name="InlineData" type="xsd:boolean"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="GetObjectResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="GetObjectResponse" type="tns:GetObjectResult"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType name="GetObjectResult">
|
||||
<xsd:complexContent>
|
||||
<xsd:extension base="tns:Result">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Metadata" type="tns:MetadataEntry" minOccurs="0" maxOccurs="unbounded"/>
|
||||
<xsd:element name="Data" type="xsd:base64Binary" nillable="true"/>
|
||||
<xsd:element name="LastModified" type="xsd:dateTime"/>
|
||||
<xsd:element name="ETag" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:extension>
|
||||
</xsd:complexContent>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:element name="GetObjectExtended">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element name="GetMetadata" type="xsd:boolean"/>
|
||||
<xsd:element name="GetData" type="xsd:boolean"/>
|
||||
<xsd:element name="InlineData" type="xsd:boolean"/>
|
||||
<xsd:element name="ByteRangeStart" type="xsd:long" minOccurs="0"/>
|
||||
<xsd:element name="ByteRangeEnd" type="xsd:long" minOccurs="0"/>
|
||||
<xsd:element name="IfModifiedSince" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="IfUnmodifiedSince" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="IfMatch" type="xsd:string" minOccurs="0" maxOccurs="100"/>
|
||||
<xsd:element name="IfNoneMatch" type="xsd:string" minOccurs="0" maxOccurs="100"/>
|
||||
<xsd:element name="ReturnCompleteObjectOnConditionFailure" type="xsd:boolean" minOccurs="0"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="GetObjectExtendedResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="GetObjectResponse" type="tns:GetObjectResult"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="PutObject">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element name="Metadata" type="tns:MetadataEntry" minOccurs="0" maxOccurs="100"/>
|
||||
<xsd:element name="ContentLength" type="xsd:long"/>
|
||||
<xsd:element name="AccessControlList" type="tns:AccessControlList" minOccurs="0"/>
|
||||
<xsd:element name="StorageClass" type="tns:StorageClass" minOccurs="0"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="PutObjectResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="PutObjectResponse" type="tns:PutObjectResult"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType name="PutObjectResult">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="ETag" type="xsd:string"/>
|
||||
<xsd:element name="LastModified" type="xsd:dateTime"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:element name="PutObjectInline">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element minOccurs="0" maxOccurs="100" name="Metadata" type="tns:MetadataEntry"/>
|
||||
<xsd:element name="Data" type="xsd:base64Binary"/>
|
||||
<xsd:element name="ContentLength" type="xsd:long"/>
|
||||
<xsd:element name="AccessControlList" type="tns:AccessControlList" minOccurs="0"/>
|
||||
<xsd:element name="StorageClass" type="tns:StorageClass" minOccurs="0"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="PutObjectInlineResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="PutObjectInlineResponse" type="tns:PutObjectResult"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="DeleteObject">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="DeleteObjectResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="DeleteObjectResponse" type="tns:Status"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="ListBucket">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="Prefix" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Marker" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="MaxKeys" type="xsd:int" minOccurs="0"/>
|
||||
<xsd:element name="Delimiter" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="ListBucketResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="ListBucketResponse" type="tns:ListBucketResult"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="ListVersionsResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="ListVersionsResponse" type="tns:ListVersionsResult"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType name="ListEntry">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element name="LastModified" type="xsd:dateTime"/>
|
||||
<xsd:element name="ETag" type="xsd:string"/>
|
||||
<xsd:element name="Size" type="xsd:long"/>
|
||||
<xsd:element name="Owner" type="tns:CanonicalUser" minOccurs="0"/>
|
||||
<xsd:element name="StorageClass" type="tns:StorageClass"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="VersionEntry">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element name="VersionId" type="xsd:string"/>
|
||||
<xsd:element name="IsLatest" type="xsd:boolean"/>
|
||||
<xsd:element name="LastModified" type="xsd:dateTime"/>
|
||||
<xsd:element name="ETag" type="xsd:string"/>
|
||||
<xsd:element name="Size" type="xsd:long"/>
|
||||
<xsd:element name="Owner" type="tns:CanonicalUser" minOccurs="0"/>
|
||||
<xsd:element name="StorageClass" type="tns:StorageClass"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="DeleteMarkerEntry">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element name="VersionId" type="xsd:string"/>
|
||||
<xsd:element name="IsLatest" type="xsd:boolean"/>
|
||||
<xsd:element name="LastModified" type="xsd:dateTime"/>
|
||||
<xsd:element name="Owner" type="tns:CanonicalUser" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="PrefixEntry">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Prefix" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="ListBucketResult">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Metadata" type="tns:MetadataEntry" minOccurs="0" maxOccurs="unbounded"/>
|
||||
<xsd:element name="Name" type="xsd:string"/>
|
||||
<xsd:element name="Prefix" type="xsd:string"/>
|
||||
<xsd:element name="Marker" type="xsd:string"/>
|
||||
<xsd:element name="NextMarker" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="MaxKeys" type="xsd:int"/>
|
||||
<xsd:element name="Delimiter" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="IsTruncated" type="xsd:boolean"/>
|
||||
<xsd:element name="Contents" type="tns:ListEntry" minOccurs="0" maxOccurs="unbounded"/>
|
||||
<xsd:element name="CommonPrefixes" type="tns:PrefixEntry" minOccurs="0" maxOccurs="unbounded"/>
|
||||
<xsd:element name="EncodingType" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="ListVersionsResult">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Metadata" type="tns:MetadataEntry" minOccurs="0" maxOccurs="unbounded"/>
|
||||
<xsd:element name="Name" type="xsd:string"/>
|
||||
<xsd:element name="Prefix" type="xsd:string"/>
|
||||
<xsd:element name="KeyMarker" type="xsd:string"/>
|
||||
<xsd:element name="VersionIdMarker" type="xsd:string"/>
|
||||
<xsd:element name="NextKeyMarker" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="NextVersionIdMarker" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="MaxKeys" type="xsd:int"/>
|
||||
<xsd:element name="Delimiter" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="IsTruncated" type="xsd:boolean"/>
|
||||
<xsd:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xsd:element name="Version" type="tns:VersionEntry"/>
|
||||
<xsd:element name="DeleteMarker" type="tns:DeleteMarkerEntry"/>
|
||||
</xsd:choice>
|
||||
<xsd:element name="CommonPrefixes" type="tns:PrefixEntry" minOccurs="0" maxOccurs="unbounded"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:element name="ListAllMyBuckets">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="ListAllMyBucketsResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="ListAllMyBucketsResponse" type="tns:ListAllMyBucketsResult"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType name="ListAllMyBucketsEntry">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Name" type="xsd:string"/>
|
||||
<xsd:element name="CreationDate" type="xsd:dateTime"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="ListAllMyBucketsResult">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Owner" type="tns:CanonicalUser"/>
|
||||
<xsd:element name="Buckets" type="tns:ListAllMyBucketsList"/>
|
||||
<xsd:element name="ContinuationToken" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Prefix" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="ListAllMyBucketsList">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Bucket" type="tns:ListAllMyBucketsEntry" minOccurs="0" maxOccurs="unbounded"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:element name="PostResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Location" type="xsd:anyURI"/>
|
||||
<xsd:element name="Bucket" type="xsd:string"/>
|
||||
<xsd:element name="Key" type="xsd:string"/>
|
||||
<xsd:element name="ETag" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:simpleType name="MetadataDirective">
|
||||
<xsd:restriction base="xsd:string">
|
||||
<xsd:enumeration value="COPY"/>
|
||||
<xsd:enumeration value="REPLACE"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:element name="CopyObject">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="SourceBucket" type="xsd:string"/>
|
||||
<xsd:element name="SourceKey" type="xsd:string"/>
|
||||
<xsd:element name="DestinationBucket" type="xsd:string"/>
|
||||
<xsd:element name="DestinationKey" type="xsd:string"/>
|
||||
<xsd:element name="MetadataDirective" type="tns:MetadataDirective" minOccurs="0"/>
|
||||
<xsd:element name="Metadata" type="tns:MetadataEntry" minOccurs="0" maxOccurs="100"/>
|
||||
<xsd:element name="AccessControlList" type="tns:AccessControlList" minOccurs="0"/>
|
||||
<xsd:element name="CopySourceIfModifiedSince" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="CopySourceIfUnmodifiedSince" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="CopySourceIfMatch" type="xsd:string" minOccurs="0" maxOccurs="100"/>
|
||||
<xsd:element name="CopySourceIfNoneMatch" type="xsd:string" minOccurs="0" maxOccurs="100"/>
|
||||
<xsd:element name="StorageClass" type="tns:StorageClass" minOccurs="0"/>
|
||||
<xsd:element name="AWSAccessKeyId" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Timestamp" type="xsd:dateTime" minOccurs="0"/>
|
||||
<xsd:element name="Signature" type="xsd:string" minOccurs="0"/>
|
||||
<xsd:element name="Credential" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:element name="CopyObjectResponse">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="CopyObjectResult" type="tns:CopyObjectResult" />
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
|
||||
<xsd:complexType name="CopyObjectResult">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="LastModified" type="xsd:dateTime"/>
|
||||
<xsd:element name="ETag" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="RequestPaymentConfiguration">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Payer" type="tns:Payer" minOccurs="1" maxOccurs="1"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:simpleType name="Payer">
|
||||
<xsd:restriction base="xsd:string">
|
||||
<xsd:enumeration value="BucketOwner"/>
|
||||
<xsd:enumeration value="Requester"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:complexType name="VersioningConfiguration">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Status" type="tns:VersioningStatus" minOccurs="0"/>
|
||||
<xsd:element name="MfaDelete" type="tns:MfaDeleteStatus" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:simpleType name="MfaDeleteStatus">
|
||||
<xsd:restriction base="xsd:string">
|
||||
<xsd:enumeration value="Enabled"/>
|
||||
<xsd:enumeration value="Disabled"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:simpleType name="VersioningStatus">
|
||||
<xsd:restriction base="xsd:string">
|
||||
<xsd:enumeration value="Enabled"/>
|
||||
<xsd:enumeration value="Suspended"/>
|
||||
</xsd:restriction>
|
||||
</xsd:simpleType>
|
||||
|
||||
<xsd:complexType name="NotificationConfiguration">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="TopicConfiguration" minOccurs="0" maxOccurs="unbounded" type="tns:TopicConfiguration"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
<xsd:complexType name="TopicConfiguration">
|
||||
<xsd:sequence>
|
||||
<xsd:element name="Topic" minOccurs="1" maxOccurs="1" type="xsd:string"/>
|
||||
<xsd:element name="Event" minOccurs="1" maxOccurs="unbounded" type="xsd:string"/>
|
||||
</xsd:sequence>
|
||||
</xsd:complexType>
|
||||
|
||||
</xsd:schema>
|
||||
7
weed/s3api/README.txt
Normal file
7
weed/s3api/README.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
see https://blog.aqwari.net/xml-schema-go/
|
||||
|
||||
1. go get aqwari.net/xml/cmd/xsdgen
|
||||
2. Add EncodingType element for ListBucketResult in AmazonS3.xsd
|
||||
3. xsdgen -o s3api_xsd_generated.go -pkg s3api AmazonS3.xsd
|
||||
4. Remove empty Grantee struct in s3api_xsd_generated.go
|
||||
5. Remove xmlns: sed s'/http:\/\/s3.amazonaws.com\/doc\/2006-03-01\/\ //' s3api_xsd_generated.go
|
||||
292
weed/s3api/auth_account_collapse_test.go
Normal file
292
weed/s3api/auth_account_collapse_test.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccountForUnscopedIdentity(t *testing.T) {
|
||||
assert.Equal(t, "alice", accountForUnscopedIdentity("alice").Id, "a named identity gets its own account id")
|
||||
assert.NotEqual(t, AccountAdmin.Id, accountForUnscopedIdentity("alice").Id, "a named identity must not inherit the admin account")
|
||||
assert.Same(t, &AccountAdmin, accountForUnscopedIdentity(AccountAdmin.Id), "the conventional admin keeps the admin account")
|
||||
assert.Same(t, &AccountAdmin, accountForUnscopedIdentity(""), "an empty name falls back to the admin account")
|
||||
}
|
||||
|
||||
func TestUnscopedIdentitiesGetDistinctAccounts(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
|
||||
config := `{
|
||||
"identities": [
|
||||
{"name": "alice", "credentials": [{"accessKey": "alice_ak", "secretKey": "alice_sk"}], "actions": ["Read"]},
|
||||
{"name": "admin", "credentials": [{"accessKey": "admin_ak", "secretKey": "admin_sk"}], "actions": ["Admin"]}
|
||||
]
|
||||
}`
|
||||
tmp, err := os.CreateTemp("", "s3-config-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmp.Name())
|
||||
_, err = tmp.WriteString(config)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmp.Close())
|
||||
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
|
||||
|
||||
alice, _, found := iam.LookupByAccessKey("alice_ak")
|
||||
require.True(t, found)
|
||||
require.NotNil(t, alice.Account)
|
||||
assert.Equal(t, "alice", alice.Account.Id, "a non-admin account-less identity owns resources as itself, not as admin")
|
||||
|
||||
admin, _, found := iam.LookupByAccessKey("admin_ak")
|
||||
require.True(t, found)
|
||||
require.NotNil(t, admin.Account)
|
||||
assert.Equal(t, AccountAdmin.Id, admin.Account.Id, "the admin identity keeps the admin account")
|
||||
}
|
||||
|
||||
// A distinct non-owner is denied an admin-owned bucket (iam nil => isUserAdmin
|
||||
// false, so only real ownership grants access).
|
||||
func TestCheckAccessByOwnershipDeniesNonOwner(t *testing.T) {
|
||||
adminOwner := AccountAdmin.Id
|
||||
s3a := &S3ApiServer{
|
||||
bucketRegistry: NewBucketRegistry(nil),
|
||||
}
|
||||
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: "b", Owner: &s3.Owner{ID: &adminOwner}})
|
||||
|
||||
nonOwner := httptest.NewRequest(http.MethodGet, "/b?ownershipControls=", nil)
|
||||
nonOwner.Header.Set(s3_constants.AmzAccountId, "alice")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, s3a.checkAccessByOwnership(nonOwner, "b"), "a distinct non-owner is denied the admin-owned bucket")
|
||||
|
||||
owner := httptest.NewRequest(http.MethodGet, "/b?ownershipControls=", nil)
|
||||
owner.Header.Set(s3_constants.AmzAccountId, AccountAdmin.Id)
|
||||
assert.Equal(t, s3err.ErrNone, s3a.checkAccessByOwnership(owner, "b"), "the actual owner is still allowed")
|
||||
}
|
||||
|
||||
// An account-less identity's synthesized account must be registered in the
|
||||
// account lookup so its id resolves to a display name. Otherwise ACL grantee
|
||||
// validation and owner display report the id as "not exists" — the regression
|
||||
// where a canned PutObjectAcl granting to the caller's own account returned
|
||||
// 400 InvalidRequest.
|
||||
func TestUnscopedIdentityAccountResolvesByName(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
|
||||
config := `{
|
||||
"identities": [
|
||||
{"name": "alice", "credentials": [{"accessKey": "alice_ak", "secretKey": "alice_sk"}], "actions": ["Read", "Write"]}
|
||||
]
|
||||
}`
|
||||
tmp, err := os.CreateTemp("", "s3-config-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmp.Name())
|
||||
_, err = tmp.WriteString(config)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmp.Close())
|
||||
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
|
||||
|
||||
assert.Equal(t, "alice", iam.GetAccountNameById("alice"),
|
||||
"account-less identity id must resolve to a display name for ACL/owner validation")
|
||||
}
|
||||
|
||||
// When an account is explicitly configured with the same id an account-less
|
||||
// identity would synthesize, the identity must reuse that configured account so
|
||||
// its custom display name/email are preserved.
|
||||
func TestUnscopedIdentityReusesConfiguredAccount(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
|
||||
config := `{
|
||||
"accounts": [
|
||||
{"id": "alice", "displayName": "Alice Smith", "emailAddress": "alice@example.com"}
|
||||
],
|
||||
"identities": [
|
||||
{"name": "alice", "credentials": [{"accessKey": "alice_ak", "secretKey": "alice_sk"}], "actions": ["Read"]}
|
||||
]
|
||||
}`
|
||||
tmp, err := os.CreateTemp("", "s3-config-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmp.Name())
|
||||
_, err = tmp.WriteString(config)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmp.Close())
|
||||
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
|
||||
|
||||
assert.Equal(t, "Alice Smith", iam.GetAccountNameById("alice"),
|
||||
"explicitly configured account display name must be preserved")
|
||||
|
||||
alice, _, found := iam.LookupByAccessKey("alice_ak")
|
||||
require.True(t, found)
|
||||
require.NotNil(t, alice.Account)
|
||||
assert.Equal(t, "Alice Smith", alice.Account.DisplayName,
|
||||
"identity must reuse the configured account, not the synthesized one")
|
||||
}
|
||||
|
||||
// Users created through the IAM API carry their account inline on the identity,
|
||||
// and no credential store emits a top-level accounts list. Such an account must
|
||||
// be registered rather than collapsed into the admin account, which gave every
|
||||
// one of those users the same owner id for ownership checks.
|
||||
func TestInlineIdentityAccountIsRegistered(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
|
||||
config := `{
|
||||
"identities": [
|
||||
{"name": "alice", "account": {"id": "100000000001", "displayName": "Alice", "emailAddress": "alice@example.com"}, "credentials": [{"accessKey": "alice_ak", "secretKey": "alice_sk"}], "actions": ["Read", "Write"]},
|
||||
{"name": "bob", "account": {"id": "100000000002", "displayName": "Bob", "emailAddress": "bob@example.com"}, "credentials": [{"accessKey": "bob_ak", "secretKey": "bob_sk"}], "actions": ["Read", "Write"]}
|
||||
]
|
||||
}`
|
||||
tmp, err := os.CreateTemp("", "s3-config-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmp.Name())
|
||||
_, err = tmp.WriteString(config)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmp.Close())
|
||||
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
|
||||
|
||||
alice, _, found := iam.LookupByAccessKey("alice_ak")
|
||||
require.True(t, found)
|
||||
require.NotNil(t, alice.Account)
|
||||
assert.Equal(t, "100000000001", alice.Account.Id, "an undeclared inline account must not collapse into admin")
|
||||
|
||||
bob, _, found := iam.LookupByAccessKey("bob_ak")
|
||||
require.True(t, found)
|
||||
require.NotNil(t, bob.Account)
|
||||
assert.NotEqual(t, alice.Account.Id, bob.Account.Id, "distinct users must not share an owner id")
|
||||
|
||||
assert.Equal(t, "Alice", iam.GetAccountNameById("100000000001"), "the registered account must resolve for ACL/owner display")
|
||||
assert.Equal(t, "100000000002", iam.GetAccountIdByEmail("bob@example.com"), "the registered account must resolve by email")
|
||||
}
|
||||
|
||||
// A dynamic update carries a single identity with no accounts list, so the merge
|
||||
// path must register the inline account the same way the full load does.
|
||||
func TestInlineIdentityAccountIsRegisteredOnUpsert(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
|
||||
config := `{
|
||||
"identities": [
|
||||
{"name": "admin", "credentials": [{"accessKey": "admin_ak", "secretKey": "admin_sk"}], "actions": ["Admin"]}
|
||||
]
|
||||
}`
|
||||
tmp, err := os.CreateTemp("", "s3-config-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmp.Name())
|
||||
_, err = tmp.WriteString(config)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmp.Close())
|
||||
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
|
||||
|
||||
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
|
||||
Name: "alice",
|
||||
Account: &iam_pb.Account{Id: "100000000001", DisplayName: "Alice", EmailAddress: "alice@example.com"},
|
||||
Credentials: []*iam_pb.Credential{{AccessKey: "alice_ak", SecretKey: "alice_sk"}},
|
||||
Actions: []string{"Read", "Write"},
|
||||
}))
|
||||
|
||||
alice, _, found := iam.LookupByAccessKey("alice_ak")
|
||||
require.True(t, found)
|
||||
require.NotNil(t, alice.Account)
|
||||
assert.Equal(t, "100000000001", alice.Account.Id, "a pushed identity must keep its own account id")
|
||||
assert.NotEqual(t, AccountAdmin.Id, alice.Account.Id, "a pushed identity must not inherit the admin account")
|
||||
assert.Equal(t, "Alice", iam.GetAccountNameById("100000000001"), "the registered account must resolve for ACL/owner display")
|
||||
|
||||
// Changing the email keeps the account id, so the merge starts from a cached
|
||||
// account that still carries the old address.
|
||||
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
|
||||
Name: "alice",
|
||||
Account: &iam_pb.Account{Id: "100000000001", DisplayName: "Alice Smith", EmailAddress: "alice.smith@example.com"},
|
||||
Credentials: []*iam_pb.Credential{{AccessKey: "alice_ak", SecretKey: "alice_sk"}},
|
||||
Actions: []string{"Read", "Write"},
|
||||
}))
|
||||
|
||||
assert.Equal(t, "100000000001", iam.GetAccountIdByEmail("alice.smith@example.com"), "the new email must be indexed")
|
||||
assert.Empty(t, iam.GetAccountIdByEmail("alice@example.com"), "the replaced email must no longer resolve")
|
||||
assert.Equal(t, "Alice Smith", iam.GetAccountNameById("100000000001"), "the display name must follow the update")
|
||||
}
|
||||
|
||||
// Two inline accounts can carry the same email. The first to register keeps the
|
||||
// lookup, and the loser can claim it once the holder moves away — otherwise an
|
||||
// email freed by an update would resolve to nobody.
|
||||
func TestInlineAccountEmailClaimAndHandoff(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
|
||||
config := `{
|
||||
"identities": [
|
||||
{"name": "admin", "credentials": [{"accessKey": "admin_ak", "secretKey": "admin_sk"}], "actions": ["Admin"]}
|
||||
]
|
||||
}`
|
||||
tmp, err := os.CreateTemp("", "s3-config-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmp.Name())
|
||||
_, err = tmp.WriteString(config)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmp.Close())
|
||||
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
|
||||
|
||||
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
|
||||
Name: "alice",
|
||||
Account: &iam_pb.Account{Id: "100000000001", DisplayName: "Alice", EmailAddress: "shared@example.com"},
|
||||
Credentials: []*iam_pb.Credential{{AccessKey: "alice_ak", SecretKey: "alice_sk"}},
|
||||
Actions: []string{"Read"},
|
||||
}))
|
||||
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
|
||||
Name: "bob",
|
||||
Account: &iam_pb.Account{Id: "100000000002", DisplayName: "Bob", EmailAddress: "shared@example.com"},
|
||||
Credentials: []*iam_pb.Credential{{AccessKey: "bob_ak", SecretKey: "bob_sk"}},
|
||||
Actions: []string{"Read"},
|
||||
}))
|
||||
|
||||
assert.Equal(t, "100000000001", iam.GetAccountIdByEmail("shared@example.com"), "the first account to claim an email keeps it")
|
||||
|
||||
// alice moves off the shared address, freeing it
|
||||
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
|
||||
Name: "alice",
|
||||
Account: &iam_pb.Account{Id: "100000000001", DisplayName: "Alice", EmailAddress: "alice@example.com"},
|
||||
Credentials: []*iam_pb.Credential{{AccessKey: "alice_ak", SecretKey: "alice_sk"}},
|
||||
Actions: []string{"Read"},
|
||||
}))
|
||||
assert.Equal(t, "100000000001", iam.GetAccountIdByEmail("alice@example.com"), "the moved account indexes its new email")
|
||||
|
||||
// bob re-syncs unchanged and picks up the address he never got to claim
|
||||
require.NoError(t, iam.UpsertIdentity(&iam_pb.Identity{
|
||||
Name: "bob",
|
||||
Account: &iam_pb.Account{Id: "100000000002", DisplayName: "Bob", EmailAddress: "shared@example.com"},
|
||||
Credentials: []*iam_pb.Credential{{AccessKey: "bob_ak", SecretKey: "bob_sk"}},
|
||||
Actions: []string{"Read"},
|
||||
}))
|
||||
assert.Equal(t, "100000000002", iam.GetAccountIdByEmail("shared@example.com"), "a freed email resolves to the account still holding it")
|
||||
}
|
||||
|
||||
// An account declared in a top-level accounts list outranks an identity's inline
|
||||
// block, which must not rewrite its metadata or take over its email.
|
||||
func TestDeclaredAccountOutranksInlineIdentityAccount(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
|
||||
config := `{
|
||||
"accounts": [
|
||||
{"id": "100000000001", "displayName": "Alice Smith", "emailAddress": "alice@example.com"}
|
||||
],
|
||||
"identities": [
|
||||
{"name": "alice", "account": {"id": "100000000001", "displayName": "wrong", "emailAddress": "wrong@example.com"}, "credentials": [{"accessKey": "alice_ak", "secretKey": "alice_sk"}], "actions": ["Read"]}
|
||||
]
|
||||
}`
|
||||
tmp, err := os.CreateTemp("", "s3-config-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmp.Name())
|
||||
_, err = tmp.WriteString(config)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmp.Close())
|
||||
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmp.Name()}, nil, "memory")
|
||||
|
||||
assert.Equal(t, "Alice Smith", iam.GetAccountNameById("100000000001"), "the declared display name must win")
|
||||
assert.Equal(t, "100000000001", iam.GetAccountIdByEmail("alice@example.com"), "the declared email must stay indexed")
|
||||
assert.Empty(t, iam.GetAccountIdByEmail("wrong@example.com"), "an inline block must not claim an email for a declared account")
|
||||
}
|
||||
217
weed/s3api/auth_audit_requester_test.go
Normal file
217
weed/s3api/auth_audit_requester_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// An STS session authenticates as an opaque session subject, so the audit entry
|
||||
// must also carry the principal ARN — the role name and the role session name
|
||||
// are only recoverable from there.
|
||||
func TestAuditRequesterArnForSTSSession(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{
|
||||
iamIntegration: &MockIAMIntegration{
|
||||
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
|
||||
return &sts.SessionInfo{
|
||||
AssumedRoleUser: "ClientRole/dev-session",
|
||||
Principal: "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session",
|
||||
Subject: "47ad4828c45b3f337bc3146081ba8f0f",
|
||||
SessionName: "dev-session",
|
||||
Credentials: &sts.Credentials{
|
||||
AccessKeyId: "ASIA0189777d42cba8e2",
|
||||
SecretAccessKey: "secret",
|
||||
},
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
Policies: []string{"ClientPolicy"},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// track() installs the holder before authentication runs.
|
||||
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
|
||||
|
||||
identity, _, errCode := iam.validateSTSSessionToken(outer, "session-token", "ASIA0189777d42cba8e2")
|
||||
require.Equal(t, s3err.ErrNone, errCode)
|
||||
|
||||
iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {})
|
||||
|
||||
log := s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone)
|
||||
assert.Equal(t, "47ad4828c45b3f337bc3146081ba8f0f", log.Requester)
|
||||
assert.Equal(t, "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session", log.RequesterArn,
|
||||
"audit entry must name the assumed role and session")
|
||||
}
|
||||
|
||||
// An OIDC-federated STS session authenticates as an opaque session subject, so
|
||||
// the requester field alone is useless for compliance auditing. The audit entry
|
||||
// must surface the authoritative OIDC identity claim (preferred_username, email
|
||||
// or sub) carried in the session request context, independent of the
|
||||
// client-supplied role session name. See issue #11264.
|
||||
func TestAuditRequesterIdentityForOIDCFederatedSession(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{
|
||||
iamIntegration: &MockIAMIntegration{
|
||||
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
|
||||
return &sts.SessionInfo{
|
||||
AssumedRoleUser: "S3ReadOnlyRole/boto3-session",
|
||||
Principal: "arn:aws:sts::assumed-role/S3ReadOnlyRole/boto3-session",
|
||||
Subject: "2a19e647c5d43a62a91ecf664d07dbe1",
|
||||
SessionName: "boto3-session",
|
||||
Credentials: &sts.Credentials{
|
||||
AccessKeyId: "ASIA0189777d42cba8e2",
|
||||
SecretAccessKey: "secret",
|
||||
},
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
Policies: []string{"S3ReadOnly"},
|
||||
ParentUser: sts.ComputeParentUser("oidc-sub-123", "https://idp.example/"),
|
||||
RequestContext: map[string]interface{}{
|
||||
"preferred_username": "grant.west",
|
||||
"email": "grant.west@digital.mod.uk",
|
||||
"sub": "oidc-sub-123",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
|
||||
|
||||
identity, _, errCode := iam.validateSTSSessionToken(outer, "session-token", "ASIA0189777d42cba8e2")
|
||||
require.Equal(t, s3err.ErrNone, errCode)
|
||||
|
||||
iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {})
|
||||
|
||||
log := s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone)
|
||||
assert.Equal(t, "2a19e647c5d43a62a91ecf664d07dbe1", log.Requester,
|
||||
"requester stays the opaque session subject for backward compatibility")
|
||||
assert.Equal(t, "grant.west", log.RequesterIdentity,
|
||||
"audit entry must surface the authoritative OIDC identity claim, not the client-supplied role session name")
|
||||
}
|
||||
|
||||
// A non-federated STS session has no OIDC identity. ValidateJWTWithClaims merges
|
||||
// the JWT registered sub claim (the opaque session id) into RequestContext, so
|
||||
// the context carries a sub even though it is not an OIDC subject. The audit
|
||||
// entry must not surface that session id as a requester_identity — ParentUser
|
||||
// gates the resolution so only federated sessions report an identity claim.
|
||||
func TestAuditRequesterIdentityEmptyForNonFederatedSession(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{
|
||||
iamIntegration: &MockIAMIntegration{
|
||||
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
|
||||
return &sts.SessionInfo{
|
||||
AssumedRoleUser: "ClientRole/dev-session",
|
||||
Principal: "arn:aws:sts::000000000000:assumed-role/ClientRole/dev-session",
|
||||
Subject: "47ad4828c45b3f337bc3146081ba8f0f",
|
||||
SessionName: "dev-session",
|
||||
Credentials: &sts.Credentials{
|
||||
AccessKeyId: "ASIA0189777d42cba8e2",
|
||||
SecretAccessKey: "secret",
|
||||
},
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
Policies: []string{"ClientPolicy"},
|
||||
// Simulate ValidateJWTWithClaims merging the registered sub
|
||||
// (the session id) into RequestContext for a session that has
|
||||
// no OIDC identity and therefore no ParentUser.
|
||||
RequestContext: map[string]interface{}{
|
||||
"sub": "47ad4828c45b3f337bc3146081ba8f0f",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
|
||||
|
||||
identity, _, errCode := iam.validateSTSSessionToken(outer, "session-token", "ASIA0189777d42cba8e2")
|
||||
require.Equal(t, s3err.ErrNone, errCode)
|
||||
|
||||
iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {})
|
||||
|
||||
log := s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone)
|
||||
assert.Empty(t, log.RequesterIdentity, "non-federated session must not surface the session id as a requester_identity")
|
||||
}
|
||||
|
||||
// A JWT-authenticated identity carries no PrincipalArn of its own — the auth
|
||||
// layer hands the principal over in a request header — so the audit entry has to
|
||||
// resolve the ARN the same way policy evaluation does.
|
||||
func TestAuditRequesterArnForJWTIdentity(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
|
||||
outer := s3_constants.EnsureIdentityHolder(httptest.NewRequest(http.MethodGet, "http://s3/test/", nil))
|
||||
outer.Header.Set(s3_constants.SeaweedFSPrincipalHeader, "arn:aws:sts::000000000000:assumed-role/ClientRole/oidc-session")
|
||||
|
||||
identity := &Identity{Name: "alice", Account: &Account{Id: "alice"}}
|
||||
iam.handleAuthResult(httptest.NewRecorder(), outer, identity, s3err.ErrNone, func(http.ResponseWriter, *http.Request) {})
|
||||
|
||||
log := s3err.GetAccessLog(outer, http.StatusOK, s3err.ErrNone)
|
||||
assert.Equal(t, "alice", log.Requester)
|
||||
assert.Equal(t, "arn:aws:sts::000000000000:assumed-role/ClientRole/oidc-session", log.RequesterArn)
|
||||
}
|
||||
|
||||
// The AssumeRole call itself is authenticated inside the STS handler, which the
|
||||
// generic auth middleware never wraps; without recording the caller there the
|
||||
// audit entry for minting a session has no requester at all.
|
||||
func TestAuditRequesterForAssumeRole(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
manager := newTestSTSIntegrationManager(t)
|
||||
|
||||
require.NoError(t, manager.CreatePolicy(ctx, "", "ClientPolicy", &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:*"},
|
||||
Resource: []string{"arn:aws:s3:::*", "arn:aws:s3:::*/*"},
|
||||
}},
|
||||
}))
|
||||
require.NoError(t, manager.CreateRole(ctx, "", "ClientRole", &integration.RoleDefinition{
|
||||
RoleName: "ClientRole",
|
||||
TrustPolicy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{{Effect: "Allow", Principal: "*", Action: []string{"sts:AssumeRole"}}},
|
||||
},
|
||||
AttachedPolicies: []string{"ClientPolicy"},
|
||||
}))
|
||||
|
||||
const accessKey, secretKey = "adminkey", "adminsecret"
|
||||
iam := &IdentityAccessManagement{iamIntegration: NewS3IAMIntegration(manager, "")}
|
||||
require.NoError(t, iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{{
|
||||
Name: "admin",
|
||||
Credentials: []*iam_pb.Credential{{AccessKey: accessKey, SecretKey: secretKey}},
|
||||
Actions: []string{"Admin"},
|
||||
}},
|
||||
}))
|
||||
|
||||
body := url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::" + defaultAccountID + ":role/ClientRole"},
|
||||
"RoleSessionName": {"dev-session"},
|
||||
}.Encode()
|
||||
req, err := newTestRequest(http.MethodPost, "http://sts.seaweedfs.test/", int64(len(body)), strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
require.NoError(t, signRequestV4(req, accessKey, secretKey))
|
||||
req = s3_constants.EnsureIdentityHolder(req)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
NewSTSHandlers(manager.GetSTSService(), iam).handleAssumeRole(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
|
||||
log := s3err.GetAccessLog(req, rec.Code, s3err.ErrNone)
|
||||
assert.Equal(t, "admin", log.Requester, "AssumeRole must audit who asked for the session")
|
||||
assert.Equal(t, "arn:aws:iam::"+defaultAccountID+":user/admin", log.RequesterArn)
|
||||
}
|
||||
68
weed/s3api/auth_config_default_deny_test.go
Normal file
68
weed/s3api/auth_config_default_deny_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// A config file the proto parser reads as empty - an unpopulated secret mount,
|
||||
// a singular "identity" - used to leave the gateway serving every anonymous
|
||||
// request, including ListBuckets and bucket creation.
|
||||
func TestConfigWithoutIdentitiesDeniesAnonymous(t *testing.T) {
|
||||
for name, config := range map[string]string{
|
||||
"no identities key": `{}`,
|
||||
"empty": `{"identities":[]}`,
|
||||
"unrecognised key": `{"identity":[{"name":"admin","credentials":[{"accessKey":"adminkey","secretKey":"adminsecret"}],"actions":["Admin"]}]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
clearEnvironmentVariableCredentials(t)
|
||||
|
||||
path := writeTempIamConfig(t, config)
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: path}, nil, "memory")
|
||||
|
||||
assert.True(t, iam.isEnabled(), "naming a config file asks for authentication, even if it yields no identity")
|
||||
|
||||
handlerCalled := false
|
||||
handler := iam.Auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
}, s3_constants.ACTION_LIST)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
|
||||
assert.False(t, handlerCalled, "ListBuckets must not run for an anonymous caller")
|
||||
assert.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// `weed mini` and `docker run seaweedfs` name no config file and stay open.
|
||||
func TestNoConfigKeepsAnonymousAllowed(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
clearEnvironmentVariableCredentials(t)
|
||||
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
|
||||
|
||||
assert.False(t, iam.isEnabled(), "auth must stay off when no config file and no identities are configured")
|
||||
}
|
||||
|
||||
// AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY register an admin identity of
|
||||
// their own, which would decide isAuthEnabled before the config file does.
|
||||
func clearEnvironmentVariableCredentials(t *testing.T) {
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", "")
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", "")
|
||||
}
|
||||
|
||||
// The proto parser drops what it does not recognise, so a typo has to be named
|
||||
// at startup or the resulting lockout has no visible cause.
|
||||
func TestUnknownS3ConfigKeys(t *testing.T) {
|
||||
assert.Equal(t, []string{"identity"}, unknownS3ConfigKeys([]byte(`{"identity":[],"accounts":[]}`)))
|
||||
assert.Empty(t, unknownS3ConfigKeys([]byte(`{"identities":[],"service_accounts":[],"serviceAccounts":[],"policies":[],"groups":[]}`)))
|
||||
assert.Empty(t, unknownS3ConfigKeys([]byte(`{"kms":{},"sts":{},"policy":{},"providers":[],"roles":[]}`)), "sections owned by other subsystems are not typos")
|
||||
assert.Empty(t, unknownS3ConfigKeys([]byte(`not json`)))
|
||||
}
|
||||
304
weed/s3api/auth_copy_source_test.go
Normal file
304
weed/s3api/auth_copy_source_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// newCopyRequest builds a request that mimics what a CopyObject caller sends:
|
||||
// a PUT to the destination bucket/object with X-Amz-Copy-Source pointing at the
|
||||
// source. The destination is what the Auth middleware would have authorized.
|
||||
func newCopyRequest(t *testing.T, dstBucket, dstObject, copySource string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPut, "http://s3.local/"+dstBucket+"/"+dstObject, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-Amz-Copy-Source", copySource)
|
||||
return req
|
||||
}
|
||||
|
||||
// TestAuthorizeCopySource_AuthDisabled verifies the source check is a no-op when
|
||||
// the IAM is not enabled (no identities configured).
|
||||
func TestAuthorizeCopySource_AuthDisabled(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
require.False(t, iam.isEnabled())
|
||||
|
||||
req := newCopyRequest(t, "dst-bucket", "dst-obj", "/src-bucket/src-obj")
|
||||
errCode := iam.AuthorizeCopySource(req, nil, "src-bucket", "src-obj", "")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
}
|
||||
|
||||
// TestAuthorizeCopySource_NilIdentityDenies verifies that with auth enabled we
|
||||
// fail closed if no identity is available.
|
||||
func TestAuthorizeCopySource_NilIdentityDenies(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.identities = []*Identity{{Name: "anyone"}}
|
||||
iam.isAuthEnabled = true
|
||||
require.True(t, iam.isEnabled())
|
||||
|
||||
req := newCopyRequest(t, "dst-bucket", "dst-obj", "/src-bucket/src-obj")
|
||||
errCode := iam.AuthorizeCopySource(req, nil, "src-bucket", "src-obj", "")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
}
|
||||
|
||||
// TestAuthorizeCopySource_AdminAllowed verifies admin identities bypass the
|
||||
// source check.
|
||||
func TestAuthorizeCopySource_AdminAllowed(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.identities = []*Identity{{Name: "admin", Actions: []Action{s3_constants.ACTION_ADMIN}}}
|
||||
iam.isAuthEnabled = true
|
||||
|
||||
admin := &Identity{Name: "admin", Account: &AccountAdmin, Actions: []Action{s3_constants.ACTION_ADMIN}}
|
||||
req := newCopyRequest(t, "dst-bucket", "dst-obj", "/src-bucket/src-obj")
|
||||
errCode := iam.AuthorizeCopySource(req, admin, "src-bucket", "src-obj", "")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
}
|
||||
|
||||
// TestAuthorizeCopySource_PrefixScopedIdentity checks that an identity scoped
|
||||
// to prefix-a/* is denied when copying from prefix-b/*, even when its
|
||||
// destination (prefix-a/*) write permission is fine.
|
||||
func TestAuthorizeCopySource_PrefixScopedIdentity(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.isAuthEnabled = true
|
||||
|
||||
scoped := &Identity{
|
||||
Name: "scoped",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{
|
||||
Action("Read:bucket-a/prefix-a/*"),
|
||||
Action("Write:bucket-a/prefix-a/*"),
|
||||
},
|
||||
}
|
||||
iam.identities = []*Identity{scoped}
|
||||
|
||||
t.Run("source within scope is allowed", func(t *testing.T) {
|
||||
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-a/src.bin")
|
||||
errCode := iam.AuthorizeCopySource(req, scoped, "bucket-a", "prefix-a/src.bin", "")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
})
|
||||
|
||||
t.Run("source outside scope is denied", func(t *testing.T) {
|
||||
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-b/src.bin")
|
||||
errCode := iam.AuthorizeCopySource(req, scoped, "bucket-a", "prefix-b/src.bin", "")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
})
|
||||
|
||||
t.Run("source in different bucket is denied", func(t *testing.T) {
|
||||
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/other-bucket/src.bin")
|
||||
errCode := iam.AuthorizeCopySource(req, scoped, "other-bucket", "src.bin", "")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAuthorizeCopySource_IAMIntegrationGetsSourceResource verifies that the
|
||||
// STS / IAM path is invoked with the SOURCE bucket/object and a GetObject-style
|
||||
// action — not the destination from the request URL.
|
||||
func TestAuthorizeCopySource_IAMIntegrationGetsSourceResource(t *testing.T) {
|
||||
var capturedAction Action
|
||||
var capturedBucket, capturedObject string
|
||||
var capturedMethod string
|
||||
var capturedURLPath string
|
||||
|
||||
mock := &MockIAMIntegration{
|
||||
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
// Resolve the action like S3IAMIntegration.AuthorizeAction would,
|
||||
// so the assertion reflects the s3:* the policy engine sees.
|
||||
capturedAction = Action(ResolveS3Action(r, string(action), bucket, object))
|
||||
capturedBucket = bucket
|
||||
capturedObject = object
|
||||
if r != nil {
|
||||
capturedMethod = r.Method
|
||||
if r.URL != nil {
|
||||
capturedURLPath = r.URL.Path
|
||||
}
|
||||
}
|
||||
// Deny: simulate a session policy that does not grant Read on src.
|
||||
return s3err.ErrAccessDenied
|
||||
},
|
||||
}
|
||||
|
||||
iam := &IdentityAccessManagement{iamIntegration: mock}
|
||||
iam.identities = []*Identity{{Name: "sts"}}
|
||||
iam.isAuthEnabled = true
|
||||
|
||||
stsIdentity := &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/scopedRole/session",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{}, // STS identity
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/scopedRole/session",
|
||||
}
|
||||
|
||||
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-b/src.bin")
|
||||
req.Header.Set("X-Amz-Security-Token", "test-session-token")
|
||||
|
||||
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "prefix-b/src.bin", "")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
assert.True(t, mock.authCalled, "IAM integration must be invoked for STS source check")
|
||||
|
||||
// Action passed through ResolveS3Action: GET method + ACTION_READ -> s3:GetObject.
|
||||
assert.Equal(t, "s3:GetObject", string(capturedAction))
|
||||
assert.Equal(t, "bucket-a", capturedBucket)
|
||||
assert.Equal(t, "prefix-b/src.bin", capturedObject)
|
||||
assert.Equal(t, http.MethodGet, capturedMethod, "synthetic source request must be a GET")
|
||||
assert.Equal(t, "/bucket-a/prefix-b/src.bin", capturedURLPath, "synthetic URL must target the source")
|
||||
|
||||
// Original request must not be mutated by the source check.
|
||||
assert.Equal(t, http.MethodPut, req.Method)
|
||||
assert.Equal(t, "/bucket-a/prefix-a/dst.bin", req.URL.Path)
|
||||
}
|
||||
|
||||
// TestAuthorizeCopySource_IAMIntegrationAllow verifies the IAM integration can
|
||||
// grant access; the source check then returns ErrNone.
|
||||
func TestAuthorizeCopySource_IAMIntegrationAllow(t *testing.T) {
|
||||
mock := &MockIAMIntegration{
|
||||
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
return s3err.ErrNone
|
||||
},
|
||||
}
|
||||
|
||||
iam := &IdentityAccessManagement{iamIntegration: mock}
|
||||
iam.identities = []*Identity{{Name: "sts"}}
|
||||
iam.isAuthEnabled = true
|
||||
|
||||
stsIdentity := &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/role/session",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{},
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
|
||||
}
|
||||
|
||||
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-a/src.bin")
|
||||
req.Header.Set("X-Amz-Security-Token", "test-session-token")
|
||||
|
||||
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "prefix-a/src.bin", "")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
}
|
||||
|
||||
// TestAuthorizeCopySource_VersionIdPropagated verifies that when copying a
|
||||
// specific source version, the IAM check sees a versionId on the synthetic
|
||||
// request so that s3:VersionId conditions and s3:GetObjectVersion resolution
|
||||
// work.
|
||||
func TestAuthorizeCopySource_VersionIdPropagated(t *testing.T) {
|
||||
var capturedAction Action
|
||||
var capturedRawQuery string
|
||||
|
||||
mock := &MockIAMIntegration{
|
||||
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
capturedAction = Action(ResolveS3Action(r, string(action), bucket, object))
|
||||
if r != nil && r.URL != nil {
|
||||
capturedRawQuery = r.URL.RawQuery
|
||||
}
|
||||
return s3err.ErrNone
|
||||
},
|
||||
}
|
||||
|
||||
iam := &IdentityAccessManagement{iamIntegration: mock}
|
||||
iam.isAuthEnabled = true
|
||||
|
||||
stsIdentity := &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/role/session",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{},
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
|
||||
}
|
||||
|
||||
req := newCopyRequest(t, "bucket-a", "dst.bin", "/bucket-a/src.bin?versionId=abc123")
|
||||
req.Header.Set("X-Amz-Security-Token", "test-session-token")
|
||||
|
||||
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "src.bin", "abc123")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.Equal(t, "s3:GetObjectVersion", string(capturedAction), "versioned source should resolve to GetObjectVersion")
|
||||
assert.Equal(t, "versionId=abc123", capturedRawQuery)
|
||||
}
|
||||
|
||||
// TestAuthorizeCopySource_PresignedURLSessionTokenPreserved guards against a
|
||||
// regression where the synthetic source request dropped X-Amz-Security-Token
|
||||
// from the original query string. Presigned URLs carry the STS token there
|
||||
// (not in headers), and VerifyActionPermission/authorizeWithIAM use it to
|
||||
// route the request to IAM authorization.
|
||||
func TestAuthorizeCopySource_PresignedURLSessionTokenPreserved(t *testing.T) {
|
||||
var capturedSessionToken string
|
||||
var capturedRawQuery string
|
||||
|
||||
mock := &MockIAMIntegration{
|
||||
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
if r != nil {
|
||||
capturedSessionToken = r.URL.Query().Get("X-Amz-Security-Token")
|
||||
capturedRawQuery = r.URL.RawQuery
|
||||
}
|
||||
// The IAM mock receives the propagated session token via IAMIdentity too.
|
||||
if identity != nil && identity.SessionToken != "" {
|
||||
capturedSessionToken = identity.SessionToken
|
||||
}
|
||||
return s3err.ErrNone
|
||||
},
|
||||
}
|
||||
|
||||
iam := &IdentityAccessManagement{iamIntegration: mock}
|
||||
iam.isAuthEnabled = true
|
||||
|
||||
stsIdentity := &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/role/session",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{},
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
|
||||
}
|
||||
|
||||
// Presigned URL: session token is in the query string, no
|
||||
// X-Amz-Security-Token header. The destination URL also carries unrelated
|
||||
// SigV4 query params (X-Amz-Algorithm, etc.) that must not leak into the
|
||||
// synthetic source action resolution.
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
"http://s3.local/bucket-a/dst.bin?X-Amz-Algorithm=AWS4-HMAC-SHA256"+
|
||||
"&X-Amz-Credential=stsKey%2F20260518%2Fus-east-1%2Fs3%2Faws4_request"+
|
||||
"&X-Amz-Date=20260518T000000Z&X-Amz-Expires=900"+
|
||||
"&X-Amz-Security-Token=presigned-session-token"+
|
||||
"&X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-Amz-Copy-Source", "/bucket-a/src.bin")
|
||||
|
||||
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "src.bin", "")
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.Equal(t, "presigned-session-token", capturedSessionToken,
|
||||
"session token from presigned-URL query must reach the IAM check")
|
||||
// versionId omitted, only the token should be present in the synthetic query.
|
||||
assert.Equal(t, "X-Amz-Security-Token=presigned-session-token", capturedRawQuery)
|
||||
}
|
||||
|
||||
// TestAuthorizeCopySource_PreservesCopySourceHeader verifies that the synthetic
|
||||
// source request still carries the X-Amz-Copy-Source header so policy condition
|
||||
// keys like s3:x-amz-copy-source remain available.
|
||||
func TestAuthorizeCopySource_PreservesCopySourceHeader(t *testing.T) {
|
||||
var capturedCopySource string
|
||||
|
||||
mock := &MockIAMIntegration{
|
||||
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
if r != nil {
|
||||
capturedCopySource = r.Header.Get("X-Amz-Copy-Source")
|
||||
}
|
||||
return s3err.ErrNone
|
||||
},
|
||||
}
|
||||
|
||||
iam := &IdentityAccessManagement{iamIntegration: mock}
|
||||
iam.isAuthEnabled = true
|
||||
|
||||
stsIdentity := &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/role/session",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{},
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
|
||||
}
|
||||
|
||||
req := newCopyRequest(t, "bucket-a", "dst.bin", "/bucket-a/src.bin")
|
||||
req.Header.Set("X-Amz-Security-Token", "test-session-token")
|
||||
|
||||
_ = iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "src.bin", "")
|
||||
assert.Equal(t, "/bucket-a/src.bin", capturedCopySource)
|
||||
}
|
||||
3188
weed/s3api/auth_credentials.go
Normal file
3188
weed/s3api/auth_credentials.go
Normal file
File diff suppressed because it is too large
Load Diff
126
weed/s3api/auth_credentials_native_floor_test.go
Normal file
126
weed/s3api/auth_credentials_native_floor_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newNativeFloorTestIAM(t *testing.T) (*IdentityAccessManagement, *Identity) {
|
||||
t.Helper()
|
||||
mgr := newTestIAMManager(t)
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
|
||||
|
||||
doc, _ := json.Marshal(map[string]interface{}{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]interface{}{
|
||||
{"Effect": "Allow", "Action": "s3:ListAllMyBuckets", "Resource": "*"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, iam.PutPolicy("ListBucketsOnly", string(doc)))
|
||||
|
||||
identity := &Identity{
|
||||
Name: "admin",
|
||||
Account: &Account{DisplayName: "admin", Id: "admin"},
|
||||
Actions: []Action{s3_constants.ACTION_ADMIN},
|
||||
PolicyNames: []string{"ListBucketsOnly"},
|
||||
PrincipalArn: "arn:aws:iam::111122223333:user/admin",
|
||||
}
|
||||
return iam, identity
|
||||
}
|
||||
|
||||
func putObjectRequest() *http.Request {
|
||||
return httptest.NewRequest(http.MethodPut, "/mybucket/file.txt", nil)
|
||||
}
|
||||
|
||||
// TestNativeAdminSurvivesAttachedPolicy reproduces issue #11226: attaching an
|
||||
// IAM policy to a user with native Admin must not override the native grant.
|
||||
// The effective permissions are the union of native permissions and attached
|
||||
// policy grants, so a Write the attached policy never mentions still succeeds.
|
||||
func TestNativeAdminSurvivesAttachedPolicy(t *testing.T) {
|
||||
iam, identity := newNativeFloorTestIAM(t)
|
||||
|
||||
errCode := iam.VerifyActionPermission(putObjectRequest(), identity,
|
||||
s3_constants.ACTION_WRITE, "mybucket", "file.txt")
|
||||
assert.Equal(t, s3err.ErrNone, errCode,
|
||||
"native Admin must remain effective after attaching a policy")
|
||||
}
|
||||
|
||||
// TestNativeAdminSurvivesDeletedPolicy reproduces the second half of #11226:
|
||||
// deleting the attached policy without detaching it first must not leave the
|
||||
// user locked out of their native permissions.
|
||||
func TestNativeAdminSurvivesDeletedPolicy(t *testing.T) {
|
||||
iam, identity := newNativeFloorTestIAM(t)
|
||||
|
||||
require.NoError(t, iam.DeletePolicy("ListBucketsOnly"))
|
||||
|
||||
errCode := iam.VerifyActionPermission(putObjectRequest(), identity,
|
||||
s3_constants.ACTION_WRITE, "mybucket", "file.txt")
|
||||
assert.Equal(t, s3err.ErrNone, errCode,
|
||||
"native Admin must remain effective after the attached policy is deleted")
|
||||
}
|
||||
|
||||
// TestNativeAdminSurvivesAttachedPolicyWithoutPrincipalArn covers the IAM
|
||||
// integration path when the Admin identity has no PrincipalArn (and no session
|
||||
// token), so the auth-path switch would otherwise deny before the native floor.
|
||||
func TestNativeAdminSurvivesAttachedPolicyWithoutPrincipalArn(t *testing.T) {
|
||||
mgr := newTestIAMManager(t)
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
|
||||
|
||||
doc, _ := json.Marshal(map[string]interface{}{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]interface{}{
|
||||
{"Effect": "Allow", "Action": "s3:ListAllMyBuckets", "Resource": "*"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, iam.PutPolicy("ListBucketsOnly", string(doc)))
|
||||
|
||||
identity := &Identity{
|
||||
Name: "admin",
|
||||
Account: &Account{DisplayName: "admin", Id: "admin"},
|
||||
Actions: []Action{s3_constants.ACTION_ADMIN},
|
||||
PolicyNames: []string{"ListBucketsOnly"},
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(putObjectRequest(), identity,
|
||||
s3_constants.ACTION_WRITE, "mybucket", "file.txt")
|
||||
assert.Equal(t, s3err.ErrNone, errCode,
|
||||
"native Admin must remain effective without a PrincipalArn")
|
||||
}
|
||||
|
||||
// TestAttachedPolicyExplicitDenyOverridesNativeAdmin ensures deny-always-wins:
|
||||
// an explicit Deny in an attached policy still constrains a native admin.
|
||||
func TestAttachedPolicyExplicitDenyOverridesNativeAdmin(t *testing.T) {
|
||||
mgr := newTestIAMManager(t)
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
|
||||
|
||||
doc, _ := json.Marshal(map[string]interface{}{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]interface{}{
|
||||
{"Effect": "Deny", "Action": "s3:PutObject", "Resource": "arn:aws:s3:::mybucket/*"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, iam.PutPolicy("DenyPutMyBucket", string(doc)))
|
||||
|
||||
identity := &Identity{
|
||||
Name: "admin",
|
||||
Account: &Account{DisplayName: "admin", Id: "admin"},
|
||||
Actions: []Action{s3_constants.ACTION_ADMIN},
|
||||
PolicyNames: []string{"DenyPutMyBucket"},
|
||||
PrincipalArn: "arn:aws:iam::111122223333:user/admin",
|
||||
}
|
||||
|
||||
errCode := iam.VerifyActionPermission(putObjectRequest(), identity,
|
||||
s3_constants.ACTION_WRITE, "mybucket", "file.txt")
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode,
|
||||
"explicit Deny in an attached policy must override native Admin")
|
||||
}
|
||||
84
weed/s3api/auth_credentials_policy_document.go
Normal file
84
weed/s3api/auth_credentials_policy_document.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// normalizeAdvancedIAMPolicies rewrites policies written in the advanced IAM
|
||||
// form ({"name": ..., "document": {...}}) into the S3 config form
|
||||
// ({"name": ..., "content": "{...}"}).
|
||||
//
|
||||
// The advanced IAM file given by -s3.iam.config is also parsed as the S3
|
||||
// identity config when no -s3.config is given, and protojson drops the unknown
|
||||
// "document" field. That leaves a policy with empty content, which fails every
|
||||
// later parse and takes the whole runtime policy sync down with it.
|
||||
func normalizeAdvancedIAMPolicies(configContent []byte) []byte {
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(configContent, &root); err != nil {
|
||||
return configContent
|
||||
}
|
||||
rawPolicies, found := root["policies"]
|
||||
if !found {
|
||||
return configContent
|
||||
}
|
||||
var policies []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawPolicies, &policies); err != nil {
|
||||
return configContent
|
||||
}
|
||||
|
||||
rewritten := false
|
||||
for _, policy := range policies {
|
||||
document, hasDocument := policy["document"]
|
||||
if !hasDocument || hasPolicyContent(policy) {
|
||||
continue
|
||||
}
|
||||
// A document already written as a JSON string is the content verbatim;
|
||||
// an inline object becomes the JSON encoding of its own bytes. Nothing
|
||||
// is mutated until the content is in hand, so a failure leaves the
|
||||
// policy as it was rather than stripping its only definition.
|
||||
content := document
|
||||
if !isJSONString(document) {
|
||||
encoded, err := json.Marshal(string(document))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content = encoded
|
||||
}
|
||||
delete(policy, "document")
|
||||
policy["content"] = content
|
||||
rewritten = true
|
||||
}
|
||||
if !rewritten {
|
||||
return configContent
|
||||
}
|
||||
|
||||
encodedPolicies, err := json.Marshal(policies)
|
||||
if err != nil {
|
||||
return configContent
|
||||
}
|
||||
root["policies"] = encodedPolicies
|
||||
normalized, err := json.Marshal(root)
|
||||
if err != nil {
|
||||
return configContent
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func hasPolicyContent(policy map[string]json.RawMessage) bool {
|
||||
raw, found := policy["content"]
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
var content string
|
||||
if err := json.Unmarshal(raw, &content); err != nil {
|
||||
// Not a string: leave whatever it is for the proto parser to reject.
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(content) != ""
|
||||
}
|
||||
|
||||
func isJSONString(raw json.RawMessage) bool {
|
||||
var s string
|
||||
return json.Unmarshal(raw, &s) == nil
|
||||
}
|
||||
83
weed/s3api/auth_credentials_policy_document_test.go
Normal file
83
weed/s3api/auth_credentials_policy_document_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The advanced IAM file doubles as the S3 identity config when only
|
||||
// -s3.iam.config is given, so its "document" policies must survive the parse.
|
||||
func TestLoadAdvancedIAMConfigPolicyDocument(t *testing.T) {
|
||||
config := []byte(`{
|
||||
"sts": {"issuer": "seaweedfs-sts"},
|
||||
"policies": [
|
||||
{
|
||||
"name": "ClientPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{"Effect": "Allow", "Action": ["s3:*"], "Resource": ["*"]}]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
parsed := &iam_pb.S3ApiConfiguration{}
|
||||
require.NoError(t, filer.ParseS3ConfigurationFromBytes(normalizeAdvancedIAMPolicies(config), parsed))
|
||||
require.Len(t, parsed.Policies, 1)
|
||||
require.Equal(t, "ClientPolicy", parsed.Policies[0].Name)
|
||||
|
||||
var document map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(parsed.Policies[0].Content), &document))
|
||||
require.Equal(t, "2012-10-17", document["Version"])
|
||||
}
|
||||
|
||||
func TestNormalizeAdvancedIAMPolicies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
config string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "document object",
|
||||
config: `{"policies":[{"name":"p","document":{"Version":"2012-10-17"}}]}`,
|
||||
content: `{"Version":"2012-10-17"}`,
|
||||
},
|
||||
{
|
||||
name: "document already encoded as a string",
|
||||
config: `{"policies":[{"name":"p","document":"{\"Version\":\"2012-10-17\"}"}]}`,
|
||||
content: `{"Version":"2012-10-17"}`,
|
||||
},
|
||||
{
|
||||
name: "content wins over document",
|
||||
config: `{"policies":[{"name":"p","content":"{\"Version\":\"keep\"}","document":{"Version":"drop"}}]}`,
|
||||
content: `{"Version":"keep"}`,
|
||||
},
|
||||
{
|
||||
name: "content only",
|
||||
config: `{"policies":[{"name":"p","content":"{\"Version\":\"keep\"}"}]}`,
|
||||
content: `{"Version":"keep"}`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
parsed := &iam_pb.S3ApiConfiguration{}
|
||||
require.NoError(t, filer.ParseS3ConfigurationFromBytes(normalizeAdvancedIAMPolicies([]byte(tc.config)), parsed))
|
||||
require.Len(t, parsed.Policies, 1)
|
||||
require.JSONEq(t, tc.content, parsed.Policies[0].Content)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Anything that is not a policy list is handed to the proto parser untouched.
|
||||
func TestNormalizeAdvancedIAMPoliciesLeavesOtherConfigsAlone(t *testing.T) {
|
||||
for _, config := range []string{
|
||||
`not json`,
|
||||
`{"identities":[{"name":"admin"}]}`,
|
||||
`{"policies":{"p":{"document":{}}}}`,
|
||||
} {
|
||||
require.Equal(t, config, string(normalizeAdvancedIAMPolicies([]byte(config))))
|
||||
}
|
||||
}
|
||||
223
weed/s3api/auth_credentials_policy_sync_test.go
Normal file
223
weed/s3api/auth_credentials_policy_sync_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestIAMManager(t *testing.T) *integration.IAMManager {
|
||||
t.Helper()
|
||||
mgr := integration.NewIAMManager()
|
||||
require.NoError(t, mgr.Initialize(&integration.IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test",
|
||||
SigningKey: []byte("test-signing-key-32-characters!!"),
|
||||
AccountId: "111122223333",
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Allow", StoreType: "memory"},
|
||||
Roles: &integration.RoleStoreConfig{StoreType: "memory"},
|
||||
}, func() string { return "localhost:8888" }))
|
||||
return mgr
|
||||
}
|
||||
|
||||
func TestPutPolicy_SyncsToIAMManager(t *testing.T) {
|
||||
mgr := newTestIAMManager(t)
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
|
||||
|
||||
policyDoc, _ := json.Marshal(map[string]interface{}{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]interface{}{
|
||||
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::backup/*"},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, iam.PutPolicy("BackupAll", string(policyDoc)))
|
||||
|
||||
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
|
||||
Principal: "arn:aws:iam::111122223333:user/test",
|
||||
Action: "s3:PutObject",
|
||||
Resource: "arn:aws:s3:::backup/file.txt",
|
||||
PolicyNames: []string{"BackupAll"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, allowed, "PutPolicy should sync to IAM Manager policy engine")
|
||||
}
|
||||
|
||||
func TestDeletePolicy_SyncsToIAMManager(t *testing.T) {
|
||||
mgr := newTestIAMManager(t)
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
|
||||
|
||||
policyDoc, _ := json.Marshal(map[string]interface{}{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]interface{}{
|
||||
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::backup/*"},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, iam.PutPolicy("BackupAll", string(policyDoc)))
|
||||
require.NoError(t, iam.DeletePolicy("BackupAll"))
|
||||
|
||||
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
|
||||
Principal: "arn:aws:iam::111122223333:user/test",
|
||||
Action: "s3:PutObject",
|
||||
Resource: "arn:aws:s3:::backup/file.txt",
|
||||
PolicyNames: []string{"BackupAll"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, allowed, "DeletePolicy should remove from IAM Manager policy engine")
|
||||
}
|
||||
|
||||
// TestSetIAMIntegration_FlushesLoadedPolicies reproduces the startup race: a
|
||||
// policy is loaded into the IAM cache before the integration is attached (so the
|
||||
// sync was skipped), then SetIAMIntegration must flush it into the manager's
|
||||
// engine. Without the flush, policy_names identities get AccessDenied until an
|
||||
// external IAM change triggers a reload.
|
||||
func TestSetIAMIntegration_FlushesLoadedPolicies(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
|
||||
policyDoc, _ := json.Marshal(map[string]interface{}{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]interface{}{
|
||||
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::backup/*"},
|
||||
},
|
||||
})
|
||||
|
||||
// Integration not attached yet, so this only lands in the legacy engine.
|
||||
require.NoError(t, iam.PutPolicy("BackupAll", string(policyDoc)))
|
||||
|
||||
mgr := newTestIAMManager(t)
|
||||
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
|
||||
|
||||
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
|
||||
Principal: "arn:aws:iam::111122223333:user/test",
|
||||
Action: "s3:PutObject",
|
||||
Resource: "arn:aws:s3:::backup/file.txt",
|
||||
PolicyNames: []string{"BackupAll"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, allowed, "SetIAMIntegration should flush already-loaded policies into the IAM Manager")
|
||||
}
|
||||
|
||||
// TestResyncIAMManager_ReflectsCurrentPolicies pins the property that prevents
|
||||
// the stale-snapshot race: SyncRuntimePolicies replaces the full desired set, so
|
||||
// the resync must derive that set from the live iam.policies map at apply time,
|
||||
// not from a view captured earlier. Here the map is mutated out from under an
|
||||
// already-synced policy, and the next resync must converge the manager onto the
|
||||
// new map rather than reinstating the old state.
|
||||
func TestResyncIAMManager_ReflectsCurrentPolicies(t *testing.T) {
|
||||
mgr := newTestIAMManager(t)
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
|
||||
|
||||
doc := func(name string) string {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]interface{}{
|
||||
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::" + name + "/*"},
|
||||
},
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
allows := func(name string) bool {
|
||||
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
|
||||
Principal: "arn:aws:iam::111122223333:user/test",
|
||||
Action: "s3:PutObject",
|
||||
Resource: "arn:aws:s3:::" + name + "/file.txt",
|
||||
PolicyNames: []string{name},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return allowed
|
||||
}
|
||||
|
||||
require.NoError(t, iam.PutPolicy("alpha", doc("alpha")))
|
||||
require.True(t, allows("alpha"))
|
||||
|
||||
// Swap the map contents without going through PutPolicy/DeletePolicy, then
|
||||
// resync. A snapshot-based sync would push a pre-swap view; the fresh-read
|
||||
// resync must mirror the current map: alpha gone, beta present.
|
||||
iam.m.Lock()
|
||||
delete(iam.policies, "alpha")
|
||||
iam.policies["beta"] = &iam_pb.Policy{Name: "beta", Content: doc("beta")}
|
||||
iam.m.Unlock()
|
||||
iam.resyncIAMManagerPolicies()
|
||||
|
||||
require.False(t, allows("alpha"), "resync should drop a policy no longer in the map")
|
||||
require.True(t, allows("beta"), "resync should add a policy newly in the map")
|
||||
}
|
||||
|
||||
func TestResync_SkipsUnparsablePolicy(t *testing.T) {
|
||||
mgr := newTestIAMManager(t)
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
|
||||
|
||||
doc, _ := json.Marshal(map[string]interface{}{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]interface{}{
|
||||
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::good/*"},
|
||||
},
|
||||
})
|
||||
|
||||
iam.m.Lock()
|
||||
iam.policies = map[string]*iam_pb.Policy{
|
||||
"broken": {Name: "broken"},
|
||||
"good": {Name: "good", Content: string(doc)},
|
||||
}
|
||||
iam.m.Unlock()
|
||||
iam.resyncIAMManagerPolicies()
|
||||
|
||||
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
|
||||
Principal: "arn:aws:iam::111122223333:user/test",
|
||||
Action: "s3:PutObject",
|
||||
Resource: "arn:aws:s3:::good/file.txt",
|
||||
PolicyNames: []string{"good"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, allowed, "an unparsable policy must not block the rest of the sync")
|
||||
}
|
||||
|
||||
// A policy whose stored definition stops parsing must stop granting access:
|
||||
// enforcing a document the operator can no longer see is worse than denying.
|
||||
func TestResync_UnparsablePolicyStopsGranting(t *testing.T) {
|
||||
mgr := newTestIAMManager(t)
|
||||
iam := &IdentityAccessManagement{}
|
||||
iam.SetIAMIntegration(NewS3IAMIntegration(mgr, ""))
|
||||
|
||||
doc, _ := json.Marshal(map[string]interface{}{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]interface{}{
|
||||
{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::good/*"},
|
||||
},
|
||||
})
|
||||
allows := func() bool {
|
||||
allowed, err := mgr.IsActionAllowed(context.Background(), &integration.ActionRequest{
|
||||
Principal: "arn:aws:iam::111122223333:user/test",
|
||||
Action: "s3:PutObject",
|
||||
Resource: "arn:aws:s3:::good/file.txt",
|
||||
PolicyNames: []string{"good"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return allowed
|
||||
}
|
||||
|
||||
require.NoError(t, iam.PutPolicy("good", string(doc)))
|
||||
require.True(t, allows())
|
||||
|
||||
iam.m.Lock()
|
||||
iam.policies["good"] = &iam_pb.Policy{Name: "good", Content: "{not json"}
|
||||
iam.m.Unlock()
|
||||
iam.resyncIAMManagerPolicies()
|
||||
|
||||
require.False(t, allows(), "an unparsable policy must not keep granting its old permissions")
|
||||
}
|
||||
473
weed/s3api/auth_credentials_static_config_test.go
Normal file
473
weed/s3api/auth_credentials_static_config_test.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential"
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
)
|
||||
|
||||
// An advanced -iam.config file (STS/OIDC/roles) carries no inline identities, so the
|
||||
// server must not enter static-config mode. Otherwise it freezes live reloads and
|
||||
// filer-backed identities created at runtime (e.g. by the operator's IAM CRDs) never
|
||||
// take effect.
|
||||
func TestIamConfigWithoutIdentitiesIsNotStatic(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
path := writeTempIamConfig(t, `{"sts":{"signingKey":"dGVzdC1zaWduaW5nLWtleQ=="}}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to load advanced iam config: %v", err)
|
||||
}
|
||||
|
||||
if s3a.iam.IsStaticConfig() {
|
||||
t.Fatalf("advanced iam config without identities must not be treated as static")
|
||||
}
|
||||
|
||||
// A filer change (operator creating a user) must still reload at runtime.
|
||||
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
|
||||
t.Fatalf("failed to create alice: %v", err)
|
||||
}
|
||||
if err := s3a.onIamConfigChange(filer.IamConfigDirectory+"/identities", nil, &filer_pb.Entry{Name: "alice.json"}); err != nil {
|
||||
t.Fatalf("onIamConfigChange returned error: %v", err)
|
||||
}
|
||||
waitForIdentity(t, s3a.iam, "alice")
|
||||
}
|
||||
|
||||
// A -config identity file protects its identities but must not block live
|
||||
// delivery of filer-managed identities to a running gateway.
|
||||
func TestConfigWithIdentitiesStillLiveReloadsDynamic(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
path := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKIAITEST","secretKey":"c2VjcmV0"}],"actions":["Admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to load identity config: %v", err)
|
||||
}
|
||||
|
||||
if !s3a.iam.IsStaticConfig() {
|
||||
t.Fatalf("config file with inline identities must be treated as static")
|
||||
}
|
||||
|
||||
s3a.iam.m.RLock()
|
||||
id := s3a.iam.nameToIdentity["static-admin"]
|
||||
s3a.iam.m.RUnlock()
|
||||
if id == nil || !id.IsStatic {
|
||||
t.Fatalf("expected static-admin to be marked static")
|
||||
}
|
||||
|
||||
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
|
||||
t.Fatalf("failed to create alice: %v", err)
|
||||
}
|
||||
if err := s3a.onIamConfigChange(filer.IamConfigDirectory+"/identities", nil, &filer_pb.Entry{Name: "alice.json"}); err != nil {
|
||||
t.Fatalf("onIamConfigChange returned error: %v", err)
|
||||
}
|
||||
waitForIdentity(t, s3a.iam, "alice")
|
||||
if !hasIdentity(s3a.iam, "static-admin") {
|
||||
t.Fatalf("static-admin must survive the dynamic reload")
|
||||
}
|
||||
|
||||
// deletion on the filer must revoke on the running gateway too
|
||||
if err := s3a.iam.credentialManager.DeleteUser(context.Background(), "alice"); err != nil {
|
||||
t.Fatalf("failed to delete alice: %v", err)
|
||||
}
|
||||
if err := s3a.onIamConfigChange(filer.IamConfigDirectory+"/identities", &filer_pb.Entry{Name: "alice.json"}, nil); err != nil {
|
||||
t.Fatalf("onIamConfigChange returned error: %v", err)
|
||||
}
|
||||
waitForIdentityGone(t, s3a.iam, "alice")
|
||||
if !hasIdentity(s3a.iam, "static-admin") {
|
||||
t.Fatalf("static-admin must survive the deletion reload")
|
||||
}
|
||||
}
|
||||
|
||||
// A single pushed identity (PutIdentity) is a partial merge and must not wipe
|
||||
// other dynamic identities or a dynamic anonymous identity.
|
||||
func TestUpsertIdentityKeepsOtherDynamicIdentities(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
path := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKIAITEST","secretKey":"c2VjcmV0"}],"actions":["Admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to load identity config: %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"alice", "anonymous"} {
|
||||
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: name}); err != nil {
|
||||
t.Fatalf("failed to create %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := s3a.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil {
|
||||
t.Fatalf("failed to load from credential manager: %v", err)
|
||||
}
|
||||
|
||||
if err := s3a.iam.UpsertIdentity(&iam_pb.Identity{Name: "bob", Actions: []string{"Read"}}); err != nil {
|
||||
t.Fatalf("failed to upsert bob: %v", err)
|
||||
}
|
||||
for _, name := range []string{"static-admin", "alice", "anonymous", "bob"} {
|
||||
if !hasIdentity(s3a.iam, name) {
|
||||
t.Fatalf("expected %s to survive a partial upsert", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reloading the static config file (grace.OnReload) must mark newly added
|
||||
// identities as static so dynamic filer updates can't overwrite them, while
|
||||
// leaving already-loaded dynamic (filer-managed) identities untouched.
|
||||
func TestReloadStaticConfigMarksNewIdentitiesWithoutFreezingDynamic(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
p1 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"c2VjcmV0"}],"actions":["Admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(p1); err != nil {
|
||||
t.Fatalf("failed to load initial config: %v", err)
|
||||
}
|
||||
|
||||
// A dynamic identity arrives from the filer; merge mode keeps it dynamic.
|
||||
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
|
||||
t.Fatalf("failed to create alice: %v", err)
|
||||
}
|
||||
if err := s3a.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil {
|
||||
t.Fatalf("failed to load from credential manager: %v", err)
|
||||
}
|
||||
if !hasIdentity(s3a.iam, "alice") {
|
||||
t.Fatalf("expected alice to load dynamically")
|
||||
}
|
||||
|
||||
// Reload the static file with a new identity bob.
|
||||
p2 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"c2VjcmV0"}],"actions":["Admin"]},{"name":"bob","credentials":[{"accessKey":"AKBOB000","secretKey":"c2VjcmV0"}],"actions":["Read"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(p2); err != nil {
|
||||
t.Fatalf("failed to reload config: %v", err)
|
||||
}
|
||||
|
||||
if !isStaticName(s3a.iam, "bob") {
|
||||
t.Fatalf("expected reloaded identity bob to be marked static")
|
||||
}
|
||||
if isStaticName(s3a.iam, "alice") {
|
||||
t.Fatalf("dynamic identity alice must not be frozen as static by a config reload")
|
||||
}
|
||||
}
|
||||
|
||||
// A config-file reload must apply an edited secretKey to its static identity.
|
||||
func TestReloadStaticConfigUpdatesExistingSecretKey(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
p1 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"b2xkc2VjcmV0"}],"actions":["Admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(p1); err != nil {
|
||||
t.Fatalf("failed to load initial config: %v", err)
|
||||
}
|
||||
|
||||
_, cred, found := s3a.iam.lookupByAccessKey("AKADMIN0")
|
||||
if !found || cred.SecretKey != "b2xkc2VjcmV0" {
|
||||
t.Fatalf("expected initial secretKey to load, got found=%v cred=%+v", found, cred)
|
||||
}
|
||||
|
||||
// Rotate the secretKey in the file and reload.
|
||||
p2 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"bmV3c2VjcmV0"}],"actions":["Admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(p2); err != nil {
|
||||
t.Fatalf("failed to reload config: %v", err)
|
||||
}
|
||||
|
||||
_, cred, found = s3a.iam.lookupByAccessKey("AKADMIN0")
|
||||
if !found {
|
||||
t.Fatalf("static-admin access key disappeared after reload")
|
||||
}
|
||||
if cred.SecretKey != "bmV3c2VjcmV0" {
|
||||
t.Fatalf("expected reloaded secretKey bmV3c2VjcmV0, got %q", cred.SecretKey)
|
||||
}
|
||||
if !isStaticName(s3a.iam, "static-admin") {
|
||||
t.Fatalf("static-admin must stay marked static after reload")
|
||||
}
|
||||
}
|
||||
|
||||
// A reload must also reapply a service-account credential under a static parent.
|
||||
func TestReloadStaticConfigUpdatesServiceAccountSecret(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
p1 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"YWRtaW4="}],"actions":["Admin"]}],"serviceAccounts":[{"id":"sa-1","parentUser":"static-admin","credential":{"accessKey":"AKSA0001","secretKey":"b2xkc2E="}}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(p1); err != nil {
|
||||
t.Fatalf("failed to load initial config: %v", err)
|
||||
}
|
||||
if _, cred, found := s3a.iam.lookupByAccessKey("AKSA0001"); !found || cred.SecretKey != "b2xkc2E=" {
|
||||
t.Fatalf("expected service account secret to load, got found=%v cred=%+v", found, cred)
|
||||
}
|
||||
|
||||
// Rotate the service account secret in the file and reload.
|
||||
p2 := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKADMIN0","secretKey":"YWRtaW4="}],"actions":["Admin"]}],"serviceAccounts":[{"id":"sa-1","parentUser":"static-admin","credential":{"accessKey":"AKSA0001","secretKey":"bmV3c2E="}}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(p2); err != nil {
|
||||
t.Fatalf("failed to reload config: %v", err)
|
||||
}
|
||||
_, cred, found := s3a.iam.lookupByAccessKey("AKSA0001")
|
||||
if !found {
|
||||
t.Fatalf("service account access key disappeared after reload")
|
||||
}
|
||||
if cred.SecretKey != "bmV3c2E=" {
|
||||
t.Fatalf("expected reloaded service account secret bmV3c2E=, got %q", cred.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
// A full snapshot reconciles policy and group deletions, static-file policies
|
||||
// survive, and groups in a static config file are ignored: the dynamic store
|
||||
// is the only source of groups.
|
||||
func TestFullStateMergeReconcilesPoliciesAndGroups(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
path := writeTempIamConfig(t, `{"identities":[{"name":"static-admin","credentials":[{"accessKey":"AKIAITEST","secretKey":"c2VjcmV0"}],"actions":["Admin"]}],"policies":[{"name":"file-policy","content":"{}"}],"groups":[{"name":"file-group","members":["static-admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to load identity config: %v", err)
|
||||
}
|
||||
if hasGroup(s3a.iam, "file-group") {
|
||||
t.Fatalf("groups in a static config file must be ignored")
|
||||
}
|
||||
|
||||
full := &iam_pb.S3ApiConfiguration{
|
||||
Policies: []*iam_pb.Policy{{Name: "dynamic-policy", Content: "{}"}},
|
||||
Groups: []*iam_pb.Group{{Name: "g1"}},
|
||||
}
|
||||
if err := s3a.iam.MergeS3ApiConfiguration(full, false, true); err != nil {
|
||||
t.Fatalf("full merge failed: %v", err)
|
||||
}
|
||||
if !hasPolicy(s3a.iam, "file-policy") || !hasPolicy(s3a.iam, "dynamic-policy") || !hasGroup(s3a.iam, "g1") {
|
||||
t.Fatalf("expected file-policy, dynamic-policy and g1 after full merge")
|
||||
}
|
||||
|
||||
// partial merge preserves groups and policies
|
||||
if err := s3a.iam.UpsertIdentity(&iam_pb.Identity{Name: "bob"}); err != nil {
|
||||
t.Fatalf("upsert failed: %v", err)
|
||||
}
|
||||
if !hasPolicy(s3a.iam, "dynamic-policy") || !hasGroup(s3a.iam, "g1") {
|
||||
t.Fatalf("partial merge must not drop dynamic-policy or g1")
|
||||
}
|
||||
|
||||
// a static-file reload leaves dynamic groups alone
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to reload config: %v", err)
|
||||
}
|
||||
if !hasGroup(s3a.iam, "g1") {
|
||||
t.Fatalf("dynamic g1 must survive a static-file reload")
|
||||
}
|
||||
|
||||
// empty full snapshot: dynamic policy and last group deleted, file policy stays
|
||||
if err := s3a.iam.MergeS3ApiConfiguration(&iam_pb.S3ApiConfiguration{}, false, true); err != nil {
|
||||
t.Fatalf("empty full merge failed: %v", err)
|
||||
}
|
||||
if hasPolicy(s3a.iam, "dynamic-policy") {
|
||||
t.Fatalf("expected dynamic-policy to be removed by empty full snapshot")
|
||||
}
|
||||
if hasGroup(s3a.iam, "g1") {
|
||||
t.Fatalf("expected g1 to be removed by empty full snapshot")
|
||||
}
|
||||
if !hasPolicy(s3a.iam, "file-policy") {
|
||||
t.Fatalf("file-policy must survive full-state reconciliation")
|
||||
}
|
||||
}
|
||||
|
||||
// flakyStore fails LoadConfiguration a fixed number of times.
|
||||
type flakyStore struct {
|
||||
credential.CredentialStore
|
||||
failures int
|
||||
}
|
||||
|
||||
func (f *flakyStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
|
||||
if f.failures > 0 {
|
||||
f.failures--
|
||||
return nil, fmt.Errorf("transient store failure")
|
||||
}
|
||||
return f.CredentialStore.LoadConfiguration(ctx)
|
||||
}
|
||||
|
||||
// A failed reload queued from an IAM config change must keep retrying until
|
||||
// the store recovers.
|
||||
func TestFailedReloadRetriesUntilSuccess(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
prev := iamReloadRetryInterval
|
||||
iamReloadRetryInterval = 10 * time.Millisecond
|
||||
t.Cleanup(func() { iamReloadRetryInterval = prev })
|
||||
|
||||
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
|
||||
t.Fatalf("failed to create alice: %v", err)
|
||||
}
|
||||
s3a.iam.credentialManager.Store = &flakyStore{CredentialStore: s3a.iam.credentialManager.Store, failures: 2}
|
||||
|
||||
// onIamConfigChange only queues a coalesced reload; the retry loop does the
|
||||
// work and retries through the transient store failures.
|
||||
if err := s3a.onIamConfigChange(filer.IamConfigDirectory+"/identities", nil, &filer_pb.Entry{Name: "alice.json"}); err != nil {
|
||||
t.Fatalf("onIamConfigChange returned error: %v", err)
|
||||
}
|
||||
waitForIdentity(t, s3a.iam, "alice")
|
||||
}
|
||||
|
||||
func hasPolicy(iam *IdentityAccessManagement, name string) bool {
|
||||
iam.m.RLock()
|
||||
defer iam.m.RUnlock()
|
||||
_, ok := iam.policies[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func hasGroup(iam *IdentityAccessManagement, name string) bool {
|
||||
iam.m.RLock()
|
||||
defer iam.m.RUnlock()
|
||||
_, ok := iam.groups[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isStaticName(iam *IdentityAccessManagement, name string) bool {
|
||||
iam.m.RLock()
|
||||
defer iam.m.RUnlock()
|
||||
return iam.staticIdentityNames[name]
|
||||
}
|
||||
|
||||
func writeTempIamConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "iam.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("failed to write temp config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// A static config file may hold ${VAR} in place of a key, so a deployment can
|
||||
// keep the keys in its own secret store and pass them in as environment
|
||||
// variables rather than baking them into the file.
|
||||
func TestStaticConfigExpandsEnvCredentialRefs(t *testing.T) {
|
||||
t.Setenv("SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID", "AKIAFROMENV")
|
||||
t.Setenv("SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY", "secretfromenv")
|
||||
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
path := writeTempIamConfig(t, `{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"${SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID}","secretKey":"${SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to load identity config: %v", err)
|
||||
}
|
||||
|
||||
_, cred, found := s3a.iam.lookupByAccessKey("AKIAFROMENV")
|
||||
if !found {
|
||||
t.Fatalf("expected the access key from the environment to be loaded")
|
||||
}
|
||||
if cred.SecretKey != "secretfromenv" {
|
||||
t.Fatalf("expected the secret key from the environment, got %q", cred.SecretKey)
|
||||
}
|
||||
if _, _, found := s3a.iam.lookupByAccessKey("${SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID}"); found {
|
||||
t.Fatalf("the unexpanded reference must not remain usable as an access key")
|
||||
}
|
||||
}
|
||||
|
||||
// An unset variable must not leave the reference behind as a literal key.
|
||||
func TestStaticConfigDropsUnresolvedEnvCredentialRefs(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
path := writeTempIamConfig(t, `{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"${SEAWEEDFS_S3_MISSING_ACCESS_KEY_ID}","secretKey":"${SEAWEEDFS_S3_MISSING_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to load identity config: %v", err)
|
||||
}
|
||||
|
||||
if _, _, found := s3a.iam.lookupByAccessKey("${SEAWEEDFS_S3_MISSING_ACCESS_KEY_ID}"); found {
|
||||
t.Fatalf("a credential referencing an unset variable must be dropped")
|
||||
}
|
||||
if !hasIdentity(s3a.iam, "anvAdmin") {
|
||||
t.Fatalf("expected the identity itself to still load")
|
||||
}
|
||||
}
|
||||
|
||||
// Keys that merely contain a dollar sign are literal, and identities coming
|
||||
// from the filer are never expanded.
|
||||
func TestEnvCredentialRefsOnlyApplyToStaticConfig(t *testing.T) {
|
||||
t.Setenv("SEAWEEDFS_S3_DYNAMIC_SECRET_ACCESS_KEY", "secretfromenv")
|
||||
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
if err := s3a.iam.LoadS3ApiConfigurationFromBytes([]byte(`{"identities":[{"name":"dynamic","credentials":[{"accessKey":"AKIALITERAL","secretKey":"${SEAWEEDFS_S3_DYNAMIC_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`)); err != nil {
|
||||
t.Fatalf("failed to load dynamic config: %v", err)
|
||||
}
|
||||
|
||||
_, cred, found := s3a.iam.lookupByAccessKey("AKIALITERAL")
|
||||
if !found {
|
||||
t.Fatalf("expected the dynamic identity to load")
|
||||
}
|
||||
if cred.SecretKey != "${SEAWEEDFS_S3_DYNAMIC_SECRET_ACCESS_KEY}" {
|
||||
t.Fatalf("a dynamic identity must keep its secret key verbatim, got %q", cred.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
// A key holding a dollar sign that is not a reference stays untouched.
|
||||
func TestStaticConfigKeepsLiteralDollarSigns(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
path := writeTempIamConfig(t, `{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"AKIALITERAL","secretKey":"pa$$word"}],"actions":["Admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to load identity config: %v", err)
|
||||
}
|
||||
|
||||
_, cred, found := s3a.iam.lookupByAccessKey("AKIALITERAL")
|
||||
if !found {
|
||||
t.Fatalf("expected the identity to load")
|
||||
}
|
||||
if cred.SecretKey != "pa$$word" {
|
||||
t.Fatalf("expected the literal secret key, got %q", cred.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
// A secret store handing over a blank value must not leave an access key that
|
||||
// any signature matches.
|
||||
func TestStaticConfigDropsEmptyEnvCredentialRefs(t *testing.T) {
|
||||
t.Setenv("SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID", "AKIAFROMENV")
|
||||
t.Setenv("SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY", "")
|
||||
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
path := writeTempIamConfig(t, `{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"${SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID}","secretKey":"${SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to load identity config: %v", err)
|
||||
}
|
||||
|
||||
if _, _, found := s3a.iam.lookupByAccessKey("AKIAFROMENV"); found {
|
||||
t.Fatalf("a credential whose secret key resolves to empty must be dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// A reference the substitution cannot match, such as a typo in the variable
|
||||
// name, must not survive as a literal key.
|
||||
func TestStaticConfigDropsMalformedEnvCredentialRefs(t *testing.T) {
|
||||
for _, malformed := range []string{"${MY-VAR}", "${1VAR}", "${}", "${UNTERMINATED", "${A}${B"} {
|
||||
t.Run(malformed, func(t *testing.T) {
|
||||
t.Setenv("SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY", "secretfromenv")
|
||||
t.Setenv("A", "a")
|
||||
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
path := writeTempIamConfig(t, fmt.Sprintf(`{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":%q,"secretKey":"${SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`, malformed))
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to load identity config: %v", err)
|
||||
}
|
||||
|
||||
if _, _, found := s3a.iam.lookupByAccessKey(malformed); found {
|
||||
t.Fatalf("%s must not become a usable access key", malformed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A resolved value that happens to contain ${ is still the key the operator set.
|
||||
func TestStaticConfigKeepsBracesComingFromTheEnvironment(t *testing.T) {
|
||||
t.Setenv("SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID", "AKIAFROMENV")
|
||||
t.Setenv("SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY", "pa${ss}word")
|
||||
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{})
|
||||
|
||||
path := writeTempIamConfig(t, `{"identities":[{"name":"anvAdmin","credentials":[{"accessKey":"${SEAWEEDFS_S3_ADMIN_ACCESS_KEY_ID}","secretKey":"${SEAWEEDFS_S3_ADMIN_SECRET_ACCESS_KEY}"}],"actions":["Admin"]}]}`)
|
||||
if err := s3a.iam.loadS3ApiConfigurationFromFile(path); err != nil {
|
||||
t.Fatalf("failed to load identity config: %v", err)
|
||||
}
|
||||
|
||||
_, cred, found := s3a.iam.lookupByAccessKey("AKIAFROMENV")
|
||||
if !found {
|
||||
t.Fatalf("expected the identity to load")
|
||||
}
|
||||
if cred.SecretKey != "pa${ss}word" {
|
||||
t.Fatalf("expected the secret key from the environment verbatim, got %q", cred.SecretKey)
|
||||
}
|
||||
}
|
||||
227
weed/s3api/auth_credentials_subscribe.go
Normal file
227
weed/s3api/auth_credentials_subscribe.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
const oidcProvidersDir = filer.IamConfigDirectory + "/oidc-providers"
|
||||
|
||||
func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, prefix string, directoriesToWatch []string) {
|
||||
|
||||
processEventFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
|
||||
|
||||
message := resp.EventNotification
|
||||
|
||||
// For rename/move operations, NewParentPath contains the destination directory.
|
||||
// We process both source and destination dirs so moves out of watched
|
||||
// directories (e.g., IAM config dirs) are not missed.
|
||||
dir := resp.Directory
|
||||
if message.NewParentPath != "" {
|
||||
dir = message.NewParentPath
|
||||
}
|
||||
|
||||
// Handle all metadata changes (create, update, delete, rename)
|
||||
// These handlers check for nil entries internally
|
||||
_ = s3a.onBucketMetadataChange(dir, message.OldEntry, message.NewEntry)
|
||||
_ = s3a.onIamConfigChange(dir, message.OldEntry, message.NewEntry)
|
||||
_ = s3a.onOIDCProviderChange(dir, message.OldEntry, message.NewEntry)
|
||||
_ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, message.NewEntry)
|
||||
|
||||
// For moves across directories, replay a delete event for the source directory
|
||||
if message.NewParentPath != "" && resp.Directory != message.NewParentPath {
|
||||
_ = s3a.onBucketMetadataChange(resp.Directory, message.OldEntry, nil)
|
||||
_ = s3a.onIamConfigChange(resp.Directory, message.OldEntry, nil)
|
||||
_ = s3a.onOIDCProviderChange(resp.Directory, message.OldEntry, nil)
|
||||
_ = s3a.onCircuitBreakerConfigChange(resp.Directory, message.OldEntry, nil)
|
||||
}
|
||||
|
||||
// For same-directory renames, replay a delete event for the old name
|
||||
// so handlers can clean up stale state (e.g., old bucket names)
|
||||
if message.OldEntry != nil && message.NewEntry != nil &&
|
||||
(message.NewParentPath == "" || message.NewParentPath == resp.Directory) &&
|
||||
message.OldEntry.Name != message.NewEntry.Name {
|
||||
_ = s3a.onBucketMetadataChange(dir, message.OldEntry, nil)
|
||||
_ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
metadataFollowOption := &pb.MetadataFollowOption{
|
||||
ClientName: clientName,
|
||||
ClientId: s3a.randomClientId,
|
||||
ClientEpoch: 1,
|
||||
SelfSignature: 0,
|
||||
PathPrefix: prefix,
|
||||
AdditionalPathPrefixes: nil,
|
||||
DirectoriesToWatch: directoriesToWatch,
|
||||
StartTsNs: lastTsNs,
|
||||
StopTsNs: 0,
|
||||
EventErrorType: pb.FatalOnError,
|
||||
}
|
||||
util.RetryUntil("followIamChanges", func() error {
|
||||
metadataFollowOption.ClientEpoch++
|
||||
return pb.WithFilerClientFollowMetadata(s3a, metadataFollowOption, processEventFn)
|
||||
}, func(err error) bool {
|
||||
glog.V(1).Infof("iam follow metadata changes: %v", err)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// onIamConfigChange handles IAM config file changes (create, update, delete).
|
||||
// It reloads even with a static -config file: the merge protects the file's
|
||||
// identities, and the filer->s3 push alone is best-effort.
|
||||
func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
|
||||
if s3a.iam == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Coalesce bursts through the reload queue instead of one full synchronous
|
||||
// reload per event: independently-refreshing credentials can rewrite several
|
||||
// files within the same second.
|
||||
|
||||
// 1. Handle traditional single identity.json file
|
||||
if dir == filer.IamConfigDirectory {
|
||||
if (oldEntry != nil && oldEntry.Name == filer.IamIdentityFile) ||
|
||||
(newEntry != nil && newEntry.Name == filer.IamIdentityFile) {
|
||||
s3a.iam.scheduleReload(dir + "/" + filer.IamIdentityFile)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. Handle multiple-file identities, policies, service accounts and groups
|
||||
isIdentityDir := dir == filer.IamConfigDirectory+"/identities" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/identities/")
|
||||
isPolicyDir := dir == filer.IamConfigDirectory+"/policies" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/policies/")
|
||||
isServiceAccountDir := dir == filer.IamConfigDirectory+"/service_accounts" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/service_accounts/")
|
||||
isGroupDir := dir == filer.IamConfigDirectory+"/groups" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/groups/")
|
||||
|
||||
if isIdentityDir || isPolicyDir || isServiceAccountDir || isGroupDir {
|
||||
s3a.iam.scheduleReload(dir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// onOIDCProviderChange refreshes the IAM-managed OIDC provider runtime view
|
||||
// whenever the persisted store under /etc/iam/oidc-providers changes — both
|
||||
// for mutations originated on this S3 server (the local IAM API also calls
|
||||
// RefreshOIDCProvidersFromStore inline, but the subscribe path costs nothing
|
||||
// extra) and for mutations originated on peer S3 servers, which this is the
|
||||
// only mechanism to learn about. A single refresh covers create, update,
|
||||
// delete, and rename because the store is small and a full reload is the
|
||||
// safest way to reach a consistent view.
|
||||
func (s3a *S3ApiServer) onOIDCProviderChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
|
||||
if dir != oidcProvidersDir && !strings.HasPrefix(dir, oidcProvidersDir+"/") {
|
||||
return nil
|
||||
}
|
||||
if s3a.iam == nil || s3a.iam.iamIntegration == nil {
|
||||
return nil
|
||||
}
|
||||
s3iam, ok := s3a.iam.iamIntegration.(*S3IAMIntegration)
|
||||
if !ok || s3iam.iamManager == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s3iam.iamManager.RefreshOIDCProvidersFromStore(context.Background()); err != nil {
|
||||
glog.Warningf("OIDC provider refresh after %s change failed: %v", dir, err)
|
||||
return err
|
||||
}
|
||||
glog.V(2).Infof("Refreshed IAM-managed OIDC providers after %s change", dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// onCircuitBreakerConfigChange handles circuit breaker config file changes (create, update, delete)
|
||||
func (s3a *S3ApiServer) onCircuitBreakerConfigChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
|
||||
if dir != s3_constants.CircuitBreakerConfigDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle deletion: reset to empty config
|
||||
if newEntry == nil && oldEntry != nil && oldEntry.Name == s3_constants.CircuitBreakerConfigFile {
|
||||
glog.V(1).Infof("Circuit breaker config file deleted, resetting to defaults")
|
||||
if err := s3a.cb.LoadS3ApiConfigurationFromBytes([]byte{}); err != nil {
|
||||
glog.Warningf("failed to reset circuit breaker config on deletion: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle create/update
|
||||
if newEntry != nil && newEntry.Name == s3_constants.CircuitBreakerConfigFile {
|
||||
if err := s3a.cb.LoadS3ApiConfigurationFromBytes(newEntry.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
glog.V(1).Infof("updated %s/%s", dir, newEntry.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reload bucket metadata
|
||||
func (s3a *S3ApiServer) onBucketMetadataChange(dir string, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) error {
|
||||
if dir == s3a.option.BucketsPath {
|
||||
s3a.maintainBucketOwnerIndex(oldEntry, newEntry)
|
||||
s3a.mirrorBucketPolicyToIAM(oldEntry, newEntry)
|
||||
if newEntry != nil {
|
||||
// Update bucket registry (existing functionality)
|
||||
s3a.bucketRegistry.LoadBucketMetadata(newEntry)
|
||||
glog.V(1).Infof("updated bucketMetadata %s/%s", dir, newEntry.Name)
|
||||
|
||||
// Update bucket configuration cache with new entry
|
||||
s3a.updateBucketConfigCacheFromEntry(newEntry)
|
||||
} else if oldEntry != nil {
|
||||
// Remove from bucket registry (existing functionality)
|
||||
s3a.bucketRegistry.RemoveBucketMetadata(oldEntry)
|
||||
glog.V(1).Infof("remove bucketMetadata %s/%s", dir, oldEntry.Name)
|
||||
|
||||
// Remove from bucket configuration cache
|
||||
s3a.invalidateBucketConfigCache(oldEntry.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateBucketConfigCacheFromEntry updates the bucket config cache when a bucket entry changes
|
||||
func (s3a *S3ApiServer) updateBucketConfigCacheFromEntry(entry *filer_pb.Entry) {
|
||||
if s3a.bucketConfigCache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
bucket := entry.Name
|
||||
|
||||
// Remove from negative cache since bucket now exists
|
||||
// This is important for buckets created via weed shell or other external means
|
||||
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
|
||||
|
||||
// Only refresh buckets already resident in the cache; cold buckets
|
||||
// lazy-load on first access so the cache holds this gateway's working
|
||||
// set, not every bucket in the cluster.
|
||||
if !s3a.bucketConfigCache.Contains(bucket) {
|
||||
return
|
||||
}
|
||||
|
||||
// newBucketConfigFromEntry is the single source of truth for mapping
|
||||
// Entry.Extended → cached fields (incl. LifecycleTTL), so a meta-log
|
||||
// Put/DeleteBucketLifecycle here can't leave a stale resolver in cache.
|
||||
config := s3a.newBucketConfigFromEntry(bucket, entry)
|
||||
|
||||
glog.V(3).Infof("updateBucketConfigCacheFromEntry: refreshing cache for bucket %s, ObjectLockConfig=%+v", bucket, config.ObjectLockConfig)
|
||||
s3a.bucketConfigCache.Set(bucket, config)
|
||||
}
|
||||
|
||||
// invalidateBucketConfigCache removes a bucket from the configuration cache
|
||||
func (s3a *S3ApiServer) invalidateBucketConfigCache(bucket string) {
|
||||
if s3a.bucketConfigCache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s3a.bucketConfigCache.Remove(bucket)
|
||||
s3a.bucketConfigCache.RemoveNegativeCache(bucket) // Also remove from negative cache
|
||||
glog.V(2).Infof("invalidateBucketConfigCache: removed bucket %s from cache", bucket)
|
||||
}
|
||||
202
weed/s3api/auth_credentials_subscribe_test.go
Normal file
202
weed/s3api/auth_credentials_subscribe_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential"
|
||||
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
)
|
||||
|
||||
func TestOnIamConfigChangeLegacyIdentityDeletionReloadsConfiguration(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{
|
||||
{
|
||||
Name: "anonymous",
|
||||
Actions: []string{
|
||||
"Read:test",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := s3a.onIamConfigChange(
|
||||
filer.IamConfigDirectory,
|
||||
&filer_pb.Entry{Name: filer.IamIdentityFile},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("onIamConfigChange returned error for legacy identity deletion: %v", err)
|
||||
}
|
||||
|
||||
if !hasIdentity(s3a.iam, "anonymous") {
|
||||
t.Fatalf("expected anonymous identity to remain loaded after legacy identity deletion event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnIamConfigChangeReloadsOnIamIdentityDirectoryChanges(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{
|
||||
{Name: "anonymous"},
|
||||
})
|
||||
|
||||
// Seed initial in-memory IAM state.
|
||||
if err := s3a.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil {
|
||||
t.Fatalf("failed to load initial IAM configuration: %v", err)
|
||||
}
|
||||
if hasIdentity(s3a.iam, "alice") {
|
||||
t.Fatalf("did not expect alice identity before creating user")
|
||||
}
|
||||
|
||||
if err := s3a.iam.credentialManager.CreateUser(context.Background(), &iam_pb.Identity{Name: "alice"}); err != nil {
|
||||
t.Fatalf("failed to create alice in memory credential manager: %v", err)
|
||||
}
|
||||
|
||||
if err := s3a.onIamConfigChange(
|
||||
filer.IamConfigDirectory+"/identities",
|
||||
nil,
|
||||
&filer_pb.Entry{Name: "alice.json"},
|
||||
); err != nil {
|
||||
t.Fatalf("onIamConfigChange returned error for identities directory update: %v", err)
|
||||
}
|
||||
|
||||
waitForIdentity(t, s3a.iam, "alice")
|
||||
}
|
||||
|
||||
func newTestS3ApiServerWithMemoryIAM(t *testing.T, identities []*iam_pb.Identity) *S3ApiServer {
|
||||
t.Helper()
|
||||
|
||||
// Create S3ApiConfiguration for test with provided identities
|
||||
config := &iam_pb.S3ApiConfiguration{
|
||||
Identities: identities,
|
||||
Accounts: []*iam_pb.Account{},
|
||||
ServiceAccounts: []*iam_pb.ServiceAccount{},
|
||||
}
|
||||
|
||||
// Create memory credential manager
|
||||
cm, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create memory credential manager: %v", err)
|
||||
}
|
||||
|
||||
// Save test configuration
|
||||
if err := cm.SaveConfiguration(context.Background(), config); err != nil {
|
||||
t.Fatalf("failed to save test configuration: %v", err)
|
||||
}
|
||||
|
||||
// Create a test IAM instance
|
||||
iam := &IdentityAccessManagement{
|
||||
m: sync.RWMutex{},
|
||||
nameToIdentity: make(map[string]*Identity),
|
||||
accessKeyIdent: make(map[string]*Identity),
|
||||
identities: []*Identity{},
|
||||
policies: make(map[string]*iam_pb.Policy),
|
||||
accounts: make(map[string]*Account),
|
||||
emailAccount: make(map[string]*Account),
|
||||
hashes: make(map[string]*sync.Pool),
|
||||
hashCounters: make(map[string]*int32),
|
||||
isAuthEnabled: false,
|
||||
stopChan: make(chan struct{}),
|
||||
reloadCh: make(chan struct{}, 1),
|
||||
useStaticConfig: false,
|
||||
credentialManager: cm,
|
||||
}
|
||||
go iam.reloadRetryLoop()
|
||||
t.Cleanup(iam.Shutdown)
|
||||
|
||||
// Load test configuration
|
||||
if err := iam.ReplaceS3ApiConfiguration(config); err != nil {
|
||||
t.Fatalf("failed to load test configuration: %v", err)
|
||||
}
|
||||
|
||||
return &S3ApiServer{
|
||||
iam: iam,
|
||||
}
|
||||
}
|
||||
|
||||
func hasIdentity(iam *IdentityAccessManagement, identityName string) bool {
|
||||
iam.m.RLock()
|
||||
defer iam.m.RUnlock()
|
||||
|
||||
_, ok := iam.nameToIdentity[identityName]
|
||||
return ok
|
||||
}
|
||||
|
||||
func waitForIdentity(t *testing.T, iam *IdentityAccessManagement, name string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !hasIdentity(iam, name) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("expected identity %s to be loaded", name)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForIdentityGone(t *testing.T, iam *IdentityAccessManagement, name string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for hasIdentity(iam, name) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("expected identity %s to be gone", name)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// countingStore wraps a store and counts LoadConfiguration calls.
|
||||
type countingStore struct {
|
||||
credential.CredentialStore
|
||||
loads int64
|
||||
}
|
||||
|
||||
func (c *countingStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
|
||||
atomic.AddInt64(&c.loads, 1)
|
||||
return c.CredentialStore.LoadConfiguration(ctx)
|
||||
}
|
||||
|
||||
// A burst of IAM config change events must coalesce into a handful of reloads,
|
||||
// not one full reload per event.
|
||||
func TestOnIamConfigChangeCoalescesBurstReloads(t *testing.T) {
|
||||
s3a := newTestS3ApiServerWithMemoryIAM(t, []*iam_pb.Identity{{Name: "anonymous"}})
|
||||
|
||||
counter := &countingStore{CredentialStore: s3a.iam.credentialManager.Store}
|
||||
s3a.iam.credentialManager.Store = counter
|
||||
|
||||
const burst = 50
|
||||
for i := 0; i < burst; i++ {
|
||||
if err := s3a.onIamConfigChange(
|
||||
filer.IamConfigDirectory+"/identities",
|
||||
nil,
|
||||
&filer_pb.Entry{Name: fmt.Sprintf("u%d.json", i)},
|
||||
); err != nil {
|
||||
t.Fatalf("onIamConfigChange returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the queue to drain: no pending signal.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
s3a.iam.reloadMu.Lock()
|
||||
empty := len(s3a.iam.reloadCh) == 0
|
||||
s3a.iam.reloadMu.Unlock()
|
||||
if empty {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("reload queue did not drain")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
// Let any final coalesced reload finish.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
loads := atomic.LoadInt64(&counter.loads)
|
||||
if loads > 3 {
|
||||
t.Fatalf("expected a burst of %d events to coalesce into <=3 reloads, got %d", burst, loads)
|
||||
}
|
||||
}
|
||||
15
weed/s3api/auth_credentials_trust.go
Normal file
15
weed/s3api/auth_credentials_trust.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ValidateTrustPolicyForPrincipal validates if a principal is allowed to assume a role
|
||||
// Delegates to the IAM integration if available
|
||||
func (iam *IdentityAccessManagement) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
||||
if iam.iamIntegration != nil {
|
||||
return iam.iamIntegration.ValidateTrustPolicyForPrincipal(ctx, roleArn, principalArn)
|
||||
}
|
||||
return fmt.Errorf("IAM integration not available")
|
||||
}
|
||||
60
weed/s3api/auth_jwt_streaming_unsigned_test.go
Normal file
60
weed/s3api/auth_jwt_streaming_unsigned_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestJWTStreamingUnsignedAuth covers the auth half of the JWT + unsigned-streaming
|
||||
// gap. A bearer-token client whose SDK appends a CRC32 trailer sends
|
||||
// x-amz-content-sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER with no SigV4 signature,
|
||||
// so the request is classified authTypeStreamingUnsigned. It must authenticate via
|
||||
// the JWT integration rather than silently falling back to the anonymous identity.
|
||||
func TestJWTStreamingUnsignedAuth(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
defer resetMemoryStore()
|
||||
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
|
||||
iam.iamIntegration = &MockIAMIntegration{
|
||||
authenticateJWTFunc: func(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) {
|
||||
return &IAMIdentity{Name: "jwt-user", Account: &AccountAdmin}, s3err.ErrNone
|
||||
},
|
||||
}
|
||||
iam.isAuthEnabled = true
|
||||
|
||||
r := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
|
||||
r.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
|
||||
r.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
|
||||
r.Header.Set("Authorization", "Bearer test.jwt.token")
|
||||
|
||||
// Stays classified as unsigned-streaming so getRequestDataReader still decodes
|
||||
// the chunked body...
|
||||
assert.Equal(t, authTypeStreamingUnsigned, getRequestAuthType(r))
|
||||
|
||||
// ...but authentication resolves to the JWT identity, not anonymous.
|
||||
identity, errCode := iam.AuthenticateRequest(r)
|
||||
assert.Equal(t, s3err.ErrNone, errCode, "JWT unsigned-streaming PUT must authenticate via the IAM integration")
|
||||
require.NotNil(t, identity)
|
||||
assert.Equal(t, "jwt-user", identity.Name)
|
||||
}
|
||||
|
||||
// TestJWTStreamingUnsignedChunkedReader covers the body-decode half of the gap.
|
||||
// newChunkedReader must not try to verify a bearer token as a SigV4 seed signature
|
||||
// for an unsigned-streaming upload. It used to compute the seed signature whenever
|
||||
// any Authorization header was present, so a JWT request failed to decode at all.
|
||||
func TestJWTStreamingUnsignedChunkedReader(t *testing.T) {
|
||||
iam := setupIam()
|
||||
|
||||
req, err := NewRequestStreamingUnsignedPayloadTrailer(true)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer test.jwt.token")
|
||||
|
||||
runWithRequest(iam, req, t, strings.Repeat("a", 17408))
|
||||
}
|
||||
45
weed/s3api/auth_presigned_semicolon_test.go
Normal file
45
weed/s3api/auth_presigned_semicolon_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
||||
)
|
||||
|
||||
// A presigned URL that signs content-type carries
|
||||
// X-Amz-SignedHeaders=content-type%3Bhost. Some clients and proxies decode
|
||||
// the %3B into a raw ';', which RFC 3986 permits in a query string and AWS
|
||||
// accepts, but Go's url.ParseQuery drops the whole pair — verification then
|
||||
// fails with MissingFields. The router middleware re-encodes the ';' so the
|
||||
// parameter survives.
|
||||
func TestPresignedPutSignedContentTypeWithRawSemicolon(t *testing.T) {
|
||||
iam := newTestIAM()
|
||||
|
||||
req, err := newTestRequest(http.MethodPut, "http://127.0.0.1:9000/bucket/key.png", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newTestRequest: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "image/png")
|
||||
if err := preSignV4WithHeaders(iam, req, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 600, []string{"host", "content-type"}); err != nil {
|
||||
t.Fatalf("preSignV4WithHeaders: %v", err)
|
||||
}
|
||||
req.URL.RawQuery = strings.ReplaceAll(req.URL.RawQuery, "%3B", ";")
|
||||
|
||||
if _, errCode := iam.reqSignatureV4Verify(req); errCode == s3err.ErrNone {
|
||||
t.Fatal("raw-semicolon query unexpectedly verified without normalization")
|
||||
}
|
||||
|
||||
errCode := s3err.ErrInternalError
|
||||
handler := util_http.EscapeSemicolonsInQuery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, errCode = iam.reqSignatureV4Verify(r)
|
||||
}))
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Fatalf("expected ErrNone through semicolon-normalizing middleware, got %v", errCode)
|
||||
}
|
||||
}
|
||||
242
weed/s3api/auth_proxy_integration_test.go
Normal file
242
weed/s3api/auth_proxy_integration_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestReverseProxySignatureVerification is an integration test that exercises
|
||||
// the full HTTP stack: real AWS SDK v4 signer -> real httputil.ReverseProxy ->
|
||||
// real net/http server running IAM signature verification.
|
||||
//
|
||||
// This catches issues that unit tests miss: header normalization by net/http,
|
||||
// proxy header injection, and real-world Host header handling.
|
||||
func TestReverseProxySignatureVerification(t *testing.T) {
|
||||
const (
|
||||
accessKey = "AKIAIOSFODNN7EXAMPLE"
|
||||
secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
)
|
||||
|
||||
configJSON := `{
|
||||
"identities": [
|
||||
{
|
||||
"name": "test_user",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "` + accessKey + `",
|
||||
"secretKey": "` + secretKey + `"
|
||||
}
|
||||
],
|
||||
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
externalUrl string // s3.externalUrl config for the backend
|
||||
clientScheme string // scheme the client uses for signing
|
||||
clientHost string // host the client signs against
|
||||
proxyForwardsHost bool // whether proxy sets X-Forwarded-Host
|
||||
portlessForwardedHost bool // nginx-style $host / $server_port: hostname in X-Forwarded-Host, port in X-Forwarded-Port
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "non-standard port, externalUrl matches proxy address",
|
||||
externalUrl: "", // filled dynamically with proxy address
|
||||
clientScheme: "http",
|
||||
clientHost: "", // filled dynamically
|
||||
proxyForwardsHost: true,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "externalUrl with non-standard port, client signs against external host",
|
||||
externalUrl: "http://api.example.com:9000",
|
||||
clientScheme: "http",
|
||||
clientHost: "api.example.com:9000",
|
||||
proxyForwardsHost: true,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "externalUrl with HTTPS default port stripped, client signs without port",
|
||||
externalUrl: "https://api.example.com:443",
|
||||
clientScheme: "https",
|
||||
clientHost: "api.example.com",
|
||||
proxyForwardsHost: true,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "externalUrl with HTTP default port stripped, client signs without port",
|
||||
externalUrl: "http://api.example.com:80",
|
||||
clientScheme: "http",
|
||||
clientHost: "api.example.com",
|
||||
proxyForwardsHost: true,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "proxy forwards X-Forwarded-Host correctly, no externalUrl needed",
|
||||
externalUrl: "",
|
||||
clientScheme: "http",
|
||||
clientHost: "api.example.com:9000",
|
||||
proxyForwardsHost: true,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "proxy without X-Forwarded-Host, no externalUrl: host mismatch",
|
||||
externalUrl: "",
|
||||
clientScheme: "http",
|
||||
clientHost: "api.example.com:9000",
|
||||
proxyForwardsHost: false,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
// The backend Host (127.0.0.1:ephemeral) shares the forwarded hostname, so the
|
||||
// backend port must not shadow X-Forwarded-Port.
|
||||
name: "nginx $host/$server_port forwarding, backend on same hostname",
|
||||
externalUrl: "",
|
||||
clientScheme: "http",
|
||||
clientHost: "127.0.0.1:9000",
|
||||
proxyForwardsHost: true,
|
||||
portlessForwardedHost: true,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "portless X-Forwarded-Host on default port, backend on same hostname",
|
||||
externalUrl: "",
|
||||
clientScheme: "http",
|
||||
clientHost: "127.0.0.1",
|
||||
proxyForwardsHost: true,
|
||||
portlessForwardedHost: true,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "proxy without X-Forwarded-Host, externalUrl saves the day",
|
||||
externalUrl: "http://api.example.com:9000",
|
||||
clientScheme: "http",
|
||||
clientHost: "api.example.com:9000",
|
||||
proxyForwardsHost: false,
|
||||
expectSuccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// --- Write config to temp file ---
|
||||
tmpFile := t.TempDir() + "/s3.json"
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(configJSON), 0644))
|
||||
|
||||
// --- Set up backend ---
|
||||
var iam *IdentityAccessManagement
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, errCode := iam.authRequest(r, "Read")
|
||||
if errCode != 0 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprintf(w, "error: %d", int(errCode))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "OK")
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
// --- Set up reverse proxy ---
|
||||
backendURL, _ := url.Parse(backend.URL)
|
||||
proxy := httputil.NewSingleHostReverseProxy(backendURL)
|
||||
|
||||
forwardsHost := tt.proxyForwardsHost
|
||||
portlessForwardedHost := tt.portlessForwardedHost
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalHost := req.Host
|
||||
originalScheme := req.URL.Scheme
|
||||
if originalScheme == "" {
|
||||
originalScheme = "http"
|
||||
}
|
||||
originalDirector(req)
|
||||
// Simulate real proxy behavior: rewrite Host to backend address
|
||||
// (nginx proxy_pass and Kong both do this by default)
|
||||
req.Host = backendURL.Host
|
||||
if forwardsHost {
|
||||
forwardedHost := originalHost
|
||||
if portlessForwardedHost {
|
||||
if h, p, splitErr := net.SplitHostPort(originalHost); splitErr == nil {
|
||||
forwardedHost = h
|
||||
req.Header.Set("X-Forwarded-Port", p)
|
||||
}
|
||||
}
|
||||
req.Header.Set("X-Forwarded-Host", forwardedHost)
|
||||
req.Header.Set("X-Forwarded-Proto", originalScheme)
|
||||
}
|
||||
}
|
||||
|
||||
proxyServer := httptest.NewServer(proxy)
|
||||
defer proxyServer.Close()
|
||||
|
||||
// --- Configure IAM ---
|
||||
externalUrl := tt.externalUrl
|
||||
clientHost := tt.clientHost
|
||||
clientScheme := tt.clientScheme
|
||||
if externalUrl == "" && clientHost == "" {
|
||||
// Dynamic: use the proxy's actual address
|
||||
proxyURL, _ := url.Parse(proxyServer.URL)
|
||||
externalUrl = proxyServer.URL
|
||||
clientHost = proxyURL.Host
|
||||
clientScheme = proxyURL.Scheme
|
||||
}
|
||||
|
||||
option := &S3ApiServerOption{
|
||||
Config: tmpFile,
|
||||
ExternalUrl: externalUrl,
|
||||
}
|
||||
iam = NewIdentityAccessManagementWithStore(option, nil, "memory")
|
||||
require.True(t, iam.isEnabled())
|
||||
|
||||
// --- Sign the request using real AWS SDK v4 signer ---
|
||||
clientURL := fmt.Sprintf("%s://%s/test-bucket/test-object", clientScheme, clientHost)
|
||||
req, err := http.NewRequest(http.MethodGet, clientURL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Host = clientHost
|
||||
|
||||
signer := v4.NewSigner()
|
||||
payloadHash := fmt.Sprintf("%x", sha256.Sum256([]byte{}))
|
||||
err = signer.SignHTTP(
|
||||
context.Background(),
|
||||
aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secretKey},
|
||||
req, payloadHash, "s3", "us-east-1", time.Now(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// --- Send the signed request through the proxy ---
|
||||
// Rewrite destination to the proxy, but keep signed headers and Host intact
|
||||
proxyURL, _ := url.Parse(proxyServer.URL)
|
||||
req.URL.Scheme = proxyURL.Scheme
|
||||
req.URL.Host = proxyURL.Host
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if tt.expectSuccess {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode,
|
||||
"Expected signature verification to succeed through reverse proxy")
|
||||
} else {
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode,
|
||||
"Expected signature verification to fail (host mismatch)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
26
weed/s3api/auth_prune_bucket_test.go
Normal file
26
weed/s3api/auth_prune_bucket_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package s3api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestActionScopedToBucket(t *testing.T) {
|
||||
cases := []struct {
|
||||
action string
|
||||
bucket string
|
||||
want bool
|
||||
}{
|
||||
{"Read:bucket", "bucket", true},
|
||||
{"Write:bucket/prefix", "bucket", true},
|
||||
{"Write:bucket/prefix/*", "bucket", true},
|
||||
{"Write:bucket/*", "bucket", true},
|
||||
{"Read:other/prefix/*", "bucket", false},
|
||||
{"Write:*", "bucket", false},
|
||||
{"Write:buck*/x", "bucket", false},
|
||||
{"Write:bucketother", "bucket", false},
|
||||
{"Admin", "bucket", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := actionScopedToBucket(c.action, c.bucket); got != c.want {
|
||||
t.Errorf("actionScopedToBucket(%q, %q) = %v, want %v", c.action, c.bucket, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
559
weed/s3api/auth_security_test.go
Normal file
559
weed/s3api/auth_security_test.go
Normal file
@@ -0,0 +1,559 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func signRawHTTPRequest(ctx context.Context, req *http.Request, accessKey, secretKey, region string) error {
|
||||
creds := aws.Credentials{
|
||||
AccessKeyID: accessKey,
|
||||
SecretAccessKey: secretKey,
|
||||
}
|
||||
signer := v4.NewSigner()
|
||||
payloadHash := fmt.Sprintf("%x", sha256.Sum256([]byte{}))
|
||||
return signer.SignHTTP(ctx, creds, req, payloadHash, "s3", region, time.Now())
|
||||
}
|
||||
|
||||
func TestReproIssue7912(t *testing.T) {
|
||||
// This test asserts behavior for a config with no anonymous identity; reset
|
||||
// the shared in-memory store so a leaked anonymous identity from another test
|
||||
// does not satisfy the unsigned-streaming auth path.
|
||||
resetMemoryStore()
|
||||
|
||||
// Create a temporary s3.json
|
||||
configContent := `{
|
||||
"identities": [
|
||||
{
|
||||
"name": "xx",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "xx_access_key",
|
||||
"secretKey": "xx_secret_key"
|
||||
}
|
||||
],
|
||||
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
|
||||
},
|
||||
{
|
||||
"name": "read_only_user",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "readonly_access_key",
|
||||
"secretKey": "readonly_secret_key"
|
||||
}
|
||||
],
|
||||
"actions": ["Read", "List"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
_, err = tmpFile.Write([]byte(configContent))
|
||||
assert.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
// Initialize Identities Access Management
|
||||
option := &S3ApiServerOption{
|
||||
Config: tmpFile.Name(),
|
||||
}
|
||||
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
|
||||
|
||||
assert.True(t, iam.isEnabled(), "Auth should be enabled")
|
||||
|
||||
// Test case 1: Unknown access key should be rejected
|
||||
t.Run("Unknown access key", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
|
||||
r.Host = "localhost:8333"
|
||||
err := signRawHTTPRequest(context.Background(), r, "unknown_key", "any_secret", "us-east-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
|
||||
assert.Equal(t, s3err.ErrInvalidAccessKeyID, errCode, "Should be denied with unknown access key")
|
||||
assert.Nil(t, identity)
|
||||
})
|
||||
|
||||
t.Run("Positive test case: properly signed credentials", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
|
||||
r.Host = "localhost:8333"
|
||||
err := signRawHTTPRequest(context.Background(), r, "readonly_access_key", "readonly_secret_key", "us-east-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
require.NotNil(t, identity)
|
||||
assert.Equal(t, "read_only_user", identity.Name)
|
||||
})
|
||||
|
||||
t.Run("Nil identity tests for guards", func(t *testing.T) {
|
||||
var nilIdentity *Identity
|
||||
// Test isAdmin guard
|
||||
assert.False(t, nilIdentity.isAdmin())
|
||||
// Test CanDo guard
|
||||
assert.False(t, nilIdentity.CanDo(s3_constants.ACTION_LIST, "bucket", "object"))
|
||||
})
|
||||
|
||||
t.Run("AuthSignatureOnly path", func(t *testing.T) {
|
||||
// Valid request
|
||||
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
|
||||
r.Host = "localhost:8333"
|
||||
err := signRawHTTPRequest(context.Background(), r, "xx_access_key", "xx_secret_key", "us-east-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
identity, errCode := iam.AuthSignatureOnly(r)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
require.NotNil(t, identity)
|
||||
assert.Equal(t, "xx", identity.Name)
|
||||
|
||||
// Invalid request (wrong signature)
|
||||
r2 := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
|
||||
r2.Host = "localhost:8333"
|
||||
err = signRawHTTPRequest(context.Background(), r2, "xx_access_key", "this_is_a_wrong_secret", "us-east-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, errCode2 := iam.AuthSignatureOnly(r2)
|
||||
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode2)
|
||||
|
||||
// Verify fix: Streaming unsigned payload should be denied without auth header in AuthSignatureOnly
|
||||
r3 := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
|
||||
r3.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
|
||||
// No Authorization header
|
||||
_, errCode3 := iam.AuthSignatureOnly(r3)
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode3, "AuthSignatureOnly should be denied with unsigned streaming if no auth header")
|
||||
|
||||
// Verify fix: client-supplied X-SeaweedFS-Principal / X-SeaweedFS-Session-Token
|
||||
// headers must be stripped, otherwise a signed low-privilege caller could
|
||||
// spoof the principal that downstream IAM authorization (e.g. S3Tables
|
||||
// CreateTableBucket) evaluates against.
|
||||
r4 := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
|
||||
r4.Host = "localhost:8333"
|
||||
err = signRawHTTPRequest(context.Background(), r4, "readonly_access_key", "readonly_secret_key", "us-east-1")
|
||||
require.NoError(t, err)
|
||||
// Attacker injects these headers after signing; they are not part of
|
||||
// SignedHeaders, so SigV4 verification still passes.
|
||||
r4.Header.Set(s3_constants.SeaweedFSPrincipalHeader, "arn:aws:iam::123456789012:root")
|
||||
r4.Header.Set(s3_constants.SeaweedFSSessionTokenHeader, "spoofed-session-token")
|
||||
|
||||
_, errCode4 := iam.AuthSignatureOnly(r4)
|
||||
assert.Equal(t, s3err.ErrNone, errCode4)
|
||||
assert.Empty(t, r4.Header.Get(s3_constants.SeaweedFSPrincipalHeader), "client-supplied X-SeaweedFS-Principal must be stripped")
|
||||
assert.Empty(t, r4.Header.Get(s3_constants.SeaweedFSSessionTokenHeader), "client-supplied X-SeaweedFS-Session-Token must be stripped")
|
||||
})
|
||||
|
||||
t.Run("Wrong secret key", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
|
||||
r.Host = "localhost:8333"
|
||||
err := signRawHTTPRequest(context.Background(), r, "readonly_access_key", "this_is_a_wrong_secret", "us-east-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
|
||||
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode, "Should NOT be allowed with wrong signature")
|
||||
assert.Nil(t, identity)
|
||||
})
|
||||
|
||||
t.Run("Anonymous request to protected bucket", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/somebucket/", nil)
|
||||
// No Authorization header
|
||||
|
||||
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode, "Should be denied for anonymous")
|
||||
assert.Nil(t, identity)
|
||||
})
|
||||
|
||||
t.Run("Non-S3 request should be denied", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
|
||||
// No headers at all
|
||||
|
||||
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode)
|
||||
assert.Nil(t, identity)
|
||||
})
|
||||
t.Run("Any other credentials", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://localhost:8333/", nil)
|
||||
r.Host = "localhost:8333"
|
||||
err := signRawHTTPRequest(context.Background(), r, "some_other_key", "some_secret", "us-east-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
identity, errCode := iam.authRequest(r, s3_constants.ACTION_LIST)
|
||||
assert.Equal(t, s3err.ErrInvalidAccessKeyID, errCode, "Should NOT be allowed with ANY other credentials")
|
||||
assert.Nil(t, identity)
|
||||
})
|
||||
t.Run("Streaming unsigned payload bypass attempt", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
|
||||
r.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
|
||||
// No Authorization header
|
||||
|
||||
identity, errCode := iam.authRequest(r, s3_constants.ACTION_WRITE)
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode, "Should be denied with unsigned streaming if no auth header")
|
||||
assert.Nil(t, identity)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAnonymousStreamingUnsignedUpload is a regression test for issue #9725.
|
||||
// Modern botocore/aiobotocore attaches a CRC32 trailer to plain PutObject calls,
|
||||
// turning the payload into STREAMING-UNSIGNED-PAYLOAD-TRAILER. An anonymous
|
||||
// (anon=True) upload then carries that header but no Authorization, which used to
|
||||
// be routed straight to SigV4 verification and rejected as AccessDenied. It must
|
||||
// instead fall back to the configured anonymous identity, just like a plain
|
||||
// anonymous PUT does.
|
||||
func TestAnonymousStreamingUnsignedUpload(t *testing.T) {
|
||||
// This test loads an anonymous identity into the shared in-memory credential
|
||||
// store; reset before and after so it neither inherits nor leaks state.
|
||||
resetMemoryStore()
|
||||
defer resetMemoryStore()
|
||||
|
||||
configContent := `{
|
||||
"identities": [
|
||||
{
|
||||
"name": "anonymous",
|
||||
"actions": ["Read", "Write", "List"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
_, err = tmpFile.Write([]byte(configContent))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmpFile.Close())
|
||||
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmpFile.Name()}, nil, "memory")
|
||||
require.True(t, iam.isEnabled(), "Auth should be enabled")
|
||||
|
||||
r := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
|
||||
r.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
|
||||
r.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
|
||||
// No Authorization header: anonymous upload with a checksum trailer.
|
||||
|
||||
// The request must stay classified as unsigned-streaming so getRequestDataReader
|
||||
// still decodes the chunked body; only the auth resolution falls back to anonymous.
|
||||
assert.Equal(t, authTypeStreamingUnsigned, getRequestAuthType(r))
|
||||
|
||||
identity, errCode := iam.authRequest(r, s3_constants.ACTION_WRITE)
|
||||
assert.Equal(t, s3err.ErrNone, errCode, "anonymous unsigned-streaming PUT should resolve to the anonymous identity")
|
||||
require.NotNil(t, identity)
|
||||
assert.Equal(t, s3_constants.AccountAnonymousId, identity.Name)
|
||||
}
|
||||
|
||||
// TestExternalUrlSignatureVerification tests that S3 signature verification works
|
||||
// correctly when s3.externalUrl is configured. It uses the real AWS SDK v2 signer
|
||||
// to prove correctness against actual S3 clients behind a reverse proxy.
|
||||
func TestExternalUrlSignatureVerification(t *testing.T) {
|
||||
configContent := `{
|
||||
"identities": [
|
||||
{
|
||||
"name": "test_user",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
}
|
||||
],
|
||||
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
_, err = tmpFile.Write([]byte(configContent))
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
accessKey := "AKIAIOSFODNN7EXAMPLE"
|
||||
secretKey := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
clientUrl string // URL the client signs against
|
||||
backendHost string // Host header SeaweedFS sees (from proxy)
|
||||
externalUrl string // s3.externalUrl config
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "non-standard port with externalUrl",
|
||||
clientUrl: "https://api.example.com:9000/test-bucket/object",
|
||||
backendHost: "backend:8333",
|
||||
externalUrl: "https://api.example.com:9000",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "HTTPS default port 443 with externalUrl (port stripped by SDK and parseExternalUrlToHost)",
|
||||
clientUrl: "https://api.example.com/test-bucket/object",
|
||||
backendHost: "backend:8333",
|
||||
externalUrl: "https://api.example.com:443",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "HTTPS without explicit port with externalUrl",
|
||||
clientUrl: "https://api.example.com/test-bucket/object",
|
||||
backendHost: "backend:8333",
|
||||
externalUrl: "https://api.example.com",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "HTTP default port 80 with externalUrl",
|
||||
clientUrl: "http://api.example.com/test-bucket/object",
|
||||
backendHost: "backend:8333",
|
||||
externalUrl: "http://api.example.com:80",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "externalUrl set, in-cluster client signs the service host",
|
||||
clientUrl: "http://seaweedfs-s3:8333/test-bucket/object",
|
||||
backendHost: "seaweedfs-s3:8333",
|
||||
externalUrl: "https://api.example.com",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "externalUrl set, external client signs a virtual-hosted bucket name",
|
||||
clientUrl: "https://test-bucket.api.example.com/object",
|
||||
backendHost: "test-bucket.api.example.com",
|
||||
externalUrl: "https://api.example.com",
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "without externalUrl, internal host causes mismatch",
|
||||
clientUrl: "https://api.example.com:9000/test-bucket/object",
|
||||
backendHost: "backend:8333",
|
||||
externalUrl: "",
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "without externalUrl, matching host works",
|
||||
clientUrl: "http://localhost:8333/test-bucket/object",
|
||||
backendHost: "localhost:8333",
|
||||
externalUrl: "",
|
||||
expectSuccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create IAM with the externalUrl
|
||||
option := &S3ApiServerOption{
|
||||
Config: tmpFile.Name(),
|
||||
ExternalUrl: tt.externalUrl,
|
||||
}
|
||||
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
|
||||
require.True(t, iam.isEnabled())
|
||||
|
||||
// Step 1: Sign a request targeting the client-facing URL using real AWS SDK signer
|
||||
signReq, err := http.NewRequest(http.MethodGet, tt.clientUrl, nil)
|
||||
require.NoError(t, err)
|
||||
signReq.Host = signReq.URL.Host
|
||||
|
||||
err = signRawHTTPRequest(context.Background(), signReq, accessKey, secretKey, "us-east-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 2: Create a separate request as the proxy would deliver it
|
||||
proxyReq := httptest.NewRequest(http.MethodGet, tt.clientUrl, nil)
|
||||
proxyReq.Host = tt.backendHost
|
||||
proxyReq.URL.Host = tt.backendHost
|
||||
|
||||
// Copy the auth headers from the signed request
|
||||
proxyReq.Header.Set("Authorization", signReq.Header.Get("Authorization"))
|
||||
proxyReq.Header.Set("X-Amz-Date", signReq.Header.Get("X-Amz-Date"))
|
||||
proxyReq.Header.Set("X-Amz-Content-Sha256", signReq.Header.Get("X-Amz-Content-Sha256"))
|
||||
|
||||
// Step 3: Verify
|
||||
identity, errCode := iam.authRequest(proxyReq, s3_constants.ACTION_LIST)
|
||||
if tt.expectSuccess {
|
||||
assert.Equal(t, s3err.ErrNone, errCode, "Expected successful signature verification")
|
||||
require.NotNil(t, identity)
|
||||
assert.Equal(t, "test_user", identity.Name)
|
||||
} else {
|
||||
assert.NotEqual(t, s3err.ErrNone, errCode, "Expected signature verification to fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealSDKSignerWithForwardedHeaders proves that extractHostHeader produces
|
||||
// values that match the real AWS SDK v2 signer's SanitizeHostForHeader behavior.
|
||||
//
|
||||
// Unlike the self-referential tests in auto_signature_v4_test.go (which sign and
|
||||
// verify with the same extractHostHeader function), this test uses the real AWS
|
||||
// SDK v2 signer to sign the request. The SDK has its own host sanitization:
|
||||
// - strips :80 for http
|
||||
// - strips :443 for https
|
||||
// - preserves all other ports
|
||||
//
|
||||
// If extractHostHeader disagrees with the SDK, the signature will not match.
|
||||
func TestRealSDKSignerWithForwardedHeaders(t *testing.T) {
|
||||
configContent := `{
|
||||
"identities": [
|
||||
{
|
||||
"name": "test_user",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
}
|
||||
],
|
||||
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
_, err = tmpFile.Write([]byte(configContent))
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
accessKey := "AKIAIOSFODNN7EXAMPLE"
|
||||
secretKey := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
|
||||
// Each test case simulates:
|
||||
// 1. A client connecting to clientUrl (SDK signs with the host from this URL)
|
||||
// 2. A proxy forwarding to SeaweedFS with X-Forwarded-* headers
|
||||
// 3. SeaweedFS extracting the host and verifying the signature
|
||||
tests := []struct {
|
||||
name string
|
||||
clientUrl string // URL the client/SDK signs against
|
||||
backendHost string // Host header SeaweedFS receives (proxy rewrites this)
|
||||
forwardedHost string // X-Forwarded-Host set by proxy
|
||||
forwardedPort string // X-Forwarded-Port set by proxy
|
||||
forwardedProto string // X-Forwarded-Proto set by proxy
|
||||
expectedHost string // what extractHostHeader should return (must match SDK)
|
||||
}{
|
||||
{
|
||||
name: "HTTPS non-standard port",
|
||||
clientUrl: "https://api.example.com:9000/bucket/key",
|
||||
backendHost: "seaweedfs:8333",
|
||||
forwardedHost: "api.example.com",
|
||||
forwardedPort: "9000",
|
||||
forwardedProto: "https",
|
||||
expectedHost: "api.example.com:9000",
|
||||
},
|
||||
{
|
||||
name: "HTTPS standard port 443 (SDK strips, we must too)",
|
||||
clientUrl: "https://api.example.com/bucket/key",
|
||||
backendHost: "seaweedfs:8333",
|
||||
forwardedHost: "api.example.com",
|
||||
forwardedPort: "443",
|
||||
forwardedProto: "https",
|
||||
expectedHost: "api.example.com",
|
||||
},
|
||||
{
|
||||
name: "HTTP standard port 80 (SDK strips, we must too)",
|
||||
clientUrl: "http://api.example.com/bucket/key",
|
||||
backendHost: "seaweedfs:8333",
|
||||
forwardedHost: "api.example.com",
|
||||
forwardedPort: "80",
|
||||
forwardedProto: "http",
|
||||
expectedHost: "api.example.com",
|
||||
},
|
||||
{
|
||||
name: "HTTP non-standard port 8080",
|
||||
clientUrl: "http://api.example.com:8080/bucket/key",
|
||||
backendHost: "seaweedfs:8333",
|
||||
forwardedHost: "api.example.com",
|
||||
forwardedPort: "8080",
|
||||
forwardedProto: "http",
|
||||
expectedHost: "api.example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with port (Traefik style), HTTPS 443",
|
||||
clientUrl: "https://api.example.com/bucket/key",
|
||||
backendHost: "seaweedfs:8333",
|
||||
forwardedHost: "api.example.com:443",
|
||||
forwardedPort: "443",
|
||||
forwardedProto: "https",
|
||||
expectedHost: "api.example.com",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with port (Traefik style), HTTP 80",
|
||||
clientUrl: "http://api.example.com/bucket/key",
|
||||
backendHost: "seaweedfs:8333",
|
||||
forwardedHost: "api.example.com:80",
|
||||
forwardedPort: "80",
|
||||
forwardedProto: "http",
|
||||
expectedHost: "api.example.com",
|
||||
},
|
||||
{
|
||||
name: "no forwarded headers, direct access",
|
||||
clientUrl: "http://localhost:8333/bucket/key",
|
||||
backendHost: "localhost:8333",
|
||||
forwardedHost: "",
|
||||
forwardedPort: "",
|
||||
forwardedProto: "",
|
||||
expectedHost: "localhost:8333",
|
||||
},
|
||||
{
|
||||
name: "empty proto with port 80 (defaults to http, strip)",
|
||||
clientUrl: "http://api.example.com/bucket/key",
|
||||
backendHost: "seaweedfs:8333",
|
||||
forwardedHost: "api.example.com",
|
||||
forwardedPort: "80",
|
||||
forwardedProto: "",
|
||||
expectedHost: "api.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
option := &S3ApiServerOption{Config: tmpFile.Name()}
|
||||
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
|
||||
require.True(t, iam.isEnabled())
|
||||
|
||||
// Step 1: Sign a request using the real AWS SDK v2 signer.
|
||||
// The SDK applies its own SanitizeHostForHeader logic.
|
||||
signReq, err := http.NewRequest(http.MethodGet, tt.clientUrl, nil)
|
||||
require.NoError(t, err)
|
||||
signReq.Host = signReq.URL.Host
|
||||
|
||||
err = signRawHTTPRequest(context.Background(), signReq, accessKey, secretKey, "us-east-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 2: Build the request as the proxy would deliver it to SeaweedFS.
|
||||
proxyReq := httptest.NewRequest(http.MethodGet, tt.clientUrl, nil)
|
||||
proxyReq.Host = tt.backendHost
|
||||
|
||||
// Copy signed auth headers
|
||||
proxyReq.Header.Set("Authorization", signReq.Header.Get("Authorization"))
|
||||
proxyReq.Header.Set("X-Amz-Date", signReq.Header.Get("X-Amz-Date"))
|
||||
proxyReq.Header.Set("X-Amz-Content-Sha256", signReq.Header.Get("X-Amz-Content-Sha256"))
|
||||
|
||||
// Set forwarded headers
|
||||
if tt.forwardedHost != "" {
|
||||
proxyReq.Header.Set("X-Forwarded-Host", tt.forwardedHost)
|
||||
}
|
||||
if tt.forwardedPort != "" {
|
||||
proxyReq.Header.Set("X-Forwarded-Port", tt.forwardedPort)
|
||||
}
|
||||
if tt.forwardedProto != "" {
|
||||
proxyReq.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||
}
|
||||
|
||||
// Step 3: Verify extractHostHeader returns the expected value.
|
||||
// This is the critical correctness check: our extracted host must match
|
||||
// what the SDK signed with, otherwise the signature will not match.
|
||||
extractedHost := extractHostHeader(proxyReq, iam.externalHost)
|
||||
assert.Equal(t, tt.expectedHost, extractedHost,
|
||||
"extractHostHeader must return a value matching what AWS SDK signed with")
|
||||
|
||||
// Step 4: Verify the full signature matches.
|
||||
identity, errCode := iam.authRequest(proxyReq, s3_constants.ACTION_LIST)
|
||||
assert.Equal(t, s3err.ErrNone, errCode,
|
||||
"Signature from real AWS SDK signer must verify successfully")
|
||||
require.NotNil(t, identity)
|
||||
assert.Equal(t, "test_user", identity.Name)
|
||||
})
|
||||
}
|
||||
}
|
||||
392
weed/s3api/auth_signature_v2.go
Normal file
392
weed/s3api/auth_signature_v2.go
Normal file
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
* The following code tries to reverse engineer the Amazon S3 APIs,
|
||||
* and is mostly copied from minio implementation.
|
||||
*/
|
||||
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
// implied. See the License for the specific language governing
|
||||
// permissions and limitations under the License.
|
||||
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// Whitelist resource list that will be used in query string for signature-V2 calculation.
|
||||
// The list should be alphabetically sorted
|
||||
var resourceList = []string{
|
||||
"acl",
|
||||
"delete",
|
||||
"lifecycle",
|
||||
"location",
|
||||
"logging",
|
||||
"notification",
|
||||
"partNumber",
|
||||
"policy",
|
||||
"requestPayment",
|
||||
"response-cache-control",
|
||||
"response-content-disposition",
|
||||
"response-content-encoding",
|
||||
"response-content-language",
|
||||
"response-content-type",
|
||||
"response-expires",
|
||||
"torrent",
|
||||
"uploadId",
|
||||
"uploads",
|
||||
"versionId",
|
||||
"versioning",
|
||||
"versions",
|
||||
"website",
|
||||
}
|
||||
|
||||
// Verify if request has AWS Signature Version '2'.
|
||||
func (iam *IdentityAccessManagement) isReqAuthenticatedV2(r *http.Request) (*Identity, s3err.ErrorCode) {
|
||||
if isRequestSignatureV2(r) {
|
||||
return iam.doesSignV2Match(r)
|
||||
}
|
||||
return iam.doesPresignV2SignatureMatch(r)
|
||||
}
|
||||
|
||||
func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(formValues http.Header) (*Identity, s3err.ErrorCode) {
|
||||
|
||||
accessKey := formValues.Get("AWSAccessKeyId")
|
||||
if accessKey == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
|
||||
identity, cred, found := iam.lookupByAccessKey(accessKey)
|
||||
if !found {
|
||||
// Log detailed error information for InvalidAccessKeyId (V2 POST)
|
||||
iam.m.RLock()
|
||||
availableKeyCount := len(iam.accessKeyIdent)
|
||||
iam.m.RUnlock()
|
||||
|
||||
glog.Warningf("InvalidAccessKeyId (V2 POST): attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
|
||||
accessKey, availableKeyCount, iam.isAuthEnabled)
|
||||
|
||||
return nil, s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
// Check service account expiration
|
||||
if cred.isCredentialExpired() {
|
||||
glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)",
|
||||
accessKey, cred.Expiration, time.Now().Unix())
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
bucket := formValues.Get("bucket")
|
||||
if !identity.CanDo(s3_constants.ACTION_WRITE, bucket, "") {
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
policy := formValues.Get("Policy")
|
||||
if policy == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
|
||||
signature := formValues.Get("Signature")
|
||||
if signature == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
|
||||
if !compareSignatureV2(signature, calculateSignatureV2(policy, cred.SecretKey)) {
|
||||
return nil, s3err.ErrSignatureDoesNotMatch
|
||||
}
|
||||
return identity, s3err.ErrNone
|
||||
}
|
||||
|
||||
// doesSignV2Match - Verify authorization header with calculated header in accordance with
|
||||
// - http://docs.aws.amazon.com/AmazonS3/latest/dev/auth-request-sig-v2.html
|
||||
//
|
||||
// returns ErrNone if the signature matches.
|
||||
func (iam *IdentityAccessManagement) doesSignV2Match(r *http.Request) (*Identity, s3err.ErrorCode) {
|
||||
v2Auth := r.Header.Get("Authorization")
|
||||
accessKey, errCode := validateV2AuthHeader(v2Auth)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, errCode
|
||||
}
|
||||
|
||||
identity, cred, found := iam.lookupByAccessKey(accessKey)
|
||||
if !found {
|
||||
// Log detailed error information for InvalidAccessKeyId (V2 signed)
|
||||
iam.m.RLock()
|
||||
availableKeyCount := len(iam.accessKeyIdent)
|
||||
iam.m.RUnlock()
|
||||
|
||||
glog.Warningf("InvalidAccessKeyId (V2 signed): attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
|
||||
accessKey, availableKeyCount, iam.isAuthEnabled)
|
||||
|
||||
return nil, s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
// Check service account expiration
|
||||
if cred.isCredentialExpired() {
|
||||
glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)",
|
||||
accessKey, cred.Expiration, time.Now().Unix())
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
expectedAuth := signatureV2(cred, r.Method, r.URL.EscapedPath(), r.URL.Query().Encode(), r.Header)
|
||||
|
||||
// Extract signatures from both auth headers
|
||||
v2Signature := ""
|
||||
expectedV2Signature := ""
|
||||
|
||||
// Extract signature from request header
|
||||
if idx := strings.LastIndex(v2Auth, ":"); idx != -1 {
|
||||
v2Signature = v2Auth[idx+1:]
|
||||
}
|
||||
|
||||
// Extract signature from expected auth header
|
||||
// This should always succeed if signatureV2 is working correctly
|
||||
if idx := strings.LastIndex(expectedAuth, ":"); idx != -1 {
|
||||
expectedV2Signature = expectedAuth[idx+1:]
|
||||
} else {
|
||||
// This indicates a bug in signatureV2 function
|
||||
return nil, s3err.ErrSignatureDoesNotMatch
|
||||
}
|
||||
|
||||
if !compareSignatureV2(v2Signature, expectedV2Signature) {
|
||||
return nil, s3err.ErrSignatureDoesNotMatch
|
||||
}
|
||||
return identity, s3err.ErrNone
|
||||
}
|
||||
|
||||
// doesPresignV2SignatureMatch - Verify query headers with calculated header in accordance with
|
||||
// - http://docs.aws.amazon.com/AmazonS3/latest/dev/auth-request-sig-v2.html
|
||||
//
|
||||
// returns ErrNone if the signature matches.
|
||||
func (iam *IdentityAccessManagement) doesPresignV2SignatureMatch(r *http.Request) (*Identity, s3err.ErrorCode) {
|
||||
query := r.URL.Query()
|
||||
expires := query.Get("Expires")
|
||||
if expires == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
|
||||
expireTimestamp, err := strconv.ParseInt(expires, 10, 64)
|
||||
if err != nil {
|
||||
return nil, s3err.ErrMalformedExpires
|
||||
}
|
||||
|
||||
if time.Unix(expireTimestamp, 0).Before(time.Now().UTC()) {
|
||||
return nil, s3err.ErrExpiredPresignRequest
|
||||
}
|
||||
|
||||
accessKey := query.Get("AWSAccessKeyId")
|
||||
if accessKey == "" {
|
||||
glog.Warningf("InvalidAccessKeyId (V2 presigned): empty access key in request")
|
||||
return nil, s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
signature := query.Get("Signature")
|
||||
if signature == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
|
||||
identity, cred, found := iam.lookupByAccessKey(accessKey)
|
||||
if !found {
|
||||
// Log detailed error information for InvalidAccessKeyId (V2 presigned)
|
||||
iam.m.RLock()
|
||||
availableKeyCount := len(iam.accessKeyIdent)
|
||||
iam.m.RUnlock()
|
||||
|
||||
glog.Warningf("InvalidAccessKeyId (V2 presigned): attempted key '%s' not found. Available keys: %d, Auth enabled: %v",
|
||||
accessKey, availableKeyCount, iam.isAuthEnabled)
|
||||
|
||||
return nil, s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
// Check service account expiration
|
||||
if cred.isCredentialExpired() {
|
||||
glog.V(2).Infof("Service account credential %s has expired (expiration: %d, now: %d)",
|
||||
accessKey, cred.Expiration, time.Now().Unix())
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
expectedSignature := preSignatureV2(cred, r.Method, r.URL.EscapedPath(), r.URL.Query().Encode(), r.Header, expires)
|
||||
if !compareSignatureV2(signature, expectedSignature) {
|
||||
return nil, s3err.ErrSignatureDoesNotMatch
|
||||
}
|
||||
return identity, s3err.ErrNone
|
||||
}
|
||||
|
||||
// validateV2AuthHeader validates AWS Signature Version '2' authentication header.
|
||||
func validateV2AuthHeader(v2Auth string) (accessKey string, errCode s3err.ErrorCode) {
|
||||
if v2Auth == "" {
|
||||
return "", s3err.ErrAuthHeaderEmpty
|
||||
}
|
||||
|
||||
// Signature V2 authorization header format:
|
||||
// Authorization: AWS AKIAIOSFODNN7EXAMPLE:frJIUN8DYpKDtOLCwo//yllqDzg=
|
||||
const signV2AlgorithmPrefix = signV2Algorithm + " "
|
||||
if !strings.HasPrefix(v2Auth, signV2AlgorithmPrefix) {
|
||||
return "", s3err.ErrSignatureVersionNotSupported
|
||||
}
|
||||
|
||||
// Strip off the Algorithm prefix.
|
||||
v2Auth = v2Auth[len(signV2Algorithm)+1:]
|
||||
authFields := strings.Split(v2Auth, ":")
|
||||
if len(authFields) != 2 {
|
||||
return "", s3err.ErrMissingFields
|
||||
}
|
||||
|
||||
// The first field is Access Key ID.
|
||||
if authFields[0] == "" {
|
||||
return "", s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
// The second field is signature.
|
||||
if authFields[1] == "" {
|
||||
return "", s3err.ErrMissingFields
|
||||
}
|
||||
|
||||
return authFields[0], s3err.ErrNone
|
||||
}
|
||||
|
||||
// signatureV2 - calculates signature version 2 for request.
|
||||
func signatureV2(cred *Credential, method string, encodedResource string, encodedQuery string, headers http.Header) string {
|
||||
stringToSign := getStringToSignV2(method, encodedResource, encodedQuery, headers, "")
|
||||
signature := calculateSignatureV2(stringToSign, cred.SecretKey)
|
||||
return signV2Algorithm + " " + cred.AccessKey + ":" + signature
|
||||
}
|
||||
|
||||
// getStringToSignV2 - string to sign in accordance with
|
||||
// - http://docs.aws.amazon.com/AmazonS3/latest/dev/auth-request-sig-v2.html
|
||||
func getStringToSignV2(method string, encodedResource, encodedQuery string, headers http.Header, expires string) string {
|
||||
canonicalHeaders := canonicalizedAmzHeadersV2(headers)
|
||||
if len(canonicalHeaders) > 0 {
|
||||
canonicalHeaders += "\n"
|
||||
}
|
||||
|
||||
// From the Amazon docs:
|
||||
//
|
||||
// StringToSign = HTTP-Verb + "\n" +
|
||||
// Content-MD5 + "\n" +
|
||||
// Content-Type + "\n" +
|
||||
// Date + "\n" +
|
||||
// CanonicalizedAmzHeaders +
|
||||
// CanonicalizedResource;
|
||||
stringToSign := method + "\n"
|
||||
stringToSign += headers.Get("Content-Md5") + "\n"
|
||||
stringToSign += headers.Get("Content-Type") + "\n"
|
||||
|
||||
if expires != "" {
|
||||
stringToSign += expires + "\n"
|
||||
} else {
|
||||
stringToSign += headers.Get("Date") + "\n"
|
||||
if v := headers.Get("x-amz-date"); v != "" {
|
||||
stringToSign = strings.Replace(stringToSign, headers.Get("Date")+"\n", "\n", -1)
|
||||
}
|
||||
}
|
||||
stringToSign += canonicalHeaders
|
||||
stringToSign += canonicalizedResourceV2(encodedResource, encodedQuery)
|
||||
return stringToSign
|
||||
}
|
||||
|
||||
// canonicalizedResourceV2 - canonicalize the resource string for signature V2.
|
||||
func canonicalizedResourceV2(encodedResource, encodedQuery string) string {
|
||||
queries := strings.Split(encodedQuery, "&")
|
||||
keyval := make(map[string]string)
|
||||
for _, query := range queries {
|
||||
key := query
|
||||
val := ""
|
||||
index := strings.Index(query, "=")
|
||||
if index != -1 {
|
||||
key = query[:index]
|
||||
val = query[index+1:]
|
||||
}
|
||||
keyval[key] = val
|
||||
}
|
||||
|
||||
var canonicalQueries []string
|
||||
for _, resource := range resourceList {
|
||||
if val, ok := keyval[resource]; ok {
|
||||
if val == "" {
|
||||
canonicalQueries = append(canonicalQueries, resource)
|
||||
continue
|
||||
}
|
||||
canonicalQueries = append(canonicalQueries, resource+"="+val)
|
||||
}
|
||||
}
|
||||
|
||||
// The queries will be already sorted as resourceList is sorted.
|
||||
if len(canonicalQueries) == 0 {
|
||||
return encodedResource
|
||||
}
|
||||
|
||||
// If queries are present then the canonicalized resource is set to encodedResource + "?" + strings.Join(canonicalQueries, "&")
|
||||
return encodedResource + "?" + strings.Join(canonicalQueries, "&")
|
||||
}
|
||||
|
||||
// canonicalizedAmzHeadersV2 - canonicalize the x-amz-* headers for signature V2.
|
||||
func canonicalizedAmzHeadersV2(headers http.Header) string {
|
||||
var keys []string
|
||||
keyval := make(map[string]string)
|
||||
for key := range headers {
|
||||
lkey := strings.ToLower(key)
|
||||
if !strings.HasPrefix(lkey, "x-amz-") {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, lkey)
|
||||
keyval[lkey] = strings.Join(headers[key], ",")
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var canonicalHeaders []string
|
||||
for _, key := range keys {
|
||||
canonicalHeaders = append(canonicalHeaders, key+":"+keyval[key])
|
||||
}
|
||||
return strings.Join(canonicalHeaders, "\n")
|
||||
}
|
||||
|
||||
// calculateSignatureV2 - calculates signature version 2.
|
||||
func calculateSignatureV2(stringToSign string, secret string) string {
|
||||
hm := hmac.New(sha1.New, []byte(secret))
|
||||
hm.Write([]byte(stringToSign))
|
||||
return base64.StdEncoding.EncodeToString(hm.Sum(nil))
|
||||
}
|
||||
|
||||
// compareSignatureV2 returns true if and only if both signatures
|
||||
// are equal. The signatures are expected to be base64 encoded strings
|
||||
// according to the AWS S3 signature V2 spec.
|
||||
func compareSignatureV2(sig1, sig2 string) bool {
|
||||
// Decode signature string to binary byte-sequence representation is required
|
||||
// as Base64 encoding of a value is not unique:
|
||||
// For example "aGVsbG8=" and "aGVsbG8=\r" will result in the same byte slice.
|
||||
signature1, err := base64.StdEncoding.DecodeString(sig1)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
signature2, err := base64.StdEncoding.DecodeString(sig2)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(signature1, signature2) == 1
|
||||
}
|
||||
|
||||
// Return signature-v2 for the presigned request.
|
||||
func preSignatureV2(cred *Credential, method string, encodedResource string, encodedQuery string, headers http.Header, expires string) string {
|
||||
stringToSign := getStringToSignV2(method, encodedResource, encodedQuery, headers, expires)
|
||||
return calculateSignatureV2(stringToSign, cred.SecretKey)
|
||||
}
|
||||
367
weed/s3api/auth_signature_v2_test.go
Normal file
367
weed/s3api/auth_signature_v2_test.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
func setupTestIAMForV2Auth() *IdentityAccessManagement {
|
||||
iam := &IdentityAccessManagement{
|
||||
identities: []*Identity{},
|
||||
accessKeyIdent: make(map[string]*Identity),
|
||||
}
|
||||
|
||||
testCred := &Credential{
|
||||
AccessKey: "AKIAIOSFODNN7EXAMPLE",
|
||||
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}
|
||||
|
||||
testIdentity := &Identity{
|
||||
Name: "testUser",
|
||||
Account: &AccountAdmin,
|
||||
Credentials: []*Credential{testCred},
|
||||
Actions: []Action{
|
||||
s3_constants.ACTION_ADMIN,
|
||||
},
|
||||
}
|
||||
|
||||
iam.identities = append(iam.identities, testIdentity)
|
||||
iam.accessKeyIdent[testCred.AccessKey] = testIdentity
|
||||
|
||||
return iam
|
||||
}
|
||||
|
||||
func TestValidateV2AuthHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authHeader string
|
||||
expectedAccessKey string
|
||||
expectedError s3err.ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "valid auth header with space",
|
||||
authHeader: "AWS AKIAIOSFODNN7EXAMPLE:frJIUN8DYpKDtOLCwo//yllqDzg=",
|
||||
expectedAccessKey: "AKIAIOSFODNN7EXAMPLE",
|
||||
expectedError: s3err.ErrNone,
|
||||
},
|
||||
{
|
||||
name: "empty auth header",
|
||||
authHeader: "",
|
||||
expectedError: s3err.ErrAuthHeaderEmpty,
|
||||
},
|
||||
{
|
||||
name: "wrong algorithm prefix",
|
||||
authHeader: "HMAC AKIAIOSFODNN7EXAMPLE:signature",
|
||||
expectedError: s3err.ErrSignatureVersionNotSupported,
|
||||
},
|
||||
{
|
||||
name: "algorithm prefix without space",
|
||||
authHeader: "AWSXAKIAIOSFODNN7EXAMPLE:signature",
|
||||
expectedError: s3err.ErrSignatureVersionNotSupported,
|
||||
},
|
||||
{
|
||||
name: "missing colon separator",
|
||||
authHeader: "AWS AKIAIOSFODNN7EXAMPLE",
|
||||
expectedError: s3err.ErrMissingFields,
|
||||
},
|
||||
{
|
||||
name: "empty access key",
|
||||
authHeader: "AWS :signature",
|
||||
expectedError: s3err.ErrInvalidAccessKeyID,
|
||||
},
|
||||
{
|
||||
name: "empty signature",
|
||||
authHeader: "AWS AKIAIOSFODNN7EXAMPLE:",
|
||||
expectedError: s3err.ErrMissingFields,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
accessKey, errCode := validateV2AuthHeader(tt.authHeader)
|
||||
|
||||
if errCode != tt.expectedError {
|
||||
t.Errorf("validateV2AuthHeader() error = %v, want %v", errCode, tt.expectedError)
|
||||
}
|
||||
|
||||
if errCode == s3err.ErrNone && accessKey != tt.expectedAccessKey {
|
||||
t.Errorf("validateV2AuthHeader() accessKey = %q, want %q", accessKey, tt.expectedAccessKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureV2Format(t *testing.T) {
|
||||
cred := &Credential{
|
||||
AccessKey: "AKIAIOSFODNN7EXAMPLE",
|
||||
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("Date", "Mon, 09 Sep 2011 23:36:00 GMT")
|
||||
|
||||
signature := signatureV2(cred, "GET", "/bucket/object", "", headers)
|
||||
|
||||
// Verify format: "AWS <AccessKey>:<Signature>" with space after AWS
|
||||
expectedPrefix := "AWS " + cred.AccessKey + ":"
|
||||
if len(signature) < len(expectedPrefix) {
|
||||
t.Fatalf("Signature too short: %s", signature)
|
||||
}
|
||||
|
||||
actualPrefix := signature[:len(expectedPrefix)]
|
||||
if actualPrefix != expectedPrefix {
|
||||
t.Errorf("Signature prefix = %q, want %q", actualPrefix, expectedPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoesSignV2Match(t *testing.T) {
|
||||
iam := setupTestIAMForV2Auth()
|
||||
cred := &Credential{
|
||||
AccessKey: "AKIAIOSFODNN7EXAMPLE",
|
||||
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
query string
|
||||
headers map[string]string
|
||||
authOverride string
|
||||
expectedError s3err.ErrorCode
|
||||
expectIdent bool
|
||||
}{
|
||||
{
|
||||
name: "valid GET request",
|
||||
method: "GET",
|
||||
path: "/bucket/object",
|
||||
query: "",
|
||||
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
|
||||
expectedError: s3err.ErrNone,
|
||||
expectIdent: true,
|
||||
},
|
||||
{
|
||||
name: "valid PUT request with content headers",
|
||||
method: "PUT",
|
||||
path: "/bucket/object",
|
||||
query: "",
|
||||
headers: map[string]string{
|
||||
"Date": "Mon, 09 Sep 2011 23:36:00 GMT",
|
||||
"Content-Type": "text/plain",
|
||||
"Content-Md5": "c8fdb181845a4ca6b8fec737b3581d76",
|
||||
},
|
||||
expectedError: s3err.ErrNone,
|
||||
expectIdent: true,
|
||||
},
|
||||
{
|
||||
name: "request with query parameters",
|
||||
method: "GET",
|
||||
path: "/bucket/object",
|
||||
query: "acl&versionId=123",
|
||||
headers: map[string]string{
|
||||
"Date": "Mon, 09 Sep 2011 23:36:00 GMT",
|
||||
},
|
||||
expectedError: s3err.ErrNone,
|
||||
expectIdent: true,
|
||||
},
|
||||
{
|
||||
name: "request with x-amz headers",
|
||||
method: "PUT",
|
||||
path: "/bucket/object",
|
||||
query: "",
|
||||
headers: map[string]string{
|
||||
"Date": "Mon, 09 Sep 2011 23:36:00 GMT",
|
||||
"x-amz-storage-class": "REDUCED_REDUNDANCY",
|
||||
"x-amz-meta-custom": "value",
|
||||
},
|
||||
expectedError: s3err.ErrNone,
|
||||
expectIdent: true,
|
||||
},
|
||||
{
|
||||
name: "invalid signature",
|
||||
method: "GET",
|
||||
path: "/bucket/object",
|
||||
query: "",
|
||||
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
|
||||
authOverride: "AWS AKIAIOSFODNN7EXAMPLE:invalidSignature123456==",
|
||||
expectedError: s3err.ErrSignatureDoesNotMatch,
|
||||
expectIdent: false,
|
||||
},
|
||||
{
|
||||
name: "non-existent access key",
|
||||
method: "GET",
|
||||
path: "/bucket/object",
|
||||
query: "",
|
||||
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
|
||||
authOverride: "AWS NONEXISTENTKEY:signature==",
|
||||
expectedError: s3err.ErrInvalidAccessKeyID,
|
||||
expectIdent: false,
|
||||
},
|
||||
{
|
||||
name: "empty authorization header",
|
||||
method: "GET",
|
||||
path: "/bucket/object",
|
||||
query: "",
|
||||
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
|
||||
authOverride: "",
|
||||
expectedError: s3err.ErrAuthHeaderEmpty,
|
||||
expectIdent: false,
|
||||
},
|
||||
{
|
||||
name: "malformed auth - missing signature",
|
||||
method: "GET",
|
||||
path: "/bucket/object",
|
||||
query: "",
|
||||
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
|
||||
authOverride: "AWS AKIAIOSFODNN7EXAMPLE",
|
||||
expectedError: s3err.ErrMissingFields,
|
||||
expectIdent: false,
|
||||
},
|
||||
{
|
||||
name: "malformed auth - wrong prefix",
|
||||
method: "GET",
|
||||
path: "/bucket/object",
|
||||
query: "",
|
||||
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
|
||||
authOverride: "HMAC AKIAIOSFODNN7EXAMPLE:sig",
|
||||
expectedError: s3err.ErrSignatureVersionNotSupported,
|
||||
expectIdent: false,
|
||||
},
|
||||
{
|
||||
name: "malformed auth - no space after AWS",
|
||||
method: "GET",
|
||||
path: "/bucket/object",
|
||||
query: "",
|
||||
headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"},
|
||||
authOverride: "AWSAKIAIOSFODNN7EXAMPLE:signature==",
|
||||
expectedError: s3err.ErrSignatureVersionNotSupported,
|
||||
expectIdent: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
url := "http://example.com" + tt.path
|
||||
if tt.query != "" {
|
||||
url += "?" + tt.query
|
||||
}
|
||||
req, err := http.NewRequest(tt.method, url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
for key, value := range tt.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
var authHeader string
|
||||
if tt.authOverride != "" {
|
||||
authHeader = tt.authOverride
|
||||
} else {
|
||||
authHeader = signatureV2(cred, req.Method, req.URL.Path, req.URL.Query().Encode(), req.Header)
|
||||
}
|
||||
if tt.name != "empty authorization header" {
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
}
|
||||
|
||||
identity, errCode := iam.doesSignV2Match(req)
|
||||
|
||||
if errCode != tt.expectedError {
|
||||
t.Errorf("doesSignV2Match() error = %v, want %v", errCode, tt.expectedError)
|
||||
}
|
||||
|
||||
if tt.expectIdent && identity == nil {
|
||||
t.Error("Expected non-nil identity")
|
||||
}
|
||||
|
||||
if !tt.expectIdent && identity != nil {
|
||||
t.Error("Expected nil identity")
|
||||
}
|
||||
|
||||
if identity != nil && identity.Name != "testUser" {
|
||||
t.Errorf("Identity name = %q, want %q", identity.Name, "testUser")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoesSignV2MatchUnicodePath(t *testing.T) {
|
||||
iam := setupTestIAMForV2Auth()
|
||||
cred := &Credential{
|
||||
AccessKey: "AKIAIOSFODNN7EXAMPLE",
|
||||
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "CJK conversation key from ai-dial-core",
|
||||
path: "/dialcore/core/Users/test-user/conversations/qwen3.5-35b-a3b__%E5%A4%9A%E7%9D%87%E3%80%82",
|
||||
},
|
||||
{
|
||||
name: "Japanese filename from issue 8598",
|
||||
path: "/voyage-dev1/public/1773200112088-998898991/%E3%81%9F%E3%81%82%E3%81%84%E3%81%BEd%C2%B7.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Date", "Mon, 09 Sep 2011 23:36:00 GMT")
|
||||
|
||||
// Clients (jclouds, AWS SDK) sign percent-encoded paths, not decoded Path.
|
||||
authHeader := signatureV2(cred, req.Method, tt.path, req.URL.Query().Encode(), req.Header)
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
identity, errCode := iam.doesSignV2Match(req)
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Fatalf("doesSignV2Match() error = %v, want %v; path=%q escaped=%q",
|
||||
errCode, s3err.ErrNone, req.URL.Path, req.URL.EscapedPath())
|
||||
}
|
||||
if identity == nil {
|
||||
t.Fatal("Expected non-nil identity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoesPresignV2MatchUnicodePath(t *testing.T) {
|
||||
iam := setupTestIAMForV2Auth()
|
||||
cred := &Credential{
|
||||
AccessKey: "AKIAIOSFODNN7EXAMPLE",
|
||||
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}
|
||||
|
||||
path := "/dialcore/core/Users/test-user/conversations/qwen3.5-35b-a3b__%E5%A4%9A%E7%9D%87%E3%80%82"
|
||||
expires := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://example.com"+path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
query := req.URL.Query()
|
||||
query.Set("AWSAccessKeyId", cred.AccessKey)
|
||||
query.Set("Expires", expires)
|
||||
query.Set("Signature", preSignatureV2(cred, req.Method, path, query.Encode(), req.Header, expires))
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
identity, errCode := iam.doesPresignV2SignatureMatch(req)
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Fatalf("doesPresignV2SignatureMatch() error = %v, want %v; path=%q escaped=%q",
|
||||
errCode, s3err.ErrNone, req.URL.Path, req.URL.EscapedPath())
|
||||
}
|
||||
if identity == nil {
|
||||
t.Fatal("Expected non-nil identity")
|
||||
}
|
||||
}
|
||||
1247
weed/s3api/auth_signature_v4.go
Normal file
1247
weed/s3api/auth_signature_v4.go
Normal file
File diff suppressed because it is too large
Load Diff
347
weed/s3api/auth_signature_v4_sts_test.go
Normal file
347
weed/s3api/auth_signature_v4_sts_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// An STS session owns resources as its own principal (OIDC subject, or the
|
||||
// assumed-role user when absent), not the shared admin account.
|
||||
func TestValidateSTSSessionTokenAssignsDistinctAccount(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
subject string
|
||||
assumedRoleUser string
|
||||
wantPrincipal string
|
||||
}{
|
||||
{"prefers the subject", "alice", "ReadOnlyRole/alice", "alice"},
|
||||
{"falls back to the assumed-role user", "", "ReadOnlyRole/bob", "ReadOnlyRole/bob"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{
|
||||
iamIntegration: &MockIAMIntegration{
|
||||
validateSessionFunc: func(ctx context.Context, token string) (*sts.SessionInfo, error) {
|
||||
return &sts.SessionInfo{
|
||||
AssumedRoleUser: tc.assumedRoleUser,
|
||||
Principal: "arn:aws:sts:::assumed-role/" + tc.assumedRoleUser,
|
||||
Subject: tc.subject,
|
||||
SessionName: "sess",
|
||||
Credentials: &sts.Credentials{
|
||||
AccessKeyId: "AKIATEST",
|
||||
SecretAccessKey: "secret",
|
||||
},
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
Policies: []string{"ReadOnly"},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://s3/test", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
identity, _, errCode := iam.validateSTSSessionToken(req, "session-token", "AKIATEST")
|
||||
require.Equal(t, s3err.ErrNone, errCode)
|
||||
require.NotNil(t, identity.Account)
|
||||
assert.Equal(t, tc.wantPrincipal, identity.Account.Id, "STS session owns resources as its principal")
|
||||
assert.Equal(t, tc.wantPrincipal, identity.Name)
|
||||
assert.NotEqual(t, AccountAdmin.Id, identity.Account.Id, "STS session must not share the admin account")
|
||||
assert.False(t, identity.isAdmin(), "an STS session has no admin action")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MockIAMIntegration is a mock implementation of IAM integration for testing
|
||||
type MockIAMIntegration struct {
|
||||
authenticateJWTFunc func(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode)
|
||||
authorizeFunc func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode
|
||||
validateTrustPolicyFunc func(ctx context.Context, roleArn, principalArn string) error
|
||||
validateSessionFunc func(ctx context.Context, token string) (*sts.SessionInfo, error)
|
||||
authCalled bool
|
||||
}
|
||||
|
||||
func (m *MockIAMIntegration) AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
m.authCalled = true
|
||||
if m.authorizeFunc != nil {
|
||||
return m.authorizeFunc(ctx, identity, action, bucket, object, r)
|
||||
}
|
||||
return s3err.ErrNone
|
||||
}
|
||||
|
||||
func (m *MockIAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) {
|
||||
if m.authenticateJWTFunc != nil {
|
||||
return m.authenticateJWTFunc(ctx, r)
|
||||
}
|
||||
return nil, s3err.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (m *MockIAMIntegration) ValidateSessionToken(ctx context.Context, token string) (*sts.SessionInfo, error) {
|
||||
if m.validateSessionFunc != nil {
|
||||
return m.validateSessionFunc(ctx, token)
|
||||
}
|
||||
return nil, nil // Not needed for these tests
|
||||
}
|
||||
|
||||
func (m *MockIAMIntegration) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
||||
if m.validateTrustPolicyFunc != nil {
|
||||
return m.validateTrustPolicyFunc(ctx, roleArn, principalArn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockIAMIntegration) DefaultAllow() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// TestVerifyV4SignatureWithSTSIdentity tests that verifyV4Signature properly handles STS identities
|
||||
// by falling back to IAM authorization when shouldCheckPermissions is true
|
||||
func TestVerifyV4SignatureWithSTSIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
identity *Identity
|
||||
shouldCheckPermissions bool
|
||||
iamIntegration *MockIAMIntegration
|
||||
expectedError s3err.ErrorCode
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "STS identity with IAM integration - should authorize via IAM",
|
||||
identity: &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{}, // Empty actions = STS identity
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
},
|
||||
shouldCheckPermissions: true,
|
||||
iamIntegration: &MockIAMIntegration{
|
||||
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
// Simulate successful IAM authorization
|
||||
return s3err.ErrNone
|
||||
},
|
||||
},
|
||||
expectedError: s3err.ErrNone,
|
||||
description: "STS identity should be authorized via IAM when Actions is empty",
|
||||
},
|
||||
{
|
||||
name: "STS identity with IAM integration - IAM denies",
|
||||
identity: &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/readOnlyRole/s3-session",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{}, // Empty actions = STS identity
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/readOnlyRole/s3-session",
|
||||
},
|
||||
shouldCheckPermissions: true,
|
||||
iamIntegration: &MockIAMIntegration{
|
||||
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
// Simulate IAM denying access (e.g., read-only role trying to write)
|
||||
if action == s3_constants.ACTION_WRITE {
|
||||
return s3err.ErrAccessDenied
|
||||
}
|
||||
return s3err.ErrNone
|
||||
},
|
||||
},
|
||||
expectedError: s3err.ErrAccessDenied,
|
||||
description: "STS identity should be denied when IAM denies access",
|
||||
},
|
||||
{
|
||||
name: "STS identity without IAM integration - should deny",
|
||||
identity: &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{}, // Empty actions = STS identity
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
},
|
||||
shouldCheckPermissions: true,
|
||||
iamIntegration: nil, // No IAM integration
|
||||
expectedError: s3err.ErrAccessDenied,
|
||||
description: "STS identity should be denied when no IAM integration is available",
|
||||
},
|
||||
{
|
||||
name: "Traditional identity with Actions - should use CanDo",
|
||||
identity: &Identity{
|
||||
Name: "traditional-user",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{s3_constants.ACTION_WRITE}, // Has actions = traditional identity
|
||||
},
|
||||
shouldCheckPermissions: true,
|
||||
iamIntegration: nil, // IAM integration not needed for traditional identities
|
||||
expectedError: s3err.ErrNone,
|
||||
description: "Traditional identity with Actions should use CanDo check",
|
||||
},
|
||||
{
|
||||
name: "Traditional identity with Actions - CanDo denies",
|
||||
identity: &Identity{
|
||||
Name: "read-only-user",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{s3_constants.ACTION_READ}, // Only has READ action
|
||||
},
|
||||
shouldCheckPermissions: true,
|
||||
iamIntegration: nil,
|
||||
expectedError: s3err.ErrAccessDenied,
|
||||
description: "Traditional identity should be denied when CanDo fails (PUT requires WRITE)",
|
||||
},
|
||||
{
|
||||
name: "shouldCheckPermissions false - skip authorization",
|
||||
identity: &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{}, // Empty actions = STS identity
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
},
|
||||
shouldCheckPermissions: false, // Skip permission check
|
||||
iamIntegration: nil,
|
||||
expectedError: s3err.ErrNone,
|
||||
description: "When shouldCheckPermissions is false, authorization should be skipped",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a mock request for PUT operation (requires WRITE action)
|
||||
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Host", "s3.amazonaws.com")
|
||||
|
||||
// Set up mux route vars for GetBucketAndObject to work
|
||||
req = mux.SetURLVars(req, map[string]string{
|
||||
"bucket": "test-bucket",
|
||||
"object": "test-object",
|
||||
})
|
||||
|
||||
// For STS identities, add session token header to trigger STS-v4 auth path
|
||||
if len(tt.identity.Actions) == 0 && tt.iamIntegration != nil {
|
||||
req.Header.Set("X-Amz-Security-Token", "test-session-token")
|
||||
}
|
||||
|
||||
// Mock the permission check logic from verifyV4Signature (now centralized in VerifyActionPermission)
|
||||
var errCode s3err.ErrorCode
|
||||
if tt.shouldCheckPermissions {
|
||||
bucket, object := s3_constants.GetBucketAndObject(req)
|
||||
action := s3_constants.ACTION_READ
|
||||
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
action = s3_constants.ACTION_WRITE
|
||||
}
|
||||
|
||||
// Create minimal IAM instance with mock integration
|
||||
var integration IAMIntegration
|
||||
if tt.iamIntegration != nil {
|
||||
integration = tt.iamIntegration
|
||||
}
|
||||
iam := &IdentityAccessManagement{
|
||||
iamIntegration: integration,
|
||||
}
|
||||
errCode = iam.VerifyActionPermission(req, tt.identity, Action(action), bucket, object)
|
||||
}
|
||||
|
||||
// Verify the result
|
||||
assert.Equal(t, tt.expectedError, errCode, tt.description)
|
||||
|
||||
// Additional verification for STS identities
|
||||
if len(tt.identity.Actions) == 0 && tt.shouldCheckPermissions {
|
||||
if tt.iamIntegration != nil {
|
||||
// When IAM integration exists, it should have been called
|
||||
assert.True(t, tt.iamIntegration.authCalled, "IAM integration should have been called for STS identity")
|
||||
} else {
|
||||
assert.Equal(t, s3err.ErrAccessDenied, errCode, "STS identity should be denied without IAM integration")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyV4SignatureSTSStreamingUpload tests the specific scenario from the bug report:
|
||||
// STS identities in streaming/chunked uploads should be authorized via IAM
|
||||
func TestVerifyV4SignatureSTSStreamingUpload(t *testing.T) {
|
||||
// Create an STS identity (empty Actions)
|
||||
stsIdentity := &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{}, // Empty - this is an STS identity
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
}
|
||||
|
||||
// Track whether IAM authorization was called
|
||||
iamAuthCalled := false
|
||||
|
||||
// Create IAM integration mock that allows the action
|
||||
iamMock := &MockIAMIntegration{
|
||||
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
iamAuthCalled = true
|
||||
// Verify we're checking the right identity
|
||||
assert.Equal(t, "arn:aws:sts::assumed-role/adminRole/s3-session", identity.Name)
|
||||
assert.Equal(t, s3_constants.ACTION_WRITE, string(action))
|
||||
assert.Equal(t, "test-bucket", bucket)
|
||||
assert.Equal(t, "test-object", object)
|
||||
return s3err.ErrNone
|
||||
},
|
||||
}
|
||||
|
||||
// Create a streaming upload request (PUT with streaming content)
|
||||
req, err := http.NewRequest("PUT", "/test-bucket/test-object", nil)
|
||||
require.NoError(t, err)
|
||||
req.Host = "s3.amazonaws.com"
|
||||
req.Header.Set("Host", "s3.amazonaws.com")
|
||||
req.Header.Set("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD")
|
||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||
req.Header.Set("X-Amz-Security-Token", "test-session-token")
|
||||
|
||||
// Simulate the permission check with shouldCheckPermissions=true
|
||||
// This is what happens in calculateSeedSignature -> verifyV4Signature(r, true)
|
||||
bucket := "test-bucket"
|
||||
object := "test-object"
|
||||
action := s3_constants.ACTION_WRITE
|
||||
|
||||
var errCode s3err.ErrorCode
|
||||
|
||||
// Create minimal IAM instance logic
|
||||
iam := &IdentityAccessManagement{
|
||||
iamIntegration: iamMock,
|
||||
}
|
||||
|
||||
errCode = iam.VerifyActionPermission(req, stsIdentity, Action(action), bucket, object)
|
||||
|
||||
// Verify that the STS identity is authorized via IAM
|
||||
assert.Equal(t, s3err.ErrNone, errCode, "STS identity should be authorized via IAM for streaming upload")
|
||||
assert.True(t, iamAuthCalled, "IAM authorization should have been called for STS identity")
|
||||
}
|
||||
|
||||
// TestVerifyV4SignatureActionDetermination tests that the correct action is determined
|
||||
// based on the HTTP method
|
||||
func TestVerifyV4SignatureActionDetermination(t *testing.T) {
|
||||
tests := []struct {
|
||||
method string
|
||||
expectedAction string
|
||||
}{
|
||||
{http.MethodGet, s3_constants.ACTION_READ},
|
||||
{http.MethodHead, s3_constants.ACTION_READ},
|
||||
{http.MethodPut, s3_constants.ACTION_WRITE},
|
||||
{http.MethodPost, s3_constants.ACTION_WRITE},
|
||||
{http.MethodDelete, s3_constants.ACTION_WRITE},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method, func(t *testing.T) {
|
||||
req, err := http.NewRequest(tt.method, "http://s3.amazonaws.com/bucket/object", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Determine action the same way verifyV4Signature does
|
||||
action := s3_constants.ACTION_READ
|
||||
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
action = s3_constants.ACTION_WRITE
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expectedAction, action, "Action should match expected for method %s", tt.method)
|
||||
})
|
||||
}
|
||||
}
|
||||
679
weed/s3api/auth_signature_v4_test.go
Normal file
679
weed/s3api/auth_signature_v4_test.go
Normal file
@@ -0,0 +1,679 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
func TestExtractV4AuthInfoFromHeader_S3Tables(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
dateStr := now.Format(iso8601Format)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
service string
|
||||
body string
|
||||
expectAutoHash bool
|
||||
}{
|
||||
{
|
||||
name: "s3 service should not auto-hash",
|
||||
service: "s3",
|
||||
body: "hello",
|
||||
expectAutoHash: false,
|
||||
},
|
||||
{
|
||||
name: "s3tables service should auto-hash",
|
||||
service: "s3tables",
|
||||
body: "hello",
|
||||
expectAutoHash: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := bytes.NewReader([]byte(tt.body))
|
||||
req, _ := http.NewRequest(http.MethodPost, "http://localhost/", body)
|
||||
|
||||
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/%s/us-east-1/%s/aws4_request, SignedHeaders=host, Signature=dummy",
|
||||
now.Format(yyyymmdd), tt.service)
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
req.Header.Set("x-amz-date", dateStr)
|
||||
|
||||
authInfo, errCode := extractV4AuthInfoFromHeader(req)
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Fatalf("extractV4AuthInfoFromHeader failed: %v", errCode)
|
||||
}
|
||||
|
||||
if tt.expectAutoHash {
|
||||
expectedHash := sha256.Sum256([]byte(tt.body))
|
||||
expectedHashStr := hex.EncodeToString(expectedHash[:])
|
||||
if authInfo.HashedPayload != expectedHashStr {
|
||||
t.Errorf("Expected auto-hashed payload %s, got %s", expectedHashStr, authInfo.HashedPayload)
|
||||
}
|
||||
} else {
|
||||
if authInfo.HashedPayload != emptySHA256 {
|
||||
t.Errorf("Expected non-auto-hashed payload %s (emptySHA256), got %s", emptySHA256, authInfo.HashedPayload)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignedHeaderRejectsEmptyHeaderNames(t *testing.T) {
|
||||
cases := []string{
|
||||
"SignedHeaders=host;",
|
||||
"SignedHeaders=;host",
|
||||
"SignedHeaders=host;;x-amz-date",
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if _, errCode := parseSignedHeader(tc); errCode != s3err.ErrMissingFields {
|
||||
t.Fatalf("parseSignedHeader(%q) errCode = %v, want %v", tc, errCode, s3err.ErrMissingFields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractV4AuthInfoFromQueryRejectsEmptySignedHeaderNames(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
cases := []string{
|
||||
"host;",
|
||||
";host",
|
||||
"host;;x-amz-date",
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc, func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "http://localhost/bucket/object", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
|
||||
query := req.URL.Query()
|
||||
query.Set("X-Amz-Algorithm", signV4Algorithm)
|
||||
query.Set("X-Amz-Credential", fmt.Sprintf("AKIAIOSFODNN7EXAMPLE/%s/us-east-1/s3/aws4_request", now.Format(yyyymmdd)))
|
||||
query.Set("X-Amz-Date", now.Format(iso8601Format))
|
||||
query.Set("X-Amz-Expires", "60")
|
||||
query.Set("X-Amz-Signature", "dummy")
|
||||
query.Set("X-Amz-SignedHeaders", tc)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
if _, errCode := extractV4AuthInfoFromQuery(req); errCode != s3err.ErrMissingFields {
|
||||
t.Fatalf("extractV4AuthInfoFromQuery(%q) errCode = %v, want %v", tc, errCode, s3err.ErrMissingFields)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCanonicalQueryString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
isPresigned bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "sorts repeated values",
|
||||
target: "http://localhost/bucket/key?partNumber=2&partNumber=10&uploadId=z",
|
||||
want: "partNumber=10&partNumber=2&uploadId=z",
|
||||
},
|
||||
{
|
||||
name: "removes presigned signature",
|
||||
target: "http://localhost/bucket/key?X-Amz-Date=20260618T000000Z&X-Amz-Signature=dummy&X-Amz-SignedHeaders=host",
|
||||
isPresigned: true,
|
||||
want: "X-Amz-Date=20260618T000000Z&X-Amz-SignedHeaders=host",
|
||||
},
|
||||
{
|
||||
name: "sorts mixed single and repeated parameters",
|
||||
target: "http://localhost/bucket/key?z=last&a=2&bucket=b&a=1",
|
||||
want: "a=1&a=2&bucket=b&z=last",
|
||||
},
|
||||
{
|
||||
name: "encodes query parameter values",
|
||||
target: "http://localhost/bucket/key?prefix=photos/2026&marker=a%2Bb",
|
||||
want: "marker=a%2Bb&prefix=photos%2F2026",
|
||||
},
|
||||
{
|
||||
name: "empty query string",
|
||||
target: "http://localhost/bucket/key",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, tt.target, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
|
||||
got := getCanonicalQueryString(req, tt.isPresigned)
|
||||
if got != tt.want {
|
||||
t.Fatalf("canonical query = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPathWithForwardedPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
forwardedPrefix string
|
||||
urlPath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty prefix returns urlPath",
|
||||
forwardedPrefix: "",
|
||||
urlPath: "/bucket/obj",
|
||||
expected: "/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "prefix without trailing slash",
|
||||
forwardedPrefix: "/storage",
|
||||
urlPath: "/bucket/obj",
|
||||
expected: "/storage/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "prefix with trailing slash",
|
||||
forwardedPrefix: "/storage/",
|
||||
urlPath: "/bucket/obj",
|
||||
expected: "/storage/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "prefix without leading slash",
|
||||
forwardedPrefix: "storage",
|
||||
urlPath: "/bucket/obj",
|
||||
expected: "/storage/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "prefix without leading slash and with trailing slash",
|
||||
forwardedPrefix: "storage/",
|
||||
urlPath: "/bucket/obj",
|
||||
expected: "/storage/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "preserve double slashes in key",
|
||||
forwardedPrefix: "/storage",
|
||||
urlPath: "/bucket//obj",
|
||||
expected: "/storage/bucket//obj",
|
||||
},
|
||||
{
|
||||
name: "preserve trailing slash in urlPath",
|
||||
forwardedPrefix: "/storage",
|
||||
urlPath: "/bucket/folder/",
|
||||
expected: "/storage/bucket/folder/",
|
||||
},
|
||||
{
|
||||
name: "preserve trailing slash with prefix having trailing slash",
|
||||
forwardedPrefix: "/storage/",
|
||||
urlPath: "/bucket/folder/",
|
||||
expected: "/storage/bucket/folder/",
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
forwardedPrefix: "/storage",
|
||||
urlPath: "/",
|
||||
expected: "/storage/",
|
||||
},
|
||||
{
|
||||
name: "complex key with multiple slashes",
|
||||
forwardedPrefix: "/api/v1",
|
||||
urlPath: "/bucket/path//with///slashes",
|
||||
expected: "/api/v1/bucket/path//with///slashes",
|
||||
},
|
||||
{
|
||||
name: "urlPath without leading slash",
|
||||
forwardedPrefix: "/storage",
|
||||
urlPath: "bucket/obj",
|
||||
expected: "/storage/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "empty urlPath with prefix",
|
||||
forwardedPrefix: "/s3",
|
||||
urlPath: "",
|
||||
expected: "/s3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildPathWithForwardedPrefix(tt.forwardedPrefix, tt.urlPath)
|
||||
if result != tt.expected {
|
||||
t.Errorf("buildPathWithForwardedPrefix(%q, %q) = %q, want %q",
|
||||
tt.forwardedPrefix, tt.urlPath, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractHostHeader tests the extractHostHeader function with various scenarios
|
||||
func TestExtractHostHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostHeader string
|
||||
forwardedHost string
|
||||
forwardedPort string
|
||||
forwardedProto string
|
||||
externalHost string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "basic host without forwarding",
|
||||
hostHeader: "example.com",
|
||||
forwardedHost: "",
|
||||
forwardedPort: "",
|
||||
forwardedProto: "",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
name: "host with port without forwarding",
|
||||
hostHeader: "example.com:8080",
|
||||
forwardedHost: "",
|
||||
forwardedPort: "",
|
||||
forwardedProto: "",
|
||||
expected: "example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host without port",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "example.com",
|
||||
forwardedPort: "",
|
||||
forwardedProto: "",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with X-Forwarded-Port (HTTP non-standard)",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "example.com",
|
||||
forwardedPort: "8080",
|
||||
forwardedProto: "http",
|
||||
expected: "example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with X-Forwarded-Port (HTTPS non-standard)",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "example.com",
|
||||
forwardedPort: "8443",
|
||||
forwardedProto: "https",
|
||||
expected: "example.com:8443",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with X-Forwarded-Port (HTTP standard port 80)",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "example.com",
|
||||
forwardedPort: "80",
|
||||
forwardedProto: "http",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with X-Forwarded-Port (HTTPS standard port 443)",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "example.com",
|
||||
forwardedPort: "443",
|
||||
forwardedProto: "https",
|
||||
expected: "example.com",
|
||||
},
|
||||
// Issue #6649: X-Forwarded-Host already contains port (Traefik/HAProxy style)
|
||||
{
|
||||
name: "X-Forwarded-Host with port already included (should not add port again)",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "127.0.0.1:8433",
|
||||
forwardedPort: "8433",
|
||||
forwardedProto: "https",
|
||||
expected: "127.0.0.1:8433",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with standard port already included (HTTPS 443)",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "example.com:443",
|
||||
forwardedPort: "443",
|
||||
forwardedProto: "https",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with port, no X-Forwarded-Port header",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "example.com:9000",
|
||||
forwardedPort: "",
|
||||
forwardedProto: "http",
|
||||
expected: "example.com:9000",
|
||||
},
|
||||
// IPv6 test cases
|
||||
{
|
||||
name: "IPv6 address with brackets and port in X-Forwarded-Host",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "[::1]:8080",
|
||||
forwardedPort: "8080",
|
||||
forwardedProto: "http",
|
||||
expected: "[::1]:8080",
|
||||
},
|
||||
{
|
||||
name: "IPv6 address without brackets, should add brackets with port",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "::1",
|
||||
forwardedPort: "8080",
|
||||
forwardedProto: "http",
|
||||
expected: "[::1]:8080",
|
||||
},
|
||||
{
|
||||
name: "IPv6 address without brackets and standard port, should strip default port",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "::1",
|
||||
forwardedPort: "80",
|
||||
forwardedProto: "http",
|
||||
expected: "::1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 address without brackets and standard HTTPS port, should strip default port",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "2001:db8::1",
|
||||
forwardedPort: "443",
|
||||
forwardedProto: "https",
|
||||
expected: "2001:db8::1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 address with brackets but no port, should add port",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "[2001:db8::1]",
|
||||
forwardedPort: "8080",
|
||||
forwardedProto: "http",
|
||||
expected: "[2001:db8::1]:8080",
|
||||
},
|
||||
{
|
||||
name: "IPv6 full address with brackets and default port (should strip default port)",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "[2001:db8:85a3::8a2e:370:7334]:443",
|
||||
forwardedPort: "443",
|
||||
forwardedProto: "https",
|
||||
expected: "2001:db8:85a3::8a2e:370:7334",
|
||||
},
|
||||
{
|
||||
name: "IPv4-mapped IPv6 address without brackets, should add brackets with port",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "::ffff:127.0.0.1",
|
||||
forwardedPort: "8080",
|
||||
forwardedProto: "http",
|
||||
expected: "[::ffff:127.0.0.1]:8080",
|
||||
},
|
||||
{
|
||||
name: "Simple port 442",
|
||||
hostHeader: "bucket.domain.com:442",
|
||||
expected: "bucket.domain.com:442",
|
||||
},
|
||||
{
|
||||
name: "Port 442 with X-Forwarded-Host",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "bucket.domain.com:442",
|
||||
expected: "bucket.domain.com:442",
|
||||
},
|
||||
{
|
||||
name: "Port 442 with X-Forwarded-Port",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "bucket.domain.com",
|
||||
forwardedPort: "442",
|
||||
expected: "bucket.domain.com:442",
|
||||
},
|
||||
{
|
||||
name: "HTTPS with port 442 (should NOT strip)",
|
||||
hostHeader: "bucket.domain.com:442",
|
||||
forwardedProto: "https",
|
||||
expected: "bucket.domain.com:442",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with multiple hosts (including port)",
|
||||
forwardedHost: "bucket.domain.com:442, internal.proxy",
|
||||
expected: "bucket.domain.com:442",
|
||||
},
|
||||
{
|
||||
name: "IPv6 with port",
|
||||
hostHeader: "[2001:db8::1]:442",
|
||||
expected: "[2001:db8::1]:442",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with port 442, but X-Forwarded-Port is 80 (should PREFER 442)",
|
||||
forwardedHost: "bucket.domain.com:442",
|
||||
forwardedPort: "80",
|
||||
forwardedProto: "http",
|
||||
expected: "bucket.domain.com:442",
|
||||
},
|
||||
// externalHost override tests
|
||||
{
|
||||
name: "externalHost overrides everything",
|
||||
hostHeader: "backend:8333",
|
||||
externalHost: "api.example.com:9000",
|
||||
expected: "api.example.com:9000",
|
||||
},
|
||||
{
|
||||
name: "externalHost overrides X-Forwarded-Host",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "proxy.example.com",
|
||||
forwardedPort: "443",
|
||||
forwardedProto: "https",
|
||||
externalHost: "api.example.com",
|
||||
expected: "api.example.com",
|
||||
},
|
||||
{
|
||||
name: "externalHost with IPv6",
|
||||
hostHeader: "backend:8333",
|
||||
externalHost: "[::1]:9000",
|
||||
expected: "[::1]:9000",
|
||||
},
|
||||
// Bug fix: X-Forwarded-Port should not override more specific ports in other headers
|
||||
{
|
||||
name: "User reported case: X-Forwarded-Port misreports 443 but Host has 30007",
|
||||
hostHeader: "storage-stgops.mt.mtnet:30007",
|
||||
forwardedHost: "storage-stgops.mt.mtnet",
|
||||
forwardedPort: "443",
|
||||
forwardedProto: "https",
|
||||
expected: "storage-stgops.mt.mtnet:30007",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host already contains correct port, ignore misaligned X-Forwarded-Port",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "storage-stgops.mt.mtnet:30007",
|
||||
forwardedPort: "443",
|
||||
forwardedProto: "https",
|
||||
expected: "storage-stgops.mt.mtnet:30007",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host has no port, match r.Host hostname and take its port",
|
||||
hostHeader: "example.com:8080",
|
||||
forwardedHost: "example.com",
|
||||
forwardedPort: "80",
|
||||
forwardedProto: "http",
|
||||
expected: "example.com:8080",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a mock request
|
||||
req, err := http.NewRequest("GET", "http://"+tt.hostHeader+"/bucket/object", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Host = tt.hostHeader
|
||||
if tt.forwardedHost != "" {
|
||||
req.Header.Set("X-Forwarded-Host", tt.forwardedHost)
|
||||
}
|
||||
if tt.forwardedPort != "" {
|
||||
req.Header.Set("X-Forwarded-Port", tt.forwardedPort)
|
||||
}
|
||||
if tt.forwardedProto != "" {
|
||||
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||
}
|
||||
|
||||
// Test the function
|
||||
result := extractHostHeader(req, tt.externalHost)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractHostHeader() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractHostHeaderCandidates tests the alternate host values tried during verification
|
||||
// when the true client-facing host is ambiguous behind a reverse proxy.
|
||||
func TestExtractHostHeaderCandidates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostHeader string
|
||||
forwardedHost string
|
||||
forwardedPort string
|
||||
forwardedProto string
|
||||
externalHost string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "externalHost leads, request host still follows",
|
||||
hostHeader: "backend:8333",
|
||||
externalHost: "api.example.com:9000",
|
||||
expected: []string{"api.example.com:9000", "backend:8333"},
|
||||
},
|
||||
{
|
||||
name: "externalHost leads the forwarded candidates",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "example.com",
|
||||
forwardedPort: "9000",
|
||||
externalHost: "api.example.com",
|
||||
expected: []string{"api.example.com", "example.com:9000", "example.com"},
|
||||
},
|
||||
{
|
||||
name: "externalHost equal to the request host is not repeated",
|
||||
hostHeader: "api.example.com:9000",
|
||||
externalHost: "api.example.com:9000",
|
||||
expected: []string{"api.example.com:9000"},
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host with port is trusted as-is",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "example.com:9000",
|
||||
forwardedPort: "443",
|
||||
expected: []string{"example.com:9000"},
|
||||
},
|
||||
{
|
||||
name: "plain Host with port is the only candidate",
|
||||
hostHeader: "example.com:8080",
|
||||
expected: []string{"example.com:8080"},
|
||||
},
|
||||
{
|
||||
name: "portless X-Forwarded-Host, hostnames match: r.Host port first, then X-Forwarded-Port, then bare",
|
||||
hostHeader: "example.com:8333",
|
||||
forwardedHost: "example.com",
|
||||
forwardedPort: "9000",
|
||||
forwardedProto: "http",
|
||||
expected: []string{"example.com:8333", "example.com:9000", "example.com"},
|
||||
},
|
||||
{
|
||||
name: "portless X-Forwarded-Host, hostnames match, no X-Forwarded-Port: bare host as fallback",
|
||||
hostHeader: "example.com:8333",
|
||||
forwardedHost: "example.com",
|
||||
expected: []string{"example.com:8333", "example.com"},
|
||||
},
|
||||
{
|
||||
name: "portless X-Forwarded-Host, hostnames differ: X-Forwarded-Port, then bare",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "example.com",
|
||||
forwardedPort: "9000",
|
||||
expected: []string{"example.com:9000", "example.com"},
|
||||
},
|
||||
{
|
||||
name: "default X-Forwarded-Port collapses into the bare candidate",
|
||||
hostHeader: "example.com:8333",
|
||||
forwardedHost: "example.com",
|
||||
forwardedPort: "443",
|
||||
forwardedProto: "https",
|
||||
expected: []string{"example.com:8333", "example.com"},
|
||||
},
|
||||
{
|
||||
name: "portless Host with X-Forwarded-Port: forwarded port, then bare",
|
||||
hostHeader: "example.com",
|
||||
forwardedPort: "9000",
|
||||
expected: []string{"example.com:9000", "example.com"},
|
||||
},
|
||||
{
|
||||
name: "bracketed portless IPv6 X-Forwarded-Host matches the request host",
|
||||
hostHeader: "[::1]:8333",
|
||||
forwardedHost: "[::1]",
|
||||
expected: []string{"[::1]:8333", "::1"},
|
||||
},
|
||||
{
|
||||
name: "unbracketed portless IPv6 X-Forwarded-Host with X-Forwarded-Port",
|
||||
hostHeader: "backend:8333",
|
||||
forwardedHost: "::1",
|
||||
forwardedPort: "8080",
|
||||
expected: []string{"[::1]:8080", "::1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "http://"+tt.hostHeader+"/bucket/object", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Host = tt.hostHeader
|
||||
if tt.forwardedHost != "" {
|
||||
req.Header.Set("X-Forwarded-Host", tt.forwardedHost)
|
||||
}
|
||||
if tt.forwardedPort != "" {
|
||||
req.Header.Set("X-Forwarded-Port", tt.forwardedPort)
|
||||
}
|
||||
if tt.forwardedProto != "" {
|
||||
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||
}
|
||||
|
||||
result := extractHostHeaderCandidates(req, tt.externalHost)
|
||||
if !slices.Equal(result, tt.expected) {
|
||||
t.Errorf("extractHostHeaderCandidates() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSignedHeadersCase(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
signedHeads []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "lowercase host",
|
||||
host: "bucket.domain.com:442",
|
||||
signedHeads: []string{"host"},
|
||||
expected: "host:bucket.domain.com:442\n",
|
||||
},
|
||||
{
|
||||
name: "uppercase Host",
|
||||
host: "bucket.domain.com:442",
|
||||
signedHeads: []string{"Host"},
|
||||
expected: "host:bucket.domain.com:442\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "http://"+tt.host+"/", nil)
|
||||
r.Host = tt.host
|
||||
extracted, errCode := extractSignedHeaders(tt.signedHeads, r, "")
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Fatalf("extractSignedHeaders failed: %v", errCode)
|
||||
}
|
||||
actual := getCanonicalHeaders(extracted)
|
||||
if actual != tt.expected {
|
||||
t.Errorf("%s: expected %q, got %q", tt.name, tt.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
295
weed/s3api/auth_signature_v4_unsigned_headers_test.go
Normal file
295
weed/s3api/auth_signature_v4_unsigned_headers_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// TestVerifySignedHeadersCoverage_Unit exercises the helper directly.
|
||||
func TestVerifySignedHeadersCoverage_Unit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
signedHeaders []string
|
||||
isPresigned bool
|
||||
want s3err.ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "no x-amz headers is fine",
|
||||
headers: map[string]string{"Content-Type": "text/plain"},
|
||||
signedHeaders: []string{"host"},
|
||||
want: s3err.ErrNone,
|
||||
},
|
||||
{
|
||||
name: "signed x-amz header is accepted",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Date": "20250101T000000Z",
|
||||
"X-Amz-Tagging": "a=b",
|
||||
},
|
||||
signedHeaders: []string{"host", "x-amz-date", "x-amz-tagging"},
|
||||
want: s3err.ErrNone,
|
||||
},
|
||||
{
|
||||
name: "unsigned x-amz-tagging is rejected (header-based)",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Date": "20250101T000000Z",
|
||||
"X-Amz-Tagging": "secret=pwn",
|
||||
},
|
||||
signedHeaders: []string{"host", "x-amz-date"},
|
||||
want: s3err.ErrSignatureDoesNotMatch,
|
||||
},
|
||||
{
|
||||
name: "unsigned x-amz-meta-* is rejected",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Meta-Owner": "attacker",
|
||||
},
|
||||
signedHeaders: []string{"host"},
|
||||
want: s3err.ErrSignatureDoesNotMatch,
|
||||
},
|
||||
{
|
||||
name: "unsigned x-amz-acl is rejected",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Acl": "public-read",
|
||||
},
|
||||
signedHeaders: []string{"host"},
|
||||
want: s3err.ErrSignatureDoesNotMatch,
|
||||
},
|
||||
{
|
||||
name: "unsigned x-amz-storage-class is rejected",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Storage-Class": "GLACIER",
|
||||
},
|
||||
signedHeaders: []string{"host"},
|
||||
want: s3err.ErrSignatureDoesNotMatch,
|
||||
},
|
||||
{
|
||||
name: "unsigned x-amz-server-side-encryption is rejected",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Server-Side-Encryption": "AES256",
|
||||
},
|
||||
signedHeaders: []string{"host"},
|
||||
want: s3err.ErrSignatureDoesNotMatch,
|
||||
},
|
||||
{
|
||||
name: "unsigned x-amz-object-lock-retain-until-date is rejected",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Object-Lock-Retain-Until-Date": "2099-01-01T00:00:00Z",
|
||||
},
|
||||
signedHeaders: []string{"host"},
|
||||
want: s3err.ErrSignatureDoesNotMatch,
|
||||
},
|
||||
{
|
||||
name: "unsigned x-amz-security-token is rejected (even presigned)",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Security-Token": "attacker-token",
|
||||
},
|
||||
signedHeaders: []string{"host"},
|
||||
isPresigned: true,
|
||||
want: s3err.ErrSignatureDoesNotMatch,
|
||||
},
|
||||
{
|
||||
name: "x-amz-content-sha256 is always exempt (presigned)",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD",
|
||||
},
|
||||
signedHeaders: []string{"host"},
|
||||
isPresigned: true,
|
||||
want: s3err.ErrNone,
|
||||
},
|
||||
{
|
||||
name: "x-amz-content-sha256 is always exempt (header-based)",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Content-Sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
},
|
||||
signedHeaders: []string{"host"},
|
||||
isPresigned: false,
|
||||
want: s3err.ErrNone,
|
||||
},
|
||||
{
|
||||
name: "presigned exempts sigv4 query params echoed as headers",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
||||
"X-Amz-Credential": "AKIA/20250101/us-east-1/s3/aws4_request",
|
||||
"X-Amz-Date": "20250101T000000Z",
|
||||
"X-Amz-Expires": "3600",
|
||||
"X-Amz-SignedHeaders": "host",
|
||||
"X-Amz-Signature": "deadbeef",
|
||||
},
|
||||
signedHeaders: []string{"host"},
|
||||
isPresigned: true,
|
||||
want: s3err.ErrNone,
|
||||
},
|
||||
{
|
||||
name: "header-based does NOT exempt x-amz-date when unsigned",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Date": "20250101T000000Z",
|
||||
},
|
||||
signedHeaders: []string{"host"},
|
||||
isPresigned: false,
|
||||
want: s3err.ErrSignatureDoesNotMatch,
|
||||
},
|
||||
{
|
||||
name: "signed headers match is case-insensitive",
|
||||
headers: map[string]string{
|
||||
"X-Amz-Tagging": "a=b",
|
||||
},
|
||||
signedHeaders: []string{"HOST", "X-Amz-Tagging"},
|
||||
want: s3err.ErrNone,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPut, "http://example.com/bucket/key", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
for k, v := range tt.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
got := verifySignedHeadersCoverage(req, tt.signedHeaders, tt.isPresigned)
|
||||
if got != tt.want {
|
||||
t.Fatalf("verifySignedHeadersCoverage = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresignedPutRejectsUnsignedTagging exercises the full verification path
|
||||
// through reqSignatureV4Verify for a presigned URL that signs only `host` but
|
||||
// whose caller attached an x-amz-tagging header after-the-fact.
|
||||
func TestPresignedPutRejectsUnsignedTagging(t *testing.T) {
|
||||
iam := newTestIAM()
|
||||
|
||||
req, err := newTestRequest(http.MethodPut, "http://127.0.0.1:9000/bucket/key", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newTestRequest: %v", err)
|
||||
}
|
||||
if err := preSignV4(iam, req, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 600); err != nil {
|
||||
t.Fatalf("preSignV4: %v", err)
|
||||
}
|
||||
// Attacker appends an unsigned header after the URL was signed.
|
||||
req.Header.Set("X-Amz-Tagging", "classification=public")
|
||||
|
||||
_, errCode := iam.reqSignatureV4Verify(req)
|
||||
if errCode != s3err.ErrSignatureDoesNotMatch {
|
||||
t.Fatalf("expected ErrSignatureDoesNotMatch for unsigned x-amz-tagging, got %v", errCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresignedPutAcceptsSignedTagging confirms that a presigned URL whose
|
||||
// SignedHeaders list covers x-amz-tagging still validates successfully.
|
||||
func TestPresignedPutAcceptsSignedTagging(t *testing.T) {
|
||||
iam := newTestIAM()
|
||||
|
||||
req, err := newTestRequest(http.MethodPut, "http://127.0.0.1:9000/bucket/key", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newTestRequest: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Amz-Tagging", "classification=public")
|
||||
if err := preSignV4WithHeaders(iam, req, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 600, []string{"host", "x-amz-tagging"}); err != nil {
|
||||
t.Fatalf("preSignV4WithHeaders: %v", err)
|
||||
}
|
||||
|
||||
_, errCode := iam.reqSignatureV4Verify(req)
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Fatalf("expected ErrNone for signed x-amz-tagging, got %v", errCode)
|
||||
}
|
||||
}
|
||||
|
||||
// preSignV4WithHeaders is a test helper that builds a presigned URL whose
|
||||
// SignedHeaders list covers the specified headers (which must already be set
|
||||
// on the request), then computes the signature over those headers and
|
||||
// attaches X-Amz-Signature.
|
||||
func preSignV4WithHeaders(_ *IdentityAccessManagement, req *http.Request, accessKey, secretKey string, expires int64, signedHeaders []string) error {
|
||||
now := time.Now().UTC()
|
||||
dateStr := now.Format(iso8601Format)
|
||||
|
||||
scope := fmt.Sprintf("%s/%s/%s/%s", now.Format(yyyymmdd), "us-east-1", "s3", "aws4_request")
|
||||
credential := fmt.Sprintf("%s/%s", accessKey, scope)
|
||||
|
||||
normalized := make([]string, 0, len(signedHeaders))
|
||||
for _, h := range signedHeaders {
|
||||
normalized = append(normalized, strings.ToLower(h))
|
||||
}
|
||||
sort.Strings(normalized)
|
||||
|
||||
query := req.URL.Query()
|
||||
query.Set("X-Amz-Algorithm", signV4Algorithm)
|
||||
query.Set("X-Amz-Credential", credential)
|
||||
query.Set("X-Amz-Date", dateStr)
|
||||
query.Set("X-Amz-Expires", fmt.Sprintf("%d", expires))
|
||||
query.Set("X-Amz-SignedHeaders", strings.Join(normalized, ";"))
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
hashedPayload := query.Get("X-Amz-Content-Sha256")
|
||||
if hashedPayload == "" {
|
||||
hashedPayload = unsignedPayload
|
||||
}
|
||||
|
||||
extracted := make(http.Header)
|
||||
for _, h := range normalized {
|
||||
if h == "host" {
|
||||
extracted[h] = []string{req.Host}
|
||||
continue
|
||||
}
|
||||
if values, ok := req.Header[http.CanonicalHeaderKey(h)]; ok {
|
||||
extracted[h] = values
|
||||
}
|
||||
}
|
||||
|
||||
canonicalRequest := getCanonicalRequest(extracted, hashedPayload, req.URL.RawQuery, req.URL.Path, req.Method)
|
||||
stringToSign := getStringToSign(canonicalRequest, now, scope)
|
||||
signingKey := getSigningKey(secretKey, now.Format(yyyymmdd), "us-east-1", "s3")
|
||||
signature := getSignature(signingKey, stringToSign)
|
||||
|
||||
query.Set("X-Amz-Signature", signature)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestPresignedPutRejectsUnsignedMetadataHeaders checks a spread of
|
||||
// security-relevant headers that the PUT handler persists.
|
||||
func TestPresignedPutRejectsUnsignedMetadataHeaders(t *testing.T) {
|
||||
dangerous := []struct {
|
||||
name string
|
||||
header string
|
||||
value string
|
||||
}{
|
||||
{"acl", "X-Amz-Acl", "public-read"},
|
||||
{"user-metadata", "X-Amz-Meta-Owner", "attacker"},
|
||||
{"storage-class", "X-Amz-Storage-Class", "GLACIER"},
|
||||
{"server-side-encryption", "X-Amz-Server-Side-Encryption", "AES256"},
|
||||
{"sse-kms-key-id", "X-Amz-Server-Side-Encryption-Aws-Kms-Key-Id", "kms-key"},
|
||||
{"object-lock-mode", "X-Amz-Object-Lock-Mode", "GOVERNANCE"},
|
||||
{"object-lock-retain-until", "X-Amz-Object-Lock-Retain-Until-Date", "2099-01-01T00:00:00Z"},
|
||||
{"website-redirect-location", "X-Amz-Website-Redirect-Location", "https://attacker.example/"},
|
||||
{"grant-full-control", "X-Amz-Grant-Full-Control", "id=attacker"},
|
||||
}
|
||||
|
||||
for _, c := range dangerous {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
iam := newTestIAM()
|
||||
|
||||
req, err := newTestRequest(http.MethodPut, "http://127.0.0.1:9000/bucket/key", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newTestRequest: %v", err)
|
||||
}
|
||||
if err := preSignV4(iam, req, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 600); err != nil {
|
||||
t.Fatalf("preSignV4: %v", err)
|
||||
}
|
||||
req.Header.Set(c.header, c.value)
|
||||
|
||||
_, errCode := iam.reqSignatureV4Verify(req)
|
||||
if errCode != s3err.ErrSignatureDoesNotMatch {
|
||||
t.Fatalf("expected rejection for unsigned %s, got %v", c.header, errCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
396
weed/s3api/auth_sts_identity_test.go
Normal file
396
weed/s3api/auth_sts_identity_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// TestSTSIdentityPolicyNamesPopulation tests that STS identities have PolicyNames
|
||||
// populated from sessionInfo.Policies, enabling IAM-based authorization.
|
||||
// This is a regression test for GitHub issue #7985.
|
||||
func TestSTSIdentityPolicyNamesPopulation(t *testing.T) {
|
||||
// Setup: Create a mock STS service
|
||||
stsService, config := setupTestSTSService(t)
|
||||
|
||||
// Create IAM with STS integration
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
|
||||
s3iam := &S3IAMIntegration{
|
||||
stsService: stsService,
|
||||
}
|
||||
iam.SetIAMIntegration(s3iam)
|
||||
|
||||
// Generate a session token with policies
|
||||
sessionId := "test-session-id"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
// Create session claims with policies
|
||||
sessionClaims := sts.NewSTSSessionClaims(sessionId, config.Issuer, expiresAt).
|
||||
WithSessionName("test-session").
|
||||
WithRoleInfo("arn:aws:iam::role/adminRole", "arn:aws:sts::assumed-role/adminRole/test-session", "arn:aws:sts::assumed-role/adminRole/test-session")
|
||||
|
||||
// Add policies to the session claims
|
||||
sessionClaims.Policies = []string{"AdminPolicy", "S3FullAccess"}
|
||||
|
||||
// Generate JWT token
|
||||
tokenGen := sts.NewTokenGenerator(config.SigningKey, config.Issuer)
|
||||
sessionToken, err := tokenGen.GenerateJWTWithClaims(sessionClaims)
|
||||
require.NoError(t, err, "Should generate session token successfully")
|
||||
|
||||
// Validate the session token
|
||||
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), sessionToken)
|
||||
require.NoError(t, err, "Session token should be valid")
|
||||
require.NotNil(t, sessionInfo, "Session info should not be nil")
|
||||
|
||||
// Verify that sessionInfo has policies
|
||||
assert.NotEmpty(t, sessionInfo.Policies, "SessionInfo should have policies")
|
||||
assert.Equal(t, []string{"AdminPolicy", "S3FullAccess"}, sessionInfo.Policies, "Policies should match")
|
||||
|
||||
// Create a mock credential from session info
|
||||
cred := &Credential{
|
||||
AccessKey: sessionInfo.Credentials.AccessKeyId,
|
||||
SecretKey: sessionInfo.Credentials.SecretAccessKey,
|
||||
Status: "Active",
|
||||
Expiration: sessionInfo.ExpiresAt.Unix(),
|
||||
}
|
||||
|
||||
// Create identity as validateSTSSessionToken does
|
||||
identity := &Identity{
|
||||
Name: sessionInfo.AssumedRoleUser,
|
||||
Account: &AccountAdmin,
|
||||
Credentials: []*Credential{cred},
|
||||
PrincipalArn: sessionInfo.Principal,
|
||||
PolicyNames: sessionInfo.Policies, // This is the fix for issue #7985
|
||||
}
|
||||
|
||||
// Verify the fix: PolicyNames should be populated
|
||||
assert.NotNil(t, identity.PolicyNames, "Identity PolicyNames should not be nil")
|
||||
assert.Equal(t, []string{"AdminPolicy", "S3FullAccess"}, identity.PolicyNames, "PolicyNames should be populated from sessionInfo.Policies")
|
||||
|
||||
// Verify that Actions is empty (STS identities should use IAM authorization, not legacy Actions)
|
||||
assert.Empty(t, identity.Actions, "STS identities should have empty Actions to trigger IAM authorization path")
|
||||
|
||||
// Verify legacy CanDo returns false (forcing fallback to IAM)
|
||||
assert.False(t, identity.CanDo("Read", "test-bucket", "/any/path"),
|
||||
"CanDo should return false for STS identities with no Actions")
|
||||
|
||||
// Verify authorization path selection
|
||||
// When identity.Actions is empty and iamIntegration is available, it should use IAM authorization
|
||||
hasActions := len(identity.Actions) > 0
|
||||
hasIAMIntegration := iam.iamIntegration != nil
|
||||
|
||||
assert.False(t, hasActions, "STS identity should not have Actions")
|
||||
assert.True(t, hasIAMIntegration, "IAM integration should be available")
|
||||
|
||||
// This combination means authorization will go through the IAM path
|
||||
t.Log("✓ STS identity will use IAM authorization path (correct behavior)")
|
||||
}
|
||||
|
||||
// TestSTSIdentityAuthorizationFlow tests the complete authorization flow for STS identities
|
||||
// to ensure they are properly authorized via IAM integration.
|
||||
func TestSTSIdentityAuthorizationFlow(t *testing.T) {
|
||||
// This test validates that:
|
||||
// 1. STS identities have empty Actions
|
||||
// 2. STS identities have PolicyNames populated
|
||||
// 3. Authorization logic routes to IAM path when Actions is empty and iamIntegration exists
|
||||
|
||||
// Create a mock STS session info
|
||||
sessionInfo := &sts.SessionInfo{
|
||||
SessionId: "test-session",
|
||||
SessionName: "s3-session",
|
||||
RoleArn: "arn:aws:iam::role/adminRole",
|
||||
AssumedRoleUser: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
Principal: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
Policies: []string{"AdminPolicy", "S3WritePolicy"},
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
Credentials: &sts.Credentials{
|
||||
AccessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
SessionToken: "test-session-token",
|
||||
Expiration: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
// Create credential from session info
|
||||
cred := &Credential{
|
||||
AccessKey: sessionInfo.Credentials.AccessKeyId,
|
||||
SecretKey: sessionInfo.Credentials.SecretAccessKey,
|
||||
Status: "Active",
|
||||
Expiration: sessionInfo.ExpiresAt.Unix(),
|
||||
}
|
||||
|
||||
// Create identity as validateSTSSessionToken does (with the fix)
|
||||
identity := &Identity{
|
||||
Name: sessionInfo.AssumedRoleUser,
|
||||
Account: &AccountAdmin,
|
||||
Credentials: []*Credential{cred},
|
||||
PrincipalArn: sessionInfo.Principal,
|
||||
PolicyNames: sessionInfo.Policies, // Fix for #7985
|
||||
}
|
||||
|
||||
// Test 1: Verify PolicyNames are populated
|
||||
assert.Equal(t, []string{"AdminPolicy", "S3WritePolicy"}, identity.PolicyNames,
|
||||
"PolicyNames should be populated from sessionInfo.Policies")
|
||||
|
||||
assert.Empty(t, identity.Actions,
|
||||
"STS identities should have empty Actions to trigger the IAM authorization path")
|
||||
|
||||
// Test 2: Verify CanDo returns false (legacy auth should be bypassed)
|
||||
// This is important because it confirms that identity.Actions being empty
|
||||
// correctly forces the authorization logic to fall back to iam.authorizeWithIAM
|
||||
assert.False(t, identity.CanDo("Read", "test-bucket", "/any/path"),
|
||||
"CanDo should return false for STS identities with no Actions")
|
||||
|
||||
// With empty Actions and populated PolicyNames, IAM authorization path will be used
|
||||
// as per auth_credentials.go:703-713
|
||||
t.Log("✓ Verified: STS identity correctly bypasses legacy CanDo() to use IAM authorization path")
|
||||
}
|
||||
|
||||
// TestSTSIdentityWithoutPolicyNames tests the bug scenario where PolicyNames is not populated
|
||||
// This reproduces the original issue #7985
|
||||
func TestSTSIdentityWithoutPolicyNames(t *testing.T) {
|
||||
// Create identity WITHOUT PolicyNames (the bug scenario)
|
||||
identityBuggy := newTestIdentity(nil)
|
||||
|
||||
// Create identity WITH PolicyNames (the fix)
|
||||
identityFixed := newTestIdentity([]string{"AdminPolicy"})
|
||||
|
||||
// Verify the bug scenario
|
||||
assert.Empty(t, identityBuggy.Actions, "Buggy identity has no Actions")
|
||||
assert.Empty(t, identityBuggy.PolicyNames, "Buggy identity has no PolicyNames (the bug)")
|
||||
|
||||
// Verify the fix
|
||||
assert.Empty(t, identityFixed.Actions, "Fixed identity has no Actions (correct)")
|
||||
assert.NotEmpty(t, identityFixed.PolicyNames, "Fixed identity has PolicyNames (the fix)")
|
||||
|
||||
t.Logf("Bug scenario: Actions=%d, PolicyNames=%d → Would be denied",
|
||||
len(identityBuggy.Actions), len(identityBuggy.PolicyNames))
|
||||
t.Logf("Fixed scenario: Actions=%d, PolicyNames=%d → Can use IAM authorization",
|
||||
len(identityFixed.Actions), len(identityFixed.PolicyNames))
|
||||
}
|
||||
|
||||
// TestCanDoPathConstruction tests that bucket and objectKey are properly concatenated
|
||||
// with a slash separator. This is a regression test for the second issue mentioned in #7985.
|
||||
func TestCanDoPathConstruction(t *testing.T) {
|
||||
// Create a test identity with wildcard actions (standard pattern)
|
||||
identity := &Identity{
|
||||
Name: "test-user",
|
||||
Account: &AccountAdmin,
|
||||
Actions: []Action{
|
||||
"Write:test-bucket/*", // Wildcard for all objects
|
||||
"Read:test-bucket/docs/*", // Wildcard for specific path
|
||||
},
|
||||
}
|
||||
|
||||
// Test cases for path construction
|
||||
testCases := []struct {
|
||||
name string
|
||||
action Action
|
||||
bucket string
|
||||
objectKey string
|
||||
shouldPass bool
|
||||
}{
|
||||
{
|
||||
name: "Wildcard match for write",
|
||||
action: "Write",
|
||||
bucket: "test-bucket",
|
||||
objectKey: "path/to/object.txt",
|
||||
shouldPass: true,
|
||||
},
|
||||
{
|
||||
name: "Wildcard match for read in docs path",
|
||||
action: "Read",
|
||||
bucket: "test-bucket",
|
||||
objectKey: "docs/file.txt",
|
||||
shouldPass: true,
|
||||
},
|
||||
{
|
||||
name: "No match - wrong action",
|
||||
action: "Delete",
|
||||
bucket: "test-bucket",
|
||||
objectKey: "file.txt",
|
||||
shouldPass: false,
|
||||
},
|
||||
{
|
||||
name: "No match - read outside docs path",
|
||||
action: "Read",
|
||||
bucket: "test-bucket",
|
||||
objectKey: "other/file.txt",
|
||||
shouldPass: false,
|
||||
},
|
||||
{
|
||||
name: "ObjectKey with leading slash",
|
||||
action: "Read",
|
||||
bucket: "test-bucket",
|
||||
objectKey: "/docs/file.txt", // Already has leading slash
|
||||
shouldPass: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := identity.CanDo(tc.action, tc.bucket, tc.objectKey)
|
||||
|
||||
// Robust path construction for verification
|
||||
fullPath := tc.bucket
|
||||
if tc.objectKey != "" && !strings.HasPrefix(tc.objectKey, "/") {
|
||||
fullPath += "/"
|
||||
}
|
||||
fullPath += tc.objectKey
|
||||
|
||||
t.Logf("Testing path: %s", fullPath)
|
||||
|
||||
if tc.shouldPass {
|
||||
assert.True(t, result, "Should allow action %s on %s", tc.action, fullPath)
|
||||
} else {
|
||||
assert.False(t, result, "Should deny action %s on %s", tc.action, fullPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateSTSSessionTokenIntegration is an integration test that validates
|
||||
// the complete flow from session token validation to identity creation
|
||||
func TestValidateSTSSessionTokenIntegration(t *testing.T) {
|
||||
// Setup STS service
|
||||
stsService, config := setupTestSTSService(t)
|
||||
|
||||
// Create IAM with STS integration
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
|
||||
s3iam := &S3IAMIntegration{
|
||||
stsService: stsService,
|
||||
}
|
||||
iam.SetIAMIntegration(s3iam)
|
||||
// Create a mock HTTP request with STS session token
|
||||
req, err := http.NewRequest("PUT", "/test-bucket/test-object.txt", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate session token
|
||||
sessionId := "integration-test-session"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
sessionClaims := sts.NewSTSSessionClaims(sessionId, config.Issuer, expiresAt).
|
||||
WithSessionName("integration-test").
|
||||
WithRoleInfo("arn:aws:iam::role/testRole", "arn:aws:sts::assumed-role/testRole/integration-test", "arn:aws:sts::assumed-role/testRole/integration-test")
|
||||
|
||||
sessionClaims.Policies = []string{"TestPolicy"}
|
||||
|
||||
tokenGen := sts.NewTokenGenerator(config.SigningKey, config.Issuer)
|
||||
sessionToken, err := tokenGen.GenerateJWTWithClaims(sessionClaims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate session token
|
||||
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), sessionToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sessionInfo)
|
||||
|
||||
// Validate session token and check identity creation
|
||||
identity, _, errCode := iam.validateSTSSessionToken(req, sessionToken, sessionInfo.Credentials.AccessKeyId)
|
||||
require.Equal(t, s3err.ErrNone, errCode)
|
||||
require.NotNil(t, identity)
|
||||
|
||||
// Verify the identity is properly configured for IAM authorization
|
||||
assert.Empty(t, identity.Actions, "STS identity should have empty Actions")
|
||||
assert.Equal(t, []string{"TestPolicy"}, identity.PolicyNames, "PolicyNames should be populated (this requires the fix in #7985)")
|
||||
assert.NotEmpty(t, identity.PrincipalArn, "PrincipalArn should be set")
|
||||
|
||||
t.Log("✓ Integration test passed: STS identity properly configured for IAM authorization")
|
||||
}
|
||||
|
||||
// TestSTSIdentityClaimsPopulation tests that Claims are properly populated from RequestContext
|
||||
// This is critical for policy variable substitution like ${jwt:preferred_username}
|
||||
func TestSTSIdentityClaimsPopulation(t *testing.T) {
|
||||
// Setup STS service
|
||||
stsService, config := setupTestSTSService(t)
|
||||
|
||||
// Create IAM with STS integration
|
||||
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory")
|
||||
s3iam := &S3IAMIntegration{
|
||||
stsService: stsService,
|
||||
}
|
||||
iam.SetIAMIntegration(s3iam)
|
||||
|
||||
// Create a mock HTTP request
|
||||
req, err := http.NewRequest("PUT", "/test-bucket/test-object.txt", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate session token with RequestContext containing user claims
|
||||
sessionId := "claims-test-session"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
sessionClaims := sts.NewSTSSessionClaims(sessionId, config.Issuer, expiresAt).
|
||||
WithSessionName("claims-test").
|
||||
WithRoleInfo("arn:aws:iam::role/S3UserRole", "arn:aws:sts::assumed-role/S3UserRole/claims-test", "arn:aws:sts::assumed-role/S3UserRole/claims-test")
|
||||
|
||||
// Add RequestContext with user claims (simulating AssumeRoleWithWebIdentity)
|
||||
sessionClaims.RequestContext = map[string]interface{}{
|
||||
"preferred_username": "f2wbnp",
|
||||
"email": "user@example.com",
|
||||
"name": "Test User",
|
||||
"groups": []string{"developers", "users"},
|
||||
}
|
||||
|
||||
sessionClaims.Policies = []string{"S3UserPolicy"}
|
||||
|
||||
tokenGen := sts.NewTokenGenerator(config.SigningKey, config.Issuer)
|
||||
sessionToken, err := tokenGen.GenerateJWTWithClaims(sessionClaims)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate session token
|
||||
sessionInfo, err := stsService.ValidateSessionToken(context.Background(), sessionToken)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sessionInfo)
|
||||
require.NotNil(t, sessionInfo.RequestContext, "RequestContext should be populated")
|
||||
|
||||
// Verify RequestContext has the claims
|
||||
assert.Equal(t, "f2wbnp", sessionInfo.RequestContext["preferred_username"])
|
||||
assert.Equal(t, "user@example.com", sessionInfo.RequestContext["email"])
|
||||
|
||||
// Validate session token and check identity creation
|
||||
identity, _, errCode := iam.validateSTSSessionToken(req, sessionToken, sessionInfo.Credentials.AccessKeyId)
|
||||
require.Equal(t, s3err.ErrNone, errCode)
|
||||
require.NotNil(t, identity)
|
||||
|
||||
// Verify Claims are populated from RequestContext
|
||||
assert.NotNil(t, identity.Claims, "Claims should be populated")
|
||||
assert.Equal(t, "f2wbnp", identity.Claims["preferred_username"], "preferred_username should be in Claims")
|
||||
assert.Equal(t, "user@example.com", identity.Claims["email"], "email should be in Claims")
|
||||
assert.Equal(t, "Test User", identity.Claims["name"], "name should be in Claims")
|
||||
|
||||
// Verify PolicyNames are also populated
|
||||
assert.Equal(t, []string{"S3UserPolicy"}, identity.PolicyNames)
|
||||
|
||||
t.Log("✓ Claims properly populated from RequestContext for policy variable substitution")
|
||||
}
|
||||
|
||||
// Helper functions for tests
|
||||
|
||||
func setupTestSTSService(t *testing.T) (*sts.STSService, *sts.STSConfig) {
|
||||
t.Helper()
|
||||
stsService := sts.NewSTSService()
|
||||
config := &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-issuer",
|
||||
SigningKey: []byte("test-signing-key-at-least-32-bytes-long-for-security"),
|
||||
}
|
||||
err := stsService.Initialize(config)
|
||||
require.NoError(t, err, "STS service should initialize successfully")
|
||||
return stsService, config
|
||||
}
|
||||
|
||||
func newTestIdentity(policyNames []string) *Identity {
|
||||
return &Identity{
|
||||
Name: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
Account: &AccountAdmin,
|
||||
Credentials: []*Credential{{AccessKey: "test", SecretKey: "test"}},
|
||||
PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session",
|
||||
PolicyNames: policyNames,
|
||||
}
|
||||
}
|
||||
149
weed/s3api/auth_sts_v4_test.go
Normal file
149
weed/s3api/auth_sts_v4_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// TestAuthorizeWithIAMSessionTokenExtraction tests that the authorizeWithIAM function
|
||||
// correctly extracts session tokens from multiple sources and prioritizes them appropriately.
|
||||
// This is a regression test for the bug where X-Amz-Security-Token was not being checked
|
||||
// for V4 signature authentication with STS credentials.
|
||||
func TestAuthorizeWithIAMSessionTokenExtraction(t *testing.T) {
|
||||
t.Run("Extracts X-SeaweedFS-Session-Token from JWT auth", func(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{
|
||||
"X-Seaweedfs-Session-Token": {"jwt-token-123"},
|
||||
"X-Seaweedfs-Principal": {"arn:aws:iam::user/test"},
|
||||
},
|
||||
URL: &url.URL{},
|
||||
}
|
||||
|
||||
// Extract tokens the same way authorizeWithIAM does
|
||||
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
|
||||
principal := req.Header.Get(s3_constants.SeaweedFSPrincipalHeader)
|
||||
|
||||
assert.Equal(t, "jwt-token-123", sessionToken, "Should extract JWT session token from header")
|
||||
assert.Equal(t, "arn:aws:iam::user/test", principal, "Should extract principal from header")
|
||||
})
|
||||
|
||||
t.Run("Extracts X-Amz-Security-Token from V4 STS auth header", func(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{
|
||||
"X-Amz-Security-Token": {"sts-token-header-456"},
|
||||
},
|
||||
URL: &url.URL{},
|
||||
}
|
||||
|
||||
// Extract tokens the same way authorizeWithIAM does
|
||||
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
|
||||
principal := req.Header.Get(s3_constants.SeaweedFSPrincipalHeader)
|
||||
|
||||
// If JWT token is empty, should fallback to X-Amz-Security-Token
|
||||
if sessionToken == "" {
|
||||
sessionToken = req.Header.Get("X-Amz-Security-Token")
|
||||
}
|
||||
|
||||
assert.Equal(t, "sts-token-header-456", sessionToken, "Should fallback to X-Amz-Security-Token when JWT token is empty")
|
||||
assert.Empty(t, principal, "JWT principal should be empty for V4 auth")
|
||||
})
|
||||
|
||||
t.Run("Extracts X-Amz-Security-Token from query parameter (presigned URL)", func(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{},
|
||||
URL: &url.URL{RawQuery: "X-Amz-Security-Token=sts-token-query-789"},
|
||||
}
|
||||
|
||||
// Extract tokens the same way authorizeWithIAM does
|
||||
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
|
||||
if sessionToken == "" {
|
||||
sessionToken = req.Header.Get("X-Amz-Security-Token")
|
||||
if sessionToken == "" {
|
||||
sessionToken = req.URL.Query().Get("X-Amz-Security-Token")
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, "sts-token-query-789", sessionToken, "Should extract token from query parameter")
|
||||
})
|
||||
|
||||
t.Run("JWT token takes precedence over X-Amz-Security-Token", func(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{
|
||||
"X-Seaweedfs-Session-Token": {"jwt-preferred"},
|
||||
"X-Seaweedfs-Principal": {"arn:aws:iam::user/jwt-user"},
|
||||
"X-Amz-Security-Token": {"sts-fallback"},
|
||||
},
|
||||
URL: &url.URL{},
|
||||
}
|
||||
|
||||
// Extract tokens the same way authorizeWithIAM does
|
||||
sessionToken := req.Header.Get(s3_constants.SeaweedFSSessionTokenHeader)
|
||||
if sessionToken == "" {
|
||||
sessionToken = req.Header.Get("X-Amz-Security-Token")
|
||||
}
|
||||
|
||||
assert.Equal(t, "jwt-preferred", sessionToken, "JWT token should take precedence")
|
||||
})
|
||||
}
|
||||
|
||||
// TestSTSSessionTokenIntoCredentials verifies that STS session tokens are properly
|
||||
// preserved when converting to credentials for authorization.
|
||||
func TestSTSSessionTokenIntoCredentials(t *testing.T) {
|
||||
// Create a credential generator and session claims
|
||||
credGen := sts.NewCredentialGenerator()
|
||||
sessionId := "test-session-123"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
// Generate temporary credentials
|
||||
creds, err := credGen.GenerateTemporaryCredentials(sessionId, expiresAt)
|
||||
require.NoError(t, err, "Should generate credentials successfully")
|
||||
require.NotNil(t, creds, "Credentials should not be nil")
|
||||
|
||||
// Verify all credential fields are present
|
||||
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should be present")
|
||||
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should be present")
|
||||
assert.NotEmpty(t, creds.SessionToken, "SessionToken should be present for STS")
|
||||
|
||||
// Verify deterministic generation (same session ID produces same credentials)
|
||||
creds2, err := credGen.GenerateTemporaryCredentials(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, creds.AccessKeyId, creds2.AccessKeyId, "AccessKeyId should be deterministic")
|
||||
assert.Equal(t, creds.SecretAccessKey, creds2.SecretAccessKey, "SecretAccessKey should be deterministic")
|
||||
assert.Equal(t, creds.SessionToken, creds2.SessionToken, "SessionToken should be deterministic for same sessionId")
|
||||
|
||||
// Verify different session produces different credentials
|
||||
creds3, err := credGen.GenerateTemporaryCredentials("different-session", expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, creds.AccessKeyId, creds3.AccessKeyId, "Different sessions should produce different access key IDs")
|
||||
assert.NotEqual(t, creds.SecretAccessKey, creds3.SecretAccessKey, "Different sessions should produce different secret keys")
|
||||
assert.NotEqual(t, creds.SessionToken, creds3.SessionToken, "Different sessions should produce different session tokens")
|
||||
}
|
||||
|
||||
// TestActionConstantsForV4Auth verifies that action constants are properly available
|
||||
// for use in authorization checks with V4 signature authentication.
|
||||
func TestActionConstantsForV4Auth(t *testing.T) {
|
||||
// Verify that S3 action constants are available
|
||||
actions := map[string]string{
|
||||
"READ": s3_constants.ACTION_READ,
|
||||
"WRITE": s3_constants.ACTION_WRITE,
|
||||
"READ_ACP": s3_constants.ACTION_READ_ACP,
|
||||
"WRITE_ACP": s3_constants.ACTION_WRITE_ACP,
|
||||
"LIST": s3_constants.ACTION_LIST,
|
||||
"TAGGING": s3_constants.ACTION_TAGGING,
|
||||
"ADMIN": s3_constants.ACTION_ADMIN,
|
||||
}
|
||||
|
||||
for name, action := range actions {
|
||||
assert.NotEmpty(t, action, "Action %s should not be empty", name)
|
||||
}
|
||||
}
|
||||
2037
weed/s3api/auto_signature_v4_test.go
Normal file
2037
weed/s3api/auto_signature_v4_test.go
Normal file
File diff suppressed because it is too large
Load Diff
210
weed/s3api/bucket_metadata.go
Normal file
210
weed/s3api/bucket_metadata.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var loadBucketMetadataFromFiler = func(r *BucketRegistry, bucketName string) (*BucketMetaData, error) {
|
||||
entry, err := r.s3a.getBucketEntry(bucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildBucketMetadata(r.s3a.iam, entry), nil
|
||||
}
|
||||
|
||||
type BucketMetaData struct {
|
||||
_ struct{} `type:"structure"`
|
||||
|
||||
Name string
|
||||
// Indicates the bucket is a table bucket.
|
||||
IsTableBucket bool
|
||||
|
||||
//By default, when another AWS account uploads an object to S3 bucket,
|
||||
//that account (the object writer) owns the object, has access to it, and
|
||||
//can grant other users access to it through ACLs. You can use Object Ownership
|
||||
//to change this default behavior so that ACLs are disabled and you, as the
|
||||
//bucket owner, automatically own every object in your bucket.
|
||||
ObjectOwnership string
|
||||
|
||||
// Container for the bucket owner's display name and ID.
|
||||
Owner *s3.Owner `type:"structure"`
|
||||
|
||||
// A list of grants for access controls.
|
||||
Acl []*s3.Grant `locationName:"AccessControlList" locationNameList:"Grant" type:"list"`
|
||||
}
|
||||
|
||||
type BucketRegistry struct {
|
||||
metadataCache *lru.Cache[string, *BucketMetaData]
|
||||
|
||||
notFound *lru.Cache[string, struct{}]
|
||||
// loadGroup deduplicates concurrent filer loads of the same bucket
|
||||
// without serializing loads of different buckets
|
||||
loadGroup singleflight.Group
|
||||
s3a *S3ApiServer
|
||||
}
|
||||
|
||||
// NewBucketRegistry creates a lazy registry: nothing is listed at startup,
|
||||
// buckets load from the filer on first access and stay fresh via the
|
||||
// metadata subscription.
|
||||
func NewBucketRegistry(s3a *S3ApiServer) *BucketRegistry {
|
||||
metadataCache, _ := lru.New[string, *BucketMetaData](bucketCacheCapacity)
|
||||
notFound, _ := lru.New[string, struct{}](bucketCacheCapacity)
|
||||
return &BucketRegistry{
|
||||
metadataCache: metadataCache,
|
||||
notFound: notFound,
|
||||
s3a: s3a,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadBucketMetadata refreshes a bucket already resident in the cache from a
|
||||
// subscription event. Cold buckets are left to lazy-load on first access so
|
||||
// the cache holds only this gateway's working set.
|
||||
func (r *BucketRegistry) LoadBucketMetadata(entry *filer_pb.Entry) {
|
||||
if r.metadataCache.Contains(entry.Name) {
|
||||
r.metadataCache.Add(entry.Name, buildBucketMetadata(r.s3a.iam, entry))
|
||||
}
|
||||
// Remove from notFound cache since bucket now exists
|
||||
r.unMarkNotFound(entry.Name)
|
||||
}
|
||||
|
||||
// bucketOwnerAccountId returns the account id owning the bucket entry. A bucket
|
||||
// created outside the S3 API (the admin UI, weed shell) records only its owning
|
||||
// identity, so that identity is resolved to its account. Without this such a
|
||||
// bucket looks unowned: it reports the default admin account as its ACL owner,
|
||||
// and under BucketOwnerEnforced every object written to it is stamped with that
|
||||
// account instead of the bucket owner.
|
||||
func bucketOwnerAccountId(accountManager AccountManager, entry *filer_pb.Entry) string {
|
||||
if ownerAccountId := string(entry.Extended[s3_constants.ExtAmzOwnerKey]); ownerAccountId != "" {
|
||||
return ownerAccountId
|
||||
}
|
||||
if identityName := string(entry.Extended[s3_constants.AmzIdentityId]); identityName != "" {
|
||||
return accountManager.GetAccountIdByIdentityName(identityName)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildBucketMetadata(accountManager AccountManager, entry *filer_pb.Entry) *BucketMetaData {
|
||||
entryJson, _ := json.Marshal(entry)
|
||||
glog.V(3).Infof("build bucket metadata,entry=%s", entryJson)
|
||||
bucketMetadata := &BucketMetaData{
|
||||
Name: entry.Name,
|
||||
IsTableBucket: s3tables.IsTableBucketEntry(entry),
|
||||
|
||||
//Default ownership: OwnershipBucketOwnerEnforced, which means Acl is disabled
|
||||
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
|
||||
|
||||
// Default owner: `AccountAdmin`
|
||||
Owner: &s3.Owner{
|
||||
ID: &AccountAdmin.Id,
|
||||
DisplayName: &AccountAdmin.DisplayName,
|
||||
},
|
||||
}
|
||||
if entry.Extended != nil {
|
||||
//ownership control
|
||||
if ownership, ok := entry.Extended[s3_constants.ExtOwnershipKey]; ok {
|
||||
if !s3_constants.ValidateOwnership(string(ownership)) {
|
||||
glog.Warningf("Invalid ownership: %s, bucket: %s", string(ownership), bucketMetadata.Name)
|
||||
}
|
||||
bucketMetadata.ObjectOwnership = s3_constants.EffectiveOwnership(string(ownership))
|
||||
}
|
||||
|
||||
//access control policy
|
||||
//owner
|
||||
ownerAccountId := bucketOwnerAccountId(accountManager, entry)
|
||||
if ownerAccountId != "" {
|
||||
ownerAccountName := accountManager.GetAccountNameById(ownerAccountId)
|
||||
if ownerAccountName == "" {
|
||||
glog.Warningf("owner[id=%s] is invalid, bucket: %s", ownerAccountId, bucketMetadata.Name)
|
||||
} else {
|
||||
bucketMetadata.Owner = &s3.Owner{
|
||||
ID: &ownerAccountId,
|
||||
DisplayName: &ownerAccountName,
|
||||
}
|
||||
}
|
||||
}
|
||||
//grants
|
||||
acpGrantsBytes, ok := entry.Extended[s3_constants.ExtAmzAclKey]
|
||||
if ok && len(acpGrantsBytes) > 0 {
|
||||
var grants []*s3.Grant
|
||||
err := json.Unmarshal(acpGrantsBytes, &grants)
|
||||
if err == nil {
|
||||
bucketMetadata.Acl = grants
|
||||
} else {
|
||||
glog.Warningf("Unmarshal ACP grants: %s(%v), bucket: %s", string(acpGrantsBytes), err, bucketMetadata.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return bucketMetadata
|
||||
}
|
||||
|
||||
func (r *BucketRegistry) RemoveBucketMetadata(entry *filer_pb.Entry) {
|
||||
r.removeMetadataCache(entry.Name)
|
||||
r.unMarkNotFound(entry.Name)
|
||||
}
|
||||
|
||||
func (r *BucketRegistry) GetBucketMetadata(bucketName string) (*BucketMetaData, s3err.ErrorCode) {
|
||||
bucketMetadata, ok := r.metadataCache.Get(bucketName)
|
||||
if ok {
|
||||
return bucketMetadata, s3err.ErrNone
|
||||
}
|
||||
|
||||
if r.notFound.Contains(bucketName) {
|
||||
return nil, s3err.ErrNoSuchBucket
|
||||
}
|
||||
|
||||
return r.LoadBucketMetadataFromFiler(bucketName)
|
||||
}
|
||||
|
||||
// LoadBucketMetadataFromFiler loads the bucket from the filer; concurrent
|
||||
// calls for the same bucket share one load, and the cache is filled inside
|
||||
// the flight so a bucket is fetched only once.
|
||||
func (r *BucketRegistry) LoadBucketMetadataFromFiler(bucketName string) (*BucketMetaData, s3err.ErrorCode) {
|
||||
metadata, err, _ := r.loadGroup.Do(bucketName, func() (interface{}, error) {
|
||||
//check if already exists
|
||||
if bucketMetaData, ok := r.metadataCache.Get(bucketName); ok {
|
||||
return bucketMetaData, nil
|
||||
}
|
||||
|
||||
//if not exists, load from filer
|
||||
bucketMetadata, err := loadBucketMetadataFromFiler(r, bucketName)
|
||||
if err != nil {
|
||||
if err == filer_pb.ErrNotFound {
|
||||
// The bucket doesn't actually exist and should no longer loaded from the filer
|
||||
r.notFound.Add(bucketName, struct{}{})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
r.setMetadataCache(bucketMetadata)
|
||||
r.unMarkNotFound(bucketName)
|
||||
return bucketMetadata, nil
|
||||
})
|
||||
if err != nil {
|
||||
if err == filer_pb.ErrNotFound {
|
||||
return nil, s3err.ErrNoSuchBucket
|
||||
}
|
||||
return nil, s3err.ErrInternalError
|
||||
}
|
||||
return metadata.(*BucketMetaData), s3err.ErrNone
|
||||
}
|
||||
|
||||
func (r *BucketRegistry) setMetadataCache(metadata *BucketMetaData) {
|
||||
r.metadataCache.Add(metadata.Name, metadata)
|
||||
}
|
||||
|
||||
func (r *BucketRegistry) removeMetadataCache(bucket string) {
|
||||
r.metadataCache.Remove(bucket)
|
||||
}
|
||||
|
||||
func (r *BucketRegistry) unMarkNotFound(bucket string) {
|
||||
r.notFound.Remove(bucket)
|
||||
}
|
||||
227
weed/s3api/bucket_metadata_test.go
Normal file
227
weed/s3api/bucket_metadata_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
type BucketMetadataTestCase struct {
|
||||
filerEntry *filer_pb.Entry
|
||||
expectBucketMetadata *BucketMetaData
|
||||
}
|
||||
|
||||
var (
|
||||
//bad entry
|
||||
badEntry = &filer_pb.Entry{
|
||||
Name: "badEntry",
|
||||
}
|
||||
|
||||
//good entry
|
||||
goodEntryAcl, _ = json.Marshal(s3_constants.PublicRead)
|
||||
goodEntry = &filer_pb.Entry{
|
||||
Name: "entryWithValidAcp",
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtOwnershipKey: []byte(s3_constants.OwnershipBucketOwnerEnforced),
|
||||
s3_constants.ExtAmzOwnerKey: []byte(AccountAdmin.DisplayName),
|
||||
s3_constants.ExtAmzAclKey: goodEntryAcl,
|
||||
},
|
||||
}
|
||||
|
||||
//ownership is ""
|
||||
ownershipEmptyStr = &filer_pb.Entry{
|
||||
Name: "ownershipEmptyStr",
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtOwnershipKey: []byte(""),
|
||||
},
|
||||
}
|
||||
|
||||
//ownership valid
|
||||
ownershipValid = &filer_pb.Entry{
|
||||
Name: "ownershipValid",
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtOwnershipKey: []byte(s3_constants.OwnershipBucketOwnerEnforced),
|
||||
},
|
||||
}
|
||||
|
||||
//owner is ""
|
||||
acpEmptyStr = &filer_pb.Entry{
|
||||
Name: "acpEmptyStr",
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtAmzOwnerKey: []byte(""),
|
||||
},
|
||||
}
|
||||
|
||||
//owner not exists
|
||||
acpEmptyObject = &filer_pb.Entry{
|
||||
Name: "acpEmptyObject",
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtAmzOwnerKey: []byte("xxxxx"),
|
||||
},
|
||||
}
|
||||
|
||||
//grants is nil
|
||||
acpOwnerNilAcp, _ = json.Marshal(make([]*s3.Grant, 0))
|
||||
acpOwnerNil = &filer_pb.Entry{
|
||||
Name: "acpOwnerNil",
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtAmzAclKey: acpOwnerNilAcp,
|
||||
},
|
||||
}
|
||||
|
||||
//load filer is
|
||||
loadFilerBucket = make(map[string]int, 1)
|
||||
loadFilerBucketLock sync.Mutex
|
||||
//override `loadBucketMetadataFromFiler` to avoid really load from filer
|
||||
)
|
||||
|
||||
var tcs = []*BucketMetadataTestCase{
|
||||
{
|
||||
badEntry, &BucketMetaData{
|
||||
Name: badEntry.Name,
|
||||
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
|
||||
Owner: &s3.Owner{
|
||||
DisplayName: &AccountAdmin.DisplayName,
|
||||
ID: &AccountAdmin.Id,
|
||||
},
|
||||
Acl: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
goodEntry, &BucketMetaData{
|
||||
Name: goodEntry.Name,
|
||||
ObjectOwnership: s3_constants.OwnershipBucketOwnerEnforced,
|
||||
Owner: &s3.Owner{
|
||||
DisplayName: &AccountAdmin.DisplayName,
|
||||
ID: &AccountAdmin.Id,
|
||||
},
|
||||
Acl: s3_constants.PublicRead,
|
||||
},
|
||||
},
|
||||
{
|
||||
ownershipEmptyStr, &BucketMetaData{
|
||||
Name: ownershipEmptyStr.Name,
|
||||
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
|
||||
Owner: &s3.Owner{
|
||||
DisplayName: &AccountAdmin.DisplayName,
|
||||
ID: &AccountAdmin.Id,
|
||||
},
|
||||
Acl: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
ownershipValid, &BucketMetaData{
|
||||
Name: ownershipValid.Name,
|
||||
ObjectOwnership: s3_constants.OwnershipBucketOwnerEnforced,
|
||||
Owner: &s3.Owner{
|
||||
DisplayName: &AccountAdmin.DisplayName,
|
||||
ID: &AccountAdmin.Id,
|
||||
},
|
||||
Acl: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
acpEmptyStr, &BucketMetaData{
|
||||
Name: acpEmptyStr.Name,
|
||||
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
|
||||
Owner: &s3.Owner{
|
||||
DisplayName: &AccountAdmin.DisplayName,
|
||||
ID: &AccountAdmin.Id,
|
||||
},
|
||||
Acl: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
acpEmptyObject, &BucketMetaData{
|
||||
Name: acpEmptyObject.Name,
|
||||
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
|
||||
Owner: &s3.Owner{
|
||||
DisplayName: &AccountAdmin.DisplayName,
|
||||
ID: &AccountAdmin.Id,
|
||||
},
|
||||
Acl: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
acpOwnerNil, &BucketMetaData{
|
||||
Name: acpOwnerNil.Name,
|
||||
ObjectOwnership: s3_constants.DefaultOwnershipForExists,
|
||||
Owner: &s3.Owner{
|
||||
DisplayName: &AccountAdmin.DisplayName,
|
||||
ID: &AccountAdmin.Id,
|
||||
},
|
||||
Acl: make([]*s3.Grant, 0),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestBuildBucketMetadata(t *testing.T) {
|
||||
iam := &IdentityAccessManagement{}
|
||||
_ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{})
|
||||
for _, tc := range tcs {
|
||||
resultBucketMetadata := buildBucketMetadata(iam, tc.filerEntry)
|
||||
if !reflect.DeepEqual(resultBucketMetadata, tc.expectBucketMetadata) {
|
||||
t.Fatalf("result is unexpected: \nresult: %v, \nexpect: %v", resultBucketMetadata, tc.expectBucketMetadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketMetadata(t *testing.T) {
|
||||
loadBucketMetadataFromFiler = func(r *BucketRegistry, bucketName string) (*BucketMetaData, error) {
|
||||
time.Sleep(time.Second)
|
||||
loadFilerBucketLock.Lock()
|
||||
loadFilerBucket[bucketName] = loadFilerBucket[bucketName] + 1
|
||||
loadFilerBucketLock.Unlock()
|
||||
return &BucketMetaData{
|
||||
Name: bucketName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
br := NewBucketRegistry(nil)
|
||||
|
||||
//start 40 goroutine for
|
||||
var wg sync.WaitGroup
|
||||
closeCh := make(chan struct{})
|
||||
for i := 0; i < 40; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
outLoop:
|
||||
for {
|
||||
for j := 0; j < 5; j++ {
|
||||
select {
|
||||
case <-closeCh:
|
||||
break outLoop
|
||||
default:
|
||||
reqBucket := fmt.Sprintf("%c", 67+j)
|
||||
_, errCode := br.GetBucketMetadata(reqBucket)
|
||||
if errCode != s3err.ErrNone {
|
||||
close(closeCh)
|
||||
t.Error("not expect")
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Microsecond)
|
||||
}
|
||||
}()
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
close(closeCh)
|
||||
wg.Wait()
|
||||
|
||||
//Each bucket is loaded from the filer only once
|
||||
for bucketName, loadCount := range loadFilerBucket {
|
||||
if loadCount != 1 {
|
||||
t.Fatalf("lock is uneffict: %s, %d", bucketName, loadCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
86
weed/s3api/bucket_owner_identity_test.go
Normal file
86
weed/s3api/bucket_owner_identity_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ownerIdentityIAM loads an account-less identity and one scoped to an explicit
|
||||
// account, the two shapes a bucket owner name can resolve through.
|
||||
func ownerIdentityIAM(t *testing.T) *IdentityAccessManagement {
|
||||
t.Helper()
|
||||
iam := &IdentityAccessManagement{}
|
||||
require.NoError(t, iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
|
||||
Identities: []*iam_pb.Identity{
|
||||
{Name: "test", Actions: []string{"Read", "Write"}},
|
||||
{
|
||||
Name: "scoped",
|
||||
Actions: []string{"Read", "Write"},
|
||||
Account: &iam_pb.Account{Id: "100000000001", DisplayName: "Scoped"},
|
||||
},
|
||||
},
|
||||
}))
|
||||
return iam
|
||||
}
|
||||
|
||||
// A bucket created outside the S3 API — the admin UI or weed shell, which record
|
||||
// the owner as an identity and never write the account id the S3 API stores — is
|
||||
// owned by that identity, not by the default admin account. Getting this wrong
|
||||
// stamps every object uploaded to the bucket with the admin account, since the
|
||||
// default BucketOwnerEnforced ownership hands objects to the bucket owner.
|
||||
func TestBucketOwnerFromIdentityId(t *testing.T) {
|
||||
iam := ownerIdentityIAM(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
identityId string
|
||||
expectOwnerId string
|
||||
}{
|
||||
{"account-less identity", "test", "test"},
|
||||
{"identity scoped to an account", "scoped", "100000000001"},
|
||||
{"unknown identity", "ghost", AccountAdmin.Id},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "bucket",
|
||||
Extended: map[string][]byte{s3_constants.AmzIdentityId: []byte(tc.identityId)},
|
||||
}
|
||||
|
||||
metadata := buildBucketMetadata(iam, entry)
|
||||
require.NotNil(t, metadata.Owner)
|
||||
assert.Equal(t, tc.expectOwnerId, *metadata.Owner.ID)
|
||||
|
||||
s3a := &S3ApiServer{iam: iam}
|
||||
config := s3a.newBucketConfigFromEntry("bucket", entry)
|
||||
assert.Equal(t, tc.identityId, config.IdentityId)
|
||||
if tc.expectOwnerId != AccountAdmin.Id {
|
||||
assert.Equal(t, tc.expectOwnerId, config.Owner,
|
||||
"the bucket config owner must match the metadata owner")
|
||||
} else {
|
||||
assert.Empty(t, config.Owner, "an unresolvable owner leaves the config unowned")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The account id recorded by the S3 API stays authoritative when both are present.
|
||||
func TestBucketOwnerPrefersAccountId(t *testing.T) {
|
||||
iam := ownerIdentityIAM(t)
|
||||
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "bucket",
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.AmzIdentityId: []byte("scoped"),
|
||||
s3_constants.ExtAmzOwnerKey: []byte("test"),
|
||||
},
|
||||
}
|
||||
|
||||
metadata := buildBucketMetadata(iam, entry)
|
||||
require.NotNil(t, metadata.Owner)
|
||||
assert.Equal(t, "test", *metadata.Owner.ID)
|
||||
}
|
||||
108
weed/s3api/bucket_paths.go
Normal file
108
weed/s3api/bucket_paths.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
var tableBucketFileValidator = s3tables.NewTableBucketFileValidator()
|
||||
|
||||
func (s3a *S3ApiServer) isTableBucket(bucket string) bool {
|
||||
if bucket == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if s3a.bucketRegistry != nil {
|
||||
metadata, errCode := s3a.bucketRegistry.GetBucketMetadata(bucket)
|
||||
if errCode != s3err.ErrNone {
|
||||
if errCode != s3err.ErrNoSuchBucket {
|
||||
glog.V(1).Infof("bucket lookup failed for %s: %v", bucket, errCode)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return metadata.IsTableBucket
|
||||
}
|
||||
|
||||
entry, err := s3a.getEntry(s3a.option.BucketsPath, bucket)
|
||||
if err == nil && entry != nil {
|
||||
return s3tables.IsTableBucketEntry(entry)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
glog.V(1).Infof("bucket lookup failed for %s: %v", bucket, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) bucketRoot(bucket string) string {
|
||||
// Returns the unified buckets root path for all bucket types
|
||||
return s3a.option.BucketsPath
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) bucketDir(bucket string) string {
|
||||
return path.Join(s3a.bucketRoot(bucket), bucket)
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) validateTableBucketObjectPath(bucket, object string) error {
|
||||
if !s3a.isTableBucket(bucket) {
|
||||
return nil
|
||||
}
|
||||
cleanObject := strings.TrimPrefix(object, "/")
|
||||
if cleanObject == "" {
|
||||
return &s3tables.IcebergLayoutError{
|
||||
Code: s3tables.ErrCodeInvalidIcebergLayout,
|
||||
Message: "object must be under namespace/table/data or metadata",
|
||||
}
|
||||
}
|
||||
fullPath := s3a.bucketDir(bucket)
|
||||
if !strings.HasSuffix(fullPath, "/") {
|
||||
fullPath += "/"
|
||||
}
|
||||
fullPath += cleanObject
|
||||
if err := tableBucketFileValidator.ValidateTableBucketUpload(fullPath); err != nil {
|
||||
return err
|
||||
}
|
||||
parts := strings.SplitN(cleanObject, "/", 4)
|
||||
// A table's marker files sit at namespace/table/<marker>, one level above
|
||||
// everything else, so they are three parts rather than four.
|
||||
if len(parts) == 3 && s3tables.IsTableMarkerFile(parts[2]) {
|
||||
return nil
|
||||
}
|
||||
if len(parts) < 4 {
|
||||
return &s3tables.IcebergLayoutError{
|
||||
Code: s3tables.ErrCodeInvalidIcebergLayout,
|
||||
Message: "object must be under namespace/table/data or metadata",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) bucketPrefix(bucket string) string {
|
||||
return s3a.bucketDir(bucket) + "/"
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) bucketExists(bucket string) (bool, error) {
|
||||
entry, err := s3a.getBucketEntry(bucket)
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return entry != nil, nil
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) getBucketEntry(bucket string) (*filer_pb.Entry, error) {
|
||||
// Dot-prefixed entries under /buckets are internal (.system) and can never
|
||||
// be valid bucket names; don't let them resolve as buckets.
|
||||
if strings.HasPrefix(bucket, ".") {
|
||||
return nil, filer_pb.ErrNotFound
|
||||
}
|
||||
return s3a.getEntry(s3a.option.BucketsPath, bucket)
|
||||
}
|
||||
261
weed/s3api/bucket_size_metrics.go
Normal file
261
weed/s3api/bucket_size_metrics.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/stats"
|
||||
)
|
||||
|
||||
const (
|
||||
bucketSizeMetricsInterval = 1 * time.Minute
|
||||
listBucketPageSize = 1000 // Page size for paginated bucket listing
|
||||
s3MetricsLockName = "s3.leader"
|
||||
)
|
||||
|
||||
// CollectionInfo holds collection statistics
|
||||
// Used for both metrics collection and quota enforcement
|
||||
type CollectionInfo struct {
|
||||
FileCount float64
|
||||
DeleteCount float64
|
||||
DeletedByteCount float64
|
||||
Size float64 // Single-copy volume size (deduplicated by volume ID), still counting un-vacuumed garbage
|
||||
PhysicalSize float64 // Physical size (including all replicas)
|
||||
VolumeCount int // Logical volume count (deduplicated by volume ID)
|
||||
}
|
||||
|
||||
// LogicalSize is the live data size: single-copy volume size minus the
|
||||
// un-vacuumed deleted/overwritten bytes. Quota enforcement and the
|
||||
// bucket_size_bytes metric use this so vacuum lag never counts against a
|
||||
// bucket, matching the usage figure the Admin UI shows.
|
||||
func (c *CollectionInfo) LogicalSize() float64 {
|
||||
if c.Size < c.DeletedByteCount {
|
||||
return 0
|
||||
}
|
||||
return c.Size - c.DeletedByteCount
|
||||
}
|
||||
|
||||
// startBucketSizeMetricsLoop periodically collects bucket size metrics and updates Prometheus gauges.
|
||||
// Uses a distributed lock to ensure only one S3 instance collects metrics at a time.
|
||||
// Should be called as a goroutine; stops when the provided context is cancelled.
|
||||
func (s3a *S3ApiServer) startBucketSizeMetricsLoop(ctx context.Context) {
|
||||
// Initial delay to let the system stabilize
|
||||
select {
|
||||
case <-time.After(10 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
// Create lock client for distributed lock
|
||||
if len(s3a.option.Filers) == 0 {
|
||||
glog.V(1).Infof("No filers configured, skipping bucket size metrics collection")
|
||||
return
|
||||
}
|
||||
filer := s3a.option.Filers[0]
|
||||
lockClient := cluster.NewLockClient(s3a.option.GrpcDialOption, filer)
|
||||
owner := string(filer) + "-s3-metrics"
|
||||
|
||||
// Start long-lived lock - this S3 instance will only collect metrics when it holds the lock
|
||||
lock := lockClient.StartLongLivedLock(s3MetricsLockName, owner, func(newLockOwner string) {
|
||||
glog.V(1).Infof("S3 bucket size metrics lock owner changed to: %s", newLockOwner)
|
||||
}, bucketSizeMetricsInterval)
|
||||
defer lock.Stop()
|
||||
|
||||
ticker := time.NewTicker(bucketSizeMetricsInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
glog.V(1).Infof("Stopping bucket size metrics collection")
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Only collect metrics if we hold the lock
|
||||
if lock.IsLocked() {
|
||||
s3a.collectAndUpdateBucketSizeMetrics(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collectAndUpdateBucketSizeMetrics collects bucket sizes from master topology
|
||||
// and updates Prometheus metrics. Uses the same approach as quota enforcement.
|
||||
func (s3a *S3ApiServer) collectAndUpdateBucketSizeMetrics(ctx context.Context) {
|
||||
// Collect collection info from master topology (same as quota enforcement)
|
||||
collectionInfos, err := s3a.collectCollectionInfoFromMaster(ctx)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Failed to collect collection info from master: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get list of buckets
|
||||
buckets, err := s3a.listBuckets(ctx)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Failed to list buckets for size metrics: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Map collections to buckets and update metrics
|
||||
for _, bucket := range buckets {
|
||||
collection := s3a.getCollectionName(bucket.Name)
|
||||
if info, found := collectionInfos[collection]; found {
|
||||
stats.UpdateBucketSizeMetrics(bucket.Name, info.LogicalSize(), info.PhysicalSize, info.FileCount)
|
||||
glog.V(3).Infof("Updated bucket size metrics: bucket=%s, logicalSize=%.0f, physicalSize=%.0f, objects=%.0f",
|
||||
bucket.Name, info.LogicalSize(), info.PhysicalSize, info.FileCount)
|
||||
} else {
|
||||
// Bucket exists but no collection data (empty bucket)
|
||||
stats.UpdateBucketSizeMetrics(bucket.Name, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
s3a.enforceBucketQuotas(ctx, buckets, collectionInfos)
|
||||
}
|
||||
|
||||
// enforceBucketQuotas flips each bucket's read-only flag to match its quota,
|
||||
// rewriting filer.conf only when a flag changes.
|
||||
func (s3a *S3ApiServer) enforceBucketQuotas(ctx context.Context, buckets []*filer_pb.Entry, collectionInfos map[string]*CollectionInfo) {
|
||||
if len(s3a.option.Filers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fc, err := filer.ReadFilerConfFromFilers(s3a.option.Filers, s3a.option.GrpcDialOption, nil)
|
||||
if err != nil {
|
||||
glog.V(1).Infof("read filer.conf for quota enforcement: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, bucket := range buckets {
|
||||
var size float64
|
||||
if info, found := collectionInfos[s3a.getCollectionName(bucket.Name)]; found {
|
||||
size = info.LogicalSize()
|
||||
}
|
||||
locPrefix := s3a.option.BucketsPath + "/" + bucket.Name + "/"
|
||||
readOnly, flipped := fc.ApplyBucketQuotaReadOnly(locPrefix, size, float64(bucket.Quota))
|
||||
stats.UpdateBucketQuotaMetrics(bucket.Name, float64(bucket.Quota), readOnly)
|
||||
if flipped {
|
||||
changed = true
|
||||
glog.V(0).Infof("bucket %s quota enforcement: readOnly=%v (size=%.0f quota=%d)", bucket.Name, readOnly, size, bucket.Quota)
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := fc.ToText(&buf); err != nil {
|
||||
glog.Errorf("serialize filer.conf for quota enforcement: %v", err)
|
||||
return
|
||||
}
|
||||
if err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
return filer.SaveInsideFiler(ctx, client, filer.DirectoryEtcSeaweedFS, filer.FilerConfName, buf.Bytes())
|
||||
}); err != nil {
|
||||
glog.Errorf("save filer.conf for quota enforcement: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// collectCollectionInfoFromMaster queries the master for topology info and extracts collection sizes.
|
||||
// This is the same approach used by shell command s3.bucket.quota.enforce.
|
||||
func (s3a *S3ApiServer) collectCollectionInfoFromMaster(ctx context.Context) (map[string]*CollectionInfo, error) {
|
||||
if len(s3a.option.Masters) == 0 {
|
||||
return nil, fmt.Errorf("no masters configured")
|
||||
}
|
||||
|
||||
// Convert masters slice to map for WithOneOfGrpcMasterClients
|
||||
masterMap := make(map[string]pb.ServerAddress)
|
||||
for _, master := range s3a.option.Masters {
|
||||
masterMap[string(master)] = master
|
||||
}
|
||||
|
||||
// Ask the master to summarise. Adding this up here instead would mean
|
||||
// being sent every volume in the cluster once a minute.
|
||||
collectionInfos := make(map[string]*CollectionInfo)
|
||||
|
||||
err := pb.WithOneOfGrpcMasterClients(false, masterMap, s3a.option.GrpcDialOption, func(client master_pb.SeaweedClient) error {
|
||||
resp, err := client.CollectionStatistics(ctx, &master_pb.CollectionStatisticsRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get collection statistics: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("empty collection statistics from master")
|
||||
}
|
||||
for _, c := range resp.Collections {
|
||||
collectionInfos[c.Collection] = &CollectionInfo{
|
||||
FileCount: float64(c.FileCount),
|
||||
DeleteCount: float64(c.DeleteCount),
|
||||
DeletedByteCount: float64(c.DeletedByteCount),
|
||||
Size: float64(c.Size),
|
||||
PhysicalSize: float64(c.PhysicalSize),
|
||||
VolumeCount: int(c.VolumeCount),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return collectionInfos, nil
|
||||
}
|
||||
|
||||
// listBuckets returns all bucket directory entries using pagination.
|
||||
func (s3a *S3ApiServer) listBuckets(ctx context.Context) ([]*filer_pb.Entry, error) {
|
||||
var buckets []*filer_pb.Entry
|
||||
|
||||
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
lastFileName := ""
|
||||
for {
|
||||
request := &filer_pb.ListEntriesRequest{
|
||||
Directory: s3a.option.BucketsPath,
|
||||
StartFromFileName: lastFileName,
|
||||
Limit: listBucketPageSize,
|
||||
InclusiveStartFrom: lastFileName == "",
|
||||
}
|
||||
|
||||
stream, err := client.ListEntries(ctx, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entriesReceived := 0
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("error receiving bucket list entries: %w", err)
|
||||
}
|
||||
entriesReceived++
|
||||
if resp.Entry != nil {
|
||||
lastFileName = resp.Entry.Name
|
||||
if resp.Entry.IsDirectory {
|
||||
// Skip .uploads and other hidden directories
|
||||
if !strings.HasPrefix(resp.Entry.Name, ".") {
|
||||
buckets = append(buckets, resp.Entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we got fewer entries than the limit, we're done
|
||||
if entriesReceived < listBucketPageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return buckets, err
|
||||
}
|
||||
31
weed/s3api/bucket_size_metrics_test.go
Normal file
31
weed/s3api/bucket_size_metrics_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCollectionInfoLogicalSize verifies logical size excludes un-vacuumed
|
||||
// garbage and never goes negative. Quota enforcement runs on this value so a
|
||||
// bucket full of tombstones is not flipped read-only while its live data is
|
||||
// under quota.
|
||||
func TestCollectionInfoLogicalSize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
size float64
|
||||
deleted float64
|
||||
expected float64
|
||||
}{
|
||||
{"no garbage", 1000, 0, 1000},
|
||||
{"some garbage", 1000, 300, 700},
|
||||
{"all garbage", 1000, 1000, 0},
|
||||
{"deleted exceeds size clamps to zero", 300, 1000, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
info := &CollectionInfo{Size: tc.size, DeletedByteCount: tc.deleted}
|
||||
if got := info.LogicalSize(); got != tc.expected {
|
||||
t.Errorf("LogicalSize(): got %.0f, want %.0f", got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
55
weed/s3api/chunked_bug_reproduction_test.go
Normal file
55
weed/s3api/chunked_bug_reproduction_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// TestChunkedEncodingMixedFormat tests the fix for GitHub issue #6847
|
||||
// where AWS SDKs send mixed format: unsigned streaming headers but signed chunk data
|
||||
func TestChunkedEncodingMixedFormat(t *testing.T) {
|
||||
expectedContent := "hello world\n"
|
||||
|
||||
// Create the problematic mixed format payload:
|
||||
// - Unsigned streaming headers (STREAMING-UNSIGNED-PAYLOAD-TRAILER)
|
||||
// - But chunk data contains chunk-signature headers
|
||||
mixedFormatPayload := "c;chunk-signature=347f6c62acd95b7c6ae18648776024a9e8cd6151184a5e777ea8e1d9b4e45b3c\r\n" +
|
||||
"hello world\n\r\n" +
|
||||
"0;chunk-signature=1a99b7790b8db0f4bfc048c8802056c3179d561e40c073167e79db5f1a6af4b2\r\n" +
|
||||
"x-amz-checksum-crc32:rwg7LQ==\r\n" +
|
||||
"\r\n"
|
||||
|
||||
// Create HTTP request with unsigned streaming headers
|
||||
req, _ := http.NewRequest("PUT", "/test-bucket/test-object", bytes.NewReader([]byte(mixedFormatPayload)))
|
||||
req.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
|
||||
req.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
|
||||
|
||||
// Process through SeaweedFS chunked reader
|
||||
iam := setupTestIAM()
|
||||
reader, errCode := iam.newChunkedReader(req)
|
||||
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Fatalf("Failed to create chunked reader: %v", errCode)
|
||||
}
|
||||
|
||||
// Read the content
|
||||
actualContent, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read content: %v", err)
|
||||
}
|
||||
|
||||
// Should correctly extract just the content, ignoring chunk signatures
|
||||
if string(actualContent) != expectedContent {
|
||||
t.Errorf("Mixed format handling failed. Expected: %q, Got: %q", expectedContent, string(actualContent))
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestIAM creates a test IAM instance using the same pattern as existing tests
|
||||
func setupTestIAM() *IdentityAccessManagement {
|
||||
iam := &IdentityAccessManagement{}
|
||||
return iam
|
||||
}
|
||||
620
weed/s3api/chunked_reader_v4.go
Normal file
620
weed/s3api/chunked_reader_v4.go
Normal file
@@ -0,0 +1,620 @@
|
||||
package s3api
|
||||
|
||||
// the related code is copied and modified from minio source code
|
||||
|
||||
/*
|
||||
* Minio Cloud Storage, (C) 2016 Minio, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/minio/crc64nvme"
|
||||
)
|
||||
|
||||
// calculateSeedSignature - Calculate seed signature in accordance with
|
||||
// - http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html
|
||||
//
|
||||
// returns signature, error otherwise if the signature mismatches or any other
|
||||
// error while parsing and validating.
|
||||
func (iam *IdentityAccessManagement) calculateSeedSignature(r *http.Request) (cred *Credential, signature string, region string, service string, date time.Time, errCode s3err.ErrorCode) {
|
||||
_, credential, calculatedSignature, authInfo, errCode := iam.verifyV4Signature(r, true)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, "", "", "", time.Time{}, errCode
|
||||
}
|
||||
|
||||
// This check ensures we only proceed for streaming uploads.
|
||||
switch authInfo.HashedPayload {
|
||||
case streamingContentSHA256, streamingContentSHA256Trailer:
|
||||
glog.V(3).Infof("streaming content sha256 (with trailer: %v)", authInfo.HashedPayload == streamingContentSHA256Trailer)
|
||||
case streamingUnsignedPayload:
|
||||
glog.V(3).Infof("streaming unsigned payload")
|
||||
default:
|
||||
return nil, "", "", "", time.Time{}, s3err.ErrContentSHA256Mismatch
|
||||
}
|
||||
|
||||
return credential, calculatedSignature, authInfo.Region, authInfo.Service, authInfo.Date, s3err.ErrNone
|
||||
}
|
||||
|
||||
const maxLineLength = 4 * humanize.KiByte // assumed <= bufio.defaultBufSize 4KiB
|
||||
|
||||
// lineTooLong is generated as chunk header is bigger than 4KiB.
|
||||
var errLineTooLong = errors.New("header line too long")
|
||||
|
||||
// Malformed encoding is generated when chunk header is wrongly formed.
|
||||
var errMalformedEncoding = errors.New("malformed chunked encoding")
|
||||
|
||||
// newChunkedReader returns a new s3ChunkedReader that translates the data read from r
|
||||
// out of HTTP "chunked" format before returning it.
|
||||
// The s3ChunkedReader returns io.EOF when the final 0-length chunk is read.
|
||||
func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.ReadCloser, s3err.ErrorCode) {
|
||||
glog.V(3).Infof("creating a new newSignV4ChunkedReader")
|
||||
|
||||
contentSha256Header := req.Header.Get("X-Amz-Content-Sha256")
|
||||
|
||||
var credential *Credential
|
||||
var seedSignature, region, service string
|
||||
var seedDate time.Time
|
||||
var errCode s3err.ErrorCode
|
||||
|
||||
switch contentSha256Header {
|
||||
// Payload for STREAMING signature should be 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD' or 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER'
|
||||
case streamingContentSHA256, streamingContentSHA256Trailer:
|
||||
glog.V(3).Infof("streaming content sha256 (with trailer: %v)", contentSha256Header == streamingContentSHA256Trailer)
|
||||
credential, seedSignature, region, service, seedDate, errCode = iam.calculateSeedSignature(req)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, errCode
|
||||
}
|
||||
case streamingUnsignedPayload:
|
||||
glog.V(3).Infof("streaming unsigned payload")
|
||||
// The seed signature is a SigV4 concept; recompute it only for SigV4-signed
|
||||
// requests. JWT-bearer, presigned, and anonymous unsigned-streaming uploads
|
||||
// carry no header seed, and verifyV4Signature would fail parsing them.
|
||||
if isRequestSignatureV4(req) {
|
||||
// We do not need to pass the seed signature to the Reader as each chunk is not signed,
|
||||
// but we do compute it to verify the caller has the correct permissions.
|
||||
_, _, _, _, _, errCode = iam.calculateSeedSignature(req)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, errCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the checksum algorithm from the x-amz-trailer Header.
|
||||
amzTrailerHeader := req.Header.Get("x-amz-trailer")
|
||||
checksumAlgorithm, err := extractChecksumAlgorithm(amzTrailerHeader)
|
||||
|
||||
if err != nil {
|
||||
glog.V(3).Infof("error extracting checksum algorithm: %v", err)
|
||||
return nil, s3err.ErrInvalidRequest
|
||||
}
|
||||
|
||||
checkSumWriter := getCheckSumWriter(checksumAlgorithm)
|
||||
hasTrailer := amzTrailerHeader != ""
|
||||
|
||||
return &s3ChunkedReader{
|
||||
cred: credential,
|
||||
reader: bufio.NewReader(req.Body),
|
||||
seedSignature: seedSignature,
|
||||
seedDate: seedDate,
|
||||
region: region,
|
||||
service: service,
|
||||
chunkSHA256Writer: sha256.New(),
|
||||
checkSumAlgorithm: checksumAlgorithm.String(),
|
||||
checkSumWriter: checkSumWriter,
|
||||
state: readChunkHeader,
|
||||
iam: iam,
|
||||
hasTrailer: hasTrailer,
|
||||
}, s3err.ErrNone
|
||||
}
|
||||
|
||||
func extractChecksumAlgorithm(amzTrailerHeader string) (ChecksumAlgorithm, error) {
|
||||
// Extract checksum algorithm from the x-amz-trailer header.
|
||||
amzTrailerHeader = strings.ToLower(strings.TrimSpace(amzTrailerHeader))
|
||||
switch amzTrailerHeader {
|
||||
case "x-amz-checksum-crc32":
|
||||
return ChecksumAlgorithmCRC32, nil
|
||||
case "x-amz-checksum-crc32c":
|
||||
return ChecksumAlgorithmCRC32C, nil
|
||||
case "x-amz-checksum-crc64nvme":
|
||||
return ChecksumAlgorithmCRC64NVMe, nil
|
||||
case "x-amz-checksum-sha1":
|
||||
return ChecksumAlgorithmSHA1, nil
|
||||
case "x-amz-checksum-sha256":
|
||||
return ChecksumAlgorithmSHA256, nil
|
||||
case "":
|
||||
return ChecksumAlgorithmNone, nil
|
||||
default:
|
||||
return ChecksumAlgorithmNone, errors.New("unsupported checksum algorithm '" + amzTrailerHeader + "'")
|
||||
}
|
||||
}
|
||||
|
||||
// Represents the overall state that is required for decoding a
|
||||
// AWS Signature V4 chunked reader.
|
||||
type s3ChunkedReader struct {
|
||||
cred *Credential
|
||||
reader *bufio.Reader
|
||||
seedSignature string
|
||||
seedDate time.Time
|
||||
region string
|
||||
service string // Service from credential scope (e.g., "s3", "iam")
|
||||
state chunkState
|
||||
lastChunk bool
|
||||
chunkSignature string // Empty string if unsigned streaming upload.
|
||||
checkSumAlgorithm string // Empty string if no checksum algorithm is specified.
|
||||
checkSumWriter hash.Hash
|
||||
chunkSHA256Writer hash.Hash // Calculates sha256 of chunk data.
|
||||
n uint64 // Unread bytes in chunk
|
||||
err error
|
||||
iam *IdentityAccessManagement
|
||||
hasTrailer bool
|
||||
}
|
||||
|
||||
// Read chunk reads the chunk token signature portion.
|
||||
func (cr *s3ChunkedReader) readS3ChunkHeader() {
|
||||
// Read the first chunk line until CRLF.
|
||||
var bytesRead, hexChunkSize, hexChunkSignature []byte
|
||||
bytesRead, cr.err = readChunkLine(cr.reader)
|
||||
// Parse s3 specific chunk extension and fetch the values.
|
||||
hexChunkSize, hexChunkSignature = parseS3ChunkExtension(bytesRead)
|
||||
|
||||
if cr.err != nil {
|
||||
return
|
||||
}
|
||||
// <hex>;token=value - converts the hex into its uint64 form.
|
||||
cr.n, cr.err = parseHexUint(hexChunkSize)
|
||||
if cr.err != nil {
|
||||
return
|
||||
}
|
||||
if cr.n == 0 {
|
||||
cr.err = io.EOF
|
||||
}
|
||||
|
||||
// Save the incoming chunk signature.
|
||||
if hexChunkSignature == nil {
|
||||
// We are using unsigned streaming upload.
|
||||
cr.chunkSignature = ""
|
||||
} else {
|
||||
cr.chunkSignature = string(hexChunkSignature)
|
||||
}
|
||||
}
|
||||
|
||||
type chunkState int
|
||||
|
||||
const (
|
||||
readChunkHeader chunkState = iota
|
||||
readChunkTrailer
|
||||
readChunk
|
||||
readTrailerChunk
|
||||
verifyChunk
|
||||
verifyChecksum
|
||||
eofChunk
|
||||
)
|
||||
|
||||
func (cs chunkState) String() string {
|
||||
stateString := ""
|
||||
switch cs {
|
||||
case readChunkHeader:
|
||||
stateString = "readChunkHeader"
|
||||
case readChunkTrailer:
|
||||
stateString = "readChunkTrailer"
|
||||
case readChunk:
|
||||
stateString = "readChunk"
|
||||
case readTrailerChunk:
|
||||
stateString = "readTrailerChunk"
|
||||
case verifyChunk:
|
||||
stateString = "verifyChunk"
|
||||
case verifyChecksum:
|
||||
stateString = "verifyChecksum"
|
||||
case eofChunk:
|
||||
stateString = "eofChunk"
|
||||
|
||||
}
|
||||
return stateString
|
||||
}
|
||||
|
||||
func (cr *s3ChunkedReader) Close() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read - implements `io.Reader`, which transparently decodes
|
||||
// the incoming AWS Signature V4 streaming signature.
|
||||
func (cr *s3ChunkedReader) Read(buf []byte) (n int, err error) {
|
||||
for {
|
||||
switch cr.state {
|
||||
case readChunkHeader:
|
||||
cr.readS3ChunkHeader()
|
||||
// If we're at the end of a chunk.
|
||||
if cr.n == 0 && cr.err == io.EOF {
|
||||
cr.state = readChunkTrailer
|
||||
cr.lastChunk = true
|
||||
continue
|
||||
}
|
||||
if cr.err != nil {
|
||||
return 0, cr.err
|
||||
}
|
||||
cr.state = readChunk
|
||||
case readChunkTrailer:
|
||||
err = peekCRLF(cr.reader)
|
||||
isTrailingChunk := cr.n == 0 && cr.lastChunk
|
||||
|
||||
if !isTrailingChunk {
|
||||
// If we're not in the trailing chunk, we should consume the bytes no matter what.
|
||||
// The error returned by peekCRLF is the same as the one by readCRLF.
|
||||
readCRLF(cr.reader)
|
||||
cr.err = err
|
||||
} else if err != nil && err != errMalformedEncoding {
|
||||
cr.err = err
|
||||
return 0, errMalformedEncoding
|
||||
} else { // equivalent to isTrailingChunk && err == errMalformedEncoding
|
||||
// FIXME: The "right" structure of the last chunk as provided by the examples in the
|
||||
// AWS documentation is "0\r\n\r\n" instead of "0\r\n", but some s3 clients when calling with
|
||||
// streaming-unsigned-payload-trailer omit the last CRLF. To avoid returning an error that, we need to accept both.
|
||||
// We arrive here when we're at the end of the 0-byte chunk, depending on the client implementation
|
||||
// the client may or may not send the optional CRLF after the 0-byte chunk.
|
||||
// If the client sends the optional CRLF, we should consume it.
|
||||
if err == nil {
|
||||
readCRLF(cr.reader)
|
||||
}
|
||||
}
|
||||
|
||||
// If we're using unsigned streaming upload, there is no signature to verify at each chunk.
|
||||
if cr.lastChunk && cr.hasTrailer {
|
||||
cr.state = readTrailerChunk
|
||||
} else if cr.chunkSignature != "" {
|
||||
cr.state = verifyChunk
|
||||
} else {
|
||||
cr.state = readChunkHeader
|
||||
}
|
||||
|
||||
case readTrailerChunk:
|
||||
// When using unsigned upload, this would be the raw contents of the trailer chunk:
|
||||
//
|
||||
// x-amz-checksum-crc32:YABb/g==\n\r\n\r\n // Trailer chunk (note optional \n character)
|
||||
// \r\n // CRLF
|
||||
//
|
||||
// When using signed upload with an additional checksum algorithm, this would be the raw contents of the trailer chunk:
|
||||
//
|
||||
// x-amz-checksum-crc32:YABb/g==\n\r\n // Trailer chunk (note optional \n character)
|
||||
// trailer-signature\r\n
|
||||
// \r\n // CRLF
|
||||
//
|
||||
// This implementation currently only supports the first case.
|
||||
// TODO: Implement the second case (signed upload with additional checksum computation for each chunk)
|
||||
|
||||
extractedCheckSumAlgorithm, extractedChecksum, err := parseChunkChecksum(cr.reader)
|
||||
if err != nil {
|
||||
cr.err = err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if extractedCheckSumAlgorithm.String() != cr.checkSumAlgorithm {
|
||||
errorMessage := fmt.Sprintf("checksum algorithm in trailer '%s' does not match the one advertised in the header '%s'", extractedCheckSumAlgorithm.String(), cr.checkSumAlgorithm)
|
||||
glog.V(3).Info(errorMessage)
|
||||
cr.err = errors.New(s3err.ErrMsgChecksumAlgorithmMismatch)
|
||||
return 0, cr.err
|
||||
}
|
||||
|
||||
// Validate checksum for data integrity (required for both signed and unsigned streaming with trailers)
|
||||
computedChecksum := cr.checkSumWriter.Sum(nil)
|
||||
base64Checksum := base64.StdEncoding.EncodeToString(computedChecksum)
|
||||
if string(extractedChecksum) != base64Checksum {
|
||||
glog.V(3).Infof("payload checksum '%s' does not match provided checksum '%s'", base64Checksum, string(extractedChecksum))
|
||||
cr.err = errors.New(s3err.ErrMsgPayloadChecksumMismatch)
|
||||
return 0, cr.err
|
||||
}
|
||||
|
||||
// TODO: Extract signature from trailer chunk and verify it.
|
||||
// For now, we just read the trailer chunk and discard it.
|
||||
|
||||
cr.state = eofChunk
|
||||
|
||||
case readChunk:
|
||||
// There is no more space left in the request buffer.
|
||||
if len(buf) == 0 {
|
||||
return n, nil
|
||||
}
|
||||
rbuf := buf
|
||||
// The request buffer is larger than the current chunk size.
|
||||
// Read only the current chunk from the underlying reader.
|
||||
if uint64(len(rbuf)) > cr.n {
|
||||
rbuf = rbuf[:cr.n]
|
||||
}
|
||||
var n0 int
|
||||
n0, cr.err = cr.reader.Read(rbuf)
|
||||
if cr.err != nil {
|
||||
// We have lesser than chunk size advertised in chunkHeader, this is 'unexpected'.
|
||||
if cr.err == io.EOF {
|
||||
cr.err = io.ErrUnexpectedEOF
|
||||
}
|
||||
return 0, cr.err
|
||||
}
|
||||
|
||||
// Calculate sha256.
|
||||
cr.chunkSHA256Writer.Write(rbuf[:n0])
|
||||
|
||||
// Compute checksum
|
||||
if cr.checkSumWriter != nil {
|
||||
cr.checkSumWriter.Write(rbuf[:n0])
|
||||
}
|
||||
|
||||
// Update the bytes read into request buffer so far.
|
||||
n += n0
|
||||
buf = buf[n0:]
|
||||
// Update bytes to be read of the current chunk before verifying chunk's signature.
|
||||
cr.n -= uint64(n0)
|
||||
|
||||
// If we're at the end of a chunk.
|
||||
if cr.n == 0 {
|
||||
cr.state = readChunkTrailer
|
||||
}
|
||||
case verifyChunk:
|
||||
// Check if we have credentials for signature verification
|
||||
// This handles the case where we have unsigned streaming (no cred) but chunks contain signatures
|
||||
//
|
||||
// BUG FIX for GitHub issue #6847:
|
||||
// Some AWS SDK versions (Java 3.7.412+, .NET 4.0.0-preview.6+) send mixed format:
|
||||
// - HTTP headers indicate unsigned streaming (STREAMING-UNSIGNED-PAYLOAD-TRAILER)
|
||||
// - But chunk data contains chunk-signature headers (normally only for signed streaming)
|
||||
// This causes a nil pointer dereference when trying to verify signatures without credentials
|
||||
if cr.cred != nil {
|
||||
// Normal signed streaming - verify the chunk signature
|
||||
// Calculate the hashed chunk.
|
||||
hashedChunk := hex.EncodeToString(cr.chunkSHA256Writer.Sum(nil))
|
||||
// Calculate the chunk signature.
|
||||
newSignature := cr.getChunkSignature(hashedChunk)
|
||||
if !compareSignatureV4(cr.chunkSignature, newSignature) {
|
||||
// Chunk signature doesn't match we return signature does not match.
|
||||
cr.err = errors.New(s3err.ErrMsgChunkSignatureMismatch)
|
||||
return 0, cr.err
|
||||
}
|
||||
// Newly calculated signature becomes the seed for the next chunk
|
||||
// this follows the chaining.
|
||||
cr.seedSignature = newSignature
|
||||
} else {
|
||||
// For unsigned streaming, we should not verify chunk signatures even if they are present
|
||||
// This fixes the bug where AWS SDKs send chunk signatures with unsigned streaming headers
|
||||
glog.V(3).Infof("Skipping chunk signature verification for unsigned streaming")
|
||||
}
|
||||
|
||||
// Common cleanup and state transition for both signed and unsigned streaming
|
||||
cr.chunkSHA256Writer.Reset()
|
||||
if cr.lastChunk {
|
||||
cr.state = eofChunk
|
||||
} else {
|
||||
cr.state = readChunkHeader
|
||||
}
|
||||
case eofChunk:
|
||||
return n, io.EOF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getChunkSignature - get chunk signature.
|
||||
func (cr *s3ChunkedReader) getChunkSignature(hashedChunk string) string {
|
||||
// Calculate string to sign.
|
||||
stringToSign := signV4Algorithm + "-PAYLOAD" + "\n" +
|
||||
cr.seedDate.Format(iso8601Format) + "\n" +
|
||||
getScope(cr.seedDate, cr.region, cr.service) + "\n" +
|
||||
cr.seedSignature + "\n" +
|
||||
emptySHA256 + "\n" +
|
||||
hashedChunk
|
||||
|
||||
// Get hmac signing key.
|
||||
signingKey := getSigningKey(cr.cred.SecretKey, cr.seedDate.Format(yyyymmdd), cr.region, cr.service)
|
||||
|
||||
// Calculate and return signature.
|
||||
return getSignature(signingKey, stringToSign)
|
||||
}
|
||||
|
||||
func readCRLF(reader *bufio.Reader) error {
|
||||
buf := make([]byte, 2)
|
||||
_, err := io.ReadFull(reader, buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkCRLF(buf)
|
||||
}
|
||||
|
||||
func peekCRLF(reader *bufio.Reader) error {
|
||||
buf, err := reader.Peek(2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkCRLF(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkCRLF(buf []byte) error {
|
||||
if len(buf) != 2 || buf[0] != '\r' || buf[1] != '\n' {
|
||||
return errMalformedEncoding
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readChunkLine(b *bufio.Reader) ([]byte, error) {
|
||||
buf, err := b.ReadSlice('\n')
|
||||
if err != nil {
|
||||
// We always know when EOF is coming.
|
||||
// If the caller asked for a line, there should be a line.
|
||||
switch err {
|
||||
case io.EOF:
|
||||
err = io.ErrUnexpectedEOF
|
||||
case bufio.ErrBufferFull:
|
||||
err = errLineTooLong
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(buf) >= maxLineLength {
|
||||
return nil, errLineTooLong
|
||||
}
|
||||
return trimTrailingWhitespace(buf), nil
|
||||
}
|
||||
|
||||
// trimTrailingWhitespace - trim trailing white space.
|
||||
func trimTrailingWhitespace(b []byte) []byte {
|
||||
for len(b) > 0 && isASCIISpace(b[len(b)-1]) {
|
||||
b = b[:len(b)-1]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// isASCIISpace - is ascii space?
|
||||
func isASCIISpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||
}
|
||||
|
||||
// Constant s3 chunk encoding signature.
|
||||
const s3ChunkSignatureStr = ";chunk-signature="
|
||||
|
||||
// parseS3ChunkExtension removes any s3 specific chunk-extension from buf.
|
||||
// For example,
|
||||
//
|
||||
// "10000;chunk-signature=..." => "10000", "chunk-signature=..."
|
||||
func parseS3ChunkExtension(buf []byte) ([]byte, []byte) {
|
||||
buf = trimTrailingWhitespace(buf)
|
||||
semi := bytes.Index(buf, []byte(s3ChunkSignatureStr))
|
||||
// Chunk signature not found, return the whole buffer.
|
||||
// This means we're using unsigned streaming upload.
|
||||
if semi == -1 {
|
||||
return buf, nil
|
||||
}
|
||||
return buf[:semi], parseChunkSignature(buf[semi:])
|
||||
}
|
||||
|
||||
func parseChunkChecksum(b *bufio.Reader) (ChecksumAlgorithm, []byte, error) {
|
||||
// Read trailer lines until empty line
|
||||
var checksumAlgorithm ChecksumAlgorithm
|
||||
var checksum []byte
|
||||
|
||||
for {
|
||||
bytesRead, err := readChunkLine(b)
|
||||
if err != nil {
|
||||
return ChecksumAlgorithmNone, nil, err
|
||||
}
|
||||
|
||||
if len(bytesRead) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
parts := bytes.SplitN(bytesRead, []byte(":"), 2)
|
||||
if len(parts) == 2 {
|
||||
key := string(bytes.TrimSpace(parts[0]))
|
||||
value := bytes.TrimSpace(parts[1])
|
||||
if alg, err := extractChecksumAlgorithm(key); err == nil {
|
||||
if checksumAlgorithm != ChecksumAlgorithmNone {
|
||||
glog.V(3).Infof("multiple checksum headers found in trailer, using last: %s", key)
|
||||
}
|
||||
checksumAlgorithm = alg
|
||||
checksum = value
|
||||
}
|
||||
// Ignore other trailer headers like x-amz-trailer-signature
|
||||
}
|
||||
}
|
||||
|
||||
return checksumAlgorithm, checksum, nil
|
||||
}
|
||||
|
||||
func parseChunkSignature(chunk []byte) []byte {
|
||||
chunkSplits := bytes.SplitN(chunk, []byte("="), 2)
|
||||
return chunkSplits[1] // Keep only the signature.
|
||||
}
|
||||
|
||||
func parseHexUint(v []byte) (n uint64, err error) {
|
||||
for i, b := range v {
|
||||
switch {
|
||||
case '0' <= b && b <= '9':
|
||||
b = b - '0'
|
||||
case 'a' <= b && b <= 'f':
|
||||
b = b - 'a' + 10
|
||||
case 'A' <= b && b <= 'F':
|
||||
b = b - 'A' + 10
|
||||
default:
|
||||
return 0, errors.New("invalid byte in chunk length")
|
||||
}
|
||||
if i == 16 {
|
||||
return 0, errors.New("http chunk length too large")
|
||||
}
|
||||
n <<= 4
|
||||
n |= uint64(b)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Checksum Algorithm represents the various checksum algorithms supported.
|
||||
type ChecksumAlgorithm int
|
||||
|
||||
const (
|
||||
ChecksumAlgorithmNone ChecksumAlgorithm = iota
|
||||
ChecksumAlgorithmCRC32
|
||||
ChecksumAlgorithmCRC32C
|
||||
ChecksumAlgorithmCRC64NVMe
|
||||
ChecksumAlgorithmSHA1
|
||||
ChecksumAlgorithmSHA256
|
||||
)
|
||||
|
||||
func (ca ChecksumAlgorithm) String() string {
|
||||
switch ca {
|
||||
case ChecksumAlgorithmNone:
|
||||
return ""
|
||||
case ChecksumAlgorithmCRC32:
|
||||
return "x-amz-checksum-crc32"
|
||||
case ChecksumAlgorithmCRC32C:
|
||||
return "x-amz-checksum-crc32c"
|
||||
case ChecksumAlgorithmCRC64NVMe:
|
||||
return "x-amz-checksum-crc64nvme"
|
||||
case ChecksumAlgorithmSHA1:
|
||||
return "x-amz-checksum-sha1"
|
||||
case ChecksumAlgorithmSHA256:
|
||||
return "x-amz-checksum-sha256"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getCheckSumWriter - get checksum writer.
|
||||
func getCheckSumWriter(checksumAlgorithm ChecksumAlgorithm) hash.Hash {
|
||||
switch checksumAlgorithm {
|
||||
case ChecksumAlgorithmCRC32:
|
||||
return crc32.NewIEEE()
|
||||
case ChecksumAlgorithmCRC32C:
|
||||
return crc32.New(crc32.MakeTable(crc32.Castagnoli))
|
||||
case ChecksumAlgorithmCRC64NVMe:
|
||||
return crc64nvme.New()
|
||||
case ChecksumAlgorithmSHA1:
|
||||
return sha1.New()
|
||||
case ChecksumAlgorithmSHA256:
|
||||
return sha256.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
479
weed/s3api/chunked_reader_v4_test.go
Normal file
479
weed/s3api/chunked_reader_v4_test.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hash/crc32"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// getDefaultTimestamp returns a current timestamp for tests
|
||||
func getDefaultTimestamp() string {
|
||||
return time.Now().UTC().Format(iso8601Format)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultTimestamp = "20130524T000000Z" // Legacy constant for reference
|
||||
defaultBucketName = "examplebucket"
|
||||
defaultAccessKeyId = UserAccessKeyPrefix + "IOSFODNN7EXAMPLE"
|
||||
defaultSecretAccessKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
defaultRegion = "us-east-1"
|
||||
)
|
||||
|
||||
func TestExtractChecksumAlgorithmIsCaseInsensitive(t *testing.T) {
|
||||
got, err := extractChecksumAlgorithm("X-Amz-Checksum-Crc32")
|
||||
if err != nil {
|
||||
t.Fatalf("extractChecksumAlgorithm returned error: %v", err)
|
||||
}
|
||||
if got != ChecksumAlgorithmCRC32 {
|
||||
t.Fatalf("extractChecksumAlgorithm() = %v, want %v", got, ChecksumAlgorithmCRC32)
|
||||
}
|
||||
}
|
||||
|
||||
func generateStreamingUnsignedPayloadTrailerPayload(includeFinalCRLF bool) string {
|
||||
// This test will implement the following scenario:
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
|
||||
|
||||
chunk1 := "2000\r\n" + strings.Repeat("a", 8192) + "\r\n"
|
||||
chunk2 := "2000\r\n" + strings.Repeat("a", 8192) + "\r\n"
|
||||
chunk3 := "400\r\n" + strings.Repeat("a", 1024) + "\r\n"
|
||||
|
||||
chunk4 := "0\r\n" /* the last chunk is empty */
|
||||
|
||||
if includeFinalCRLF {
|
||||
// Some clients omit the final CRLF, so we need to test that case as well
|
||||
chunk4 += "\r\n"
|
||||
}
|
||||
|
||||
data := strings.Repeat("a", 17408)
|
||||
writer := crc32.NewIEEE()
|
||||
_, err := writer.Write([]byte(data))
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Error:", err)
|
||||
}
|
||||
checksum := writer.Sum(nil)
|
||||
base64EncodedChecksum := base64.StdEncoding.EncodeToString(checksum)
|
||||
trailer := "x-amz-checksum-crc32:" + base64EncodedChecksum + "\n\r\n\r\n\r\n"
|
||||
|
||||
payload := chunk1 + chunk2 + chunk3 + chunk4 + trailer
|
||||
return payload
|
||||
}
|
||||
|
||||
func NewRequestStreamingUnsignedPayloadTrailer(includeFinalCRLF bool) (*http.Request, error) {
|
||||
// This test will implement the following scenario:
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
|
||||
|
||||
payload := generateStreamingUnsignedPayloadTrailerPayload(includeFinalCRLF)
|
||||
req, err := http.NewRequest("PUT", "http://amzn-s3-demo-bucket/Key+", bytes.NewReader([]byte(payload)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Host", "amzn-s3-demo-bucket")
|
||||
req.Header.Set("x-amz-date", getDefaultTimestamp())
|
||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||
req.Header.Set("x-amz-decoded-content-length", "17408")
|
||||
req.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
|
||||
req.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func TestNewSignV4ChunkedReaderStreamingUnsignedPayloadTrailer(t *testing.T) {
|
||||
// This test will implement the following scenario:
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
|
||||
iam := setupIam()
|
||||
|
||||
req, err := NewRequestStreamingUnsignedPayloadTrailer(true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
// The expected payload a long string of 'a's
|
||||
expectedPayload := strings.Repeat("a", 17408)
|
||||
|
||||
runWithRequest(iam, req, t, expectedPayload)
|
||||
|
||||
req, err = NewRequestStreamingUnsignedPayloadTrailer(false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
runWithRequest(iam, req, t, expectedPayload)
|
||||
}
|
||||
|
||||
func runWithRequest(iam IdentityAccessManagement, req *http.Request, t *testing.T, expectedPayload string) {
|
||||
reader, errCode := iam.newChunkedReader(req)
|
||||
assert.NotNil(t, reader)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read data: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, expectedPayload, string(data))
|
||||
}
|
||||
|
||||
func setupIam() IdentityAccessManagement {
|
||||
// Create an IdentityAccessManagement instance
|
||||
// Add default access keys and secrets
|
||||
|
||||
iam := IdentityAccessManagement{
|
||||
identities: []*Identity{},
|
||||
accessKeyIdent: map[string]*Identity{},
|
||||
accounts: map[string]*Account{},
|
||||
emailAccount: map[string]*Account{},
|
||||
hashes: map[string]*sync.Pool{},
|
||||
hashCounters: map[string]*int32{},
|
||||
identityAnonymous: nil,
|
||||
domain: "",
|
||||
isAuthEnabled: false,
|
||||
}
|
||||
|
||||
iam.identities = append(iam.identities, &Identity{
|
||||
Name: "default",
|
||||
Credentials: []*Credential{
|
||||
{
|
||||
AccessKey: defaultAccessKeyId,
|
||||
SecretKey: defaultSecretAccessKey,
|
||||
},
|
||||
},
|
||||
Actions: []Action{
|
||||
"Read",
|
||||
"Write",
|
||||
"List",
|
||||
},
|
||||
})
|
||||
|
||||
iam.accessKeyIdent[defaultAccessKeyId] = iam.identities[0]
|
||||
return iam
|
||||
}
|
||||
|
||||
// TestSignedStreamingUpload tests streaming uploads with signed chunks
|
||||
// This replaces the removed AWS example test with a dynamic signature generation approach
|
||||
func TestSignedStreamingUpload(t *testing.T) {
|
||||
iam := setupIam()
|
||||
|
||||
// Create a simple streaming upload with 2 chunks
|
||||
chunk1Data := strings.Repeat("a", 1024)
|
||||
chunk2Data := strings.Repeat("b", 512)
|
||||
|
||||
// Use current time for signatures
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format(iso8601Format)
|
||||
dateStamp := now.Format(yyyymmdd)
|
||||
|
||||
// Calculate seed signature
|
||||
scope := dateStamp + "/" + defaultRegion + "/s3/aws4_request"
|
||||
|
||||
// Build canonical request for seed signature
|
||||
hashedPayload := "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
|
||||
canonicalHeaders := "content-encoding:aws-chunked\n" +
|
||||
"host:s3.amazonaws.com\n" +
|
||||
"x-amz-content-sha256:" + hashedPayload + "\n" +
|
||||
"x-amz-date:" + amzDate + "\n" +
|
||||
"x-amz-decoded-content-length:1536\n"
|
||||
signedHeaders := "content-encoding;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length"
|
||||
|
||||
canonicalRequest := "PUT\n" +
|
||||
"/test-bucket/test-object\n" +
|
||||
"\n" +
|
||||
canonicalHeaders + "\n" +
|
||||
signedHeaders + "\n" +
|
||||
hashedPayload
|
||||
|
||||
canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + canonicalRequestHash
|
||||
|
||||
signingKey := getSigningKey(defaultSecretAccessKey, dateStamp, defaultRegion, "s3")
|
||||
seedSignature := getSignature(signingKey, stringToSign)
|
||||
|
||||
// Calculate chunk signatures
|
||||
chunk1Hash := getSHA256Hash([]byte(chunk1Data))
|
||||
chunk1StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
seedSignature + "\n" + emptySHA256 + "\n" + chunk1Hash
|
||||
chunk1Signature := getSignature(signingKey, chunk1StringToSign)
|
||||
|
||||
chunk2Hash := getSHA256Hash([]byte(chunk2Data))
|
||||
chunk2StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
chunk1Signature + "\n" + emptySHA256 + "\n" + chunk2Hash
|
||||
chunk2Signature := getSignature(signingKey, chunk2StringToSign)
|
||||
|
||||
finalStringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
chunk2Signature + "\n" + emptySHA256 + "\n" + emptySHA256
|
||||
finalSignature := getSignature(signingKey, finalStringToSign)
|
||||
|
||||
// Build the chunked payload
|
||||
payload := fmt.Sprintf("400;chunk-signature=%s\r\n%s\r\n", chunk1Signature, chunk1Data) +
|
||||
fmt.Sprintf("200;chunk-signature=%s\r\n%s\r\n", chunk2Signature, chunk2Data) +
|
||||
fmt.Sprintf("0;chunk-signature=%s\r\n\r\n", finalSignature)
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object",
|
||||
bytes.NewReader([]byte(payload)))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Host", "s3.amazonaws.com")
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", hashedPayload)
|
||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||
req.Header.Set("x-amz-decoded-content-length", "1536")
|
||||
|
||||
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
defaultAccessKeyId, scope, signedHeaders, seedSignature)
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
// Test the chunked reader
|
||||
reader, errCode := iam.newChunkedReader(req)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.NotNil(t, reader)
|
||||
|
||||
// Read and verify the payload
|
||||
data, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, chunk1Data+chunk2Data, string(data))
|
||||
}
|
||||
|
||||
// createTrailerStreamingRequest creates a streaming upload request with trailer for testing.
|
||||
// If useValidTrailerSignature is true, uses a correctly calculated trailer signature;
|
||||
// otherwise uses an intentionally wrong signature for negative testing.
|
||||
func createTrailerStreamingRequest(t *testing.T, useValidTrailerSignature bool) (*http.Request, string) {
|
||||
chunk1Data := "hello world\n"
|
||||
chunk1DataLen := len(chunk1Data)
|
||||
chunk1DataLenHex := fmt.Sprintf("%x", chunk1DataLen)
|
||||
|
||||
// Use current time for signatures
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format(iso8601Format)
|
||||
dateStamp := now.Format(yyyymmdd)
|
||||
|
||||
// Calculate seed signature
|
||||
scope := dateStamp + "/" + defaultRegion + "/s3/aws4_request"
|
||||
|
||||
// Build canonical request for seed signature
|
||||
hashedPayload := "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER"
|
||||
canonicalHeaders := "content-encoding:aws-chunked\n" +
|
||||
"host:s3.amazonaws.com\n" +
|
||||
"x-amz-content-sha256:" + hashedPayload + "\n" +
|
||||
"x-amz-date:" + amzDate + "\n" +
|
||||
fmt.Sprintf("x-amz-decoded-content-length:%d\n", chunk1DataLen) +
|
||||
"x-amz-trailer:x-amz-checksum-crc32\n"
|
||||
signedHeaders := "content-encoding;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length;x-amz-trailer"
|
||||
|
||||
canonicalRequest := "PUT\n" +
|
||||
"/test-bucket/test-object\n" +
|
||||
"\n" +
|
||||
canonicalHeaders + "\n" +
|
||||
signedHeaders + "\n" +
|
||||
hashedPayload
|
||||
|
||||
canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + canonicalRequestHash
|
||||
|
||||
signingKey := getSigningKey(defaultSecretAccessKey, dateStamp, defaultRegion, "s3")
|
||||
seedSignature := getSignature(signingKey, stringToSign)
|
||||
|
||||
// Calculate chunk signatures
|
||||
chunk1Hash := getSHA256Hash([]byte(chunk1Data))
|
||||
chunk1StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
seedSignature + "\n" + emptySHA256 + "\n" + chunk1Hash
|
||||
chunk1Signature := getSignature(signingKey, chunk1StringToSign)
|
||||
|
||||
// Final chunk (0 bytes)
|
||||
finalStringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
chunk1Signature + "\n" + emptySHA256 + "\n" + emptySHA256
|
||||
finalSignature := getSignature(signingKey, finalStringToSign)
|
||||
|
||||
// Calculate CRC32 checksum for trailer
|
||||
crcWriter := crc32.NewIEEE()
|
||||
_, crcErr := crcWriter.Write([]byte(chunk1Data))
|
||||
assert.NoError(t, crcErr)
|
||||
checksum := crcWriter.Sum(nil)
|
||||
base64EncodedChecksum := base64.StdEncoding.EncodeToString(checksum)
|
||||
|
||||
// The on-wire trailer format uses \r\n (HTTP/aws-chunked convention)
|
||||
trailerOnWire := "x-amz-checksum-crc32:" + base64EncodedChecksum + "\r\n"
|
||||
|
||||
// Calculate or use wrong trailer signature
|
||||
var trailerSignature string
|
||||
if useValidTrailerSignature {
|
||||
// The canonical trailer content uses \n for signing (per AWS SigV4 spec)
|
||||
trailerCanonical := "x-amz-checksum-crc32:" + base64EncodedChecksum + "\n"
|
||||
trailerHash := getSHA256Hash([]byte(trailerCanonical))
|
||||
trailerStringToSign := "AWS4-HMAC-SHA256-TRAILER\n" + amzDate + "\n" + scope + "\n" +
|
||||
finalSignature + "\n" + trailerHash
|
||||
trailerSignature = getSignature(signingKey, trailerStringToSign)
|
||||
} else {
|
||||
// Intentionally wrong signature for negative testing
|
||||
trailerSignature = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
|
||||
// Build the chunked payload with trailer and trailer signature
|
||||
payload := fmt.Sprintf("%s;chunk-signature=%s\r\n%s\r\n", chunk1DataLenHex, chunk1Signature, chunk1Data) +
|
||||
fmt.Sprintf("0;chunk-signature=%s\r\n", finalSignature) +
|
||||
trailerOnWire +
|
||||
"x-amz-trailer-signature:" + trailerSignature + "\r\n" +
|
||||
"\r\n"
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object",
|
||||
bytes.NewReader([]byte(payload)))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Host", "s3.amazonaws.com")
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", hashedPayload)
|
||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||
req.Header.Set("x-amz-decoded-content-length", fmt.Sprintf("%d", chunk1DataLen))
|
||||
req.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
|
||||
|
||||
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
defaultAccessKeyId, scope, signedHeaders, seedSignature)
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
return req, chunk1Data
|
||||
}
|
||||
|
||||
// TestSignedStreamingUploadWithTrailer tests streaming uploads with signed chunks and trailers
|
||||
// This tests the STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER content-sha256 header value
|
||||
// which is used by AWS SDK v2 when checksum validation is enabled
|
||||
func TestSignedStreamingUploadWithTrailer(t *testing.T) {
|
||||
iam := setupIam()
|
||||
req, expectedData := createTrailerStreamingRequest(t, true)
|
||||
|
||||
// Test the chunked reader
|
||||
reader, errCode := iam.newChunkedReader(req)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.NotNil(t, reader)
|
||||
|
||||
// Read and verify the payload
|
||||
data, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedData, string(data))
|
||||
}
|
||||
|
||||
// TestSignedStreamingUploadWithTrailerInvalidSignature tests behavior with invalid trailer signatures.
|
||||
// This is a negative test case for trailer signature validation. It currently verifies that an invalid
|
||||
// signature doesn't break content reading, and is prepared for when validation is implemented.
|
||||
func TestSignedStreamingUploadWithTrailerInvalidSignature(t *testing.T) {
|
||||
iam := setupIam()
|
||||
req, expectedData := createTrailerStreamingRequest(t, false)
|
||||
|
||||
// Test the chunked reader - it should be created successfully
|
||||
reader, errCode := iam.newChunkedReader(req)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.NotNil(t, reader)
|
||||
|
||||
// Read the payload - currently trailer signature validation may not be implemented,
|
||||
// but this test documents the expected behavior and will catch regressions
|
||||
// if trailer signature validation is added in the future
|
||||
data, err := io.ReadAll(reader)
|
||||
// Note: If trailer signature validation is implemented, this should fail with an error
|
||||
// For now, we just verify the content is correctly extracted
|
||||
if err != nil {
|
||||
assert.Contains(t, err.Error(), "signature", "Error should indicate signature mismatch")
|
||||
} else {
|
||||
// If no error, content should still be correct (trailer sig validation not yet implemented)
|
||||
assert.Equal(t, expectedData, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignedStreamingUploadInvalidSignature tests that invalid chunk signatures are rejected
|
||||
// This is a negative test case to ensure signature validation is actually working
|
||||
func TestSignedStreamingUploadInvalidSignature(t *testing.T) {
|
||||
iam := setupIam()
|
||||
|
||||
// Create a simple streaming upload with 1 chunk
|
||||
chunk1Data := strings.Repeat("a", 1024)
|
||||
|
||||
// Use current time for signatures
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format(iso8601Format)
|
||||
dateStamp := now.Format(yyyymmdd)
|
||||
|
||||
// Calculate seed signature
|
||||
scope := dateStamp + "/" + defaultRegion + "/s3/aws4_request"
|
||||
|
||||
// Build canonical request for seed signature
|
||||
hashedPayload := "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
|
||||
canonicalHeaders := "content-encoding:aws-chunked\n" +
|
||||
"host:s3.amazonaws.com\n" +
|
||||
"x-amz-content-sha256:" + hashedPayload + "\n" +
|
||||
"x-amz-date:" + amzDate + "\n" +
|
||||
"x-amz-decoded-content-length:1024\n"
|
||||
signedHeaders := "content-encoding;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length"
|
||||
|
||||
canonicalRequest := "PUT\n" +
|
||||
"/test-bucket/test-object\n" +
|
||||
"\n" +
|
||||
canonicalHeaders + "\n" +
|
||||
signedHeaders + "\n" +
|
||||
hashedPayload
|
||||
|
||||
canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + canonicalRequestHash
|
||||
|
||||
signingKey := getSigningKey(defaultSecretAccessKey, dateStamp, defaultRegion, "s3")
|
||||
seedSignature := getSignature(signingKey, stringToSign)
|
||||
|
||||
// Calculate chunk signature (correct)
|
||||
chunk1Hash := getSHA256Hash([]byte(chunk1Data))
|
||||
chunk1StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
seedSignature + "\n" + emptySHA256 + "\n" + chunk1Hash
|
||||
chunk1Signature := getSignature(signingKey, chunk1StringToSign)
|
||||
|
||||
// Calculate final signature (correct)
|
||||
finalStringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
chunk1Signature + "\n" + emptySHA256 + "\n" + emptySHA256
|
||||
finalSignature := getSignature(signingKey, finalStringToSign)
|
||||
|
||||
// Build the chunked payload with INTENTIONALLY WRONG chunk signature
|
||||
// We'll use a modified signature to simulate a tampered request
|
||||
wrongChunkSignatureBytes := []byte(chunk1Signature)
|
||||
if len(wrongChunkSignatureBytes) > 0 {
|
||||
// Flip the first hex character to guarantee a different signature
|
||||
if wrongChunkSignatureBytes[0] == '0' {
|
||||
wrongChunkSignatureBytes[0] = '1'
|
||||
} else {
|
||||
wrongChunkSignatureBytes[0] = '0'
|
||||
}
|
||||
}
|
||||
wrongChunkSignature := string(wrongChunkSignatureBytes)
|
||||
payload := fmt.Sprintf("400;chunk-signature=%s\r\n%s\r\n", wrongChunkSignature, chunk1Data) +
|
||||
fmt.Sprintf("0;chunk-signature=%s\r\n\r\n", finalSignature)
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object",
|
||||
bytes.NewReader([]byte(payload)))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Host", "s3.amazonaws.com")
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", hashedPayload)
|
||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||
req.Header.Set("x-amz-decoded-content-length", "1024")
|
||||
|
||||
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
defaultAccessKeyId, scope, signedHeaders, seedSignature)
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
// Test the chunked reader - it should be created successfully
|
||||
reader, errCode := iam.newChunkedReader(req)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.NotNil(t, reader)
|
||||
|
||||
// Try to read the payload - this should fail with signature validation error
|
||||
_, err = io.ReadAll(reader)
|
||||
assert.Error(t, err, "Expected error when reading chunk with invalid signature")
|
||||
assert.Contains(t, err.Error(), "chunk signature does not match", "Error should indicate chunk signature mismatch")
|
||||
}
|
||||
277
weed/s3api/copy_source_decode_test.go
Normal file
277
weed/s3api/copy_source_decode_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
func TestCopySourceWithExclamationMark(t *testing.T) {
|
||||
// Reproduce https://github.com/seaweedfs/seaweedfs/issues/8544
|
||||
testCases := []struct {
|
||||
name string
|
||||
rawCopySource string
|
||||
expectedBucket string
|
||||
expectedObject string
|
||||
expectedVersion string
|
||||
dstBucket string
|
||||
dstObject string
|
||||
shouldBeEqual bool
|
||||
}{
|
||||
{
|
||||
name: "encoded exclamation mark - different dest",
|
||||
rawCopySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/Another test!.odt",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/Hello.odt",
|
||||
shouldBeEqual: false,
|
||||
},
|
||||
{
|
||||
name: "unencoded exclamation mark - different dest",
|
||||
rawCopySource: "my-bucket/path/to/Another%20test!.odt",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/Another test!.odt",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/Hello.odt",
|
||||
shouldBeEqual: false,
|
||||
},
|
||||
{
|
||||
name: "encoded exclamation mark - same dest",
|
||||
rawCopySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/Another test!.odt",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/Another test!.odt",
|
||||
shouldBeEqual: true,
|
||||
},
|
||||
{
|
||||
name: "encoded path with versionId",
|
||||
rawCopySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt?versionId=abc123",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/Another test!.odt",
|
||||
expectedVersion: "abc123",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/Hello.odt",
|
||||
shouldBeEqual: false,
|
||||
},
|
||||
{
|
||||
name: "unencoded path with versionId",
|
||||
rawCopySource: "my-bucket/path/to/Another%20test!.odt?versionId=v2",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/Another test!.odt",
|
||||
expectedVersion: "v2",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/Another test!.odt",
|
||||
shouldBeEqual: true,
|
||||
},
|
||||
{
|
||||
name: "plus sign in key with versionId",
|
||||
rawCopySource: "my-bucket/path/to/file+name.odt?versionId=xyz",
|
||||
expectedBucket: "my-bucket",
|
||||
expectedObject: "path/to/file+name.odt",
|
||||
expectedVersion: "xyz",
|
||||
dstBucket: "my-bucket",
|
||||
dstObject: "path/to/file+name.odt",
|
||||
shouldBeEqual: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cpSrcPath, err := url.PathUnescape(tc.rawCopySource)
|
||||
if err != nil {
|
||||
cpSrcPath = tc.rawCopySource
|
||||
}
|
||||
|
||||
srcBucket, srcObject, srcVersionId := pathToBucketObjectAndVersion(tc.rawCopySource, cpSrcPath)
|
||||
|
||||
if srcBucket != tc.expectedBucket {
|
||||
t.Errorf("expected srcBucket=%q, got %q", tc.expectedBucket, srcBucket)
|
||||
}
|
||||
if srcObject != tc.expectedObject {
|
||||
t.Errorf("expected srcObject=%q, got %q", tc.expectedObject, srcObject)
|
||||
}
|
||||
if srcVersionId != tc.expectedVersion {
|
||||
t.Errorf("expected versionId=%q, got %q", tc.expectedVersion, srcVersionId)
|
||||
}
|
||||
|
||||
isEqual := srcBucket == tc.dstBucket && srcObject == tc.dstObject
|
||||
if isEqual != tc.shouldBeEqual {
|
||||
t.Errorf("expected comparison result %v, got %v (srcObject=%q, dstObject=%q)",
|
||||
tc.shouldBeEqual, isEqual, srcObject, tc.dstObject)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopySourceDecodingPlusSign verifies that + in the copy source header
|
||||
// is treated as a literal plus sign (not a space), matching url.PathUnescape
|
||||
// behavior. Using url.QueryUnescape would incorrectly convert + to space.
|
||||
func TestCopySourceDecodingPlusSign(t *testing.T) {
|
||||
// Key: "path/to/file+name.odt"
|
||||
// With url.QueryUnescape, literal "+" → " " (WRONG for paths)
|
||||
// With url.PathUnescape, literal "+" → "+" (CORRECT)
|
||||
rawCopySource := "my-bucket/path/to/file+name.odt"
|
||||
|
||||
// url.QueryUnescape would give "file name.odt" — WRONG
|
||||
queryDecoded, _ := url.QueryUnescape(rawCopySource)
|
||||
if queryDecoded == rawCopySource {
|
||||
t.Fatal("QueryUnescape should have changed + to space")
|
||||
}
|
||||
|
||||
// url.PathUnescape preserves literal "+" — CORRECT
|
||||
pathDecoded, _ := url.PathUnescape(rawCopySource)
|
||||
if pathDecoded != rawCopySource {
|
||||
t.Fatalf("PathUnescape should have preserved +, got %q", pathDecoded)
|
||||
}
|
||||
|
||||
_, srcObject, _ := pathToBucketObjectAndVersion(rawCopySource, pathDecoded)
|
||||
if srcObject != "path/to/file+name.odt" {
|
||||
t.Errorf("expected srcObject=%q, got %q", "path/to/file+name.odt", srcObject)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopySourceRejectsTraversal verifies `.`/`..` segments in a copy source
|
||||
// are rejected so the source stays within the parsed bucket.
|
||||
func TestCopySourceRejectsTraversal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawCopySource string
|
||||
wantErr bool
|
||||
}{
|
||||
{"clean source passes", "bucket-a/dir/key", false},
|
||||
{"leading slash source passes", "/bucket-a/dir/key", false},
|
||||
{"source with versionId passes", "bucket-a/dir/key?versionId=v1", false},
|
||||
|
||||
{"dotdot escapes bucket", "bucket-a/../bucket-secret/flag", true},
|
||||
{"leading slash dotdot escapes bucket", "/bucket-a/../bucket-secret/flag", true},
|
||||
{"percent-encoded dotdot escapes bucket", "bucket-a/%2e%2e/bucket-secret/flag", true},
|
||||
{"encoded slash dotdot escapes bucket", "bucket-a/..%2fbucket-secret/flag", true},
|
||||
{"backslash dotdot escapes bucket", "bucket-a/..\\bucket-secret/flag", true},
|
||||
{"nested dotdot escapes bucket", "bucket-a/dir/../../bucket-secret/flag", true},
|
||||
{"bare dot segment rejected", "bucket-a/./key", true},
|
||||
{"dotdot bucket rejected", "../bucket-secret/flag", true},
|
||||
{"dotdot with versionId escapes bucket", "bucket-a/../bucket-secret/flag?versionId=v1", true},
|
||||
{"versionId encoded slash rejected", "bucket-a/dir/key?versionId=v1%2F..%2Fsecret", true},
|
||||
{"versionId backslash rejected", "bucket-a/dir/key?versionId=v1%5C..%5Csecret", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cpSrcPath, err := url.PathUnescape(tc.rawCopySource)
|
||||
if err != nil {
|
||||
cpSrcPath = tc.rawCopySource
|
||||
}
|
||||
srcBucket, srcObject, srcVersionId := pathToBucketObjectAndVersion(tc.rawCopySource, cpSrcPath)
|
||||
|
||||
gotErr := validateCopySource(cpSrcPath, srcBucket, srcObject, srcVersionId) != nil
|
||||
if gotErr != tc.wantErr {
|
||||
t.Errorf("ValidateCopySource(%q) error = %v, want %v (parsed bucket=%q object=%q)",
|
||||
tc.rawCopySource, gotErr, tc.wantErr, srcBucket, srcObject)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopySourceRoutingWithSpecialChars tests that mux variable extraction
|
||||
// correctly handles special characters like ! (%21) in both the URL path
|
||||
// and the X-Amz-Copy-Source header.
|
||||
func TestCopySourceRoutingWithSpecialChars(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
dstURL string // URL for the PUT request (destination)
|
||||
copySource string // X-Amz-Copy-Source header value
|
||||
expectSameKey bool // whether srcObject should equal dstObject
|
||||
}{
|
||||
{
|
||||
name: "different keys, source has encoded !",
|
||||
dstURL: "/my-bucket/path/to/Hello.odt",
|
||||
copySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
|
||||
expectSameKey: false,
|
||||
},
|
||||
{
|
||||
name: "same key with !, source encoded, dest unencoded",
|
||||
dstURL: "/my-bucket/path/to/Another%20test%21.odt",
|
||||
copySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
|
||||
expectSameKey: true,
|
||||
},
|
||||
{
|
||||
name: "same key with !, both percent-encoded differently",
|
||||
dstURL: "/my-bucket/path/to/Another%20test!.odt",
|
||||
copySource: "my-bucket/path%2Fto%2FAnother%20test%21.odt",
|
||||
expectSameKey: true,
|
||||
},
|
||||
{
|
||||
name: "key with + sign, source has literal +",
|
||||
dstURL: "/my-bucket/path/to/file+name.odt",
|
||||
copySource: "my-bucket/path/to/file+name.odt",
|
||||
expectSameKey: true,
|
||||
},
|
||||
{
|
||||
name: "key with + sign, source has %2B",
|
||||
dstURL: "/my-bucket/path/to/file+name.odt",
|
||||
copySource: "my-bucket/path/to/file%2Bname.odt",
|
||||
expectSameKey: true,
|
||||
},
|
||||
{
|
||||
name: "lowercase %2f in copy source",
|
||||
dstURL: "/my-bucket/path/to/Hello.odt",
|
||||
copySource: "my-bucket/path%2fto%2fAnother%20test.odt",
|
||||
expectSameKey: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var capturedDstBucket, capturedDstObject string
|
||||
var capturedSrcBucket, capturedSrcObject string
|
||||
|
||||
router := mux.NewRouter().SkipClean(true)
|
||||
bucket := router.PathPrefix("/{bucket}").Subrouter()
|
||||
bucket.Methods(http.MethodPut).Path("/{object:(?s).+}").
|
||||
HeadersRegexp("X-Amz-Copy-Source", `(?i).*?(\/|%2F).*?`).
|
||||
HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedDstBucket, capturedDstObject = s3_constants.GetBucketAndObject(r)
|
||||
|
||||
rawCopySource := r.Header.Get("X-Amz-Copy-Source")
|
||||
cpSrcPath, err := url.PathUnescape(rawCopySource)
|
||||
if err != nil {
|
||||
cpSrcPath = rawCopySource
|
||||
}
|
||||
capturedSrcBucket, capturedSrcObject, _ = pathToBucketObjectAndVersion(rawCopySource, cpSrcPath)
|
||||
|
||||
if capturedSrcBucket == capturedDstBucket && capturedSrcObject == capturedDstObject {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "ErrInvalidCopyDest")
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "OK")
|
||||
}
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("PUT", tc.dstURL, nil)
|
||||
req.Header.Set("X-Amz-Copy-Source", tc.copySource)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if tc.expectSameKey {
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected same key detection (400), got %d; src=%q dst=%q",
|
||||
rr.Code, capturedSrcObject, capturedDstObject)
|
||||
}
|
||||
} else {
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected different keys (200), got %d; src=%q dst=%q",
|
||||
rr.Code, capturedSrcObject, capturedDstObject)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
385
weed/s3api/cors/cors.go
Normal file
385
weed/s3api/cors/cors.go
Normal file
@@ -0,0 +1,385 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CORSRule represents a single CORS rule
|
||||
type CORSRule struct {
|
||||
AllowedHeaders []string `xml:"AllowedHeader,omitempty" json:"AllowedHeaders,omitempty"`
|
||||
AllowedMethods []string `xml:"AllowedMethod" json:"AllowedMethods"`
|
||||
AllowedOrigins []string `xml:"AllowedOrigin" json:"AllowedOrigins"`
|
||||
ExposeHeaders []string `xml:"ExposeHeader,omitempty" json:"ExposeHeaders,omitempty"`
|
||||
MaxAgeSeconds *int `xml:"MaxAgeSeconds,omitempty" json:"MaxAgeSeconds,omitempty"`
|
||||
ID string `xml:"ID,omitempty" json:"ID,omitempty"`
|
||||
}
|
||||
|
||||
// CORSConfiguration represents the CORS configuration for a bucket
|
||||
type CORSConfiguration struct {
|
||||
CORSRules []CORSRule `xml:"CORSRule" json:"CORSRules"`
|
||||
}
|
||||
|
||||
// CORSRequest represents a CORS request
|
||||
type CORSRequest struct {
|
||||
Origin string
|
||||
Method string
|
||||
RequestHeaders []string
|
||||
IsPreflightRequest bool
|
||||
AccessControlRequestMethod string
|
||||
AccessControlRequestHeaders []string
|
||||
}
|
||||
|
||||
// CORSResponse represents the response for a CORS request
|
||||
type CORSResponse struct {
|
||||
AllowOrigin string
|
||||
AllowMethods string
|
||||
AllowHeaders string
|
||||
ExposeHeaders string
|
||||
MaxAge string
|
||||
AllowCredentials bool
|
||||
}
|
||||
|
||||
// ValidateConfiguration validates a CORS configuration
|
||||
func ValidateConfiguration(config *CORSConfiguration) error {
|
||||
if config == nil {
|
||||
return fmt.Errorf("CORS configuration cannot be nil")
|
||||
}
|
||||
|
||||
if len(config.CORSRules) == 0 {
|
||||
return fmt.Errorf("CORS configuration must have at least one rule")
|
||||
}
|
||||
|
||||
if len(config.CORSRules) > 100 {
|
||||
return fmt.Errorf("CORS configuration cannot have more than 100 rules")
|
||||
}
|
||||
|
||||
for i, rule := range config.CORSRules {
|
||||
if err := validateRule(&rule); err != nil {
|
||||
return fmt.Errorf("invalid CORS rule at index %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseRequest parses an HTTP request to extract CORS information
|
||||
func ParseRequest(r *http.Request) *CORSRequest {
|
||||
corsReq := &CORSRequest{
|
||||
Origin: r.Header.Get("Origin"),
|
||||
Method: r.Method,
|
||||
}
|
||||
|
||||
// Check if this is a preflight request
|
||||
if r.Method == "OPTIONS" {
|
||||
corsReq.IsPreflightRequest = true
|
||||
corsReq.AccessControlRequestMethod = r.Header.Get("Access-Control-Request-Method")
|
||||
|
||||
if headers := r.Header.Get("Access-Control-Request-Headers"); headers != "" {
|
||||
corsReq.AccessControlRequestHeaders = strings.Split(headers, ",")
|
||||
for i := range corsReq.AccessControlRequestHeaders {
|
||||
corsReq.AccessControlRequestHeaders[i] = strings.TrimSpace(corsReq.AccessControlRequestHeaders[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return corsReq
|
||||
}
|
||||
|
||||
// validateRule validates a single CORS rule
|
||||
func validateRule(rule *CORSRule) error {
|
||||
if len(rule.AllowedMethods) == 0 {
|
||||
return fmt.Errorf("AllowedMethods cannot be empty")
|
||||
}
|
||||
|
||||
if len(rule.AllowedOrigins) == 0 {
|
||||
return fmt.Errorf("AllowedOrigins cannot be empty")
|
||||
}
|
||||
|
||||
// Validate allowed methods
|
||||
validMethods := map[string]bool{
|
||||
"GET": true,
|
||||
"PUT": true,
|
||||
"POST": true,
|
||||
"DELETE": true,
|
||||
"HEAD": true,
|
||||
}
|
||||
|
||||
for _, method := range rule.AllowedMethods {
|
||||
if !validMethods[method] {
|
||||
return fmt.Errorf("invalid HTTP method: %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate origins
|
||||
for _, origin := range rule.AllowedOrigins {
|
||||
if origin == "*" {
|
||||
continue
|
||||
}
|
||||
if err := validateOrigin(origin); err != nil {
|
||||
return fmt.Errorf("invalid origin %s: %v", origin, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate MaxAgeSeconds
|
||||
if rule.MaxAgeSeconds != nil && *rule.MaxAgeSeconds < 0 {
|
||||
return fmt.Errorf("MaxAgeSeconds cannot be negative")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOrigin validates an origin string
|
||||
func validateOrigin(origin string) error {
|
||||
if origin == "" {
|
||||
return fmt.Errorf("origin cannot be empty")
|
||||
}
|
||||
|
||||
// Special case: "*" is always valid
|
||||
if origin == "*" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count wildcards
|
||||
wildcardCount := strings.Count(origin, "*")
|
||||
if wildcardCount > 1 {
|
||||
return fmt.Errorf("origin can contain at most one wildcard")
|
||||
}
|
||||
|
||||
// If there's a wildcard, it should be in a valid position
|
||||
if wildcardCount == 1 {
|
||||
// Must be in the format: http://*.example.com or https://*.example.com
|
||||
if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
|
||||
return fmt.Errorf("origin with wildcard must start with http:// or https://")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EvaluateRequest evaluates a CORS request against a CORS configuration
|
||||
func EvaluateRequest(config *CORSConfiguration, corsReq *CORSRequest) (*CORSResponse, error) {
|
||||
if config == nil || corsReq == nil {
|
||||
return nil, fmt.Errorf("config and corsReq cannot be nil")
|
||||
}
|
||||
|
||||
if corsReq.Origin == "" {
|
||||
return nil, fmt.Errorf("origin header is required for CORS requests")
|
||||
}
|
||||
|
||||
// Find the first rule that matches the origin
|
||||
for _, rule := range config.CORSRules {
|
||||
if matchesOrigin(rule.AllowedOrigins, corsReq.Origin) {
|
||||
// For preflight requests, we need more detailed validation
|
||||
if corsReq.IsPreflightRequest {
|
||||
return buildPreflightResponse(&rule, corsReq), nil
|
||||
} else {
|
||||
// For actual requests, check method
|
||||
if containsString(rule.AllowedMethods, corsReq.Method) {
|
||||
return buildResponse(&rule, corsReq), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no matching CORS rule found")
|
||||
}
|
||||
|
||||
// buildPreflightResponse builds a CORS response for preflight requests
|
||||
func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
|
||||
response := &CORSResponse{
|
||||
AllowOrigin: corsReq.Origin,
|
||||
}
|
||||
|
||||
// Check if the requested method is allowed
|
||||
methodAllowed := corsReq.AccessControlRequestMethod == "" || containsString(rule.AllowedMethods, corsReq.AccessControlRequestMethod)
|
||||
|
||||
// Check requested headers
|
||||
var allowedRequestHeaders []string
|
||||
allHeadersAllowed := true
|
||||
|
||||
if len(corsReq.AccessControlRequestHeaders) > 0 {
|
||||
// Check if wildcard is allowed
|
||||
hasWildcard := false
|
||||
for _, header := range rule.AllowedHeaders {
|
||||
if header == "*" {
|
||||
hasWildcard = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasWildcard {
|
||||
// All requested headers are allowed with wildcard
|
||||
allowedRequestHeaders = corsReq.AccessControlRequestHeaders
|
||||
} else {
|
||||
// Check each requested header individually
|
||||
for _, requestedHeader := range corsReq.AccessControlRequestHeaders {
|
||||
if matchesHeader(rule.AllowedHeaders, requestedHeader) {
|
||||
allowedRequestHeaders = append(allowedRequestHeaders, requestedHeader)
|
||||
} else {
|
||||
allHeadersAllowed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only set method and header info if both method and ALL headers are allowed
|
||||
if methodAllowed && allHeadersAllowed {
|
||||
response.AllowMethods = strings.Join(rule.AllowedMethods, ", ")
|
||||
|
||||
if len(allowedRequestHeaders) > 0 {
|
||||
response.AllowHeaders = strings.Join(allowedRequestHeaders, ", ")
|
||||
}
|
||||
|
||||
// Set exposed headers
|
||||
if len(rule.ExposeHeaders) > 0 {
|
||||
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
|
||||
}
|
||||
|
||||
// Set max age
|
||||
if rule.MaxAgeSeconds != nil {
|
||||
response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// buildResponse builds a CORS response from a matching rule
|
||||
func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
|
||||
response := &CORSResponse{
|
||||
AllowOrigin: corsReq.Origin,
|
||||
}
|
||||
|
||||
// Set allowed methods
|
||||
response.AllowMethods = strings.Join(rule.AllowedMethods, ", ")
|
||||
|
||||
// Set allowed headers
|
||||
if len(rule.AllowedHeaders) > 0 {
|
||||
response.AllowHeaders = strings.Join(rule.AllowedHeaders, ", ")
|
||||
}
|
||||
|
||||
// Set expose headers
|
||||
if len(rule.ExposeHeaders) > 0 {
|
||||
response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
|
||||
}
|
||||
|
||||
// Set max age
|
||||
if rule.MaxAgeSeconds != nil {
|
||||
response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// matchesOrigin checks if the request origin matches any allowed origin
|
||||
func matchesOrigin(allowedOrigins []string, origin string) bool {
|
||||
for _, allowedOrigin := range allowedOrigins {
|
||||
if allowedOrigin == "*" {
|
||||
return true
|
||||
}
|
||||
if allowedOrigin == origin {
|
||||
return true
|
||||
}
|
||||
// Handle wildcard patterns like https://*.example.com
|
||||
if strings.Contains(allowedOrigin, "*") {
|
||||
if matchWildcard(allowedOrigin, origin) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchWildcard performs wildcard matching for origins
|
||||
func matchWildcard(pattern, text string) bool {
|
||||
// Simple wildcard matching - only supports single * at the beginning
|
||||
if strings.HasPrefix(pattern, "http://*") {
|
||||
suffix := pattern[8:] // Remove "http://*"
|
||||
return strings.HasPrefix(text, "http://") && strings.HasSuffix(text, suffix)
|
||||
}
|
||||
if strings.HasPrefix(pattern, "https://*") {
|
||||
suffix := pattern[9:] // Remove "https://*"
|
||||
return strings.HasPrefix(text, "https://") && strings.HasSuffix(text, suffix)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesHeader checks if a header is allowed
|
||||
func matchesHeader(allowedHeaders []string, header string) bool {
|
||||
// If no headers are specified, all headers are allowed
|
||||
if len(allowedHeaders) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Header matching is case-insensitive
|
||||
header = strings.ToLower(header)
|
||||
|
||||
for _, allowedHeader := range allowedHeaders {
|
||||
allowedHeaderLower := strings.ToLower(allowedHeader)
|
||||
|
||||
// Wildcard match
|
||||
if allowedHeaderLower == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if allowedHeaderLower == header {
|
||||
return true
|
||||
}
|
||||
|
||||
// Prefix wildcard match (e.g., "x-amz-*" matches "x-amz-date")
|
||||
if strings.HasSuffix(allowedHeaderLower, "*") {
|
||||
prefix := strings.TrimSuffix(allowedHeaderLower, "*")
|
||||
if strings.HasPrefix(header, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsString checks if a slice contains a specific string
|
||||
func containsString(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApplyHeaders applies CORS headers to an HTTP response
|
||||
func ApplyHeaders(w http.ResponseWriter, corsResp *CORSResponse) {
|
||||
if corsResp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if corsResp.AllowOrigin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", corsResp.AllowOrigin)
|
||||
}
|
||||
|
||||
if corsResp.AllowMethods != "" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", corsResp.AllowMethods)
|
||||
}
|
||||
|
||||
if corsResp.AllowHeaders != "" {
|
||||
w.Header().Set("Access-Control-Allow-Headers", corsResp.AllowHeaders)
|
||||
}
|
||||
|
||||
if corsResp.ExposeHeaders != "" {
|
||||
w.Header().Set("Access-Control-Expose-Headers", corsResp.ExposeHeaders)
|
||||
}
|
||||
|
||||
if corsResp.MaxAge != "" {
|
||||
w.Header().Set("Access-Control-Max-Age", corsResp.MaxAge)
|
||||
}
|
||||
|
||||
if corsResp.AllowCredentials {
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
}
|
||||
569
weed/s3api/cors/cors_test.go
Normal file
569
weed/s3api/cors/cors_test.go
Normal file
@@ -0,0 +1,569 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateConfiguration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *CORSConfiguration
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "nil config",
|
||||
config: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty rules",
|
||||
config: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid single rule",
|
||||
config: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "too many rules",
|
||||
config: &CORSConfiguration{
|
||||
CORSRules: make([]CORSRule, 101),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid method",
|
||||
config: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedMethods: []string{"INVALID"},
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty origins",
|
||||
config: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedOrigins: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid origin with multiple wildcards",
|
||||
config: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedOrigins: []string{"http://*.*.example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative MaxAgeSeconds",
|
||||
config: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedOrigins: []string{"*"},
|
||||
MaxAgeSeconds: intPtr(-1),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateConfiguration(tt.config)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateConfiguration() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOrigin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty origin",
|
||||
origin: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid origin",
|
||||
origin: "http://example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard origin",
|
||||
origin: "*",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid wildcard origin",
|
||||
origin: "http://*.example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "https wildcard origin",
|
||||
origin: "https://*.example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid wildcard origin",
|
||||
origin: "*.example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiple wildcards",
|
||||
origin: "http://*.*.example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateOrigin(tt.origin)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateOrigin() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
want *CORSRequest
|
||||
}{
|
||||
{
|
||||
name: "simple GET request",
|
||||
req: &http.Request{
|
||||
Method: "GET",
|
||||
Header: http.Header{
|
||||
"Origin": []string{"http://example.com"},
|
||||
},
|
||||
},
|
||||
want: &CORSRequest{
|
||||
Origin: "http://example.com",
|
||||
Method: "GET",
|
||||
IsPreflightRequest: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OPTIONS preflight request",
|
||||
req: &http.Request{
|
||||
Method: "OPTIONS",
|
||||
Header: http.Header{
|
||||
"Origin": []string{"http://example.com"},
|
||||
"Access-Control-Request-Method": []string{"PUT"},
|
||||
"Access-Control-Request-Headers": []string{"Content-Type, Authorization"},
|
||||
},
|
||||
},
|
||||
want: &CORSRequest{
|
||||
Origin: "http://example.com",
|
||||
Method: "OPTIONS",
|
||||
IsPreflightRequest: true,
|
||||
AccessControlRequestMethod: "PUT",
|
||||
AccessControlRequestHeaders: []string{"Content-Type", "Authorization"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "request without origin",
|
||||
req: &http.Request{
|
||||
Method: "GET",
|
||||
Header: http.Header{},
|
||||
},
|
||||
want: &CORSRequest{
|
||||
Origin: "",
|
||||
Method: "GET",
|
||||
IsPreflightRequest: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParseRequest(tt.req)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ParseRequest() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesOrigin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedOrigins []string
|
||||
origin string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "wildcard match",
|
||||
allowedOrigins: []string{"*"},
|
||||
origin: "http://example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact match",
|
||||
allowedOrigins: []string{"http://example.com"},
|
||||
origin: "http://example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
allowedOrigins: []string{"http://example.com"},
|
||||
origin: "http://other.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain match",
|
||||
allowedOrigins: []string{"http://*.example.com"},
|
||||
origin: "http://api.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain no match",
|
||||
allowedOrigins: []string{"http://*.example.com"},
|
||||
origin: "http://example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "multiple origins with match",
|
||||
allowedOrigins: []string{"http://example.com", "http://other.com"},
|
||||
origin: "http://other.com",
|
||||
want: true,
|
||||
},
|
||||
// HTTPS test cases
|
||||
{
|
||||
name: "https exact match",
|
||||
allowedOrigins: []string{"https://example.com"},
|
||||
origin: "https://example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "https no match",
|
||||
allowedOrigins: []string{"https://example.com"},
|
||||
origin: "https://other.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "https wildcard subdomain match",
|
||||
allowedOrigins: []string{"https://*.example.com"},
|
||||
origin: "https://api.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "https wildcard subdomain no match - base domain",
|
||||
allowedOrigins: []string{"https://*.example.com"},
|
||||
origin: "https://example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "https wildcard subdomain no match - different domain",
|
||||
allowedOrigins: []string{"https://*.example.com"},
|
||||
origin: "https://api.other.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "protocol mismatch - http pattern https origin",
|
||||
allowedOrigins: []string{"http://*.example.com"},
|
||||
origin: "https://api.example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "protocol mismatch - https pattern http origin",
|
||||
allowedOrigins: []string{"https://*.example.com"},
|
||||
origin: "http://api.example.com",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchesOrigin(tt.allowedOrigins, tt.origin)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchesOrigin() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedHeaders []string
|
||||
header string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty allowed headers",
|
||||
allowedHeaders: []string{},
|
||||
header: "Content-Type",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard match",
|
||||
allowedHeaders: []string{"*"},
|
||||
header: "Content-Type",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact match",
|
||||
allowedHeaders: []string{"Content-Type"},
|
||||
header: "Content-Type",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
allowedHeaders: []string{"content-type"},
|
||||
header: "Content-Type",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
allowedHeaders: []string{"Authorization"},
|
||||
header: "Content-Type",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard prefix match",
|
||||
allowedHeaders: []string{"x-amz-*"},
|
||||
header: "x-amz-date",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchesHeader(tt.allowedHeaders, tt.header)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchesHeader() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRequest(t *testing.T) {
|
||||
config := &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedOrigins: []string{"http://example.com"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
ExposeHeaders: []string{"ETag"},
|
||||
MaxAgeSeconds: intPtr(3600),
|
||||
},
|
||||
{
|
||||
AllowedMethods: []string{"PUT"},
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *CORSConfiguration
|
||||
corsReq *CORSRequest
|
||||
want *CORSResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "matching first rule",
|
||||
config: config,
|
||||
corsReq: &CORSRequest{
|
||||
Origin: "http://example.com",
|
||||
Method: "GET",
|
||||
},
|
||||
want: &CORSResponse{
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET, POST",
|
||||
AllowHeaders: "Content-Type",
|
||||
ExposeHeaders: "ETag",
|
||||
MaxAge: "3600",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "matching second rule",
|
||||
config: config,
|
||||
corsReq: &CORSRequest{
|
||||
Origin: "http://other.com",
|
||||
Method: "PUT",
|
||||
},
|
||||
want: &CORSResponse{
|
||||
AllowOrigin: "http://other.com",
|
||||
AllowMethods: "PUT",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no matching rule",
|
||||
config: config,
|
||||
corsReq: &CORSRequest{
|
||||
Origin: "http://forbidden.com",
|
||||
Method: "GET",
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "preflight request",
|
||||
config: config,
|
||||
corsReq: &CORSRequest{
|
||||
Origin: "http://example.com",
|
||||
Method: "OPTIONS",
|
||||
IsPreflightRequest: true,
|
||||
AccessControlRequestMethod: "POST",
|
||||
AccessControlRequestHeaders: []string{"Content-Type"},
|
||||
},
|
||||
want: &CORSResponse{
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET, POST",
|
||||
AllowHeaders: "Content-Type",
|
||||
ExposeHeaders: "ETag",
|
||||
MaxAge: "3600",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "preflight request with forbidden header",
|
||||
config: config,
|
||||
corsReq: &CORSRequest{
|
||||
Origin: "http://example.com",
|
||||
Method: "OPTIONS",
|
||||
IsPreflightRequest: true,
|
||||
AccessControlRequestMethod: "POST",
|
||||
AccessControlRequestHeaders: []string{"Authorization"},
|
||||
},
|
||||
want: &CORSResponse{
|
||||
AllowOrigin: "http://example.com",
|
||||
// No AllowMethods or AllowHeaders because the requested header is forbidden
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "request without origin",
|
||||
config: config,
|
||||
corsReq: &CORSRequest{
|
||||
Origin: "",
|
||||
Method: "GET",
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := EvaluateRequest(tt.config, tt.corsReq)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("EvaluateRequest() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("EvaluateRequest() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
corsResp *CORSResponse
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "nil response",
|
||||
corsResp: nil,
|
||||
want: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "complete response",
|
||||
corsResp: &CORSResponse{
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET, POST",
|
||||
AllowHeaders: "Content-Type",
|
||||
ExposeHeaders: "ETag",
|
||||
MaxAge: "3600",
|
||||
},
|
||||
want: map[string]string{
|
||||
"Access-Control-Allow-Origin": "http://example.com",
|
||||
"Access-Control-Allow-Methods": "GET, POST",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Expose-Headers": "ETag",
|
||||
"Access-Control-Max-Age": "3600",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with credentials",
|
||||
corsResp: &CORSResponse{
|
||||
AllowOrigin: "http://example.com",
|
||||
AllowMethods: "GET",
|
||||
AllowCredentials: true,
|
||||
},
|
||||
want: map[string]string{
|
||||
"Access-Control-Allow-Origin": "http://example.com",
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a proper response writer using httptest
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ApplyHeaders(w, tt.corsResp)
|
||||
|
||||
// Extract headers from the response
|
||||
headers := make(map[string]string)
|
||||
for key, values := range w.Header() {
|
||||
if len(values) > 0 {
|
||||
headers[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(headers, tt.want) {
|
||||
t.Errorf("ApplyHeaders() headers = %v, want %v", headers, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions and types for testing
|
||||
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
142
weed/s3api/cors/middleware.go
Normal file
142
weed/s3api/cors/middleware.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// BucketChecker interface for checking bucket existence
|
||||
type BucketChecker interface {
|
||||
CheckBucket(r *http.Request, bucket string) s3err.ErrorCode
|
||||
}
|
||||
|
||||
// CORSConfigGetter interface for getting CORS configuration
|
||||
type CORSConfigGetter interface {
|
||||
GetCORSConfiguration(bucket string) (*CORSConfiguration, s3err.ErrorCode)
|
||||
}
|
||||
|
||||
// Middleware handles CORS evaluation for all S3 API requests
|
||||
type Middleware struct {
|
||||
bucketChecker BucketChecker
|
||||
corsConfigGetter CORSConfigGetter
|
||||
fallbackConfig *CORSConfiguration // Global CORS configuration as fallback
|
||||
}
|
||||
|
||||
// NewMiddleware creates a new CORS middleware instance with optional global fallback config
|
||||
func NewMiddleware(bucketChecker BucketChecker, corsConfigGetter CORSConfigGetter, fallbackConfig *CORSConfiguration) *Middleware {
|
||||
return &Middleware{
|
||||
bucketChecker: bucketChecker,
|
||||
corsConfigGetter: corsConfigGetter,
|
||||
fallbackConfig: fallbackConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// getCORSConfig retrieves the applicable CORS configuration, trying bucket-specific first, then fallback.
|
||||
// Returns the configuration and a boolean indicating if any configuration was found.
|
||||
// Only falls back to global config when there's explicitly no bucket-level config.
|
||||
// For other errors (e.g., access denied), returns false to let the handler deny the request.
|
||||
func (m *Middleware) getCORSConfig(bucket string) (*CORSConfiguration, bool) {
|
||||
config, errCode := m.corsConfigGetter.GetCORSConfiguration(bucket)
|
||||
|
||||
switch errCode {
|
||||
case s3err.ErrNone:
|
||||
if config != nil {
|
||||
// Found a bucket-specific config, use it.
|
||||
return config, true
|
||||
}
|
||||
// No bucket config, proceed to fallback.
|
||||
case s3err.ErrNoSuchCORSConfiguration:
|
||||
// No bucket config, proceed to fallback.
|
||||
case s3err.ErrNoSuchBucket:
|
||||
// Bucket doesn't exist, proceed to fallback.
|
||||
// This ensures we don't leak existence information by returning 403 vs 200.
|
||||
default:
|
||||
// Any other error means we should not proceed.
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// No bucket-level config found, try global fallback
|
||||
if m.fallbackConfig != nil {
|
||||
return m.fallbackConfig, true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Handler returns the CORS middleware handler
|
||||
func (m *Middleware) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
m.processCORS(w, r, func() {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// HandleOptionsRequest handles OPTIONS requests for CORS preflight
|
||||
func (m *Middleware) HandleOptionsRequest(w http.ResponseWriter, r *http.Request) {
|
||||
m.processCORS(w, r, func() {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
// processCORS handles the common CORS logic for both regular and preflight requests.
|
||||
// It takes a next callback which is executed if the request flow proceeds (i.e., not short-circuited by CORS errors or preflight responses).
|
||||
func (m *Middleware) processCORS(w http.ResponseWriter, r *http.Request, next func()) {
|
||||
corsReq := ParseRequest(r)
|
||||
bucket, _ := s3_constants.GetBucketAndObject(r)
|
||||
|
||||
// 1. Basic Validation
|
||||
if bucket == "" {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Load Configuration
|
||||
config, hasConfig := m.getCORSConfig(bucket)
|
||||
|
||||
// 3. Apply Vary Header (Always applied if config exists)
|
||||
if hasConfig {
|
||||
w.Header().Add("Vary", "Origin")
|
||||
}
|
||||
|
||||
// 4. Handle Non-CORS Requests
|
||||
if corsReq.Origin == "" {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Handle Missing Configuration
|
||||
if !hasConfig {
|
||||
if corsReq.IsPreflightRequest {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
||||
return
|
||||
}
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Evaluate CORS Request
|
||||
corsResp, err := EvaluateRequest(config, corsReq)
|
||||
if err != nil {
|
||||
glog.V(3).Infof("CORS evaluation failed for bucket %s: %v", bucket, err)
|
||||
if corsReq.IsPreflightRequest {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
||||
return
|
||||
}
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Success Case
|
||||
ApplyHeaders(w, corsResp)
|
||||
|
||||
if corsReq.IsPreflightRequest {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
240
weed/s3api/cors/middleware_nonexistent_bucket_test.go
Normal file
240
weed/s3api/cors/middleware_nonexistent_bucket_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// TestMiddlewareNonExistentBucket tests that CORS headers are applied even for non-existent buckets
|
||||
// This prevents information disclosure about bucket existence and ensures consistent CORS behavior
|
||||
func TestMiddlewareNonExistentBucket(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fallbackConfig *CORSConfiguration
|
||||
requestOrigin string
|
||||
requestMethod string
|
||||
isOptions bool
|
||||
expectedStatus int
|
||||
expectedOriginHeader string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Preflight request to non-existent bucket with global CORS config",
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://example.com",
|
||||
requestMethod: "OPTIONS",
|
||||
isOptions: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOriginHeader: "https://example.com",
|
||||
description: "Preflight to non-existent bucket should succeed with CORS headers",
|
||||
},
|
||||
{
|
||||
name: "Actual request to non-existent bucket with global CORS config",
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://example.com",
|
||||
requestMethod: "GET",
|
||||
isOptions: false,
|
||||
expectedStatus: http.StatusNotFound, // Handler returns NoSuchBucket
|
||||
expectedOriginHeader: "https://example.com",
|
||||
description: "GET to non-existent bucket should have CORS headers even with NoSuchBucket error",
|
||||
},
|
||||
{
|
||||
name: "Preflight to non-existent bucket with specific origin",
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"https://allowed.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://allowed.com",
|
||||
requestMethod: "OPTIONS",
|
||||
isOptions: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOriginHeader: "https://allowed.com",
|
||||
description: "Preflight to non-existent bucket with matching origin should succeed",
|
||||
},
|
||||
{
|
||||
name: "Preflight to non-existent bucket with non-matching origin",
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"https://allowed.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://notallowed.com",
|
||||
requestMethod: "OPTIONS",
|
||||
isOptions: true,
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedOriginHeader: "",
|
||||
description: "Preflight to non-existent bucket with non-matching origin should fail",
|
||||
},
|
||||
{
|
||||
name: "Preflight to non-existent bucket without CORS config",
|
||||
fallbackConfig: nil,
|
||||
requestOrigin: "https://example.com",
|
||||
requestMethod: "OPTIONS",
|
||||
isOptions: true,
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedOriginHeader: "",
|
||||
description: "Preflight to non-existent bucket without CORS config should fail",
|
||||
},
|
||||
{
|
||||
name: "Bucket listing request to non-existent bucket with CORS",
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://mydomain.com",
|
||||
requestMethod: "OPTIONS",
|
||||
isOptions: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOriginHeader: "https://mydomain.com",
|
||||
description: "Bucket listing preflight to non-existent bucket should have CORS headers (issue #8065)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup mocks - bucket does NOT exist
|
||||
bucketChecker := &mockBucketChecker{bucketExists: false}
|
||||
configGetter := &mockCORSConfigGetter{
|
||||
config: nil, // No bucket-specific config
|
||||
errCode: s3err.ErrNone,
|
||||
}
|
||||
|
||||
// Create middleware with fallback config
|
||||
middleware := NewMiddleware(bucketChecker, configGetter, tt.fallbackConfig)
|
||||
|
||||
// Create request with mux variables
|
||||
req := httptest.NewRequest(tt.requestMethod, "/nonexistent-bucket/testobject", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{
|
||||
"bucket": "nonexistent-bucket",
|
||||
"object": "testobject",
|
||||
})
|
||||
if tt.requestOrigin != "" {
|
||||
req.Header.Set("Origin", tt.requestOrigin)
|
||||
}
|
||||
if tt.isOptions {
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
}
|
||||
|
||||
// Create response recorder
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create a handler that returns 404 for non-existent buckets
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Simulate NoSuchBucket error
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
// Execute middleware
|
||||
if tt.isOptions {
|
||||
middleware.HandleOptionsRequest(w, req)
|
||||
} else {
|
||||
middleware.Handler(nextHandler).ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("%s: expected status %d, got %d", tt.description, tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
// Check CORS header
|
||||
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
|
||||
if actualOrigin != tt.expectedOriginHeader {
|
||||
t.Errorf("%s: expected Access-Control-Allow-Origin='%s', got '%s'",
|
||||
tt.description, tt.expectedOriginHeader, actualOrigin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareConsistentBehavior tests that CORS headers are consistent regardless of bucket existence
|
||||
// This is a security test to ensure bucket existence cannot be inferred from CORS header presence
|
||||
func TestMiddlewareConsistentBehavior(t *testing.T) {
|
||||
fallbackConfig := &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
bucketExists bool
|
||||
description string
|
||||
}{
|
||||
{bucketExists: true, description: "existing bucket"},
|
||||
{bucketExists: false, description: "non-existent bucket"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
// Setup mocks
|
||||
bucketChecker := &mockBucketChecker{bucketExists: tc.bucketExists}
|
||||
configGetter := &mockCORSConfigGetter{
|
||||
config: nil,
|
||||
errCode: s3err.ErrNone,
|
||||
}
|
||||
|
||||
middleware := NewMiddleware(bucketChecker, configGetter, fallbackConfig)
|
||||
|
||||
// Create preflight request
|
||||
req := httptest.NewRequest("OPTIONS", "/testbucket/testobject", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{
|
||||
"bucket": "testbucket",
|
||||
"object": "testobject",
|
||||
})
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
middleware.HandleOptionsRequest(w, req)
|
||||
|
||||
// Both existing and non-existent buckets should return same CORS headers
|
||||
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
|
||||
if actualOrigin != "https://example.com" {
|
||||
t.Errorf("%s: expected Access-Control-Allow-Origin='https://example.com', got '%s'",
|
||||
tc.description, actualOrigin)
|
||||
}
|
||||
|
||||
// Both should return 200 OK for preflight
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("%s: expected status 200, got %d", tc.description, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
564
weed/s3api/cors/middleware_test.go
Normal file
564
weed/s3api/cors/middleware_test.go
Normal file
@@ -0,0 +1,564 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// Mock implementations for testing
|
||||
|
||||
type mockBucketChecker struct {
|
||||
bucketExists bool
|
||||
}
|
||||
|
||||
func (m *mockBucketChecker) CheckBucket(r *http.Request, bucket string) s3err.ErrorCode {
|
||||
if m.bucketExists {
|
||||
return s3err.ErrNone
|
||||
}
|
||||
return s3err.ErrNoSuchBucket
|
||||
}
|
||||
|
||||
type mockCORSConfigGetter struct {
|
||||
config *CORSConfiguration
|
||||
errCode s3err.ErrorCode
|
||||
}
|
||||
|
||||
func (m *mockCORSConfigGetter) GetCORSConfiguration(bucket string) (*CORSConfiguration, s3err.ErrorCode) {
|
||||
return m.config, m.errCode
|
||||
}
|
||||
|
||||
// TestMiddlewareFallbackConfig tests that the middleware uses fallback config when bucket-level config is not available
|
||||
func TestMiddlewareFallbackConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bucketConfig *CORSConfiguration
|
||||
fallbackConfig *CORSConfiguration
|
||||
requestOrigin string
|
||||
requestMethod string
|
||||
isOptions bool
|
||||
expectedStatus int
|
||||
expectedOriginHeader string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "No bucket config, fallback to global config with wildcard",
|
||||
bucketConfig: nil,
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://example.com",
|
||||
requestMethod: "GET",
|
||||
isOptions: false,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOriginHeader: "https://example.com",
|
||||
description: "Should use fallback global config when no bucket config exists",
|
||||
},
|
||||
{
|
||||
name: "No bucket config, fallback to global config with specific origin",
|
||||
bucketConfig: nil,
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://example.com",
|
||||
requestMethod: "GET",
|
||||
isOptions: false,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOriginHeader: "https://example.com",
|
||||
description: "Should use fallback config with specific origin match",
|
||||
},
|
||||
{
|
||||
name: "No bucket config, fallback rejects non-matching origin",
|
||||
bucketConfig: nil,
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"https://allowed.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://notallowed.com",
|
||||
requestMethod: "GET",
|
||||
isOptions: false,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOriginHeader: "",
|
||||
description: "Should not apply CORS headers when origin doesn't match fallback config",
|
||||
},
|
||||
{
|
||||
name: "Bucket config takes precedence over fallback",
|
||||
bucketConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"https://bucket-specific.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://bucket-specific.com",
|
||||
requestMethod: "GET",
|
||||
isOptions: false,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOriginHeader: "https://bucket-specific.com",
|
||||
description: "Bucket-level config should be used instead of fallback",
|
||||
},
|
||||
{
|
||||
name: "Bucket config rejects, even though fallback would allow",
|
||||
bucketConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"https://restricted.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://example.com",
|
||||
requestMethod: "GET",
|
||||
isOptions: false,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOriginHeader: "",
|
||||
description: "Bucket-level config is authoritative, fallback should not apply",
|
||||
},
|
||||
{
|
||||
name: "No config at all, no CORS headers",
|
||||
bucketConfig: nil,
|
||||
fallbackConfig: nil,
|
||||
requestOrigin: "https://example.com",
|
||||
requestMethod: "GET",
|
||||
isOptions: false,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOriginHeader: "",
|
||||
description: "Without any config, no CORS headers should be applied",
|
||||
},
|
||||
{
|
||||
name: "OPTIONS preflight with fallback config",
|
||||
bucketConfig: nil,
|
||||
fallbackConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestOrigin: "https://example.com",
|
||||
requestMethod: "OPTIONS",
|
||||
isOptions: true,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedOriginHeader: "https://example.com",
|
||||
description: "OPTIONS preflight should work with fallback config",
|
||||
},
|
||||
{
|
||||
name: "OPTIONS preflight without any config should fail",
|
||||
bucketConfig: nil,
|
||||
fallbackConfig: nil,
|
||||
requestOrigin: "https://example.com",
|
||||
requestMethod: "OPTIONS",
|
||||
isOptions: true,
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedOriginHeader: "",
|
||||
description: "OPTIONS preflight without config should return 403",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup mocks
|
||||
bucketChecker := &mockBucketChecker{bucketExists: true}
|
||||
configGetter := &mockCORSConfigGetter{
|
||||
config: tt.bucketConfig,
|
||||
errCode: s3err.ErrNone,
|
||||
}
|
||||
|
||||
// Create middleware with optional fallback
|
||||
middleware := NewMiddleware(bucketChecker, configGetter, tt.fallbackConfig)
|
||||
|
||||
// Create request with mux variables
|
||||
req := httptest.NewRequest(tt.requestMethod, "/testbucket/testobject", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{
|
||||
"bucket": "testbucket",
|
||||
"object": "testobject",
|
||||
})
|
||||
if tt.requestOrigin != "" {
|
||||
req.Header.Set("Origin", tt.requestOrigin)
|
||||
}
|
||||
if tt.isOptions {
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
}
|
||||
|
||||
// Create response recorder
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create a simple handler that returns 200 OK
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Execute middleware
|
||||
if tt.isOptions {
|
||||
middleware.HandleOptionsRequest(w, req)
|
||||
} else {
|
||||
middleware.Handler(nextHandler).ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("%s: expected status %d, got %d", tt.description, tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
// Check CORS header
|
||||
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
|
||||
if actualOrigin != tt.expectedOriginHeader {
|
||||
t.Errorf("%s: expected Access-Control-Allow-Origin='%s', got '%s'",
|
||||
tt.description, tt.expectedOriginHeader, actualOrigin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareFallbackConfigWithMultipleOrigins tests fallback with multiple allowed origins
|
||||
func TestMiddlewareFallbackConfigWithMultipleOrigins(t *testing.T) {
|
||||
fallbackConfig := &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"https://example1.com", "https://example2.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
bucketChecker := &mockBucketChecker{bucketExists: true}
|
||||
configGetter := &mockCORSConfigGetter{
|
||||
config: nil, // No bucket config
|
||||
errCode: s3err.ErrNone,
|
||||
}
|
||||
|
||||
middleware := NewMiddleware(bucketChecker, configGetter, fallbackConfig)
|
||||
|
||||
tests := []struct {
|
||||
origin string
|
||||
shouldMatch bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
origin: "https://example1.com",
|
||||
shouldMatch: true,
|
||||
description: "First allowed origin should match",
|
||||
},
|
||||
{
|
||||
origin: "https://example2.com",
|
||||
shouldMatch: true,
|
||||
description: "Second allowed origin should match",
|
||||
},
|
||||
{
|
||||
origin: "https://example3.com",
|
||||
shouldMatch: false,
|
||||
description: "Non-allowed origin should not match",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/testbucket/testobject", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{
|
||||
"bucket": "testbucket",
|
||||
"object": "testobject",
|
||||
})
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
middleware.Handler(nextHandler).ServeHTTP(w, req)
|
||||
|
||||
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
|
||||
if tt.shouldMatch {
|
||||
if actualOrigin != tt.origin {
|
||||
t.Errorf("%s: expected Access-Control-Allow-Origin='%s', got '%s'",
|
||||
tt.description, tt.origin, actualOrigin)
|
||||
}
|
||||
} else {
|
||||
if actualOrigin != "" {
|
||||
t.Errorf("%s: expected no Access-Control-Allow-Origin header, got '%s'",
|
||||
tt.description, actualOrigin)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareFallbackWithError tests that real errors (not "no config") don't trigger fallback
|
||||
func TestMiddlewareFallbackWithError(t *testing.T) {
|
||||
fallbackConfig := &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
errCode s3err.ErrorCode
|
||||
expectedOriginHeader string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "ErrAccessDenied should not trigger fallback",
|
||||
errCode: s3err.ErrAccessDenied,
|
||||
expectedOriginHeader: "",
|
||||
description: "Access denied errors should not expose CORS headers",
|
||||
},
|
||||
{
|
||||
name: "ErrInternalError should not trigger fallback",
|
||||
errCode: s3err.ErrInternalError,
|
||||
expectedOriginHeader: "",
|
||||
description: "Internal errors should not expose CORS headers",
|
||||
},
|
||||
{
|
||||
name: "ErrNoSuchBucket should trigger fallback",
|
||||
errCode: s3err.ErrNoSuchBucket,
|
||||
expectedOriginHeader: "https://example.com",
|
||||
description: "Bucket not found errors should expose CORS headers to prevent information disclosure",
|
||||
},
|
||||
{
|
||||
name: "ErrNoSuchCORSConfiguration should trigger fallback",
|
||||
errCode: s3err.ErrNoSuchCORSConfiguration,
|
||||
expectedOriginHeader: "https://example.com",
|
||||
description: "Explicit no CORS config should use fallback",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bucketChecker := &mockBucketChecker{bucketExists: true}
|
||||
configGetter := &mockCORSConfigGetter{
|
||||
config: nil,
|
||||
errCode: tt.errCode,
|
||||
}
|
||||
|
||||
middleware := NewMiddleware(bucketChecker, configGetter, fallbackConfig)
|
||||
|
||||
req := httptest.NewRequest("GET", "/testbucket/testobject", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{
|
||||
"bucket": "testbucket",
|
||||
"object": "testobject",
|
||||
})
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
middleware.Handler(nextHandler).ServeHTTP(w, req)
|
||||
|
||||
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
|
||||
if actualOrigin != tt.expectedOriginHeader {
|
||||
t.Errorf("%s: expected Access-Control-Allow-Origin='%s', got '%s'",
|
||||
tt.description, tt.expectedOriginHeader, actualOrigin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareVaryHeader tests that the Vary: Origin header is correctly applied in various scenarios
|
||||
func TestMiddlewareVaryHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bucketConfig *CORSConfiguration
|
||||
shouldHaveVaryHeader bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Specific allowed origin",
|
||||
bucketConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldHaveVaryHeader: true,
|
||||
description: "Should have Vary: Origin header when CORS config exists with specific origin",
|
||||
},
|
||||
{
|
||||
name: "Wildcard allowed origin",
|
||||
bucketConfig: &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldHaveVaryHeader: true,
|
||||
description: "Should have Vary: Origin header even when CORS config has wildcard origin",
|
||||
},
|
||||
{
|
||||
name: "No CORS configuration",
|
||||
bucketConfig: nil,
|
||||
shouldHaveVaryHeader: false,
|
||||
description: "Should NOT have Vary: Origin header when no CORS config exists",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup mocks
|
||||
bucketChecker := &mockBucketChecker{bucketExists: true}
|
||||
|
||||
var errCode s3err.ErrorCode
|
||||
if tt.bucketConfig == nil {
|
||||
errCode = s3err.ErrNoSuchCORSConfiguration
|
||||
} else {
|
||||
errCode = s3err.ErrNone
|
||||
}
|
||||
|
||||
configGetter := &mockCORSConfigGetter{
|
||||
config: tt.bucketConfig,
|
||||
errCode: errCode,
|
||||
}
|
||||
|
||||
// Create middleware
|
||||
middleware := NewMiddleware(bucketChecker, configGetter, nil)
|
||||
|
||||
// Create request WITHOUT Origin header
|
||||
req := httptest.NewRequest("GET", "/testbucket/testobject", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{
|
||||
"bucket": "testbucket",
|
||||
"object": "testobject",
|
||||
})
|
||||
|
||||
// Create response recorder
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create a simple handler that returns 200 OK
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Execute middleware
|
||||
middleware.Handler(nextHandler).ServeHTTP(w, req)
|
||||
|
||||
// Check Vary header
|
||||
hasVaryOrigin := hasVaryOrigin(w.Header())
|
||||
|
||||
if tt.shouldHaveVaryHeader && !hasVaryOrigin {
|
||||
t.Errorf("%s: expected Vary: Origin header, but got %v", tt.description, w.Header()["Vary"])
|
||||
} else if !tt.shouldHaveVaryHeader && hasVaryOrigin {
|
||||
t.Errorf("%s: expected NO Vary: Origin header, but got it", tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleOptionsRequestVaryHeader reproduces the issue where HandleOptionsRequest misses Vary: Origin
|
||||
func TestHandleOptionsRequestVaryHeader(t *testing.T) {
|
||||
// Setup mocks
|
||||
bucketChecker := &mockBucketChecker{bucketExists: true}
|
||||
|
||||
config := &CORSConfiguration{
|
||||
CORSRules: []CORSRule{
|
||||
{
|
||||
AllowedOrigins: []string{"https://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
configGetter := &mockCORSConfigGetter{
|
||||
config: config,
|
||||
errCode: s3err.ErrNone,
|
||||
}
|
||||
|
||||
// Create middleware
|
||||
middleware := NewMiddleware(bucketChecker, configGetter, nil)
|
||||
|
||||
// Create OPTIONS request
|
||||
req := httptest.NewRequest("OPTIONS", "/testbucket/testobject", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{
|
||||
"bucket": "testbucket",
|
||||
"object": "testobject",
|
||||
})
|
||||
|
||||
// Set valid CORS headers
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
|
||||
// Create response recorder
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Execute HandleOptionsRequest
|
||||
middleware.HandleOptionsRequest(w, req)
|
||||
|
||||
// Check Response Status
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check Vary header - verified to FAIL without fix
|
||||
if !hasVaryOrigin(w.Header()) {
|
||||
t.Errorf("Expected Vary: Origin header in OPTIONS response, but got %v", w.Header()["Vary"])
|
||||
}
|
||||
}
|
||||
|
||||
// hasVaryOrigin checks if the "Vary" header contains "Origin"
|
||||
func hasVaryOrigin(header http.Header) bool {
|
||||
varyHeaders := header["Vary"]
|
||||
for _, h := range varyHeaders {
|
||||
for _, v := range strings.Split(h, ",") {
|
||||
if strings.TrimSpace(v) == "Origin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
226
weed/s3api/crc_combine.go
Normal file
226
weed/s3api/crc_combine.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package s3api
|
||||
|
||||
import "math"
|
||||
|
||||
// crcParams describes a CRC algorithm in the Rocksoft/Williams parameterization (see "15. A
|
||||
// Parameterized Model For CRC Algorithms" in http://www.ross.net/crc/download/crc_v3.txt)
|
||||
type crcParams struct {
|
||||
width uint32 // the width of the algorithm expressed in bits; 1 less than the width of poly
|
||||
poly uint64 // the unreflected poly
|
||||
init uint64 // initial register value
|
||||
xorout uint64 // value XORed into the final register
|
||||
refin bool // reflect input bytes
|
||||
refout bool // reflect final register
|
||||
}
|
||||
|
||||
var crcCombineParams = map[ChecksumAlgorithm]crcParams{
|
||||
ChecksumAlgorithmCRC64NVMe: {
|
||||
width: 64,
|
||||
poly: 0xad93d23594c93659,
|
||||
init: 0xffffffffffffffff,
|
||||
xorout: 0xffffffffffffffff,
|
||||
refin: true,
|
||||
refout: true,
|
||||
},
|
||||
ChecksumAlgorithmCRC32: {
|
||||
width: 32,
|
||||
poly: 0x04c11db7,
|
||||
init: 0xffffffff,
|
||||
xorout: 0xffffffff,
|
||||
refin: true,
|
||||
refout: true,
|
||||
},
|
||||
ChecksumAlgorithmCRC32C: {
|
||||
width: 32,
|
||||
poly: 0x1edc6f41,
|
||||
init: 0xffffffff,
|
||||
xorout: 0xffffffff,
|
||||
refin: true,
|
||||
refout: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Ported from: https://github.com/awesomized/crc-fast-rust/blob/3a853cc7daf2cd47cc4466f198680cabdfb0b5fa/src/combine.rs
|
||||
|
||||
/*
|
||||
Derived from this excellent answer by Mark Adler on StackOverflow:
|
||||
https://stackoverflow.com/questions/29915764/generic-crc-8-16-32-64-combine-implementation/29928573#29928573
|
||||
*/
|
||||
|
||||
/* crccomb.c -- generalized combination of CRCs
|
||||
* Copyright (C) 2015 Mark Adler
|
||||
* Version 1.1 29 Apr 2015 Mark Adler
|
||||
*/
|
||||
|
||||
/*
|
||||
This software is provided 'as-is', without any express or implied
|
||||
warranty. In no event will the author be held liable for any damages
|
||||
arising from the use of this software.
|
||||
|
||||
Permission is granted to anyone to use this software for any purpose,
|
||||
including commercial applications, and to alter it and redistribute it
|
||||
freely, subject to the following restrictions:
|
||||
|
||||
1. The origin of this software must not be misrepresented; you must not
|
||||
claim that you wrote the original software. If you use this software
|
||||
in a product, an acknowledgment in the product documentation would be
|
||||
appreciated but is not required.
|
||||
2. Altered source versions must be plainly marked as such, and must not be
|
||||
misrepresented as being the original software.
|
||||
3. This notice may not be removed or altered from any source distribution.
|
||||
|
||||
Mark Adler
|
||||
madler@alumni.caltech.edu
|
||||
*/
|
||||
|
||||
/*
|
||||
zlib provides a fast operation to combine the CRCs of two sequences of bytes
|
||||
into a single CRC, which is the CRC of the two sequences concatenated. That
|
||||
operation requires only the two CRC's and the length of the second sequence.
|
||||
The routine in zlib only works on the particular CRC-32 used by zlib. The
|
||||
code provided here generalizes that operation to apply to a wide range of
|
||||
CRCs. The CRC is specified in a series of #defines, based on the
|
||||
parameterization found in Ross William's excellent CRC tutorial here:
|
||||
|
||||
http://www.ross.net/crc/download/crc_v3.txt
|
||||
|
||||
A comprehensive catalogue of known CRCs, their parameters, check values, and
|
||||
references can be found here:
|
||||
|
||||
http://reveng.sourceforge.net/crc-catalogue/all.htm
|
||||
*/
|
||||
|
||||
// Multiply the GF(2) vector vec by the GF(2) matrix mat, returning the
|
||||
// resulting vector. The vector is stored as bits in a crc_t. The matrix is
|
||||
// similarly stored with each column as a crc_t, where the number of columns is
|
||||
// at least enough to cover the position of the most significant 1 bit in the
|
||||
// vector (so a dimension parameter is not needed).
|
||||
func gf2MatrixTimes(mat *[64]uint64, vec uint64) uint64 {
|
||||
var sum uint64
|
||||
idx := 0
|
||||
|
||||
for vec > 0 {
|
||||
if vec&1 == 1 {
|
||||
sum ^= mat[idx]
|
||||
}
|
||||
vec >>= 1
|
||||
idx++
|
||||
}
|
||||
|
||||
return sum
|
||||
}
|
||||
|
||||
// Multiply the matrix mat by itself, returning the result in square. WIDTH is
|
||||
// the dimension of the matrices, i.e., the number of bits in each crc_t
|
||||
// (rows), and the number of crc_t's (columns).
|
||||
func gf2MatrixSquare(square *[64]uint64, mat *[64]uint64) {
|
||||
for n := 0; n < 64; n++ {
|
||||
square[n] = gf2MatrixTimes(mat, mat[n])
|
||||
}
|
||||
}
|
||||
|
||||
// Combine the CRCs of two successive sequences, where crc1 is the CRC of the
|
||||
// first sequence of bytes, crc2 is the CRC of the immediately following
|
||||
// sequence of bytes, and len2 is the length of the second sequence. The CRC
|
||||
// of the combined sequence is returned.
|
||||
func combineCRC(crc1 uint64, crc2 uint64, len2 uint64, params crcParams) uint64 {
|
||||
even := [64]uint64{} /* even-power-of-two zeros operator */
|
||||
odd := [64]uint64{} /* odd-power-of-two zeros operator */
|
||||
|
||||
/* exclusive-or the result with len2 zeros applied to the CRC of an empty
|
||||
sequence */
|
||||
crc1 ^= params.init ^ params.xorout
|
||||
|
||||
/* construct the operator for one zero bit and put in odd[] */
|
||||
if params.refin && params.refout {
|
||||
// use the reflected POLY
|
||||
odd[0] = reflectPoly(params.poly, params.width)
|
||||
col := uint64(1)
|
||||
for n := uint32(1); n < params.width; n++ {
|
||||
odd[n] = col
|
||||
col <<= 1
|
||||
}
|
||||
} else if !params.refin && !params.refout {
|
||||
col := uint64(2)
|
||||
for n := uint32(0); n < params.width-1; n++ {
|
||||
odd[n] = col
|
||||
col <<= 1
|
||||
}
|
||||
odd[params.width-1] = params.poly
|
||||
} else {
|
||||
panic("Unsupported CRC configuration")
|
||||
}
|
||||
|
||||
/* put operator for two zero bits in even */
|
||||
gf2MatrixSquare(&even, &odd)
|
||||
|
||||
/* put operator for four zero bits in odd */
|
||||
gf2MatrixSquare(&odd, &even)
|
||||
|
||||
/* apply len2 zeros to crc1 (first square will put the operator for one
|
||||
zero byte, eight zero bits, in even) */
|
||||
for {
|
||||
/* apply zeros operator for this bit of len2 */
|
||||
gf2MatrixSquare(&even, &odd)
|
||||
if len2&1 == 1 {
|
||||
crc1 = gf2MatrixTimes(&even, crc1)
|
||||
}
|
||||
len2 >>= 1
|
||||
|
||||
/* if no more bits set, then done */
|
||||
if len2 == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
/* another iteration of the loop with odd and even swapped */
|
||||
gf2MatrixSquare(&odd, &even)
|
||||
if len2&1 == 1 {
|
||||
crc1 = gf2MatrixTimes(&odd, crc1)
|
||||
}
|
||||
len2 >>= 1
|
||||
|
||||
/* if no more bits set, then done */
|
||||
if len2 == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/* return combined crc */
|
||||
return crc1 ^ crc2
|
||||
}
|
||||
|
||||
func reflectPoly(poly uint64, width uint32) uint64 {
|
||||
if width > 64 {
|
||||
panic("Width must be <= 64 bits")
|
||||
}
|
||||
|
||||
// First reverse all bits
|
||||
reversed := bitReverse(poly)
|
||||
|
||||
// Shift right to get the significant bits in the correct position
|
||||
// For a 32-bit poly, we need to shift right by (64 - 32) = 32 bits
|
||||
shifted := reversed >> (64 - width)
|
||||
|
||||
// Create mask for the target width
|
||||
var mask uint64
|
||||
if width == 64 {
|
||||
mask = math.MaxUint64
|
||||
} else {
|
||||
mask = (1 << width) - 1
|
||||
}
|
||||
|
||||
// Apply mask to ensure we only keep the bits we want
|
||||
return shifted & mask
|
||||
}
|
||||
|
||||
func bitReverse(forward uint64) uint64 {
|
||||
reversed := uint64(0)
|
||||
|
||||
for i := 0; i < 64; i++ {
|
||||
reversed <<= 1
|
||||
reversed |= forward & 1
|
||||
forward >>= 1
|
||||
}
|
||||
|
||||
return reversed
|
||||
}
|
||||
124
weed/s3api/crc_combine_test.go
Normal file
124
weed/s3api/crc_combine_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"hash/crc32"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/crc64nvme"
|
||||
)
|
||||
|
||||
// Tests that combining per-block CRC-64/NVME values reproduces the CRC of the concatenated data.
|
||||
func TestCombineCRC64NVME(t *testing.T) {
|
||||
params := crcCombineParams[ChecksumAlgorithmCRC64NVMe]
|
||||
|
||||
blocks := [][]byte{
|
||||
[]byte("abc"),
|
||||
[]byte("def"),
|
||||
[]byte("hello world, this is a longer block of data to combine"),
|
||||
bytes.Repeat([]byte{0x00}, 5*1024*1024),
|
||||
bytes.Repeat([]byte{0xAB, 0xCD}, 1024),
|
||||
[]byte("z"),
|
||||
}
|
||||
|
||||
var combined uint64
|
||||
var concatenated []byte
|
||||
for i, block := range blocks {
|
||||
blockCRC := crc64nvme.Checksum(block)
|
||||
|
||||
if i == 0 {
|
||||
combined = blockCRC
|
||||
} else {
|
||||
combined = combineCRC(combined, blockCRC, uint64(len(block)), params)
|
||||
}
|
||||
concatenated = append(concatenated, block...)
|
||||
|
||||
want := crc64nvme.Checksum(concatenated)
|
||||
if combined != want {
|
||||
t.Fatalf("after block %d: combined = %016x, want %016x", i, combined, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tests that combining per-block CRC-32 values reproduces the CRC of the concatenated data.
|
||||
func TestCombineCRC32(t *testing.T) {
|
||||
params := crcCombineParams[ChecksumAlgorithmCRC32]
|
||||
|
||||
blocks := [][]byte{
|
||||
[]byte("abc"),
|
||||
[]byte("def"),
|
||||
[]byte("hello world, this is a longer block of data to combine"),
|
||||
bytes.Repeat([]byte{0x00}, 5*1024*1024),
|
||||
bytes.Repeat([]byte{0xAB, 0xCD}, 1024),
|
||||
[]byte("z"),
|
||||
}
|
||||
|
||||
var combined uint64
|
||||
var concatenated []byte
|
||||
for i, block := range blocks {
|
||||
blockCRC := uint64(crc32.Checksum(block, crc32.MakeTable(crc32.IEEE)))
|
||||
|
||||
if i == 0 {
|
||||
combined = blockCRC
|
||||
} else {
|
||||
combined = combineCRC(combined, blockCRC, uint64(len(block)), params)
|
||||
}
|
||||
concatenated = append(concatenated, block...)
|
||||
|
||||
want := uint64(crc32.Checksum(concatenated, crc32.MakeTable(crc32.IEEE)))
|
||||
if combined != want {
|
||||
t.Fatalf("after block %d: combined = %016x, want %016x", i, combined, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tests that combining per-block CRC-32C values reproduces the CRC of the concatenated data.
|
||||
func TestCombineCRC32C(t *testing.T) {
|
||||
params := crcCombineParams[ChecksumAlgorithmCRC32C]
|
||||
table := crc32.MakeTable(crc32.Castagnoli)
|
||||
|
||||
blocks := [][]byte{
|
||||
[]byte("abc"),
|
||||
[]byte("def"),
|
||||
[]byte("hello world, this is a longer block of data to combine"),
|
||||
bytes.Repeat([]byte{0x00}, 5*1024*1024),
|
||||
bytes.Repeat([]byte{0xAB, 0xCD}, 1024),
|
||||
[]byte("z"),
|
||||
}
|
||||
|
||||
var combined uint64
|
||||
var concatenated []byte
|
||||
for i, block := range blocks {
|
||||
blockCRC := uint64(crc32.Checksum(block, table))
|
||||
|
||||
if i == 0 {
|
||||
combined = blockCRC
|
||||
} else {
|
||||
combined = combineCRC(combined, blockCRC, uint64(len(block)), params)
|
||||
}
|
||||
concatenated = append(concatenated, block...)
|
||||
|
||||
want := uint64(crc32.Checksum(concatenated, table))
|
||||
if combined != want {
|
||||
t.Fatalf("after block %d: combined = %016x, want %016x", i, combined, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineCRC64NVMEEmptySecond(t *testing.T) {
|
||||
params := crcCombineParams[ChecksumAlgorithmCRC64NVMe]
|
||||
crc1 := crc64nvme.Checksum([]byte("abc"))
|
||||
|
||||
if got := combineCRC(crc1, crc64nvme.Checksum(nil), 0, params); got != crc1 {
|
||||
t.Fatalf("combining with empty second block changed crc: got %016x, want %016x", got, crc1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineCRC32EmptySecond(t *testing.T) {
|
||||
params := crcCombineParams[ChecksumAlgorithmCRC32]
|
||||
crc1 := uint64(crc32.Checksum([]byte("abc"), crc32.MakeTable(crc32.IEEE)))
|
||||
|
||||
if got := combineCRC(crc1, uint64(crc32.Checksum(nil, crc32.MakeTable(crc32.IEEE))), 0, params); got != crc1 {
|
||||
t.Fatalf("combining with empty second block changed crc: got %016x, want %016x", got, crc1)
|
||||
}
|
||||
}
|
||||
27
weed/s3api/credential_iam_errors.go
Normal file
27
weed/s3api/credential_iam_errors.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential"
|
||||
)
|
||||
|
||||
// CredentialErrToIamErrCode maps a credential store error to the appropriate
|
||||
// IAM error code. Permanent client errors (e.g. duplicate entity, missing
|
||||
// entity) are mapped to their specific IAM codes; all other errors fall back
|
||||
// to ServiceFailure so the caller receives an HTTP 500 rather than a 200.
|
||||
//
|
||||
// This is shared between the standalone IAM server (weed/iamapi) and the
|
||||
// embedded IAM handler (weed/s3api) so error granularity stays in sync.
|
||||
func CredentialErrToIamErrCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, credential.ErrUserAlreadyExists):
|
||||
return iam.ErrCodeEntityAlreadyExistsException
|
||||
case errors.Is(err, credential.ErrUserNotFound),
|
||||
errors.Is(err, credential.ErrAccessKeyNotFound):
|
||||
return iam.ErrCodeNoSuchEntityException
|
||||
default:
|
||||
return iam.ErrCodeServiceFailureException
|
||||
}
|
||||
}
|
||||
13
weed/s3api/custom_types.go
Normal file
13
weed/s3api/custom_types.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// ConditionalHeaderResult holds the result of conditional header checking
|
||||
type ConditionalHeaderResult struct {
|
||||
ErrorCode s3err.ErrorCode
|
||||
ETag string // ETag of the object (for 304 responses)
|
||||
Entry *filer_pb.Entry // Entry fetched during conditional check (nil if not fetched or object doesn't exist)
|
||||
}
|
||||
1606
weed/s3api/filer_multipart.go
Normal file
1606
weed/s3api/filer_multipart.go
Normal file
File diff suppressed because it is too large
Load Diff
206
weed/s3api/filer_multipart_checksum_test.go
Normal file
206
weed/s3api/filer_multipart_checksum_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"hash/crc32"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/minio/crc64nvme"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
func makeCRC64NVMEPartEntry(data []byte) *filer_pb.Entry {
|
||||
sum := crc64nvme.New()
|
||||
sum.Write(data)
|
||||
return &filer_pb.Entry{
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(len(data))},
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtChecksumAlgorithm: []byte(s3_constants.AmzChecksumCRC64NVME),
|
||||
s3_constants.ExtChecksumValue: []byte(base64.StdEncoding.EncodeToString(sum.Sum(nil))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func makeCRC32PartEntry(data []byte) *filer_pb.Entry {
|
||||
sum := crc32.NewIEEE()
|
||||
sum.Write(data)
|
||||
return &filer_pb.Entry{
|
||||
Attributes: &filer_pb.FuseAttributes{FileSize: uint64(len(data))},
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtChecksumAlgorithm: []byte(s3_constants.AmzChecksumCRC32),
|
||||
s3_constants.ExtChecksumValue: []byte(base64.StdEncoding.EncodeToString(sum.Sum(nil))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Tests the FULL_OBJECT checksum of a multipart upload equals the CRC64NVME of the concatenated
|
||||
// part data, with no composite "-N" suffix.
|
||||
func TestComputeFullObjectChecksumCRC64NVME(t *testing.T) {
|
||||
parts := [][]byte{
|
||||
bytes.Repeat([]byte("a"), 5*1024*1024),
|
||||
bytes.Repeat([]byte("b"), 5*1024*1024),
|
||||
[]byte("tail part, smaller than the rest"),
|
||||
}
|
||||
|
||||
partEntries := map[int][]*filer_pb.Entry{}
|
||||
var whole []byte
|
||||
completed := []int{}
|
||||
for i, data := range parts {
|
||||
partNumber := i + 1
|
||||
partEntries[partNumber] = []*filer_pb.Entry{makeCRC64NVMEPartEntry(data)}
|
||||
completed = append(completed, partNumber)
|
||||
whole = append(whole, data...)
|
||||
}
|
||||
|
||||
got, err := computeFullObjectChecksum(s3_constants.AmzChecksumCRC64NVME, partEntries, completed)
|
||||
if err != nil {
|
||||
t.Fatalf("computeFullObjectChecksum: %v", err)
|
||||
}
|
||||
|
||||
wholeSum := crc64nvme.New()
|
||||
wholeSum.Write(whole)
|
||||
expected := base64.StdEncoding.EncodeToString(wholeSum.Sum(nil))
|
||||
|
||||
if got != expected {
|
||||
t.Fatalf("full object checksum = %q, want %q", got, expected)
|
||||
}
|
||||
if bytes.ContainsRune([]byte(got), '-') {
|
||||
t.Fatalf("full object checksum must not carry a -N suffix: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Tests the FULL_OBJECT checksum of a multipart upload equals the CRC32 of the concatenated
|
||||
// part data, with no composite "-N" suffix.
|
||||
func TestComputeFullObjectChecksumCRC32(t *testing.T) {
|
||||
parts := [][]byte{
|
||||
bytes.Repeat([]byte("a"), 5*1024*1024),
|
||||
bytes.Repeat([]byte("b"), 5*1024*1024),
|
||||
[]byte("tail part, smaller than the rest"),
|
||||
}
|
||||
|
||||
partEntries := map[int][]*filer_pb.Entry{}
|
||||
var whole []byte
|
||||
completed := []int{}
|
||||
for i, data := range parts {
|
||||
partNumber := i + 1
|
||||
partEntries[partNumber] = []*filer_pb.Entry{makeCRC32PartEntry(data)}
|
||||
completed = append(completed, partNumber)
|
||||
whole = append(whole, data...)
|
||||
}
|
||||
|
||||
got, err := computeFullObjectChecksum(s3_constants.AmzChecksumCRC32, partEntries, completed)
|
||||
if err != nil {
|
||||
t.Fatalf("computeFullObjectChecksum: %v", err)
|
||||
}
|
||||
|
||||
wholeSum := crc32.NewIEEE()
|
||||
wholeSum.Write(whole)
|
||||
expected := base64.StdEncoding.EncodeToString(wholeSum.Sum(nil))
|
||||
|
||||
if got != expected {
|
||||
t.Fatalf("full object checksum = %q, want %q", got, expected)
|
||||
}
|
||||
if bytes.ContainsRune([]byte(got), '-') {
|
||||
t.Fatalf("full object checksum must not carry a -N suffix: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMultipartChecksumType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
algo ChecksumAlgorithm
|
||||
requested string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"crc64 default full", ChecksumAlgorithmCRC64NVMe, "", s3_constants.ChecksumTypeFullObject, false},
|
||||
{"crc64 explicit full", ChecksumAlgorithmCRC64NVMe, s3_constants.ChecksumTypeFullObject, s3_constants.ChecksumTypeFullObject, false},
|
||||
{"crc64 composite rejected", ChecksumAlgorithmCRC64NVMe, s3_constants.ChecksumTypeComposite, "", true},
|
||||
{"crc32 default composite", ChecksumAlgorithmCRC32, "", s3_constants.ChecksumTypeComposite, false},
|
||||
{"crc32 explicit composite", ChecksumAlgorithmCRC32, s3_constants.ChecksumTypeComposite, s3_constants.ChecksumTypeComposite, false},
|
||||
{"crc32 explicit full", ChecksumAlgorithmCRC32, s3_constants.ChecksumTypeFullObject, s3_constants.ChecksumTypeFullObject, false},
|
||||
{"crc32c explicit full", ChecksumAlgorithmCRC32C, s3_constants.ChecksumTypeFullObject, s3_constants.ChecksumTypeFullObject, false},
|
||||
{"sha256 default composite", ChecksumAlgorithmSHA256, "", s3_constants.ChecksumTypeComposite, false},
|
||||
{"sha256 full rejected", ChecksumAlgorithmSHA256, s3_constants.ChecksumTypeFullObject, "", true},
|
||||
{"invalid checksum type", ChecksumAlgorithmCRC64NVMe, "bogus", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := resolveMultipartChecksumType(tc.algo, tc.requested)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CompleteMultipartUpload returns the object checksum in the XML body, where the
|
||||
// AWS SDKs read it from — not as an HTTP response header.
|
||||
func TestCompleteMultipartUploadResultChecksumXML(t *testing.T) {
|
||||
result := &CompleteMultipartUploadResult{
|
||||
ETag: aws.String("\"etag-1\""),
|
||||
ChecksumType: s3_constants.ChecksumTypeComposite,
|
||||
}
|
||||
result.SetChecksum(s3_constants.AmzChecksumCRC32C, "fx4FQw==-1")
|
||||
|
||||
encoded := string(s3err.EncodeXMLResponse(result))
|
||||
for _, want := range []string{
|
||||
"<ChecksumCRC32C>fx4FQw==-1</ChecksumCRC32C>",
|
||||
"<ChecksumType>COMPOSITE</ChecksumType>",
|
||||
} {
|
||||
if !strings.Contains(encoded, want) {
|
||||
t.Fatalf("response %q does not contain %q", encoded, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(encoded, "<ChecksumCRC32>") {
|
||||
t.Fatalf("response %q carries an unrequested algorithm", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
// A retried CompleteMultipartUpload rebuilds the response from the committed
|
||||
// entry, so it must repeat the checksum the first attempt returned.
|
||||
func TestCompleteMultipartResultChecksumFromEntry(t *testing.T) {
|
||||
input := &s3.CompleteMultipartUploadInput{Bucket: aws.String("bucket"), Key: aws.String("key")}
|
||||
entry := &filer_pb.Entry{Extended: map[string][]byte{
|
||||
s3_constants.ExtChecksumAlgorithm: []byte(s3_constants.AmzChecksumCRC32C),
|
||||
s3_constants.ExtChecksumValue: []byte("fx4FQw==-1"),
|
||||
s3_constants.ExtChecksumType: []byte(s3_constants.ChecksumTypeComposite),
|
||||
}}
|
||||
|
||||
result := completeMultipartResult(httptest.NewRequest(http.MethodPost, "/bucket/key", nil), input, "\"etag-1\"", entry)
|
||||
if result.ChecksumCRC32C != "fx4FQw==-1" {
|
||||
t.Fatalf("ChecksumCRC32C = %q, want %q", result.ChecksumCRC32C, "fx4FQw==-1")
|
||||
}
|
||||
if result.ChecksumType != s3_constants.ChecksumTypeComposite {
|
||||
t.Fatalf("ChecksumType = %q, want %q", result.ChecksumType, s3_constants.ChecksumTypeComposite)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateMultipartUpload echoes the algorithm and type it recorded, so a client
|
||||
// can tell which checksum the upload will be completed with.
|
||||
func TestCreateMultipartUploadResultChecksumHeaders(t *testing.T) {
|
||||
result := &InitiateMultipartUploadResult{
|
||||
ChecksumAlgorithm: "CRC32C",
|
||||
ChecksumType: s3_constants.ChecksumTypeComposite,
|
||||
}
|
||||
if encoded := string(s3err.EncodeXMLResponse(result)); strings.Contains(encoded, "Checksum") {
|
||||
t.Fatalf("response %q carries checksum members in the XML body", encoded)
|
||||
}
|
||||
}
|
||||
121
weed/s3api/filer_multipart_etag_test.go
Normal file
121
weed/s3api/filer_multipart_etag_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Tests multipart upload ETag behavior when both stored S3 ETags and
|
||||
// MD5-derived ETags are present. The tests verify that SeaweedFS uses
|
||||
// the stored ETag for part validation and multipart ETag calculation,
|
||||
// while still falling back to the MD5-derived value when necessary.
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
func TestGetEtagFromEntryPrefersStoredExtendedETag(t *testing.T) {
|
||||
storedETag := "11111111111111111111111111111111"
|
||||
computedMD5 := mustDecodeHexETagForTest(t, "22222222222222222222222222222222")
|
||||
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "0001.part",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Md5: computedMD5,
|
||||
},
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtETagKey: []byte(storedETag),
|
||||
},
|
||||
}
|
||||
|
||||
if got, want := getEtagFromEntry(entry), `"`+storedETag+`"`; got != want {
|
||||
t.Fatalf("getEtagFromEntry() = %q, want stored extended ETag %q", got, want)
|
||||
}
|
||||
|
||||
match, invalid, normalizedPartETag, normalizedEntryETag := validateCompletePartETag(storedETag, entry)
|
||||
if invalid || !match {
|
||||
t.Fatalf("validateCompletePartETag() = match:%v invalid:%v part:%q entry:%q, want stored ETag match", match, invalid, normalizedPartETag, normalizedEntryETag)
|
||||
}
|
||||
|
||||
computedETag := hex.EncodeToString(computedMD5)
|
||||
match, invalid, normalizedPartETag, normalizedEntryETag = validateCompletePartETag(computedETag, entry)
|
||||
if invalid || match {
|
||||
t.Fatalf("validateCompletePartETag() = match:%v invalid:%v part:%q entry:%q, want computed filer ETag mismatch", match, invalid, normalizedPartETag, normalizedEntryETag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEtagFromEntryFallbacksToFilerETag(t *testing.T) {
|
||||
computedETag := "33333333333333333333333333333333"
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "0001.part",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Md5: mustDecodeHexETagForTest(t, computedETag),
|
||||
},
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtETagKey: []byte(""),
|
||||
},
|
||||
}
|
||||
|
||||
if got, want := getEtagFromEntry(entry), `"`+computedETag+`"`; got != want {
|
||||
t.Fatalf("getEtagFromEntry() = %q, want fallback filer ETag %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateMultipartETagUsesStoredPartETags(t *testing.T) {
|
||||
storedPart1 := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
storedPart2 := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
computedPart1 := "cccccccccccccccccccccccccccccccc"
|
||||
computedPart2 := "dddddddddddddddddddddddddddddddd"
|
||||
|
||||
partEntries := map[int][]*filer_pb.Entry{
|
||||
1: {newMultipartETagTestEntry(t, "0001.part", storedPart1, computedPart1)},
|
||||
2: {newMultipartETagTestEntry(t, "0002.part", storedPart2, computedPart2)},
|
||||
}
|
||||
|
||||
got := calculateMultipartETag(partEntries, []int{1, 2})
|
||||
want := expectedMultipartETagForTest(t, storedPart1, storedPart2)
|
||||
if got != want {
|
||||
t.Fatalf("calculateMultipartETag() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
legacyComputedETag := expectedMultipartETagForTest(t, computedPart1, computedPart2)
|
||||
if got == legacyComputedETag {
|
||||
t.Fatalf("calculateMultipartETag() = %q, still used filer.ETag-derived part ETags", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newMultipartETagTestEntry(t *testing.T, name, storedETag, computedETag string) *filer_pb.Entry {
|
||||
t.Helper()
|
||||
return &filer_pb.Entry{
|
||||
Name: name,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Md5: mustDecodeHexETagForTest(t, computedETag),
|
||||
},
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtETagKey: []byte(storedETag),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func expectedMultipartETagForTest(t *testing.T, partETags ...string) string {
|
||||
t.Helper()
|
||||
var combined []byte
|
||||
for _, partETag := range partETags {
|
||||
partETag = strings.Trim(partETag, `"`)
|
||||
if before, _, found := strings.Cut(partETag, "-"); found {
|
||||
partETag = before
|
||||
}
|
||||
combined = append(combined, mustDecodeHexETagForTest(t, partETag)...)
|
||||
}
|
||||
return fmt.Sprintf("%x-%d", md5.Sum(combined), len(partETags))
|
||||
}
|
||||
|
||||
func mustDecodeHexETagForTest(t *testing.T, etag string) []byte {
|
||||
t.Helper()
|
||||
decoded, err := hex.DecodeString(etag)
|
||||
if err != nil {
|
||||
t.Fatalf("decode hex ETag %q: %v", etag, err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
346
weed/s3api/filer_multipart_sse_s3_test.go
Normal file
346
weed/s3api/filer_multipart_sse_s3_test.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
func TestCompletedMultipartChunkBackfillsSSES3MetadataFromUploadEntry(t *testing.T) {
|
||||
keyManager := initSSES3KeyManagerForTest(t)
|
||||
|
||||
key, err := GenerateSSES3Key()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSSES3Key: %v", err)
|
||||
}
|
||||
keyData, err := SerializeSSES3Metadata(key)
|
||||
if err != nil {
|
||||
t.Fatalf("SerializeSSES3Metadata: %v", err)
|
||||
}
|
||||
|
||||
baseIV := []byte("1234567890abcdef")
|
||||
uploadEntry := &filer_pb.Entry{
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.SeaweedFSSSES3Encryption: []byte(s3_constants.SSEAlgorithmAES256),
|
||||
s3_constants.SeaweedFSSSES3BaseIV: []byte(base64.StdEncoding.EncodeToString(baseIV)),
|
||||
s3_constants.SeaweedFSSSES3KeyData: []byte(base64.StdEncoding.EncodeToString(keyData)),
|
||||
},
|
||||
}
|
||||
|
||||
sses3Info, err := extractMultipartSSES3Info(uploadEntry)
|
||||
if err != nil {
|
||||
t.Fatalf("extractMultipartSSES3Info: %v", err)
|
||||
}
|
||||
|
||||
partLocalOffset := int64(8 * 1024 * 1024)
|
||||
finalObjectOffset := int64(40 * 1024 * 1024)
|
||||
chunk := &filer_pb.FileChunk{
|
||||
FileId: "1,abc",
|
||||
Offset: partLocalOffset,
|
||||
Size: 1024,
|
||||
}
|
||||
|
||||
finalChunk, err := completedMultipartChunk(chunk, finalObjectOffset, sses3Info)
|
||||
if err != nil {
|
||||
t.Fatalf("completedMultipartChunk: %v", err)
|
||||
}
|
||||
if finalChunk.GetOffset() != finalObjectOffset {
|
||||
t.Fatalf("final chunk offset = %d, want %d", finalChunk.GetOffset(), finalObjectOffset)
|
||||
}
|
||||
if finalChunk.GetSseType() != filer_pb.SSEType_SSE_S3 {
|
||||
t.Fatalf("final chunk SSE type = %s, want SSE_S3", finalChunk.GetSseType())
|
||||
}
|
||||
if len(finalChunk.GetSseMetadata()) == 0 {
|
||||
t.Fatal("expected final chunk SSE metadata")
|
||||
}
|
||||
|
||||
chunkKey, err := DeserializeSSES3Metadata(finalChunk.GetSseMetadata(), keyManager)
|
||||
if err != nil {
|
||||
t.Fatalf("DeserializeSSES3Metadata: %v", err)
|
||||
}
|
||||
if !bytes.Equal(chunkKey.Key, key.Key) {
|
||||
t.Fatal("chunk metadata key does not match upload key")
|
||||
}
|
||||
expectedIV, _ := calculateIVWithOffset(baseIV, partLocalOffset)
|
||||
if !bytes.Equal(chunkKey.IV, expectedIV) {
|
||||
t.Fatalf("chunk metadata IV = %x, want %x", chunkKey.IV, expectedIV)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedMultipartChunkPreservesExistingSSES3Metadata(t *testing.T) {
|
||||
existingMetadata := []byte("already-present")
|
||||
chunk := &filer_pb.FileChunk{
|
||||
FileId: "2,abc",
|
||||
Offset: 1024,
|
||||
Size: 2048,
|
||||
SseType: filer_pb.SSEType_SSE_S3,
|
||||
SseMetadata: existingMetadata,
|
||||
}
|
||||
|
||||
finalChunk, err := completedMultipartChunk(chunk, 4096, &multipartSSES3Info{})
|
||||
if err != nil {
|
||||
t.Fatalf("completedMultipartChunk: %v", err)
|
||||
}
|
||||
if !bytes.Equal(finalChunk.GetSseMetadata(), existingMetadata) {
|
||||
t.Fatal("existing SSE-S3 metadata should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyMultipartSSES3HeadersFromUploadEntry pins the canonical object-level
|
||||
// SSE-S3 attributes onto the completed entry: SeaweedFSSSES3Key (used by
|
||||
// IsSSES3EncryptedInternal and the read-side decryption-key lookup) and
|
||||
// X-Amz-Server-Side-Encryption (used by detectPrimarySSEType for non-chunked
|
||||
// reads). Pre-existing values must not be clobbered.
|
||||
func TestApplyMultipartSSES3HeadersFromUploadEntry(t *testing.T) {
|
||||
initSSES3KeyManagerForTest(t)
|
||||
|
||||
key, err := GenerateSSES3Key()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSSES3Key: %v", err)
|
||||
}
|
||||
keyData, err := SerializeSSES3Metadata(key)
|
||||
if err != nil {
|
||||
t.Fatalf("SerializeSSES3Metadata: %v", err)
|
||||
}
|
||||
|
||||
baseIV := []byte("1234567890abcdef")
|
||||
uploadEntry := &filer_pb.Entry{
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.SeaweedFSSSES3Encryption: []byte(s3_constants.SSEAlgorithmAES256),
|
||||
s3_constants.SeaweedFSSSES3BaseIV: []byte(base64.StdEncoding.EncodeToString(baseIV)),
|
||||
s3_constants.SeaweedFSSSES3KeyData: []byte(base64.StdEncoding.EncodeToString(keyData)),
|
||||
},
|
||||
}
|
||||
sses3Info, err := extractMultipartSSES3Info(uploadEntry)
|
||||
if err != nil {
|
||||
t.Fatalf("extractMultipartSSES3Info: %v", err)
|
||||
}
|
||||
|
||||
t.Run("backfills missing canonical attributes", func(t *testing.T) {
|
||||
finalEntry := &filer_pb.Entry{Extended: map[string][]byte{}}
|
||||
applyMultipartSSES3HeadersFromUploadEntry(finalEntry, sses3Info)
|
||||
if !bytes.Equal(finalEntry.Extended[s3_constants.SeaweedFSSSES3Key], keyData) {
|
||||
t.Fatal("final entry did not receive object-level SSE-S3 key metadata")
|
||||
}
|
||||
if got := string(finalEntry.Extended[s3_constants.AmzServerSideEncryption]); got != s3_constants.SSEAlgorithmAES256 {
|
||||
t.Fatalf("final entry SSE header = %q, want %q", got, s3_constants.SSEAlgorithmAES256)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not clobber existing canonical attributes", func(t *testing.T) {
|
||||
existingKey := []byte("existing-key-bytes")
|
||||
existingHeader := []byte("aws:kms")
|
||||
finalEntry := &filer_pb.Entry{Extended: map[string][]byte{
|
||||
s3_constants.SeaweedFSSSES3Key: existingKey,
|
||||
s3_constants.AmzServerSideEncryption: existingHeader,
|
||||
}}
|
||||
applyMultipartSSES3HeadersFromUploadEntry(finalEntry, sses3Info)
|
||||
if !bytes.Equal(finalEntry.Extended[s3_constants.SeaweedFSSSES3Key], existingKey) {
|
||||
t.Fatal("existing SSE-S3 key was overwritten")
|
||||
}
|
||||
if !bytes.Equal(finalEntry.Extended[s3_constants.AmzServerSideEncryption], existingHeader) {
|
||||
t.Fatal("existing X-Amz-Server-Side-Encryption was overwritten")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil info is a no-op", func(t *testing.T) {
|
||||
finalEntry := &filer_pb.Entry{Extended: map[string][]byte{}}
|
||||
applyMultipartSSES3HeadersFromUploadEntry(finalEntry, nil)
|
||||
if len(finalEntry.Extended) != 0 {
|
||||
t.Fatalf("nil sses3Info should not write any keys, got %d", len(finalEntry.Extended))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCompletedMultipartChunkBackfilledIVDecryptsActualCiphertext is a round
|
||||
// trip across the encryption boundary: it encrypts simulated multipart parts
|
||||
// using the same call path putToFiler uses (CreateSSES3EncryptedReaderWithBaseIV
|
||||
// with partOffset=0, then chunked at fixed boundaries), throws away the chunk
|
||||
// level SSE metadata to simulate the bug from #8908, runs completedMultipartChunk
|
||||
// to backfill it, and decrypts each chunk's slice of ciphertext with the
|
||||
// backfilled IV. The plaintext must come back intact.
|
||||
//
|
||||
// Regression coverage for the gemini review on #9224 which suggested using
|
||||
// (partNumber-1)*PartOffsetMultiplier + chunk.Offset for the backfill IV. That
|
||||
// formula does not match what the encryption side actually does (PartOffsetMultiplier
|
||||
// is defined but not used in the write path), so adopting it would silently
|
||||
// produce backfilled IVs that fail to decrypt the bytes on disk.
|
||||
func TestCompletedMultipartChunkBackfilledIVDecryptsActualCiphertext(t *testing.T) {
|
||||
keyManager := initSSES3KeyManagerForTest(t)
|
||||
|
||||
key, err := GenerateSSES3Key()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSSES3Key: %v", err)
|
||||
}
|
||||
keyData, err := SerializeSSES3Metadata(key)
|
||||
if err != nil {
|
||||
t.Fatalf("SerializeSSES3Metadata: %v", err)
|
||||
}
|
||||
|
||||
baseIV := make([]byte, s3_constants.AESBlockSize)
|
||||
if _, err := rand.Read(baseIV); err != nil {
|
||||
t.Fatalf("rand.Read baseIV: %v", err)
|
||||
}
|
||||
|
||||
uploadEntry := &filer_pb.Entry{
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.SeaweedFSSSES3Encryption: []byte(s3_constants.SSEAlgorithmAES256),
|
||||
s3_constants.SeaweedFSSSES3BaseIV: []byte(base64.StdEncoding.EncodeToString(baseIV)),
|
||||
s3_constants.SeaweedFSSSES3KeyData: []byte(base64.StdEncoding.EncodeToString(keyData)),
|
||||
},
|
||||
}
|
||||
sses3Info, err := extractMultipartSSES3Info(uploadEntry)
|
||||
if err != nil {
|
||||
t.Fatalf("extractMultipartSSES3Info: %v", err)
|
||||
}
|
||||
|
||||
const chunkSize = int64(8 * 1024 * 1024)
|
||||
|
||||
// Two parts: part 1 spans three internal chunks (offsets 0, 8MB, 16MB),
|
||||
// part 2 spans two (offsets 0, 8MB). Both parts are encrypted independently
|
||||
// with partOffset=0, the way putToFiler invokes the encryption path today.
|
||||
parts := []struct {
|
||||
name string
|
||||
plaintext []byte
|
||||
}{
|
||||
{"part1", makeRandomPlaintext(t, int(chunkSize)*3-1234)},
|
||||
{"part2", makeRandomPlaintext(t, int(chunkSize)*2+5678)},
|
||||
}
|
||||
|
||||
for _, p := range parts {
|
||||
t.Run(p.name, func(t *testing.T) {
|
||||
// Encrypt the part stream the same way handleSSES3MultipartEncryption does.
|
||||
encReader, _, err := CreateSSES3EncryptedReaderWithBaseIV(bytes.NewReader(p.plaintext), key, baseIV, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSSES3EncryptedReaderWithBaseIV: %v", err)
|
||||
}
|
||||
ciphertext, err := io.ReadAll(encReader)
|
||||
if err != nil {
|
||||
t.Fatalf("read encrypted stream: %v", err)
|
||||
}
|
||||
if len(ciphertext) != len(p.plaintext) {
|
||||
t.Fatalf("ciphertext length %d != plaintext length %d", len(ciphertext), len(p.plaintext))
|
||||
}
|
||||
|
||||
// Slice the encrypted stream into chunks the same way UploadReaderInChunks
|
||||
// would, and build FileChunks tagged SseType=NONE to simulate the bug.
|
||||
var bugChunks []*filer_pb.FileChunk
|
||||
for off := int64(0); off < int64(len(ciphertext)); off += chunkSize {
|
||||
end := off + chunkSize
|
||||
if end > int64(len(ciphertext)) {
|
||||
end = int64(len(ciphertext))
|
||||
}
|
||||
bugChunks = append(bugChunks, &filer_pb.FileChunk{
|
||||
FileId: "1,deadbeef",
|
||||
Offset: off,
|
||||
Size: uint64(end - off),
|
||||
// SseType deliberately left at default NONE.
|
||||
})
|
||||
}
|
||||
|
||||
// finalOffset doesn't influence the IV calculation, only the chunk's
|
||||
// position in the assembled object. Use a fake non-zero value to
|
||||
// confirm the function does not accidentally use it for the IV.
|
||||
const finalOffset int64 = 1 << 40
|
||||
|
||||
for _, bugChunk := range bugChunks {
|
||||
finalChunk, err := completedMultipartChunk(bugChunk, finalOffset+bugChunk.Offset, sses3Info)
|
||||
if err != nil {
|
||||
t.Fatalf("completedMultipartChunk(offset=%d): %v", bugChunk.Offset, err)
|
||||
}
|
||||
if finalChunk.GetSseType() != filer_pb.SSEType_SSE_S3 {
|
||||
t.Fatalf("chunk@%d not retagged SSE_S3", bugChunk.Offset)
|
||||
}
|
||||
meta, err := DeserializeSSES3Metadata(finalChunk.GetSseMetadata(), keyManager)
|
||||
if err != nil {
|
||||
t.Fatalf("DeserializeSSES3Metadata(chunk@%d): %v", bugChunk.Offset, err)
|
||||
}
|
||||
|
||||
cipherSlice := ciphertext[bugChunk.Offset : bugChunk.Offset+int64(bugChunk.Size)]
|
||||
wantPlain := p.plaintext[bugChunk.Offset : bugChunk.Offset+int64(bugChunk.Size)]
|
||||
|
||||
decReader, err := CreateSSES3DecryptedReader(bytes.NewReader(cipherSlice), key, meta.IV)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSSES3DecryptedReader(chunk@%d): %v", bugChunk.Offset, err)
|
||||
}
|
||||
gotPlain, err := io.ReadAll(decReader)
|
||||
if err != nil {
|
||||
t.Fatalf("read decrypted chunk@%d: %v", bugChunk.Offset, err)
|
||||
}
|
||||
if !bytes.Equal(gotPlain, wantPlain) {
|
||||
t.Fatalf("chunk@%d: backfilled IV produced wrong plaintext (first mismatch byte at %d)",
|
||||
bugChunk.Offset, firstMismatch(gotPlain, wantPlain))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompletedMultipartChunkRejectsPartNumberMultiplierFormula is a guard
|
||||
// against re-introducing the gemini review's suggested formula, which would
|
||||
// produce IVs incompatible with the encryption side. The test simulates what
|
||||
// would happen if the backfill used (partNumber-1)*PartOffsetMultiplier as an
|
||||
// extra offset term: the resulting IV does not decrypt the actual ciphertext.
|
||||
func TestCompletedMultipartChunkRejectsPartNumberMultiplierFormula(t *testing.T) {
|
||||
key, err := GenerateSSES3Key()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSSES3Key: %v", err)
|
||||
}
|
||||
baseIV := make([]byte, s3_constants.AESBlockSize)
|
||||
if _, err := rand.Read(baseIV); err != nil {
|
||||
t.Fatalf("rand.Read baseIV: %v", err)
|
||||
}
|
||||
|
||||
plaintext := makeRandomPlaintext(t, 4096)
|
||||
|
||||
encReader, _, err := CreateSSES3EncryptedReaderWithBaseIV(bytes.NewReader(plaintext), key, baseIV, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSSES3EncryptedReaderWithBaseIV: %v", err)
|
||||
}
|
||||
ciphertext, err := io.ReadAll(encReader)
|
||||
if err != nil {
|
||||
t.Fatalf("read encrypted stream: %v", err)
|
||||
}
|
||||
|
||||
// "partNumber=2" with PartOffsetMultiplier added would shift the IV by 8GB.
|
||||
wrongIV, _ := calculateIVWithOffset(baseIV, s3_constants.PartOffsetMultiplier+0)
|
||||
|
||||
decReader, err := CreateSSES3DecryptedReader(bytes.NewReader(ciphertext), key, wrongIV)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSSES3DecryptedReader: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(decReader)
|
||||
if err != nil {
|
||||
t.Fatalf("read decrypted: %v", err)
|
||||
}
|
||||
if bytes.Equal(got, plaintext) {
|
||||
t.Fatal("partNumber-multiplier IV decrypted plaintext correctly; the backfill formula must NOT include this term")
|
||||
}
|
||||
}
|
||||
|
||||
func makeRandomPlaintext(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
t.Fatalf("rand.Read: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func firstMismatch(a, b []byte) int {
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if a[i] != b[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
270
weed/s3api/filer_util.go
Normal file
270
weed/s3api/filer_util.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func (s3a *S3ApiServer) mkdir(parentDirectoryPath string, dirName string, fn func(entry *filer_pb.Entry)) error {
|
||||
|
||||
return filer_pb.Mkdir(context.Background(), s3a, parentDirectoryPath, dirName, fn)
|
||||
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) mkFile(parentDirectoryPath string, fileName string, chunks []*filer_pb.FileChunk, fn func(entry *filer_pb.Entry)) error {
|
||||
|
||||
err := filer_pb.MkFile(context.Background(), s3a, parentDirectoryPath, fileName, chunks, fn)
|
||||
if errors.Is(err, filer_pb.ErrExistingIsDirectory) && !isReservedDirectoryName(fileName) {
|
||||
// Other keys are nested under this one, so the object goes onto the directory
|
||||
// they live in - the same place a PutObject of this key writes it.
|
||||
err = filer_pb.MkFile(context.Background(), s3a, parentDirectoryPath, fileName, chunks, func(entry *filer_pb.Entry) {
|
||||
if fn != nil {
|
||||
fn(entry)
|
||||
}
|
||||
entry.MarkPrefixObject()
|
||||
})
|
||||
}
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) list(parentDirectoryPath, prefix, startFrom string, inclusive bool, limit uint32) (entries []*filer_pb.Entry, isLast bool, err error) {
|
||||
|
||||
return listWithRetry(parentDirectoryPath, func() (entries []*filer_pb.Entry, isLast bool, err error) {
|
||||
err = filer_pb.List(context.Background(), s3a, parentDirectoryPath, prefix, func(entry *filer_pb.Entry, isLastEntry bool) error {
|
||||
entries = append(entries, entry)
|
||||
if isLastEntry {
|
||||
isLast = true
|
||||
}
|
||||
return nil
|
||||
}, startFrom, inclusive, limit)
|
||||
|
||||
if len(entries) == 0 {
|
||||
isLast = true
|
||||
}
|
||||
|
||||
return
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// A listing has no side effects and collects into a fresh slice per attempt, so
|
||||
// a replay can neither duplicate nor drop entries; the bound caps a filer that
|
||||
// is genuinely down at two extra attempts and 300ms of added wait.
|
||||
const (
|
||||
listRetryAttempts = 3
|
||||
listRetryInitialBackoff = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// isRetryableListError defers to util.IsTransientError, which reads the status
|
||||
// the filer sent rather than the message DoSeaweedListWithSnapshot builds around
|
||||
// it out of the bucket and prefix the client chose. Not-found is authoritative
|
||||
// and must reach the caller unchanged.
|
||||
func isRetryableListError(err error) bool {
|
||||
return err != nil && !isFilerNotFound(err) && util.IsTransientError(err)
|
||||
}
|
||||
|
||||
// listWithRetry replays doList while the filer answers with a transient error.
|
||||
// Both failure points, the ListEntries call itself and the stream.Recv that
|
||||
// follows it, surface as a plain error out of filer_pb.List, so a single retry
|
||||
// point above it covers both.
|
||||
func listWithRetry(parentDirectoryPath string, doList func() (entries []*filer_pb.Entry, isLast bool, err error)) (entries []*filer_pb.Entry, isLast bool, err error) {
|
||||
|
||||
backoff := listRetryInitialBackoff
|
||||
for attempt := 1; ; attempt++ {
|
||||
entries, isLast, err = doList()
|
||||
if err == nil || attempt >= listRetryAttempts || !isRetryableListError(err) {
|
||||
return entries, isLast, err
|
||||
}
|
||||
glog.V(1).Infof("list %s attempt %d/%d hit a transient error, retrying in %v: %v", parentDirectoryPath, attempt, listRetryAttempts, backoff, err)
|
||||
time.Sleep(backoff)
|
||||
backoff *= 2
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// A delete is idempotent at the filer, which answers an entry that is already
|
||||
// gone with an empty resp.Error, so a reply the transport dropped can be
|
||||
// reissued instead of surfaced: as a 500 on the bucket delete, or as a per-key
|
||||
// InternalError inside the 200 of a multi-object delete, which no SDK retries.
|
||||
// Each attempt re-enters WithFilerClient, so it walks the failover list again
|
||||
// on a connection the failed one had invalidated.
|
||||
func (s3a *S3ApiServer) rm(ctx context.Context, parentDirectoryPath, entryName string, isDeleteData, isRecursive bool) error {
|
||||
|
||||
return retryFilerOp(ctx, "rm "+parentDirectoryPath+"/"+entryName, func() error {
|
||||
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
|
||||
return doDeleteEntry(ctx, client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) rmObject(ctx context.Context, parentDirectoryPath, entryName string, isDeleteData, isRecursive bool) error {
|
||||
|
||||
return retryFilerOp(ctx, "rmObject "+parentDirectoryPath+"/"+entryName, func() error {
|
||||
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
|
||||
return deleteObjectEntry(ctx, client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func deleteObjectEntry(ctx context.Context, client filer_pb.SeaweedFilerClient, parentDirectoryPath, entryName string, isDeleteData, isRecursive bool) error {
|
||||
err := doDeleteEntry(ctx, client, parentDirectoryPath, entryName, isDeleteData, isRecursive)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, filer.ErrNonEmptyFolder) {
|
||||
return err
|
||||
}
|
||||
|
||||
return demoteDirectoryMarkerToImplicitDirectory(ctx, client, parentDirectoryPath, entryName)
|
||||
}
|
||||
|
||||
func doDeleteEntry(ctx context.Context, client filer_pb.SeaweedFilerClient, parentDirectoryPath string, entryName string, isDeleteData bool, isRecursive bool) error {
|
||||
request := &filer_pb.DeleteEntryRequest{
|
||||
Directory: parentDirectoryPath,
|
||||
Name: entryName,
|
||||
IsDeleteData: isDeleteData,
|
||||
IsRecursive: isRecursive,
|
||||
IgnoreRecursiveError: true,
|
||||
}
|
||||
|
||||
glog.V(1).Infof("delete entry %v/%v: %v", parentDirectoryPath, entryName, request)
|
||||
if resp, err := client.DeleteEntry(ctx, request); err != nil {
|
||||
glog.V(1).Infof("delete entry %v: %v", request, err)
|
||||
return fmt.Errorf("delete entry %s/%s: %w", parentDirectoryPath, entryName, err)
|
||||
} else {
|
||||
if resp.Error != "" {
|
||||
// the path wrapped in here is the client's, so classify the filer's
|
||||
// text now, while it still stands alone
|
||||
return fmt.Errorf("delete entry %s/%s: %w", parentDirectoryPath, entryName, filer.DeleteEntryError(resp.Error))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func demoteDirectoryMarkerToImplicitDirectory(ctx context.Context, client filer_pb.SeaweedFilerClient, parentDirectoryPath, entryName string) error {
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: parentDirectoryPath,
|
||||
Name: entryName,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("lookup entry %s/%s: %w", parentDirectoryPath, entryName, err)
|
||||
}
|
||||
if resp.Entry == nil || !resp.Entry.IsDirectory {
|
||||
return nil
|
||||
}
|
||||
if !resp.Entry.IsDirectoryKeyObject() {
|
||||
return nil
|
||||
}
|
||||
|
||||
clearDirectoryMarkerMetadata(resp.Entry)
|
||||
|
||||
if err := filer_pb.UpdateEntry(ctx, client, &filer_pb.UpdateEntryRequest{
|
||||
Directory: parentDirectoryPath,
|
||||
Entry: resp.Entry,
|
||||
}); err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) || status.Code(err) == codes.NotFound {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("update entry %s/%s: %w", parentDirectoryPath, entryName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearDirectoryMarkerMetadata(entry *filer_pb.Entry) {
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
if entry.Attributes == nil {
|
||||
entry.Attributes = &filer_pb.FuseAttributes{}
|
||||
}
|
||||
|
||||
entry.Attributes.Mime = ""
|
||||
entry.Attributes.Md5 = nil
|
||||
entry.Attributes.FileSize = 0
|
||||
entry.Content = nil
|
||||
entry.Chunks = nil
|
||||
|
||||
if len(entry.Extended) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make(map[string][]byte)
|
||||
for k, v := range entry.Extended {
|
||||
lowerKey := strings.ToLower(k)
|
||||
if lowerKey == s3_constants.SeaweedFSPrefixObject {
|
||||
// The path is a plain directory again, not a key of its own.
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(lowerKey, "xattr-") || strings.HasPrefix(lowerKey, s3_constants.SeaweedFSInternalPrefix) {
|
||||
filtered[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
entry.Extended = nil
|
||||
return
|
||||
}
|
||||
entry.Extended = filtered
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) exists(parentDirectoryPath string, entryName string, isDirectory bool) (exists bool, err error) {
|
||||
|
||||
return filer_pb.Exists(context.Background(), s3a, parentDirectoryPath, entryName, isDirectory)
|
||||
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) getEntry(parentDirectoryPath, entryName string) (entry *filer_pb.Entry, err error) {
|
||||
fullPath := util.NewFullPath(parentDirectoryPath, entryName)
|
||||
entry, _, _, err = filer_pb.GetEntry(context.Background(), s3a, fullPath)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) updateEntry(parentDirectoryPath string, newEntry *filer_pb.Entry) error {
|
||||
updateEntryRequest := &filer_pb.UpdateEntryRequest{
|
||||
Directory: parentDirectoryPath,
|
||||
Entry: newEntry,
|
||||
}
|
||||
|
||||
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
err := filer_pb.UpdateEntry(context.Background(), client, updateEntryRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) getCollectionName(bucket string) string {
|
||||
if s3a.option.FilerGroup != "" {
|
||||
return fmt.Sprintf("%s_%s", s3a.option.FilerGroup, bucket)
|
||||
}
|
||||
return bucket
|
||||
}
|
||||
|
||||
func objectKey(key *string) *string {
|
||||
if strings.HasPrefix(*key, "/") {
|
||||
t := (*key)[1:]
|
||||
return &t
|
||||
}
|
||||
return key
|
||||
}
|
||||
338
weed/s3api/filer_util_delete_test.go
Normal file
338
weed/s3api/filer_util_delete_test.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
grpc "google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type deleteObjectEntryTestClient struct {
|
||||
filer_pb.SeaweedFilerClient
|
||||
|
||||
deleteResp *filer_pb.DeleteEntryResponse
|
||||
deleteErr error
|
||||
lookupResp *filer_pb.LookupDirectoryEntryResponse
|
||||
lookupErr error
|
||||
updateErr error
|
||||
|
||||
// deleteCallErrs scripts the RPC itself per attempt, deleteRespErrs the
|
||||
// filer's own message: the two ways a delete fails, told apart because
|
||||
// only the first is a transport that dropped a reply.
|
||||
deleteCallErrs []error
|
||||
deleteRespErrs []string
|
||||
|
||||
deleteAttempts int
|
||||
deleteReqs []*filer_pb.DeleteEntryRequest
|
||||
deleteReq *filer_pb.DeleteEntryRequest
|
||||
lookupReq *filer_pb.LookupDirectoryEntryRequest
|
||||
updateReq *filer_pb.UpdateEntryRequest
|
||||
}
|
||||
|
||||
func (c *deleteObjectEntryTestClient) DeleteEntry(ctx context.Context, req *filer_pb.DeleteEntryRequest, _ ...grpc.CallOption) (*filer_pb.DeleteEntryResponse, error) {
|
||||
attempt := c.deleteAttempts
|
||||
c.deleteAttempts++
|
||||
c.deleteReq = req
|
||||
c.deleteReqs = append(c.deleteReqs, req)
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, status.FromContextError(err).Err()
|
||||
}
|
||||
if attempt < len(c.deleteCallErrs) && c.deleteCallErrs[attempt] != nil {
|
||||
return nil, c.deleteCallErrs[attempt]
|
||||
}
|
||||
if attempt < len(c.deleteRespErrs) && c.deleteRespErrs[attempt] != "" {
|
||||
return &filer_pb.DeleteEntryResponse{Error: c.deleteRespErrs[attempt]}, nil
|
||||
}
|
||||
if c.deleteResp == nil {
|
||||
return &filer_pb.DeleteEntryResponse{}, c.deleteErr
|
||||
}
|
||||
return c.deleteResp, c.deleteErr
|
||||
}
|
||||
|
||||
func (c *deleteObjectEntryTestClient) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
c.lookupReq = req
|
||||
if c.lookupResp == nil {
|
||||
return &filer_pb.LookupDirectoryEntryResponse{}, c.lookupErr
|
||||
}
|
||||
return c.lookupResp, c.lookupErr
|
||||
}
|
||||
|
||||
func (c *deleteObjectEntryTestClient) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest, _ ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) {
|
||||
c.updateReq = req
|
||||
return &filer_pb.UpdateEntryResponse{}, c.updateErr
|
||||
}
|
||||
|
||||
func TestDeleteObjectEntryDemotesNonEmptyDirectoryMarker(t *testing.T) {
|
||||
client := &deleteObjectEntryTestClient{
|
||||
deleteResp: &filer_pb.DeleteEntryResponse{
|
||||
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
|
||||
},
|
||||
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "photos",
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mime: "application/octet-stream",
|
||||
Md5: []byte{1, 2, 3, 4},
|
||||
FileSize: 4,
|
||||
},
|
||||
Content: []byte("test"),
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.ExtETagKey: []byte("etag"),
|
||||
s3_constants.ExtAmzOwnerKey: []byte("owner"),
|
||||
s3_constants.AmzUserMetaPrefix + "Color": []byte("blue"),
|
||||
s3_constants.AmzObjectTaggingPrefix + "k": []byte("v"),
|
||||
"xattr-keep": []byte("keep-me"),
|
||||
"x-seaweedfs-internal": []byte("keep-me-too"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := deleteObjectEntry(context.Background(), client, "/buckets/test", "photos", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client.lookupReq)
|
||||
require.NotNil(t, client.updateReq)
|
||||
|
||||
updated := client.updateReq.Entry
|
||||
require.NotNil(t, updated)
|
||||
assert.False(t, updated.IsDirectoryKeyObject())
|
||||
assert.Equal(t, "", updated.Attributes.Mime)
|
||||
assert.Empty(t, updated.Attributes.Md5)
|
||||
assert.Zero(t, updated.Attributes.FileSize)
|
||||
assert.Nil(t, updated.Content)
|
||||
assert.Nil(t, updated.Chunks)
|
||||
assert.Equal(t, map[string][]byte{
|
||||
"xattr-keep": []byte("keep-me"),
|
||||
"x-seaweedfs-internal": []byte("keep-me-too"),
|
||||
}, updated.Extended)
|
||||
}
|
||||
|
||||
// A prefix object is demoted the same way, and the mark has to go with the data:
|
||||
// the path is a plain directory again, and no longer a key of its own.
|
||||
func TestDeleteObjectEntryDemotesPrefixObject(t *testing.T) {
|
||||
client := &deleteObjectEntryTestClient{
|
||||
deleteResp: &filer_pb.DeleteEntryResponse{
|
||||
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
|
||||
},
|
||||
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "photos",
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{},
|
||||
Extended: map[string][]byte{
|
||||
s3_constants.SeaweedFSPrefixObject: []byte("true"),
|
||||
s3_constants.ExtETagKey: []byte("etag"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, deleteObjectEntry(context.Background(), client, "/buckets/test", "photos", true, false))
|
||||
require.NotNil(t, client.updateReq)
|
||||
|
||||
updated := client.updateReq.Entry
|
||||
require.NotNil(t, updated)
|
||||
assert.False(t, updated.IsPrefixObject())
|
||||
assert.False(t, updated.IsDirectoryKeyObject())
|
||||
assert.Empty(t, updated.Extended)
|
||||
}
|
||||
|
||||
func TestDeleteObjectEntryTreatsImplicitDirectoryAsSuccessfulNoop(t *testing.T) {
|
||||
client := &deleteObjectEntryTestClient{
|
||||
deleteResp: &filer_pb.DeleteEntryResponse{
|
||||
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
|
||||
},
|
||||
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "photos",
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := deleteObjectEntry(context.Background(), client, "/buckets/test", "photos", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client.lookupReq)
|
||||
assert.Nil(t, client.updateReq)
|
||||
}
|
||||
|
||||
func TestDeleteObjectEntryIgnoresConcurrentUpdateNotFound(t *testing.T) {
|
||||
client := &deleteObjectEntryTestClient{
|
||||
deleteResp: &filer_pb.DeleteEntryResponse{
|
||||
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/test/photos",
|
||||
},
|
||||
lookupResp: &filer_pb.LookupDirectoryEntryResponse{
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: "photos",
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mime: "application/octet-stream",
|
||||
},
|
||||
},
|
||||
},
|
||||
updateErr: status.Error(codes.NotFound, "already removed"),
|
||||
}
|
||||
|
||||
err := deleteObjectEntry(context.Background(), client, "/buckets/test", "photos", true, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client.lookupReq)
|
||||
require.NotNil(t, client.updateReq)
|
||||
}
|
||||
|
||||
// The key is the client's and the filer echoes it in the message it sends back,
|
||||
// so a key named after the marker must not turn a real failure into the demote
|
||||
// no-op, which would answer a failed delete with a 204.
|
||||
func TestDeleteObjectEntryIgnoresMarkerSpoofedByKey(t *testing.T) {
|
||||
name := filer.MsgFailDelNonEmptyFolder
|
||||
client := &deleteObjectEntryTestClient{
|
||||
deleteResp: &filer_pb.DeleteEntryResponse{
|
||||
Error: "delete file /buckets/test/" + name + ": filer store delete: disk full",
|
||||
},
|
||||
}
|
||||
|
||||
err := deleteObjectEntry(context.Background(), client, "/buckets/test", name, true, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "disk full")
|
||||
assert.Nil(t, client.lookupReq)
|
||||
assert.Nil(t, client.updateReq)
|
||||
}
|
||||
|
||||
func TestDeleteObjectEntryPropagatesNonDirectoryDeleteErrors(t *testing.T) {
|
||||
client := &deleteObjectEntryTestClient{
|
||||
deleteErr: errors.New("boom"),
|
||||
}
|
||||
|
||||
err := deleteObjectEntry(context.Background(), client, "/buckets/test", "photos", true, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "boom")
|
||||
assert.Nil(t, client.lookupReq)
|
||||
assert.Nil(t, client.updateReq)
|
||||
}
|
||||
|
||||
// A delete is idempotent at the filer, so a reply the transport dropped is
|
||||
// reissued rather than surfaced. Issue #7204 saw the surfaced version as a 500
|
||||
// on the bucket delete, which boto3 resent and then answered NoSuchBucket;
|
||||
// issue #7224 saw it as a per-key InternalError inside a 200, which no SDK
|
||||
// retries, so the object silently stayed.
|
||||
func TestDeleteUnversionedObjectReplaysADroppedReply(t *testing.T) {
|
||||
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
|
||||
client := &deleteObjectEntryTestClient{
|
||||
deleteCallErrs: []error{status.Error(codes.Unavailable, "transport is closing"), nil},
|
||||
}
|
||||
|
||||
err := s3a.deleteUnversionedObjectWithClient(testRetryCtx(10*time.Millisecond), client, "mybucket", "a/b/c.txt", false)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, client.deleteAttempts)
|
||||
// the replay must ask for the same delete, not a differently shaped one
|
||||
require.Len(t, client.deleteReqs, 2)
|
||||
assert.Equal(t, client.deleteReqs[0].String(), client.deleteReqs[1].String())
|
||||
}
|
||||
|
||||
// The filer formats the deleted path, and for a recursive delete the child it
|
||||
// stopped on, into the message it sends back, so both are the client's text.
|
||||
// Nothing here reads that text, and this pins it: the same failure under a key
|
||||
// named after a transport condition is replayed exactly as often as under a
|
||||
// plain one.
|
||||
func TestDeleteUnversionedObjectReplayIgnoresTheObjectName(t *testing.T) {
|
||||
attemptsFor := func(key string) int {
|
||||
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
|
||||
client := &deleteObjectEntryTestClient{
|
||||
deleteRespErrs: []string{"delete file /buckets/b/" + key + ": permission denied", ""},
|
||||
}
|
||||
require.NoError(t, s3a.deleteUnversionedObjectWithClient(testRetryCtx(10*time.Millisecond), client, "b", key, false))
|
||||
return client.deleteAttempts
|
||||
}
|
||||
|
||||
plain := attemptsFor("plain.log")
|
||||
for _, key := range []string{"transport.log", "unavailable.txt", "slowdown.csv", filer_pb.ErrNotFound.Error()} {
|
||||
assert.Equal(t, plain, attemptsFor(key), "key %q must not steer the replay", key)
|
||||
}
|
||||
}
|
||||
|
||||
// A folder the filer refused because it still has children is an answer about
|
||||
// the tree, not a hiccup: it is handed straight to the demote path instead of
|
||||
// being replayed until the allowance runs out.
|
||||
func TestDeleteUnversionedObjectDoesNotReplayANonEmptyFolder(t *testing.T) {
|
||||
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
|
||||
client := &deleteObjectEntryTestClient{
|
||||
deleteResp: &filer_pb.DeleteEntryResponse{
|
||||
Error: filer.MsgFailDelNonEmptyFolder + ": /buckets/b/photos",
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, s3a.deleteUnversionedObjectWithClient(context.Background(), client, "b", "photos", false))
|
||||
assert.Equal(t, 1, client.deleteAttempts)
|
||||
assert.NotNil(t, client.lookupReq, "the demote must still run")
|
||||
}
|
||||
|
||||
// A caller that has gone away is not worth replaying for.
|
||||
func TestDeleteUnversionedObjectDoesNotReplayForACancelledCaller(t *testing.T) {
|
||||
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
|
||||
client := &deleteObjectEntryTestClient{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := s3a.deleteUnversionedObjectWithClient(ctx, client, "b", "k", false)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, client.deleteAttempts)
|
||||
}
|
||||
|
||||
// The client picks how many keys a multi-object delete carries, so the backoff
|
||||
// is drawn from one allowance held by the request rather than one per key.
|
||||
func TestDeleteUnversionedObjectDrawsFromTheRequestAllowance(t *testing.T) {
|
||||
s3a := &S3ApiServer{option: &S3ApiServerOption{BucketsPath: "/buckets"}}
|
||||
ctx := testRetryCtx(20 * time.Millisecond)
|
||||
|
||||
start := time.Now()
|
||||
var attempts int
|
||||
for i := 0; i < 50; i++ {
|
||||
client := &deleteObjectEntryTestClient{deleteErr: status.Error(codes.Unavailable, "transport is closing")}
|
||||
require.Error(t, s3a.deleteUnversionedObjectWithClient(ctx, client, "b", fmt.Sprintf("k%d", i), false))
|
||||
attempts += client.deleteAttempts
|
||||
}
|
||||
|
||||
assert.Less(t, time.Since(start), time.Second, "one allowance, not one per key")
|
||||
assert.Greater(t, attempts, 50, "the allowance must still buy a replay")
|
||||
}
|
||||
|
||||
// testRetryCtx caps the backoff so a test exercises the replay without waiting
|
||||
// out the real per-request allowance.
|
||||
func testRetryCtx(allowance time.Duration) context.Context {
|
||||
return withFilerRetryBudget(context.Background(), allowance)
|
||||
}
|
||||
|
||||
func TestIsRetryableFilerErr(t *testing.T) {
|
||||
assert.False(t, isRetryableFilerErr(nil))
|
||||
assert.True(t, isRetryableFilerErr(status.Error(codes.Unavailable, "transport is closing")))
|
||||
assert.True(t, isRetryableFilerErr(fmt.Errorf("delete entry /b/k: %w", status.Error(codes.Unavailable, "x"))))
|
||||
// the filer's own message carries no status, and a store error may well clear
|
||||
assert.True(t, isRetryableFilerErr(filer.DeleteEntryError("delete file /b/k: filer store delete: disk full")))
|
||||
|
||||
assert.False(t, isRetryableFilerErr(filer.DeleteEntryError(filer.MsgFailDelNonEmptyFolder+": /b/photos")))
|
||||
assert.False(t, isRetryableFilerErr(filer_pb.ErrNotFound))
|
||||
assert.False(t, isRetryableFilerErr(status.Error(codes.NotFound, "gone")))
|
||||
assert.False(t, isRetryableFilerErr(context.Canceled))
|
||||
assert.False(t, isRetryableFilerErr(status.Error(codes.Canceled, "context canceled")))
|
||||
assert.False(t, isRetryableFilerErr(status.Error(codes.DeadlineExceeded, "context deadline exceeded")))
|
||||
|
||||
// the message is never read, so the same code classifies the same way
|
||||
// whatever the client named the key
|
||||
assert.Equal(t,
|
||||
isRetryableFilerErr(status.Error(codes.PermissionDenied, "denied for /b/plain.log")),
|
||||
isRetryableFilerErr(status.Error(codes.PermissionDenied, "denied for /b/transport.log")))
|
||||
}
|
||||
297
weed/s3api/filer_util_list_retry_test.go
Normal file
297
weed/s3api/filer_util_list_retry_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
grpc "google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// listRetryStream replays a scripted sequence of entries and then, optionally,
|
||||
// fails. failAfter < 0 means the stream runs to EOF.
|
||||
type listRetryStream struct {
|
||||
entries []*filer_pb.Entry
|
||||
index int
|
||||
failAfter int
|
||||
failErr error
|
||||
}
|
||||
|
||||
func (s *listRetryStream) Recv() (*filer_pb.ListEntriesResponse, error) {
|
||||
if s.failErr != nil && s.failAfter >= 0 && s.index >= s.failAfter {
|
||||
return nil, s.failErr
|
||||
}
|
||||
if s.index >= len(s.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
entry := s.entries[s.index]
|
||||
s.index++
|
||||
return &filer_pb.ListEntriesResponse{Entry: entry}, nil
|
||||
}
|
||||
|
||||
func (s *listRetryStream) Header() (metadata.MD, error) { return metadata.MD{}, nil }
|
||||
func (s *listRetryStream) Trailer() metadata.MD { return metadata.MD{} }
|
||||
func (s *listRetryStream) CloseSend() error { return nil }
|
||||
func (s *listRetryStream) Context() context.Context { return context.Background() }
|
||||
func (s *listRetryStream) SendMsg(any) error { return nil }
|
||||
func (s *listRetryStream) RecvMsg(any) error { return nil }
|
||||
|
||||
// listRetryClient is a filer whose ListEntries fails in a scripted way for the
|
||||
// first attempts and then behaves. callErrs is consumed one entry per attempt:
|
||||
// a non-nil entry fails the ListEntries call itself (issue #7221), a nil entry
|
||||
// lets the call through so recvErrs decides whether the stream breaks partway
|
||||
// (issue #7235).
|
||||
type listRetryClient struct {
|
||||
filer_pb.SeaweedFilerClient
|
||||
|
||||
entries []*filer_pb.Entry
|
||||
callErrs []error
|
||||
recvErrs []error
|
||||
recvAfter []int
|
||||
|
||||
attempts int
|
||||
}
|
||||
|
||||
func (c *listRetryClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) {
|
||||
attempt := c.attempts
|
||||
c.attempts++
|
||||
|
||||
if attempt < len(c.callErrs) && c.callErrs[attempt] != nil {
|
||||
return nil, c.callErrs[attempt]
|
||||
}
|
||||
|
||||
stream := &listRetryStream{entries: c.entries, failAfter: -1}
|
||||
if attempt < len(c.recvErrs) && c.recvErrs[attempt] != nil {
|
||||
stream.failErr = c.recvErrs[attempt]
|
||||
stream.failAfter = c.recvAfter[attempt]
|
||||
}
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
// listRetryAccessor hands the scripted client to filer_pb.List without any
|
||||
// wrapping of its own, so the test sees the error exactly as it leaves
|
||||
// DoSeaweedListWithSnapshot.
|
||||
type listRetryAccessor struct {
|
||||
client filer_pb.SeaweedFilerClient
|
||||
}
|
||||
|
||||
func (a *listRetryAccessor) WithFilerClient(_ bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(a.client)
|
||||
}
|
||||
|
||||
func (a *listRetryAccessor) AdjustedUrl(*filer_pb.Location) string { return "" }
|
||||
func (a *listRetryAccessor) GetDataCenter() string { return "" }
|
||||
|
||||
// listOnce mirrors (*S3ApiServer).list so the test drives the real
|
||||
// filer_pb.List and DoSeaweedListWithSnapshot code path, including the fresh
|
||||
// accumulator that makes a replay safe.
|
||||
func listOnce(client filer_pb.FilerClient, parentDirectoryPath string) (entries []*filer_pb.Entry, isLast bool, err error) {
|
||||
err = filer_pb.List(context.Background(), client, parentDirectoryPath, "", func(entry *filer_pb.Entry, isLastEntry bool) error {
|
||||
entries = append(entries, entry)
|
||||
if isLastEntry {
|
||||
isLast = true
|
||||
}
|
||||
return nil
|
||||
}, "", false, 100)
|
||||
|
||||
if len(entries) == 0 {
|
||||
isLast = true
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func testUploadEntries(names ...string) []*filer_pb.Entry {
|
||||
entries := make([]*filer_pb.Entry, 0, len(names))
|
||||
for _, name := range names {
|
||||
entries = append(entries, &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func entryNames(entries []*filer_pb.Entry) []string {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Issue #7221: a transient failure of the ListEntries call itself.
|
||||
func TestListWithRetryRecoversFromTransientListEntriesCall(t *testing.T) {
|
||||
client := &listRetryClient{
|
||||
entries: testUploadEntries("upload-1", "upload-2", "upload-3"),
|
||||
callErrs: []error{
|
||||
status.Error(codes.Unavailable, "filer is restarting"),
|
||||
status.Error(codes.Unavailable, "filer is restarting"),
|
||||
nil,
|
||||
},
|
||||
}
|
||||
accessor := &listRetryAccessor{client: client}
|
||||
|
||||
entries, _, err := listWithRetry("/buckets/b/.uploads", func() ([]*filer_pb.Entry, bool, error) {
|
||||
return listOnce(accessor, "/buckets/b/.uploads")
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"upload-1", "upload-2", "upload-3"}, entryNames(entries))
|
||||
assert.Equal(t, listRetryAttempts, client.attempts, "should have used every allowed attempt")
|
||||
}
|
||||
|
||||
// Issue #7235: a transient failure on stream.Recv part way through the listing.
|
||||
// The replay must return the full listing exactly once, with no entry from the
|
||||
// aborted attempt carried over.
|
||||
func TestListWithRetryRecoversFromTransientStreamRecv(t *testing.T) {
|
||||
client := &listRetryClient{
|
||||
entries: testUploadEntries("upload-1", "upload-2", "upload-3"),
|
||||
// break after two successful receives, so the first attempt has already
|
||||
// handed "upload-1" to the callback when the stream dies
|
||||
recvErrs: []error{status.Error(codes.Unavailable, "transport is closing"), nil},
|
||||
recvAfter: []int{2, 0},
|
||||
}
|
||||
accessor := &listRetryAccessor{client: client}
|
||||
|
||||
entries, _, err := listWithRetry("/buckets/b/.uploads", func() ([]*filer_pb.Entry, bool, error) {
|
||||
return listOnce(accessor, "/buckets/b/.uploads")
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"upload-1", "upload-2", "upload-3"}, entryNames(entries), "replay must not duplicate the entries of the aborted attempt")
|
||||
assert.Equal(t, 2, client.attempts)
|
||||
}
|
||||
|
||||
// The retry is bounded: a filer that stays down still fails the request, and
|
||||
// the transient error reaches the caller.
|
||||
func TestListWithRetryGivesUpAfterBoundedAttempts(t *testing.T) {
|
||||
client := &listRetryClient{
|
||||
entries: testUploadEntries("upload-1"),
|
||||
callErrs: []error{
|
||||
status.Error(codes.Unavailable, "no filer available"),
|
||||
status.Error(codes.Unavailable, "no filer available"),
|
||||
status.Error(codes.Unavailable, "no filer available"),
|
||||
nil, // would succeed on a fourth attempt, which must never happen
|
||||
},
|
||||
}
|
||||
accessor := &listRetryAccessor{client: client}
|
||||
|
||||
_, _, err := listWithRetry("/buckets/b/.uploads", func() ([]*filer_pb.Entry, bool, error) {
|
||||
return listOnce(accessor, "/buckets/b/.uploads")
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Unavailable")
|
||||
assert.Equal(t, listRetryAttempts, client.attempts)
|
||||
}
|
||||
|
||||
// A non-transient error is a real failure and must propagate on the first
|
||||
// attempt rather than being retried away.
|
||||
func TestListWithRetryPropagatesNonTransientError(t *testing.T) {
|
||||
for name, listErr := range map[string]error{
|
||||
"permission denied": status.Error(codes.PermissionDenied, "not allowed"),
|
||||
"invalid argument": status.Error(codes.InvalidArgument, "bad prefix"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
client := &listRetryClient{
|
||||
entries: testUploadEntries("upload-1"),
|
||||
callErrs: []error{listErr, nil},
|
||||
}
|
||||
accessor := &listRetryAccessor{client: client}
|
||||
|
||||
_, _, err := listWithRetry("/buckets/b/.uploads", func() ([]*filer_pb.Entry, bool, error) {
|
||||
return listOnce(accessor, "/buckets/b/.uploads")
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, client.attempts, "a non-transient error must not be retried")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Not-found is authoritative: it must not be retried, and it must still read as
|
||||
// not-found afterwards so listMultipartUploads keeps answering with an empty
|
||||
// list instead of a 500.
|
||||
func TestListWithRetryPropagatesNotFound(t *testing.T) {
|
||||
client := &listRetryClient{
|
||||
entries: testUploadEntries("upload-1"),
|
||||
callErrs: []error{status.Error(codes.NotFound, "filer: no entry is found in filer store"), nil},
|
||||
}
|
||||
accessor := &listRetryAccessor{client: client}
|
||||
|
||||
_, _, err := listWithRetry("/buckets/b/.uploads", func() ([]*filer_pb.Entry, bool, error) {
|
||||
return listOnce(accessor, "/buckets/b/.uploads")
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, client.attempts, "not-found is authoritative and must not be retried")
|
||||
assert.True(t, isFilerNotFound(err), "not-found must survive the retry wrapper")
|
||||
}
|
||||
|
||||
func TestIsRetryableListError(t *testing.T) {
|
||||
assert.False(t, isRetryableListError(nil))
|
||||
assert.True(t, isRetryableListError(status.Error(codes.Unavailable, "connection refused")))
|
||||
assert.True(t, isRetryableListError(status.Error(codes.ResourceExhausted, "too many requests")))
|
||||
// the ListEntries path loses the gRPC status to a %v wrap, so classification
|
||||
// has to survive on the message alone
|
||||
assert.True(t, isRetryableListError(fmt.Errorf("list /buckets/b/.uploads: %v", status.Error(codes.Unavailable, "filer is restarting"))))
|
||||
// and this is the shape the S3 gateway actually sees once the failover in
|
||||
// (*S3ApiServer).WithFilerClient has exhausted every filer
|
||||
assert.True(t, isRetryableListError(fmt.Errorf("all filers failed, last error: %w",
|
||||
fmt.Errorf("list /buckets/b/.uploads: %v", status.Error(codes.Unavailable, "filer is restarting")))))
|
||||
assert.False(t, isRetryableListError(status.Error(codes.NotFound, "filer: no entry is found in filer store")))
|
||||
assert.False(t, isRetryableListError(status.Error(codes.PermissionDenied, "not allowed")))
|
||||
assert.False(t, isRetryableListError(context.Canceled))
|
||||
}
|
||||
|
||||
// The bucket and the prefix are the client's to choose and they land inside the
|
||||
// message DoSeaweedListWithSnapshot builds, so they must not decide whether the
|
||||
// failure is retried.
|
||||
func TestListWithRetryIgnoresUserControlledPath(t *testing.T) {
|
||||
for name, listErr := range map[string]error{
|
||||
"permission denied": status.Error(codes.PermissionDenied, "not allowed"),
|
||||
"invalid argument": status.Error(codes.InvalidArgument, "bad prefix"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
client := &listRetryClient{
|
||||
entries: testUploadEntries("upload-1"),
|
||||
callErrs: []error{listErr, nil},
|
||||
}
|
||||
accessor := &listRetryAccessor{client: client}
|
||||
|
||||
_, _, err := listWithRetry("/buckets/transport/unavailable", func() ([]*filer_pb.Entry, bool, error) {
|
||||
return listOnce(accessor, "/buckets/transport/unavailable")
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, client.attempts, "a bucket name must not make a non-transient error look transient")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The inverse: a prefix carrying the not-found sentence must not suppress a
|
||||
// retry the filer actually asked for.
|
||||
func TestListWithRetryIgnoresSpoofedNotFoundPath(t *testing.T) {
|
||||
client := &listRetryClient{
|
||||
entries: testUploadEntries("upload-1"),
|
||||
callErrs: []error{
|
||||
status.Error(codes.Unavailable, "filer is restarting"),
|
||||
nil,
|
||||
},
|
||||
}
|
||||
accessor := &listRetryAccessor{client: client}
|
||||
|
||||
dir := "/buckets/b/filer: no entry is found in filer store"
|
||||
entries, _, err := listWithRetry(dir, func() ([]*filer_pb.Entry, bool, error) {
|
||||
return listOnce(accessor, dir)
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"upload-1"}, entryNames(entries))
|
||||
assert.Equal(t, 2, client.attempts, "an object name must not suppress a retry")
|
||||
}
|
||||
106
weed/s3api/filer_util_tags.go
Normal file
106
weed/s3api/filer_util_tags.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
const (
|
||||
S3TAG_PREFIX = s3_constants.AmzObjectTagging + "-"
|
||||
)
|
||||
|
||||
func (s3a *S3ApiServer) getTags(parentDirectoryPath string, entryName string) (tags map[string]string, err error) {
|
||||
|
||||
err = s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
|
||||
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: parentDirectoryPath,
|
||||
Name: entryName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags = make(map[string]string)
|
||||
for k, v := range resp.Entry.Extended {
|
||||
if strings.HasPrefix(k, S3TAG_PREFIX) {
|
||||
tags[k[len(S3TAG_PREFIX):]] = string(v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) setTags(parentDirectoryPath string, entryName string, tags map[string]string) (err error) {
|
||||
|
||||
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
|
||||
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: parentDirectoryPath,
|
||||
Name: entryName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for k, _ := range resp.Entry.Extended {
|
||||
if strings.HasPrefix(k, S3TAG_PREFIX) {
|
||||
delete(resp.Entry.Extended, k)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Entry.Extended == nil {
|
||||
resp.Entry.Extended = make(map[string][]byte)
|
||||
}
|
||||
for k, v := range tags {
|
||||
resp.Entry.Extended[S3TAG_PREFIX+k] = []byte(v)
|
||||
}
|
||||
|
||||
return filer_pb.UpdateEntry(context.Background(), client, &filer_pb.UpdateEntryRequest{
|
||||
Directory: parentDirectoryPath,
|
||||
Entry: resp.Entry,
|
||||
IsFromOtherCluster: false,
|
||||
Signatures: nil,
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) rmTags(parentDirectoryPath string, entryName string) (err error) {
|
||||
|
||||
return s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
|
||||
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: parentDirectoryPath,
|
||||
Name: entryName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasDeletion := false
|
||||
for k, _ := range resp.Entry.Extended {
|
||||
if strings.HasPrefix(k, S3TAG_PREFIX) {
|
||||
delete(resp.Entry.Extended, k)
|
||||
hasDeletion = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasDeletion {
|
||||
return nil
|
||||
}
|
||||
|
||||
return filer_pb.UpdateEntry(context.Background(), client, &filer_pb.UpdateEntryRequest{
|
||||
Directory: parentDirectoryPath,
|
||||
Entry: resp.Entry,
|
||||
IsFromOtherCluster: false,
|
||||
Signatures: nil,
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
93
weed/s3api/iam_batch_delete_test.go
Normal file
93
weed/s3api/iam_batch_delete_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAuthorizeObjectDelete_AwsCanonicalPolicy: a policy granting s3:DeleteObject
|
||||
// on <bucket>/* must allow per-key batch deletes. Pre-fix the bucket-level check
|
||||
// built arn:aws:s3:::<bucket> and never matched the object-scoped policy.
|
||||
func TestAuthorizeObjectDelete_AwsCanonicalPolicy(t *testing.T) {
|
||||
const bucket = "test-bucket"
|
||||
const policyName = "delete-test-bucket-objects"
|
||||
|
||||
policyDoc, err := json.Marshal(map[string]any{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]any{
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:DeleteObject",
|
||||
"Resource": "arn:aws:s3:::" + bucket + "/*",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
iam := &IdentityAccessManagement{
|
||||
isAuthEnabled: true,
|
||||
}
|
||||
require.NoError(t, iam.PutPolicy(policyName, string(policyDoc)))
|
||||
|
||||
identity := &Identity{
|
||||
Name: "alice",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{policyName},
|
||||
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("POST", "/"+bucket+"?delete", nil)
|
||||
|
||||
require.Equal(t, s3err.ErrNone,
|
||||
iam.AuthorizeObjectDelete(r, identity, bucket, "objects/a.txt", ""),
|
||||
"s3:DeleteObject on arn:aws:s3:::%s/* must allow deleting %s/objects/a.txt", bucket, bucket)
|
||||
|
||||
require.Equal(t, s3err.ErrAccessDenied,
|
||||
iam.AuthorizeObjectDelete(r, identity, "other-bucket", "objects/a.txt", ""),
|
||||
"keys outside the granted bucket must be denied")
|
||||
}
|
||||
|
||||
// TestAuthorizeObjectDelete_PrefixScopedPolicy: a prefix-scoped policy must allow
|
||||
// batch deletes under the prefix and deny keys outside it, per-key.
|
||||
func TestAuthorizeObjectDelete_PrefixScopedPolicy(t *testing.T) {
|
||||
const bucket = "test-bucket"
|
||||
const policyName = "delete-prefix-only"
|
||||
|
||||
policyDoc, err := json.Marshal(map[string]any{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]any{
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:DeleteObject",
|
||||
"Resource": "arn:aws:s3:::" + bucket + "/safe/*",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
iam := &IdentityAccessManagement{
|
||||
isAuthEnabled: true,
|
||||
}
|
||||
require.NoError(t, iam.PutPolicy(policyName, string(policyDoc)))
|
||||
|
||||
identity := &Identity{
|
||||
Name: "alice",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{policyName},
|
||||
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("POST", "/"+bucket+"?delete", nil)
|
||||
|
||||
require.Equal(t, s3err.ErrNone,
|
||||
iam.AuthorizeObjectDelete(r, identity, bucket, "safe/inside.txt", ""),
|
||||
"key under granted prefix must be allowed")
|
||||
|
||||
require.Equal(t, s3err.ErrAccessDenied,
|
||||
iam.AuthorizeObjectDelete(r, identity, bucket, "danger/outside.txt", ""),
|
||||
"key outside the granted prefix must be denied per-key, not at the batch level")
|
||||
}
|
||||
279
weed/s3api/iam_defaults_test.go
Normal file
279
weed/s3api/iam_defaults_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLoadIAMManagerFromConfig_Defaults(t *testing.T) {
|
||||
// Create a temporary config file with minimal content (just policy)
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "iam_config.json")
|
||||
|
||||
configContent := `{
|
||||
"sts": {
|
||||
"providers": []
|
||||
},
|
||||
"policy": {
|
||||
"storeType": "memory",
|
||||
"defaultEffect": "Allow"
|
||||
}
|
||||
}`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// dummy filer address provider
|
||||
filerProvider := func() string { return "localhost:8888" }
|
||||
defaultSigningKeyProvider := func() string { return "default-secure-signing-key" }
|
||||
|
||||
// Load the manager
|
||||
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, manager)
|
||||
}
|
||||
|
||||
func TestLoadIAMManagerFromConfig_Overrides(t *testing.T) {
|
||||
// Create a temporary config file with EXPLICIT values
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "iam_config_explicit.json")
|
||||
|
||||
configContent := `{
|
||||
"sts": {
|
||||
"tokenDuration": "2h",
|
||||
"maxSessionLength": "24h",
|
||||
"issuer": "custom-issuer",
|
||||
"signingKey": "ZXhwbGljaXQtc2lnbmluZy1rZXktMTIzNDU="
|
||||
},
|
||||
"policy": {
|
||||
"storeType": "memory",
|
||||
"defaultEffect": "Allow"
|
||||
}
|
||||
}`
|
||||
// Base64 encoded "explicit-signing-key-12345" is "ZXhwbGljaXQtc2lnbmluZy1rZXktMTIzNDU="
|
||||
|
||||
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
filerProvider := func() string { return "localhost:8888" }
|
||||
defaultSigningKeyProvider := func() string { return "default-secure-signing-key" }
|
||||
|
||||
// Load
|
||||
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, manager)
|
||||
}
|
||||
|
||||
func TestLoadIAMManagerFromConfig_PartialDefaults(t *testing.T) {
|
||||
// Test that partial configs (e.g. providing SigningKey but not Duration) work
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "iam_config_partial.json")
|
||||
|
||||
// Signing key provided in JSON, others missing
|
||||
configContent := `{
|
||||
"sts": {
|
||||
"signingKey": "anNvbi1wcm92aWRlZC1rZXktMTIzNDU="
|
||||
},
|
||||
"policy": {
|
||||
"storeType": "memory",
|
||||
"defaultEffect": "Allow"
|
||||
}
|
||||
}`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
filerProvider := func() string { return "localhost:8888" }
|
||||
// Default signing key provided but should be IGNORED because JSON has one
|
||||
defaultSigningKeyProvider := func() string { return "server-default-key-should-be-ignored" }
|
||||
|
||||
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, manager)
|
||||
}
|
||||
|
||||
func TestLoadIAMManagerFromConfig_ExplicitEmptyKey(t *testing.T) {
|
||||
// Test that if JSON has empty signing key string, it still falls back
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "iam_config_empty_key.json")
|
||||
|
||||
// Signing key explicitly empty
|
||||
configContent := `{
|
||||
"sts": {
|
||||
"signingKey": ""
|
||||
},
|
||||
"policy": {
|
||||
"storeType": "memory",
|
||||
"defaultEffect": "Allow"
|
||||
}
|
||||
}`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
filerProvider := func() string { return "localhost:8888" }
|
||||
defaultSigningKeyProvider := func() string { return "fallback-key-should-be-used" }
|
||||
|
||||
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, manager)
|
||||
}
|
||||
|
||||
func TestLoadIAMManagerFromConfig_MissingKeyError(t *testing.T) {
|
||||
// Test that if BOTH keys are empty, it fails with a clear error
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "iam_config_all_empty.json")
|
||||
|
||||
// Signing key explicitly empty in JSON
|
||||
configContent := `{
|
||||
"sts": {
|
||||
"signingKey": ""
|
||||
},
|
||||
"policy": {
|
||||
"storeType": "memory",
|
||||
"defaultEffect": "Allow"
|
||||
}
|
||||
}`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
filerProvider := func() string { return "localhost:8888" }
|
||||
defaultSigningKeyProvider := func() string { return "" } // Empty default too
|
||||
|
||||
// Ensure no SSE-S3 key interferes (global state in tests is tricky, but let's assume clean state or no mock)
|
||||
// Ideally we would mock GetSSES3KeyManager().GetMasterKey() but it's a global singleton.
|
||||
// For this unit test, if the global key manager has no key, it should fail.
|
||||
|
||||
_, err = loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
|
||||
|
||||
// Should return a clear error
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no signing key found for STS service")
|
||||
}
|
||||
|
||||
func TestLoadIAMManagerFromConfig_ExplicitFileDefaultsToDeny(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "iam_config_implicit_policy_defaults.json")
|
||||
|
||||
// Explicit config file with no policy.defaultEffect should default to Deny.
|
||||
configContent := `{
|
||||
"sts": {
|
||||
"issuer": "explicit-config"
|
||||
}
|
||||
}`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
filerProvider := func() string { return "localhost:8888" }
|
||||
defaultSigningKeyProvider := func() string { return "fallback-key-for-explicit-config" }
|
||||
|
||||
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, manager)
|
||||
assert.False(t, manager.DefaultAllow())
|
||||
}
|
||||
|
||||
func TestLoadIAMManagerFromConfig_NoFileDefaultsToAllow(t *testing.T) {
|
||||
// No explicit IAM file should preserve zero-config startup behavior.
|
||||
filerProvider := func() string { return "localhost:8888" }
|
||||
defaultSigningKeyProvider := func() string { return "fallback-key-for-zero-config" }
|
||||
|
||||
manager, err := loadIAMManagerFromConfig("", filerProvider, defaultSigningKeyProvider)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, manager)
|
||||
assert.True(t, manager.DefaultAllow())
|
||||
}
|
||||
|
||||
func TestLoadIAMManagerFromConfig_ExplicitFileEnforcesUserScopedPolicy(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "iam_regression_8366.json")
|
||||
|
||||
// Regression coverage for #8366:
|
||||
// with explicit IAM config and omitted policy.defaultEffect, unrestricted bucket creation
|
||||
// must NOT be allowed.
|
||||
configContent := `{
|
||||
"sts": {
|
||||
"issuer": "seaweedfs-sts"
|
||||
},
|
||||
"policies": [
|
||||
{
|
||||
"name": "S3UserPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListAllMyBuckets"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": [
|
||||
"arn:aws:s3:::user-${jwt:preferred_username}",
|
||||
"arn:aws:s3:::user-${jwt:preferred_username}/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "S3UserRole",
|
||||
"roleArn": "arn:aws:iam::role/S3UserRole",
|
||||
"attachedPolicies": ["S3UserPolicy"],
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Federated": "*"},
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
filerProvider := func() string { return "localhost:8888" }
|
||||
defaultSigningKeyProvider := func() string { return "fallback-key-for-regression-8366" }
|
||||
|
||||
manager, err := loadIAMManagerFromConfig(configPath, filerProvider, defaultSigningKeyProvider)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, manager)
|
||||
assert.False(t, manager.DefaultAllow())
|
||||
|
||||
ctx := context.Background()
|
||||
principal := "arn:aws:sts::000000000000:assumed-role/S3UserRole/alice-session"
|
||||
reqCtx := map[string]interface{}{"jwt:preferred_username": "alice"}
|
||||
|
||||
allowed, err := manager.IsActionAllowed(ctx, &integration.ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:CreateBucket",
|
||||
Resource: "arn:aws:s3:::arbitrary-bucket",
|
||||
RequestContext: reqCtx,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, allowed, "arbitrary bucket creation should be denied")
|
||||
|
||||
allowed, err = manager.IsActionAllowed(ctx, &integration.ActionRequest{
|
||||
Principal: principal,
|
||||
Action: "s3:CreateBucket",
|
||||
Resource: "arn:aws:s3:::user-alice",
|
||||
RequestContext: reqCtx,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, allowed, "user-scoped bucket creation should be allowed")
|
||||
}
|
||||
114
weed/s3api/iam_list_prefix_regression_test.go
Normal file
114
weed/s3api/iam_list_prefix_regression_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ListObjects requests carry their key scope in the ?prefix= query parameter,
|
||||
// which authRequestWithAuthType promotes into object so the legacy CanDo path
|
||||
// can honor prefix-scoped Action strings. That promoted object must not reach
|
||||
// the policy resource ARN: s3:ListBucket is a bucket-level action, so the
|
||||
// resource stays arn:aws:s3:::bucket and any prefix scoping is expressed via
|
||||
// the s3:prefix Condition.
|
||||
func TestEvaluateIAMPolicies_ListBucketWithPrefix(t *testing.T) {
|
||||
const bucket = "test-bucket"
|
||||
|
||||
iam := &IdentityAccessManagement{}
|
||||
require.NoError(t, iam.PutPolicy("list-bucket", mustPolicy(t, map[string]any{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]any{{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::" + bucket,
|
||||
}},
|
||||
})))
|
||||
|
||||
identity := &Identity{
|
||||
Name: "alice",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{"list-bucket"},
|
||||
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
|
||||
}
|
||||
|
||||
// authRequestWithAuthType promotes prefix into object before reaching the
|
||||
// IAM evaluator; pass the post-promotion value to mirror that flow.
|
||||
withPrefix := httptest.NewRequest("GET", "/"+bucket+"?list-type=2&prefix=foo/", nil)
|
||||
require.True(t, iam.evaluateIAMPolicies(withPrefix, identity, s3_constants.ACTION_LIST, bucket, "foo/"),
|
||||
"s3:ListBucket on the bucket ARN must allow listing with a prefix")
|
||||
|
||||
noPrefix := httptest.NewRequest("GET", "/"+bucket+"?list-type=2", nil)
|
||||
require.True(t, iam.evaluateIAMPolicies(noPrefix, identity, s3_constants.ACTION_LIST, bucket, ""),
|
||||
"s3:ListBucket on the bucket ARN must allow listing without a prefix")
|
||||
}
|
||||
|
||||
// Prefix scoping still works once it moves to the Condition: a policy that
|
||||
// grants s3:ListBucket on the bucket only under an s3:prefix StringLike must
|
||||
// allow a matching prefix and deny a non-matching one.
|
||||
func TestEvaluateIAMPolicies_ListBucketPrefixCondition(t *testing.T) {
|
||||
const bucket = "test-bucket"
|
||||
|
||||
iam := &IdentityAccessManagement{}
|
||||
require.NoError(t, iam.PutPolicy("list-scoped", mustPolicy(t, map[string]any{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]any{{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::" + bucket,
|
||||
"Condition": map[string]any{"StringLike": map[string]any{"s3:prefix": "warehouse/*"}},
|
||||
}},
|
||||
})))
|
||||
|
||||
identity := &Identity{
|
||||
Name: "bob",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{"list-scoped"},
|
||||
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
|
||||
}
|
||||
|
||||
matching := httptest.NewRequest("GET", "/"+bucket+"?list-type=2&prefix=warehouse/data", nil)
|
||||
require.True(t, iam.evaluateIAMPolicies(matching, identity, s3_constants.ACTION_LIST, bucket, "warehouse/data"),
|
||||
"prefix matching the s3:prefix condition must be allowed")
|
||||
|
||||
nonMatching := httptest.NewRequest("GET", "/"+bucket+"?list-type=2&prefix=secrets/", nil)
|
||||
require.False(t, iam.evaluateIAMPolicies(nonMatching, identity, s3_constants.ACTION_LIST, bucket, "secrets/"),
|
||||
"prefix outside the s3:prefix condition must be denied")
|
||||
}
|
||||
|
||||
// Listing variants keep their specific action even when a prefix is promoted
|
||||
// into object: ?versions resolves to s3:ListBucketVersions, not s3:ListBucket.
|
||||
func TestEvaluateIAMPolicies_ListBucketVersionsWithPrefix(t *testing.T) {
|
||||
const bucket = "test-bucket"
|
||||
|
||||
iam := &IdentityAccessManagement{}
|
||||
require.NoError(t, iam.PutPolicy("list-versions", mustPolicy(t, map[string]any{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]any{{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:ListBucketVersions",
|
||||
"Resource": "arn:aws:s3:::" + bucket,
|
||||
}},
|
||||
})))
|
||||
|
||||
identity := &Identity{
|
||||
Name: "carol",
|
||||
Account: &AccountAdmin,
|
||||
PolicyNames: []string{"list-versions"},
|
||||
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("GET", "/"+bucket+"?versions&prefix=foo/", nil)
|
||||
require.True(t, iam.evaluateIAMPolicies(r, identity, s3_constants.ACTION_LIST, bucket, "foo/"),
|
||||
"s3:ListBucketVersions must still resolve when listing with a prefix")
|
||||
}
|
||||
|
||||
func mustPolicy(t *testing.T, doc map[string]any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(doc)
|
||||
require.NoError(t, err)
|
||||
return string(b)
|
||||
}
|
||||
76
weed/s3api/iam_optional_test.go
Normal file
76
weed/s3api/iam_optional_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/credential"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// resetMemoryStore resets the shared in-memory credential store so that tests
|
||||
// that rely on an empty store are not polluted by earlier tests.
|
||||
func resetMemoryStore() {
|
||||
for _, store := range credential.Stores {
|
||||
if store.GetName() == credential.StoreTypeMemory {
|
||||
if resettable, ok := store.(interface{ Reset() }); ok {
|
||||
resettable.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIAMManagerWithNoConfig(t *testing.T) {
|
||||
// Verify that IAM can be initialized without any config
|
||||
option := &S3ApiServerOption{
|
||||
Config: "",
|
||||
}
|
||||
iamManager := NewIdentityAccessManagementWithStore(option, nil, "memory")
|
||||
assert.NotNil(t, iamManager)
|
||||
// Internal state might be hard to access directly, but successful init implies defaults worked.
|
||||
}
|
||||
|
||||
func TestLoadIAMManagerFromConfig_EmptyConfigWithFallbackKey(t *testing.T) {
|
||||
// Reset the shared memory store to avoid state leaking from other tests.
|
||||
resetMemoryStore()
|
||||
|
||||
// Initialize IAM with empty config — no anonymous identity is configured,
|
||||
// so LookupAnonymous should return not-found.
|
||||
option := &S3ApiServerOption{
|
||||
Config: "",
|
||||
IamConfig: "",
|
||||
EnableIam: true,
|
||||
}
|
||||
iamManager := NewIdentityAccessManagementWithStore(option, nil, "memory")
|
||||
|
||||
_, found := iamManager.LookupAnonymous()
|
||||
assert.False(t, found, "Anonymous identity should not be found when not explicitly configured")
|
||||
}
|
||||
|
||||
// TestSetIAMIntegrationKeepsAuthDisabledWithoutConfig is a regression test for
|
||||
// issue #9557. The `weed mini` defaults (and the bare `docker run seaweedfs`
|
||||
// image) start with EnableIam=true but no IAM config file and no identities.
|
||||
// The advanced-IAM init path used to also flip isAuthEnabled to true via
|
||||
// SetIAMIntegration, which then rejected every anonymous request as
|
||||
// AccessDenied — breaking out-of-the-box S3 access. Setting an integration
|
||||
// must not, on its own, enable auth enforcement; explicit configs use
|
||||
// EnableAuthEnforcement to opt in.
|
||||
func TestSetIAMIntegrationKeepsAuthDisabledWithoutConfig(t *testing.T) {
|
||||
resetMemoryStore()
|
||||
|
||||
option := &S3ApiServerOption{
|
||||
EnableIam: true,
|
||||
}
|
||||
iam := NewIdentityAccessManagementWithStore(option, nil, "memory")
|
||||
|
||||
// Simulate an integration object being plugged in (the constructor in
|
||||
// s3api_server.go does this when EnableIam=true, even with no config file).
|
||||
// We only care that auth stays off — the integration value itself is opaque.
|
||||
iam.SetIAMIntegration(&S3IAMIntegration{})
|
||||
|
||||
assert.False(t, iam.isEnabled(), "Auth must stay disabled when no identities and no IamConfig are configured")
|
||||
|
||||
// And EnableAuthEnforcement does flip it on — this is what the startup
|
||||
// path runs when the operator explicitly passes -s3.iam.config.
|
||||
iam.EnableAuthEnforcement()
|
||||
assert.True(t, iam.isEnabled(), "EnableAuthEnforcement must turn auth on")
|
||||
}
|
||||
254
weed/s3api/iceberg/commit_helpers.go
Normal file
254
weed/s3api/iceberg/commit_helpers.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/google/uuid"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
const requirementAssertCreate = "assert-create"
|
||||
|
||||
// sleepBeforeCommitRetry backs off between commit attempts, jittered so that
|
||||
// writers that collided do not line up again on the next try.
|
||||
func sleepBeforeCommitRetry(attempt int) {
|
||||
jitter := time.Duration(rand.Int64N(int64(25 * time.Millisecond)))
|
||||
time.Sleep(time.Duration(50*attempt)*time.Millisecond + jitter)
|
||||
}
|
||||
|
||||
// stageCommitMetadata writes the metadata file a commit will point the catalog
|
||||
// at, returning the name and location it landed on. The exclusive create keeps
|
||||
// one writer from overwriting another's file; when the name is already taken
|
||||
// the metadata goes to a unique one instead of failing, because the file there
|
||||
// may be an orphan left by an interrupted commit, and refusing would wedge the
|
||||
// table until an orphan sweep ran. The catalog pointer, not the file name,
|
||||
// decides which commit won. This mirrors how the maintenance worker stages its
|
||||
// own metadata.
|
||||
func (s *Server) stageCommitMetadata(ctx context.Context, metadataBucket, metadataPath, location, metadataFileName string, metadataBytes []byte) (string, string, error) {
|
||||
err := s.saveMetadataFile(ctx, metadataBucket, metadataPath, metadataFileName, metadataBytes, true)
|
||||
if errors.Is(err, filer_pb.ErrEntryAlreadyExists) {
|
||||
// v{N}-{uuid}, a form both the catalog and the maintenance worker
|
||||
// still read the version from.
|
||||
metadataFileName = fmt.Sprintf("%s-%s.metadata.json", strings.TrimSuffix(metadataFileName, ".metadata.json"), uuid.NewString())
|
||||
glog.V(1).Infof("Iceberg: metadata file already staged for %s, using %s", location, metadataFileName)
|
||||
err = s.saveMetadataFile(ctx, metadataBucket, metadataPath, metadataFileName, metadataBytes, true)
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return metadataFileName, fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(location, "/"), metadataFileName), nil
|
||||
}
|
||||
|
||||
type icebergRequestError struct {
|
||||
status int
|
||||
errType string
|
||||
message string
|
||||
}
|
||||
|
||||
type createOnCommitInput struct {
|
||||
bucketARN string
|
||||
markerBucket string
|
||||
namespace []string
|
||||
tableName string
|
||||
identityName string
|
||||
location string
|
||||
tableUUID uuid.UUID
|
||||
baseMetadata table.Metadata
|
||||
baseMetadataLoc string
|
||||
baseMetadataVer int
|
||||
updates table.Updates
|
||||
statisticsUpdates []statisticsUpdate
|
||||
}
|
||||
|
||||
func isS3TablesConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, s3tables.ErrVersionTokenMismatch) {
|
||||
return true
|
||||
}
|
||||
var tableErr *s3tables.S3TablesError
|
||||
return errors.As(err, &tableErr) && tableErr.Type == s3tables.ErrCodeConflict
|
||||
}
|
||||
|
||||
func isS3TablesNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
return true
|
||||
}
|
||||
var tableErr *s3tables.S3TablesError
|
||||
return errors.As(err, &tableErr) &&
|
||||
(tableErr.Type == s3tables.ErrCodeNoSuchTable || tableErr.Type == s3tables.ErrCodeNoSuchNamespace || strings.Contains(strings.ToLower(tableErr.Message), "not found"))
|
||||
}
|
||||
|
||||
func hasAssertCreateRequirement(requirements table.Requirements) bool {
|
||||
for _, requirement := range requirements {
|
||||
if requirement.GetType() == requirementAssertCreate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isS3TablesAlreadyExists(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "already exists") {
|
||||
return true
|
||||
}
|
||||
var tableErr *s3tables.S3TablesError
|
||||
return errors.As(err, &tableErr) &&
|
||||
(tableErr.Type == s3tables.ErrCodeTableAlreadyExists || tableErr.Type == s3tables.ErrCodeNamespaceAlreadyExists || strings.Contains(strings.ToLower(tableErr.Message), "already exists"))
|
||||
}
|
||||
|
||||
func (s *Server) finalizeCreateOnCommit(ctx context.Context, input createOnCommitInput) (*CommitTableResponse, *icebergRequestError) {
|
||||
builder, err := table.MetadataBuilderFromBase(input.baseMetadata, input.baseMetadataLoc)
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusInternalServerError,
|
||||
errType: "InternalServerError",
|
||||
message: "Failed to create metadata builder: " + err.Error(),
|
||||
}
|
||||
}
|
||||
for _, update := range input.updates {
|
||||
if err := update.Apply(builder); err != nil {
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
errType: "BadRequestException",
|
||||
message: "Failed to apply update: " + err.Error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newMetadata, err := builder.Build()
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
errType: "BadRequestException",
|
||||
message: "Failed to build new metadata: " + err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
metadataVersion := input.baseMetadataVer + 1
|
||||
if metadataVersion <= 0 {
|
||||
metadataVersion = 1
|
||||
}
|
||||
metadataFileName := fmt.Sprintf("v%d.metadata.json", metadataVersion)
|
||||
newMetadataLocation := fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(input.location, "/"), metadataFileName)
|
||||
|
||||
metadataBytes, err := json.Marshal(newMetadata)
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusInternalServerError,
|
||||
errType: "InternalServerError",
|
||||
message: "Failed to serialize metadata: " + err.Error(),
|
||||
}
|
||||
}
|
||||
metadataBytes, err = applyStatisticsUpdates(metadataBytes, input.statisticsUpdates)
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
errType: "BadRequestException",
|
||||
message: "Failed to apply statistics updates: " + err.Error(),
|
||||
}
|
||||
}
|
||||
metadataBytes = refreshDefaultNameMapping(metadataBytes, newMetadata)
|
||||
// Same spec-compliance fixup we apply on create-table; ensures
|
||||
// v{N}.metadata.json files written through this create-on-commit path are
|
||||
// also readable by strict Iceberg clients reading directly from S3.
|
||||
metadataBytes = ensureMetadataSpecCompliance(metadataBytes)
|
||||
newMetadata, err = table.ParseMetadataBytes(metadataBytes)
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusInternalServerError,
|
||||
errType: "InternalServerError",
|
||||
message: "Failed to parse committed metadata: " + err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
metadataBucket, metadataPath, err := parseS3Location(input.location)
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusInternalServerError,
|
||||
errType: "InternalServerError",
|
||||
message: "Invalid table location: " + err.Error(),
|
||||
}
|
||||
}
|
||||
if err := confineMetadataLocation(metadataBucket, metadataPath, input.markerBucket); err != nil {
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusBadRequest,
|
||||
errType: "BadRequestException",
|
||||
message: err.Error(),
|
||||
}
|
||||
}
|
||||
if err := s.saveMetadataFile(ctx, metadataBucket, metadataPath, metadataFileName, metadataBytes, false); err != nil {
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusInternalServerError,
|
||||
errType: "InternalServerError",
|
||||
message: "Failed to save metadata file: " + err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
createReq := &s3tables.CreateTableRequest{
|
||||
TableBucketARN: input.bucketARN,
|
||||
Namespace: input.namespace,
|
||||
Name: input.tableName,
|
||||
Format: "ICEBERG",
|
||||
Metadata: &s3tables.TableMetadata{
|
||||
Iceberg: &s3tables.IcebergMetadata{
|
||||
TableUUID: input.tableUUID.String(),
|
||||
},
|
||||
FullMetadata: metadataBytes,
|
||||
},
|
||||
MetadataVersion: metadataVersion,
|
||||
MetadataLocation: newMetadataLocation,
|
||||
}
|
||||
createErr := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(ctx, mgrClient, "CreateTable", createReq, nil, input.identityName)
|
||||
})
|
||||
if createErr != nil {
|
||||
if cleanupErr := s.deleteMetadataFile(ctx, metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
|
||||
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s after create-on-commit failure: %v", newMetadataLocation, cleanupErr)
|
||||
}
|
||||
if isS3TablesConflict(createErr) || isS3TablesAlreadyExists(createErr) {
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusConflict,
|
||||
errType: "CommitFailedException",
|
||||
message: "Table was created concurrently",
|
||||
}
|
||||
}
|
||||
glog.Errorf("Iceberg: CommitTable CreateTable error: %v", createErr)
|
||||
return nil, &icebergRequestError{
|
||||
status: http.StatusInternalServerError,
|
||||
errType: "InternalServerError",
|
||||
message: "Failed to commit table creation: " + createErr.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
markerBucket := input.markerBucket
|
||||
if markerBucket == "" {
|
||||
markerBucket = metadataBucket
|
||||
}
|
||||
if markerErr := s.deleteStageCreateMarkers(ctx, markerBucket, input.namespace, input.tableName); markerErr != nil {
|
||||
glog.V(1).Infof("Iceberg: failed to cleanup stage-create markers for %s.%s after finalize: %v", flattenNamespacePath(input.namespace), input.tableName, markerErr)
|
||||
}
|
||||
|
||||
return &CommitTableResponse{
|
||||
MetadataLocation: newMetadataLocation,
|
||||
Metadata: newMetadata,
|
||||
}, nil
|
||||
}
|
||||
173
weed/s3api/iceberg/commit_updates.go
Normal file
173
weed/s3api/iceberg/commit_updates.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/apache/iceberg-go/table"
|
||||
)
|
||||
|
||||
type statisticsUpdate struct {
|
||||
set *table.StatisticsFile
|
||||
remove *int64
|
||||
}
|
||||
|
||||
var ErrIncompleteSetStatistics = errors.New("set-statistics requires snapshot-id, statistics-path, file-size-in-bytes, and file-footer-size-in-bytes")
|
||||
|
||||
type commitAction struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type setStatisticsUpdate struct {
|
||||
Action string `json:"action"`
|
||||
SnapshotID *int64 `json:"snapshot-id,omitempty"`
|
||||
StatisticsPath string `json:"statistics-path,omitempty"`
|
||||
FileSizeInBytes *int64 `json:"file-size-in-bytes,omitempty"`
|
||||
FileFooterSizeInBytes *int64 `json:"file-footer-size-in-bytes,omitempty"`
|
||||
KeyMetadata *string `json:"key-metadata,omitempty"`
|
||||
BlobMetadata []table.BlobMetadata `json:"blob-metadata,omitempty"`
|
||||
Statistics *table.StatisticsFile `json:"statistics,omitempty"`
|
||||
}
|
||||
|
||||
func (u *setStatisticsUpdate) asStatisticsFile() (*table.StatisticsFile, error) {
|
||||
if u.Statistics != nil {
|
||||
if u.Statistics.BlobMetadata == nil {
|
||||
u.Statistics.BlobMetadata = []table.BlobMetadata{}
|
||||
}
|
||||
return u.Statistics, nil
|
||||
}
|
||||
if u.SnapshotID == nil || u.StatisticsPath == "" || u.FileSizeInBytes == nil || u.FileFooterSizeInBytes == nil {
|
||||
return nil, ErrIncompleteSetStatistics
|
||||
}
|
||||
|
||||
stats := &table.StatisticsFile{
|
||||
SnapshotID: *u.SnapshotID,
|
||||
StatisticsPath: u.StatisticsPath,
|
||||
FileSizeInBytes: *u.FileSizeInBytes,
|
||||
FileFooterSizeInBytes: *u.FileFooterSizeInBytes,
|
||||
KeyMetadata: u.KeyMetadata,
|
||||
BlobMetadata: u.BlobMetadata,
|
||||
}
|
||||
if stats.BlobMetadata == nil {
|
||||
stats.BlobMetadata = []table.BlobMetadata{}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
type removeStatisticsUpdate struct {
|
||||
Action string `json:"action"`
|
||||
SnapshotID int64 `json:"snapshot-id"`
|
||||
}
|
||||
|
||||
func parseCommitUpdates(rawUpdates []json.RawMessage) (table.Updates, []statisticsUpdate, error) {
|
||||
filtered := make([]json.RawMessage, 0, len(rawUpdates))
|
||||
statisticsUpdates := make([]statisticsUpdate, 0)
|
||||
|
||||
for _, raw := range rawUpdates {
|
||||
var action commitAction
|
||||
if err := json.Unmarshal(raw, &action); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
switch action.Action {
|
||||
case "set-statistics":
|
||||
var setUpdate setStatisticsUpdate
|
||||
if err := json.Unmarshal(raw, &setUpdate); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
stats, err := setUpdate.asStatisticsFile()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
statisticsUpdates = append(statisticsUpdates, statisticsUpdate{set: stats})
|
||||
case "remove-statistics":
|
||||
var removeUpdate removeStatisticsUpdate
|
||||
if err := json.Unmarshal(raw, &removeUpdate); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
snapshotID := removeUpdate.SnapshotID
|
||||
statisticsUpdates = append(statisticsUpdates, statisticsUpdate{remove: &snapshotID})
|
||||
default:
|
||||
filtered = append(filtered, raw)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return nil, statisticsUpdates, nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(filtered)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var updates table.Updates
|
||||
if err := json.Unmarshal(data, &updates); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return updates, statisticsUpdates, nil
|
||||
}
|
||||
|
||||
func applyStatisticsUpdates(metadataBytes []byte, updates []statisticsUpdate) ([]byte, error) {
|
||||
if len(updates) == 0 {
|
||||
return metadataBytes, nil
|
||||
}
|
||||
|
||||
var metadata map[string]json.RawMessage
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var statistics []table.StatisticsFile
|
||||
if rawStatistics, ok := metadata["statistics"]; ok && len(rawStatistics) > 0 {
|
||||
if err := json.Unmarshal(rawStatistics, &statistics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
statisticsBySnapshot := make(map[int64]table.StatisticsFile, len(statistics))
|
||||
orderedSnapshotIDs := make([]int64, 0, len(statistics))
|
||||
inOrder := make(map[int64]bool, len(statistics))
|
||||
for _, stat := range statistics {
|
||||
statisticsBySnapshot[stat.SnapshotID] = stat
|
||||
if !inOrder[stat.SnapshotID] {
|
||||
orderedSnapshotIDs = append(orderedSnapshotIDs, stat.SnapshotID)
|
||||
inOrder[stat.SnapshotID] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, update := range updates {
|
||||
if update.set != nil {
|
||||
statisticsBySnapshot[update.set.SnapshotID] = *update.set
|
||||
if !inOrder[update.set.SnapshotID] {
|
||||
orderedSnapshotIDs = append(orderedSnapshotIDs, update.set.SnapshotID)
|
||||
inOrder[update.set.SnapshotID] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if update.remove != nil {
|
||||
delete(statisticsBySnapshot, *update.remove)
|
||||
}
|
||||
}
|
||||
|
||||
statistics = make([]table.StatisticsFile, 0, len(statisticsBySnapshot))
|
||||
for _, snapshotID := range orderedSnapshotIDs {
|
||||
stat, ok := statisticsBySnapshot[snapshotID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
statistics = append(statistics, stat)
|
||||
}
|
||||
|
||||
if len(statistics) == 0 {
|
||||
delete(metadata, "statistics")
|
||||
} else {
|
||||
data, err := json.Marshal(statistics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata["statistics"] = data
|
||||
}
|
||||
|
||||
return json.Marshal(metadata)
|
||||
}
|
||||
404
weed/s3api/iceberg/handlers_commit.go
Normal file
404
weed/s3api/iceberg/handlers_commit.go
Normal file
@@ -0,0 +1,404 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// handleUpdateTable commits updates to a table.
|
||||
// Implements the Iceberg REST Catalog commit protocol.
|
||||
func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
tableName := vars["table"]
|
||||
|
||||
if len(namespace) == 0 || tableName == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and table name are required")
|
||||
return
|
||||
}
|
||||
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
|
||||
// Extract identity from context
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
// Parse commit request and keep statistics updates separate because iceberg-go v0.4.0
|
||||
// does not decode set/remove-statistics update actions yet.
|
||||
var raw struct {
|
||||
Identifier *TableIdentifier `json:"identifier,omitempty"`
|
||||
Requirements json.RawMessage `json:"requirements"`
|
||||
Updates []json.RawMessage `json:"updates"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req CommitTableRequest
|
||||
req.Identifier = raw.Identifier
|
||||
var statisticsUpdates []statisticsUpdate
|
||||
if len(raw.Requirements) > 0 {
|
||||
normalized, err := normalizeRequirements(raw.Requirements)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid requirements: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(normalized, &req.Requirements); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid requirements: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(raw.Updates) > 0 {
|
||||
var err error
|
||||
req.Updates, statisticsUpdates, err = parseCommitUpdates(raw.Updates)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid updates: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
// Manifest repair runs once, as soon as the table location is known; on
|
||||
// commit retries the updates already reference the repaired files. Repair
|
||||
// is best effort end to end: the originals parsed already, so a repair
|
||||
// that fails to re-parse is discarded rather than failing the commit.
|
||||
manifestsRepaired := false
|
||||
repairManifests := func(location string) {
|
||||
if manifestsRepaired {
|
||||
return
|
||||
}
|
||||
manifestsRepaired = true
|
||||
repaired, changed := s.repairAddSnapshotManifests(r.Context(), location, raw.Updates)
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
repairedUpdates, repairedStatistics, err := parseCommitUpdates(repaired)
|
||||
if err != nil {
|
||||
glog.Warningf("Iceberg: repaired updates failed to parse, keeping originals: %v", err)
|
||||
return
|
||||
}
|
||||
raw.Updates = repaired
|
||||
req.Updates = repairedUpdates
|
||||
statisticsUpdates = repairedStatistics
|
||||
}
|
||||
|
||||
maxCommitAttempts := 3
|
||||
generatedLegacyUUID := uuid.New()
|
||||
stageCreateEnabled := isStageCreateEnabled()
|
||||
for attempt := 1; attempt <= maxCommitAttempts; attempt++ {
|
||||
getReq := &s3tables.GetTableRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: namespace,
|
||||
Name: tableName,
|
||||
}
|
||||
var getResp s3tables.GetTableResponse
|
||||
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", getReq, &getResp, identityName)
|
||||
})
|
||||
if err != nil {
|
||||
if isS3TablesNotFound(err) {
|
||||
location := fmt.Sprintf("s3://%s/%s", bucketName, path.Join(flattenNamespacePath(namespace), tableName))
|
||||
tableUUID := generatedLegacyUUID
|
||||
baseMetadataVersion := 0
|
||||
baseMetadataLocation := ""
|
||||
var baseMetadata table.Metadata
|
||||
|
||||
var latestMarker *stageCreateMarker
|
||||
if stageCreateEnabled {
|
||||
var markerErr error
|
||||
latestMarker, markerErr = s.loadLatestStageCreateMarker(r.Context(), bucketName, namespace, tableName)
|
||||
if markerErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to load stage-create marker: "+markerErr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if latestMarker != nil {
|
||||
if latestMarker.Location != "" {
|
||||
location = strings.TrimSuffix(latestMarker.Location, "/")
|
||||
}
|
||||
if latestMarker.TableUUID != "" {
|
||||
if parsedUUID, parseErr := uuid.Parse(latestMarker.TableUUID); parseErr == nil {
|
||||
tableUUID = parsedUUID
|
||||
}
|
||||
}
|
||||
|
||||
stagedMetadataLocation := latestMarker.StagedMetadataLocation
|
||||
if stagedMetadataLocation == "" {
|
||||
stagedMetadataLocation = fmt.Sprintf("%s/metadata/v1.metadata.json", strings.TrimSuffix(location, "/"))
|
||||
}
|
||||
stagedLocation := tableLocationFromMetadataLocation(stagedMetadataLocation)
|
||||
stagedFileName := path.Base(stagedMetadataLocation)
|
||||
stagedBucket, stagedPath, parseLocationErr := parseS3Location(stagedLocation)
|
||||
if parseLocationErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid staged metadata location: "+parseLocationErr.Error())
|
||||
return
|
||||
}
|
||||
if err := confineMetadataLocation(stagedBucket, stagedPath, bucketName); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
|
||||
return
|
||||
}
|
||||
stagedMetadataBytes, loadErr := s.loadMetadataFile(r.Context(), stagedBucket, stagedPath, stagedFileName)
|
||||
if loadErr != nil {
|
||||
if !errors.Is(loadErr, filer_pb.ErrNotFound) {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to load staged metadata: "+loadErr.Error())
|
||||
return
|
||||
}
|
||||
} else if len(stagedMetadataBytes) > 0 {
|
||||
stagedMetadata, parseErr := table.ParseMetadataBytes(stagedMetadataBytes)
|
||||
if parseErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse staged metadata: "+parseErr.Error())
|
||||
return
|
||||
}
|
||||
// Staged metadata is only a template for table creation; commit starts from version 1.
|
||||
baseMetadata = stagedMetadata
|
||||
baseMetadataLocation = ""
|
||||
baseMetadataVersion = 0
|
||||
if stagedMetadata.TableUUID() != uuid.Nil {
|
||||
tableUUID = stagedMetadata.TableUUID()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasAssertCreate := hasAssertCreateRequirement(req.Requirements)
|
||||
hasStagedTemplate := baseMetadata != nil
|
||||
if !(stageCreateEnabled && (hasAssertCreate || hasStagedTemplate)) {
|
||||
writeError(w, http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", tableName))
|
||||
return
|
||||
}
|
||||
// From here the commit creates the table, writing its metadata
|
||||
// file before the create that authorizes it.
|
||||
if authErr := s.authorizeCreateTable(r.Context(), bucketARN, namespace, tableName, identityName); authErr != nil {
|
||||
writeManagerError(w, authErr)
|
||||
return
|
||||
}
|
||||
|
||||
for _, requirement := range req.Requirements {
|
||||
validateAgainst := table.Metadata(nil)
|
||||
if hasStagedTemplate && requirement.GetType() != requirementAssertCreate {
|
||||
validateAgainst = baseMetadata
|
||||
}
|
||||
if requirementErr := requirement.Validate(validateAgainst); requirementErr != nil {
|
||||
writeError(w, http.StatusConflict, "CommitFailedException", "Requirement failed: "+requirementErr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if baseMetadata == nil {
|
||||
var buildErr error
|
||||
if baseMetadata, buildErr = newTableMetadata(tableUUID, location, nil, nil, nil, nil); buildErr != nil {
|
||||
glog.Errorf("Iceberg: CommitTable placeholder metadata for %s: %v", tableName, buildErr)
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build current metadata")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
createBucket, createPath, createLocErr := parseS3Location(location)
|
||||
if createLocErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid table location: "+createLocErr.Error())
|
||||
return
|
||||
}
|
||||
if err := confineMetadataLocation(createBucket, createPath, bucketName); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
repairManifests(location)
|
||||
|
||||
result, reqErr := s.finalizeCreateOnCommit(r.Context(), createOnCommitInput{
|
||||
bucketARN: bucketARN,
|
||||
markerBucket: bucketName,
|
||||
namespace: namespace,
|
||||
tableName: tableName,
|
||||
identityName: identityName,
|
||||
location: location,
|
||||
tableUUID: tableUUID,
|
||||
baseMetadata: baseMetadata,
|
||||
baseMetadataLoc: baseMetadataLocation,
|
||||
baseMetadataVer: baseMetadataVersion,
|
||||
updates: req.Updates,
|
||||
statisticsUpdates: statisticsUpdates,
|
||||
})
|
||||
if reqErr != nil {
|
||||
writeError(w, reqErr.status, reqErr.errType, reqErr.message)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: CommitTable GetTable error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
location := tableLocationFromMetadataLocation(getResp.MetadataLocation)
|
||||
if location == "" {
|
||||
location = fmt.Sprintf("s3://%s/%s", bucketName, path.Join(flattenNamespacePath(namespace), tableName))
|
||||
}
|
||||
locBucket, locPath, locErr := parseS3Location(location)
|
||||
if locErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid table location: "+locErr.Error())
|
||||
return
|
||||
}
|
||||
if err := confineMetadataLocation(locBucket, locPath, bucketName); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
|
||||
return
|
||||
}
|
||||
tableUUID := uuid.Nil
|
||||
if getResp.Metadata != nil && getResp.Metadata.Iceberg != nil && getResp.Metadata.Iceberg.TableUUID != "" {
|
||||
if parsed, parseErr := uuid.Parse(getResp.Metadata.Iceberg.TableUUID); parseErr == nil {
|
||||
tableUUID = parsed
|
||||
}
|
||||
}
|
||||
if tableUUID == uuid.Nil {
|
||||
tableUUID = generatedLegacyUUID
|
||||
}
|
||||
|
||||
var currentMetadata table.Metadata
|
||||
if getResp.Metadata != nil && len(getResp.Metadata.FullMetadata) > 0 {
|
||||
currentMetadata, err = table.ParseMetadataBytes(getResp.Metadata.FullMetadata)
|
||||
if err != nil {
|
||||
glog.Errorf("Iceberg: Failed to parse current metadata for %s: %v", tableName, err)
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse current metadata")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
currentMetadata, err = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
glog.Errorf("Iceberg: CommitTable placeholder metadata for %s: %v", tableName, err)
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build current metadata")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, requirement := range req.Requirements {
|
||||
if err := requirement.Validate(currentMetadata); err != nil {
|
||||
writeError(w, http.StatusConflict, "CommitFailedException", "Requirement failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
repairManifests(location)
|
||||
|
||||
builder, err := table.MetadataBuilderFromBase(currentMetadata, getResp.MetadataLocation)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to create metadata builder: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, update := range req.Updates {
|
||||
if err := update.Apply(builder); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to apply update: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newMetadata, err := builder.Build()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to build new metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
metadataVersion := getResp.MetadataVersion + 1
|
||||
metadataFileName := fmt.Sprintf("v%d.metadata.json", metadataVersion)
|
||||
newMetadataLocation := fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(location, "/"), metadataFileName)
|
||||
|
||||
metadataBytes, err := json.Marshal(newMetadata)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
// iceberg-go does not currently support set/remove-statistics updates in MetadataBuilder.
|
||||
// Patch the encoded metadata JSON and parse it back to keep the response object consistent.
|
||||
metadataBytes, err = applyStatisticsUpdates(metadataBytes, statisticsUpdates)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to apply statistics updates: "+err.Error())
|
||||
return
|
||||
}
|
||||
metadataBytes = refreshDefaultNameMapping(metadataBytes, newMetadata)
|
||||
// Same spec-compliance fixup we apply on create-table; ensures
|
||||
// v{N}.metadata.json files written during commit are also readable by
|
||||
// strict Iceberg clients reading directly from S3, and that the
|
||||
// FullMetadata persisted in S3Tables stays consistent.
|
||||
metadataBytes = ensureMetadataSpecCompliance(metadataBytes)
|
||||
newMetadata, err = table.ParseMetadataBytes(metadataBytes)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse committed metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
metadataBucket, metadataPath, err := parseS3Location(location)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid table location: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := confineMetadataLocation(metadataBucket, metadataPath, bucketName); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
|
||||
return
|
||||
}
|
||||
metadataFileName, newMetadataLocation, err = s.stageCommitMetadata(r.Context(), metadataBucket, metadataPath, location, metadataFileName, metadataBytes)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save metadata file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updateReq := &s3tables.UpdateTableRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: namespace,
|
||||
Name: tableName,
|
||||
VersionToken: getResp.VersionToken,
|
||||
Metadata: &s3tables.TableMetadata{
|
||||
Iceberg: &s3tables.IcebergMetadata{
|
||||
TableUUID: tableUUID.String(),
|
||||
},
|
||||
FullMetadata: metadataBytes,
|
||||
},
|
||||
MetadataVersion: metadataVersion,
|
||||
MetadataLocation: newMetadataLocation,
|
||||
}
|
||||
|
||||
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "UpdateTable", updateReq, nil, identityName)
|
||||
})
|
||||
if err == nil {
|
||||
result := CommitTableResponse{
|
||||
MetadataLocation: newMetadataLocation,
|
||||
Metadata: newMetadata,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
|
||||
if isS3TablesConflict(err) {
|
||||
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
|
||||
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s on conflict: %v", newMetadataLocation, cleanupErr)
|
||||
}
|
||||
if attempt < maxCommitAttempts {
|
||||
glog.V(1).Infof("Iceberg: CommitTable conflict for %s (attempt %d/%d), retrying", tableName, attempt, maxCommitAttempts)
|
||||
sleepBeforeCommitRetry(attempt)
|
||||
continue
|
||||
}
|
||||
writeError(w, http.StatusConflict, "CommitFailedException", "Version token mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
|
||||
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s after update failure: %v", newMetadataLocation, cleanupErr)
|
||||
}
|
||||
glog.Errorf("Iceberg: CommitTable UpdateTable error: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to commit table update: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
58
weed/s3api/iceberg/handlers_metrics.go
Normal file
58
weed/s3api/iceberg/handlers_metrics.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
// Reports carry per-file scan metrics and grow with the table. The content is
|
||||
// discarded either way, so anything past this is accepted without being read
|
||||
// into memory rather than rejected.
|
||||
const maxMetricsReportBytes = 1 << 20
|
||||
|
||||
// handleReportMetrics accepts the scan and commit reports engines send after
|
||||
// planning or committing. The catalog keeps no metrics store, but the endpoint
|
||||
// has to exist: clients that get a 404 here log an error per query, and some
|
||||
// treat repeated failures as a broken catalog.
|
||||
func (s *Server) handleReportMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
tableName := vars["table"]
|
||||
|
||||
if len(namespace) == 0 || tableName == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and table name are required")
|
||||
return
|
||||
}
|
||||
|
||||
// One byte past the limit distinguishes a report that fits from one that
|
||||
// was cut short, which would otherwise fail to parse and look malformed.
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxMetricsReportBytes+1))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if len(body) > maxMetricsReportBytes {
|
||||
glog.V(3).Infof("Iceberg: discarding oversized metrics report for %s.%s", flattenNamespacePath(namespace), tableName)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
var report struct {
|
||||
ReportType string `json:"report-type"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &report); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if report.ReportType == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "report-type is required")
|
||||
return
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Iceberg: metrics report %q for %s.%s", report.ReportType, flattenNamespacePath(namespace), tableName)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
63
weed/s3api/iceberg/handlers_metrics_test.go
Normal file
63
weed/s3api/iceberg/handlers_metrics_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func postMetrics(t *testing.T, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/namespaces/ns/tables/t/metrics", strings.NewReader(body))
|
||||
r = mux.SetURLVars(r, map[string]string{"namespace": "ns", "table": "t"})
|
||||
w := httptest.NewRecorder()
|
||||
(&Server{}).handleReportMetrics(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestHandleReportMetricsAcceptsScanReport(t *testing.T) {
|
||||
w := postMetrics(t, `{"report-type":"scan-report","table-name":"t","snapshot-id":1}`)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d: %s", w.Code, http.StatusNoContent, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportMetricsRejectsGarbage(t *testing.T) {
|
||||
w := postMetrics(t, `not json`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// The spec makes the body and its report-type required.
|
||||
func TestHandleReportMetricsRequiresReportType(t *testing.T) {
|
||||
for _, body := range []string{``, `{}`, `{"report-type":""}`} {
|
||||
if w := postMetrics(t, body); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("body %q: status = %d, want %d", body, w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A report larger than the read limit is still a valid report; truncating it
|
||||
// and then failing to parse would hand the client an error for a query that
|
||||
// worked.
|
||||
func TestHandleReportMetricsAcceptsOversizedReport(t *testing.T) {
|
||||
padding := strings.Repeat("x", maxMetricsReportBytes)
|
||||
w := postMetrics(t, `{"report-type":"scan-report","padding":"`+padding+`"}`)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d: %s", w.Code, http.StatusNoContent, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReportMetricsRequiresTable(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/namespaces/ns/tables//metrics", strings.NewReader(`{}`))
|
||||
r = mux.SetURLVars(r, map[string]string{"namespace": "ns"})
|
||||
w := httptest.NewRecorder()
|
||||
(&Server{}).handleReportMetrics(w, r)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
389
weed/s3api/iceberg/handlers_namespace.go
Normal file
389
weed/s3api/iceberg/handlers_namespace.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// handleConfig returns catalog configuration.
|
||||
//
|
||||
// When a client passes ?warehouse=, the Iceberg REST spec expects the server
|
||||
// to echo a catalog identifier back as overrides.prefix so subsequent calls
|
||||
// use /v1/{prefix}/... and land on the right table bucket. Without this,
|
||||
// clients like DuckDB's ATTACH flow fall back to an unprefixed path that
|
||||
// resolves to the wrong bucket and report phantom "schema does not exist"
|
||||
// errors.
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
config := CatalogConfig{
|
||||
Defaults: map[string]string{},
|
||||
Overrides: map[string]string{},
|
||||
}
|
||||
if warehouse := strings.TrimSpace(r.URL.Query().Get("warehouse")); warehouse != "" {
|
||||
// Only the bucket portion of the warehouse is meaningful today —
|
||||
// SeaweedFS table-bucket routing is bucket-scoped, so any sub-path
|
||||
// (e.g. s3://bucket/prefix/) is ignored here and clients that try to
|
||||
// scope a catalog under a sub-prefix will still land on the bucket.
|
||||
if bucket := resolveWarehouseBucket(warehouse); bucket != "" {
|
||||
config.Overrides["prefix"] = bucket
|
||||
// Echo the location form regardless of how the client spelled the
|
||||
// warehouse: clients derive default table locations from it.
|
||||
config.Defaults["warehouse"] = "s3://" + bucket
|
||||
}
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(config); err != nil {
|
||||
glog.Warningf("handleConfig: Failed to encode config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleListNamespaces lists namespaces in a catalog.
|
||||
func (s *Server) handleListNamespaces(w http.ResponseWriter, r *http.Request) {
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
|
||||
// Extract identity from context
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
pageToken, pageSize, err := parsePagination(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// The Iceberg REST spec allows a "parent" query parameter for hierarchical
|
||||
// namespace listing. Convert it to the dot-separated prefix used by S3 Tables.
|
||||
var prefix string
|
||||
if parent := r.URL.Query().Get("parent"); parent != "" {
|
||||
parentParts := parseNamespace(parent)
|
||||
if len(parentParts) > 0 {
|
||||
prefix = flattenNamespacePath(parentParts) + "."
|
||||
}
|
||||
}
|
||||
|
||||
// Use S3 Tables manager to list namespaces
|
||||
var resp s3tables.ListNamespacesResponse
|
||||
req := &s3tables.ListNamespacesRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Prefix: prefix,
|
||||
ContinuationToken: pageToken,
|
||||
MaxNamespaces: pageSize,
|
||||
}
|
||||
|
||||
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "ListNamespaces", req, &resp, identityName)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
glog.Infof("Iceberg: ListNamespaces error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to Iceberg format
|
||||
namespaces := make([]Namespace, 0, len(resp.Namespaces))
|
||||
for _, ns := range resp.Namespaces {
|
||||
namespaces = append(namespaces, Namespace(ns.Namespace))
|
||||
}
|
||||
|
||||
result := ListNamespacesResponse{
|
||||
NextPageToken: resp.ContinuationToken,
|
||||
Namespaces: namespaces,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleCreateNamespace creates a new namespace.
|
||||
func (s *Server) handleCreateNamespace(w http.ResponseWriter, r *http.Request) {
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
|
||||
// Extract identity from context
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
var req CreateNamespaceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Namespace) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Use S3 Tables manager to create namespace
|
||||
createReq := &s3tables.CreateNamespaceRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: req.Namespace,
|
||||
Properties: normalizeNamespaceProperties(req.Properties),
|
||||
}
|
||||
var createResp s3tables.CreateNamespaceResponse
|
||||
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
glog.V(2).Infof("Iceberg: handleCreateNamespace calling Execute with identityName=%s", identityName)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "CreateNamespace", createReq, &createResp, identityName)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
|
||||
return
|
||||
}
|
||||
glog.Errorf("Iceberg: CreateNamespace error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result := CreateNamespaceResponse{
|
||||
Namespace: Namespace(createResp.Namespace),
|
||||
Properties: withDefaultNamespaceLocation(createResp.Properties, bucketName, createResp.Namespace),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleGetNamespace gets namespace metadata.
|
||||
func (s *Server) handleGetNamespace(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
if len(namespace) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
|
||||
return
|
||||
}
|
||||
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
|
||||
// Extract identity from context
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
// Use S3 Tables manager to get namespace
|
||||
getReq := &s3tables.GetNamespaceRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: namespace,
|
||||
}
|
||||
var getResp s3tables.GetNamespaceResponse
|
||||
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "GetNamespace", getReq, &getResp, identityName)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
|
||||
return
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: GetNamespace error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result := GetNamespaceResponse{
|
||||
Namespace: Namespace(getResp.Namespace),
|
||||
Properties: withDefaultNamespaceLocation(getResp.Properties, bucketName, getResp.Namespace),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// applyNamespacePropertyUpdates applies removals and updates to a copy of
|
||||
// current and returns the merged property map alongside the Iceberg REST
|
||||
// summary of which keys were removed, updated, or were missing.
|
||||
func applyNamespacePropertyUpdates(current map[string]string, removals []string, updates map[string]string) (map[string]string, UpdateNamespacePropertiesResponse) {
|
||||
properties := make(map[string]string, len(current))
|
||||
for k, v := range current {
|
||||
properties[k] = v
|
||||
}
|
||||
|
||||
summary := UpdateNamespacePropertiesResponse{Removed: []string{}, Updated: []string{}, Missing: []string{}}
|
||||
seen := make(map[string]struct{}, len(removals))
|
||||
for _, key := range removals {
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if _, ok := properties[key]; ok {
|
||||
delete(properties, key)
|
||||
summary.Removed = append(summary.Removed, key)
|
||||
} else {
|
||||
summary.Missing = append(summary.Missing, key)
|
||||
}
|
||||
}
|
||||
for key, value := range updates {
|
||||
properties[key] = value
|
||||
summary.Updated = append(summary.Updated, key)
|
||||
}
|
||||
return properties, summary
|
||||
}
|
||||
|
||||
// handleUpdateNamespaceProperties applies a set of removals and updates to a
|
||||
// namespace's properties and returns which keys were removed, updated, or
|
||||
// missing, per the Iceberg REST spec.
|
||||
func (s *Server) handleUpdateNamespaceProperties(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
if len(namespace) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateNamespacePropertiesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// A key cannot be both removed and updated in the same request.
|
||||
for _, key := range req.Removals {
|
||||
if _, ok := req.Updates[key]; ok {
|
||||
writeError(w, http.StatusUnprocessableEntity, "UnprocessableEntityException",
|
||||
fmt.Sprintf("Cannot remove and update the same key: %s", key))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
// Load the current properties so we can compute the summary and the merged map.
|
||||
getReq := &s3tables.GetNamespaceRequest{TableBucketARN: bucketARN, Namespace: namespace}
|
||||
var getResp s3tables.GetNamespaceResponse
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "GetNamespace", getReq, &getResp, identityName)
|
||||
})
|
||||
if err != nil {
|
||||
writeNamespaceManagerError(w, err, namespace)
|
||||
return
|
||||
}
|
||||
|
||||
properties, summary := applyNamespacePropertyUpdates(getResp.Properties, req.Removals, req.Updates)
|
||||
|
||||
updReq := &s3tables.UpdateNamespaceRequest{TableBucketARN: bucketARN, Namespace: namespace, Properties: properties}
|
||||
var updResp s3tables.UpdateNamespaceResponse
|
||||
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "UpdateNamespace", updReq, &updResp, identityName)
|
||||
})
|
||||
if err != nil {
|
||||
writeNamespaceManagerError(w, err, namespace)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// writeNamespaceManagerError maps an s3tables manager error to the matching
|
||||
// Iceberg REST response. A namespace dropped between read and write surfaces
|
||||
// as 404 and a denied caller as 403; anything else is a 500.
|
||||
func writeNamespaceManagerError(w http.ResponseWriter, err error, namespace Namespace) {
|
||||
var s3Err *s3tables.S3TablesError
|
||||
if errors.As(err, &s3Err) {
|
||||
switch s3Err.Type {
|
||||
case s3tables.ErrCodeNoSuchNamespace:
|
||||
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
|
||||
return
|
||||
case s3tables.ErrCodeAccessDenied:
|
||||
writeError(w, http.StatusForbidden, "ForbiddenException", "Not authorized to update namespace properties")
|
||||
return
|
||||
}
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: UpdateNamespaceProperties error: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
|
||||
}
|
||||
|
||||
// handleNamespaceExists checks if a namespace exists.
|
||||
func (s *Server) handleNamespaceExists(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
if len(namespace) == 0 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
|
||||
// Extract identity from context
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
getReq := &s3tables.GetNamespaceRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: namespace,
|
||||
}
|
||||
var getResp s3tables.GetNamespaceResponse
|
||||
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "GetNamespace", getReq, &getResp, identityName)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if nameValidationError(err) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: NamespaceExists error: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDropNamespace deletes a namespace.
|
||||
func (s *Server) handleDropNamespace(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
if len(namespace) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
|
||||
return
|
||||
}
|
||||
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
|
||||
// Extract identity from context
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
deleteReq := &s3tables.DeleteNamespaceRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: namespace,
|
||||
}
|
||||
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "DeleteNamespace", deleteReq, nil, identityName)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "not empty") {
|
||||
writeError(w, http.StatusConflict, "NamespaceNotEmptyException", "Namespace is not empty")
|
||||
return
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: DropNamespace error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
397
weed/s3api/iceberg/handlers_oauth.go
Normal file
397
weed/s3api/iceberg/handlers_oauth.go
Normal file
@@ -0,0 +1,397 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
// OAuthTokenResponse is the response for POST /v1/oauth/tokens.
|
||||
type OAuthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
// IssuedTokenType is set to the access-token URN on token-exchange
|
||||
// responses, where RFC 8693 requires it.
|
||||
IssuedTokenType string `json:"issued_token_type,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthErrorResponse is the error response for the OAuth endpoint.
|
||||
type OAuthErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Description string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// IcebergClaims are JWT claims for Iceberg catalog OAuth tokens.
|
||||
type IcebergClaims struct {
|
||||
IdentityName string `json:"identity_name"`
|
||||
AccessKey string `json:"access_key"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
const defaultOauthTokenExpiry = 3600
|
||||
|
||||
// maxOauthTokenExpiry bounds the configured TTL so seconds-to-Duration
|
||||
// conversions cannot overflow into already-expired tokens.
|
||||
const maxOauthTokenExpiry = 365 * 24 * 3600
|
||||
|
||||
// accessTokenTokenType is the RFC 8693 token-type URN for access tokens,
|
||||
// required in issued_token_type on token-exchange responses.
|
||||
const accessTokenTokenType = "urn:ietf:params:oauth:token-type:access_token"
|
||||
|
||||
// grant types accepted by POST /v1/oauth/tokens. Iceberg Java 1.10.x
|
||||
// refreshes tokens via token-exchange (exchangeEnabled defaults true), so a
|
||||
// server that only accepts client_credentials leaves those clients unable
|
||||
// to refresh.
|
||||
const (
|
||||
grantTypeClientCredentials = "client_credentials"
|
||||
grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
)
|
||||
|
||||
// oauthExpirySeconds returns the OAuth token TTL. Deployments whose
|
||||
// clients cannot refresh tokens on 401 can raise this to survive client
|
||||
// restart cycles instead of dying every hour.
|
||||
func oauthExpirySeconds() int {
|
||||
if v := os.Getenv("ICEBERG_OAUTH_TOKEN_EXPIRY"); v != "" {
|
||||
// Parse in 64-bit space: on 32-bit platforms Atoi overflows and
|
||||
// errors on oversized values, which would silently fall back to
|
||||
// the default instead of clamping.
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
|
||||
if n > maxOauthTokenExpiry {
|
||||
return maxOauthTokenExpiry
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
}
|
||||
return defaultOauthTokenExpiry
|
||||
}
|
||||
|
||||
// tokenExchangeGrace is how long an expired subject token may still be
|
||||
// exchanged for a fresh access token. Signature verification is the real
|
||||
// gate; the grace exists so a client holding a token that expired while the
|
||||
// exchange grant was unsupported (or during a server outage) recovers
|
||||
// without a process restart.
|
||||
func tokenExchangeGrace() time.Duration {
|
||||
grace := 2 * oauthExpirySeconds()
|
||||
if grace < 3600 {
|
||||
grace = 3600
|
||||
}
|
||||
if grace > 86400 {
|
||||
grace = 86400
|
||||
}
|
||||
return time.Duration(grace) * time.Second
|
||||
}
|
||||
|
||||
// handleOAuthTokens implements the OAuth2 client_credentials flow.
|
||||
// POST /v1/oauth/tokens
|
||||
func (s *Server) handleOAuthTokens(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Could not parse form body")
|
||||
return
|
||||
}
|
||||
|
||||
// Reject credentials in query string to prevent leaking secrets into logs and caches.
|
||||
if r.URL.Query().Get("client_secret") != "" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "client_secret must not be sent in the URL")
|
||||
return
|
||||
}
|
||||
|
||||
grantType := r.PostFormValue("grant_type")
|
||||
switch grantType {
|
||||
case grantTypeClientCredentials:
|
||||
// handled below
|
||||
case grantTypeTokenExchange, "token_exchange":
|
||||
s.handleTokenExchange(w, r)
|
||||
return
|
||||
default:
|
||||
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type",
|
||||
fmt.Sprintf("Unsupported grant_type: %s", grantType))
|
||||
return
|
||||
}
|
||||
|
||||
clientID := r.PostFormValue("client_id")
|
||||
clientSecret := r.PostFormValue("client_secret")
|
||||
|
||||
// Also support HTTP Basic auth per OAuth2 spec
|
||||
if clientID == "" && clientSecret == "" {
|
||||
var ok bool
|
||||
clientID, clientSecret, ok = r.BasicAuth()
|
||||
if !ok {
|
||||
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "Missing client credentials")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if clientID == "" || clientSecret == "" {
|
||||
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "Missing client_id or client_secret")
|
||||
return
|
||||
}
|
||||
|
||||
if s.credentialValidator == nil {
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Credential validation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
identityName, _, err := s.credentialValidator.ValidateS3Credential(clientID, clientSecret)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Iceberg OAuth: credential validation failed for client_id=%s: %v", clientID, err)
|
||||
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "Invalid client credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a JWT signed with a key derived from the client secret.
|
||||
// Include the access key in claims so we can look up the exact credential for verification.
|
||||
tokenString, err := mintIcebergToken(identityName, clientID, clientSecret, oauthExpirySeconds())
|
||||
if err != nil {
|
||||
glog.Errorf("Iceberg OAuth: failed to sign token: %v", err)
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
scope := r.PostFormValue("scope")
|
||||
resp := OAuthTokenResponse{
|
||||
AccessToken: tokenString,
|
||||
TokenType: "bearer",
|
||||
ExpiresIn: oauthExpirySeconds(),
|
||||
Scope: scope,
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleTokenExchange implements RFC 8693 token exchange for Iceberg REST
|
||||
// clients: a previously issued access token (subject_token) is exchanged for
|
||||
// a fresh one. Iceberg Java's OAuth2Manager refreshes via this grant
|
||||
// (exchangeEnabled defaults true), so supporting it lets those clients
|
||||
// self-heal before their token expires — no client restart needed.
|
||||
//
|
||||
// Client authentication (RFC 8693 §2.1) is accepted but not required:
|
||||
// Iceberg Java's refreshExpiredToken sends Basic credentials with the
|
||||
// exchange, while its proactive scheduled refresh sends only the Bearer
|
||||
// session headers. An expired subject_token is only exchangeable with valid
|
||||
// client credentials, and an unauthenticated exchange never extends the
|
||||
// token's lifetime beyond the subject_token's own expiry.
|
||||
func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request) {
|
||||
if s.credentialValidator == nil {
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Credential validation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
subjectToken := r.PostFormValue("subject_token")
|
||||
if subjectToken == "" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Missing subject_token")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the subject token by signature (exp checked separately against
|
||||
// the recovery grace).
|
||||
unverified := &IcebergClaims{}
|
||||
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
||||
if _, _, err := parser.ParseUnverified(subjectToken, unverified); err != nil {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Invalid subject_token")
|
||||
return
|
||||
}
|
||||
if unverified.AccessKey == "" || unverified.Issuer != "seaweedfs-iceberg" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Invalid subject_token")
|
||||
return
|
||||
}
|
||||
identityName, _, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Iceberg OAuth: token exchange failed to get credential for access key: %v", err)
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Invalid subject_token")
|
||||
return
|
||||
}
|
||||
|
||||
// Optional client authentication. When present, the client must
|
||||
// authenticate with the same credential that issued the subject_token.
|
||||
clientID, clientSecret, hasBasic := r.BasicAuth()
|
||||
if !hasBasic {
|
||||
clientID = r.PostFormValue("client_id")
|
||||
clientSecret = r.PostFormValue("client_secret")
|
||||
hasBasic = clientID != "" && clientSecret != ""
|
||||
}
|
||||
clientAuthenticated := false
|
||||
if hasBasic {
|
||||
if _, _, err := s.credentialValidator.ValidateS3Credential(clientID, clientSecret); err != nil {
|
||||
glog.V(2).Infof("Iceberg OAuth: token exchange client authentication failed for client_id=%s: %v", clientID, err)
|
||||
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "Invalid client credentials")
|
||||
return
|
||||
}
|
||||
if clientID != unverified.AccessKey {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "subject_token was issued to a different client")
|
||||
return
|
||||
}
|
||||
clientAuthenticated = true
|
||||
}
|
||||
|
||||
signingKey := deriveSigningKey(unverified.AccessKey, secretKey)
|
||||
claims := &IcebergClaims{}
|
||||
parsed, err := jwt.ParseWithClaims(subjectToken, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return signingKey, nil
|
||||
}, jwt.WithoutClaimsValidation())
|
||||
if err != nil || !parsed.Valid {
|
||||
glog.V(2).Infof("Iceberg OAuth: token exchange signature verification failed: %v", err)
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Invalid subject_token")
|
||||
return
|
||||
}
|
||||
if claims.Issuer != "seaweedfs-iceberg" {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Invalid subject_token")
|
||||
return
|
||||
}
|
||||
if claims.ExpiresAt == nil {
|
||||
// Tokens minted by this server always carry an expiry; a subject
|
||||
// token without one must not be exchangeable forever.
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "subject_token has no expiry")
|
||||
return
|
||||
}
|
||||
|
||||
ttlSeconds := oauthExpirySeconds()
|
||||
if time.Now().After(claims.ExpiresAt.Time) {
|
||||
// Expired subject tokens are only exchangeable by an authenticated
|
||||
// client, within the recovery grace.
|
||||
if !clientAuthenticated {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant",
|
||||
"subject_token is expired; client authentication is required to exchange it")
|
||||
return
|
||||
}
|
||||
if time.Since(claims.ExpiresAt.Time) > tokenExchangeGrace() {
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "subject_token expired beyond the exchange grace window")
|
||||
return
|
||||
}
|
||||
} else if remaining := int(time.Until(claims.ExpiresAt.Time).Seconds()); !clientAuthenticated && remaining < ttlSeconds {
|
||||
// Unauthenticated exchange (proactive refresh carrying only the
|
||||
// Bearer) must not extend the lifetime past the subject's expiry;
|
||||
// otherwise a leaked token could chain-refresh forever. An
|
||||
// authenticated client gets a fresh full TTL — it could mint one
|
||||
// via client_credentials anyway.
|
||||
ttlSeconds = remaining
|
||||
}
|
||||
if ttlSeconds <= 0 {
|
||||
// A subject token with under a second left would otherwise be
|
||||
// exchanged for a token that is born expired.
|
||||
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "subject_token has no remaining lifetime")
|
||||
return
|
||||
}
|
||||
|
||||
tokenString, err := mintIcebergToken(identityName, unverified.AccessKey, secretKey, ttlSeconds)
|
||||
if err != nil {
|
||||
glog.Errorf("Iceberg OAuth: failed to sign exchanged token: %v", err)
|
||||
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
resp := OAuthTokenResponse{
|
||||
AccessToken: tokenString,
|
||||
TokenType: "bearer",
|
||||
ExpiresIn: ttlSeconds,
|
||||
Scope: r.PostFormValue("scope"),
|
||||
IssuedTokenType: accessTokenTokenType,
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// mintIcebergToken issues a signed access token for the given identity and
|
||||
// credential, valid for ttlSeconds.
|
||||
func mintIcebergToken(identityName, accessKey, secret string, ttlSeconds int) (string, error) {
|
||||
signingKey := deriveSigningKey(accessKey, secret)
|
||||
now := time.Now()
|
||||
claims := IcebergClaims{
|
||||
IdentityName: identityName,
|
||||
AccessKey: accessKey,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(ttlSeconds) * time.Second)),
|
||||
Issuer: "seaweedfs-iceberg",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(signingKey)
|
||||
}
|
||||
|
||||
// authenticateBearer validates a Bearer token from the Authorization header.
|
||||
// Returns the identity name, identity object, and whether auth succeeded.
|
||||
func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
||||
return "", nil, false
|
||||
}
|
||||
tokenString := strings.TrimSpace(auth[7:])
|
||||
if tokenString == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
if s.credentialValidator == nil {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// Parse the token without verification first to get the access key,
|
||||
// then look up the exact credential to verify the signature.
|
||||
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
||||
unverified := &IcebergClaims{}
|
||||
_, _, err := parser.ParseUnverified(tokenString, unverified)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Iceberg OAuth: failed to parse token: %v", err)
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
if unverified.AccessKey == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// Look up the credential by access key to get the signing key for verification
|
||||
identityName, identity, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Iceberg OAuth: failed to get credential for access key: %v", err)
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
signingKey := deriveSigningKey(unverified.AccessKey, secretKey)
|
||||
claims := &IcebergClaims{}
|
||||
verified, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return signingKey, nil
|
||||
})
|
||||
if err != nil || !verified.Valid {
|
||||
glog.V(2).Infof("Iceberg OAuth: token verification failed: %v", err)
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
return identityName, identity, true
|
||||
}
|
||||
|
||||
// deriveSigningKey derives a signing key from the access key and secret using HMAC-SHA256.
|
||||
// Including the access key prevents cross-credential token forgery when two
|
||||
// credentials happen to share the same secret.
|
||||
func deriveSigningKey(accessKey, secret string) []byte {
|
||||
h := hmac.New(sha256.New, []byte("seaweedfs-iceberg-oauth"))
|
||||
h.Write([]byte(accessKey))
|
||||
h.Write([]byte{0}) // null separator
|
||||
h.Write([]byte(secret))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func writeOAuthError(w http.ResponseWriter, status int, errCode, description string) {
|
||||
resp := OAuthErrorResponse{
|
||||
Error: errCode,
|
||||
Description: description,
|
||||
}
|
||||
writeJSON(w, status, resp)
|
||||
}
|
||||
410
weed/s3api/iceberg/handlers_oauth_test.go
Normal file
410
weed/s3api/iceberg/handlers_oauth_test.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type mockCredentialValidator struct {
|
||||
credentials map[string]string // accessKey -> secretKey
|
||||
identities map[string]string // accessKey -> identityName
|
||||
}
|
||||
|
||||
func (m *mockCredentialValidator) ValidateS3Credential(accessKey, secretKey string) (string, interface{}, error) {
|
||||
expected, ok := m.credentials[accessKey]
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("access key not found")
|
||||
}
|
||||
if expected != secretKey {
|
||||
return "", nil, fmt.Errorf("invalid secret key")
|
||||
}
|
||||
return m.identities[accessKey], nil, nil
|
||||
}
|
||||
|
||||
func (m *mockCredentialValidator) GetCredentialByAccessKey(accessKey string) (string, interface{}, string, error) {
|
||||
secret, ok := m.credentials[accessKey]
|
||||
if !ok {
|
||||
return "", nil, "", fmt.Errorf("access key not found")
|
||||
}
|
||||
return m.identities[accessKey], nil, secret, nil
|
||||
}
|
||||
|
||||
func newTestServerWithOAuth() *Server {
|
||||
cv := &mockCredentialValidator{
|
||||
credentials: map[string]string{"AKID123": "secret456"},
|
||||
identities: map[string]string{"AKID123": "testuser"},
|
||||
}
|
||||
s := &Server{
|
||||
credentialValidator: cv,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestHandleOAuthTokens_Success(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.handleOAuthTokens(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp OAuthTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.TokenType != "bearer" {
|
||||
t.Errorf("expected token_type=bearer, got %s", resp.TokenType)
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
t.Error("expected non-empty access_token")
|
||||
}
|
||||
if resp.ExpiresIn != oauthExpirySeconds() {
|
||||
t.Errorf("expected expires_in=%d, got %d", oauthExpirySeconds(), resp.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOAuthTokens_InvalidCredentials(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
body := "grant_type=client_credentials&client_id=AKID123&client_secret=wrongsecret"
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.handleOAuthTokens(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOAuthTokens_UnsupportedGrantType(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
body := "grant_type=authorization_code&client_id=AKID123&client_secret=secret456"
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.handleOAuthTokens(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenRoundTrip(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
// Get a token
|
||||
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleOAuthTokens(w, req)
|
||||
|
||||
var resp OAuthTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Use the token for Bearer auth
|
||||
authReq := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil)
|
||||
authReq.Header.Set("Authorization", "Bearer "+resp.AccessToken)
|
||||
|
||||
identityName, _, ok := s.authenticateBearer(authReq)
|
||||
if !ok {
|
||||
t.Fatal("expected Bearer auth to succeed")
|
||||
}
|
||||
if identityName != "testuser" {
|
||||
t.Errorf("expected identity 'testuser', got '%s'", identityName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenInvalid(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
|
||||
_, _, ok := s.authenticateBearer(req)
|
||||
if ok {
|
||||
t.Error("expected Bearer auth to fail with invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenNone(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil)
|
||||
|
||||
_, _, ok := s.authenticateBearer(req)
|
||||
if ok {
|
||||
t.Error("expected Bearer auth to fail with no token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOauthExpirySecondsEnvOverride pins the TTL knob: the token TTL must
|
||||
// be configurable so clients that cannot refresh on 401 can be given
|
||||
// longer-lived tokens instead of dying every hour.
|
||||
func TestOauthExpirySecondsEnvOverride(t *testing.T) {
|
||||
if got := oauthExpirySeconds(); got != defaultOauthTokenExpiry {
|
||||
t.Fatalf("default TTL = %d, want %d", got, defaultOauthTokenExpiry)
|
||||
}
|
||||
t.Setenv("ICEBERG_OAUTH_TOKEN_EXPIRY", "86400")
|
||||
if got := oauthExpirySeconds(); got != 86400 {
|
||||
t.Fatalf("env TTL = %d, want 86400", got)
|
||||
}
|
||||
t.Setenv("ICEBERG_OAUTH_TOKEN_EXPIRY", "-5")
|
||||
if got := oauthExpirySeconds(); got != defaultOauthTokenExpiry {
|
||||
t.Fatalf("negative TTL must fall back to default, got %d", got)
|
||||
}
|
||||
t.Setenv("ICEBERG_OAUTH_TOKEN_EXPIRY", "garbage")
|
||||
if got := oauthExpirySeconds(); got != defaultOauthTokenExpiry {
|
||||
t.Fatalf("invalid TTL must fall back to default, got %d", got)
|
||||
}
|
||||
// oversized values are clamped so Duration math cannot overflow
|
||||
t.Setenv("ICEBERG_OAUTH_TOKEN_EXPIRY", "99999999999999999")
|
||||
if got := oauthExpirySeconds(); got != maxOauthTokenExpiry {
|
||||
t.Fatalf("oversized TTL must clamp to %d, got %d", maxOauthTokenExpiry, got)
|
||||
}
|
||||
|
||||
// a token minted under an override carries the override's expiry
|
||||
t.Setenv("ICEBERG_OAUTH_TOKEN_EXPIRY", "7200")
|
||||
s := newTestServerWithOAuth()
|
||||
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleOAuthTokens(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp OAuthTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.ExpiresIn != 7200 {
|
||||
t.Fatalf("minted token expires_in = %d, want 7200", resp.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func exchangeRequest(t *testing.T, subjectToken string) *httptest.ResponseRecorder {
|
||||
return exchangeRequestWithBasic(t, subjectToken, "", "")
|
||||
}
|
||||
|
||||
// exchangeRequestWithBasic mirrors Iceberg Java: refreshExpiredToken sends
|
||||
// Basic client credentials with the token-exchange grant, the proactive
|
||||
// scheduled refresh sends only the form body.
|
||||
func exchangeRequestWithBasic(t *testing.T, subjectToken, basicUser, basicPass string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
s := newTestServerWithOAuth()
|
||||
body := "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=" + url.QueryEscape(subjectToken)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if basicUser != "" || basicPass != "" {
|
||||
req.SetBasicAuth(basicUser, basicPass)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.handleOAuthTokens(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestTokenExchangeLiveSubject: the proactive refresh path Iceberg Java
|
||||
// 1.10.x uses (exchangeEnabled defaults true, Bearer-only headers) must
|
||||
// mint a fresh working token.
|
||||
func TestTokenExchangeLiveSubject(t *testing.T) {
|
||||
s := newTestServerWithOAuth()
|
||||
now := time.Now()
|
||||
live := mintTestToken(t, "AKID123", "secret456", now, now.Add(30*time.Minute))
|
||||
|
||||
w := exchangeRequest(t, live)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp OAuthTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.TokenType != "bearer" || resp.AccessToken == "" {
|
||||
t.Fatalf("bad token response: %+v", resp)
|
||||
}
|
||||
if resp.IssuedTokenType != accessTokenTokenType {
|
||||
t.Fatalf("issued_token_type = %q, want %s", resp.IssuedTokenType, accessTokenTokenType)
|
||||
}
|
||||
// Unauthenticated exchange must not outlive the subject token.
|
||||
if resp.ExpiresIn > 30*60 {
|
||||
t.Fatalf("expires_in = %d, must be bounded by the subject's remaining lifetime (<= 1800)", resp.ExpiresIn)
|
||||
}
|
||||
// the exchanged token must authenticate like a normal Bearer
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+resp.AccessToken)
|
||||
if _, _, ok := s.authenticateBearer(req); !ok {
|
||||
t.Fatalf("exchanged token must pass authenticateBearer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenExchangeExpiredWithinGrace: a client whose token expired while
|
||||
// exchange was unsupported must recover without a restart. This is the
|
||||
// refreshExpiredToken path, which authenticates with Basic credentials.
|
||||
func TestTokenExchangeExpiredWithinGrace(t *testing.T) {
|
||||
now := time.Now()
|
||||
expiredRecently := mintTestToken(t, "AKID123", "secret456", now.Add(-20*time.Minute), now.Add(-10*time.Minute))
|
||||
|
||||
w := exchangeRequestWithBasic(t, expiredRecently, "AKID123", "secret456")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 within grace, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp OAuthTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Authenticated refresh gets the full configured TTL.
|
||||
if resp.ExpiresIn != oauthExpirySeconds() {
|
||||
t.Fatalf("expires_in = %d, want %d", resp.ExpiresIn, oauthExpirySeconds())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenExchangeAuthenticatedLiveSubjectFullTTL: an authenticated client
|
||||
// exchanging a live token renews the session — the TTL cap only applies to
|
||||
// unauthenticated (Bearer-only) exchanges.
|
||||
func TestTokenExchangeAuthenticatedLiveSubjectFullTTL(t *testing.T) {
|
||||
now := time.Now()
|
||||
live := mintTestToken(t, "AKID123", "secret456", now, now.Add(30*time.Minute))
|
||||
|
||||
w := exchangeRequestWithBasic(t, live, "AKID123", "secret456")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp OAuthTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.ExpiresIn != oauthExpirySeconds() {
|
||||
t.Fatalf("authenticated live exchange expires_in = %d, want full TTL %d", resp.ExpiresIn, oauthExpirySeconds())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenExchangeSubjectNearlyExpired: a Bearer-only exchange when the
|
||||
// subject token has under a second left must be rejected, not minted into
|
||||
// an already-expired token (expires_in: 0).
|
||||
func TestTokenExchangeSubjectNearlyExpired(t *testing.T) {
|
||||
// jwt/v5 serializes exp at one-second precision, so pin the expiry to
|
||||
// the next whole-second boundary: the token is always live when minted
|
||||
// and exchanged, yet has under a second of remaining lifetime.
|
||||
exp := time.Now().Truncate(time.Second).Add(time.Second)
|
||||
nearlyDead := mintTestToken(t, "AKID123", "secret456", exp.Add(-time.Hour), exp)
|
||||
|
||||
w := exchangeRequest(t, nearlyDead)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "invalid_grant") {
|
||||
t.Fatalf("expected invalid_grant, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenExchangeExpiredWithoutClientAuth: a leaked expired token must
|
||||
// not be exchangeable without client credentials.
|
||||
func TestTokenExchangeExpiredWithoutClientAuth(t *testing.T) {
|
||||
now := time.Now()
|
||||
expiredRecently := mintTestToken(t, "AKID123", "secret456", now.Add(-20*time.Minute), now.Add(-10*time.Minute))
|
||||
|
||||
w := exchangeRequest(t, expiredRecently)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "invalid_grant") {
|
||||
t.Fatalf("expected invalid_grant, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenExchangeExpiredBeyondGrace: stale tokens far past expiry must not
|
||||
// act as eternal credentials, even with client auth.
|
||||
func TestTokenExchangeExpiredBeyondGrace(t *testing.T) {
|
||||
now := time.Now()
|
||||
longDead := mintTestToken(t, "AKID123", "secret456", now.Add(-48*time.Hour), now.Add(-47*time.Hour))
|
||||
|
||||
w := exchangeRequestWithBasic(t, longDead, "AKID123", "secret456")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 beyond grace, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "invalid_grant") {
|
||||
t.Fatalf("expected invalid_grant, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenExchangeGarbageSubject: malformed subject tokens are rejected
|
||||
// with 400 invalid_grant per RFC 6749 §5.2.
|
||||
func TestTokenExchangeGarbageSubject(t *testing.T) {
|
||||
w := exchangeRequest(t, "not-a-jwt")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenExchangeWrongClientCredentials: valid subject token, but Basic
|
||||
// auth with wrong credentials or a different client is rejected.
|
||||
func TestTokenExchangeWrongClientCredentials(t *testing.T) {
|
||||
now := time.Now()
|
||||
live := mintTestToken(t, "AKID123", "secret456", now, now.Add(30*time.Minute))
|
||||
|
||||
// wrong secret → invalid_client 401
|
||||
w := exchangeRequestWithBasic(t, live, "AKID123", "wrongsecret")
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong secret: expected 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
// a different valid client must not exchange someone else's token
|
||||
s := &Server{credentialValidator: &mockCredentialValidator{
|
||||
credentials: map[string]string{"AKID123": "secret456", "AKID999": "other-secret"},
|
||||
identities: map[string]string{"AKID123": "testuser", "AKID999": "otheruser"},
|
||||
}}
|
||||
body := "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=" + url.QueryEscape(live)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth("AKID999", "other-secret")
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleOAuthTokens(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid_grant") {
|
||||
t.Fatalf("cross-client exchange: expected 400 invalid_grant, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenExchangeSubjectWithoutExpiry: a correctly signed subject token
|
||||
// without an exp claim must be rejected — it would otherwise refresh forever.
|
||||
func TestTokenExchangeSubjectWithoutExpiry(t *testing.T) {
|
||||
claims := IcebergClaims{
|
||||
IdentityName: "testuser",
|
||||
AccessKey: "AKID123",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "seaweedfs-iceberg",
|
||||
// no ExpiresAt
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(deriveSigningKey("AKID123", "secret456"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := exchangeRequestWithBasic(t, signed, "AKID123", "secret456")
|
||||
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "invalid_grant") {
|
||||
t.Fatalf("expected 400 invalid_grant for nil-exp subject, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
1000
weed/s3api/iceberg/handlers_table.go
Normal file
1000
weed/s3api/iceberg/handlers_table.go
Normal file
File diff suppressed because it is too large
Load Diff
364
weed/s3api/iceberg/handlers_transaction.go
Normal file
364
weed/s3api/iceberg/handlers_transaction.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/google/uuid"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// CommitTransactionRequest is sent to POST /v1/transactions/commit.
|
||||
type CommitTransactionRequest struct {
|
||||
TableChanges []tableChangeRequest `json:"table-changes"`
|
||||
}
|
||||
|
||||
type tableChangeRequest struct {
|
||||
Identifier *TableIdentifier `json:"identifier"`
|
||||
Requirements json.RawMessage `json:"requirements"`
|
||||
Updates []json.RawMessage `json:"updates"`
|
||||
}
|
||||
|
||||
// preparedTableCommit holds the per-table work resolved during validation so
|
||||
// pointer flips (and their rollback) can run after every table is validated.
|
||||
type preparedTableCommit struct {
|
||||
namespace []string
|
||||
tableName string
|
||||
tableUUID uuid.UUID
|
||||
versionToken string
|
||||
metadataBucket string
|
||||
metadataPath string
|
||||
location string
|
||||
metadataFileName string
|
||||
metadataBytes []byte
|
||||
metadataVersion int
|
||||
newMetadataLoc string
|
||||
|
||||
// prior table state captured before the flip, so a rollback can revert
|
||||
// every field handleUpdateTable would have mutated (not just the location).
|
||||
prevMetadataLoc string
|
||||
prevMetadataVersion int
|
||||
prevMetadata *s3tables.TableMetadata
|
||||
}
|
||||
|
||||
// handleCommitTransaction commits changes to multiple tables in one request.
|
||||
// Validation is atomic (all requirements evaluated before any write); pointer
|
||||
// flips are best-effort with rollback, so this is not crash-atomic.
|
||||
func (s *Server) handleCommitTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
var req CommitTransactionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.TableChanges) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1: resolve, load, and validate every table; build new metadata bytes.
|
||||
prepared := make([]preparedTableCommit, 0, len(req.TableChanges))
|
||||
for _, change := range req.TableChanges {
|
||||
if change.Identifier == nil || len(change.Identifier.Namespace) == 0 || change.Identifier.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Each table change requires identifier namespace and name")
|
||||
return
|
||||
}
|
||||
pc, reqErr := s.prepareTableCommit(r.Context(), bucketName, bucketARN, identityName, change)
|
||||
if reqErr != nil {
|
||||
writeError(w, reqErr.status, reqErr.errType, reqErr.message)
|
||||
return
|
||||
}
|
||||
prepared = append(prepared, *pc)
|
||||
}
|
||||
|
||||
// Phase 2: write each new metadata.json object. Staging is exclusive for the
|
||||
// same reason a single-table commit stages exclusively: a transaction racing
|
||||
// another writer on one of its tables would otherwise overwrite that
|
||||
// writer's metadata, and the pointer flip below decides who won.
|
||||
for i := range prepared {
|
||||
pc := &prepared[i]
|
||||
fileName, location, err := s.stageCommitMetadata(r.Context(), pc.metadataBucket, pc.metadataPath, pc.location, pc.metadataFileName, pc.metadataBytes)
|
||||
if err != nil {
|
||||
// No pointer flipped yet, so every written file is safe to delete.
|
||||
s.cleanupPreparedMetadata(r.Context(), prepared[:i+1], nil)
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save metadata file: "+err.Error())
|
||||
return
|
||||
}
|
||||
pc.metadataFileName = fileName
|
||||
pc.newMetadataLoc = location
|
||||
}
|
||||
|
||||
// Phase 3: flip each table's pointer xattr; on failure roll back prior flips.
|
||||
for i := range prepared {
|
||||
pc := &prepared[i]
|
||||
if err := s.flipTablePointer(r.Context(), bucketARN, identityName, pc); err != nil {
|
||||
rolledBack := s.rollbackTablePointers(r.Context(), bucketARN, identityName, prepared[:i])
|
||||
s.cleanupPreparedMetadata(r.Context(), prepared, cleanupSafeMetadata(prepared, i, rolledBack))
|
||||
if isS3TablesConflict(err) {
|
||||
writeError(w, http.StatusConflict, "CommitFailedException", "Version token mismatch")
|
||||
return
|
||||
}
|
||||
glog.Errorf("Iceberg: CommitTransaction UpdateTable error: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to commit table update: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) prepareTableCommit(ctx context.Context, bucketName, bucketARN, identityName string, change tableChangeRequest) (*preparedTableCommit, *icebergRequestError) {
|
||||
namespace := []string(change.Identifier.Namespace)
|
||||
tableName := change.Identifier.Name
|
||||
|
||||
requirements, updates, statisticsUpdates, reqErr := parseTableChange(change)
|
||||
if reqErr != nil {
|
||||
return nil, reqErr
|
||||
}
|
||||
|
||||
getReq := &s3tables.GetTableRequest{TableBucketARN: bucketARN, Namespace: namespace, Name: tableName}
|
||||
var getResp s3tables.GetTableResponse
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(ctx, mgrClient, "GetTable", getReq, &getResp, identityName)
|
||||
})
|
||||
if err != nil {
|
||||
if isS3TablesNotFound(err) {
|
||||
return nil, &icebergRequestError{http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", tableName)}
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: CommitTransaction GetTable error: %v", err)
|
||||
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", err.Error()}
|
||||
}
|
||||
|
||||
location := tableLocationFromMetadataLocation(getResp.MetadataLocation)
|
||||
if location == "" {
|
||||
location = fmt.Sprintf("s3://%s/%s", bucketName, path.Join(flattenNamespacePath(namespace), tableName))
|
||||
}
|
||||
tableUUID := uuid.Nil
|
||||
if getResp.Metadata != nil && getResp.Metadata.Iceberg != nil && getResp.Metadata.Iceberg.TableUUID != "" {
|
||||
if parsed, parseErr := uuid.Parse(getResp.Metadata.Iceberg.TableUUID); parseErr == nil {
|
||||
tableUUID = parsed
|
||||
}
|
||||
}
|
||||
if tableUUID == uuid.Nil {
|
||||
tableUUID = uuid.New()
|
||||
}
|
||||
|
||||
var currentMetadata table.Metadata
|
||||
if getResp.Metadata != nil && len(getResp.Metadata.FullMetadata) > 0 {
|
||||
currentMetadata, err = table.ParseMetadataBytes(getResp.Metadata.FullMetadata)
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to parse current metadata"}
|
||||
}
|
||||
} else {
|
||||
currentMetadata, err = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
glog.Errorf("Iceberg: CommitTransaction placeholder metadata for %s: %v", tableName, err)
|
||||
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to build current metadata"}
|
||||
}
|
||||
}
|
||||
|
||||
for _, requirement := range requirements {
|
||||
if err := requirement.Validate(currentMetadata); err != nil {
|
||||
return nil, &icebergRequestError{http.StatusConflict, "CommitFailedException", "Requirement failed: " + err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
builder, err := table.MetadataBuilderFromBase(currentMetadata, getResp.MetadataLocation)
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to create metadata builder: " + err.Error()}
|
||||
}
|
||||
for _, update := range updates {
|
||||
if err := update.Apply(builder); err != nil {
|
||||
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Failed to apply update: " + err.Error()}
|
||||
}
|
||||
}
|
||||
newMetadata, err := builder.Build()
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Failed to build new metadata: " + err.Error()}
|
||||
}
|
||||
|
||||
metadataVersion := getResp.MetadataVersion + 1
|
||||
metadataFileName := fmt.Sprintf("v%d.metadata.json", metadataVersion)
|
||||
newMetadataLocation := fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(location, "/"), metadataFileName)
|
||||
|
||||
metadataBytes, err := json.Marshal(newMetadata)
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to serialize metadata: " + err.Error()}
|
||||
}
|
||||
metadataBytes, err = applyStatisticsUpdates(metadataBytes, statisticsUpdates)
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Failed to apply statistics updates: " + err.Error()}
|
||||
}
|
||||
metadataBytes = ensureMetadataSpecCompliance(metadataBytes)
|
||||
|
||||
metadataBucket, metadataPath, err := parseS3Location(location)
|
||||
if err != nil {
|
||||
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Invalid table location: " + err.Error()}
|
||||
}
|
||||
if err := confineMetadataLocation(metadataBucket, metadataPath, bucketName); err != nil {
|
||||
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", err.Error()}
|
||||
}
|
||||
|
||||
return &preparedTableCommit{
|
||||
namespace: namespace,
|
||||
tableName: tableName,
|
||||
tableUUID: tableUUID,
|
||||
versionToken: getResp.VersionToken,
|
||||
metadataBucket: metadataBucket,
|
||||
metadataPath: metadataPath,
|
||||
location: location,
|
||||
metadataFileName: metadataFileName,
|
||||
metadataBytes: metadataBytes,
|
||||
metadataVersion: metadataVersion,
|
||||
newMetadataLoc: newMetadataLocation,
|
||||
prevMetadataLoc: getResp.MetadataLocation,
|
||||
prevMetadataVersion: getResp.MetadataVersion,
|
||||
prevMetadata: cloneTableMetadata(getResp.Metadata),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// cloneTableMetadata deep-copies the prior table metadata so a later rollback
|
||||
// restores the exact pre-transaction bytes without aliasing the get response.
|
||||
func cloneTableMetadata(m *s3tables.TableMetadata) *s3tables.TableMetadata {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
clone := &s3tables.TableMetadata{}
|
||||
if m.Iceberg != nil {
|
||||
iceberg := *m.Iceberg
|
||||
clone.Iceberg = &iceberg
|
||||
}
|
||||
if len(m.FullMetadata) > 0 {
|
||||
clone.FullMetadata = append(json.RawMessage(nil), m.FullMetadata...)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func parseTableChange(change tableChangeRequest) (table.Requirements, table.Updates, []statisticsUpdate, *icebergRequestError) {
|
||||
var requirements table.Requirements
|
||||
if len(change.Requirements) > 0 {
|
||||
normalized, err := normalizeRequirements(change.Requirements)
|
||||
if err != nil {
|
||||
return nil, nil, nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Invalid requirements: " + err.Error()}
|
||||
}
|
||||
if err := json.Unmarshal(normalized, &requirements); err != nil {
|
||||
return nil, nil, nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Invalid requirements: " + err.Error()}
|
||||
}
|
||||
}
|
||||
var updates table.Updates
|
||||
var statisticsUpdates []statisticsUpdate
|
||||
if len(change.Updates) > 0 {
|
||||
var err error
|
||||
updates, statisticsUpdates, err = parseCommitUpdates(change.Updates)
|
||||
if err != nil {
|
||||
return nil, nil, nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Invalid updates: " + err.Error()}
|
||||
}
|
||||
}
|
||||
return requirements, updates, statisticsUpdates, nil
|
||||
}
|
||||
|
||||
func (s *Server) flipTablePointer(ctx context.Context, bucketARN, identityName string, pc *preparedTableCommit) error {
|
||||
updateReq := &s3tables.UpdateTableRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: pc.namespace,
|
||||
Name: pc.tableName,
|
||||
VersionToken: pc.versionToken,
|
||||
Metadata: &s3tables.TableMetadata{
|
||||
Iceberg: &s3tables.IcebergMetadata{TableUUID: pc.tableUUID.String()},
|
||||
FullMetadata: pc.metadataBytes,
|
||||
},
|
||||
MetadataVersion: pc.metadataVersion,
|
||||
MetadataLocation: pc.newMetadataLoc,
|
||||
}
|
||||
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(ctx, mgrClient, "UpdateTable", updateReq, nil, identityName)
|
||||
})
|
||||
}
|
||||
|
||||
// rollbackTablePointers restores already-flipped tables to their full prior
|
||||
// state. handleUpdateTable applies partial field updates, so the restore must
|
||||
// re-send every field the flip changed (location, version, and full metadata),
|
||||
// not just the location. Best-effort: a failed rollback is logged, not surfaced.
|
||||
// The returned slice flags, per flipped table, whether the restore succeeded so
|
||||
// the caller can avoid deleting metadata a still-flipped pointer references.
|
||||
func (s *Server) rollbackTablePointers(ctx context.Context, bucketARN, identityName string, flipped []preparedTableCommit) []bool {
|
||||
restored := make([]bool, len(flipped))
|
||||
for i := range flipped {
|
||||
pc := &flipped[i]
|
||||
updateReq := buildTableRestoreRequest(bucketARN, pc)
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(ctx, mgrClient, "UpdateTable", updateReq, nil, identityName)
|
||||
})
|
||||
if err != nil {
|
||||
glog.Errorf("Iceberg: CommitTransaction rollback of %s failed: %v", pc.tableName, err)
|
||||
continue
|
||||
}
|
||||
restored[i] = true
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
// buildTableRestoreRequest reconstructs the UpdateTableRequest that reverts a
|
||||
// flipped table to its captured prior state. Every field handleUpdateTable
|
||||
// would have mutated on the flip is re-supplied here so the partial update
|
||||
// fully restores it (ModifiedAt and VersionToken are always regenerated by the
|
||||
// handler and cannot be pinned back).
|
||||
func buildTableRestoreRequest(bucketARN string, pc *preparedTableCommit) *s3tables.UpdateTableRequest {
|
||||
return &s3tables.UpdateTableRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: pc.namespace,
|
||||
Name: pc.tableName,
|
||||
Metadata: cloneTableMetadata(pc.prevMetadata),
|
||||
MetadataVersion: pc.prevMetadataVersion,
|
||||
MetadataLocation: pc.prevMetadataLoc,
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupSafeMetadata reports, per prepared table, whether deleting its newly
|
||||
// written metadata file is safe after a flip failed at failedIndex. A file is
|
||||
// safe to delete only when its table's pointer no longer references it: tables
|
||||
// at or past failedIndex were never flipped, and earlier tables are safe only
|
||||
// if their rollback succeeded. A table whose rollback failed still points at the
|
||||
// new metadata, so deleting it would strand the pointer on a missing file.
|
||||
func cleanupSafeMetadata(prepared []preparedTableCommit, failedIndex int, rolledBack []bool) []bool {
|
||||
safe := make([]bool, len(prepared))
|
||||
for i := range prepared {
|
||||
switch {
|
||||
case i >= failedIndex:
|
||||
safe[i] = true
|
||||
case i < len(rolledBack):
|
||||
safe[i] = rolledBack[i]
|
||||
}
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// cleanupPreparedMetadata deletes the newly written metadata file for each
|
||||
// prepared table flagged safe; unflagged tables are left in place because a
|
||||
// still-flipped pointer references them.
|
||||
func (s *Server) cleanupPreparedMetadata(ctx context.Context, prepared []preparedTableCommit, safe []bool) {
|
||||
for i := range prepared {
|
||||
pc := &prepared[i]
|
||||
if i < len(safe) && !safe[i] {
|
||||
glog.Errorf("Iceberg: CommitTransaction keeping metadata %s; %s rollback failed and still references it", pc.newMetadataLoc, pc.tableName)
|
||||
continue
|
||||
}
|
||||
if cleanupErr := s.deleteMetadataFile(ctx, pc.metadataBucket, pc.metadataPath, pc.metadataFileName); cleanupErr != nil {
|
||||
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s: %v", pc.newMetadataLoc, cleanupErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
449
weed/s3api/iceberg/handlers_view.go
Normal file
449
weed/s3api/iceberg/handlers_view.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/view"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// handleListViews lists views in a namespace.
|
||||
func (s *Server) handleListViews(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
if len(namespace) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
|
||||
return
|
||||
}
|
||||
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
pageToken, pageSize, err := parsePagination(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
listReq := &s3tables.ListViewsRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: namespace,
|
||||
ContinuationToken: pageToken,
|
||||
MaxViews: pageSize,
|
||||
}
|
||||
var listResp s3tables.ListViewsResponse
|
||||
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "ListViews", listReq, &listResp, identityName)
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
|
||||
return
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: ListViews error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
identifiers := make([]TableIdentifier, 0, len(listResp.Views))
|
||||
for _, v := range listResp.Views {
|
||||
identifiers = append(identifiers, TableIdentifier{Namespace: namespace, Name: v.Name})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ListViewsResponse{
|
||||
NextPageToken: listResp.ContinuationToken,
|
||||
Identifiers: identifiers,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateView creates a new view and writes its v1 metadata.json.
|
||||
func (s *Server) handleCreateView(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
if len(namespace) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateViewRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "view name is required")
|
||||
return
|
||||
}
|
||||
if req.Schema == nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "view schema is required")
|
||||
return
|
||||
}
|
||||
if req.ViewVersion == nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "view-version is required")
|
||||
return
|
||||
}
|
||||
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
viewPath := path.Join(flattenNamespacePath(namespace), req.Name)
|
||||
location := strings.TrimSuffix(req.Location, "/")
|
||||
if location == "" {
|
||||
if req.Properties != nil {
|
||||
if warehouse := strings.TrimSuffix(req.Properties["warehouse"], "/"); warehouse != "" {
|
||||
location = fmt.Sprintf("%s/%s", warehouse, viewPath)
|
||||
}
|
||||
}
|
||||
if location == "" {
|
||||
if warehouse := strings.TrimSuffix(os.Getenv("ICEBERG_WAREHOUSE"), "/"); warehouse != "" {
|
||||
location = fmt.Sprintf("%s/%s", warehouse, viewPath)
|
||||
}
|
||||
}
|
||||
if location == "" {
|
||||
location = fmt.Sprintf("s3://%s/%s", bucketName, viewPath)
|
||||
}
|
||||
} else {
|
||||
parsedBucket, parsedPath, err := parseS3Location(location)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid view location: "+err.Error())
|
||||
return
|
||||
}
|
||||
if parsedPath == "" {
|
||||
location = fmt.Sprintf("s3://%s/%s", parsedBucket, viewPath)
|
||||
}
|
||||
}
|
||||
|
||||
metadataBucket, metadataPath, err := parseS3Location(location)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid view location: "+err.Error())
|
||||
return
|
||||
}
|
||||
if metadataBucket != bucketName {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "view location must be within bucket "+bucketName)
|
||||
return
|
||||
}
|
||||
if !isValidTablePath(metadataPath) {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "invalid view location path")
|
||||
return
|
||||
}
|
||||
|
||||
metadata, err := view.NewMetadata(req.ViewVersion, req.Schema, location, viewProperties(req.Properties))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to build view metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
metadataFileName := "v1.metadata.json"
|
||||
metadataLocation := fmt.Sprintf("%s/metadata/%s", location, metadataFileName)
|
||||
metadataBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize view metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Authoritative existence check before touching storage: a registered view
|
||||
// short-circuits with its stored definition (idempotent CreateView) so we
|
||||
// never overwrite the persisted metadata of an existing view.
|
||||
if existsResp, existsErr := s.getView(r, namespace, req.Name); existsErr == nil {
|
||||
result, buildErr := s.buildViewResponse(r, existsResp, bucketName, namespace, req.Name)
|
||||
if buildErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", buildErr.Error())
|
||||
return
|
||||
}
|
||||
writeLoadResult(w, http.StatusOK, result)
|
||||
return
|
||||
} else if !isViewNotFound(existsErr) {
|
||||
glog.V(1).Infof("Iceberg: CreateView existence check failed for %s.%s: %v", flattenNamespacePath(namespace), req.Name, existsErr)
|
||||
writeManagerError(w, existsErr)
|
||||
return
|
||||
}
|
||||
|
||||
createReq := &s3tables.CreateViewRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: namespace,
|
||||
Name: req.Name,
|
||||
Metadata: &s3tables.TableMetadata{
|
||||
Iceberg: &s3tables.IcebergMetadata{TableUUID: metadata.ViewUUID().String()},
|
||||
FullMetadata: metadataBytes,
|
||||
},
|
||||
MetadataLocation: metadataLocation,
|
||||
MetadataVersion: 1,
|
||||
}
|
||||
var createResp s3tables.CreateViewResponse
|
||||
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "CreateView", createReq, &createResp, identityName)
|
||||
})
|
||||
if err != nil {
|
||||
if isViewAlreadyExists(err) {
|
||||
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
|
||||
return
|
||||
}
|
||||
var viewErr *s3tables.S3TablesError
|
||||
if errors.As(err, &viewErr) && viewErr.Type == s3tables.ErrCodeNoSuchNamespace {
|
||||
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
|
||||
return
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: CreateView error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Persist the metadata file only after the catalog registers the view, so a
|
||||
// missing namespace or name collision fails before any bytes hit storage.
|
||||
if err := s.saveMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName, metadataBytes, false); err != nil {
|
||||
// Roll back the registered view so it doesn't linger pointing at metadata
|
||||
// that was never written.
|
||||
if dropErr := s.dropView(r, namespace, req.Name); dropErr != nil {
|
||||
glog.V(1).Infof("Iceberg: failed to roll back view %s.%s after metadata write error: %v", flattenNamespacePath(namespace), req.Name, dropErr)
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save view metadata file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
finalLocation := createResp.MetadataLocation
|
||||
if finalLocation == "" {
|
||||
finalLocation = metadataLocation
|
||||
}
|
||||
config, _ := s.buildFileIOConfig(r, location)
|
||||
writeLoadResult(w, http.StatusOK, ViewResponse{
|
||||
MetadataLocation: finalLocation,
|
||||
Metadata: metadata,
|
||||
Config: config,
|
||||
})
|
||||
}
|
||||
|
||||
// handleLoadView loads view metadata.
|
||||
func (s *Server) handleLoadView(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
viewName := vars["view"]
|
||||
if len(namespace) == 0 || viewName == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and view name are required")
|
||||
return
|
||||
}
|
||||
|
||||
getResp, err := s.getView(r, namespace, viewName)
|
||||
if err != nil {
|
||||
if isViewNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View does not exist: %s", viewName))
|
||||
return
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: LoadView error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.buildViewResponse(r, getResp, getBucketFromPrefix(r), namespace, viewName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
|
||||
return
|
||||
}
|
||||
writeLoadResult(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleViewExists checks if a view exists.
|
||||
func (s *Server) handleViewExists(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
viewName := vars["view"]
|
||||
if len(namespace) == 0 || viewName == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.getView(r, namespace, viewName); err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDropView deletes a view.
|
||||
func (s *Server) handleDropView(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
viewName := vars["view"]
|
||||
if len(namespace) == 0 || viewName == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and view name are required")
|
||||
return
|
||||
}
|
||||
|
||||
var storedMetadataLocation string
|
||||
if getResp, err := s.getView(r, namespace, viewName); err == nil {
|
||||
storedMetadataLocation = getResp.MetadataLocation
|
||||
}
|
||||
|
||||
err := s.dropView(r, namespace, viewName)
|
||||
if err != nil {
|
||||
if isViewNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View does not exist: %s", viewName))
|
||||
return
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: DropView error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if storedMetadataLocation != "" {
|
||||
if viewLoc := tableLocationFromMetadataLocation(storedMetadataLocation); viewLoc != "" {
|
||||
if dataBucket, dataPath, parseErr := parseS3Location(viewLoc); parseErr == nil {
|
||||
if cleanupErr := s.cleanupStaleTableLocation(r.Context(), dataBucket, dataPath); cleanupErr != nil {
|
||||
glog.V(1).Infof("Iceberg: failed to purge dropped view location s3://%s/%s: %v", dataBucket, dataPath, cleanupErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// dropView removes a view registration from the catalog.
|
||||
func (s *Server) dropView(r *http.Request, namespace []string, viewName string) error {
|
||||
deleteReq := &s3tables.DeleteViewRequest{
|
||||
TableBucketARN: buildTableBucketARN(getBucketFromPrefix(r)),
|
||||
Namespace: namespace,
|
||||
Name: viewName,
|
||||
}
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "DeleteView", deleteReq, nil, identityName)
|
||||
})
|
||||
}
|
||||
|
||||
// getView fetches a view's stored metadata pointer from the catalog.
|
||||
func (s *Server) getView(r *http.Request, namespace []string, viewName string) (s3tables.GetViewResponse, error) {
|
||||
getReq := &s3tables.GetViewRequest{
|
||||
TableBucketARN: buildTableBucketARN(getBucketFromPrefix(r)),
|
||||
Namespace: namespace,
|
||||
Name: viewName,
|
||||
}
|
||||
var getResp s3tables.GetViewResponse
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "GetView", getReq, &getResp, identityName)
|
||||
})
|
||||
return getResp, err
|
||||
}
|
||||
|
||||
// buildViewResponse parses the stored view metadata into a ViewResponse.
|
||||
func (s *Server) buildViewResponse(r *http.Request, getResp s3tables.GetViewResponse, bucketName string, namespace []string, viewName string) (ViewResponse, error) {
|
||||
if getResp.Metadata == nil || len(getResp.Metadata.FullMetadata) == 0 {
|
||||
return ViewResponse{}, fmt.Errorf("view %s has no metadata", viewName)
|
||||
}
|
||||
metadata, err := view.ParseMetadataBytes(getResp.Metadata.FullMetadata)
|
||||
if err != nil {
|
||||
return ViewResponse{}, fmt.Errorf("failed to parse view metadata: %w", err)
|
||||
}
|
||||
config, _ := s.buildFileIOConfig(r, tableLocationFromMetadataLocation(getResp.MetadataLocation))
|
||||
return ViewResponse{
|
||||
MetadataLocation: getResp.MetadataLocation,
|
||||
Metadata: metadata,
|
||||
Config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// viewProperties returns a non-nil copy of props for view metadata construction.
|
||||
func viewProperties(props iceberg.Properties) iceberg.Properties {
|
||||
out := make(iceberg.Properties, len(props))
|
||||
for k, v := range props {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isViewNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var viewErr *s3tables.S3TablesError
|
||||
if errors.As(err, &viewErr) {
|
||||
if viewErr.Type == s3tables.ErrCodeNoSuchView || viewErr.Type == s3tables.ErrCodeNoSuchNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "not found")
|
||||
}
|
||||
|
||||
func isViewAlreadyExists(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var viewErr *s3tables.S3TablesError
|
||||
if errors.As(err, &viewErr) && viewErr.Type == s3tables.ErrCodeViewAlreadyExists {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "already exists")
|
||||
}
|
||||
|
||||
// handleRenameView moves a view's catalog pointer to a new namespace/name,
|
||||
// the view counterpart of POST /v1/{prefix}/tables/rename.
|
||||
func (s *Server) handleRenameView(w http.ResponseWriter, r *http.Request) {
|
||||
var req RenameTableRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
source := parseNamespace(encodeNamespace(req.Source.Namespace))
|
||||
dest := parseNamespace(encodeNamespace(req.Destination.Namespace))
|
||||
if len(source) == 0 || req.Source.Name == "" || len(dest) == 0 || req.Destination.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "source and destination namespace and name are required")
|
||||
return
|
||||
}
|
||||
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
renameReq := &s3tables.RenameTableRequest{
|
||||
TableBucketARN: buildTableBucketARN(bucketName),
|
||||
SourceNamespace: source,
|
||||
SourceName: req.Source.Name,
|
||||
DestNamespace: dest,
|
||||
DestName: req.Destination.Name,
|
||||
}
|
||||
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "RenameView", renameReq, nil, identityName)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
var viewErr *s3tables.S3TablesError
|
||||
if errors.As(err, &viewErr) {
|
||||
switch viewErr.Type {
|
||||
case s3tables.ErrCodeNoSuchView:
|
||||
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View does not exist: %s", req.Source.Name))
|
||||
return
|
||||
case s3tables.ErrCodeNoSuchNamespace:
|
||||
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", dest))
|
||||
return
|
||||
case s3tables.ErrCodeViewAlreadyExists:
|
||||
writeError(w, http.StatusConflict, "AlreadyExistsException", fmt.Sprintf("View already exists: %s", req.Destination.Name))
|
||||
return
|
||||
case s3tables.ErrCodeInvalidRequest:
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", viewErr.Message)
|
||||
return
|
||||
}
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: RenameView error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
165
weed/s3api/iceberg/handlers_view_update.go
Normal file
165
weed/s3api/iceberg/handlers_view_update.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/iceberg-go/view"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// handleUpdateView applies requirements and updates to a view, writes a new
|
||||
// metadata.json, and flips the stored pointer. Mirrors the table commit flow.
|
||||
func (s *Server) handleUpdateView(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
namespace := parseNamespace(vars["namespace"])
|
||||
viewName := vars["view"]
|
||||
if len(namespace) == 0 || viewName == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and view name are required")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateViewRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bucketName := getBucketFromPrefix(r)
|
||||
bucketARN := buildTableBucketARN(bucketName)
|
||||
identityName := s3_constants.GetIdentityNameFromContext(r)
|
||||
|
||||
const maxCommitAttempts = 3
|
||||
for attempt := 1; attempt <= maxCommitAttempts; attempt++ {
|
||||
getResp, err := s.getView(r, namespace, viewName)
|
||||
if err != nil {
|
||||
if isViewNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "NoSuchViewException", fmt.Sprintf("View does not exist: %s", viewName))
|
||||
return
|
||||
}
|
||||
glog.V(1).Infof("Iceberg: UpdateView GetView error: %v", err)
|
||||
writeManagerError(w, err)
|
||||
return
|
||||
}
|
||||
if getResp.Metadata == nil || len(getResp.Metadata.FullMetadata) == 0 {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "view has no metadata")
|
||||
return
|
||||
}
|
||||
|
||||
currentMetadata, err := view.ParseMetadataBytes(getResp.Metadata.FullMetadata)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse current view metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for _, requirement := range req.Requirements {
|
||||
if err := requirement.Validate(currentMetadata); err != nil {
|
||||
writeError(w, http.StatusConflict, "CommitFailedException", "Requirement failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
builder, err := view.MetadataBuilderFromBase(currentMetadata)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to create view metadata builder: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, update := range req.Updates {
|
||||
if err := update.Apply(builder); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to apply update: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
newMetadata, err := builder.Build()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to build new view metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
location := tableLocationFromMetadataLocation(getResp.MetadataLocation)
|
||||
if location == "" {
|
||||
location = newMetadata.Location()
|
||||
}
|
||||
metadataVersion := getResp.MetadataVersion + 1
|
||||
metadataFileName := fmt.Sprintf("v%d.metadata.json", metadataVersion)
|
||||
newMetadataLocation := fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(location, "/"), metadataFileName)
|
||||
|
||||
metadataBytes, err := json.Marshal(newMetadata)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize view metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
metadataBucket, metadataPath, err := parseS3Location(location)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid view location: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := confineMetadataLocation(metadataBucket, metadataPath, bucketName); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
|
||||
return
|
||||
}
|
||||
metadataFileName, newMetadataLocation, err = s.stageCommitMetadata(r.Context(), metadataBucket, metadataPath, location, metadataFileName, metadataBytes)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save view metadata file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tableUUID := newMetadata.ViewUUID()
|
||||
if tableUUID == uuid.Nil {
|
||||
tableUUID = currentMetadata.ViewUUID()
|
||||
}
|
||||
updateReq := &s3tables.UpdateViewRequest{
|
||||
TableBucketARN: bucketARN,
|
||||
Namespace: namespace,
|
||||
Name: viewName,
|
||||
VersionToken: getResp.VersionToken,
|
||||
Metadata: &s3tables.TableMetadata{
|
||||
Iceberg: &s3tables.IcebergMetadata{TableUUID: tableUUID.String()},
|
||||
FullMetadata: metadataBytes,
|
||||
},
|
||||
MetadataVersion: metadataVersion,
|
||||
MetadataLocation: newMetadataLocation,
|
||||
}
|
||||
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
mgrClient := s3tables.NewManagerClient(client)
|
||||
return s.tablesManager.Execute(r.Context(), mgrClient, "UpdateView", updateReq, nil, identityName)
|
||||
})
|
||||
if err == nil {
|
||||
config, _ := s.buildFileIOConfig(r, location)
|
||||
writeLoadResult(w, http.StatusOK, ViewResponse{
|
||||
MetadataLocation: newMetadataLocation,
|
||||
Metadata: newMetadata,
|
||||
Config: config,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if isS3TablesConflict(err) {
|
||||
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
|
||||
glog.V(1).Infof("Iceberg: failed to cleanup view metadata file %s on conflict: %v", newMetadataLocation, cleanupErr)
|
||||
}
|
||||
if attempt < maxCommitAttempts {
|
||||
glog.V(1).Infof("Iceberg: UpdateView conflict for %s (attempt %d/%d), retrying", viewName, attempt, maxCommitAttempts)
|
||||
sleepBeforeCommitRetry(attempt)
|
||||
continue
|
||||
}
|
||||
writeError(w, http.StatusConflict, "CommitFailedException", "Version token mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
|
||||
glog.V(1).Infof("Iceberg: failed to cleanup view metadata file %s after update failure: %v", newMetadataLocation, cleanupErr)
|
||||
}
|
||||
glog.Errorf("Iceberg: UpdateView error: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to commit view update: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
4
weed/s3api/iceberg/iceberg.go
Normal file
4
weed/s3api/iceberg/iceberg.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package iceberg provides Iceberg REST Catalog API support.
|
||||
// It implements the Apache Iceberg REST Catalog specification
|
||||
// backed by S3 Tables metadata storage.
|
||||
package iceberg
|
||||
79
weed/s3api/iceberg/iceberg_access_delegation_test.go
Normal file
79
weed/s3api/iceberg/iceberg_access_delegation_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
func TestWantsVendedCredentials(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values []string
|
||||
want bool
|
||||
}{
|
||||
{name: "no header", want: false},
|
||||
{name: "vended credentials", values: []string{"vended-credentials"}, want: true},
|
||||
{name: "mechanism list", values: []string{"remote-signing,vended-credentials"}, want: true},
|
||||
{name: "spaced and mixed case", values: []string{"Remote-Signing, Vended-Credentials"}, want: true},
|
||||
{name: "repeated header", values: []string{"remote-signing", "vended-credentials"}, want: true},
|
||||
{name: "remote signing only", values: []string{"remote-signing"}, want: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
|
||||
for _, v := range tc.values {
|
||||
r.Header.Add(accessDelegationHeader, v)
|
||||
}
|
||||
if got := wantsVendedCredentials(r); got != tc.want {
|
||||
t.Fatalf("wantsVendedCredentials(%v) = %v, want %v", tc.values, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A client that asked for vended credentials replaces its own storage
|
||||
// credentials with whatever the catalog returns. With no vending configured
|
||||
// there are none, so an endpoint by itself would leave the client signing
|
||||
// nothing and every data file read and write would come back 403.
|
||||
func TestBuildFileIOConfigWithholdsEndpointFromCredentialVendingClients(t *testing.T) {
|
||||
s := &Server{s3Endpoint: "http://seaweed.example:8333"}
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
|
||||
r.Header.Set(accessDelegationHeader, "vended-credentials")
|
||||
if got, _ := s.buildFileIOConfig(r, "s3://warehouse/ns/t"); len(got) != 0 {
|
||||
t.Fatalf("buildFileIOConfig() = %v, want empty for a vended-credentials request", got)
|
||||
}
|
||||
|
||||
plain := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
|
||||
if got, _ := s.buildFileIOConfig(plain, "s3://warehouse/ns/t"); got["s3.endpoint"] != s.s3Endpoint {
|
||||
t.Fatalf("s3.endpoint = %q, want %q", got["s3.endpoint"], s.s3Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// The body of a load response depends on the delegation header, so a cache in
|
||||
// front of the catalog must not key on the URL alone.
|
||||
func TestWriteLoadResultVariesOnTheDelegationHeader(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
writeLoadResult(rec, http.StatusOK, LoadTableResult{})
|
||||
if got := rec.Header().Get("Vary"); got != accessDelegationHeader {
|
||||
t.Fatalf("Vary = %q, want %q", got, accessDelegationHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTableResultOmitsConfigForCredentialVendingClients(t *testing.T) {
|
||||
s := &Server{s3Endpoint: "http://seaweed.example:8333"}
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
|
||||
r.Header.Set(accessDelegationHeader, "vended-credentials")
|
||||
|
||||
getResp := s3tables.GetTableResponse{MetadataLocation: "s3://bkt/ns/t/metadata/v1.metadata.json"}
|
||||
result, err := s.buildLoadTableResult(r, getResp, "bkt", []string{"ns"}, "t")
|
||||
if err != nil {
|
||||
t.Fatalf("buildLoadTableResult() error = %v", err)
|
||||
}
|
||||
if len(result.Config) != 0 {
|
||||
t.Fatalf("LoadTableResult.Config = %v, want empty", result.Config)
|
||||
}
|
||||
}
|
||||
97
weed/s3api/iceberg/iceberg_commit_location_test.go
Normal file
97
weed/s3api/iceberg/iceberg_commit_location_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// seedPoisonedTable registers a table entry whose stored metadataLocation
|
||||
// points outside its own bucket via a ".." segment, simulating a value
|
||||
// persisted verbatim by the raw S3Tables UpdateTable API.
|
||||
func seedPoisonedTable(t *testing.T, fc *memFiler, bucket, namespace, tableName, metadataLocation string) {
|
||||
t.Helper()
|
||||
meta := s3tables.TableMetadata{
|
||||
Iceberg: &s3tables.IcebergMetadata{TableUUID: "00000000-0000-0000-0000-000000000001"},
|
||||
}
|
||||
internal := map[string]any{
|
||||
"name": tableName,
|
||||
"namespace": namespace,
|
||||
"format": "ICEBERG",
|
||||
"ownerAccountId": s3_constants.AccountAdminId,
|
||||
"versionToken": "v1",
|
||||
"metadataVersion": 1,
|
||||
"metadataLocation": metadataLocation,
|
||||
"metadata": meta,
|
||||
}
|
||||
metaBytes, _ := json.Marshal(internal)
|
||||
fc.seed(s3tables.GetTablePath(bucket, namespace, tableName), &filer_pb.Entry{
|
||||
Name: tableName,
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{s3tables.ExtendedKeyMetadata: metaBytes},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCommitTableRejectsStoredTraversalLocation(t *testing.T) {
|
||||
const attacker = "attacker"
|
||||
const victim = "victim"
|
||||
fc := newMemFiler()
|
||||
seedNamespace(fc, attacker, "ns", s3_constants.AccountAdminId)
|
||||
seedPoisonedTable(t, fc, attacker, "ns", "t",
|
||||
"s3://attacker/../victim/planted/metadata/v1.metadata.json")
|
||||
|
||||
s := NewServer(fc, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/"+attacker+"/namespaces/ns/tables/t",
|
||||
strings.NewReader(`{"requirements":[],"updates":[]}`))
|
||||
r = mux.SetURLVars(r, map[string]string{"prefix": attacker, "namespace": "ns", "table": "t"})
|
||||
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), s3_constants.AccountAdminId))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleUpdateTable(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", w.Code, http.StatusBadRequest, w.Body.String())
|
||||
}
|
||||
for p := range fc.entries {
|
||||
if strings.Contains(p, "/"+victim+"/") && !strings.Contains(p, "/"+attacker+"/") {
|
||||
t.Fatalf("cross-tenant write escaped into victim tree at %s (status=%d body=%s)", p, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitTransactionRejectsStoredTraversalLocation(t *testing.T) {
|
||||
const attacker = "attacker"
|
||||
const victim = "victim"
|
||||
fc := newMemFiler()
|
||||
seedNamespace(fc, attacker, "ns", s3_constants.AccountAdminId)
|
||||
seedPoisonedTable(t, fc, attacker, "ns", "t",
|
||||
"s3://attacker/../victim/planted/metadata/v1.metadata.json")
|
||||
|
||||
s := NewServer(fc, nil)
|
||||
|
||||
body := `{"table-changes":[{"identifier":{"namespace":["ns"],"name":"t"},"requirements":[],"updates":[]}]}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/"+attacker+"/transactions/commit",
|
||||
strings.NewReader(body))
|
||||
r = mux.SetURLVars(r, map[string]string{"prefix": attacker})
|
||||
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), s3_constants.AccountAdminId))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleCommitTransaction(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", w.Code, http.StatusBadRequest, w.Body.String())
|
||||
}
|
||||
for p := range fc.entries {
|
||||
if strings.Contains(p, "/"+victim+"/") && !strings.Contains(p, "/"+attacker+"/") {
|
||||
t.Fatalf("cross-tenant transaction write escaped into victim tree at %s (status=%d body=%s)", p, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
134
weed/s3api/iceberg/iceberg_commit_updates_test.go
Normal file
134
weed/s3api/iceberg/iceberg_commit_updates_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
func TestParseCommitUpdatesSeparatesStatistics(t *testing.T) {
|
||||
raw := []json.RawMessage{
|
||||
json.RawMessage(`{"action":"set-statistics","snapshot-id":10,"statistics-path":"s3://bucket/table/metadata/stats.puffin","file-size-in-bytes":100,"file-footer-size-in-bytes":20,"blob-metadata":[]}`),
|
||||
json.RawMessage(`{"action":"set-properties","updates":{"k":"v"}}`),
|
||||
}
|
||||
|
||||
updates, stats, err := parseCommitUpdates(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseCommitUpdates() error = %v", err)
|
||||
}
|
||||
if len(stats) != 1 {
|
||||
t.Fatalf("statistics updates = %d, want 1", len(stats))
|
||||
}
|
||||
if stats[0].set == nil || stats[0].set.SnapshotID != 10 {
|
||||
t.Fatalf("unexpected statistics update: %#v", stats[0])
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("decoded updates = %d, want 1", len(updates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommitUpdatesRejectsIncompleteSetStatistics(t *testing.T) {
|
||||
raw := []json.RawMessage{
|
||||
json.RawMessage(`{"action":"set-statistics","snapshot-id":10}`),
|
||||
}
|
||||
|
||||
_, _, err := parseCommitUpdates(raw)
|
||||
if err == nil {
|
||||
t.Fatalf("parseCommitUpdates() expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrIncompleteSetStatistics) {
|
||||
t.Fatalf("parseCommitUpdates() error = %v, want ErrIncompleteSetStatistics", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyStatisticsUpdatesUpsertAndRemove(t *testing.T) {
|
||||
metadata := []byte(`{"format-version":2,"statistics":[{"snapshot-id":1,"statistics-path":"s3://bucket/stats-1.puffin","file-size-in-bytes":10,"file-footer-size-in-bytes":1,"blob-metadata":[]},{"snapshot-id":2,"statistics-path":"s3://bucket/stats-2.puffin","file-size-in-bytes":20,"file-footer-size-in-bytes":2,"blob-metadata":[]}]} `)
|
||||
|
||||
snapshotID := int64(2)
|
||||
setUpdates := []statisticsUpdate{
|
||||
{
|
||||
set: &statisticsFileForTest,
|
||||
},
|
||||
{
|
||||
remove: &snapshotID,
|
||||
},
|
||||
}
|
||||
|
||||
updated, err := applyStatisticsUpdates(metadata, setUpdates)
|
||||
if err != nil {
|
||||
t.Fatalf("applyStatisticsUpdates() error = %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal(updated, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal(updated) error = %v", err)
|
||||
}
|
||||
|
||||
var stats []map[string]any
|
||||
if err := json.Unmarshal(decoded["statistics"], &stats); err != nil {
|
||||
t.Fatalf("json.Unmarshal(statistics) error = %v", err)
|
||||
}
|
||||
if len(stats) != 1 {
|
||||
t.Fatalf("statistics length = %d, want 1", len(stats))
|
||||
}
|
||||
if got := int64(stats[0]["snapshot-id"].(float64)); got != 1 {
|
||||
t.Fatalf("remaining snapshot-id = %d, want 1", got)
|
||||
}
|
||||
if got := int64(stats[0]["file-size-in-bytes"].(float64)); got != 11 {
|
||||
t.Fatalf("remaining file-size-in-bytes = %d, want 11", got)
|
||||
}
|
||||
}
|
||||
|
||||
var statisticsFileForTest = table.StatisticsFile{
|
||||
SnapshotID: 1,
|
||||
StatisticsPath: "s3://bucket/stats-1.puffin",
|
||||
FileSizeInBytes: 11,
|
||||
FileFooterSizeInBytes: 2,
|
||||
BlobMetadata: []table.BlobMetadata{},
|
||||
}
|
||||
|
||||
func TestIsS3TablesConflict(t *testing.T) {
|
||||
if !isS3TablesConflict(s3tables.ErrVersionTokenMismatch) {
|
||||
t.Fatalf("expected ErrVersionTokenMismatch to be conflict")
|
||||
}
|
||||
if !isS3TablesConflict(&s3tables.S3TablesError{Type: s3tables.ErrCodeConflict, Message: "Version token mismatch"}) {
|
||||
t.Fatalf("expected S3Tables conflict error to be conflict")
|
||||
}
|
||||
if isS3TablesConflict(errors.New("other")) {
|
||||
t.Fatalf("unexpected conflict for non-conflict error")
|
||||
}
|
||||
}
|
||||
|
||||
// A create-on-commit is a create, and it writes the new table's metadata file
|
||||
// before the registration that authorizes it.
|
||||
func TestCreateOnCommitDeniedBeforeAnyWrite(t *testing.T) {
|
||||
const bucket = "warehouse"
|
||||
fc := newMemFiler()
|
||||
seedNamespace(fc, bucket, "finance", "alice")
|
||||
s := NewServer(fc, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/"+bucket+"/namespaces/finance/tables/quarterly_reports",
|
||||
strings.NewReader(`{"requirements":[{"type":"assert-create"}],"updates":[]}`))
|
||||
r = mux.SetURLVars(r, map[string]string{"prefix": bucket, "namespace": "finance", "table": "quarterly_reports"})
|
||||
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), "mallory"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleUpdateTable(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (body: %s)", w.Code, http.StatusForbidden, w.Body.String())
|
||||
}
|
||||
for p, entry := range fc.entries {
|
||||
if !entry.IsDirectory && strings.Contains(p, "quarterly_reports") {
|
||||
t.Errorf("unauthorized caller wrote %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
200
weed/s3api/iceberg/iceberg_create_table_test.go
Normal file
200
weed/s3api/iceberg/iceberg_create_table_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
func TestValidateCreateTableRequestRequiresName(t *testing.T) {
|
||||
err := validateCreateTableRequest(CreateTableRequest{})
|
||||
if !errors.Is(err, errTableNameRequired) {
|
||||
t.Fatalf("validateCreateTableRequest() error = %v, want errTableNameRequired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCreateTableRequestAcceptsWithName(t *testing.T) {
|
||||
err := validateCreateTableRequest(CreateTableRequest{Name: "orders"})
|
||||
if err != nil {
|
||||
t.Fatalf("validateCreateTableRequest() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStageCreateEnabledDefaultsToTrue(t *testing.T) {
|
||||
t.Setenv("ICEBERG_ENABLE_STAGE_CREATE", "")
|
||||
if !isStageCreateEnabled() {
|
||||
t.Fatalf("isStageCreateEnabled() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStageCreateEnabledFalseValues(t *testing.T) {
|
||||
falseValues := []string{"0", "false", "FALSE", "no", "off"}
|
||||
for _, value := range falseValues {
|
||||
t.Setenv("ICEBERG_ENABLE_STAGE_CREATE", value)
|
||||
if isStageCreateEnabled() {
|
||||
t.Fatalf("isStageCreateEnabled() = true for value %q, want false", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseSchema(t *testing.T, raw string) *iceberg.Schema {
|
||||
t.Helper()
|
||||
var schema iceberg.Schema
|
||||
if err := json.Unmarshal([]byte(raw), &schema); err != nil {
|
||||
t.Fatalf("parse schema: %v", err)
|
||||
}
|
||||
return &schema
|
||||
}
|
||||
|
||||
const variantSchema = `{"type":"struct","schema-id":0,"fields":[
|
||||
{"id":1,"name":"id","required":true,"type":"long"},
|
||||
{"id":2,"name":"payload","required":false,"type":"variant"}]}`
|
||||
|
||||
// A v3-only column type without format-version 3 is the client's mistake, and
|
||||
// the reason has to travel back to them rather than only into the server log.
|
||||
func TestNewTableMetadataRejectsV3TypeBelowV3(t *testing.T) {
|
||||
_, err := newTableMetadata(uuid.New(), "s3://bkt/ns/t", mustParseSchema(t, variantSchema), nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("newTableMetadata() error = nil, want invalid schema")
|
||||
}
|
||||
if !errors.Is(err, iceberg.ErrInvalidSchema) {
|
||||
t.Fatalf("newTableMetadata() error = %v, want ErrInvalidSchema", err)
|
||||
}
|
||||
|
||||
// writeManagerError turns this into a 400; see TestWriteManagerError.
|
||||
if !strings.Contains(err.Error(), "variant is not supported until v3") {
|
||||
t.Errorf("error = %q, want the underlying reason", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTableMetadataAcceptsV3TypeAtV3(t *testing.T) {
|
||||
metadata, err := newTableMetadata(uuid.New(), "s3://bkt/ns/t", mustParseSchema(t, variantSchema), nil, nil,
|
||||
iceberg.Properties{"format-version": "3"})
|
||||
if err != nil {
|
||||
t.Fatalf("newTableMetadata() error = %v, want nil", err)
|
||||
}
|
||||
if got := metadata.Version(); got != 3 {
|
||||
t.Fatalf("metadata.Version() = %d, want 3", got)
|
||||
}
|
||||
if _, found := metadata.CurrentSchema().FindFieldByName("payload"); !found {
|
||||
t.Error("variant field missing from stored schema")
|
||||
}
|
||||
}
|
||||
|
||||
// A LoadTable response must never carry nil metadata: it serializes as
|
||||
// "metadata":null under HTTP 200, which no Iceberg client can parse.
|
||||
func TestBuildLoadTableResultNeverReturnsNilMetadata(t *testing.T) {
|
||||
cases := map[string]s3tables.GetTableResponse{
|
||||
"no stored metadata": {MetadataLocation: "s3://bkt/ns/t/metadata/v1.metadata.json"},
|
||||
"empty full metadata": {Metadata: &s3tables.TableMetadata{}},
|
||||
"unparseable metadata": {Metadata: &s3tables.TableMetadata{FullMetadata: json.RawMessage(`{"nope":`)}},
|
||||
}
|
||||
for name, getResp := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
|
||||
result, err := (&Server{}).buildLoadTableResult(r, getResp, "bkt", []string{"ns"}, "t")
|
||||
if err != nil {
|
||||
t.Fatalf("buildLoadTableResult() error = %v, want nil", err)
|
||||
}
|
||||
if result.Metadata == nil {
|
||||
t.Fatal("buildLoadTableResult() returned nil metadata with no error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newCreateTableRequest(t *testing.T, bucket, namespace, body, identity string) *http.Request {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/"+bucket+"/namespaces/"+namespace+"/tables", strings.NewReader(body))
|
||||
r = mux.SetURLVars(r, map[string]string{"prefix": bucket, "namespace": namespace})
|
||||
return r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), identity))
|
||||
}
|
||||
|
||||
// A caller who may not create the table must be refused before anything is
|
||||
// written: both branches of CreateTable write to the bucket, and stage-create
|
||||
// never reaches the registration that carries the authorization.
|
||||
func TestCreateTableDeniedBeforeAnyWrite(t *testing.T) {
|
||||
const bucket = "warehouse"
|
||||
for name, body := range map[string]string{
|
||||
"stage-create": `{"name":"quarterly_reports","stage-create":true}`,
|
||||
"create": `{"name":"quarterly_reports"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fc := newMemFiler()
|
||||
seedNamespace(fc, bucket, "finance", "alice")
|
||||
s := NewServer(fc, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleCreateTable(w, newCreateTableRequest(t, bucket, "finance", body, "mallory"))
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (body: %s)", w.Code, http.StatusForbidden, w.Body.String())
|
||||
}
|
||||
for p, entry := range fc.entries {
|
||||
if !entry.IsDirectory && strings.Contains(p, "quarterly_reports") {
|
||||
t.Errorf("unauthorized caller wrote %s", p)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The namespace owner still gets the staged metadata and marker stage-create
|
||||
// exists to leave behind.
|
||||
func TestStageCreateWritesStagedFilesForOwner(t *testing.T) {
|
||||
const bucket = "warehouse"
|
||||
fc := newMemFiler()
|
||||
seedNamespace(fc, bucket, "finance", "alice")
|
||||
s := NewServer(fc, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleCreateTable(w, newCreateTableRequest(t, bucket, "finance", `{"name":"quarterly_reports","stage-create":true}`, "alice"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
|
||||
staged := 0
|
||||
for p, entry := range fc.entries {
|
||||
if !entry.IsDirectory && strings.Contains(p, stageCreateMarkerDirName) {
|
||||
staged++
|
||||
}
|
||||
}
|
||||
if staged != 2 {
|
||||
t.Fatalf("staged files = %d, want the metadata file and its marker", staged)
|
||||
}
|
||||
if _, ok := fc.entries[s3tables.GetTablePath(bucket, "finance", "quarterly_reports")]; ok {
|
||||
t.Error("stage-create registered the table")
|
||||
}
|
||||
}
|
||||
|
||||
// A caller allowed by the actions on its own identity, rather than by ownership
|
||||
// or a resource policy, has to survive the hop into the s3tables manager: the
|
||||
// gate must not refuse what the create behind it would allow.
|
||||
func TestCreateTableAllowedByIdentityActions(t *testing.T) {
|
||||
const bucket = "warehouse"
|
||||
fc := newMemFiler()
|
||||
seedNamespace(fc, bucket, "finance", "alice")
|
||||
s := NewServer(fc, nil)
|
||||
|
||||
r := newCreateTableRequest(t, bucket, "finance", `{"name":"quarterly_reports","stage-create":true}`, "mallory")
|
||||
r = r.WithContext(s3_constants.SetIdentityInContext(r.Context(), &struct {
|
||||
Name string
|
||||
Actions []string
|
||||
}{Name: "mallory", Actions: []string{"s3tables:CreateTable"}}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleCreateTable(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
}
|
||||
233
weed/s3api/iceberg/iceberg_create_view_test.go
Normal file
233
weed/s3api/iceberg/iceberg_create_view_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/apache/iceberg-go/view"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// memFiler is a minimal in-memory SeaweedFilerClient backing the view handlers
|
||||
// end-to-end through the s3tables manager. Only the entry operations the create
|
||||
// path touches are implemented; anything else panics so a missing dependency is
|
||||
// loud rather than silently passing.
|
||||
type memFiler struct {
|
||||
filer_pb.SeaweedFilerClient
|
||||
entries map[string]*filer_pb.Entry
|
||||
// failFileCreate, when set, makes CreateEntry fail for non-directory entries,
|
||||
// simulating a metadata-file write error.
|
||||
failFileCreate error
|
||||
}
|
||||
|
||||
func newMemFiler() *memFiler {
|
||||
return &memFiler{entries: map[string]*filer_pb.Entry{}}
|
||||
}
|
||||
|
||||
func (m *memFiler) WithFilerClient(_ bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(m)
|
||||
}
|
||||
|
||||
func (m *memFiler) seed(p string, entry *filer_pb.Entry) {
|
||||
m.entries[p] = entry
|
||||
}
|
||||
|
||||
func (m *memFiler) LookupDirectoryEntry(_ context.Context, in *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
entry, ok := m.entries[path.Join(in.Directory, in.Name)]
|
||||
if !ok {
|
||||
return nil, filer_pb.ErrNotFound
|
||||
}
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil
|
||||
}
|
||||
|
||||
func (m *memFiler) CreateEntry(_ context.Context, in *filer_pb.CreateEntryRequest, _ ...grpc.CallOption) (*filer_pb.CreateEntryResponse, error) {
|
||||
if m.failFileCreate != nil && !in.Entry.IsDirectory {
|
||||
return nil, m.failFileCreate
|
||||
}
|
||||
m.entries[path.Join(in.Directory, in.Entry.Name)] = in.Entry
|
||||
return &filer_pb.CreateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (m *memFiler) DeleteEntry(_ context.Context, in *filer_pb.DeleteEntryRequest, _ ...grpc.CallOption) (*filer_pb.DeleteEntryResponse, error) {
|
||||
prefix := path.Join(in.Directory, in.Name)
|
||||
for p := range m.entries {
|
||||
if p == prefix || strings.HasPrefix(p, prefix+"/") {
|
||||
delete(m.entries, p)
|
||||
}
|
||||
}
|
||||
return &filer_pb.DeleteEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (m *memFiler) UpdateEntry(_ context.Context, in *filer_pb.UpdateEntryRequest, _ ...grpc.CallOption) (*filer_pb.UpdateEntryResponse, error) {
|
||||
m.entries[path.Join(in.Directory, in.Entry.Name)] = in.Entry
|
||||
return &filer_pb.UpdateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
func (m *memFiler) ListEntries(_ context.Context, in *filer_pb.ListEntriesRequest, _ ...grpc.CallOption) (filer_pb.SeaweedFiler_ListEntriesClient, error) {
|
||||
var names []string
|
||||
for p := range m.entries {
|
||||
if path.Dir(p) == in.Directory {
|
||||
names = append(names, p)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
stream := &memListEntries{}
|
||||
for _, name := range names {
|
||||
stream.entries = append(stream.entries, m.entries[name])
|
||||
}
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
type memListEntries struct {
|
||||
grpc.ClientStream
|
||||
entries []*filer_pb.Entry
|
||||
}
|
||||
|
||||
func (m *memListEntries) Recv() (*filer_pb.ListEntriesResponse, error) {
|
||||
if len(m.entries) == 0 {
|
||||
return nil, io.EOF
|
||||
}
|
||||
entry := m.entries[0]
|
||||
m.entries = m.entries[1:]
|
||||
return &filer_pb.ListEntriesResponse{Entry: entry}, nil
|
||||
}
|
||||
|
||||
func newCreateViewRequest(t *testing.T, namespace, name, sql string) *http.Request {
|
||||
t.Helper()
|
||||
schema := iceberg.NewSchemaWithIdentifiers(0, nil,
|
||||
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int32, Required: true},
|
||||
)
|
||||
ver, err := view.NewVersionFromSQL(1, schema.ID, sql, table.Identifier{namespace})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(CreateViewRequest{Name: name, Schema: schema, ViewVersion: ver})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/namespaces/"+namespace+"/views", bytes.NewReader(body))
|
||||
r = mux.SetURLVars(r, map[string]string{"namespace": namespace})
|
||||
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), s3_constants.AccountAdminId))
|
||||
return r
|
||||
}
|
||||
|
||||
// seedNamespace registers a bucket and namespace owned by owner so the s3tables
|
||||
// existence and auth-context lookups pass.
|
||||
func seedNamespace(fc *memFiler, bucket, namespace, owner string) {
|
||||
fc.seed(s3tables.GetTableBucketPath(bucket), &filer_pb.Entry{Name: bucket, IsDirectory: true})
|
||||
meta, _ := json.Marshal(map[string]any{"namespace": []string{namespace}, "ownerAccountId": owner})
|
||||
fc.seed(s3tables.GetNamespacePath(bucket, namespace), &filer_pb.Entry{
|
||||
Name: namespace,
|
||||
IsDirectory: true,
|
||||
Extended: map[string][]byte{s3tables.ExtendedKeyMetadata: meta},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateViewMissingNamespaceReturns404(t *testing.T) {
|
||||
fc := newMemFiler()
|
||||
s := NewServer(fc, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1"))
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusNotFound, w.Body.String())
|
||||
}
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil {
|
||||
t.Fatalf("unmarshal error body: %v", err)
|
||||
}
|
||||
if errResp.Error.Type != "NoSuchNamespaceException" {
|
||||
t.Fatalf("error type = %q, want NoSuchNamespaceException", errResp.Error.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateViewTagsEntryAsView(t *testing.T) {
|
||||
const bucket = "warehouse"
|
||||
fc := newMemFiler()
|
||||
seedNamespace(fc, bucket, "ns", s3_constants.AccountAdminId)
|
||||
s := NewServer(fc, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create status = %d, want 200 (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
entry, ok := fc.entries[s3tables.GetTablePath(bucket, "ns", "v")]
|
||||
if !ok {
|
||||
t.Fatalf("view entry not created")
|
||||
}
|
||||
if got := string(entry.Extended[s3tables.ExtendedKeyEntryType]); got != s3tables.EntryTypeView {
|
||||
t.Fatalf("entryType = %q, want %q", got, s3tables.EntryTypeView)
|
||||
}
|
||||
if _, ok := entry.Extended[s3tables.ExtendedKeyMetadata]; !ok {
|
||||
t.Fatalf("view entry missing metadata attribute")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateViewDuplicateDoesNotClobberMetadata(t *testing.T) {
|
||||
const bucket = "warehouse"
|
||||
fc := newMemFiler()
|
||||
seedNamespace(fc, bucket, "ns", s3_constants.AccountAdminId)
|
||||
s := NewServer(fc, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("first create status = %d, want 200 (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
metadataKey := path.Join(s3tables.GetTablePath(bucket, "ns", "v"), "metadata", "v1.metadata.json")
|
||||
first, ok := fc.entries[metadataKey]
|
||||
if !ok {
|
||||
t.Fatalf("metadata file not written at %s", metadataKey)
|
||||
}
|
||||
original := append([]byte(nil), first.Content...)
|
||||
|
||||
// Re-create the same view with different SQL: the existence pre-check must
|
||||
// short-circuit so the persisted v1.metadata.json stays byte-for-byte intact.
|
||||
w = httptest.NewRecorder()
|
||||
s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 2"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("duplicate create status = %d, want 200 (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := fc.entries[metadataKey].Content; !bytes.Equal(got, original) {
|
||||
t.Fatalf("duplicate create clobbered stored metadata:\n got %s\nwant %s", got, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateViewRollsBackEntryWhenMetadataWriteFails(t *testing.T) {
|
||||
const bucket = "warehouse"
|
||||
fc := newMemFiler()
|
||||
seedNamespace(fc, bucket, "ns", s3_constants.AccountAdminId)
|
||||
fc.failFileCreate = errors.New("disk full")
|
||||
s := NewServer(fc, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.handleCreateView(w, newCreateViewRequest(t, "ns", "v", "SELECT 1"))
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusInternalServerError, w.Body.String())
|
||||
}
|
||||
|
||||
// The registered view must be rolled back so it doesn't linger pointing at
|
||||
// metadata that was never written.
|
||||
if _, ok := fc.entries[s3tables.GetTablePath(bucket, "ns", "v")]; ok {
|
||||
t.Fatalf("view entry left behind after metadata write failure")
|
||||
}
|
||||
}
|
||||
111
weed/s3api/iceberg/iceberg_credential_vending_test.go
Normal file
111
weed/s3api/iceberg/iceberg_credential_vending_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type stubVendor struct {
|
||||
credentials *VendedCredentials
|
||||
err error
|
||||
gotBucket string
|
||||
gotPrefix string
|
||||
gotcaller string
|
||||
}
|
||||
|
||||
func (v *stubVendor) VendTableCredentials(_ context.Context, principal, bucket, prefix string) (*VendedCredentials, error) {
|
||||
v.gotcaller, v.gotBucket, v.gotPrefix = principal, bucket, prefix
|
||||
return v.credentials, v.err
|
||||
}
|
||||
|
||||
func vendingServer(vendor CredentialVendor) *Server {
|
||||
s := &Server{s3Endpoint: "http://s3.example:8333"}
|
||||
s.SetCredentialVendor(vendor)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestBuildFileIOConfigVendingVendsScopedCredentials(t *testing.T) {
|
||||
expiry := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||
vendor := &stubVendor{credentials: &VendedCredentials{
|
||||
AccessKeyID: "ASIAEXAMPLE",
|
||||
SecretAccessKey: "secret",
|
||||
SessionToken: "token",
|
||||
Expiration: expiry,
|
||||
}}
|
||||
s := vendingServer(vendor)
|
||||
|
||||
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
|
||||
r.Header.Set(accessDelegationHeader, "vended-credentials")
|
||||
|
||||
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
|
||||
|
||||
if vendor.gotBucket != "warehouse" || vendor.gotPrefix != "ns/t" {
|
||||
t.Errorf("vendor called with bucket=%q prefix=%q, want warehouse and ns/t", vendor.gotBucket, vendor.gotPrefix)
|
||||
}
|
||||
if config["s3.access-key-id"] != "ASIAEXAMPLE" || config["s3.secret-access-key"] != "secret" || config["s3.session-token"] != "token" {
|
||||
t.Errorf("config missing vended credentials: %v", config)
|
||||
}
|
||||
if config["s3.endpoint"] != "http://s3.example:8333" {
|
||||
t.Errorf("config missing the endpoint the credentials are for: %v", config)
|
||||
}
|
||||
if len(storageCredentials) != 1 {
|
||||
t.Fatalf("storage-credentials = %d entries, want 1", len(storageCredentials))
|
||||
}
|
||||
if storageCredentials[0].Prefix != "s3://warehouse/ns/t" {
|
||||
t.Errorf("storage credential prefix = %q", storageCredentials[0].Prefix)
|
||||
}
|
||||
if storageCredentials[0].Config["s3.session-token"] != "token" {
|
||||
t.Errorf("storage credential is missing the session token: %v", storageCredentials[0].Config)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a vendor the response must stay silent about the endpoint too: a
|
||||
// client that asked for vended credentials would otherwise drop its own and
|
||||
// start sending unsigned requests.
|
||||
func TestBuildFileIOConfigVendingWithoutVendorSaysNothing(t *testing.T) {
|
||||
s := &Server{s3Endpoint: "http://s3.example:8333"}
|
||||
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
|
||||
r.Header.Set(accessDelegationHeader, "vended-credentials")
|
||||
|
||||
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
|
||||
if len(config) != 0 || storageCredentials != nil {
|
||||
t.Errorf("config = %v, storage-credentials = %v, want both empty", config, storageCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileIOConfigVendingFallsBackWhenVendingFails(t *testing.T) {
|
||||
s := vendingServer(&stubVendor{err: errors.New("no role configured")})
|
||||
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
|
||||
r.Header.Set(accessDelegationHeader, "vended-credentials")
|
||||
|
||||
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
|
||||
if len(config) != 0 || storageCredentials != nil {
|
||||
t.Errorf("config = %v, storage-credentials = %v, want both empty", config, storageCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
// A client that did not ask for delegation keeps the plain endpoint config and
|
||||
// must never be handed credentials.
|
||||
func TestBuildFileIOConfigVendingWithoutDelegationHeader(t *testing.T) {
|
||||
vendor := &stubVendor{credentials: &VendedCredentials{AccessKeyID: "ASIAEXAMPLE"}}
|
||||
s := vendingServer(vendor)
|
||||
|
||||
r := httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t", nil)
|
||||
config, storageCredentials := s.buildFileIOConfig(r, "s3://warehouse/ns/t")
|
||||
|
||||
if _, vended := config["s3.access-key-id"]; vended {
|
||||
t.Errorf("credentials vended without the delegation header: %v", config)
|
||||
}
|
||||
if config["s3.endpoint"] != "http://s3.example:8333" {
|
||||
t.Errorf("config = %v, want the endpoint", config)
|
||||
}
|
||||
if storageCredentials != nil {
|
||||
t.Errorf("storage-credentials = %v, want none", storageCredentials)
|
||||
}
|
||||
if vendor.gotBucket != "" {
|
||||
t.Errorf("vendor was called for a request that did not ask for delegation")
|
||||
}
|
||||
}
|
||||
87
weed/s3api/iceberg/iceberg_invalid_name_test.go
Normal file
87
weed/s3api/iceberg/iceberg_invalid_name_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
func TestNameValidationError(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
// Wrapped exactly as the s3tables manager surfaces it.
|
||||
{fmt.Errorf("all filers failed, last error: invalid namespace name: only 'a-z', '0-9', and '_' are allowed"), true},
|
||||
{fmt.Errorf("invalid table name: only 'a-z', '0-9', and '_' are allowed"), true},
|
||||
{fmt.Errorf("namespace name must start with a letter or digit"), true},
|
||||
{fmt.Errorf("namespace name cannot start with reserved prefix 'aws'"), true},
|
||||
{fmt.Errorf("table name must be between 1 and 255 characters"), true},
|
||||
{fmt.Errorf("namespace not found"), false},
|
||||
{fmt.Errorf("all filers failed, last error: rpc timeout"), false},
|
||||
// Unrelated faults that merely mention a name must stay 500.
|
||||
{fmt.Errorf("failed to resolve table name from index"), false},
|
||||
{fmt.Errorf("error fetching namespace name mapping"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := nameValidationError(c.err); got != c.want {
|
||||
t.Errorf("nameValidationError(%v) = %v, want %v", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteManagerError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode int
|
||||
wantType string
|
||||
wantMessage []string
|
||||
}{
|
||||
{name: "invalid name is a client error", err: fmt.Errorf("invalid namespace name: only 'a-z', '0-9', and '_' are allowed"), wantCode: http.StatusBadRequest, wantType: "BadRequestException"},
|
||||
{name: "rejected schema is a client error", err: fmt.Errorf("%w: for v2: variant is not supported until v3", iceberg.ErrInvalidSchema), wantCode: http.StatusBadRequest, wantType: "BadRequestException"},
|
||||
{name: "bad format version is a client error", err: fmt.Errorf("%w: 4", iceberg.ErrInvalidFormatVersion), wantCode: http.StatusBadRequest, wantType: "BadRequestException"},
|
||||
{
|
||||
name: "missing table bucket is a client error",
|
||||
err: fmt.Errorf("all filers failed, last error: %w", &s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchBucket, Message: "table bucket warehouse not found"}),
|
||||
wantCode: http.StatusNotFound,
|
||||
wantType: "NoSuchNamespaceException",
|
||||
// The guidance is the point of the mapping: the bucket the request
|
||||
// resolved to is one the client never named, so the response has to
|
||||
// say how to name a real one.
|
||||
wantMessage: []string{"table bucket warehouse not found", "warehouse=s3://<table-bucket>/", "/v1/<table-bucket>/"},
|
||||
},
|
||||
{name: "everything else is a server fault", err: fmt.Errorf("all filers failed, last error: connection refused"), wantCode: http.StatusInternalServerError, wantType: "InternalServerError"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
writeManagerError(rec, c.err)
|
||||
if rec.Code != c.wantCode {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, c.wantCode)
|
||||
}
|
||||
var resp ErrorResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Type != c.wantType {
|
||||
t.Fatalf("error type = %q, want %q", resp.Error.Type, c.wantType)
|
||||
}
|
||||
if resp.Error.Code != c.wantCode {
|
||||
t.Fatalf("error code = %d, want %d", resp.Error.Code, c.wantCode)
|
||||
}
|
||||
for _, want := range c.wantMessage {
|
||||
if !strings.Contains(resp.Error.Message, want) {
|
||||
t.Errorf("message %q does not contain %q", resp.Error.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
105
weed/s3api/iceberg/iceberg_issue_9103_test.go
Normal file
105
weed/s3api/iceberg/iceberg_issue_9103_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetBucketFromPrefix_WarehouseQueryFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "warehouse query routes to its bucket when no prefix in path",
|
||||
url: "/v1/namespaces?warehouse=s3%3A%2F%2Fmyblkt%2F",
|
||||
want: "myblkt",
|
||||
},
|
||||
{
|
||||
name: "warehouse query with sub-path still picks the bucket",
|
||||
url: "/v1/namespaces?warehouse=s3%3A%2F%2Fanother%2Fextra",
|
||||
want: "another",
|
||||
},
|
||||
{
|
||||
name: "bare bucket name is taken as the table bucket",
|
||||
url: "/v1/namespaces?warehouse=not-a-url",
|
||||
want: "not-a-url",
|
||||
},
|
||||
{
|
||||
name: "table bucket ARN routes to its bucket",
|
||||
url: "/v1/namespaces?warehouse=arn%3Aaws%3As3tables%3Aus-east-1%3Aadmin%3Abucket%2Fseaweed-iceberg",
|
||||
want: "seaweed-iceberg",
|
||||
},
|
||||
{
|
||||
name: "unusable warehouse value falls through to default",
|
||||
url: "/v1/namespaces?warehouse=file%3A%2F%2F%2Ftmp%2Fwh",
|
||||
want: "warehouse",
|
||||
},
|
||||
{
|
||||
name: "no warehouse and no prefix returns default",
|
||||
url: "/v1/namespaces",
|
||||
want: "warehouse",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", tc.url, nil)
|
||||
got := getBucketFromPrefix(r)
|
||||
if got != tc.want {
|
||||
t.Fatalf("getBucketFromPrefix(%q) = %q, want %q", tc.url, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileIOConfig(t *testing.T) {
|
||||
loadTable := func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
|
||||
}
|
||||
|
||||
t.Run("no endpoint configured yields empty config", func(t *testing.T) {
|
||||
s := &Server{}
|
||||
got, _ := s.buildFileIOConfig(loadTable(), "s3://warehouse/ns/t")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("buildFileIOConfig() = %v, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("endpoint is advertised with path-style-access and region", func(t *testing.T) {
|
||||
s := &Server{s3Endpoint: "http://seaweed.example:8333"}
|
||||
got, _ := s.buildFileIOConfig(loadTable(), "s3://warehouse/ns/t")
|
||||
if got["s3.endpoint"] != "http://seaweed.example:8333" {
|
||||
t.Fatalf("s3.endpoint = %q, want %q", got["s3.endpoint"], "http://seaweed.example:8333")
|
||||
}
|
||||
if got["s3.path-style-access"] != "true" {
|
||||
t.Fatalf("s3.path-style-access = %q, want %q", got["s3.path-style-access"], "true")
|
||||
}
|
||||
if got["s3.region"] == "" {
|
||||
t.Fatalf("s3.region was empty, want a non-empty default so clients like DuckDB do not require AWS_REGION")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetBucketFromPrefix_TableBucketEnvFallback(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/v1/namespaces", nil)
|
||||
|
||||
t.Setenv("S3_TABLE_BUCKET", " ,analytics, other")
|
||||
if got := getBucketFromPrefix(r); got != "analytics" {
|
||||
t.Fatalf("first S3_TABLE_BUCKET entry: got %q, want analytics", got)
|
||||
}
|
||||
|
||||
// The explicit default still wins over the table bucket list.
|
||||
t.Setenv("S3TABLES_DEFAULT_BUCKET", "explicit")
|
||||
if got := getBucketFromPrefix(r); got != "explicit" {
|
||||
t.Fatalf("S3TABLES_DEFAULT_BUCKET override: got %q, want explicit", got)
|
||||
}
|
||||
|
||||
// A prefixless value with neither env set falls through to the default.
|
||||
t.Setenv("S3TABLES_DEFAULT_BUCKET", "")
|
||||
t.Setenv("S3_TABLE_BUCKET", " , ")
|
||||
if got := getBucketFromPrefix(r); got != "warehouse" {
|
||||
t.Fatalf("empty specs: got %q, want warehouse", got)
|
||||
}
|
||||
}
|
||||
29
weed/s3api/iceberg/iceberg_namespace_properties_test.go
Normal file
29
weed/s3api/iceberg/iceberg_namespace_properties_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package iceberg
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeNamespacePropertiesNil(t *testing.T) {
|
||||
properties := normalizeNamespaceProperties(nil)
|
||||
if properties == nil {
|
||||
t.Fatalf("normalizeNamespaceProperties(nil) returned nil map")
|
||||
}
|
||||
if len(properties) != 0 {
|
||||
t.Fatalf("normalizeNamespaceProperties(nil) length = %d, want 0", len(properties))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNamespacePropertiesReturnsInputWhenSet(t *testing.T) {
|
||||
input := map[string]string{
|
||||
"owner": "analytics",
|
||||
}
|
||||
|
||||
properties := normalizeNamespaceProperties(input)
|
||||
if properties["owner"] != "analytics" {
|
||||
t.Fatalf("normalized properties value = %q, want %q", properties["owner"], "analytics")
|
||||
}
|
||||
|
||||
input["owner"] = "updated"
|
||||
if properties["owner"] != "updated" {
|
||||
t.Fatalf("normalizeNamespaceProperties should reuse the input map when non-nil")
|
||||
}
|
||||
}
|
||||
69
weed/s3api/iceberg/iceberg_pagination_test.go
Normal file
69
weed/s3api/iceberg/iceberg_pagination_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePaginationDefaultValues(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/v1/namespaces", nil)
|
||||
|
||||
pageToken, pageSize, err := parsePagination(req)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePagination() error = %v", err)
|
||||
}
|
||||
if pageToken != "" {
|
||||
t.Fatalf("pageToken = %q, want empty", pageToken)
|
||||
}
|
||||
if pageSize != defaultListPageSize {
|
||||
t.Fatalf("pageSize = %d, want %d", pageSize, defaultListPageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePaginationUsesCamelCaseParameters(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/v1/namespaces?pageToken=abc&pageSize=25", nil)
|
||||
|
||||
pageToken, pageSize, err := parsePagination(req)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePagination() error = %v", err)
|
||||
}
|
||||
if pageToken != "abc" {
|
||||
t.Fatalf("pageToken = %q, want %q", pageToken, "abc")
|
||||
}
|
||||
if pageSize != 25 {
|
||||
t.Fatalf("pageSize = %d, want %d", pageSize, 25)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePaginationSupportsHyphenatedFallback(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/v1/namespaces?page-token=abc&page-size=17", nil)
|
||||
|
||||
pageToken, pageSize, err := parsePagination(req)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePagination() error = %v", err)
|
||||
}
|
||||
if pageToken != "abc" {
|
||||
t.Fatalf("pageToken = %q, want %q", pageToken, "abc")
|
||||
}
|
||||
if pageSize != 17 {
|
||||
t.Fatalf("pageSize = %d, want %d", pageSize, 17)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePaginationRejectsInvalidPageSize(t *testing.T) {
|
||||
testCases := []string{
|
||||
"/v1/namespaces?pageSize=0",
|
||||
"/v1/namespaces?pageSize=-1",
|
||||
"/v1/namespaces?pageSize=foo",
|
||||
"/v1/namespaces?pageSize=1001",
|
||||
}
|
||||
|
||||
for _, rawURL := range testCases {
|
||||
t.Run(rawURL, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", rawURL, nil)
|
||||
if _, _, err := parsePagination(req); err == nil {
|
||||
t.Fatalf("parsePagination() expected error for url %q", rawURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
61
weed/s3api/iceberg/iceberg_register_table_test.go
Normal file
61
weed/s3api/iceberg/iceberg_register_table_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// TestRegisterTableRequestWireShape pins the field names to the iceberg-go REST
|
||||
// client payload: {"name", "metadata-location"}.
|
||||
func TestRegisterTableRequestWireShape(t *testing.T) {
|
||||
body := `{"name":"orders","metadata-location":"s3://bucket/ns/orders/metadata/v1.metadata.json"}`
|
||||
var req RegisterTableRequest
|
||||
if err := json.Unmarshal([]byte(body), &req); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if req.Name != "orders" {
|
||||
t.Errorf("Name = %q, want orders", req.Name)
|
||||
}
|
||||
if req.MetadataLocation != "s3://bucket/ns/orders/metadata/v1.metadata.json" {
|
||||
t.Errorf("MetadataLocation = %q", req.MetadataLocation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterTableRejectsOutOfBoundsLocation confirms a metadata-location that
|
||||
// points outside the request's table bucket, or contains a traversal segment, is
|
||||
// rejected with 400 before any metadata read.
|
||||
func TestRegisterTableRejectsOutOfBoundsLocation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
metadataLocation string
|
||||
}{
|
||||
{"cross bucket", "s3://other/ns/orders/metadata/v1.metadata.json"},
|
||||
{"traversal", "s3://bucket/ns/../../etc/metadata/v1.metadata.json"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
body := `{"name":"orders","metadata-location":"` + c.metadataLocation + `"}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/bucket/namespaces/ns/register", strings.NewReader(body))
|
||||
r = mux.SetURLVars(r, map[string]string{"prefix": "bucket", "namespace": "ns"})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
(&Server{}).handleRegisterTable(rec, r)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
var resp ErrorResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Type != "BadRequestException" {
|
||||
t.Fatalf("error type = %q, want BadRequestException", resp.Error.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
32
weed/s3api/iceberg/iceberg_rename_table_test.go
Normal file
32
weed/s3api/iceberg/iceberg_rename_table_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleRenameTableRejectsIncompleteIdentifiers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"missing source name", `{"source":{"namespace":["ns"]},"destination":{"namespace":["ns"],"name":"t2"}}`},
|
||||
{"missing source namespace", `{"source":{"name":"t"},"destination":{"namespace":["ns"],"name":"t2"}}`},
|
||||
{"missing destination name", `{"source":{"namespace":["ns"],"name":"t"},"destination":{"namespace":["ns"]}}`},
|
||||
{"missing destination namespace", `{"source":{"namespace":["ns"],"name":"t"},"destination":{"name":"t2"}}`},
|
||||
{"empty body", `{}`},
|
||||
{"malformed json", `{`},
|
||||
}
|
||||
s := &Server{}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/v1/tables/rename", strings.NewReader(tc.body))
|
||||
w := httptest.NewRecorder()
|
||||
s.handleRenameTable(w, r)
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("handleRenameTable() status = %d, want 400", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
93
weed/s3api/iceberg/iceberg_snapshots_param_test.go
Normal file
93
weed/s3api/iceberg/iceberg_snapshots_param_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// metadataWithSnapshots builds metadata whose main branch is on the last
|
||||
// snapshot, optionally tagging one of the earlier ones.
|
||||
func metadataWithSnapshots(t *testing.T, ids []int64, tagged int64) table.Metadata {
|
||||
t.Helper()
|
||||
|
||||
base, err := newTableMetadata(uuid.New(), "s3://bkt/ns/t", nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newTableMetadata() error = %v", err)
|
||||
}
|
||||
builder, err := table.MetadataBuilderFromBase(base, "")
|
||||
if err != nil {
|
||||
t.Fatalf("MetadataBuilderFromBase() error = %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for i, id := range ids {
|
||||
snapshot := table.Snapshot{
|
||||
SnapshotID: id,
|
||||
TimestampMs: now + int64(i),
|
||||
ManifestList: "metadata/snap-" + string(rune('0'+i)) + ".avro",
|
||||
}
|
||||
if err := builder.AddSnapshot(&snapshot); err != nil {
|
||||
t.Fatalf("AddSnapshot(%d) error = %v", id, err)
|
||||
}
|
||||
}
|
||||
if err := builder.SetSnapshotRef(table.MainBranch, ids[len(ids)-1], table.BranchRef); err != nil {
|
||||
t.Fatalf("SetSnapshotRef(main) error = %v", err)
|
||||
}
|
||||
if tagged != 0 {
|
||||
if err := builder.SetSnapshotRef("release", tagged, table.TagRef); err != nil {
|
||||
t.Fatalf("SetSnapshotRef(release) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
metadata, err := builder.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func snapshotIDs(metadata table.Metadata) map[int64]bool {
|
||||
ids := map[int64]bool{}
|
||||
for _, snapshot := range metadata.Snapshots() {
|
||||
ids[snapshot.SnapshotID] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestApplySnapshotsParamRefsKeepsOnlyReferenced(t *testing.T) {
|
||||
metadata := metadataWithSnapshots(t, []int64{1, 2, 3}, 1)
|
||||
|
||||
got, err := applySnapshotsParam(httptest.NewRequest("GET", "/v1/namespaces/ns/tables/t?snapshots=refs", nil), metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("applySnapshotsParam() error = %v", err)
|
||||
}
|
||||
|
||||
ids := snapshotIDs(got)
|
||||
if !ids[1] {
|
||||
t.Error("tagged snapshot 1 was dropped")
|
||||
}
|
||||
if !ids[3] {
|
||||
t.Error("current snapshot 3 was dropped")
|
||||
}
|
||||
if ids[2] {
|
||||
t.Error("unreferenced snapshot 2 was returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySnapshotsParamDefaultsToAll(t *testing.T) {
|
||||
metadata := metadataWithSnapshots(t, []int64{1, 2, 3}, 1)
|
||||
|
||||
for _, target := range []string{"/v1/namespaces/ns/tables/t", "/v1/namespaces/ns/tables/t?snapshots=all"} {
|
||||
got, err := applySnapshotsParam(httptest.NewRequest("GET", target, nil), metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("applySnapshotsParam(%s) error = %v", target, err)
|
||||
}
|
||||
if len(got.Snapshots()) != 3 {
|
||||
t.Errorf("applySnapshotsParam(%s) kept %d snapshots, want 3", target, len(got.Snapshots()))
|
||||
}
|
||||
}
|
||||
}
|
||||
254
weed/s3api/iceberg/iceberg_transaction_test.go
Normal file
254
weed/s3api/iceberg/iceberg_transaction_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
func TestDecodeCommitTransactionRequest(t *testing.T) {
|
||||
body := `{"table-changes":[
|
||||
{"identifier":{"namespace":["ns"],"name":"t1"},"requirements":[{"type":"assert-table-uuid","uuid":"00000000-0000-0000-0000-000000000001"}],"updates":[{"action":"set-properties","updates":{"k":"v"}}]},
|
||||
{"identifier":{"namespace":["ns","sub"],"name":"t2"},"requirements":[],"updates":[]}
|
||||
]}`
|
||||
|
||||
var req CommitTransactionRequest
|
||||
if err := json.Unmarshal([]byte(body), &req); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(req.TableChanges) != 2 {
|
||||
t.Fatalf("table-changes = %d, want 2", len(req.TableChanges))
|
||||
}
|
||||
if req.TableChanges[0].Identifier.Name != "t1" {
|
||||
t.Fatalf("change[0] name = %q, want t1", req.TableChanges[0].Identifier.Name)
|
||||
}
|
||||
if got := []string(req.TableChanges[1].Identifier.Namespace); len(got) != 2 || got[1] != "sub" {
|
||||
t.Fatalf("change[1] namespace = %v, want [ns sub]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableChangeSeparatesStatistics(t *testing.T) {
|
||||
change := tableChangeRequest{
|
||||
Requirements: json.RawMessage(`[{"type":"assert-table-uuid","uuid":"00000000-0000-0000-0000-000000000001"}]`),
|
||||
Updates: []json.RawMessage{
|
||||
json.RawMessage(`{"action":"set-properties","updates":{"k":"v"}}`),
|
||||
json.RawMessage(`{"action":"set-statistics","snapshot-id":10,"statistics-path":"s3://bucket/table/metadata/stats.puffin","file-size-in-bytes":100,"file-footer-size-in-bytes":20,"blob-metadata":[]}`),
|
||||
},
|
||||
}
|
||||
|
||||
requirements, updates, statistics, reqErr := parseTableChange(change)
|
||||
if reqErr != nil {
|
||||
t.Fatalf("parseTableChange() error = %+v", reqErr)
|
||||
}
|
||||
if len(requirements) != 1 {
|
||||
t.Fatalf("requirements = %d, want 1", len(requirements))
|
||||
}
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("updates = %d, want 1", len(updates))
|
||||
}
|
||||
if len(statistics) != 1 || statistics[0].set == nil || statistics[0].set.SnapshotID != 10 {
|
||||
t.Fatalf("unexpected statistics updates: %#v", statistics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTableChangeRejectsInvalidRequirements(t *testing.T) {
|
||||
change := tableChangeRequest{Requirements: json.RawMessage(`{not json}`)}
|
||||
_, _, _, reqErr := parseTableChange(change)
|
||||
if reqErr == nil {
|
||||
t.Fatalf("parseTableChange() expected error for invalid requirements")
|
||||
}
|
||||
if reqErr.status != 400 {
|
||||
t.Fatalf("status = %d, want 400", reqErr.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneTableMetadataIsIndependent(t *testing.T) {
|
||||
orig := &s3tables.TableMetadata{
|
||||
Iceberg: &s3tables.IcebergMetadata{TableUUID: "uuid-1"},
|
||||
FullMetadata: json.RawMessage(`{"v":1}`),
|
||||
}
|
||||
clone := cloneTableMetadata(orig)
|
||||
if clone == orig || clone.Iceberg == orig.Iceberg {
|
||||
t.Fatalf("cloneTableMetadata returned aliased pointers")
|
||||
}
|
||||
|
||||
orig.Iceberg.TableUUID = "mutated"
|
||||
orig.FullMetadata[0] = 'X'
|
||||
if clone.Iceberg.TableUUID != "uuid-1" {
|
||||
t.Fatalf("clone Iceberg.TableUUID = %q, want uuid-1", clone.Iceberg.TableUUID)
|
||||
}
|
||||
if string(clone.FullMetadata) != `{"v":1}` {
|
||||
t.Fatalf("clone FullMetadata = %q, mutated with source", clone.FullMetadata)
|
||||
}
|
||||
|
||||
if cloneTableMetadata(nil) != nil {
|
||||
t.Fatalf("cloneTableMetadata(nil) = non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTableRestoreRequestCarriesPriorState(t *testing.T) {
|
||||
pc := &preparedTableCommit{
|
||||
namespace: []string{"ns", "sub"},
|
||||
tableName: "t1",
|
||||
prevMetadataLoc: "s3://bucket/ns/t1/metadata/v3.metadata.json",
|
||||
prevMetadataVersion: 3,
|
||||
prevMetadata: &s3tables.TableMetadata{
|
||||
Iceberg: &s3tables.IcebergMetadata{TableUUID: uuid.NewString()},
|
||||
FullMetadata: json.RawMessage(`{"format-version":2,"last-updated-ms":3}`),
|
||||
},
|
||||
}
|
||||
|
||||
req := buildTableRestoreRequest("arn:bucket", pc)
|
||||
|
||||
if req.TableBucketARN != "arn:bucket" || req.Name != "t1" {
|
||||
t.Fatalf("restore target = %s/%s, want arn:bucket/t1", req.TableBucketARN, req.Name)
|
||||
}
|
||||
if req.MetadataLocation != pc.prevMetadataLoc {
|
||||
t.Fatalf("restore MetadataLocation = %q, want %q", req.MetadataLocation, pc.prevMetadataLoc)
|
||||
}
|
||||
if req.MetadataVersion != pc.prevMetadataVersion {
|
||||
t.Fatalf("restore MetadataVersion = %d, want %d", req.MetadataVersion, pc.prevMetadataVersion)
|
||||
}
|
||||
if req.Metadata == nil || !bytes.Equal(req.Metadata.FullMetadata, pc.prevMetadata.FullMetadata) {
|
||||
t.Fatalf("restore FullMetadata = %q, want %q", req.Metadata.FullMetadata, pc.prevMetadata.FullMetadata)
|
||||
}
|
||||
if req.Metadata.Iceberg == nil || req.Metadata.Iceberg.TableUUID != pc.prevMetadata.Iceberg.TableUUID {
|
||||
t.Fatalf("restore TableUUID mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRollbackRevertsEveryFlippedField mirrors handleUpdateTable's partial-field
|
||||
// update semantics to prove that after a flip + rollback the table is back to its
|
||||
// pre-transaction state. With the old location-only rollback the table kept the
|
||||
// new full metadata and version; the restore request must revert all of them.
|
||||
func TestRollbackRevertsEveryFlippedField(t *testing.T) {
|
||||
// prior on-disk state of an already-committed table.
|
||||
prior := tableEntry{
|
||||
metadataLocation: "s3://bucket/ns/t1/metadata/v3.metadata.json",
|
||||
metadataVersion: 3,
|
||||
metadata: &s3tables.TableMetadata{
|
||||
Iceberg: &s3tables.IcebergMetadata{TableUUID: "uuid-1"},
|
||||
FullMetadata: json.RawMessage(`{"format-version":2,"v":3}`),
|
||||
},
|
||||
}
|
||||
|
||||
pc := &preparedTableCommit{
|
||||
namespace: []string{"ns"},
|
||||
tableName: "t1",
|
||||
tableUUID: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
|
||||
metadataBytes: json.RawMessage(`{"format-version":2,"v":4}`),
|
||||
metadataVersion: 4,
|
||||
newMetadataLoc: "s3://bucket/ns/t1/metadata/v4.metadata.json",
|
||||
prevMetadataLoc: prior.metadataLocation,
|
||||
prevMetadataVersion: prior.metadataVersion,
|
||||
prevMetadata: cloneTableMetadata(prior.metadata),
|
||||
}
|
||||
|
||||
// flip: apply the new commit, then roll it back via the restore request.
|
||||
state := prior.clone()
|
||||
state.applyUpdate(&s3tables.UpdateTableRequest{
|
||||
Metadata: &s3tables.TableMetadata{Iceberg: &s3tables.IcebergMetadata{TableUUID: pc.tableUUID.String()}, FullMetadata: pc.metadataBytes},
|
||||
MetadataVersion: pc.metadataVersion,
|
||||
MetadataLocation: pc.newMetadataLoc,
|
||||
})
|
||||
state.applyUpdate(buildTableRestoreRequest("arn:bucket", pc))
|
||||
|
||||
if state.metadataLocation != prior.metadataLocation {
|
||||
t.Fatalf("after rollback location = %q, want %q", state.metadataLocation, prior.metadataLocation)
|
||||
}
|
||||
if state.metadataVersion != prior.metadataVersion {
|
||||
t.Fatalf("after rollback version = %d, want %d", state.metadataVersion, prior.metadataVersion)
|
||||
}
|
||||
if !bytes.Equal(state.metadata.FullMetadata, prior.metadata.FullMetadata) {
|
||||
t.Fatalf("after rollback FullMetadata = %q, want %q", state.metadata.FullMetadata, prior.metadata.FullMetadata)
|
||||
}
|
||||
if state.metadata.Iceberg.TableUUID != prior.metadata.Iceberg.TableUUID {
|
||||
t.Fatalf("after rollback TableUUID = %q, want %q", state.metadata.Iceberg.TableUUID, prior.metadata.Iceberg.TableUUID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupSafeMetadataKeepsFailedRollback proves a flip failure at index 2
|
||||
// where table 0's rollback also fails leaves table 0's new metadata in place
|
||||
// (its pointer still references it), while the rolled-back table 1 and the
|
||||
// never-flipped tables 2 and 3 are safe to delete.
|
||||
func TestCleanupSafeMetadataKeepsFailedRollback(t *testing.T) {
|
||||
prepared := make([]preparedTableCommit, 4)
|
||||
rolledBack := []bool{false, true} // tables 0 and 1 were flipped; 0's rollback failed
|
||||
|
||||
safe := cleanupSafeMetadata(prepared, 2, rolledBack)
|
||||
|
||||
want := []bool{false, true, true, true}
|
||||
for i := range want {
|
||||
if safe[i] != want[i] {
|
||||
t.Fatalf("safe[%d] = %v, want %v (full %v)", i, safe[i], want[i], safe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupSafeMetadataFirstFlipFails covers a flip failure at the first table:
|
||||
// nothing was flipped, so every written file is safe to delete.
|
||||
func TestCleanupSafeMetadataFirstFlipFails(t *testing.T) {
|
||||
prepared := make([]preparedTableCommit, 3)
|
||||
safe := cleanupSafeMetadata(prepared, 0, nil)
|
||||
for i, ok := range safe {
|
||||
if !ok {
|
||||
t.Fatalf("safe[%d] = false, want true (full %v)", i, safe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupSafeMetadataAllRolledBack covers the common case where every prior
|
||||
// flip rolls back cleanly, so all metadata files are safe to delete.
|
||||
func TestCleanupSafeMetadataAllRolledBack(t *testing.T) {
|
||||
prepared := make([]preparedTableCommit, 3)
|
||||
safe := cleanupSafeMetadata(prepared, 2, []bool{true, true})
|
||||
for i, ok := range safe {
|
||||
if !ok {
|
||||
t.Fatalf("safe[%d] = false, want true (full %v)", i, safe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tableEntry models the persisted table fields and the partial-update rules
|
||||
// handleUpdateTable applies, so rollback can be exercised without a live filer.
|
||||
type tableEntry struct {
|
||||
metadataLocation string
|
||||
metadataVersion int
|
||||
metadata *s3tables.TableMetadata
|
||||
}
|
||||
|
||||
func (e tableEntry) clone() *tableEntry {
|
||||
return &tableEntry{
|
||||
metadataLocation: e.metadataLocation,
|
||||
metadataVersion: e.metadataVersion,
|
||||
metadata: cloneTableMetadata(e.metadata),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *tableEntry) applyUpdate(req *s3tables.UpdateTableRequest) {
|
||||
if req.Metadata != nil {
|
||||
if e.metadata == nil {
|
||||
e.metadata = &s3tables.TableMetadata{}
|
||||
}
|
||||
if req.Metadata.Iceberg != nil {
|
||||
if e.metadata.Iceberg == nil {
|
||||
e.metadata.Iceberg = &s3tables.IcebergMetadata{}
|
||||
}
|
||||
if req.Metadata.Iceberg.TableUUID != "" {
|
||||
e.metadata.Iceberg.TableUUID = req.Metadata.Iceberg.TableUUID
|
||||
}
|
||||
}
|
||||
if len(req.Metadata.FullMetadata) > 0 {
|
||||
e.metadata.FullMetadata = req.Metadata.FullMetadata
|
||||
}
|
||||
}
|
||||
if req.MetadataLocation != "" {
|
||||
e.metadataLocation = req.MetadataLocation
|
||||
}
|
||||
if req.MetadataVersion > 0 {
|
||||
e.metadataVersion = req.MetadataVersion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func TestApplyNamespacePropertyUpdates(t *testing.T) {
|
||||
current := map[string]string{"foo": "bar", "prop": "yes"}
|
||||
|
||||
props, summary := applyNamespacePropertyUpdates(current, []string{"abc"}, map[string]string{"prop": "no"})
|
||||
|
||||
// Removal of a missing key reports missing, not removed.
|
||||
if !reflect.DeepEqual(summary.Removed, []string{}) {
|
||||
t.Errorf("Removed = %v, want []", summary.Removed)
|
||||
}
|
||||
if !reflect.DeepEqual(summary.Updated, []string{"prop"}) {
|
||||
t.Errorf("Updated = %v, want [prop]", summary.Updated)
|
||||
}
|
||||
if !reflect.DeepEqual(summary.Missing, []string{"abc"}) {
|
||||
t.Errorf("Missing = %v, want [abc]", summary.Missing)
|
||||
}
|
||||
if props["prop"] != "no" || props["foo"] != "bar" {
|
||||
t.Errorf("merged properties = %v, want foo=bar prop=no", props)
|
||||
}
|
||||
|
||||
// Input map must not be mutated.
|
||||
if current["prop"] != "yes" {
|
||||
t.Errorf("source map mutated: prop = %q, want yes", current["prop"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNamespacePropertyUpdatesRemoveExisting(t *testing.T) {
|
||||
props, summary := applyNamespacePropertyUpdates(
|
||||
map[string]string{"a": "1", "b": "2"}, []string{"a"}, nil)
|
||||
|
||||
if !reflect.DeepEqual(summary.Removed, []string{"a"}) {
|
||||
t.Errorf("Removed = %v, want [a]", summary.Removed)
|
||||
}
|
||||
if _, ok := props["a"]; ok {
|
||||
t.Errorf("key a should have been removed: %v", props)
|
||||
}
|
||||
if len(summary.Missing) != 0 || len(summary.Updated) != 0 {
|
||||
t.Errorf("unexpected summary: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNamespacePropertyUpdatesDuplicateRemovals(t *testing.T) {
|
||||
// A repeated removal key must not be reported as both removed and missing.
|
||||
_, summary := applyNamespacePropertyUpdates(
|
||||
map[string]string{"a": "1"}, []string{"a", "a"}, nil)
|
||||
|
||||
if !reflect.DeepEqual(summary.Removed, []string{"a"}) {
|
||||
t.Errorf("Removed = %v, want [a]", summary.Removed)
|
||||
}
|
||||
if len(summary.Missing) != 0 {
|
||||
t.Errorf("Missing = %v, want []", summary.Missing)
|
||||
}
|
||||
}
|
||||
|
||||
// A key in both removals and updates is rejected before any backend call, so
|
||||
// the conflict path is reachable without a filer.
|
||||
func TestHandleUpdateNamespacePropertiesConflict(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/namespaces/ns/properties",
|
||||
strings.NewReader(`{"removals":["x"],"updates":{"x":"1"}}`))
|
||||
req = mux.SetURLVars(req, map[string]string{"namespace": "ns"})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
(&Server{}).handleUpdateNamespaceProperties(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, want 422", rec.Code)
|
||||
}
|
||||
var resp ErrorResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Error.Type != "UnprocessableEntityException" {
|
||||
t.Fatalf("error type = %q, want UnprocessableEntityException", resp.Error.Type)
|
||||
}
|
||||
}
|
||||
115
weed/s3api/iceberg/iceberg_view_test.go
Normal file
115
weed/s3api/iceberg/iceberg_view_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/apache/iceberg-go/view"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
func TestIsViewNotFound(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{&s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchView, Message: "view x not found"}, true},
|
||||
{&s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchNamespace, Message: "namespace x not found"}, true},
|
||||
{errors.New("view x not found"), true},
|
||||
{&s3tables.S3TablesError{Type: s3tables.ErrCodeViewAlreadyExists, Message: "already exists"}, false},
|
||||
{errors.New("connection refused"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isViewNotFound(c.err); got != c.want {
|
||||
t.Errorf("isViewNotFound(%v) = %v, want %v", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsViewAlreadyExists(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{&s3tables.S3TablesError{Type: s3tables.ErrCodeViewAlreadyExists, Message: "already exists"}, true},
|
||||
{errors.New("view already exists"), true},
|
||||
{&s3tables.S3TablesError{Type: s3tables.ErrCodeNoSuchView, Message: "not found"}, false},
|
||||
{errors.New("connection refused"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isViewAlreadyExists(c.err); got != c.want {
|
||||
t.Errorf("isViewAlreadyExists(%v) = %v, want %v", c.err, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewPropertiesCopiesAndIsNonNil(t *testing.T) {
|
||||
if got := viewProperties(nil); got == nil {
|
||||
t.Fatal("viewProperties(nil) = nil, want non-nil")
|
||||
}
|
||||
src := iceberg.Properties{"k": "v"}
|
||||
out := viewProperties(src)
|
||||
out["k2"] = "v2"
|
||||
if _, ok := src["k2"]; ok {
|
||||
t.Fatal("viewProperties did not copy: mutation leaked into source")
|
||||
}
|
||||
if out["k"] != "v" {
|
||||
t.Fatalf("viewProperties dropped key: got %q", out["k"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewResponseRoundTrip verifies a built view metadata marshals into the
|
||||
// ViewResponse wire shape and parses back via the iceberg-go view parser.
|
||||
func TestViewResponseRoundTrip(t *testing.T) {
|
||||
schema := iceberg.NewSchemaWithIdentifiers(0, nil,
|
||||
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int32, Required: true},
|
||||
)
|
||||
ver, err := view.NewVersionFromSQL(1, schema.ID, "SELECT 1", table.Identifier{"ns"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md, err := view.NewMetadata(ver, schema, "s3://bucket/ns/v", iceberg.Properties{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp := ViewResponse{
|
||||
MetadataLocation: "s3://bucket/ns/v/metadata/v1.metadata.json",
|
||||
Metadata: md,
|
||||
Config: iceberg.Properties{},
|
||||
}
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
if _, ok := raw["metadata-location"]; !ok {
|
||||
t.Fatal("response missing metadata-location")
|
||||
}
|
||||
if _, ok := raw["metadata"]; !ok {
|
||||
t.Fatal("response missing metadata")
|
||||
}
|
||||
|
||||
var back ViewResponse
|
||||
if err := json.Unmarshal(data, &back); err != nil {
|
||||
t.Fatalf("unmarshal back: %v", err)
|
||||
}
|
||||
if back.Metadata == nil {
|
||||
t.Fatal("metadata nil after round-trip")
|
||||
}
|
||||
if back.Metadata.CurrentVersion().VersionID != 1 {
|
||||
t.Fatalf("current version = %d, want 1", back.Metadata.CurrentVersion().VersionID)
|
||||
}
|
||||
if back.MetadataLocation != resp.MetadataLocation {
|
||||
t.Fatalf("metadata-location = %q, want %q", back.MetadataLocation, resp.MetadataLocation)
|
||||
}
|
||||
}
|
||||
71
weed/s3api/iceberg/iceberg_warehouse_test.go
Normal file
71
weed/s3api/iceberg/iceberg_warehouse_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The admin console tells users that every table bucket is its own Iceberg
|
||||
// catalog, so the /v1/config handshake must turn whichever spelling of that
|
||||
// bucket the client sends into overrides.prefix. PyIceberg only sends the
|
||||
// warehouse on this one call, so a dropped value leaves it talking to the
|
||||
// default bucket for the rest of the session.
|
||||
func TestHandleConfigWarehouseSpellings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
warehouse string
|
||||
wantPrefix string
|
||||
wantWarehouse string
|
||||
}{
|
||||
{
|
||||
name: "s3 location",
|
||||
warehouse: "s3://seaweed-iceberg/",
|
||||
wantPrefix: "seaweed-iceberg",
|
||||
wantWarehouse: "s3://seaweed-iceberg",
|
||||
},
|
||||
{
|
||||
name: "bare bucket name",
|
||||
warehouse: "seaweed-iceberg",
|
||||
wantPrefix: "seaweed-iceberg",
|
||||
wantWarehouse: "s3://seaweed-iceberg",
|
||||
},
|
||||
{
|
||||
name: "table bucket ARN",
|
||||
warehouse: "arn:aws:s3tables:us-east-1:admin:bucket/seaweed-iceberg",
|
||||
wantPrefix: "seaweed-iceberg",
|
||||
wantWarehouse: "s3://seaweed-iceberg",
|
||||
},
|
||||
{
|
||||
name: "no warehouse",
|
||||
warehouse: "",
|
||||
},
|
||||
{
|
||||
name: "unusable value",
|
||||
warehouse: "file:///tmp/wh",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
target := "/v1/config"
|
||||
if tc.warehouse != "" {
|
||||
target += "?" + url.Values{"warehouse": {tc.warehouse}}.Encode()
|
||||
}
|
||||
r := httptest.NewRequest("GET", target, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
(&Server{}).handleConfig(rec, r)
|
||||
|
||||
var config CatalogConfig
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &config); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
if got := config.Overrides["prefix"]; got != tc.wantPrefix {
|
||||
t.Errorf("overrides.prefix = %q, want %q", got, tc.wantPrefix)
|
||||
}
|
||||
if got := config.Defaults["warehouse"]; got != tc.wantWarehouse {
|
||||
t.Errorf("defaults.warehouse = %q, want %q", got, tc.wantWarehouse)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
618
weed/s3api/iceberg/manifest_repair.go
Normal file
618
weed/s3api/iceberg/manifest_repair.go
Normal file
@@ -0,0 +1,618 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/linkedin/goavro/v2"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
||||
)
|
||||
|
||||
// Some engines (ClickHouse's experimental Iceberg writes among them) commit
|
||||
// snapshots whose manifest avro files omit the spec's field-id annotations and
|
||||
// reference files by bucket-relative path. Both make the snapshot unreadable
|
||||
// for other engines even though the data itself is fine. Since the catalog is
|
||||
// the commit chokepoint, add-snapshot updates are repaired here: the manifest
|
||||
// list and manifests are transcoded with the annotations added and the paths
|
||||
// absolutized, and the snapshot is pointed at the repaired copies.
|
||||
|
||||
// avroFieldIDs describes the spec-mandated ids for one avro field and the
|
||||
// fields nested beneath it (through unions, arrays, and records).
|
||||
type avroFieldIDs struct {
|
||||
id int
|
||||
elementID int
|
||||
children map[string]avroFieldIDs
|
||||
}
|
||||
|
||||
// manifestListFieldIDs returns the fixed v2 manifest_file schema ids.
|
||||
func manifestListFieldIDs() map[string]avroFieldIDs {
|
||||
return map[string]avroFieldIDs{
|
||||
"manifest_path": {id: 500},
|
||||
"manifest_length": {id: 501},
|
||||
"partition_spec_id": {id: 502},
|
||||
"content": {id: 517},
|
||||
"sequence_number": {id: 515},
|
||||
"min_sequence_number": {id: 516},
|
||||
"added_snapshot_id": {id: 503},
|
||||
"added_files_count": {id: 504},
|
||||
"existing_files_count": {id: 505},
|
||||
"deleted_files_count": {id: 506},
|
||||
"added_rows_count": {id: 512},
|
||||
"existing_rows_count": {id: 513},
|
||||
"deleted_rows_count": {id: 514},
|
||||
"partitions": {id: 507, elementID: 508, children: map[string]avroFieldIDs{
|
||||
"contains_null": {id: 509},
|
||||
"contains_nan": {id: 518},
|
||||
"lower_bound": {id: 510},
|
||||
"upper_bound": {id: 511},
|
||||
}},
|
||||
"key_metadata": {id: 519},
|
||||
"first_row_id": {id: 520},
|
||||
}
|
||||
}
|
||||
|
||||
// manifestEntryFieldIDs returns the fixed v2 manifest_entry schema ids. The
|
||||
// partition struct is table-specific; its ids come from the partition-spec
|
||||
// JSON embedded in the manifest's OCF metadata.
|
||||
func manifestEntryFieldIDs(partitionIDs map[string]avroFieldIDs) map[string]avroFieldIDs {
|
||||
return map[string]avroFieldIDs{
|
||||
"status": {id: 0},
|
||||
"snapshot_id": {id: 1},
|
||||
"sequence_number": {id: 3},
|
||||
"file_sequence_number": {id: 4},
|
||||
"data_file": {id: 2, children: map[string]avroFieldIDs{
|
||||
"content": {id: 134},
|
||||
"file_path": {id: 100},
|
||||
"file_format": {id: 101},
|
||||
"partition": {id: 102, children: partitionIDs},
|
||||
"record_count": {id: 103},
|
||||
"file_size_in_bytes": {id: 104},
|
||||
"column_sizes": {id: 108, children: kvFieldIDs(117, 118)},
|
||||
"value_counts": {id: 109, children: kvFieldIDs(119, 120)},
|
||||
"null_value_counts": {id: 110, children: kvFieldIDs(121, 122)},
|
||||
"nan_value_counts": {id: 137, children: kvFieldIDs(138, 139)},
|
||||
"lower_bounds": {id: 125, children: kvFieldIDs(126, 127)},
|
||||
"upper_bounds": {id: 128, children: kvFieldIDs(129, 130)},
|
||||
"key_metadata": {id: 131},
|
||||
"split_offsets": {id: 132, elementID: 133},
|
||||
"equality_ids": {id: 135, elementID: 136},
|
||||
"sort_order_id": {id: 140},
|
||||
"first_row_id": {id: 142},
|
||||
"referenced_data_file": {id: 143},
|
||||
"content_offset": {id: 144},
|
||||
"content_size_in_bytes": {id: 145},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func kvFieldIDs(keyID, valueID int) map[string]avroFieldIDs {
|
||||
return map[string]avroFieldIDs{
|
||||
"key": {id: keyID},
|
||||
"value": {id: valueID},
|
||||
}
|
||||
}
|
||||
|
||||
// partitionFieldIDsFromSpec extracts name -> field-id from the partition-spec
|
||||
// JSON carried in a manifest's OCF metadata.
|
||||
func partitionFieldIDsFromSpec(specJSON []byte) (map[string]avroFieldIDs, error) {
|
||||
if len(specJSON) == 0 {
|
||||
return map[string]avroFieldIDs{}, nil
|
||||
}
|
||||
var fields []struct {
|
||||
Name string `json:"name"`
|
||||
FieldID int `json:"field-id"`
|
||||
}
|
||||
if err := json.Unmarshal(specJSON, &fields); err != nil {
|
||||
return nil, fmt.Errorf("parse partition-spec metadata: %w", err)
|
||||
}
|
||||
ids := make(map[string]avroFieldIDs, len(fields))
|
||||
for _, f := range fields {
|
||||
ids[f.Name] = avroFieldIDs{id: f.FieldID}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// annotateAvroRecordFields adds "field-id" to every field of a parsed avro
|
||||
// record schema, recursing into nested types. Returns whether anything was
|
||||
// added, and ok=false when a field has no known id (the schema is left for an
|
||||
// unknown writer dialect rather than half-annotated).
|
||||
func annotateAvroRecordFields(record map[string]any, ids map[string]avroFieldIDs) (changed, ok bool) {
|
||||
fields, _ := record["fields"].([]any)
|
||||
if len(fields) == 0 {
|
||||
return false, true
|
||||
}
|
||||
for _, f := range fields {
|
||||
field, isMap := f.(map[string]any)
|
||||
if !isMap {
|
||||
return changed, false
|
||||
}
|
||||
name, _ := field["name"].(string)
|
||||
spec, known := ids[name]
|
||||
if !known {
|
||||
return changed, false
|
||||
}
|
||||
if _, has := field["field-id"]; !has {
|
||||
field["field-id"] = spec.id
|
||||
changed = true
|
||||
}
|
||||
typeChanged, typeOK := annotateAvroType(field["type"], spec)
|
||||
changed = changed || typeChanged
|
||||
if !typeOK {
|
||||
return changed, false
|
||||
}
|
||||
}
|
||||
return changed, true
|
||||
}
|
||||
|
||||
// annotateAvroType annotates the schema node of a single field: unions are
|
||||
// followed through their non-null branches, arrays get "element-id", and
|
||||
// nested records recurse with the field's child ids.
|
||||
func annotateAvroType(node any, spec avroFieldIDs) (changed, ok bool) {
|
||||
switch t := node.(type) {
|
||||
case []any: // union
|
||||
for _, branch := range t {
|
||||
branchChanged, branchOK := annotateAvroType(branch, spec)
|
||||
changed = changed || branchChanged
|
||||
if !branchOK {
|
||||
return changed, false
|
||||
}
|
||||
}
|
||||
return changed, true
|
||||
case map[string]any:
|
||||
switch t["type"] {
|
||||
case "record":
|
||||
childIDs := spec.children
|
||||
if childIDs == nil {
|
||||
childIDs = map[string]avroFieldIDs{}
|
||||
}
|
||||
return annotateAvroRecordFields(t, childIDs)
|
||||
case "array":
|
||||
if spec.elementID != 0 {
|
||||
if _, has := t["element-id"]; !has {
|
||||
t["element-id"] = spec.elementID
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
itemChanged, itemOK := annotateAvroType(t["items"], spec)
|
||||
return changed || itemChanged, itemOK
|
||||
case "map":
|
||||
// The spec wants key-id/value-id on genuine avro maps; this repair
|
||||
// only knows the array-of-key_value encoding, so leave the file to
|
||||
// its writer rather than half-annotating it.
|
||||
return changed, false
|
||||
default:
|
||||
return false, true
|
||||
}
|
||||
default: // primitive name or named-type reference
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
|
||||
// transcodeManifestAvro decodes an avro OCF file, annotates its schema with
|
||||
// the given ids, applies mutate to each datum and fixMeta to the OCF metadata,
|
||||
// and re-encodes. When nothing changes, the original bytes are returned
|
||||
// unchanged.
|
||||
func transcodeManifestAvro(raw []byte, ids map[string]avroFieldIDs, mutate func(map[string]any) bool, fixMeta func(map[string][]byte) bool) ([]byte, bool, error) {
|
||||
reader, err := goavro.NewOCFReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("open avro: %w", err)
|
||||
}
|
||||
meta := reader.MetaData()
|
||||
|
||||
outMeta := make(map[string][]byte, len(meta))
|
||||
for k, v := range meta {
|
||||
if k == "avro.schema" || k == "avro.codec" {
|
||||
continue
|
||||
}
|
||||
outMeta[k] = v
|
||||
}
|
||||
metaChanged := false
|
||||
if fixMeta != nil {
|
||||
metaChanged = fixMeta(outMeta)
|
||||
}
|
||||
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(meta["avro.schema"], &schema); err != nil {
|
||||
return nil, false, fmt.Errorf("parse avro schema: %w", err)
|
||||
}
|
||||
schemaChanged, ok := annotateAvroRecordFields(schema, ids)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("avro schema has fields outside the Iceberg manifest spec")
|
||||
}
|
||||
|
||||
datumChanged := false
|
||||
var datums []any
|
||||
for reader.Scan() {
|
||||
datum, err := reader.Read()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("read avro datum: %w", err)
|
||||
}
|
||||
if record, isMap := datum.(map[string]any); isMap && mutate != nil {
|
||||
if mutate(record) {
|
||||
datumChanged = true
|
||||
}
|
||||
}
|
||||
datums = append(datums, datum)
|
||||
}
|
||||
if err := reader.Err(); err != nil {
|
||||
return nil, false, fmt.Errorf("scan avro: %w", err)
|
||||
}
|
||||
|
||||
if !schemaChanged && !datumChanged && !metaChanged {
|
||||
return raw, false, nil
|
||||
}
|
||||
|
||||
annotatedSchema, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writer, err := goavro.NewOCFWriter(goavro.OCFConfig{
|
||||
W: &buf,
|
||||
Schema: string(annotatedSchema),
|
||||
MetaData: outMeta,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("create avro writer: %w", err)
|
||||
}
|
||||
if err := writer.Append(datums); err != nil {
|
||||
return nil, false, fmt.Errorf("write avro datums: %w", err)
|
||||
}
|
||||
return buf.Bytes(), true, nil
|
||||
}
|
||||
|
||||
// manifestStore abstracts reading and writing files in a table's metadata
|
||||
// directory so the repair logic is testable without a filer.
|
||||
type manifestStore interface {
|
||||
loadFile(ctx context.Context, location string) ([]byte, error)
|
||||
saveFile(ctx context.Context, location string, data []byte) error
|
||||
}
|
||||
|
||||
// absolutizeLocation resolves a bucket-relative path against the bucket of the
|
||||
// table location. Locations that already carry a scheme pass through.
|
||||
func absolutizeLocation(location, tableLocation string) string {
|
||||
if location == "" || strings.Contains(location, "://") {
|
||||
return location
|
||||
}
|
||||
bucket, _, err := parseS3Location(tableLocation)
|
||||
if err != nil {
|
||||
return location
|
||||
}
|
||||
return "s3://" + bucket + "/" + strings.TrimPrefix(location, "/")
|
||||
}
|
||||
|
||||
// metadataFileName returns the file name when location is a direct child of
|
||||
// the table's metadata directory, which is the only place repair will read
|
||||
// from or write to.
|
||||
func metadataFileName(tableLocation, location string) (string, bool) {
|
||||
prefix := strings.TrimSuffix(tableLocation, "/") + "/metadata/"
|
||||
name := strings.TrimPrefix(location, prefix)
|
||||
if name == location || name == "" || strings.Contains(name, "/") {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
const repairedManifestPrefix = "repaired-"
|
||||
|
||||
// maxRepairableManifestSize bounds how much manifest data the repair path
|
||||
// buffers in memory; anything larger fails the repair and the commit
|
||||
// proceeds with the writer's original files.
|
||||
const maxRepairableManifestSize = 64 << 20
|
||||
|
||||
// hasFieldIDAnnotations is the cheap compliance probe: spec-compliant writers
|
||||
// annotate every manifest avro schema with field-id, and a writer that
|
||||
// annotates the manifest list annotates its manifests too.
|
||||
func hasFieldIDAnnotations(raw []byte) bool {
|
||||
reader, err := goavro.NewOCFReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return bytes.Contains(reader.MetaData()["avro.schema"], []byte(`"field-id"`))
|
||||
}
|
||||
|
||||
// repairManifestList loads a snapshot's manifest list and, when it needs
|
||||
// repair, rewrites it (and the manifests it references) into spec-compliant
|
||||
// copies next to the originals. Returns the manifest list location the
|
||||
// snapshot should reference.
|
||||
func repairManifestList(ctx context.Context, store manifestStore, tableLocation, listLocation string) (string, bool, error) {
|
||||
absListLocation := absolutizeLocation(listLocation, tableLocation)
|
||||
listName, inMetadataDir := metadataFileName(tableLocation, absListLocation)
|
||||
if !inMetadataDir {
|
||||
return listLocation, false, nil
|
||||
}
|
||||
|
||||
listBytes, err := store.loadFile(ctx, absListLocation)
|
||||
if err != nil {
|
||||
return listLocation, false, fmt.Errorf("load manifest list: %w", err)
|
||||
}
|
||||
if absListLocation == listLocation && hasFieldIDAnnotations(listBytes) {
|
||||
return listLocation, false, nil
|
||||
}
|
||||
|
||||
metadataDir := strings.TrimSuffix(tableLocation, "/") + "/metadata/"
|
||||
var manifestErr error
|
||||
repairedList, listChanged, err := transcodeManifestAvro(listBytes, manifestListFieldIDs(), func(record map[string]any) bool {
|
||||
manifestPath, _ := record["manifest_path"].(string)
|
||||
if manifestPath == "" || manifestErr != nil {
|
||||
return false
|
||||
}
|
||||
absManifest := absolutizeLocation(manifestPath, tableLocation)
|
||||
recordChanged := absManifest != manifestPath
|
||||
if recordChanged {
|
||||
record["manifest_path"] = absManifest
|
||||
}
|
||||
|
||||
manifestName, ok := metadataFileName(tableLocation, absManifest)
|
||||
if !ok {
|
||||
return recordChanged
|
||||
}
|
||||
manifestBytes, err := store.loadFile(ctx, absManifest)
|
||||
if err != nil {
|
||||
manifestErr = fmt.Errorf("load manifest %s: %w", absManifest, err)
|
||||
return recordChanged
|
||||
}
|
||||
repaired, changed, err := repairManifest(manifestBytes, tableLocation, manifestContentValue(record))
|
||||
if err != nil {
|
||||
manifestErr = fmt.Errorf("repair manifest %s: %w", absManifest, err)
|
||||
return recordChanged
|
||||
}
|
||||
if !changed {
|
||||
return recordChanged
|
||||
}
|
||||
repairedLocation := metadataDir + repairedManifestPrefix + manifestName
|
||||
if err := store.saveFile(ctx, repairedLocation, repaired); err != nil {
|
||||
manifestErr = fmt.Errorf("save repaired manifest %s: %w", repairedLocation, err)
|
||||
return recordChanged
|
||||
}
|
||||
record["manifest_path"] = repairedLocation
|
||||
record["manifest_length"] = int64(len(repaired))
|
||||
return true
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return listLocation, false, err
|
||||
}
|
||||
if manifestErr != nil {
|
||||
return listLocation, false, manifestErr
|
||||
}
|
||||
if !listChanged && absListLocation == listLocation {
|
||||
return listLocation, false, nil
|
||||
}
|
||||
|
||||
repairedListLocation := metadataDir + repairedManifestPrefix + listName
|
||||
if listChanged {
|
||||
if err := store.saveFile(ctx, repairedListLocation, repairedList); err != nil {
|
||||
return listLocation, false, fmt.Errorf("save repaired manifest list: %w", err)
|
||||
}
|
||||
return repairedListLocation, true, nil
|
||||
}
|
||||
// Only the pointer was relative; the file itself is fine.
|
||||
return absListLocation, true, nil
|
||||
}
|
||||
|
||||
// manifestContentValue reads the manifest-list entry's content field
|
||||
// (0 = data, 1 = deletes); v1 lists have no such field and default to data.
|
||||
// Union-typed values arrive from goavro as a single-branch map.
|
||||
func manifestContentValue(record map[string]any) int {
|
||||
value := record["content"]
|
||||
if union, isUnion := value.(map[string]any); isUnion {
|
||||
for _, branch := range union {
|
||||
value = branch
|
||||
break
|
||||
}
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// fixManifestOCFMetadata fixes OCF metadata keys the spec requires of
|
||||
// manifests but lax writers omit or contradict: "content" must agree with the
|
||||
// manifest-list entry, which is what readers trust for scan planning, and
|
||||
// "partition-spec-id" (ClickHouse writes the underscore variant).
|
||||
func fixManifestOCFMetadata(listContent int) func(map[string][]byte) bool {
|
||||
return func(meta map[string][]byte) bool {
|
||||
changed := false
|
||||
content := "data"
|
||||
if listContent == 1 {
|
||||
content = "deletes"
|
||||
}
|
||||
if string(meta["content"]) != content {
|
||||
meta["content"] = []byte(content)
|
||||
changed = true
|
||||
}
|
||||
if _, ok := meta["partition-spec-id"]; !ok {
|
||||
if v, ok := meta["partition_spec_id"]; ok {
|
||||
meta["partition-spec-id"] = v
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
}
|
||||
|
||||
// repairManifest annotates a single manifest's schema and absolutizes the
|
||||
// data file paths inside it. listContent is the content declared by the
|
||||
// manifest-list entry referencing this manifest.
|
||||
func repairManifest(raw []byte, tableLocation string, listContent int) ([]byte, bool, error) {
|
||||
reader, err := goavro.NewOCFReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("open manifest avro: %w", err)
|
||||
}
|
||||
partitionIDs, err := partitionFieldIDsFromSpec(reader.MetaData()["partition-spec"])
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return transcodeManifestAvro(raw, manifestEntryFieldIDs(partitionIDs), func(record map[string]any) bool {
|
||||
dataFile, _ := record["data_file"].(map[string]any)
|
||||
if dataFile == nil {
|
||||
return false
|
||||
}
|
||||
filePath, _ := dataFile["file_path"].(string)
|
||||
absPath := absolutizeLocation(filePath, tableLocation)
|
||||
if absPath == filePath {
|
||||
return false
|
||||
}
|
||||
dataFile["file_path"] = absPath
|
||||
return true
|
||||
}, fixManifestOCFMetadata(listContent))
|
||||
}
|
||||
|
||||
// repairAddSnapshotUpdates rewrites the manifest-list references of
|
||||
// add-snapshot updates whose manifests need repair. Repair is best effort: on
|
||||
// any error the original update is kept and the commit proceeds as it would
|
||||
// have without repair.
|
||||
func repairAddSnapshotUpdates(ctx context.Context, store manifestStore, tableLocation string, rawUpdates []json.RawMessage) ([]json.RawMessage, bool) {
|
||||
changedAny := false
|
||||
out := make([]json.RawMessage, len(rawUpdates))
|
||||
copy(out, rawUpdates)
|
||||
for i, rawUpdate := range rawUpdates {
|
||||
var probe struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.Unmarshal(rawUpdate, &probe); err != nil || probe.Action != "add-snapshot" {
|
||||
continue
|
||||
}
|
||||
var update map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawUpdate, &update); err != nil {
|
||||
continue
|
||||
}
|
||||
var snapshot map[string]json.RawMessage
|
||||
if err := json.Unmarshal(update["snapshot"], &snapshot); err != nil {
|
||||
continue
|
||||
}
|
||||
var listLocation string
|
||||
if err := json.Unmarshal(snapshot["manifest-list"], &listLocation); err != nil || listLocation == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
repairedLocation, changed, err := repairManifestList(ctx, store, tableLocation, listLocation)
|
||||
if err != nil {
|
||||
glog.Warningf("Iceberg: manifest repair for %s skipped: %v", listLocation, err)
|
||||
continue
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
|
||||
locationJSON, err := json.Marshal(repairedLocation)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
snapshot["manifest-list"] = locationJSON
|
||||
snapshotJSON, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
update["snapshot"] = snapshotJSON
|
||||
updateJSON, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[i] = updateJSON
|
||||
changedAny = true
|
||||
glog.V(1).Infof("Iceberg: repaired manifest list %s -> %s", listLocation, repairedLocation)
|
||||
}
|
||||
return out, changedAny
|
||||
}
|
||||
|
||||
// serverManifestStore reads and writes table metadata files through the filer.
|
||||
type serverManifestStore struct {
|
||||
server *Server
|
||||
tableLocation string
|
||||
}
|
||||
|
||||
func (st *serverManifestStore) split(location string) (bucket, tablePath, fileName string, err error) {
|
||||
fileName, ok := metadataFileName(st.tableLocation, location)
|
||||
if !ok {
|
||||
return "", "", "", fmt.Errorf("%s is outside the table metadata directory", location)
|
||||
}
|
||||
bucket, tablePath, err = parseS3Location(st.tableLocation)
|
||||
return bucket, tablePath, fileName, err
|
||||
}
|
||||
|
||||
func (st *serverManifestStore) loadFile(ctx context.Context, location string) ([]byte, error) {
|
||||
bucket, tablePath, fileName, err := st.split(location)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st.server.loadTableMetadataBlob(ctx, bucket, tablePath, fileName)
|
||||
}
|
||||
|
||||
func (st *serverManifestStore) saveFile(ctx context.Context, location string, data []byte) error {
|
||||
bucket, tablePath, fileName, err := st.split(location)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return st.server.saveMetadataBlob(ctx, bucket, tablePath, fileName, data, "application/avro", false)
|
||||
}
|
||||
|
||||
// repairAddSnapshotManifests is the server entry point used by the commit
|
||||
// handlers.
|
||||
func (s *Server) repairAddSnapshotManifests(ctx context.Context, tableLocation string, rawUpdates []json.RawMessage) ([]json.RawMessage, bool) {
|
||||
store := &serverManifestStore{server: s, tableLocation: tableLocation}
|
||||
return repairAddSnapshotUpdates(ctx, store, tableLocation, rawUpdates)
|
||||
}
|
||||
|
||||
// lookupFileIDAdapter lets filer.StreamContent resolve volume locations
|
||||
// through the iceberg server's filer client.
|
||||
type lookupFileIDAdapter struct {
|
||||
fn wdclient.LookupFileIdFunctionType
|
||||
}
|
||||
|
||||
func (a *lookupFileIDAdapter) GetLookupFileIdFunction() wdclient.LookupFileIdFunctionType {
|
||||
return a.fn
|
||||
}
|
||||
|
||||
// loadTableMetadataBlob reads a file from the table's metadata directory,
|
||||
// following chunks for files that were written through the S3 gateway rather
|
||||
// than inline by the catalog itself.
|
||||
func (s *Server) loadTableMetadataBlob(ctx context.Context, bucketName, tablePath, fileName string) ([]byte, error) {
|
||||
var entry *filer_pb.Entry
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: metadataDirPath(bucketName, tablePath),
|
||||
Name: fileName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry = resp.Entry
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, fmt.Errorf("no entry for %s", fileName)
|
||||
}
|
||||
if len(entry.Content) > 0 || len(entry.GetChunks()) == 0 {
|
||||
return entry.Content, nil
|
||||
}
|
||||
if size := filer.FileSize(entry); size > maxRepairableManifestSize {
|
||||
return nil, fmt.Errorf("%s is %d bytes, larger than the %d byte repair limit", fileName, size, maxRepairableManifestSize)
|
||||
}
|
||||
|
||||
fullClient, ok := s.filerClient.(filer_pb.FilerClient)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("filer client cannot resolve chunk locations")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
lookup := &lookupFileIDAdapter{fn: filer.LookupFn(fullClient)}
|
||||
if err := filer.StreamContent(lookup, &buf, entry.GetChunks(), 0, int64(filer.FileSize(entry))); err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", fileName, err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
281
weed/s3api/iceberg/manifest_repair_test.go
Normal file
281
weed/s3api/iceberg/manifest_repair_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
icebergmanifest "github.com/apache/iceberg-go"
|
||||
"github.com/linkedin/goavro/v2"
|
||||
)
|
||||
|
||||
// The testdata fixtures were written by ClickHouse 25.8's experimental
|
||||
// Iceberg insert against a SeaweedFS table bucket. clickhouse_rel holds the
|
||||
// default output (bucket-relative paths, no field-id annotations);
|
||||
// clickhouse_abs was written with write_full_path_in_iceberg_metadata=1
|
||||
// (absolute paths, still no field-ids).
|
||||
|
||||
type fakeManifestStore struct {
|
||||
files map[string][]byte
|
||||
saved map[string][]byte
|
||||
}
|
||||
|
||||
func newFakeManifestStore() *fakeManifestStore {
|
||||
return &fakeManifestStore{files: map[string][]byte{}, saved: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (f *fakeManifestStore) loadFile(_ context.Context, location string) ([]byte, error) {
|
||||
if data, ok := f.files[location]; ok {
|
||||
return data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no file at %s", location)
|
||||
}
|
||||
|
||||
func (f *fakeManifestStore) saveFile(_ context.Context, location string, data []byte) error {
|
||||
f.files[location] = data
|
||||
f.saved[location] = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadFixture(t *testing.T, parts ...string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(append([]string{"testdata"}, parts...)...))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const (
|
||||
relTableLocation = "s3://iceberg-tables/wprobe/target_rel"
|
||||
relListName = "snap-449670589-2-48b874af-90f6-4e5e-8002-df91e59c8a0c.avro"
|
||||
relManifestName = "690958a2-9936-4a04-8117-d8104c977195.avro"
|
||||
absTableLocation = "s3://iceberg-tables/wprobe/target"
|
||||
absListName = "snap-331611856-2-51798e6c-427b-4fc2-9149-99e75d96e045.avro"
|
||||
absManifestName = "b317f0e5-10f3-41d6-86bc-8b42f01f3e86.avro"
|
||||
)
|
||||
|
||||
// verifyRepairedManifests checks the repaired files with iceberg-go's strict
|
||||
// typed readers: the manifest list parses, references the repaired manifest
|
||||
// with the right length, and the manifest's data file path is absolute.
|
||||
func verifyRepairedManifests(t *testing.T, store *fakeManifestStore, listLocation, tableLocation string) {
|
||||
t.Helper()
|
||||
|
||||
listBytes, ok := store.files[listLocation]
|
||||
if !ok {
|
||||
t.Fatalf("repaired manifest list %s was not saved", listLocation)
|
||||
}
|
||||
if !bytes.Contains(listBytes, []byte(`"field-id"`)) {
|
||||
t.Fatalf("repaired manifest list schema has no field-id annotations")
|
||||
}
|
||||
|
||||
manifests, err := icebergmanifest.ReadManifestList(bytes.NewReader(listBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("iceberg-go cannot read repaired manifest list: %v", err)
|
||||
}
|
||||
if len(manifests) != 1 {
|
||||
t.Fatalf("manifest list has %d entries, want 1", len(manifests))
|
||||
}
|
||||
manifestPath := manifests[0].FilePath()
|
||||
if !strings.HasPrefix(manifestPath, tableLocation+"/metadata/") {
|
||||
t.Fatalf("manifest path %s is not absolute under the table location", manifestPath)
|
||||
}
|
||||
|
||||
manifestBytes, ok := store.files[manifestPath]
|
||||
if !ok {
|
||||
t.Fatalf("manifest %s not present in store", manifestPath)
|
||||
}
|
||||
if manifests[0].Length() != int64(len(manifestBytes)) {
|
||||
t.Fatalf("manifest_length %d != stored size %d", manifests[0].Length(), len(manifestBytes))
|
||||
}
|
||||
if !bytes.Contains(manifestBytes, []byte(`"field-id"`)) {
|
||||
t.Fatalf("repaired manifest schema has no field-id annotations")
|
||||
}
|
||||
|
||||
entries, err := icebergmanifest.ReadManifest(manifests[0], bytes.NewReader(manifestBytes), false)
|
||||
if err != nil {
|
||||
t.Fatalf("iceberg-go cannot read repaired manifest: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("manifest has %d entries, want 1", len(entries))
|
||||
}
|
||||
dataFile := entries[0].DataFile()
|
||||
if !strings.HasPrefix(dataFile.FilePath(), "s3://iceberg-tables/") {
|
||||
t.Fatalf("data file path %s is not absolute", dataFile.FilePath())
|
||||
}
|
||||
if dataFile.Count() != 2 {
|
||||
t.Fatalf("record count %d, want 2", dataFile.Count())
|
||||
}
|
||||
if len(dataFile.LowerBoundValues()) == 0 {
|
||||
t.Fatalf("lower bounds were lost in repair")
|
||||
}
|
||||
}
|
||||
|
||||
func addSnapshotUpdateJSON(t *testing.T, manifestList string) []json.RawMessage {
|
||||
t.Helper()
|
||||
update := fmt.Sprintf(`{"action":"add-snapshot","snapshot":{"snapshot-id":449670589,"sequence-number":1,"timestamp-ms":1754625869000,"manifest-list":%q,"summary":{"operation":"append"},"schema-id":0}}`, manifestList)
|
||||
return []json.RawMessage{json.RawMessage(update)}
|
||||
}
|
||||
|
||||
func TestRepairClickHouseRelativeManifests(t *testing.T) {
|
||||
store := newFakeManifestStore()
|
||||
store.files[relTableLocation+"/metadata/"+relListName] = loadFixture(t, "clickhouse_rel", relListName)
|
||||
store.files[relTableLocation+"/metadata/"+relManifestName] = loadFixture(t, "clickhouse_rel", relManifestName)
|
||||
|
||||
rawUpdates := addSnapshotUpdateJSON(t, "wprobe/target_rel/metadata/"+relListName)
|
||||
repaired, changed := repairAddSnapshotUpdates(context.Background(), store, relTableLocation, rawUpdates)
|
||||
if !changed {
|
||||
t.Fatal("expected repair to change the update")
|
||||
}
|
||||
|
||||
var update struct {
|
||||
Snapshot struct {
|
||||
ManifestList string `json:"manifest-list"`
|
||||
SnapshotID int64 `json:"snapshot-id"`
|
||||
Summary struct {
|
||||
Operation string `json:"operation"`
|
||||
} `json:"summary"`
|
||||
} `json:"snapshot"`
|
||||
}
|
||||
if err := json.Unmarshal(repaired[0], &update); err != nil {
|
||||
t.Fatalf("unmarshal repaired update: %v", err)
|
||||
}
|
||||
wantList := relTableLocation + "/metadata/" + repairedManifestPrefix + relListName
|
||||
if update.Snapshot.ManifestList != wantList {
|
||||
t.Fatalf("manifest-list = %s, want %s", update.Snapshot.ManifestList, wantList)
|
||||
}
|
||||
// The rest of the snapshot survives the rewrite.
|
||||
if update.Snapshot.SnapshotID != 449670589 || update.Snapshot.Summary.Operation != "append" {
|
||||
t.Fatalf("snapshot fields were disturbed: %s", repaired[0])
|
||||
}
|
||||
|
||||
verifyRepairedManifests(t, store, wantList, relTableLocation)
|
||||
}
|
||||
|
||||
func TestRepairClickHouseAbsoluteManifests(t *testing.T) {
|
||||
store := newFakeManifestStore()
|
||||
listLocation := absTableLocation + "/metadata/" + absListName
|
||||
store.files[listLocation] = loadFixture(t, "clickhouse_abs", absListName)
|
||||
store.files[absTableLocation+"/metadata/"+absManifestName] = loadFixture(t, "clickhouse_abs", absManifestName)
|
||||
|
||||
repaired, changed, err := repairManifestList(context.Background(), store, absTableLocation, listLocation)
|
||||
if err != nil {
|
||||
t.Fatalf("repairManifestList: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("expected repair for missing field-ids")
|
||||
}
|
||||
verifyRepairedManifests(t, store, repaired, absTableLocation)
|
||||
}
|
||||
|
||||
func TestRepairedManifestsAreLeftAlone(t *testing.T) {
|
||||
store := newFakeManifestStore()
|
||||
listLocation := absTableLocation + "/metadata/" + absListName
|
||||
store.files[listLocation] = loadFixture(t, "clickhouse_abs", absListName)
|
||||
store.files[absTableLocation+"/metadata/"+absManifestName] = loadFixture(t, "clickhouse_abs", absManifestName)
|
||||
|
||||
firstPass, changed, err := repairManifestList(context.Background(), store, absTableLocation, listLocation)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("first pass: changed=%v err=%v", changed, err)
|
||||
}
|
||||
|
||||
store.saved = map[string][]byte{}
|
||||
secondPass, changed, err := repairManifestList(context.Background(), store, absTableLocation, firstPass)
|
||||
if err != nil {
|
||||
t.Fatalf("second pass: %v", err)
|
||||
}
|
||||
if changed || secondPass != firstPass {
|
||||
t.Fatalf("second pass rewrote an already-repaired list: changed=%v location=%s", changed, secondPass)
|
||||
}
|
||||
if len(store.saved) != 0 {
|
||||
t.Fatalf("second pass saved files: %v", store.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairKeepsDeleteManifestContent(t *testing.T) {
|
||||
// The fixture lacks the OCF "content" key; the repaired copy must take the
|
||||
// content declared by the manifest-list entry, not default to data.
|
||||
raw := loadFixture(t, "clickhouse_rel", relManifestName)
|
||||
repaired, changed, err := repairManifest(raw, relTableLocation, 1)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("repairManifest: changed=%v err=%v", changed, err)
|
||||
}
|
||||
reader, err := goavro.NewOCFReader(bytes.NewReader(repaired))
|
||||
if err != nil {
|
||||
t.Fatalf("read repaired manifest: %v", err)
|
||||
}
|
||||
if got := string(reader.MetaData()["content"]); got != "deletes" {
|
||||
t.Fatalf(`OCF content = %q, want "deletes"`, got)
|
||||
}
|
||||
|
||||
asData, _, err := repairManifest(raw, relTableLocation, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("repairManifest: %v", err)
|
||||
}
|
||||
reader, err = goavro.NewOCFReader(bytes.NewReader(asData))
|
||||
if err != nil {
|
||||
t.Fatalf("read repaired manifest: %v", err)
|
||||
}
|
||||
if got := string(reader.MetaData()["content"]); got != "data" {
|
||||
t.Fatalf(`OCF content = %q, want "data"`, got)
|
||||
}
|
||||
|
||||
// An existing content value that contradicts the manifest-list entry is
|
||||
// corrected, not preserved.
|
||||
relabeled, changed, err := repairManifest(asData, relTableLocation, 1)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("repairManifest on mismatched content: changed=%v err=%v", changed, err)
|
||||
}
|
||||
reader, err = goavro.NewOCFReader(bytes.NewReader(relabeled))
|
||||
if err != nil {
|
||||
t.Fatalf("read relabeled manifest: %v", err)
|
||||
}
|
||||
if got := string(reader.MetaData()["content"]); got != "deletes" {
|
||||
t.Fatalf(`OCF content after relabel = %q, want "deletes"`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestContentValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
record map[string]any
|
||||
want int
|
||||
}{
|
||||
{map[string]any{"content": int32(1)}, 1},
|
||||
{map[string]any{"content": int64(1)}, 1},
|
||||
{map[string]any{"content": map[string]any{"int": int32(1)}}, 1},
|
||||
{map[string]any{"content": nil}, 0},
|
||||
{map[string]any{}, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := manifestContentValue(c.record); got != c.want {
|
||||
t.Errorf("manifestContentValue(%v) = %d, want %d", c.record, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairSkipsLocationsOutsideMetadataDir(t *testing.T) {
|
||||
store := newFakeManifestStore()
|
||||
rawUpdates := addSnapshotUpdateJSON(t, "s3://other-bucket/elsewhere/list.avro")
|
||||
_, changed := repairAddSnapshotUpdates(context.Background(), store, relTableLocation, rawUpdates)
|
||||
if changed {
|
||||
t.Fatal("repair touched a manifest list outside the table metadata directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairLeavesUnreadableListAlone(t *testing.T) {
|
||||
store := newFakeManifestStore()
|
||||
store.files[relTableLocation+"/metadata/junk.avro"] = []byte("not avro")
|
||||
rawUpdates := addSnapshotUpdateJSON(t, relTableLocation+"/metadata/junk.avro")
|
||||
repaired, changed := repairAddSnapshotUpdates(context.Background(), store, relTableLocation, rawUpdates)
|
||||
if changed {
|
||||
t.Fatal("repair claimed to change an unreadable manifest list")
|
||||
}
|
||||
if !bytes.Equal(repaired[0], rawUpdates[0]) {
|
||||
t.Fatal("update was modified despite repair failure")
|
||||
}
|
||||
}
|
||||
132
weed/s3api/iceberg/metadata_compliance.go
Normal file
132
weed/s3api/iceberg/metadata_compliance.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// specRequiredEmptyOrder is iterated in this fixed order so the byte-level
|
||||
// append in ensureMetadataSpecCompliance produces deterministic output.
|
||||
var specRequiredEmptyOrder = []string{
|
||||
"current-snapshot-id",
|
||||
"snapshots",
|
||||
"snapshot-log",
|
||||
"metadata-log",
|
||||
"refs",
|
||||
}
|
||||
|
||||
// specRequiredEmptyDefaults holds the sentinel values Iceberg requires when
|
||||
// the corresponding state is empty.
|
||||
var specRequiredEmptyDefaults = map[string]json.RawMessage{
|
||||
"current-snapshot-id": json.RawMessage("-1"),
|
||||
"snapshots": json.RawMessage("[]"),
|
||||
"snapshot-log": json.RawMessage("[]"),
|
||||
"metadata-log": json.RawMessage("[]"),
|
||||
"refs": json.RawMessage("{}"),
|
||||
}
|
||||
|
||||
// isJSONNull reports whether raw encodes the JSON null literal, ignoring
|
||||
// insignificant whitespace allowed by RFC 8259.
|
||||
func isJSONNull(raw json.RawMessage) bool {
|
||||
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
||||
}
|
||||
|
||||
// ensureMetadataSpecCompliance backfills Iceberg spec-required fields that
|
||||
// iceberg-go v0.5.0 drops via `omitempty` on optional pointer/slice fields.
|
||||
//
|
||||
// For tables with no snapshots (e.g. freshly created tables), iceberg-go
|
||||
// represents an empty state with nil/empty Go values, then strips those keys
|
||||
// entirely from the JSON output. The Iceberg REST spec, however, requires
|
||||
// these keys to be present even when empty — Java/Spark/Trino clients
|
||||
// fail to parse responses missing them (most visibly with
|
||||
// "Cannot parse missing long current-snapshot-id"). This helper rehydrates
|
||||
// the missing keys without overwriting any present real values.
|
||||
//
|
||||
// Missing keys are spliced in at the byte level just before the closing
|
||||
// brace so iceberg-go's struct-declared key order is preserved (a naive
|
||||
// map remarshal would alphabetize every key in the document). The slower
|
||||
// remarshal path is only used when an explicit JSON null needs replacing,
|
||||
// which iceberg-go itself never emits.
|
||||
//
|
||||
// Returns the original bytes unchanged when parsing fails or no key needs
|
||||
// to be added, to avoid corrupting an otherwise-valid payload.
|
||||
func ensureMetadataSpecCompliance(raw []byte) []byte {
|
||||
if len(raw) == 0 {
|
||||
return raw
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return raw
|
||||
}
|
||||
// A top-level JSON null leaves obj == nil with no error; writes would
|
||||
// panic on the slow path. It is not a valid TableMetadata document, so
|
||||
// pass it through unchanged.
|
||||
if obj == nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
// A field is "missing" for spec purposes if it's absent OR encoded as
|
||||
// JSON null. Some writers emit `"current-snapshot-id": null` for empty
|
||||
// state instead of omitting; strict clients reject both the same way.
|
||||
var toAppend []string
|
||||
hasExplicitNull := false
|
||||
for _, key := range specRequiredEmptyOrder {
|
||||
v, present := obj[key]
|
||||
switch {
|
||||
case !present:
|
||||
toAppend = append(toAppend, key)
|
||||
case isJSONNull(v):
|
||||
hasExplicitNull = true
|
||||
}
|
||||
}
|
||||
if len(toAppend) == 0 && !hasExplicitNull {
|
||||
return raw
|
||||
}
|
||||
|
||||
if !hasExplicitNull {
|
||||
return appendMissingObjectKeys(raw, toAppend, len(obj) > 0)
|
||||
}
|
||||
|
||||
// Slow path: replace explicit nulls. Rare in practice, so the
|
||||
// alphabetical key reordering inherent to map remarshal is acceptable.
|
||||
for key, v := range obj {
|
||||
if _, required := specRequiredEmptyDefaults[key]; required && isJSONNull(v) {
|
||||
obj[key] = specRequiredEmptyDefaults[key]
|
||||
}
|
||||
}
|
||||
for _, key := range toAppend {
|
||||
obj[key] = specRequiredEmptyDefaults[key]
|
||||
}
|
||||
fixed, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
|
||||
// appendMissingObjectKeys splices the given keys into raw just before the
|
||||
// closing brace of the top-level JSON object. raw is assumed to be a valid
|
||||
// JSON object (the caller has already unmarshalled it). hasMembers tells us
|
||||
// whether the object had any existing members, so we know whether to emit a
|
||||
// leading comma for the first appended key.
|
||||
func appendMissingObjectKeys(raw []byte, keys []string, hasMembers bool) []byte {
|
||||
closeIdx := bytes.LastIndexByte(raw, '}')
|
||||
if closeIdx < 0 {
|
||||
return raw
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.Grow(len(raw) + len(keys)*48)
|
||||
buf.Write(raw[:closeIdx])
|
||||
for _, key := range keys {
|
||||
if hasMembers {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
buf.WriteByte('"')
|
||||
buf.WriteString(key)
|
||||
buf.WriteString(`":`)
|
||||
buf.Write(specRequiredEmptyDefaults[key])
|
||||
hasMembers = true
|
||||
}
|
||||
buf.Write(raw[closeIdx:])
|
||||
return buf.Bytes()
|
||||
}
|
||||
162
weed/s3api/iceberg/metadata_compliance_test.go
Normal file
162
weed/s3api/iceberg/metadata_compliance_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureMetadataSpecCompliance_BackfillsMissingFields(t *testing.T) {
|
||||
// Mirrors the real iceberg-go v0.5.0 output for a freshly created table:
|
||||
// current-snapshot-id, snapshots, snapshot-log, metadata-log, refs all absent.
|
||||
input := []byte(`{
|
||||
"format-version": 2,
|
||||
"table-uuid": "82e3eec4-3aee-414f-a444-94c03c641d20",
|
||||
"location": "s3://s3table/default/t1",
|
||||
"last-sequence-number": 0,
|
||||
"last-updated-ms": 1779866785466,
|
||||
"last-column-id": 2,
|
||||
"current-schema-id": 0,
|
||||
"default-spec-id": 0,
|
||||
"last-partition-id": 999,
|
||||
"default-sort-order-id": 0
|
||||
}`)
|
||||
|
||||
out := ensureMetadataSpecCompliance(input)
|
||||
|
||||
var got map[string]json.RawMessage
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if v, ok := got["current-snapshot-id"]; !ok || string(v) != "-1" {
|
||||
t.Errorf("current-snapshot-id missing or wrong: present=%v value=%s", ok, string(v))
|
||||
}
|
||||
for _, key := range []string{"snapshots", "snapshot-log", "metadata-log"} {
|
||||
v, ok := got[key]
|
||||
if !ok || string(v) != "[]" {
|
||||
t.Errorf("%s missing or wrong: present=%v value=%s", key, ok, string(v))
|
||||
}
|
||||
}
|
||||
if v, ok := got["refs"]; !ok || string(v) != "{}" {
|
||||
t.Errorf("refs missing or wrong: present=%v value=%s", ok, string(v))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMetadataSpecCompliance_PreservesExistingFields(t *testing.T) {
|
||||
// Real snapshot id and refs must not be overwritten by sentinels.
|
||||
input := []byte(`{
|
||||
"format-version": 2,
|
||||
"current-snapshot-id": 9876543210,
|
||||
"snapshots": [{"snapshot-id": 9876543210}],
|
||||
"snapshot-log": [{"snapshot-id": 9876543210, "timestamp-ms": 1}],
|
||||
"metadata-log": [{"metadata-file": "v1.metadata.json", "timestamp-ms": 1}],
|
||||
"refs": {"main": {"snapshot-id": 9876543210, "type": "branch"}}
|
||||
}`)
|
||||
|
||||
out := ensureMetadataSpecCompliance(input)
|
||||
|
||||
var got map[string]json.RawMessage
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
if string(got["current-snapshot-id"]) != "9876543210" {
|
||||
t.Errorf("real snapshot id was overwritten: %s", string(got["current-snapshot-id"]))
|
||||
}
|
||||
if string(got["snapshots"]) == "[]" {
|
||||
t.Errorf("non-empty snapshots was overwritten with empty array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMetadataSpecCompliance_ReplacesExplicitNullsWithSentinels(t *testing.T) {
|
||||
// Some writers emit explicit JSON null for unset values instead of omitting
|
||||
// the key. Strict Iceberg clients reject these the same way as missing keys.
|
||||
input := []byte(`{
|
||||
"format-version": 2,
|
||||
"current-snapshot-id": null,
|
||||
"snapshots": null,
|
||||
"snapshot-log": null,
|
||||
"metadata-log": null,
|
||||
"refs": null
|
||||
}`)
|
||||
|
||||
out := ensureMetadataSpecCompliance(input)
|
||||
|
||||
var got map[string]json.RawMessage
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
if string(got["current-snapshot-id"]) != "-1" {
|
||||
t.Errorf("current-snapshot-id should be replaced with -1, got %s", string(got["current-snapshot-id"]))
|
||||
}
|
||||
for _, key := range []string{"snapshots", "snapshot-log", "metadata-log"} {
|
||||
if string(got[key]) != "[]" {
|
||||
t.Errorf("%s should be replaced with [], got %s", key, string(got[key]))
|
||||
}
|
||||
}
|
||||
if string(got["refs"]) != "{}" {
|
||||
t.Errorf("refs should be replaced with {}, got %s", string(got["refs"]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMetadataSpecCompliance_InvalidJSONReturnedUnchanged(t *testing.T) {
|
||||
input := []byte(`{not valid json`)
|
||||
out := ensureMetadataSpecCompliance(input)
|
||||
if string(out) != string(input) {
|
||||
t.Errorf("invalid JSON should be returned unchanged; got %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMetadataSpecCompliance_EmptyInputReturnedUnchanged(t *testing.T) {
|
||||
if out := ensureMetadataSpecCompliance(nil); out != nil {
|
||||
t.Errorf("nil input should be returned unchanged, got %v", out)
|
||||
}
|
||||
if out := ensureMetadataSpecCompliance([]byte{}); len(out) != 0 {
|
||||
t.Errorf("empty input should be returned unchanged, got %v", out)
|
||||
}
|
||||
// A top-level JSON null literal must not panic on the slow path.
|
||||
if out := ensureMetadataSpecCompliance([]byte("null")); string(out) != "null" {
|
||||
t.Errorf("top-level null should be returned unchanged, got %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
// Original iceberg-go key ordering must survive the backfill: appended
|
||||
// sentinels go at the end without disturbing prior fields. A map-based
|
||||
// remarshal would have sorted everything alphabetically.
|
||||
func TestEnsureMetadataSpecCompliance_PreservesOriginalKeyOrder(t *testing.T) {
|
||||
// Compact JSON, keys in struct-declared order from iceberg-go.
|
||||
input := []byte(`{"format-version":2,"table-uuid":"82e3eec4-3aee-414f-a444-94c03c641d20","location":"s3://x/t","last-sequence-number":0,"last-updated-ms":1,"last-column-id":2,"current-schema-id":0,"default-spec-id":0,"last-partition-id":999,"default-sort-order-id":0}`)
|
||||
|
||||
out := ensureMetadataSpecCompliance(input)
|
||||
|
||||
// Prior keys keep their order, sentinels appended at the end.
|
||||
want := `{"format-version":2,"table-uuid":"82e3eec4-3aee-414f-a444-94c03c641d20","location":"s3://x/t","last-sequence-number":0,"last-updated-ms":1,"last-column-id":2,"current-schema-id":0,"default-spec-id":0,"last-partition-id":999,"default-sort-order-id":0,"current-snapshot-id":-1,"snapshots":[],"snapshot-log":[],"metadata-log":[],"refs":{}}`
|
||||
if string(out) != want {
|
||||
t.Errorf("unexpected output\n got: %s\nwant: %s", string(out), want)
|
||||
}
|
||||
|
||||
// Sanity: still valid JSON.
|
||||
var parsed map[string]json.RawMessage
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty object {} must round-trip to a valid JSON object containing
|
||||
// only the spec sentinels (no leading comma).
|
||||
func TestEnsureMetadataSpecCompliance_EmptyObjectBackfilled(t *testing.T) {
|
||||
out := ensureMetadataSpecCompliance([]byte(`{}`))
|
||||
want := `{"current-snapshot-id":-1,"snapshots":[],"snapshot-log":[],"metadata-log":[],"refs":{}}`
|
||||
if string(out) != want {
|
||||
t.Errorf("unexpected output\n got: %s\nwant: %s", string(out), want)
|
||||
}
|
||||
}
|
||||
|
||||
// When all fields are already present, the original bytes must be returned
|
||||
// untouched (no whitespace normalization, no key reordering).
|
||||
func TestEnsureMetadataSpecCompliance_AllPresentReturnsSameBytes(t *testing.T) {
|
||||
input := []byte("{\n \"current-snapshot-id\": 1,\n \"snapshots\": [],\n \"snapshot-log\": [],\n \"metadata-log\": [],\n \"refs\": {}\n}")
|
||||
out := ensureMetadataSpecCompliance(input)
|
||||
if string(out) != string(input) {
|
||||
t.Errorf("expected original bytes returned unchanged\n got: %q\nwant: %q", string(out), string(input))
|
||||
}
|
||||
}
|
||||
171
weed/s3api/iceberg/metadata_files.go
Normal file
171
weed/s3api/iceberg/metadata_files.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
||||
)
|
||||
|
||||
// metadataDirPath returns the filer directory holding a table's metadata files.
|
||||
func metadataDirPath(bucketName, tablePath string) string {
|
||||
dir := path.Join(s3tables.TablesPath, bucketName)
|
||||
if tablePath != "" {
|
||||
dir = path.Join(dir, tablePath)
|
||||
}
|
||||
return path.Join(dir, "metadata")
|
||||
}
|
||||
|
||||
// saveMetadataFile saves the Iceberg metadata JSON file to the filer.
|
||||
// It constructs the filer path from the S3 location components. With exclusive
|
||||
// set the write fails with filer_pb.ErrEntryAlreadyExists rather than replacing
|
||||
// a file that is already there: two commits racing off the same base pick the
|
||||
// same v{N} name, and letting the second overwrite the first would leave the
|
||||
// winner's catalog pointer aimed at the loser's metadata.
|
||||
func (s *Server) saveMetadataFile(ctx context.Context, bucketName, tablePath, metadataFileName string, content []byte, exclusive bool) error {
|
||||
return s.saveMetadataBlob(ctx, bucketName, tablePath, metadataFileName, content, "application/json", exclusive)
|
||||
}
|
||||
|
||||
// saveMetadataBlob saves a file into the table's metadata directory.
|
||||
func (s *Server) saveMetadataBlob(ctx context.Context, bucketName, tablePath, metadataFileName string, content []byte, mimeType string, exclusive bool) error {
|
||||
|
||||
// Create context with timeout for file operations
|
||||
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
bucketsPath := s3tables.TablesPath
|
||||
|
||||
ensureDir := func(parent, name, errorContext string) error {
|
||||
_, err := filer_pb.LookupEntry(opCtx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: parent,
|
||||
Name: name,
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if err != filer_pb.ErrNotFound {
|
||||
return fmt.Errorf("lookup %s failed: %w", errorContext, err)
|
||||
}
|
||||
|
||||
// If lookup fails with ErrNotFound, try to create the directory.
|
||||
resp, createErr := client.CreateEntry(opCtx, &filer_pb.CreateEntryRequest{
|
||||
Directory: parent,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: name,
|
||||
IsDirectory: true,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0755 | os.ModeDir),
|
||||
},
|
||||
},
|
||||
})
|
||||
if createErr != nil {
|
||||
return fmt.Errorf("failed to create %s: %w", errorContext, createErr)
|
||||
}
|
||||
if resp.ErrorCode != filer_pb.FilerError_OK && resp.ErrorCode != filer_pb.FilerError_ENTRY_ALREADY_EXISTS {
|
||||
return fmt.Errorf("failed to create %s: %s", errorContext, resp.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
bucketDir := path.Join(bucketsPath, bucketName)
|
||||
// 1. Ensure bucket directory exists: <bucketsPath>/<bucket>
|
||||
if err := ensureDir(bucketsPath, bucketName, "bucket directory"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Ensure table path exists under the bucket directory
|
||||
tableDir := bucketDir
|
||||
if tablePath != "" {
|
||||
segments := strings.Split(tablePath, "/")
|
||||
for _, segment := range segments {
|
||||
if segment == "" {
|
||||
continue
|
||||
}
|
||||
if err := ensureDir(tableDir, segment, "table directory"); err != nil {
|
||||
return err
|
||||
}
|
||||
tableDir = path.Join(tableDir, segment)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Ensure metadata directory exists: <bucketsPath>/<bucket>/<tablePath>/metadata
|
||||
metadataDir := path.Join(tableDir, "metadata")
|
||||
if err := ensureDir(tableDir, "metadata", "metadata directory"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. Write the file
|
||||
resp, err := client.CreateEntry(opCtx, &filer_pb.CreateEntryRequest{
|
||||
Directory: metadataDir,
|
||||
OExcl: exclusive,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: metadataFileName,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0644),
|
||||
FileSize: uint64(len(content)),
|
||||
},
|
||||
Content: content,
|
||||
Extended: map[string][]byte{
|
||||
"Mime-Type": []byte(mimeType),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write metadata file: %w", err)
|
||||
}
|
||||
if resp.ErrorCode != filer_pb.FilerError_OK {
|
||||
if sentinel := filer_pb.FilerErrorToSentinel(resp.ErrorCode); sentinel != nil {
|
||||
return fmt.Errorf("failed to write metadata file: %w", sentinel)
|
||||
}
|
||||
return fmt.Errorf("failed to write metadata file: code=%v %s", resp.ErrorCode, resp.Error)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) deleteMetadataFile(ctx context.Context, bucketName, tablePath, metadataFileName string) error {
|
||||
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
metadataDir := metadataDirPath(bucketName, tablePath)
|
||||
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
return filer_pb.DoRemove(opCtx, client, metadataDir, metadataFileName, true, false, true, false, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) loadMetadataFile(ctx context.Context, bucketName, tablePath, metadataFileName string) ([]byte, error) {
|
||||
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
metadataDir := metadataDirPath(bucketName, tablePath)
|
||||
|
||||
var content []byte
|
||||
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := filer_pb.LookupEntry(opCtx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: metadataDir,
|
||||
Name: metadataFileName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil || resp.Entry == nil {
|
||||
return fmt.Errorf("lookup returned nil entry for %s/%s", metadataDir, metadataFileName)
|
||||
}
|
||||
content = append([]byte(nil), resp.Entry.Content...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
128
weed/s3api/iceberg/metadata_files_test.go
Normal file
128
weed/s3api/iceberg/metadata_files_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// stubFilerClient answers only the calls the metadata writers make, tracking
|
||||
// which paths exist so exclusive creates can be exercised.
|
||||
type stubFilerClient struct {
|
||||
filer_pb.SeaweedFilerClient
|
||||
entries map[string][]byte
|
||||
}
|
||||
|
||||
func newStubFilerClient() *stubFilerClient {
|
||||
return &stubFilerClient{entries: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (c *stubFilerClient) WithFilerClient(_ bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
|
||||
return fn(c)
|
||||
}
|
||||
|
||||
func (c *stubFilerClient) LookupDirectoryEntry(_ context.Context, req *filer_pb.LookupDirectoryEntryRequest, _ ...grpc.CallOption) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
||||
key := path.Join(req.Directory, req.Name)
|
||||
if _, ok := c.entries[key]; !ok {
|
||||
return nil, filer_pb.ErrNotFound
|
||||
}
|
||||
return &filer_pb.LookupDirectoryEntryResponse{Entry: &filer_pb.Entry{Name: req.Name}}, nil
|
||||
}
|
||||
|
||||
func (c *stubFilerClient) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest, _ ...grpc.CallOption) (*filer_pb.CreateEntryResponse, error) {
|
||||
key := path.Join(req.Directory, req.Entry.Name)
|
||||
if _, exists := c.entries[key]; exists && req.OExcl {
|
||||
return &filer_pb.CreateEntryResponse{
|
||||
Error: "entry already exists",
|
||||
ErrorCode: filer_pb.FilerError_ENTRY_ALREADY_EXISTS,
|
||||
}, nil
|
||||
}
|
||||
c.entries[key] = req.Entry.Content
|
||||
return &filer_pb.CreateEntryResponse{}, nil
|
||||
}
|
||||
|
||||
// Two commits racing off the same base metadata pick the same v{N} file name.
|
||||
// The loser must be told, not allowed to replace the winner's metadata.
|
||||
func TestSaveMetadataFileExclusiveRefusesToOverwrite(t *testing.T) {
|
||||
client := newStubFilerClient()
|
||||
s := &Server{filerClient: client}
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"winner":true}`), true); err != nil {
|
||||
t.Fatalf("first write failed: %v", err)
|
||||
}
|
||||
|
||||
err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"loser":true}`), true)
|
||||
if !errors.Is(err, filer_pb.ErrEntryAlreadyExists) {
|
||||
t.Fatalf("second write err = %v, want ErrEntryAlreadyExists", err)
|
||||
}
|
||||
|
||||
stored := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), "v2.metadata.json")])
|
||||
if stored != `{"winner":true}` {
|
||||
t.Errorf("stored metadata = %s, want the first writer's content", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// A metadata file left behind by an interrupted commit must not wedge the
|
||||
// table: the next commit stages under a unique name rather than failing or
|
||||
// overwriting, and the catalog pointer still decides the winner.
|
||||
func TestStageCommitMetadataFallsBackToAUniqueName(t *testing.T) {
|
||||
client := newStubFilerClient()
|
||||
s := &Server{filerClient: client}
|
||||
ctx := context.Background()
|
||||
|
||||
name, location, err := s.stageCommitMetadata(ctx, "bkt", "ns/tbl", "s3://bkt/ns/tbl", "v2.metadata.json", []byte(`{"orphan":true}`))
|
||||
if err != nil {
|
||||
t.Fatalf("first stage failed: %v", err)
|
||||
}
|
||||
if name != "v2.metadata.json" {
|
||||
t.Errorf("first stage used %q, want the plain versioned name", name)
|
||||
}
|
||||
if location != "s3://bkt/ns/tbl/metadata/v2.metadata.json" {
|
||||
t.Errorf("location = %q", location)
|
||||
}
|
||||
|
||||
name, location, err = s.stageCommitMetadata(ctx, "bkt", "ns/tbl", "s3://bkt/ns/tbl", "v2.metadata.json", []byte(`{"second":true}`))
|
||||
if err != nil {
|
||||
t.Fatalf("second stage failed: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(name, "v2-") || !strings.HasSuffix(name, ".metadata.json") {
|
||||
t.Errorf("second stage used %q, want a unique v2-* name", name)
|
||||
}
|
||||
if location != "s3://bkt/ns/tbl/metadata/"+name {
|
||||
t.Errorf("location = %q, want it to match the staged name", location)
|
||||
}
|
||||
|
||||
stored := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), "v2.metadata.json")])
|
||||
if stored != `{"orphan":true}` {
|
||||
t.Errorf("the first file was overwritten: %s", stored)
|
||||
}
|
||||
if fallback := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), name)]); fallback != `{"second":true}` {
|
||||
t.Errorf("fallback file holds %s, want the second writer's metadata", fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// Paths that legitimately rewrite a file, such as manifest repair, keep the
|
||||
// overwriting behaviour.
|
||||
func TestSaveMetadataFileOverwrites(t *testing.T) {
|
||||
client := newStubFilerClient()
|
||||
s := &Server{filerClient: client}
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"first":true}`), false); err != nil {
|
||||
t.Fatalf("first write failed: %v", err)
|
||||
}
|
||||
if err := s.saveMetadataFile(ctx, "bkt", "ns/tbl", "v2.metadata.json", []byte(`{"second":true}`), false); err != nil {
|
||||
t.Fatalf("second write failed: %v", err)
|
||||
}
|
||||
|
||||
stored := string(client.entries[path.Join(metadataDirPath("bkt", "ns/tbl"), "v2.metadata.json")])
|
||||
if stored != `{"second":true}` {
|
||||
t.Errorf("stored metadata = %s, want the second write", stored)
|
||||
}
|
||||
}
|
||||
142
weed/s3api/iceberg/name_mapping.go
Normal file
142
weed/s3api/iceberg/name_mapping.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
)
|
||||
|
||||
// nameMappingFromSchema derives the spec's field-id-to-names mapping from a
|
||||
// table schema, mirroring Java's MappingUtil.create. List elements and map
|
||||
// keys/values use the canonical "element"/"key"/"value" names.
|
||||
func nameMappingFromSchema(schema *iceberg.Schema) iceberg.NameMapping {
|
||||
return mappedFieldsOf(schema.Fields())
|
||||
}
|
||||
|
||||
func mappedFieldsOf(fields []iceberg.NestedField) []iceberg.MappedField {
|
||||
mapped := make([]iceberg.MappedField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
id := field.ID
|
||||
mapped = append(mapped, iceberg.MappedField{
|
||||
Names: []string{field.Name},
|
||||
FieldID: &id,
|
||||
Fields: mappedFieldsOfType(field.Type),
|
||||
})
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
func mappedFieldsOfType(typ iceberg.Type) []iceberg.MappedField {
|
||||
switch t := typ.(type) {
|
||||
case *iceberg.StructType:
|
||||
return mappedFieldsOf(t.FieldList)
|
||||
case *iceberg.ListType:
|
||||
return mappedFieldsOf([]iceberg.NestedField{t.ElementField()})
|
||||
case *iceberg.MapType:
|
||||
return mappedFieldsOf([]iceberg.NestedField{t.KeyField(), t.ValueField()})
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// refreshDefaultNameMapping keeps schema.name-mapping.default in sync with
|
||||
// the current schema across commits, so schema evolution does not strand
|
||||
// readers of field-id-less data files on the creation-time mapping. The
|
||||
// stored mapping is merged rather than replaced: per field-id, names already
|
||||
// present are kept alongside the current schema name, so files written under
|
||||
// a renamed column's old name (and user-added aliases) stay resolvable.
|
||||
// Returns the (possibly patched) serialized metadata.
|
||||
func refreshDefaultNameMapping(raw []byte, updated table.Metadata) []byte {
|
||||
if updated == nil {
|
||||
return raw
|
||||
}
|
||||
updatedSchema := updated.CurrentSchema()
|
||||
if updatedSchema == nil || len(updatedSchema.Fields()) == 0 {
|
||||
return raw
|
||||
}
|
||||
mapping := nameMappingFromSchema(updatedSchema)
|
||||
|
||||
existing := updated.Properties()[table.DefaultNameMappingKey]
|
||||
if existing != "" {
|
||||
var existingMapping iceberg.NameMapping
|
||||
if err := json.Unmarshal([]byte(existing), &existingMapping); err != nil {
|
||||
// Not a mapping we can merge; leave whatever the user stored.
|
||||
return raw
|
||||
}
|
||||
mapping = mergeMappedFields(existingMapping, mapping)
|
||||
}
|
||||
wantJSON, err := json.Marshal(mapping)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
want := string(wantJSON)
|
||||
if existing == want {
|
||||
return raw
|
||||
}
|
||||
|
||||
var metadata map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil {
|
||||
return raw
|
||||
}
|
||||
properties := map[string]json.RawMessage{}
|
||||
if rawProperties, ok := metadata["properties"]; ok {
|
||||
if err := json.Unmarshal(rawProperties, &properties); err != nil {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
valueJSON, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
properties[table.DefaultNameMappingKey] = valueJSON
|
||||
propertiesJSON, err := json.Marshal(properties)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
metadata["properties"] = propertiesJSON
|
||||
patched, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return patched
|
||||
}
|
||||
|
||||
// mergeMappedFields folds the names of an existing mapping into the mapping
|
||||
// derived from the current schema. Fields are matched by field-id per nesting
|
||||
// level; entries whose ids left the schema are dropped, since current readers
|
||||
// cannot project those fields anyway. A name can belong to only one field per
|
||||
// level (Java readers reject ambiguous mappings), so a historical name that
|
||||
// the current schema assigns to a different field-id stays with its new owner
|
||||
// instead of being duplicated onto the old one — which also keeps mappings
|
||||
// clean when a commit replaces the schema with reassigned field ids.
|
||||
func mergeMappedFields(existing, derived []iceberg.MappedField) []iceberg.MappedField {
|
||||
byID := make(map[int]*iceberg.MappedField, len(existing))
|
||||
for i := range existing {
|
||||
byID[existing[i].ID()] = &existing[i]
|
||||
}
|
||||
nameOwner := make(map[string]int)
|
||||
for _, d := range derived {
|
||||
for _, name := range d.Names {
|
||||
nameOwner[name] = d.ID()
|
||||
}
|
||||
}
|
||||
for i := range derived {
|
||||
prior, ok := byID[derived[i].ID()]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, name := range prior.Names {
|
||||
if ownerID, taken := nameOwner[name]; taken && ownerID != derived[i].ID() {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(derived[i].Names, name) {
|
||||
derived[i].Names = append(derived[i].Names, name)
|
||||
nameOwner[name] = derived[i].ID()
|
||||
}
|
||||
}
|
||||
derived[i].Fields = mergeMappedFields(prior.Fields, derived[i].Fields)
|
||||
}
|
||||
return derived
|
||||
}
|
||||
213
weed/s3api/iceberg/name_mapping_test.go
Normal file
213
weed/s3api/iceberg/name_mapping_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/iceberg-go"
|
||||
"github.com/apache/iceberg-go/table"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestNameMappingFromSchema(t *testing.T) {
|
||||
schema := iceberg.NewSchema(0,
|
||||
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
||||
iceberg.NestedField{ID: 2, Name: "label", Type: iceberg.PrimitiveTypes.String},
|
||||
iceberg.NestedField{ID: 3, Name: "tags", Type: &iceberg.ListType{
|
||||
ElementID: 4, Element: iceberg.PrimitiveTypes.String,
|
||||
}},
|
||||
iceberg.NestedField{ID: 5, Name: "attrs", Type: &iceberg.MapType{
|
||||
KeyID: 6, KeyType: iceberg.PrimitiveTypes.String,
|
||||
ValueID: 7, ValueType: &iceberg.StructType{FieldList: []iceberg.NestedField{
|
||||
{ID: 8, Name: "inner", Type: iceberg.PrimitiveTypes.Int32},
|
||||
}},
|
||||
}},
|
||||
)
|
||||
|
||||
mapping := nameMappingFromSchema(schema)
|
||||
got, err := json.Marshal(mapping)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal mapping: %v", err)
|
||||
}
|
||||
|
||||
want := `[{"names":["id"],"field-id":1},{"names":["label"],"field-id":2},` +
|
||||
`{"names":["tags"],"field-id":3,"fields":[{"names":["element"],"field-id":4}]},` +
|
||||
`{"names":["attrs"],"field-id":5,"fields":[{"names":["key"],"field-id":6},` +
|
||||
`{"names":["value"],"field-id":7,"fields":[{"names":["inner"],"field-id":8}]}]}]`
|
||||
if string(got) != want {
|
||||
t.Fatalf("mapping = %s\nwant %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshDefaultNameMappingDropsReassignedNames(t *testing.T) {
|
||||
// The stored mapping was derived from a schema whose ids were later
|
||||
// reassigned (id<->name swapped). The merged mapping must follow the
|
||||
// current schema exactly: duplicating names across ids makes Java readers
|
||||
// reject the whole mapping.
|
||||
schema := iceberg.NewSchema(0,
|
||||
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
||||
iceberg.NestedField{ID: 2, Name: "name", Type: iceberg.PrimitiveTypes.String},
|
||||
)
|
||||
metadata, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil,
|
||||
iceberg.Properties{table.DefaultNameMappingKey: `[{"names":["name"],"field-id":1},{"names":["id"],"field-id":2}]`})
|
||||
if err != nil {
|
||||
t.Fatalf("newTableMetadata: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal metadata: %v", err)
|
||||
}
|
||||
|
||||
refreshed := refreshDefaultNameMapping(raw, metadata)
|
||||
final, err := table.ParseMetadataBytes(refreshed)
|
||||
if err != nil {
|
||||
t.Fatalf("parse refreshed metadata: %v", err)
|
||||
}
|
||||
var mapping iceberg.NameMapping
|
||||
if err := json.Unmarshal([]byte(final.Properties()[table.DefaultNameMappingKey]), &mapping); err != nil {
|
||||
t.Fatalf("parse refreshed mapping: %v", err)
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for _, field := range mapping {
|
||||
for _, name := range field.Names {
|
||||
if prior, dup := seen[name]; dup {
|
||||
t.Fatalf("name %q mapped to both field %d and field %d: %s",
|
||||
name, prior, field.ID(), final.Properties()[table.DefaultNameMappingKey])
|
||||
}
|
||||
seen[name] = field.ID()
|
||||
}
|
||||
}
|
||||
if seen["id"] != 1 || seen["name"] != 2 {
|
||||
t.Fatalf("current schema names must win: %s", final.Properties()[table.DefaultNameMappingKey])
|
||||
}
|
||||
}
|
||||
|
||||
// evolveMetadataSchema returns serialized metadata whose schema renamed
|
||||
// "label" to "tag" and gained an "extra" column while the properties still
|
||||
// carry the creation-time mapping, simulating a schema-evolution commit.
|
||||
func evolveMetadataSchema(t *testing.T, base table.Metadata) []byte {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(base)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal base metadata: %v", err)
|
||||
}
|
||||
evolved := iceberg.NewSchema(0,
|
||||
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
||||
iceberg.NestedField{ID: 2, Name: "tag", Type: iceberg.PrimitiveTypes.String},
|
||||
iceberg.NestedField{ID: 3, Name: "extra", Type: iceberg.PrimitiveTypes.String},
|
||||
)
|
||||
schemaJSON, err := json.Marshal(evolved)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal evolved schema: %v", err)
|
||||
}
|
||||
var metadata map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil {
|
||||
t.Fatalf("unmarshal metadata: %v", err)
|
||||
}
|
||||
metadata["schemas"] = json.RawMessage("[" + string(schemaJSON) + "]")
|
||||
edited, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal edited metadata: %v", err)
|
||||
}
|
||||
return edited
|
||||
}
|
||||
|
||||
func TestRefreshDefaultNameMappingOnSchemaEvolution(t *testing.T) {
|
||||
schema := iceberg.NewSchema(0,
|
||||
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
||||
iceberg.NestedField{ID: 2, Name: "label", Type: iceberg.PrimitiveTypes.String},
|
||||
)
|
||||
base, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newTableMetadata: %v", err)
|
||||
}
|
||||
|
||||
edited := evolveMetadataSchema(t, base)
|
||||
updated, err := table.ParseMetadataBytes(edited)
|
||||
if err != nil {
|
||||
t.Fatalf("parse evolved metadata: %v", err)
|
||||
}
|
||||
|
||||
refreshed := refreshDefaultNameMapping(edited, updated)
|
||||
final, err := table.ParseMetadataBytes(refreshed)
|
||||
if err != nil {
|
||||
t.Fatalf("parse refreshed metadata: %v", err)
|
||||
}
|
||||
var mapping iceberg.NameMapping
|
||||
if err := json.Unmarshal([]byte(final.Properties()[table.DefaultNameMappingKey]), &mapping); err != nil {
|
||||
t.Fatalf("parse refreshed mapping: %v", err)
|
||||
}
|
||||
if len(mapping) != 3 || mapping[2].ID() != 3 || mapping[2].Names[0] != "extra" {
|
||||
t.Fatalf("mapping was not refreshed for the evolved schema: %s", final.Properties()[table.DefaultNameMappingKey])
|
||||
}
|
||||
// The renamed column keeps its historical name so files written under the
|
||||
// old physical name stay resolvable.
|
||||
if mapping[1].ID() != 2 || !slices.Contains(mapping[1].Names, "tag") || !slices.Contains(mapping[1].Names, "label") {
|
||||
t.Fatalf("renamed column lost a name: %s", final.Properties()[table.DefaultNameMappingKey])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshDefaultNameMappingKeepsUserNames(t *testing.T) {
|
||||
schema := iceberg.NewSchema(0,
|
||||
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
||||
iceberg.NestedField{ID: 2, Name: "label", Type: iceberg.PrimitiveTypes.String},
|
||||
)
|
||||
base, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil,
|
||||
iceberg.Properties{table.DefaultNameMappingKey: `[{"names":["custom"],"field-id":1}]`})
|
||||
if err != nil {
|
||||
t.Fatalf("newTableMetadata: %v", err)
|
||||
}
|
||||
|
||||
edited := evolveMetadataSchema(t, base)
|
||||
updated, err := table.ParseMetadataBytes(edited)
|
||||
if err != nil {
|
||||
t.Fatalf("parse evolved metadata: %v", err)
|
||||
}
|
||||
|
||||
refreshed := refreshDefaultNameMapping(edited, updated)
|
||||
final, err := table.ParseMetadataBytes(refreshed)
|
||||
if err != nil {
|
||||
t.Fatalf("parse refreshed metadata: %v", err)
|
||||
}
|
||||
var mapping iceberg.NameMapping
|
||||
if err := json.Unmarshal([]byte(final.Properties()[table.DefaultNameMappingKey]), &mapping); err != nil {
|
||||
t.Fatalf("parse refreshed mapping: %v", err)
|
||||
}
|
||||
// The user's alias survives the merge alongside the schema name.
|
||||
if mapping[0].ID() != 1 || !slices.Contains(mapping[0].Names, "custom") || !slices.Contains(mapping[0].Names, "id") {
|
||||
t.Fatalf("user alias was dropped: %s", final.Properties()[table.DefaultNameMappingKey])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTableSetsDefaultNameMapping(t *testing.T) {
|
||||
// Request ids are deliberately non-sequential: the metadata constructor
|
||||
// reassigns fresh ids, and the stamped mapping must follow the final
|
||||
// schema, not the request.
|
||||
schema := iceberg.NewSchema(0,
|
||||
iceberg.NestedField{ID: 7, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
|
||||
iceberg.NestedField{ID: 3, Name: "label", Type: iceberg.PrimitiveTypes.String},
|
||||
)
|
||||
metadata, err := newTableMetadata(uuid.New(), "s3://bucket/ns/tbl", schema, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newTableMetadata: %v", err)
|
||||
}
|
||||
mappingJSON, ok := metadata.Properties()[table.DefaultNameMappingKey]
|
||||
if !ok {
|
||||
t.Fatalf("newTableMetadata did not set %s: %v", table.DefaultNameMappingKey, metadata.Properties())
|
||||
}
|
||||
var mapping iceberg.NameMapping
|
||||
if err := json.Unmarshal([]byte(mappingJSON), &mapping); err != nil {
|
||||
t.Fatalf("parse stamped mapping: %v", err)
|
||||
}
|
||||
finalSchema := metadata.CurrentSchema()
|
||||
if len(mapping) != len(finalSchema.Fields()) {
|
||||
t.Fatalf("mapping has %d entries, schema has %d", len(mapping), len(finalSchema.Fields()))
|
||||
}
|
||||
for i, field := range finalSchema.Fields() {
|
||||
if mapping[i].ID() != field.ID || mapping[i].Names[0] != field.Name {
|
||||
t.Fatalf("mapping entry %d = %v, want id %d name %s", i, mapping[i], field.ID, field.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
104
weed/s3api/iceberg/path_validation.go
Normal file
104
weed/s3api/iceberg/path_validation.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// validateRequestPath rejects Iceberg REST requests whose captured
|
||||
// {prefix}/{namespace}/{table} mux vars would produce a parent-directory
|
||||
// traversal when joined into a filer path. The iceberg router runs with
|
||||
// SkipClean(true), so `..` survives routing; downstream path.Join calls
|
||||
// (stageCreateMarkerDir, location builders, etc.) then collapse it and
|
||||
// escape the table-bucket directory.
|
||||
//
|
||||
// {prefix} maps to a table-bucket name; {table} is a single path segment;
|
||||
// {namespace} is unit-separator (0x1F) joined parts that get flattened into
|
||||
// a single dotted name for the on-disk layout — each part is validated
|
||||
// individually.
|
||||
func validateRequestPath(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
// Use the comma-ok form so vars only checked when the matched route
|
||||
// actually captures them; when captured, an empty value is itself a
|
||||
// rejection because downstream path.Join would collapse it.
|
||||
if prefix, ok := vars["prefix"]; ok {
|
||||
if prefix == "" || !s3_constants.IsValidBucketName(prefix) {
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "invalid prefix")
|
||||
return
|
||||
}
|
||||
}
|
||||
if table, ok := vars["table"]; ok {
|
||||
if table == "" || !isValidNameSegment(table) {
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "invalid table name")
|
||||
return
|
||||
}
|
||||
}
|
||||
if ns, ok := vars["namespace"]; ok {
|
||||
if ns == "" {
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "invalid namespace")
|
||||
return
|
||||
}
|
||||
// Reject leading/trailing/consecutive unit separators so distinct
|
||||
// inputs cannot collapse to the same parsed namespace via
|
||||
// parseNamespace's empty-part filter.
|
||||
for _, part := range strings.Split(ns, "\x1F") {
|
||||
if part == "" || !isValidNameSegment(part) {
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "invalid namespace")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// isValidTablePath reports whether a "/"-separated table path (the part below
|
||||
// the bucket) is free of segments that path.Join would collapse to escape the
|
||||
// bucket directory. Empty segments (from leading/duplicate slashes) are
|
||||
// ignored, but the path must resolve to at least one real segment so it does
|
||||
// not collapse to the bucket-level metadata directory.
|
||||
func isValidTablePath(tablePath string) bool {
|
||||
hasSegment := false
|
||||
for _, segment := range strings.Split(tablePath, "/") {
|
||||
if segment == "" {
|
||||
continue
|
||||
}
|
||||
if !isValidNameSegment(segment) {
|
||||
return false
|
||||
}
|
||||
hasSegment = true
|
||||
}
|
||||
return hasSegment
|
||||
}
|
||||
|
||||
// isValidNameSegment rejects a single path-segment value (bucket prefix slot,
|
||||
// table name, or one namespace part) that would be unsafe to embed in a filer
|
||||
// path: `.`, `..`, embedded slash/backslash, or NUL.
|
||||
func isValidNameSegment(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
if s == "." || s == ".." {
|
||||
return false
|
||||
}
|
||||
return !strings.ContainsAny(s, "/\\\x00")
|
||||
}
|
||||
|
||||
// confineMetadataLocation checks that a parsed s3 location stays within the
|
||||
// authorized table bucket and rejects traversal segments that path.Join in
|
||||
// saveMetadataBlob would collapse to escape it. The table path must resolve
|
||||
// to at least one real segment so its metadata directory is table-specific.
|
||||
func confineMetadataLocation(metadataBucket, metadataPath, bucketName string) error {
|
||||
if metadataBucket != bucketName {
|
||||
return fmt.Errorf("table location must be within bucket %s", bucketName)
|
||||
}
|
||||
if !isValidTablePath(metadataPath) {
|
||||
return fmt.Errorf("invalid table location path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
139
weed/s3api/iceberg/path_validation_test.go
Normal file
139
weed/s3api/iceberg/path_validation_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package iceberg
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func TestIsValidTablePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
tablePath string
|
||||
want bool
|
||||
}{
|
||||
{"sales.orders", true},
|
||||
{"ns/table", true},
|
||||
{"ns//table", true},
|
||||
{"", false},
|
||||
{"/", false},
|
||||
{"//", false},
|
||||
{"../other", false},
|
||||
{"ns/../../etc", false},
|
||||
{"ns/./x", false},
|
||||
{`ns\..\x`, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isValidTablePath(tt.tablePath); got != tt.want {
|
||||
t.Errorf("isValidTablePath(%q) = %v, want %v", tt.tablePath, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestPath_RejectsTraversal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawPath string
|
||||
wantCode int
|
||||
}{
|
||||
{"clean namespace+table passes", "/v1/namespaces/sales/tables/orders", http.StatusOK},
|
||||
{"clean prefixed passes", "/v1/wh/namespaces/sales/tables/orders", http.StatusOK},
|
||||
{"clean namespace only passes", "/v1/namespaces/sales", http.StatusOK},
|
||||
|
||||
// SkipClean(true) means raw `..` survives routing — these are the
|
||||
// realistic traversal shapes the middleware must catch.
|
||||
{"dotdot as prefix var rejected", "/v1/../namespaces/sales", http.StatusBadRequest},
|
||||
{"dotdot as namespace var rejected", "/v1/namespaces/..", http.StatusBadRequest},
|
||||
{"dotdot as namespace var prefixed rejected", "/v1/wh/namespaces/..", http.StatusBadRequest},
|
||||
{"dotdot as table var rejected", "/v1/namespaces/sales/tables/..", http.StatusBadRequest},
|
||||
{"dot as table var rejected", "/v1/namespaces/sales/tables/.", http.StatusBadRequest},
|
||||
// Iceberg clients send the 0x1F unit separator percent-encoded; mux
|
||||
// decodes it before the middleware sees the namespace var.
|
||||
{"unit-sep namespace with dotdot part rejected", "/v1/namespaces/sales%1F..%1Fevil", http.StatusBadRequest},
|
||||
{"leading unit-sep namespace rejected", "/v1/namespaces/%1Fsales", http.StatusBadRequest},
|
||||
{"trailing unit-sep namespace rejected", "/v1/namespaces/sales%1F", http.StatusBadRequest},
|
||||
{"consecutive unit-sep namespace rejected", "/v1/namespaces/sales%1F%1Fevil", http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := mux.NewRouter().SkipClean(true)
|
||||
router.Use(validateRequestPath)
|
||||
handlerCalled := false
|
||||
pass := func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
router.HandleFunc("/v1/namespaces/{namespace}", pass)
|
||||
router.HandleFunc("/v1/namespaces/{namespace}/tables/{table}", pass)
|
||||
router.HandleFunc("/v1/{prefix}/namespaces/{namespace}", pass)
|
||||
router.HandleFunc("/v1/{prefix}/namespaces/{namespace}/tables/{table}", pass)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, tt.rawPath, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != tt.wantCode {
|
||||
t.Fatalf("path %q: got status %d, want %d (body=%q)", tt.rawPath, rr.Code, tt.wantCode, rr.Body.String())
|
||||
}
|
||||
if tt.wantCode == http.StatusBadRequest && handlerCalled {
|
||||
t.Fatalf("path %q: inner handler reached despite rejection", tt.rawPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Defense-in-depth: if a future route or middleware ever leaves one of the
|
||||
// captured vars empty, the middleware must still reject the request. The
|
||||
// default mux regex won't normally allow this.
|
||||
func TestValidateRequestPath_RejectsEmptyCapturedVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vars map[string]string
|
||||
}{
|
||||
{"empty prefix", map[string]string{"prefix": "", "namespace": "ns"}},
|
||||
{"empty table", map[string]string{"namespace": "ns", "table": ""}},
|
||||
{"empty namespace", map[string]string{"namespace": ""}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handlerCalled := false
|
||||
h := validateRequestPath(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
}))
|
||||
req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/", nil), tt.vars)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if handlerCalled {
|
||||
t.Fatalf("vars %v: inner handler reached despite empty capture", tt.vars)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidNameSegment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"empty ok", "", true},
|
||||
{"plain", "orders", true},
|
||||
{"with dot inside", "my.table", true},
|
||||
{"hidden", ".hidden", true},
|
||||
|
||||
{"bare dot", ".", false},
|
||||
{"bare dotdot", "..", false},
|
||||
{"contains slash", "foo/bar", false},
|
||||
{"contains backslash", "foo\\bar", false},
|
||||
{"contains nul", "foo\x00bar", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isValidNameSegment(tt.input); got != tt.want {
|
||||
t.Errorf("isValidNameSegment(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user