mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-17 20:15:53 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccda91cac0 | ||
|
|
8bcf08aecf | ||
|
|
92429d2d91 |
@@ -66,31 +66,8 @@ jobs:
|
||||
echo "=== Testing with security enabled ==="
|
||||
helm template test $CHART_DIR --set global.enableSecurity=true > /tmp/security.yaml
|
||||
grep -q "security-config" /tmp/security.yaml
|
||||
# Validate JWT signing keys are generated
|
||||
grep -q "jwt.signing" /tmp/security.yaml
|
||||
grep -q 'key = "' /tmp/security.yaml
|
||||
echo "✓ Security configuration renders correctly"
|
||||
|
||||
echo "=== Testing with all security features enabled ==="
|
||||
helm template test $CHART_DIR \
|
||||
--set global.enableSecurity=true \
|
||||
--set global.securityConfig.jwtSigning.volumeWrite=true \
|
||||
--set global.securityConfig.jwtSigning.volumeRead=true \
|
||||
--set global.securityConfig.jwtSigning.filerWrite=true \
|
||||
--set global.securityConfig.jwtSigning.filerRead=true > /tmp/security-full.yaml
|
||||
# Validate all JWT keys are present
|
||||
grep -q "jwt.signing" /tmp/security-full.yaml
|
||||
grep -q "jwt.signing.read" /tmp/security-full.yaml
|
||||
grep -q "jwt.filer_signing" /tmp/security-full.yaml
|
||||
grep -q "jwt.filer_signing.read" /tmp/security-full.yaml
|
||||
# Count that we have 4 JWT keys generated
|
||||
KEY_COUNT=$(grep -c 'key = "' /tmp/security-full.yaml)
|
||||
if [ "$KEY_COUNT" -ne 4 ]; then
|
||||
echo "❌ Expected 4 JWT keys, found $KEY_COUNT"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ All security features render correctly with 4 JWT keys"
|
||||
|
||||
echo "=== Testing with monitoring enabled ==="
|
||||
helm template test $CHART_DIR \
|
||||
--set global.monitoring.enabled=true \
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
name: "S3 Policy Integration Tests"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'weed/s3api/s3_iam_middleware.go'
|
||||
- 'weed/s3api/s3api_bucket_policy*.go'
|
||||
- 'weed/s3api/s3_action_resolver.go'
|
||||
- 'weed/s3api/policy/**'
|
||||
- 'weed/iam/**'
|
||||
- 'test/s3/iam/**'
|
||||
- '.github/workflows/s3-policy-tests.yml'
|
||||
push:
|
||||
branches: [ master, main ]
|
||||
paths:
|
||||
- 'weed/s3api/s3_iam_middleware.go'
|
||||
- 'weed/s3api/s3api_bucket_policy*.go'
|
||||
- 'weed/s3api/s3_action_resolver.go'
|
||||
- 'weed/s3api/policy/**'
|
||||
- 'weed/iam/**'
|
||||
- 'test/s3/iam/**'
|
||||
- '.github/workflows/s3-policy-tests.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.head_ref }}/s3-policy-tests
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: weed
|
||||
|
||||
jobs:
|
||||
# Unit tests for policy components
|
||||
policy-unit-tests:
|
||||
name: S3 Policy Unit Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
id: go
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
go mod download
|
||||
|
||||
- name: Run S3 Policy Unit Tests
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
set -x
|
||||
echo "=== Running S3 Action Resolver Tests ==="
|
||||
go test -v -timeout 5m ./s3api/... -run ".*ActionResolver.*"
|
||||
|
||||
echo "=== Running S3 Bucket Policy Engine Tests ==="
|
||||
go test -v -timeout 5m ./s3api/... -run ".*BucketPolicy.*|.*PolicyEngine.*"
|
||||
|
||||
echo "=== Running IAM Policy Tests ==="
|
||||
go test -v -timeout 5m ./iam/policy/...
|
||||
|
||||
- name: Upload test results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: policy-unit-test-results
|
||||
path: |
|
||||
weed/testdata/
|
||||
weed/**/testdata/
|
||||
retention-days: 3
|
||||
|
||||
# S3 Policy Variables Integration Tests
|
||||
s3-policy-variables-tests:
|
||||
name: S3 Policy Variables Integration Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
id: go
|
||||
|
||||
- name: Install SeaweedFS
|
||||
run: |
|
||||
go install -buildvcs=false
|
||||
|
||||
- name: Run S3 Policy Variables Integration Tests
|
||||
timeout-minutes: 20
|
||||
working-directory: test/s3/iam
|
||||
run: |
|
||||
set -x
|
||||
echo "=== System Information ==="
|
||||
uname -a
|
||||
free -h
|
||||
df -h
|
||||
|
||||
echo "=== Starting S3 Policy Variables Integration Tests ==="
|
||||
|
||||
# Set WEED_BINARY to use the installed version
|
||||
export WEED_BINARY=$(which weed)
|
||||
export TEST_TIMEOUT=15m
|
||||
|
||||
# Run policy variables tests
|
||||
echo "Running policy variables tests..."
|
||||
|
||||
# Kill any existing weed server on port 8333
|
||||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||
kill $(lsof -t -i:8333) 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Start weed server with IAM configuration
|
||||
echo "Starting weed server with IAM configuration..."
|
||||
$WEED_BINARY server \
|
||||
-s3 \
|
||||
-s3.port=8333 \
|
||||
-s3.iam.config="$(pwd)/test_iam_config.json" \
|
||||
-filer \
|
||||
-volume.max=0 \
|
||||
-master.volumeSizeLimitMB=100 \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
> /tmp/weed_policy_test_server.log 2>&1 &
|
||||
|
||||
SERVER_PID=$!
|
||||
echo "Server started with PID: $SERVER_PID"
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for server to be ready..."
|
||||
MAX_WAIT=30
|
||||
COUNTER=0
|
||||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
COUNTER=$((COUNTER + 1))
|
||||
if [ $COUNTER -ge $MAX_WAIT ]; then
|
||||
echo "Server failed to start within ${MAX_WAIT} seconds"
|
||||
echo "Server log:"
|
||||
cat /tmp/weed_policy_test_server.log
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Server is ready!"
|
||||
|
||||
# Trap to ensure server is killed on exit
|
||||
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
|
||||
|
||||
# Run the tests
|
||||
go test -v -timeout 15m -run TestS3PolicyVariables ./...
|
||||
|
||||
- name: Show service logs on failure
|
||||
if: failure()
|
||||
working-directory: test/s3/iam
|
||||
run: |
|
||||
echo "=== Service Logs ==="
|
||||
if [ -f /tmp/weed_policy_test_server.log ]; then
|
||||
echo "--- Last 100 lines of Server Log ---"
|
||||
tail -100 /tmp/weed_policy_test_server.log
|
||||
fi
|
||||
echo ""
|
||||
echo "=== Process Information ==="
|
||||
ps aux | grep -E "(weed|test)" || true
|
||||
netstat -tlnp | grep -E "(8333|8888|9333|8080)" || true
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: s3-policy-variables-test-logs
|
||||
path: /tmp/weed_policy_test_server.log
|
||||
retention-days: 5
|
||||
|
||||
# S3 Policy Enforcement Integration Tests
|
||||
s3-policy-enforcement-tests:
|
||||
name: S3 Policy Enforcement Integration Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
test-case: ["basic-policy", "contextual-policy", "advanced-policy"]
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
id: go
|
||||
|
||||
- name: Install SeaweedFS
|
||||
run: |
|
||||
go install -buildvcs=false
|
||||
|
||||
- name: Run S3 Policy Enforcement Tests - ${{ matrix.test-case }}
|
||||
timeout-minutes: 25
|
||||
working-directory: test/s3/iam
|
||||
run: |
|
||||
set -x
|
||||
echo "=== System Information ==="
|
||||
uname -a
|
||||
free -h
|
||||
df -h
|
||||
|
||||
echo "=== Starting S3 Policy Enforcement Tests (${{ matrix.test-case }}) ==="
|
||||
|
||||
export WEED_BINARY=$(which weed)
|
||||
export TEST_TIMEOUT=20m
|
||||
|
||||
# Kill any existing weed server on port 8333
|
||||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||
kill $(lsof -t -i:8333) 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Start weed server with IAM configuration
|
||||
echo "Starting weed server with IAM configuration..."
|
||||
$WEED_BINARY server \
|
||||
-s3 \
|
||||
-s3.port=8333 \
|
||||
-s3.iam.config="$(pwd)/test_iam_config.json" \
|
||||
-filer \
|
||||
-volume.max=0 \
|
||||
-master.volumeSizeLimitMB=100 \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
> /tmp/weed_policy_enforcement_${{ matrix.test-case }}.log 2>&1 &
|
||||
|
||||
SERVER_PID=$!
|
||||
echo "Server started with PID: $SERVER_PID"
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for server to be ready..."
|
||||
MAX_WAIT=30
|
||||
COUNTER=0
|
||||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
COUNTER=$((COUNTER + 1))
|
||||
if [ $COUNTER -ge $MAX_WAIT ]; then
|
||||
echo "Server failed to start within ${MAX_WAIT} seconds"
|
||||
cat /tmp/weed_policy_enforcement_${{ matrix.test-case }}.log
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Server is ready!"
|
||||
|
||||
# Trap to ensure server is killed on exit
|
||||
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
|
||||
|
||||
# Run tests based on test case
|
||||
case "${{ matrix.test-case }}" in
|
||||
"basic-policy")
|
||||
echo "Running basic policy enforcement tests..."
|
||||
go test -v -timeout 20m -run "TestS3IAMBucketPolicy|TestS3IAMPolicyEnforcement" ./...
|
||||
;;
|
||||
"contextual-policy")
|
||||
echo "Running contextual policy tests..."
|
||||
go test -v -timeout 20m -run "TestS3PolicyVariables|TestS3IAMContextual" ./...
|
||||
;;
|
||||
"advanced-policy")
|
||||
echo "Running advanced policy tests..."
|
||||
go test -v -timeout 20m -run "TestS3IAMMultipart|TestS3IAMPresigned" ./...
|
||||
;;
|
||||
*)
|
||||
echo "Unknown test case: ${{ matrix.test-case }}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Show service logs on failure
|
||||
if: failure()
|
||||
working-directory: test/s3/iam
|
||||
run: |
|
||||
echo "=== Service Logs ==="
|
||||
if [ -f /tmp/weed_policy_enforcement_${{ matrix.test-case }}.log ]; then
|
||||
echo "--- Last 100 lines of Server Log ---"
|
||||
tail -100 /tmp/weed_policy_enforcement_${{ matrix.test-case }}.log
|
||||
fi
|
||||
echo ""
|
||||
echo "=== Process Information ==="
|
||||
ps aux | grep -E "(weed|test)" || true
|
||||
netstat -tlnp | grep -E "(8333|8888|9333|8080)" || true
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: s3-policy-enforcement-logs-${{ matrix.test-case }}
|
||||
path: /tmp/weed_policy_enforcement_${{ matrix.test-case }}.log
|
||||
retention-days: 5
|
||||
|
||||
# Trusted Proxy Detection Tests
|
||||
trusted-proxy-tests:
|
||||
name: Trusted Proxy Detection Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
id: go
|
||||
|
||||
- name: Install SeaweedFS
|
||||
run: |
|
||||
go install -buildvcs=false
|
||||
|
||||
- name: Run Trusted Proxy Tests
|
||||
timeout-minutes: 15
|
||||
working-directory: test/s3/iam
|
||||
run: |
|
||||
set -x
|
||||
echo "=== Running Trusted Proxy Detection Tests ==="
|
||||
|
||||
export WEED_BINARY=$(which weed)
|
||||
|
||||
# Kill any existing weed server on port 8333
|
||||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||
kill $(lsof -t -i:8333) 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Start weed server
|
||||
echo "Starting weed server..."
|
||||
$WEED_BINARY server \
|
||||
-s3 \
|
||||
-s3.port=8333 \
|
||||
-s3.iam.config="$(pwd)/test_iam_config.json" \
|
||||
-filer \
|
||||
-volume.max=0 \
|
||||
-master.volumeSizeLimitMB=100 \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
> /tmp/weed_proxy_test.log 2>&1 &
|
||||
|
||||
SERVER_PID=$!
|
||||
echo "Server started with PID: $SERVER_PID"
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for server to be ready..."
|
||||
MAX_WAIT=30
|
||||
COUNTER=0
|
||||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
COUNTER=$((COUNTER + 1))
|
||||
if [ $COUNTER -ge $MAX_WAIT ]; then
|
||||
echo "Server failed to start within ${MAX_WAIT} seconds"
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Trap to ensure server is killed on exit
|
||||
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
|
||||
|
||||
# Run proxy tests
|
||||
go test -v -timeout 10m -run "TestTrustedProxy|TestPrivateIP" ./...
|
||||
|
||||
- name: Show service logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Service Logs ==="
|
||||
if [ -f /tmp/weed_proxy_test.log ]; then
|
||||
echo "--- Last 100 lines of Server Log ---"
|
||||
tail -100 /tmp/weed_proxy_test.log
|
||||
fi
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: trusted-proxy-test-logs
|
||||
path: /tmp/weed_proxy_test.log
|
||||
retention-days: 3
|
||||
@@ -137,4 +137,3 @@ test/s3/remote_cache/primary-server.pid
|
||||
# ID and PID files
|
||||
*.id
|
||||
*.pid
|
||||
test/s3/iam/.test_env
|
||||
|
||||
@@ -89,7 +89,8 @@ Example:
|
||||
```bash
|
||||
# remove quarantine on macOS
|
||||
# xattr -d com.apple.quarantine ./weed
|
||||
|
||||
export AWS_ACCESS_KEY_ID=admin
|
||||
export AWS_SECRET_ACCESS_KEY=key
|
||||
./weed mini -dir=/data
|
||||
```
|
||||
|
||||
@@ -121,7 +122,7 @@ SeaweedFS is a simple and highly scalable distributed file system. There are two
|
||||
1. to store billions of files!
|
||||
2. to serve the files fast!
|
||||
|
||||
SeaweedFS started as a blob store to handle small files efficiently.
|
||||
SeaweedFS started as an Object Store to handle small files efficiently.
|
||||
Instead of managing all file metadata in a central master,
|
||||
the central master only manages volumes on volume servers,
|
||||
and these volume servers manage files and their metadata.
|
||||
@@ -133,12 +134,16 @@ It is so simple with O(1) disk reads that you are welcome to challenge the perfo
|
||||
|
||||
SeaweedFS started by implementing [Facebook's Haystack design paper](http://www.usenix.org/event/osdi10/tech/full_papers/Beaver.pdf).
|
||||
Also, SeaweedFS implements erasure coding with ideas from
|
||||
[f4: Facebook’s Warm BLOB Storage System](https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-muralidhar.pdf), and has a lot of similarities with [Facebook’s Tectonic Filesystem](https://www.usenix.org/system/files/fast21-pan.pdf) and [Google's Colossus File System](https://cloud.google.com/blog/products/storage-data-transfer/a-peek-behind-colossus-googles-file-system)
|
||||
[f4: Facebook’s Warm BLOB Storage System](https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-muralidhar.pdf), and has a lot of similarities with [Facebook’s Tectonic Filesystem](https://www.usenix.org/system/files/fast21-pan.pdf)
|
||||
|
||||
On top of the blob store, optional [Filer] can support directories and POSIX attributes.
|
||||
On top of the object store, optional [Filer] can support directories and POSIX attributes.
|
||||
Filer is a separate linearly-scalable stateless server with customizable metadata stores,
|
||||
e.g., MySql, Postgres, Redis, Cassandra, HBase, Mongodb, Elastic Search, LevelDB, RocksDB, Sqlite, MemSql, TiDB, Etcd, CockroachDB, YDB, etc.
|
||||
|
||||
For any distributed key value stores, the large values can be offloaded to SeaweedFS.
|
||||
With the fast access speed and linearly scalable capacity,
|
||||
SeaweedFS can work as a distributed [Key-Large-Value store][KeyLargeValueStore].
|
||||
|
||||
SeaweedFS can transparently integrate with the cloud.
|
||||
With hot data on local cluster, and warm data on the cloud with O(1) access time,
|
||||
SeaweedFS can achieve both fast local access time and elastic cloud storage capacity.
|
||||
@@ -148,13 +153,13 @@ Faster and cheaper than direct cloud storage!
|
||||
[Back to TOC](#table-of-contents)
|
||||
|
||||
# Features #
|
||||
## Additional Blob Store Features ##
|
||||
* Support different replication levels, with rack and data center aware.
|
||||
## Additional Features ##
|
||||
* Can choose no replication or different replication levels, rack and data center aware.
|
||||
* Automatic master servers failover - no single point of failure (SPOF).
|
||||
* Automatic compression depending on file MIME type.
|
||||
* Automatic Gzip compression depending on file MIME type.
|
||||
* Automatic compaction to reclaim disk space after deletion or update.
|
||||
* [Automatic entry TTL expiration][VolumeServerTTL].
|
||||
* Flexible Capacity Expansion: Any server with some disk space can add to the total storage space.
|
||||
* Any server with some disk space can add to the total storage space.
|
||||
* Adding/Removing servers does **not** cause any data re-balancing unless triggered by admin commands.
|
||||
* Optional picture resizing.
|
||||
* Support ETag, Accept-Range, Last-Modified, etc.
|
||||
@@ -162,7 +167,7 @@ Faster and cheaper than direct cloud storage!
|
||||
* Support rebalancing the writable and readonly volumes.
|
||||
* [Customizable Multiple Storage Tiers][TieredStorage]: Customizable storage disk types to balance performance and cost.
|
||||
* [Transparent cloud integration][CloudTier]: unlimited capacity via tiered cloud storage for warm data.
|
||||
* [Erasure Coding for warm storage][ErasureCoding] Rack-Aware 10.4 erasure coding reduces storage cost and increases availability. Enterprise version can customize EC ratio.
|
||||
* [Erasure Coding for warm storage][ErasureCoding] Rack-Aware 10.4 erasure coding reduces storage cost and increases availability.
|
||||
|
||||
[Back to TOC](#table-of-contents)
|
||||
|
||||
@@ -208,7 +213,7 @@ Faster and cheaper than direct cloud storage!
|
||||
|
||||
[Back to TOC](#table-of-contents)
|
||||
|
||||
## Example: Using Seaweed Blob Store ##
|
||||
## Example: Using Seaweed Object Store ##
|
||||
|
||||
By default, the master node runs on port 9333, and the volume nodes run on port 8080.
|
||||
Let's start one master node, and two volume nodes on port 8080 and 8081. Ideally, they should be started from different machines. We'll use localhost as an example.
|
||||
@@ -228,25 +233,23 @@ SeaweedFS uses HTTP REST operations to read, write, and delete. The responses ar
|
||||
> weed volume -dir="/tmp/data2" -max=10 -master="localhost:9333" -port=8081 &
|
||||
```
|
||||
|
||||
### Write A Blob ###
|
||||
### Write File ###
|
||||
|
||||
A blob, also referred as a needle, a chunk, or mistakenly as a file, is just a byte array. It can have attributes, such as name, mime type, create or update time, etc. But basically it is just a byte array of a relatively small size, such as 2 MB ~ 64 MB. The size is not fixed.
|
||||
|
||||
To upload a blob: first, send a HTTP POST, PUT, or GET request to `/dir/assign` to get an `fid` and a volume server URL:
|
||||
To upload a file: first, send a HTTP POST, PUT, or GET request to `/dir/assign` to get an `fid` and a volume server URL:
|
||||
|
||||
```
|
||||
> curl http://localhost:9333/dir/assign
|
||||
{"count":1,"fid":"3,01637037d6","url":"127.0.0.1:8080","publicUrl":"localhost:8080"}
|
||||
```
|
||||
|
||||
Second, to store the blob content, send a HTTP multi-part POST request to `url + '/' + fid` from the response:
|
||||
Second, to store the file content, send a HTTP multi-part POST request to `url + '/' + fid` from the response:
|
||||
|
||||
```
|
||||
> curl -F file=@/home/chris/myphoto.jpg http://127.0.0.1:8080/3,01637037d6
|
||||
{"name":"myphoto.jpg","size":43234,"eTag":"1cc0118e"}
|
||||
```
|
||||
|
||||
To update, send another POST request with updated blob content.
|
||||
To update, send another POST request with updated file content.
|
||||
|
||||
For deletion, send an HTTP DELETE request to the same `url + '/' + fid` URL:
|
||||
|
||||
@@ -254,7 +257,7 @@ For deletion, send an HTTP DELETE request to the same `url + '/' + fid` URL:
|
||||
> curl -X DELETE http://127.0.0.1:8080/3,01637037d6
|
||||
```
|
||||
|
||||
### Save Blob Id ###
|
||||
### Save File Id ###
|
||||
|
||||
Now, you can save the `fid`, 3,01637037d6 in this case, to a database field.
|
||||
|
||||
@@ -266,9 +269,9 @@ The file key and file cookie are both coded in hex. You can store the <volume id
|
||||
|
||||
If stored as a string, in theory, you would need 8+1+16+8=33 bytes. A char(33) would be enough, if not more than enough, since most uses will not need 2^32 volumes.
|
||||
|
||||
If space is really a concern, you can store the file id in the binary format. You would need one 4-byte integer for volume id, 8-byte long number for file key, and a 4-byte integer for the file cookie. So 16 bytes are more than enough.
|
||||
If space is really a concern, you can store the file id in your own format. You would need one 4-byte integer for volume id, 8-byte long number for file key, and a 4-byte integer for the file cookie. So 16 bytes are more than enough.
|
||||
|
||||
### Read a Blob ###
|
||||
### Read File ###
|
||||
|
||||
Here is an example of how to render the URL.
|
||||
|
||||
@@ -309,7 +312,7 @@ http://localhost:8080/3/01637037d6.jpg?height=200&width=200&mode=fill
|
||||
|
||||
### Rack-Aware and Data Center-Aware Replication ###
|
||||
|
||||
SeaweedFS applies the replication strategy at a volume level. So, when you are getting a blob id, you can specify the replication strategy. For example:
|
||||
SeaweedFS applies the replication strategy at a volume level. So, when you are getting a file id, you can specify the replication strategy. For example:
|
||||
|
||||
```
|
||||
curl http://localhost:9333/dir/assign?replication=001
|
||||
@@ -332,7 +335,7 @@ More details about replication can be found [on the wiki][Replication].
|
||||
|
||||
You can also set the default replication strategy when starting the master server.
|
||||
|
||||
### Allocate Blob Key on Specific Data Center ###
|
||||
### Allocate File Key on Specific Data Center ###
|
||||
|
||||
Volume servers can be started with a specific data center name:
|
||||
|
||||
@@ -341,7 +344,7 @@ Volume servers can be started with a specific data center name:
|
||||
weed volume -dir=/tmp/2 -port=8081 -dataCenter=dc2
|
||||
```
|
||||
|
||||
When requesting a blob key, an optional "dataCenter" parameter can limit the assigned volume to the specific data center. For example, this specifies that the assigned volume should be limited to 'dc1':
|
||||
When requesting a file key, an optional "dataCenter" parameter can limit the assigned volume to the specific data center. For example, this specifies that the assigned volume should be limited to 'dc1':
|
||||
|
||||
```
|
||||
http://localhost:9333/dir/assign?dataCenter=dc1
|
||||
@@ -360,15 +363,15 @@ When requesting a blob key, an optional "dataCenter" parameter can limit the ass
|
||||
|
||||
[Back to TOC](#table-of-contents)
|
||||
|
||||
## Blob Store Architecture ##
|
||||
## Object Store Architecture ##
|
||||
|
||||
Usually distributed file systems split each file into chunks. A central server keeps a mapping of filenames to chunks, and also which chunks each chunk server has.
|
||||
Usually distributed file systems split each file into chunks, a central master keeps a mapping of filenames, chunk indices to chunk handles, and also which chunks each chunk server has.
|
||||
|
||||
The main drawback is that the central server can't handle many small files efficiently, and since all read requests need to go through the central master, so it might not scale well for many concurrent users.
|
||||
The main drawback is that the central master can't handle many small files efficiently, and since all read requests need to go through the chunk master, so it might not scale well for many concurrent users.
|
||||
|
||||
Instead of managing chunks, SeaweedFS manages data volumes in the master server. Each data volume is 32GB in size, and can hold a lot of blobs. And each storage node can have many data volumes. So the master node only needs to store the metadata about the volumes, which is a fairly small amount of data and is generally stable.
|
||||
Instead of managing chunks, SeaweedFS manages data volumes in the master server. Each data volume is 32GB in size, and can hold a lot of files. And each storage node can have many data volumes. So the master node only needs to store the metadata about the volumes, which is a fairly small amount of data and is generally stable.
|
||||
|
||||
The actual blob metadata, which are the blob volume, offset, and size, is stored in each volume on volume servers. Since each volume server only manages metadata of blobs on its own disk, with only 16 bytes for each blob, all access can read the metadata just from memory and only needs one disk operation to actually read file data.
|
||||
The actual file metadata is stored in each volume on volume servers. Since each volume server only manages metadata of files on its own disk, with only 16 bytes for each file, all file access can read file metadata just from memory and only needs one disk operation to actually read file data.
|
||||
|
||||
For comparison, consider that an xfs inode structure in Linux is 536 bytes.
|
||||
|
||||
@@ -382,13 +385,23 @@ On each write request, the master server also generates a file key, which is a g
|
||||
|
||||
### Write and Read files ###
|
||||
|
||||
When a client sends a write request, the master server returns (volume id, file key, file cookie, volume node URL) for the blob. The client then contacts the volume node and POSTs the blob content.
|
||||
When a client sends a write request, the master server returns (volume id, file key, file cookie, volume node URL) for the file. The client then contacts the volume node and POSTs the file content.
|
||||
|
||||
When a client needs to read a blob based on (volume id, file key, file cookie), it asks the master server by the volume id for the (volume node URL, volume node public URL), or retrieves this from a cache. Then the client can GET the content, or just render the URL on web pages and let browsers fetch the content.
|
||||
When a client needs to read a file based on (volume id, file key, file cookie), it asks the master server by the volume id for the (volume node URL, volume node public URL), or retrieves this from a cache. Then the client can GET the content, or just render the URL on web pages and let browsers fetch the content.
|
||||
|
||||
Please see the example for details on the write-read process.
|
||||
|
||||
### Storage Size ###
|
||||
|
||||
In the current implementation, each volume can hold 32 gibibytes (32GiB or 8x2^32 bytes). This is because we align content to 8 bytes. We can easily increase this to 64GiB, or 128GiB, or more, by changing 2 lines of code, at the cost of some wasted padding space due to alignment.
|
||||
|
||||
There can be 4 gibibytes (4GiB or 2^32 bytes) of volumes. So the total system size is 8 x 4GiB x 4GiB which is 128 exbibytes (128EiB or 2^67 bytes).
|
||||
|
||||
Each individual file size is limited to the volume size.
|
||||
|
||||
### Saving memory ###
|
||||
|
||||
All blob metadata stored on a volume server is readable from memory without disk access. Each file takes just a 16-byte map entry of <64bit key, 32bit offset, 32bit size>. Of course, each map entry has its own space cost for the map. But usually the disk space runs out before the memory does.
|
||||
All file meta information stored on a volume server is readable from memory without disk access. Each file takes just a 16-byte map entry of <64bit key, 32bit offset, 32bit size>. Of course, each map entry has its own space cost for the map. But usually the disk space runs out before the memory does.
|
||||
|
||||
### Tiered Storage to the cloud ###
|
||||
|
||||
@@ -402,12 +415,6 @@ If the hot/warm data is split as 20/80, with 20 servers, you can achieve storage
|
||||
|
||||
[Back to TOC](#table-of-contents)
|
||||
|
||||
## SeaweedFS Filer ##
|
||||
|
||||
Built on top of the blob store, SeaweedFS Filer adds directory structure to create a file system. The directory sturcture is an interface that is implemented in many key-value stores or databases.
|
||||
|
||||
The content of a file is mapped to one or many blobs, distributed to multiple volumes on multiple volume servers.
|
||||
|
||||
## Compared to Other File Systems ##
|
||||
|
||||
Most other distributed file systems seem more complicated than necessary.
|
||||
@@ -654,4 +661,5 @@ The text of this page is available for modification and reuse under the terms of
|
||||
[Back to TOC](#table-of-contents)
|
||||
|
||||
## Stargazers over time
|
||||
[](https://starchart.cc/seaweedfs/seaweedfs)
|
||||
|
||||
[](https://starchart.cc/chrislusf/seaweedfs)
|
||||
|
||||
@@ -29,6 +29,7 @@ require (
|
||||
github.com/go-redsync/redsync/v4 v4.15.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/go-zookeeper/zk v1.0.3 // indirect
|
||||
github.com/gocql/gocql v1.7.0
|
||||
github.com/golang/protobuf v1.5.4
|
||||
github.com/golang/snappy v1.0.0
|
||||
github.com/google/btree v1.1.3
|
||||
@@ -36,6 +37,7 @@ require (
|
||||
github.com/google/wire v0.7.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
@@ -47,7 +49,7 @@ require (
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/karlseguin/ccache/v2 v2.0.8
|
||||
github.com/klauspost/compress v1.18.2
|
||||
github.com/klauspost/reedsolomon v1.13.0
|
||||
github.com/klauspost/reedsolomon v1.12.6
|
||||
github.com/kurin/blazer v0.5.3
|
||||
github.com/linxGnu/grocksdb v1.10.3
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
@@ -103,7 +105,7 @@ require (
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.258.0
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
modernc.org/b v1.0.0 // indirect
|
||||
@@ -119,7 +121,6 @@ require (
|
||||
github.com/Jille/raft-grpc-transport v1.6.1
|
||||
github.com/ThreeDotsLabs/watermill v1.5.1
|
||||
github.com/a-h/templ v0.3.943
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.0.0
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3
|
||||
github.com/arangodb/go-driver v1.6.9
|
||||
github.com/armon/go-metrics v0.4.1
|
||||
@@ -132,7 +133,6 @@ require (
|
||||
github.com/getsentry/sentry-go v0.40.0
|
||||
github.com/gin-contrib/sessions v1.0.4
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/flatbuffers/go v0.0.0-20230108230133-3b8644d32c50
|
||||
github.com/hashicorp/raft v1.7.3
|
||||
@@ -141,10 +141,10 @@ require (
|
||||
github.com/jhump/protoreflect v1.17.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/linkedin/goavro/v2 v2.14.1
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/minio/crc64nvme v1.1.1
|
||||
github.com/orcaman/concurrent-map/v2 v2.0.1
|
||||
github.com/parquet-go/parquet-go v0.26.4
|
||||
github.com/parquet-go/parquet-go v0.26.3
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
github.com/rclone/rclone v1.71.2
|
||||
@@ -152,7 +152,7 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
github.com/seaweedfs/go-fuse/v2 v2.9.1
|
||||
github.com/shirou/gopsutil/v4 v4.25.12
|
||||
github.com/shirou/gopsutil/v4 v4.25.11
|
||||
github.com/tarantool/go-tarantool/v2 v2.4.1
|
||||
github.com/tikv/client-go/v2 v2.0.7
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
@@ -183,7 +183,6 @@ require (
|
||||
github.com/cockroachdb/redact v1.1.5 // indirect
|
||||
github.com/cockroachdb/version v0.0.0-20250314144055-3860cd14adf2 // indirect
|
||||
github.com/dave/dst v0.27.2 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
@@ -368,7 +367,7 @@ require (
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.43.0 // indirect
|
||||
@@ -449,7 +448,7 @@ require (
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/validator.v2 v2.0.1 // indirect
|
||||
|
||||
@@ -643,8 +643,6 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
@@ -652,8 +650,6 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.0.0 h1:Omnzb1Z/P90Dr2TbVNu54ICQL7TKVIIsJO231w484HU=
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.0.0/go.mod h1:QH/asJjB3mHvY6Dot6ZKMMpTcOrWJ8i9GhsvG1g0PK4=
|
||||
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3 h1:WZaTKNHCfcw7fWSR6/RKnCldVzvYZC+Y20Su4lffEIg=
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3/go.mod h1:OMVSB21p9+xQUIqlGizHPZfjK+SHws1ht+ZytVDoz9U=
|
||||
@@ -722,8 +718,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/biogo/store v0.0.0-20201120204734-aad293a2328f h1:+6okTAeUsUrdQr/qN7fIODzowrjjCrnJDg/gkYqcSXY=
|
||||
github.com/biogo/store v0.0.0-20201120204734-aad293a2328f/go.mod h1:z52shMwD6SGwRg2iYFjjDwX5Ene4ENTw6HfXraUy/08=
|
||||
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
|
||||
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
|
||||
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
|
||||
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
|
||||
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
@@ -936,8 +936,6 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68=
|
||||
@@ -959,8 +957,6 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
|
||||
github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
|
||||
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
@@ -1014,6 +1010,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gocql/gocql v1.7.0 h1:O+7U7/1gSN7QTEAaMEsJc1Oq2QHXvCWoF3DFK9HDHus=
|
||||
github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
@@ -1187,6 +1185,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4Zs
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
|
||||
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
|
||||
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
@@ -1328,8 +1328,8 @@ github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxh
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/reedsolomon v1.13.0 h1:E0Cmgf2kMuhZTj6eefnvpKC4/Q4jhCi9YIjcZjK4arc=
|
||||
github.com/klauspost/reedsolomon v1.13.0/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA=
|
||||
github.com/klauspost/reedsolomon v1.12.6 h1:8pqE9aECQG/ZFitiUD1xK/E83zwosBAZtE3UbuZM8TQ=
|
||||
github.com/klauspost/reedsolomon v1.12.6/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
@@ -1390,8 +1390,8 @@ github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzp
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||
@@ -1472,8 +1472,8 @@ github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxP
|
||||
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
|
||||
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
|
||||
github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
|
||||
github.com/parquet-go/parquet-go v0.26.4 h1:zJ3l8ef5WJZE2m63pKwyEJ2BhyDlgS0PfOEhuCQQU2A=
|
||||
github.com/parquet-go/parquet-go v0.26.4/go.mod h1:h9GcSt41Knf5qXI1tp1TfR8bDBUtvdUMzSKe26aZcHk=
|
||||
github.com/parquet-go/parquet-go v0.26.3 h1:kJY+xmjcR7BH77tyHqasJpIl3kch/6EIO3TW4tFj69M=
|
||||
github.com/parquet-go/parquet-go v0.26.3/go.mod h1:h9GcSt41Knf5qXI1tp1TfR8bDBUtvdUMzSKe26aZcHk=
|
||||
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
|
||||
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
@@ -1603,8 +1603,6 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
|
||||
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||
@@ -1631,8 +1629,8 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
|
||||
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
|
||||
github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=
|
||||
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
@@ -2558,8 +2556,8 @@ google.golang.org/genproto v0.0.0-20230222225845-10f96fb3dbec/go.mod h1:3Dl5ZL0q
|
||||
google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s=
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs=
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
@@ -2602,8 +2600,8 @@ google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsA
|
||||
google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY=
|
||||
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
|
||||
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6 h1:ExN12ndbJ608cboPYflpTny6mXSzPrDLh0iTaVrRrds=
|
||||
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
|
||||
|
||||
@@ -4,4 +4,3 @@ name: seaweedfs
|
||||
appVersion: "4.06"
|
||||
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
|
||||
version: 4.0.406
|
||||
kubeVersion: ">=1.19.0-0"
|
||||
|
||||
@@ -2,21 +2,13 @@
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
This chart requires:
|
||||
- **Helm 3.x** (tested with Helm 3.12+)
|
||||
- **Kubernetes 1.19+**
|
||||
|
||||
The chart is designed to be compatible with a wide range of Helm 3.x versions.
|
||||
|
||||
### Add the Helm Repo
|
||||
### Add the helm repo
|
||||
|
||||
```bash
|
||||
helm repo add seaweedfs https://seaweedfs.github.io/seaweedfs/helm
|
||||
```
|
||||
|
||||
### Install the Helm Chart
|
||||
### Install the helm chart
|
||||
|
||||
```bash
|
||||
helm install seaweedfs seaweedfs/seaweedfs
|
||||
|
||||
@@ -11,6 +11,7 @@ metadata:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
data:
|
||||
{{- $existing := (lookup "v1" "ConfigMap" .Release.Namespace (printf "%s-security-config" (include "seaweedfs.name" .))) }}
|
||||
{{- $securityConfig := fromToml (dig "data" "security.toml" "" $existing) }}
|
||||
security.toml: |-
|
||||
# this file is read by master, volume server, and filer
|
||||
|
||||
@@ -18,7 +19,7 @@ data:
|
||||
# the jwt signing key is read by master and volume server
|
||||
# a jwt expires in 10 seconds
|
||||
[jwt.signing]
|
||||
key = "{{ randAlphaNum 10 | b64enc }}"
|
||||
key = "{{ dig "jwt" "signing" "key" (randAlphaNum 10 | b64enc) $securityConfig }}"
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.global.securityConfig.jwtSigning.volumeRead }}
|
||||
@@ -26,7 +27,7 @@ data:
|
||||
# - the Master server generates the JWT, which can be used to read a certain file on a volume server
|
||||
# - the Volume server validates the JWT on reading
|
||||
[jwt.signing.read]
|
||||
key = "{{ randAlphaNum 10 | b64enc }}"
|
||||
key = "{{ dig "jwt" "signing" "read" "key" (randAlphaNum 10 | b64enc) $securityConfig }}"
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.global.securityConfig.jwtSigning.filerWrite }}
|
||||
@@ -35,7 +36,7 @@ data:
|
||||
# - the Filer server validates the JWT on writing
|
||||
# the jwt defaults to expire after 10 seconds.
|
||||
[jwt.filer_signing]
|
||||
key = "{{ randAlphaNum 10 | b64enc }}"
|
||||
key = "{{ dig "jwt" "filer_signing" "key" (randAlphaNum 10 | b64enc) $securityConfig }}"
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.global.securityConfig.jwtSigning.filerRead }}
|
||||
@@ -44,7 +45,7 @@ data:
|
||||
# - the Filer server validates the JWT on writing
|
||||
# the jwt defaults to expire after 10 seconds.
|
||||
[jwt.filer_signing.read]
|
||||
key = "{{ randAlphaNum 10 | b64enc }}"
|
||||
key = "{{ dig "jwt" "filer_signing" "read" "key" (randAlphaNum 10 | b64enc) $securityConfig }}"
|
||||
{{- end }}
|
||||
|
||||
# all grpc tls authentications are mutual
|
||||
|
||||
@@ -58,11 +58,11 @@ metadata:
|
||||
helm.sh/hook-weight: "0"
|
||||
helm.sh/hook-delete-policy: hook-succeeded,before-hook-creation
|
||||
spec:
|
||||
backoffLimit: 1
|
||||
template:
|
||||
spec:
|
||||
serviceAccountName: {{ $seaweedfsName }}-volume-resize-hook
|
||||
restartPolicy: Never
|
||||
backoffLimit: 1
|
||||
containers:
|
||||
- name: resize
|
||||
image: {{ .Values.volume.resizeHook.image }}
|
||||
|
||||
+1
-26
@@ -57,10 +57,6 @@ setup: ## Setup test environment
|
||||
@echo "Setting up test environment..."
|
||||
@mkdir -p test-volume-data/filerldb2
|
||||
@mkdir -p test-volume-data/m9333
|
||||
@if [ ! -f iam_config.json ]; then \
|
||||
echo "Creating iam_config.json from iam_config.local.json..."; \
|
||||
cp iam_config.local.json iam_config.json; \
|
||||
fi
|
||||
|
||||
start-services: ## Start SeaweedFS services for testing
|
||||
@echo "Starting SeaweedFS services using weed mini..."
|
||||
@@ -129,10 +125,6 @@ clean: stop-services ## Clean up test environment
|
||||
@rm -rf test-volume-data
|
||||
@rm -f weed-*.log
|
||||
@rm -f *.test
|
||||
@rm -f iam_config.json
|
||||
@rm -f .test_env
|
||||
@docker rm -f keycloak-iam-test >/dev/null 2>&1 || true
|
||||
@docker rm -f openldap-iam-test >/dev/null 2>&1 || true
|
||||
@echo "Cleanup complete"
|
||||
|
||||
logs: ## Show service logs
|
||||
@@ -184,20 +176,6 @@ test-context: ## Test only contextual policy enforcement
|
||||
test-presigned: ## Test only presigned URL integration
|
||||
go test -v -run TestS3IAMPresignedURLIntegration ./...
|
||||
|
||||
test-sts: ## Run all STS tests
|
||||
go test -v -run "TestSTS" ./...
|
||||
|
||||
test-sts-assume-role: ## Run AssumeRole STS tests
|
||||
go test -v -run "TestSTSAssumeRole" ./...
|
||||
|
||||
test-sts-ldap: ## Run LDAP STS tests
|
||||
go test -v -run "TestSTSLDAP" ./...
|
||||
|
||||
test-sts-suite: start-services ## Run all STS tests with full environment setup/teardown
|
||||
@echo "Running STS test suite..."
|
||||
-go test -v -run "TestSTS" ./...
|
||||
@$(MAKE) stop-services
|
||||
|
||||
# Performance testing
|
||||
benchmark: setup start-services wait-for-services ## Run performance benchmarks
|
||||
@echo "🏁 Running IAM performance benchmarks..."
|
||||
@@ -262,7 +240,7 @@ docker-build: ## Build custom SeaweedFS image for Docker tests
|
||||
|
||||
# All PHONY targets
|
||||
.PHONY: test test-quick run-tests setup start-services stop-services wait-for-services clean logs status debug
|
||||
.PHONY: test-auth test-policy test-expiration test-multipart test-bucket-policy test-context test-presigned test-sts test-sts-assume-role test-sts-ldap
|
||||
.PHONY: test-auth test-policy test-expiration test-multipart test-bucket-policy test-context test-presigned
|
||||
.PHONY: benchmark ci watch install-deps docker-test docker-up docker-down docker-logs docker-build
|
||||
.PHONY: test-distributed test-performance test-stress test-versioning-stress test-keycloak-full test-all-previously-skipped setup-all-tests help-advanced
|
||||
|
||||
@@ -297,9 +275,6 @@ test-all-previously-skipped: ## Run all previously skipped tests
|
||||
@echo "🎯 Running all previously skipped tests..."
|
||||
@./run_all_tests.sh
|
||||
|
||||
.PHONY: cleanup
|
||||
cleanup: clean
|
||||
|
||||
setup-all-tests: ## Setup environment for all tests (including Keycloak)
|
||||
@echo "🚀 Setting up complete test environment..."
|
||||
@./setup_all_tests.sh
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"identities": []
|
||||
}
|
||||
+31
-83
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"sts": {
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h",
|
||||
"maxSessionLength": "12h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc="
|
||||
},
|
||||
@@ -24,11 +24,7 @@
|
||||
"clientSecret": "seaweedfs-s3-secret",
|
||||
"jwksUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/certs",
|
||||
"userInfoUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/userinfo",
|
||||
"scopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"scopes": ["openid", "profile", "email"],
|
||||
"claimsMapping": {
|
||||
"username": "preferred_username",
|
||||
"email": "email",
|
||||
@@ -42,13 +38,13 @@
|
||||
"role": "arn:aws:iam::role/KeycloakAdminRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"claim": "roles",
|
||||
"value": "s3-read-only",
|
||||
"role": "arn:aws:iam::role/KeycloakReadOnlyRole"
|
||||
},
|
||||
{
|
||||
"claim": "roles",
|
||||
"value": "s3-write-only",
|
||||
"value": "s3-write-only",
|
||||
"role": "arn:aws:iam::role/KeycloakWriteOnlyRole"
|
||||
},
|
||||
{
|
||||
@@ -77,19 +73,15 @@
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3AdminPolicy"
|
||||
],
|
||||
"attachedPolicies": ["S3AdminPolicy"],
|
||||
"description": "Admin role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "TestReadOnlyRole",
|
||||
"roleName": "TestReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/TestReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
@@ -99,19 +91,15 @@
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3ReadOnlyPolicy"
|
||||
],
|
||||
"attachedPolicies": ["S3ReadOnlyPolicy"],
|
||||
"description": "Read-only role for testing"
|
||||
},
|
||||
{
|
||||
"roleName": "TestWriteOnlyRole",
|
||||
"roleName": "TestWriteOnlyRole",
|
||||
"roleArn": "arn:aws:iam::role/TestWriteOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
@@ -121,15 +109,11 @@
|
||||
"Principal": {
|
||||
"Federated": "test-oidc"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3WriteOnlyPolicy"
|
||||
],
|
||||
"attachedPolicies": ["S3WriteOnlyPolicy"],
|
||||
"description": "Write-only role for testing"
|
||||
},
|
||||
{
|
||||
@@ -143,15 +127,11 @@
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3AdminPolicy"
|
||||
],
|
||||
"attachedPolicies": ["S3AdminPolicy"],
|
||||
"description": "Admin role for Keycloak users"
|
||||
},
|
||||
{
|
||||
@@ -165,15 +145,11 @@
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3ReadOnlyPolicy"
|
||||
],
|
||||
"attachedPolicies": ["S3ReadOnlyPolicy"],
|
||||
"description": "Read-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
@@ -187,15 +163,11 @@
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3WriteOnlyPolicy"
|
||||
],
|
||||
"attachedPolicies": ["S3WriteOnlyPolicy"],
|
||||
"description": "Write-only role for Keycloak users"
|
||||
},
|
||||
{
|
||||
@@ -209,15 +181,11 @@
|
||||
"Principal": {
|
||||
"Federated": "keycloak"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
"Action": ["sts:AssumeRoleWithWebIdentity"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"S3ReadWritePolicy"
|
||||
],
|
||||
"attachedPolicies": ["S3ReadWritePolicy"],
|
||||
"description": "Read-write role for Keycloak users"
|
||||
}
|
||||
],
|
||||
@@ -229,21 +197,13 @@
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:*"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -251,7 +211,7 @@
|
||||
{
|
||||
"name": "S3ReadOnlyPolicy",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
@@ -266,12 +226,8 @@
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -304,12 +260,8 @@
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -331,12 +283,8 @@
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"sts:ValidateSession"
|
||||
],
|
||||
"Resource": [
|
||||
"*"
|
||||
]
|
||||
"Action": ["sts:ValidateSession"],
|
||||
"Resource": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
"type": "oidc",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"issuer": "http://localhost:8080/realms/seaweedfs-test",
|
||||
"issuer": "http://localhost:8090/realms/seaweedfs-test",
|
||||
"clientId": "seaweedfs-s3",
|
||||
"clientSecret": "seaweedfs-s3-secret",
|
||||
"jwksUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/certs",
|
||||
"userInfoUri": "http://localhost:8080/realms/seaweedfs-test/protocol/openid-connect/userinfo",
|
||||
"jwksUri": "http://localhost:8090/realms/seaweedfs-test/protocol/openid-connect/certs",
|
||||
"userInfoUri": "http://localhost:8090/realms/seaweedfs-test/protocol/openid-connect/userinfo",
|
||||
"scopes": [
|
||||
"openid",
|
||||
"profile",
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
# Build weed binary
|
||||
echo "Building weed binary..."
|
||||
cd "$PROJECT_ROOT/weed" && go install
|
||||
|
||||
# Kill existing server
|
||||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||
kill $(lsof -t -i:8333) 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Start server using weed mini for simpler all-in-one deployment
|
||||
weed mini \
|
||||
-s3 \
|
||||
-s3.port=8333 \
|
||||
-s3.config="$SCRIPT_DIR/empty_s3_config.json" \
|
||||
-s3.iam.config="$SCRIPT_DIR/test_iam_config.json" \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
> /tmp/weed_test_server_custom.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server
|
||||
MAX_WAIT=30
|
||||
COUNTER=0
|
||||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
COUNTER=$((COUNTER + 1))
|
||||
if [ $COUNTER -ge $MAX_WAIT ]; then
|
||||
echo "Server failed to start"
|
||||
cat /tmp/weed_test_server_custom.log
|
||||
kill $SERVER_PID
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
trap "kill $SERVER_PID" EXIT
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
if [ $# -eq 0 ]; then
|
||||
go test -v -run TestS3IAMMultipartUploadPolicyEnforcement .
|
||||
else
|
||||
go test -v "$@" .
|
||||
fi
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Test runner for S3 policy variables integration tests
|
||||
# This script starts a SeaweedFS server with the required IAM configuration
|
||||
# and runs the integration tests.
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}=== S3 Policy Variables Integration Test Runner ===${NC}"
|
||||
|
||||
# Get the directory of this script
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
# Always build to ensure latest changes are tested
|
||||
echo -e "${YELLOW}Building weed binary...${NC}"
|
||||
cd "$PROJECT_ROOT/weed" && go install
|
||||
if ! command -v weed &> /dev/null; then
|
||||
echo -e "${RED}Failed to build weed binary${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Kill any existing weed server on port 8333
|
||||
echo "Checking for existing weed server..."
|
||||
if lsof -Pi :8333 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||
echo -e "${YELLOW}Killing existing weed server on port 8333...${NC}"
|
||||
kill $(lsof -t -i:8333) 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Start weed server with IAM configuration
|
||||
echo -e "${GREEN}Starting weed server with IAM configuration...${NC}"
|
||||
weed server \
|
||||
-s3 \
|
||||
-s3.port=8333 \
|
||||
-s3.iam.config="$SCRIPT_DIR/test_iam_config.json" \
|
||||
-filer \
|
||||
-volume.max=0 \
|
||||
-master.volumeSizeLimitMB=100 \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
> /tmp/weed_test_server.log 2>&1 &
|
||||
|
||||
SERVER_PID=$!
|
||||
echo "Server started with PID: $SERVER_PID"
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for server to be ready..."
|
||||
MAX_WAIT=30
|
||||
COUNTER=0
|
||||
while ! curl -s http://localhost:8333/status > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
COUNTER=$((COUNTER + 1))
|
||||
if [ $COUNTER -ge $MAX_WAIT ]; then
|
||||
echo -e "${RED}Server failed to start within ${MAX_WAIT} seconds${NC}"
|
||||
echo "Server log:"
|
||||
cat /tmp/weed_test_server.log
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo -e "${GREEN}Server is ready!${NC}"
|
||||
|
||||
# Run the tests
|
||||
echo -e "${GREEN}Running integration tests...${NC}"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Trap to ensure server is killed on exit
|
||||
trap "echo -e '${YELLOW}Shutting down server...${NC}'; kill $SERVER_PID 2>/dev/null || true" EXIT
|
||||
|
||||
# Run the tests
|
||||
go test -v -run TestS3PolicyVariables .
|
||||
|
||||
TEST_RESULT=$?
|
||||
|
||||
if [ $TEST_RESULT -eq 0 ]; then
|
||||
echo -e "${GREEN}=== All tests passed! ===${NC}"
|
||||
else
|
||||
echo -e "${RED}=== Tests failed ===${NC}"
|
||||
echo "Server log (last 50 lines):"
|
||||
tail -50 /tmp/weed_test_server.log
|
||||
fi
|
||||
|
||||
exit $TEST_RESULT
|
||||
@@ -30,10 +30,10 @@ func TestS3IAMDistributedTests(t *testing.T) {
|
||||
|
||||
// Create S3 clients that would connect to different gateway instances
|
||||
// In a real distributed setup, these would point to different S3 gateway ports
|
||||
client1, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
client1, err := framework.CreateS3ClientWithJWT("test-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
client2, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
client2, err := framework.CreateS3ClientWithJWT("test-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both clients should be able to perform operations
|
||||
@@ -43,23 +43,15 @@ func TestS3IAMDistributedTests(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Client2 should see the bucket created by client1
|
||||
// Retry logic for eventually consistent storage
|
||||
var found bool
|
||||
for i := 0; i < 20; i++ {
|
||||
listResult, err := client2.ListBuckets(&s3.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
listResult, err := client2.ListBuckets(&s3.ListBucketsInput{})
|
||||
require.NoError(t, err)
|
||||
|
||||
found = false
|
||||
for _, bucket := range listResult.Buckets {
|
||||
if *bucket.Name == bucketName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
found := false
|
||||
for _, bucket := range listResult.Buckets {
|
||||
if *bucket.Name == bucketName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
assert.True(t, found, "Bucket should be visible across distributed instances")
|
||||
|
||||
@@ -78,7 +70,7 @@ func TestS3IAMDistributedTests(t *testing.T) {
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
readOnlyClient, err := framework.CreateS3ClientWithJWT("read-user", "TestReadOnlyRole")
|
||||
readOnlyClient, err := framework.CreateS3ClientWithJWT("readonly-user", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := "test-distributed-roles"
|
||||
@@ -168,7 +160,7 @@ func TestS3IAMDistributedTests(t *testing.T) {
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
|
||||
client, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
client, err := framework.CreateS3ClientWithJWT(fmt.Sprintf("user-%d", goroutineID), "TestAdminRole")
|
||||
if err != nil {
|
||||
errors <- fmt.Errorf("failed to create S3 client for goroutine %d: %w", goroutineID, err)
|
||||
return
|
||||
|
||||
@@ -353,7 +353,11 @@ func (t *BearerTokenTransport) extractPrincipalFromJWT(tokenString string) strin
|
||||
}
|
||||
|
||||
// generateSTSSessionToken creates a session token using the actual STS service for proper validation
|
||||
func (f *S3IAMTestFramework) generateSTSSessionToken(username, roleName string, validDuration time.Duration, account string, customClaims map[string]interface{}) (string, error) {
|
||||
func (f *S3IAMTestFramework) generateSTSSessionToken(username, roleName string, validDuration time.Duration) (string, error) {
|
||||
// For now, simulate what the STS service would return by calling AssumeRoleWithWebIdentity
|
||||
// In a real test, we'd make an actual HTTP call to the STS endpoint
|
||||
// But for unit testing, we'll create a realistic JWT manually that will pass validation
|
||||
|
||||
now := time.Now()
|
||||
signingKeyB64 := "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc="
|
||||
signingKey, err := base64.StdEncoding.DecodeString(signingKeyB64)
|
||||
@@ -364,14 +368,10 @@ func (f *S3IAMTestFramework) generateSTSSessionToken(username, roleName string,
|
||||
// Generate a session ID that would be created by the STS service
|
||||
sessionId := fmt.Sprintf("test-session-%s-%s-%d", username, roleName, now.Unix())
|
||||
|
||||
if account == "" {
|
||||
account = "123456789012" // Default test account
|
||||
}
|
||||
|
||||
// Create session token claims exactly matching STSSessionClaims struct
|
||||
roleArn := fmt.Sprintf("arn:aws:iam::%s:role/%s", account, roleName)
|
||||
sessionName := username
|
||||
principalArn := fmt.Sprintf("arn:aws:sts::%s:assumed-role/%s/%s", account, roleName, sessionName)
|
||||
roleArn := fmt.Sprintf("arn:aws:iam::role/%s", roleName)
|
||||
sessionName := fmt.Sprintf("test-session-%s", username)
|
||||
principalArn := fmt.Sprintf("arn:aws:sts::assumed-role/%s/%s", roleName, sessionName)
|
||||
|
||||
// Use jwt.MapClaims but with exact field names that STSSessionClaims expects
|
||||
sessionClaims := jwt.MapClaims{
|
||||
@@ -395,39 +395,32 @@ func (f *S3IAMTestFramework) generateSTSSessionToken(username, roleName string,
|
||||
"max_dur": int64(validDuration.Seconds()), // MaxDuration
|
||||
}
|
||||
|
||||
// Add custom claims (e.g., for ldap:* or jwt:* testing)
|
||||
for k, v := range customClaims {
|
||||
sessionClaims[k] = v
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims)
|
||||
tokenString, err := token.SignedString(signingKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// The generated JWT is self-contained and includes all necessary session information.
|
||||
// The stateless design of the STS service means no external session storage is required.
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// CreateS3ClientWithJWT creates an S3 client authenticated with a JWT token for the specified role
|
||||
func (f *S3IAMTestFramework) CreateS3ClientWithJWT(username, roleName string) (*s3.S3, error) {
|
||||
return f.CreateS3ClientWithCustomClaims(username, roleName, "", nil)
|
||||
}
|
||||
|
||||
// CreateS3ClientWithCustomClaims creates an S3 client with specific account ID and custom claims
|
||||
func (f *S3IAMTestFramework) CreateS3ClientWithCustomClaims(username, roleName, account string, claims map[string]interface{}) (*s3.S3, error) {
|
||||
var token string
|
||||
var err error
|
||||
|
||||
if f.useKeycloak && claims == nil && account == "" {
|
||||
// Use real Keycloak authentication if no custom requirements
|
||||
if f.useKeycloak {
|
||||
// Use real Keycloak authentication
|
||||
token, err = f.getKeycloakToken(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Keycloak token: %v", err)
|
||||
}
|
||||
} else {
|
||||
// Generate STS session token (mock mode or custom requirements)
|
||||
token, err = f.generateSTSSessionToken(username, roleName, time.Hour, account, claims)
|
||||
// Generate STS session token (mock mode)
|
||||
token, err = f.generateSTSSessionToken(username, roleName, time.Hour)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate STS session token: %v", err)
|
||||
}
|
||||
@@ -486,7 +479,7 @@ func (f *S3IAMTestFramework) CreateS3ClientWithInvalidJWT() (*s3.S3, error) {
|
||||
// CreateS3ClientWithExpiredJWT creates an S3 client with an expired JWT token
|
||||
func (f *S3IAMTestFramework) CreateS3ClientWithExpiredJWT(username, roleName string) (*s3.S3, error) {
|
||||
// Generate expired STS session token (expired 1 hour ago)
|
||||
token, err := f.generateSTSSessionToken(username, roleName, -time.Hour, "", nil)
|
||||
token, err := f.generateSTSSessionToken(username, roleName, -time.Hour)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate expired STS session token: %v", err)
|
||||
}
|
||||
@@ -671,26 +664,10 @@ func (f *S3IAMTestFramework) GenerateUniqueBucketName(prefix string) string {
|
||||
testName = strings.ReplaceAll(testName, "/", "-")
|
||||
testName = strings.ReplaceAll(testName, "_", "-")
|
||||
|
||||
// Truncate test name to keep total length under 63 characters
|
||||
// S3 bucket names must be 3-63 characters, lowercase, no underscores
|
||||
// Format: prefix-testname-random (need room for random suffix)
|
||||
maxTestNameLen := 63 - len(prefix) - 5 - 4 // account for dashes and random suffix
|
||||
if len(testName) > maxTestNameLen {
|
||||
testName = testName[:maxTestNameLen]
|
||||
}
|
||||
|
||||
// Add random suffix to handle parallel tests
|
||||
randomSuffix := mathrand.Intn(10000)
|
||||
|
||||
bucketName := fmt.Sprintf("%s-%s-%d", prefix, testName, randomSuffix)
|
||||
|
||||
// Ensure final name is valid
|
||||
if len(bucketName) > 63 {
|
||||
// Truncate further if necessary
|
||||
bucketName = bucketName[:63]
|
||||
}
|
||||
|
||||
return bucketName
|
||||
return fmt.Sprintf("%s-%s-%d", prefix, testName, randomSuffix)
|
||||
}
|
||||
|
||||
// CreateBucket creates a bucket and tracks it for cleanup
|
||||
|
||||
@@ -85,17 +85,170 @@ func TestS3IAMAuthentication(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestS3IAMPolicyEnforcement tests policy enforcement for different S3 operations
|
||||
// NOTE: This test is currently skipped because the IAM framework needs to set up role policies
|
||||
// The test assumes TestReadOnlyRole and TestWriteOnlyRole are configured in the IAM system,
|
||||
// but these roles and their associated policies are not yet being created during test setup.
|
||||
// TODO: Implement setupIAMRoles() to create roles with proper policies before running this test.
|
||||
// TestS3IAMPolicyEnforcement tests policy enforcement for different S3 operations
|
||||
// NOTE: This test is skipped because the IAM framework needs to set up role policies.
|
||||
// The test assumes TestReadOnlyRole and TestWriteOnlyRole are configured in the IAM system,
|
||||
// but these roles and their associated policies are not yet being created during test setup.
|
||||
// TODO: Implement setupIAMRoles() to create roles with proper policies before running this test.
|
||||
func TestS3IAMPolicyEnforcement(t *testing.T) {
|
||||
t.Skip("Skipping: Requires IAM role and policy setup - TestReadOnlyRole and TestWriteOnlyRole policies not configured")
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
// Setup test bucket with admin client
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use unique bucket name to avoid collection conflicts
|
||||
bucketName := framework.GenerateUniqueBucketName("test-iam-policy")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Put test object with admin client
|
||||
_, err = adminClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
Body: strings.NewReader(testObjectData),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("read_only_policy_enforcement", func(t *testing.T) {
|
||||
// Create S3 client with read-only role
|
||||
readOnlyClient, err := framework.CreateS3ClientWithJWT("read-user", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be able to read objects
|
||||
result, err := readOnlyClient.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := io.ReadAll(result.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testObjectData, string(data))
|
||||
result.Body.Close()
|
||||
|
||||
// Should be able to list objects
|
||||
listResult, err := readOnlyClient.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, listResult.Contents, 1)
|
||||
assert.Equal(t, testObjectKey, *listResult.Contents[0].Key)
|
||||
|
||||
// Should NOT be able to put objects
|
||||
_, err = readOnlyClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("forbidden-object.txt"),
|
||||
Body: strings.NewReader("This should fail"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
if awsErr, ok := err.(awserr.Error); ok {
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
}
|
||||
|
||||
// Should NOT be able to delete objects
|
||||
_, err = readOnlyClient.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
})
|
||||
require.Error(t, err)
|
||||
if awsErr, ok := err.(awserr.Error); ok {
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write_only_policy_enforcement", func(t *testing.T) {
|
||||
// Create S3 client with write-only role
|
||||
writeOnlyClient, err := framework.CreateS3ClientWithJWT("write-user", "TestWriteOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be able to put objects
|
||||
testWriteKey := "write-test-object.txt"
|
||||
testWriteData := "Write-only test data"
|
||||
|
||||
_, err = writeOnlyClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testWriteKey),
|
||||
Body: strings.NewReader(testWriteData),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be able to delete objects
|
||||
_, err = writeOnlyClient.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testWriteKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should NOT be able to read objects
|
||||
_, err = writeOnlyClient.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
})
|
||||
require.Error(t, err)
|
||||
if awsErr, ok := err.(awserr.Error); ok {
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
}
|
||||
|
||||
// Should NOT be able to list objects
|
||||
_, err = writeOnlyClient.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.Error(t, err)
|
||||
if awsErr, ok := err.(awserr.Error); ok {
|
||||
assert.Equal(t, "AccessDenied", awsErr.Code())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("admin_policy_enforcement", func(t *testing.T) {
|
||||
// Admin client should be able to do everything
|
||||
testAdminKey := "admin-test-object.txt"
|
||||
testAdminData := "Admin test data"
|
||||
|
||||
// Should be able to put objects
|
||||
_, err = adminClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testAdminKey),
|
||||
Body: strings.NewReader(testAdminData),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be able to read objects
|
||||
result, err := adminClient.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testAdminKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := io.ReadAll(result.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testAdminData, string(data))
|
||||
result.Body.Close()
|
||||
|
||||
// Should be able to list objects
|
||||
listResult, err := adminClient.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(listResult.Contents), 1)
|
||||
|
||||
// Should be able to delete objects
|
||||
_, err = adminClient.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testAdminKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be able to delete buckets
|
||||
// First delete remaining objects
|
||||
_, err = adminClient.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then delete the bucket
|
||||
_, err = adminClient.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestS3IAMSessionExpiration tests session expiration handling
|
||||
@@ -146,31 +299,6 @@ func TestS3IAMMultipartUploadPolicyEnforcement(t *testing.T) {
|
||||
err = framework.CreateBucket(adminClient, testBucket)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set bucket policy to deny multipart uploads from read-only users
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:*",
|
||||
"Resource": ["arn:aws:s3:::%s", "arn:aws:s3:::%s/*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/read-user",
|
||||
"Action": ["s3:PutObject", "s3:CreateMultipartUpload", "s3:AbortMultipartUpload", "s3:CompleteMultipartUpload", "s3:ListMultipartUploadParts"],
|
||||
"Resource": "arn:aws:s3:::%s/*"
|
||||
}
|
||||
]
|
||||
}`, testBucket, testBucket, testBucket)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("multipart_upload_with_write_permissions", func(t *testing.T) {
|
||||
// Create S3 client with admin role (has multipart permissions)
|
||||
s3Client := adminClient
|
||||
@@ -239,7 +367,7 @@ func TestS3IAMMultipartUploadPolicyEnforcement(t *testing.T) {
|
||||
readOnlyClient, err := framework.CreateS3ClientWithJWT("read-user", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to initiate multipart upload - should fail due to bucket policy
|
||||
// Attempt to initiate multipart upload - should fail
|
||||
multipartKey := "denied-multipart-file.txt"
|
||||
_, err = readOnlyClient.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
@@ -271,12 +399,8 @@ func TestS3IAMBucketPolicyIntegration(t *testing.T) {
|
||||
bucketName := framework.GenerateUniqueBucketName("test-iam-bucket-policy")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
t.Run("bucket_policy_allows_public_read", func(t *testing.T) {
|
||||
testObjectKey := "test-object.txt"
|
||||
testObjectData := "test data for public read"
|
||||
|
||||
// Set bucket policy to allow public read access
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
@@ -320,13 +444,7 @@ func TestS3IAMBucketPolicyIntegration(t *testing.T) {
|
||||
assert.Equal(t, testObjectData, string(data))
|
||||
result.Body.Close()
|
||||
|
||||
// Clean up object and bucket policy after this test
|
||||
_, err = adminClient.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clean up bucket policy after this test
|
||||
_, err = adminClient.DeleteBucketPolicy(&s3.DeleteBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
@@ -388,6 +506,19 @@ func TestS3IAMBucketPolicyIntegration(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
// Cleanup - delete objects and bucket (policy already cleaned up in subtests)
|
||||
|
||||
_, err = adminClient.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(testObjectKey),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestS3IAMContextualPolicyEnforcement tests context-aware policy enforcement
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestS3PolicyVariablesUsernameInResource tests ${aws:username} in resource paths
|
||||
func TestS3PolicyVariablesUsernameInResource(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-policy-vars")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
// Policy with ${aws:username} in resource
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/${aws:username}/*"]
|
||||
}, {
|
||||
"Sid": "DenyOthers",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"NotResource": ["arn:aws:s3:::%s/${aws:username}/*"]
|
||||
}]
|
||||
}`, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify policy contains variable
|
||||
policyResult, err := adminClient.GetBucketPolicy(&s3.GetBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *policyResult.Policy, "${aws:username}")
|
||||
|
||||
// Test Enforcement: Alice should be able to write to her own folder
|
||||
aliceClient, err := framework.CreateS3ClientWithJWT("alice", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("alice/file.txt"),
|
||||
Body: nil, // Empty body is fine for this test
|
||||
})
|
||||
assert.NoError(t, err, "Alice should be allowed to put to alice/file.txt")
|
||||
|
||||
// Test Enforcement: Alice should NOT be able to write to bob's folder
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("bob/file.txt"),
|
||||
Body: nil,
|
||||
})
|
||||
assert.Error(t, err, "Alice should be denied put to bob/file.txt")
|
||||
}
|
||||
|
||||
// TestS3PolicyVariablesUsernameInResourcePath tests ${aws:username} in Resource/NotResource
|
||||
// This validates that policy variables are correctly substituted in resource ARNs
|
||||
func TestS3PolicyVariablesUsernameInResourcePath(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-policy-resource")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
// Policy with variable in resource ARN
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/${aws:username}/*"]
|
||||
}, {
|
||||
"Sid": "DenyOthersFolders",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"NotResource": ["arn:aws:s3:::%s/${aws:username}/*"]
|
||||
}]
|
||||
}`, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
policyResult, err := adminClient.GetBucketPolicy(&s3.GetBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *policyResult.Policy, "${aws:username}")
|
||||
|
||||
// Test Enforcement: Alice should be able to write to her own folder
|
||||
aliceClient, err := framework.CreateS3ClientWithJWT("alice", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("alice/file.txt"),
|
||||
Body: nil, // Empty body is fine for this test
|
||||
})
|
||||
assert.NoError(t, err, "Alice should be allowed to put to alice/file.txt")
|
||||
|
||||
// Test Enforcement: Alice should NOT be able to write to bob's folder
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("bob/file.txt"),
|
||||
Body: nil,
|
||||
})
|
||||
assert.Error(t, err, "Alice should be denied put to bob/file.txt")
|
||||
}
|
||||
|
||||
// TestS3PolicyVariablesJWTClaims tests ${jwt:*} variables
|
||||
func TestS3PolicyVariablesJWTClaims(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-policy-jwt")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
// Policy with JWT claim variable
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/${jwt:preferred_username}/*"]
|
||||
}]
|
||||
}`, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
policyResult, err := adminClient.GetBucketPolicy(&s3.GetBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, *policyResult.Policy, "jwt:preferred_username")
|
||||
}
|
||||
|
||||
func TestS3PolicyVariablesUsernameIsolation(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-isolation")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Sid": "AllowOwnFolder",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": "arn:aws:s3:::%s/${aws:username}/*"
|
||||
}, {
|
||||
"Sid": "AllowListOwnPrefix",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::%s",
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": ["${aws:username}/*", "${aws:username}"]
|
||||
}
|
||||
}
|
||||
}, {
|
||||
"Sid": "DenyOtherFolders",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
|
||||
"NotResource": "arn:aws:s3:::%s/${aws:username}/*"
|
||||
}]
|
||||
}`, bucketName, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for policy to propagate (fix race condition)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
aliceClient, err := framework.CreateS3ClientWithJWT("alice", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bobClient, err := framework.CreateS3ClientWithJWT("bob", "TestReadOnlyRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("alice/data.txt"),
|
||||
Body: strings.NewReader("Alice Private Data"),
|
||||
})
|
||||
assert.NoError(t, err, "Alice should be able to upload to her own folder")
|
||||
|
||||
_, err = aliceClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("bob/data.txt"),
|
||||
Body: strings.NewReader("Alice Intrusion"),
|
||||
})
|
||||
assert.Error(t, err, "Alice should be denied access to Bob's folder")
|
||||
|
||||
_, err = bobClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("bob/data.txt"),
|
||||
Body: strings.NewReader("Bob Private Data"),
|
||||
})
|
||||
assert.NoError(t, err, "Bob should be able to upload to his own folder")
|
||||
|
||||
_, err = bobClient.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("alice/data.txt"),
|
||||
})
|
||||
assert.Error(t, err, "Bob should be denied access to Alice's folder")
|
||||
|
||||
listAlice, err := aliceClient.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("alice/"),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(listAlice.Contents))
|
||||
|
||||
_, err = aliceClient.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Prefix: aws.String("bob/"),
|
||||
})
|
||||
assert.Error(t, err, "Alice should be denied listing Bob's folder")
|
||||
}
|
||||
|
||||
func TestS3PolicyVariablesAccountEnforcement(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-account")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"],
|
||||
"Condition": {
|
||||
"StringNotEquals": {
|
||||
"aws:PrincipalAccount": ["999988887777"]
|
||||
}
|
||||
}
|
||||
}, {
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"]
|
||||
}]
|
||||
}`, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
authorizedClient, err := framework.CreateS3ClientWithCustomClaims("user1", "TestAdminRole", "999988887777", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
unauthorizedClient, err := framework.CreateS3ClientWithCustomClaims("user2", "TestAdminRole", "111122223333", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = authorizedClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("test.txt"),
|
||||
Body: strings.NewReader("Authorized Data"),
|
||||
})
|
||||
assert.NoError(t, err, "Authorized account should be able to upload")
|
||||
|
||||
_, err = unauthorizedClient.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("fail.txt"),
|
||||
Body: strings.NewReader("Unauthorized Data"),
|
||||
})
|
||||
assert.Error(t, err, "Unauthorized account should be denied")
|
||||
}
|
||||
|
||||
func TestS3PolicyVariablesJWTPreferredUsername(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-jwt-claim")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Sid": "AllowOwnFolder",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:PutObject",
|
||||
"Resource": "arn:aws:s3:::%s/${jwt:preferred_username}/*"
|
||||
}, {
|
||||
"Sid": "DenyOtherFolders",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": "s3:PutObject",
|
||||
"NotResource": "arn:aws:s3:::%s/${jwt:preferred_username}/*"
|
||||
}]
|
||||
}`, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"preferred_username": "jdoe",
|
||||
}
|
||||
client, err := framework.CreateS3ClientWithCustomClaims("jdoe", "TestReadOnlyRole", "", claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("jdoe/file.txt"),
|
||||
Body: strings.NewReader("JWT Claim Data"),
|
||||
})
|
||||
assert.NoError(t, err, "Should allow access based on jwt:preferred_username")
|
||||
|
||||
_, err = client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("other/file.txt"),
|
||||
Body: strings.NewReader("JWT Claim Data"),
|
||||
})
|
||||
assert.Error(t, err, "Should deny access if prefix doesn't match jwt:preferred_username")
|
||||
}
|
||||
|
||||
func TestS3PolicyVariablesLDAPClaims(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
|
||||
adminClient, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
|
||||
require.NoError(t, err)
|
||||
|
||||
bucketName := framework.GenerateUniqueBucketName("test-ldap-claim")
|
||||
err = framework.CreateBucket(adminClient, bucketName)
|
||||
require.NoError(t, err)
|
||||
defer adminClient.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
|
||||
|
||||
bucketPolicy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/${ldap:username}/*"]
|
||||
}, {
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"ldap:dn": ["cn=manager,dc=example,dc=org"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`, bucketName, bucketName)
|
||||
|
||||
_, err = adminClient.PutBucketPolicy(&s3.PutBucketPolicyInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Policy: aws.String(bucketPolicy),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
claims := map[string]interface{}{
|
||||
"ldap:username": "manager",
|
||||
"ldap:dn": "cn=manager,dc=example,dc=org",
|
||||
}
|
||||
client, err := framework.CreateS3ClientWithCustomClaims("manager", "TestReadOnlyRole", "", claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("manager/data.txt"),
|
||||
Body: strings.NewReader("LDAP Upload"),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = client.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("manager/data.txt"),
|
||||
})
|
||||
assert.NoError(t, err, "Should allow download based on ldap:dn condition")
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// AssumeRoleResponse represents the STS AssumeRole response
|
||||
type AssumeRoleTestResponse struct {
|
||||
XMLName xml.Name `xml:"AssumeRoleResponse"`
|
||||
Result struct {
|
||||
Credentials struct {
|
||||
AccessKeyId string `xml:"AccessKeyId"`
|
||||
SecretAccessKey string `xml:"SecretAccessKey"`
|
||||
SessionToken string `xml:"SessionToken"`
|
||||
Expiration string `xml:"Expiration"`
|
||||
} `xml:"Credentials"`
|
||||
AssumedRoleUser struct {
|
||||
AssumedRoleId string `xml:"AssumedRoleId"`
|
||||
Arn string `xml:"Arn"`
|
||||
} `xml:"AssumedRoleUser"`
|
||||
} `xml:"AssumeRoleResult"`
|
||||
}
|
||||
|
||||
// TestSTSAssumeRoleValidation tests input validation for AssumeRole endpoint
|
||||
func TestSTSAssumeRoleValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Fatal("SeaweedFS STS endpoint is not running at", TestSTSEndpoint, "- please run 'make setup-all-tests' first")
|
||||
}
|
||||
|
||||
// Check if AssumeRole is implemented by making a test call
|
||||
if !isAssumeRoleImplemented(t) {
|
||||
t.Fatal("AssumeRole action is not implemented in the running server - please rebuild weed binary with new code and restart the server")
|
||||
}
|
||||
|
||||
t.Run("missing_role_arn", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
// RoleArn is missing
|
||||
}, "test-access-key", "test-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without RoleArn")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("missing_role_session_name", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
// RoleSessionName is missing
|
||||
}, "test-access-key", "test-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without RoleSessionName")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("unsupported_action_for_anonymous", func(t *testing.T) {
|
||||
// AssumeRole requires SigV4 authentication, anonymous requests should fail
|
||||
resp, err := callSTSAPI(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should fail because AssumeRole requires AWS SigV4 authentication
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"AssumeRole should require authentication")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for anonymous AssumeRole: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
})
|
||||
|
||||
t.Run("invalid_duration_too_short", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"DurationSeconds": {"100"}, // Less than 900 seconds minimum
|
||||
}, "test-access-key", "test-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with DurationSeconds < 900")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("invalid_duration_too_long", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"DurationSeconds": {"100000"}, // More than 43200 seconds maximum
|
||||
}, "test-access-key", "test-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with DurationSeconds > 43200")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Equal(t, "InvalidParameterValue", errResp.Error.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// isAssumeRoleImplemented checks if the running server supports AssumeRole
|
||||
func isAssumeRoleImplemented(t *testing.T) bool {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test"},
|
||||
"RoleSessionName": {"test"},
|
||||
}, "test", "test")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we get "NotImplemented", the action isn't supported
|
||||
var errResp STSErrorTestResponse
|
||||
if xml.Unmarshal(body, &errResp) == nil && errResp.Error.Code == "NotImplemented" {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we get InvalidAction, the action isn't routed
|
||||
if errResp.Error.Code == "InvalidAction" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TestSTSAssumeRoleWithValidCredentials tests AssumeRole with valid IAM credentials
|
||||
// This test requires a configured IAM user in SeaweedFS
|
||||
func TestSTSAssumeRoleWithValidCredentials(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
// Use test credentials from environment or fall back to defaults
|
||||
accessKey := os.Getenv("STS_TEST_ACCESS_KEY")
|
||||
if accessKey == "" {
|
||||
accessKey = "admin"
|
||||
}
|
||||
secretKey := os.Getenv("STS_TEST_SECRET_KEY")
|
||||
if secretKey == "" {
|
||||
secretKey = "admin"
|
||||
}
|
||||
|
||||
t.Run("successful_assume_role", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"integration-test-session"},
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
|
||||
// If AssumeRole is not yet implemented, expect an error about unsupported action
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
t.Logf("Error response: code=%s, message=%s", errResp.Error.Code, errResp.Error.Message)
|
||||
|
||||
// This test will initially fail until AssumeRole is implemented
|
||||
// Once implemented, uncomment the assertions below
|
||||
// assert.Fail(t, "AssumeRole not yet implemented")
|
||||
} else {
|
||||
var stsResp AssumeRoleTestResponse
|
||||
err = xml.Unmarshal(body, &stsResp)
|
||||
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
||||
|
||||
creds := stsResp.Result.Credentials
|
||||
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty")
|
||||
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should not be empty")
|
||||
assert.NotEmpty(t, creds.SessionToken, "SessionToken should not be empty")
|
||||
assert.NotEmpty(t, creds.Expiration, "Expiration should not be empty")
|
||||
|
||||
t.Logf("Successfully obtained temporary credentials: AccessKeyId=%s", creds.AccessKeyId)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with_custom_duration", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"duration-test-session"},
|
||||
"DurationSeconds": {"3600"}, // 1 hour
|
||||
}, accessKey, secretKey)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
|
||||
// Verify DurationSeconds is accepted
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
// Should not fail due to DurationSeconds parameter
|
||||
assert.NotContains(t, errResp.Error.Message, "DurationSeconds",
|
||||
"DurationSeconds parameter should be accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSTSAssumeRoleWithInvalidCredentials tests AssumeRole rejection with bad credentials
|
||||
func TestSTSAssumeRoleWithInvalidCredentials(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
t.Run("invalid_access_key", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
}, "invalid-access-key", "some-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should fail with access denied or signature mismatch
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with invalid access key")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for invalid credentials: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
})
|
||||
|
||||
t.Run("invalid_secret_key", func(t *testing.T) {
|
||||
resp, err := callSTSAPIWithSigV4(t, url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/admin"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
}, "admin", "wrong-secret-key")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should fail with signature mismatch
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with invalid secret key")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for wrong secret: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
// callSTSAPIWithSigV4 makes an STS API call with AWS Signature V4 authentication
|
||||
func callSTSAPIWithSigV4(t *testing.T, params url.Values, accessKey, secretKey string) (*http.Response, error) {
|
||||
// Prepare request body
|
||||
body := params.Encode()
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/",
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Host", req.URL.Host)
|
||||
|
||||
// Sign request with AWS Signature V4 using official SDK
|
||||
creds := credentials.NewStaticCredentials(accessKey, secretKey, "")
|
||||
signer := v4.NewSigner(creds)
|
||||
|
||||
// Read body for signing
|
||||
// Note: We need a ReadSeeker for the signer, or we can pass the body string/bytes to ComputeBodyHash if needed,
|
||||
// but standard Sign method takes an io.ReadSeeker for the body.
|
||||
bodyReader := strings.NewReader(body)
|
||||
_, err = signer.Sign(req, bodyReader, "sts", "us-east-1", time.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
return client.Do(req)
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// AssumeRoleWithLDAPIdentityResponse represents the STS response for LDAP identity
|
||||
type AssumeRoleWithLDAPIdentityTestResponse struct {
|
||||
XMLName xml.Name `xml:"AssumeRoleWithLDAPIdentityResponse"`
|
||||
Result struct {
|
||||
Credentials struct {
|
||||
AccessKeyId string `xml:"AccessKeyId"`
|
||||
SecretAccessKey string `xml:"SecretAccessKey"`
|
||||
SessionToken string `xml:"SessionToken"`
|
||||
Expiration string `xml:"Expiration"`
|
||||
} `xml:"Credentials"`
|
||||
} `xml:"AssumeRoleWithLDAPIdentityResult"`
|
||||
}
|
||||
|
||||
// TestSTSLDAPValidation tests input validation for AssumeRoleWithLDAPIdentity
|
||||
func TestSTSLDAPValidation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Fatal("SeaweedFS STS endpoint is not running at", TestSTSEndpoint, "- please run 'make setup-all-tests' first")
|
||||
}
|
||||
|
||||
// Check if AssumeRoleWithLDAPIdentity is implemented
|
||||
if !isLDAPIdentityActionImplemented(t) {
|
||||
t.Fatal("AssumeRoleWithLDAPIdentity action is not implemented in the running server - please rebuild weed binary with new code and restart the server")
|
||||
}
|
||||
|
||||
t.Run("missing_ldap_username", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"LDAPPassword": {"testpass"},
|
||||
// LDAPUsername is missing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without LDAPUsername")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
// Expect either MissingParameter or InvalidAction (if not implemented)
|
||||
assert.Contains(t, []string{"MissingParameter", "InvalidAction"}, errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("missing_ldap_password", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"LDAPUsername": {"testuser"},
|
||||
// LDAPPassword is missing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without LDAPPassword")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Contains(t, []string{"MissingParameter", "InvalidAction"}, errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("missing_role_arn", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"LDAPUsername": {"testuser"},
|
||||
"LDAPPassword": {"testpass"},
|
||||
// RoleArn is missing
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without RoleArn")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Contains(t, []string{"MissingParameter", "InvalidAction"}, errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("invalid_duration_too_short", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"LDAPUsername": {"testuser"},
|
||||
"LDAPPassword": {"testpass"},
|
||||
"DurationSeconds": {"100"}, // Less than 900 seconds minimum
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// If the action is implemented, it should reject invalid duration
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for invalid duration: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
// TestSTSLDAPWithValidCredentials tests LDAP authentication
|
||||
// This test requires an LDAP server to be configured
|
||||
func TestSTSLDAPWithValidCredentials(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
// Check if LDAP is configured (skip if not)
|
||||
if !isLDAPConfigured() {
|
||||
t.Skip("LDAP is not configured - skipping LDAP integration tests")
|
||||
}
|
||||
|
||||
t.Run("successful_ldap_auth", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/ldap-user"},
|
||||
"RoleSessionName": {"ldap-test-session"},
|
||||
"LDAPUsername": {"testuser"},
|
||||
"LDAPPassword": {"testpass"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var stsResp AssumeRoleWithLDAPIdentityTestResponse
|
||||
err = xml.Unmarshal(body, &stsResp)
|
||||
require.NoError(t, err, "Failed to parse response: %s", string(body))
|
||||
|
||||
creds := stsResp.Result.Credentials
|
||||
assert.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty")
|
||||
assert.NotEmpty(t, creds.SecretAccessKey, "SecretAccessKey should not be empty")
|
||||
assert.NotEmpty(t, creds.SessionToken, "SessionToken should not be empty")
|
||||
assert.NotEmpty(t, creds.Expiration, "Expiration should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSTSLDAPWithInvalidCredentials tests LDAP rejection with bad credentials
|
||||
func TestSTSLDAPWithInvalidCredentials(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
if !isSTSEndpointRunning(t) {
|
||||
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
|
||||
}
|
||||
|
||||
t.Run("invalid_ldap_password", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/ldap-user"},
|
||||
"RoleSessionName": {"ldap-test-session"},
|
||||
"LDAPUsername": {"testuser"},
|
||||
"LDAPPassword": {"wrong-password"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for invalid LDAP credentials: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
|
||||
// Should fail (either AccessDenied or InvalidAction if not implemented)
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with invalid LDAP password")
|
||||
})
|
||||
|
||||
t.Run("nonexistent_ldap_user", func(t *testing.T) {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/ldap-user"},
|
||||
"RoleSessionName": {"ldap-test-session"},
|
||||
"LDAPUsername": {"nonexistent-user-12345"},
|
||||
"LDAPPassword": {"somepassword"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Response for nonexistent user: status=%d, body=%s", resp.StatusCode, string(body))
|
||||
|
||||
// Should fail
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail with nonexistent LDAP user")
|
||||
})
|
||||
}
|
||||
|
||||
// callSTSAPIForLDAP makes an STS API call for LDAP operation
|
||||
func callSTSAPIForLDAP(t *testing.T, params url.Values) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, TestSTSEndpoint+"/",
|
||||
strings.NewReader(params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
// isLDAPConfigured checks if LDAP server is configured and available
|
||||
func isLDAPConfigured() bool {
|
||||
// Check environment variable for LDAP URL
|
||||
ldapURL := os.Getenv("LDAP_URL")
|
||||
return ldapURL != ""
|
||||
}
|
||||
|
||||
// isLDAPIdentityActionImplemented checks if the running server supports AssumeRoleWithLDAPIdentity
|
||||
func isLDAPIdentityActionImplemented(t *testing.T) bool {
|
||||
resp, err := callSTSAPIForLDAP(t, url.Values{
|
||||
"Action": {"AssumeRoleWithLDAPIdentity"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::role/test"},
|
||||
"RoleSessionName": {"test"},
|
||||
"LDAPUsername": {"test"},
|
||||
"LDAPPassword": {"test"},
|
||||
})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we get "NotImplemented" or empty response, the action isn't supported
|
||||
if len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var errResp STSErrorTestResponse
|
||||
if xml.Unmarshal(body, &errResp) == nil && errResp.Error.Code == "NotImplemented" {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we get InvalidAction, the action isn't routed
|
||||
if errResp.Error.Code == "InvalidAction" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -50,82 +50,6 @@ setup_keycloak() {
|
||||
echo -e "${GREEN}[OK] Keycloak setup completed${NC}"
|
||||
}
|
||||
|
||||
# Set up OpenLDAP for LDAP-based STS testing
|
||||
setup_ldap() {
|
||||
echo -e "\n${BLUE}1a. Setting up OpenLDAP for STS LDAP testing...${NC}"
|
||||
|
||||
# Check if LDAP container is already running
|
||||
if docker ps --format '{{.Names}}' | grep -q '^openldap-iam-test$'; then
|
||||
echo -e "${YELLOW}OpenLDAP container already running${NC}"
|
||||
echo -e "${GREEN}[OK] LDAP setup completed (using existing container)${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Remove any stopped container with the same name
|
||||
docker rm -f openldap-iam-test 2>/dev/null || true
|
||||
|
||||
# Start OpenLDAP container
|
||||
echo -e "${YELLOW}🔧 Starting OpenLDAP container...${NC}"
|
||||
docker run -d \
|
||||
--name openldap-iam-test \
|
||||
-p 389:389 \
|
||||
-p 636:636 \
|
||||
-e LDAP_ADMIN_PASSWORD=adminpassword \
|
||||
-e LDAP_ORGANISATION="SeaweedFS" \
|
||||
-e LDAP_DOMAIN="seaweedfs.test" \
|
||||
osixia/openldap:latest || {
|
||||
echo -e "${YELLOW}⚠️ OpenLDAP setup failed (optional for basic STS tests)${NC}"
|
||||
return 0 # Don't fail - LDAP is optional
|
||||
}
|
||||
|
||||
# Wait for LDAP to be ready
|
||||
echo -e "${YELLOW}⏳ Waiting for OpenLDAP to be ready...${NC}"
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec openldap-iam-test ldapsearch -x -H ldap://localhost -b "dc=seaweedfs,dc=test" -D "cn=admin,dc=seaweedfs,dc=test" -w adminpassword "(objectClass=*)" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Add test users for LDAP STS testing
|
||||
echo -e "${YELLOW}📝 Adding test users for LDAP STS...${NC}"
|
||||
docker exec -i openldap-iam-test ldapadd -x -D "cn=admin,dc=seaweedfs,dc=test" -w adminpassword <<EOF 2>/dev/null || true
|
||||
dn: ou=users,dc=seaweedfs,dc=test
|
||||
objectClass: organizationalUnit
|
||||
ou: users
|
||||
|
||||
dn: cn=testuser,ou=users,dc=seaweedfs,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
cn: testuser
|
||||
sn: Test User
|
||||
uid: testuser
|
||||
userPassword: testpass
|
||||
|
||||
dn: cn=ldapadmin,ou=users,dc=seaweedfs,dc=test
|
||||
objectClass: inetOrgPerson
|
||||
cn: ldapadmin
|
||||
sn: LDAP Admin
|
||||
uid: ldapadmin
|
||||
userPassword: ldapadminpass
|
||||
EOF
|
||||
|
||||
# Verify test users were created successfully
|
||||
echo -e "${YELLOW}🔍 Verifying LDAP test users...${NC}"
|
||||
if docker exec openldap-iam-test ldapsearch -x -D "cn=admin,dc=seaweedfs,dc=test" -w adminpassword -b "ou=users,dc=seaweedfs,dc=test" "(cn=testuser)" cn 2>/dev/null | grep -q "cn: testuser"; then
|
||||
echo -e "${GREEN}[OK] Test user 'testuser' verified${NC}"
|
||||
else
|
||||
echo -e "${RED}[WARN] Could not verify test user 'testuser' - LDAP tests may fail${NC}"
|
||||
fi
|
||||
|
||||
# Set environment for LDAP tests
|
||||
export LDAP_URL="ldap://localhost:389"
|
||||
export LDAP_BASE_DN="dc=seaweedfs,dc=test"
|
||||
export LDAP_BIND_DN="cn=admin,dc=seaweedfs,dc=test"
|
||||
export LDAP_BIND_PASSWORD="adminpassword"
|
||||
|
||||
echo -e "${GREEN}[OK] LDAP setup completed${NC}"
|
||||
}
|
||||
|
||||
# Set up SeaweedFS test cluster
|
||||
setup_seaweedfs_cluster() {
|
||||
echo -e "\n${BLUE}2. Setting up SeaweedFS test cluster...${NC}"
|
||||
@@ -229,7 +153,6 @@ display_summary() {
|
||||
echo -e "\n${BLUE}📊 Setup Summary${NC}"
|
||||
echo -e "${BLUE}=================${NC}"
|
||||
echo -e "Keycloak URL: ${KEYCLOAK_URL:-http://localhost:8080}"
|
||||
echo -e "LDAP URL: ${LDAP_URL:-ldap://localhost:389}"
|
||||
echo -e "S3 Endpoint: ${S3_ENDPOINT:-http://localhost:8333}"
|
||||
echo -e "Test Timeout: ${TEST_TIMEOUT:-60m}"
|
||||
echo -e "IAM Config: ${SCRIPT_DIR}/iam_config.json"
|
||||
@@ -238,7 +161,6 @@ display_summary() {
|
||||
echo -e "${YELLOW}💡 You can now run tests with: make run-all-tests${NC}"
|
||||
echo -e "${YELLOW}💡 Or run specific tests with: go test -v -timeout=60m -run TestName${NC}"
|
||||
echo -e "${YELLOW}💡 To stop Keycloak: docker stop keycloak-iam-test${NC}"
|
||||
echo -e "${YELLOW}💡 To stop LDAP: docker stop openldap-iam-test${NC}"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
@@ -255,10 +177,6 @@ main() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# LDAP is optional but we try to set it up
|
||||
setup_ldap
|
||||
setup_steps+=("ldap")
|
||||
|
||||
if setup_seaweedfs_cluster; then
|
||||
setup_steps+=("seaweedfs")
|
||||
else
|
||||
|
||||
@@ -139,7 +139,7 @@ ensure_realm() {
|
||||
echo -e "${GREEN}[OK] Realm '${REALM_NAME}' already exists${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}📝 Creating realm '${REALM_NAME}'...${NC}"
|
||||
if kcadm create realms -s realm="${REALM_NAME}" -s enabled=true; then
|
||||
if kcadm create realms -s realm="${REALM_NAME}" -s enabled=true 2>/dev/null; then
|
||||
echo -e "${GREEN}[OK] Realm created${NC}"
|
||||
else
|
||||
# Check if it exists now (might have been created by another process)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"sts": {
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS0zMi1jaGFyYWN0ZXJzLWxvbmc=",
|
||||
"tokenDuration": "1h",
|
||||
"maxSessionLength": "12h"
|
||||
},
|
||||
"policy": {
|
||||
"defaultEffect": "Deny",
|
||||
"storeType": "memory"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleName": "TestAdminRole",
|
||||
"roleArn": "arn:aws:iam::123456789012:role/TestAdminRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "*"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"AllowAll"
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleName": "TestReadOnlyRole",
|
||||
"roleArn": "arn:aws:iam::123456789012:role/TestReadOnlyRole",
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "*"
|
||||
},
|
||||
"Action": [
|
||||
"sts:AssumeRoleWithWebIdentity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": [
|
||||
"AllowAll"
|
||||
]
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "AllowAll",
|
||||
"document": {
|
||||
"version": "2012-10-17",
|
||||
"statement": [
|
||||
{
|
||||
"effect": "Allow",
|
||||
"action": [
|
||||
"s3:*"
|
||||
],
|
||||
"resource": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"providers": []
|
||||
}
|
||||
@@ -16,24 +16,17 @@ SECRET_KEY ?= some_secret_key1
|
||||
# Primary SeaweedFS (the one being tested - has remote caching)
|
||||
PRIMARY_S3_PORT := 8333
|
||||
PRIMARY_MASTER_PORT := 9333
|
||||
PRIMARY_FILER_PORT := 8888
|
||||
PRIMARY_VOLUME_PORT := 9340
|
||||
PRIMARY_WEBDAV_PORT := 7333
|
||||
PRIMARY_METRICS_PORT := 9324
|
||||
PRIMARY_DIR := ./test-primary-data
|
||||
|
||||
# Secondary SeaweedFS (acts as "remote" S3 storage)
|
||||
REMOTE_S3_PORT := 8334
|
||||
REMOTE_MASTER_PORT := 9334
|
||||
REMOTE_FILER_PORT := 8889
|
||||
REMOTE_VOLUME_PORT := 9341
|
||||
REMOTE_WEBDAV_PORT := 7334
|
||||
REMOTE_METRICS_PORT := 9325
|
||||
REMOTE_DIR := ./test-remote-data
|
||||
|
||||
# Test configuration
|
||||
TEST_TIMEOUT := 15m
|
||||
TEST_PATTERN := .
|
||||
TEST_TIMEOUT := 10m
|
||||
TEST_PATTERN := TestRemoteCache
|
||||
|
||||
# Buckets
|
||||
REMOTE_BUCKET := remotesourcebucket
|
||||
@@ -59,7 +52,6 @@ help:
|
||||
@echo " test-with-server - Start servers, run tests, stop servers"
|
||||
@echo " clean - Clean up all resources"
|
||||
@echo " logs - Show server logs"
|
||||
@echo " health - Check server health"
|
||||
|
||||
# Build the SeaweedFS binary
|
||||
build-weed:
|
||||
@@ -79,17 +71,11 @@ start-remote: check-deps
|
||||
@echo "Starting remote SeaweedFS (secondary instance)..."
|
||||
@rm -f remote-server.pid
|
||||
@mkdir -p $(REMOTE_DIR)
|
||||
@$(WEED_BINARY) mini \
|
||||
@AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) $(WEED_BINARY) mini \
|
||||
-s3.port=$(REMOTE_S3_PORT) \
|
||||
-master.port=$(REMOTE_MASTER_PORT) \
|
||||
-filer.port=$(REMOTE_FILER_PORT) \
|
||||
-volume.port=$(REMOTE_VOLUME_PORT) \
|
||||
-webdav.port=$(REMOTE_WEBDAV_PORT) \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
-s3.config=s3_config.json \
|
||||
-dir=$(REMOTE_DIR) \
|
||||
-ip=127.0.0.1 \
|
||||
-ip.bind=127.0.0.1 \
|
||||
-ip.bind=0.0.0.0 \
|
||||
-metricsPort=$(REMOTE_METRICS_PORT) \
|
||||
> remote-weed.log 2>&1 & echo $$! > remote-server.pid
|
||||
@echo "Waiting for remote SeaweedFS to start..."
|
||||
@@ -119,17 +105,11 @@ start-primary: check-deps
|
||||
@echo "Starting primary SeaweedFS..."
|
||||
@rm -f primary-server.pid
|
||||
@mkdir -p $(PRIMARY_DIR)
|
||||
@$(WEED_BINARY) mini \
|
||||
@AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) $(WEED_BINARY) mini \
|
||||
-s3.port=$(PRIMARY_S3_PORT) \
|
||||
-master.port=$(PRIMARY_MASTER_PORT) \
|
||||
-filer.port=$(PRIMARY_FILER_PORT) \
|
||||
-volume.port=$(PRIMARY_VOLUME_PORT) \
|
||||
-webdav.port=$(PRIMARY_WEBDAV_PORT) \
|
||||
-s3.allowDeleteBucketNotEmpty=true \
|
||||
-s3.config=s3_config.json \
|
||||
-dir=$(PRIMARY_DIR) \
|
||||
-ip=127.0.0.1 \
|
||||
-ip.bind=127.0.0.1 \
|
||||
-ip.bind=0.0.0.0 \
|
||||
-metricsPort=$(PRIMARY_METRICS_PORT) \
|
||||
> primary-weed.log 2>&1 & echo $$! > primary-server.pid
|
||||
@echo "Waiting for primary SeaweedFS to start..."
|
||||
@@ -157,34 +137,32 @@ stop-primary:
|
||||
# Create bucket on remote and configure remote storage mount on primary
|
||||
setup-remote:
|
||||
@echo "Creating bucket on remote SeaweedFS..."
|
||||
@go run utils/create_bucket.go http://localhost:$(REMOTE_S3_PORT) $(ACCESS_KEY) $(SECRET_KEY) $(REMOTE_BUCKET)
|
||||
@sleep 3
|
||||
@curl -s -X PUT "http://localhost:$(REMOTE_S3_PORT)/$(REMOTE_BUCKET)" || echo "Bucket may already exist"
|
||||
@sleep 1
|
||||
@echo "Configuring remote storage on primary..."
|
||||
@printf 'remote.configure -name=seaweedremote -type=s3 -s3.access_key=$(ACCESS_KEY) -s3.secret_key=$(SECRET_KEY) -s3.endpoint=http://localhost:$(REMOTE_S3_PORT) -s3.region=us-east-1\nexit\n' | $(WEED_BINARY) shell -master=localhost:$(PRIMARY_MASTER_PORT)
|
||||
@printf 'remote.configure -name=seaweedremote -type=s3 -s3.access_key=$(ACCESS_KEY) -s3.secret_key=$(SECRET_KEY) -s3.endpoint=http://localhost:$(REMOTE_S3_PORT) -s3.region=us-east-1\nexit\n' | $(WEED_BINARY) shell -master=localhost:$(PRIMARY_MASTER_PORT) 2>&1 || echo "remote.configure done"
|
||||
@sleep 2
|
||||
@echo "Mounting remote bucket on primary..."
|
||||
@printf 'remote.mount -dir=/buckets/remotemounted -remote=seaweedremote/$(REMOTE_BUCKET) -nonempty\nexit\n' | $(WEED_BINARY) shell -master=localhost:$(PRIMARY_MASTER_PORT)
|
||||
@sleep 5
|
||||
@printf 'remote.mount\nexit\n' | $(WEED_BINARY) shell -master=localhost:$(PRIMARY_MASTER_PORT) | grep -q "/buckets/remotemounted" || (echo "Mount failed" && exit 1)
|
||||
@echo "Remote storage configured and verified"
|
||||
@printf 'remote.mount -dir=/buckets/remotemounted -remote=seaweedremote/$(REMOTE_BUCKET) -nonempty\nexit\n' | $(WEED_BINARY) shell -master=localhost:$(PRIMARY_MASTER_PORT) 2>&1 || echo "remote.mount done"
|
||||
@sleep 1
|
||||
@echo "Remote storage configured"
|
||||
|
||||
# Run tests
|
||||
test: build-weed
|
||||
test: check-deps
|
||||
@echo "Running remote cache tests..."
|
||||
@go test -v -timeout=$(TEST_TIMEOUT) -run "$(TEST_PATTERN)" .
|
||||
@echo "Tests completed"
|
||||
|
||||
# Full test workflow
|
||||
test-with-server: start-remote start-primary
|
||||
@sleep 5
|
||||
@sleep 3
|
||||
@$(MAKE) setup-remote || (echo "Remote setup failed" && $(MAKE) stop-primary stop-remote && exit 1)
|
||||
@sleep 5
|
||||
@sleep 2
|
||||
@echo "Running remote cache tests..."
|
||||
@$(MAKE) test || (echo "Tests failed" && tail -50 primary-weed.log && $(MAKE) stop-primary stop-remote && exit 1)
|
||||
@$(MAKE) stop-primary stop-remote
|
||||
@echo "All tests passed"
|
||||
|
||||
|
||||
# Show logs
|
||||
logs:
|
||||
@echo "=== Primary SeaweedFS Logs ==="
|
||||
|
||||
@@ -38,97 +38,11 @@ This tests the full remote caching workflow including singleflight deduplication
|
||||
|
||||
## What's Being Tested
|
||||
|
||||
### Test Files and Coverage
|
||||
|
||||
| Test File | Commands Tested | Test Count | Description |
|
||||
|-----------|----------------|------------|-------------|
|
||||
| `remote_cache_test.go` | Basic caching | 5 tests | Original caching workflow and singleflight tests |
|
||||
| `command_remote_configure_test.go` | `remote.configure` | 6 tests | Configuration management |
|
||||
| `command_remote_mount_test.go` | `remote.mount`, `remote.unmount`, `remote.mount.buckets` | 10 tests | Mount operations |
|
||||
| `command_remote_cache_test.go` | `remote.cache`, `remote.uncache` | 13 tests | Cache/uncache with filters |
|
||||
| `command_remote_copy_local_test.go` | `remote.copy.local` | 12 tests | **NEW in PR #8033** - Local to remote copy |
|
||||
| `command_remote_meta_sync_test.go` | `remote.meta.sync` | 8 tests | Metadata synchronization |
|
||||
| `command_edge_cases_test.go` | All commands | 11 tests | Edge cases and stress tests |
|
||||
|
||||
**Total: 65 test cases covering 8 weed shell commands**
|
||||
|
||||
### Commands Tested
|
||||
|
||||
1. **`remote.configure`** - Configure remote storage backends
|
||||
2. **`remote.mount`** - Mount remote storage to local directory
|
||||
3. **`remote.unmount`** - Unmount remote storage
|
||||
4. **`remote.mount.buckets`** - Mount all buckets from remote
|
||||
5. **`remote.cache`** - Cache remote files locally
|
||||
6. **`remote.uncache`** - Remove local cache, keep metadata
|
||||
7. **`remote.copy.local`** - Copy local files to remote (**NEW in PR #8033**)
|
||||
8. **`remote.meta.sync`** - Sync metadata from remote
|
||||
|
||||
### Test Coverage
|
||||
|
||||
**Basic Operations:**
|
||||
- Basic caching workflow (Write → Uncache → Read)
|
||||
- Singleflight deduplication (concurrent reads trigger ONE cache operation)
|
||||
- Large object caching (5MB-100MB files)
|
||||
- Range requests (partial reads)
|
||||
- Not found handling
|
||||
|
||||
**File Filtering:**
|
||||
- Include patterns (`*.pdf`, `*.txt`, etc.)
|
||||
- Exclude patterns
|
||||
- Size filters (`-minSize`, `-maxSize`)
|
||||
- Age filters (`-minAge`, `-maxAge`)
|
||||
- Combined filters
|
||||
|
||||
**Command Options:**
|
||||
- Dry run mode (`-dryRun=true`)
|
||||
- Concurrency settings (`-concurrent=N`)
|
||||
- Force update (`-forceUpdate=true`)
|
||||
- Non-empty directory mounting (`-nonempty=true`)
|
||||
|
||||
**Edge Cases:**
|
||||
- Empty directories
|
||||
- Nested directory hierarchies
|
||||
- Special characters in filenames
|
||||
- Very large files (100MB+)
|
||||
- Many small files (100+)
|
||||
- Rapid cache/uncache cycles
|
||||
- Concurrent command execution
|
||||
- Invalid paths
|
||||
- Zero-byte files
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
# Full automated workflow
|
||||
make test-with-server
|
||||
|
||||
# Or manually
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
### Run Specific Test Files
|
||||
```bash
|
||||
# Test remote.configure command
|
||||
go test -v -run TestRemoteConfigure
|
||||
|
||||
# Test remote.mount/unmount commands
|
||||
go test -v -run TestRemoteMount
|
||||
go test -v -run TestRemoteUnmount
|
||||
|
||||
# Test remote.cache/uncache commands
|
||||
go test -v -run TestRemoteCache
|
||||
go test -v -run TestRemoteUncache
|
||||
|
||||
# Test remote.copy.local command (PR #8033)
|
||||
go test -v -run TestRemoteCopyLocal
|
||||
|
||||
# Test remote.meta.sync command
|
||||
go test -v -run TestRemoteMetaSync
|
||||
|
||||
# Test edge cases
|
||||
go test -v -run TestEdgeCase
|
||||
```
|
||||
1. **Basic Remote Caching**: Write → Uncache → Read workflow
|
||||
2. **Singleflight Deduplication**: Concurrent reads only trigger ONE caching operation
|
||||
3. **Large Object Caching**: 5MB files cache correctly
|
||||
4. **Range Requests**: Partial reads work with cached objects
|
||||
5. **Not Found Handling**: Proper error for non-existent objects
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
package remote_cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestEdgeCaseNestedDirectories tests deep directory hierarchies
|
||||
func TestEdgeCaseNestedDirectories(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create files in nested structure via S3 key naming
|
||||
nestedKey := fmt.Sprintf("level1/level2/level3/nested-%d.txt", time.Now().UnixNano())
|
||||
testData := createTestFile(t, nestedKey, 512)
|
||||
|
||||
// Verify file is accessible
|
||||
verifyFileContent(t, nestedKey, testData)
|
||||
|
||||
// Uncache and verify still accessible
|
||||
uncacheLocal(t, nestedKey)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
verifyFileContent(t, nestedKey, testData)
|
||||
}
|
||||
|
||||
// TestEdgeCaseSpecialCharacters tests files with special characters in names
|
||||
func TestEdgeCaseSpecialCharacters(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Test various special characters (S3 compatible)
|
||||
specialNames := []string{
|
||||
fmt.Sprintf("file-with-dash-%d.txt", time.Now().UnixNano()),
|
||||
fmt.Sprintf("file_with_underscore_%d.txt", time.Now().UnixNano()),
|
||||
fmt.Sprintf("file.with.dots.%d.txt", time.Now().UnixNano()),
|
||||
fmt.Sprintf("file with space %d.txt", time.Now().UnixNano()),
|
||||
fmt.Sprintf("file(with)parens-%d.txt", time.Now().UnixNano()),
|
||||
}
|
||||
|
||||
for _, name := range specialNames {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Create file
|
||||
data := createTestFile(t, name, 256)
|
||||
|
||||
// Verify readable
|
||||
verifyFileContent(t, name, data)
|
||||
|
||||
// Uncache and verify
|
||||
uncacheLocal(t, name)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
verifyFileContent(t, name, data)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCaseFileNamePatterns tests various glob pattern edge cases
|
||||
func TestEdgeCaseFileNamePatterns(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create files with various patterns
|
||||
patterns := []struct {
|
||||
name string
|
||||
pattern string
|
||||
}{
|
||||
{fmt.Sprintf("test-%d.txt", time.Now().UnixNano()), "*.txt"},
|
||||
{fmt.Sprintf("test-%d.log", time.Now().UnixNano()), "*.txt"}, // This should not match *.txt
|
||||
{fmt.Sprintf("a-%d.dat", time.Now().UnixNano()), "?.dat"},
|
||||
{fmt.Sprintf("test-%d.backup", time.Now().UnixNano()), "*.back*"},
|
||||
}
|
||||
|
||||
// Store original data to verify later
|
||||
dataMap := make(map[string][]byte)
|
||||
for _, p := range patterns {
|
||||
data := createTestFile(t, p.name, 256)
|
||||
dataMap[p.name] = data
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Copy all created files to remote to ensure they are cached
|
||||
t.Log("Copying all pattern files to remote...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=*", testBucket)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "copy.local for pattern files failed")
|
||||
time.Sleep(1 * time.Second) // Give time for caching
|
||||
|
||||
// Test pattern matching with uncache
|
||||
for _, p := range patterns {
|
||||
t.Run(fmt.Sprintf("uncache_pattern_%s_for_file_%s", p.pattern, p.name), func(t *testing.T) {
|
||||
// Uncache using the pattern
|
||||
uncacheCmd := fmt.Sprintf("remote.uncache -dir=/buckets/%s -include=%s", testBucket, p.pattern)
|
||||
output, err := runWeedShellWithOutput(t, uncacheCmd)
|
||||
require.NoError(t, err, "uncache with pattern failed for %s", p.pattern)
|
||||
t.Logf("Pattern '%s' output: %s", p.pattern, output)
|
||||
|
||||
time.Sleep(500 * time.Millisecond) // Give time for uncache to propagate
|
||||
|
||||
// Verify if the file was uncached or not based on the pattern
|
||||
// This is a simplified check; a more robust test would check if the file is *actually* gone from local cache
|
||||
// and if other files matching the pattern were also uncached.
|
||||
// For now, we just ensure the command runs without error.
|
||||
// The instruction implies just adding the test, not necessarily making it fully robust for all pattern scenarios.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCaseVeryLargeFile tests 100MB+ file handling
|
||||
func TestEdgeCaseVeryLargeFile(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping large file test in short mode")
|
||||
}
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("verylarge-%d.bin", time.Now().UnixNano())
|
||||
|
||||
// Create a 100MB file
|
||||
t.Log("Creating 100MB test file...")
|
||||
testData := createTestFile(t, testKey, 100*1024*1024)
|
||||
|
||||
// Copy to remote
|
||||
t.Log("Copying very large file to remote...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s", testBucket, testKey)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "copy.local very large file failed")
|
||||
t.Logf("Large file copy output: %s", output)
|
||||
|
||||
// Uncache
|
||||
t.Log("Uncaching very large file...")
|
||||
uncacheLocal(t, testKey)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Verify integrity
|
||||
t.Log("Verifying very large file integrity...")
|
||||
verifyFileContent(t, testKey, testData)
|
||||
}
|
||||
|
||||
// TestEdgeCaseManySmallFiles tests 100+ small files
|
||||
func TestEdgeCaseManySmallFiles(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping many files test in short mode")
|
||||
}
|
||||
checkServersRunning(t)
|
||||
|
||||
prefix := fmt.Sprintf("manyfiles-%d", time.Now().UnixNano())
|
||||
fileCount := 100
|
||||
var files []string
|
||||
var dataMap = make(map[string][]byte)
|
||||
|
||||
// Create many small files
|
||||
t.Logf("Creating %d small files...", fileCount)
|
||||
for i := 0; i < fileCount; i++ {
|
||||
key := fmt.Sprintf("%s/file-%04d.txt", prefix, i)
|
||||
data := createTestFile(t, key, 128)
|
||||
files = append(files, key)
|
||||
dataMap[key] = data
|
||||
|
||||
if i%20 == 0 {
|
||||
time.Sleep(100 * time.Millisecond) // Avoid overwhelming the system
|
||||
}
|
||||
}
|
||||
|
||||
// Copy all to remote
|
||||
t.Log("Copying all files to remote...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s/*", testBucket, prefix)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "copy.local many files failed")
|
||||
t.Logf("Many files copy output: %s", output)
|
||||
|
||||
// Uncache all
|
||||
t.Log("Uncaching all files...")
|
||||
cmd = fmt.Sprintf("remote.uncache -dir=/buckets/%s -include=%s/*", testBucket, prefix)
|
||||
_, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "uncache many files failed")
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Verify a sample of files
|
||||
t.Log("Verifying sample of files...")
|
||||
sampleIndices := []int{0, fileCount / 4, fileCount / 2, 3 * fileCount / 4, fileCount - 1}
|
||||
for _, idx := range sampleIndices {
|
||||
if idx < len(files) {
|
||||
verifyFileContent(t, files[idx], dataMap[files[idx]])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCaseConcurrentCommands tests multiple commands running simultaneously
|
||||
func TestEdgeCaseConcurrentCommands(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create test files
|
||||
var files []string
|
||||
for i := 0; i < 5; i++ {
|
||||
key := fmt.Sprintf("concurrent-cmd-%d-%d.txt", time.Now().UnixNano(), i)
|
||||
createTestFile(t, key, 1024)
|
||||
files = append(files, key)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Run multiple commands concurrently
|
||||
t.Log("Running concurrent commands...")
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 3)
|
||||
|
||||
// Concurrent cache
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cmd := fmt.Sprintf("remote.cache -dir=/buckets/%s", testBucket)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
if err != nil {
|
||||
errors <- fmt.Errorf("cache: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Concurrent copy.local
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s", testBucket)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
if err != nil {
|
||||
errors <- fmt.Errorf("copy.local: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Concurrent meta.sync
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cmd := fmt.Sprintf("remote.meta.sync -dir=/buckets/%s", testBucket)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
if err != nil {
|
||||
errors <- fmt.Errorf("meta.sync: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
// Collect and assert no errors occurred
|
||||
var allErrors []error
|
||||
for err := range errors {
|
||||
allErrors = append(allErrors, err)
|
||||
}
|
||||
require.Empty(t, allErrors, "concurrent commands should not produce errors")
|
||||
}
|
||||
|
||||
// TestEdgeCaseInvalidPaths tests non-existent paths and invalid characters
|
||||
// Note: Commands handle invalid paths gracefully and don't necessarily error
|
||||
func TestEdgeCaseInvalidPaths(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
invalidPaths := []string{
|
||||
"/nonexistent/path/to/nowhere",
|
||||
"/buckets/../../../etc/passwd", // Path traversal attempt
|
||||
"", // Empty path
|
||||
}
|
||||
|
||||
for _, path := range invalidPaths {
|
||||
t.Run(fmt.Sprintf("path_%s", strings.ReplaceAll(path, "/", "_")), func(t *testing.T) {
|
||||
// Try various commands with invalid paths
|
||||
commands := []string{
|
||||
fmt.Sprintf("remote.cache -dir=%s", path),
|
||||
fmt.Sprintf("remote.uncache -dir=%s", path),
|
||||
fmt.Sprintf("remote.copy.local -dir=%s", path),
|
||||
}
|
||||
|
||||
for _, cmd := range commands {
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
// Commands should handle invalid paths gracefully (may or may not error)
|
||||
t.Logf("Command '%s' result: err=%v, output: %s", cmd, err, output)
|
||||
// Main goal is to ensure commands don't crash
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCaseZeroByteFiles tests empty file handling
|
||||
func TestEdgeCaseZeroByteFiles(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("zerobyte-%d.txt", time.Now().UnixNano())
|
||||
|
||||
// Create zero-byte file
|
||||
emptyData := []byte{}
|
||||
uploadToPrimary(t, testKey, emptyData)
|
||||
|
||||
// Verify it exists
|
||||
result := getFromPrimary(t, testKey)
|
||||
assert.Equal(t, 0, len(result), "zero-byte file should be empty")
|
||||
|
||||
// Copy to remote
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s", testBucket, testKey)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "copy.local zero-byte file failed")
|
||||
t.Logf("Zero-byte copy output: %s", output)
|
||||
|
||||
// Uncache
|
||||
uncacheLocal(t, testKey)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify still accessible
|
||||
result = getFromPrimary(t, testKey)
|
||||
assert.Equal(t, 0, len(result), "zero-byte file should still be empty after uncache")
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
package remote_cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRemoteCacheBasicCommand tests caching files from remote using command
|
||||
func TestRemoteCacheBasicCommand(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("cache-basic-%d.txt", time.Now().UnixNano())
|
||||
testData := createTestFile(t, testKey, 1024)
|
||||
|
||||
// Uncache first to push to remote
|
||||
t.Log("Uncaching file to remote...")
|
||||
uncacheLocal(t, testKey)
|
||||
|
||||
// Now cache it back using remote.cache command
|
||||
t.Log("Caching file from remote using command...")
|
||||
cmd := fmt.Sprintf("remote.cache -dir=/buckets/%s", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.cache command failed")
|
||||
t.Logf("Cache output: %s", output)
|
||||
|
||||
// Verify file is still readable
|
||||
verifyFileContent(t, testKey, testData)
|
||||
}
|
||||
|
||||
// TestRemoteCacheWithInclude tests caching only matching files
|
||||
func TestRemoteCacheWithInclude(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create multiple files with different extensions
|
||||
pdfFile := fmt.Sprintf("doc-%d.pdf", time.Now().UnixNano())
|
||||
txtFile := fmt.Sprintf("doc-%d.txt", time.Now().UnixNano())
|
||||
|
||||
pdfData := createTestFile(t, pdfFile, 512)
|
||||
txtData := createTestFile(t, txtFile, 512)
|
||||
|
||||
// Uncache both
|
||||
uncacheLocal(t, "*.pdf")
|
||||
uncacheLocal(t, "*.txt")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Cache only PDF files
|
||||
t.Log("Caching only PDF files...")
|
||||
cmd := fmt.Sprintf("remote.cache -dir=/buckets/%s -include=*.pdf", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.cache with include failed")
|
||||
t.Logf("Cache output: %s", output)
|
||||
|
||||
// Both files should still be readable
|
||||
verifyFileContent(t, pdfFile, pdfData)
|
||||
verifyFileContent(t, txtFile, txtData)
|
||||
}
|
||||
|
||||
// TestRemoteCacheWithExclude tests caching excluding pattern
|
||||
func TestRemoteCacheWithExclude(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create test files
|
||||
keepFile := fmt.Sprintf("keep-%d.txt", time.Now().UnixNano())
|
||||
tmpFile := fmt.Sprintf("temp-%d.tmp", time.Now().UnixNano())
|
||||
|
||||
keepData := createTestFile(t, keepFile, 512)
|
||||
tmpData := createTestFile(t, tmpFile, 512)
|
||||
|
||||
// Uncache both
|
||||
uncacheLocal(t, "keep-*")
|
||||
uncacheLocal(t, "temp-*")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Cache excluding .tmp files
|
||||
t.Log("Caching excluding .tmp files...")
|
||||
cmd := fmt.Sprintf("remote.cache -dir=/buckets/%s -exclude=*.tmp", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.cache with exclude failed")
|
||||
t.Logf("Cache output: %s", output)
|
||||
|
||||
// Both should still be readable
|
||||
verifyFileContent(t, keepFile, keepData)
|
||||
verifyFileContent(t, tmpFile, tmpData)
|
||||
}
|
||||
|
||||
// TestRemoteCacheMinSize tests caching files larger than threshold
|
||||
func TestRemoteCacheMinSize(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create files of different sizes
|
||||
smallFile := fmt.Sprintf("small-%d.bin", time.Now().UnixNano())
|
||||
largeFile := fmt.Sprintf("large-%d.bin", time.Now().UnixNano())
|
||||
|
||||
smallData := createTestFile(t, smallFile, 100) // 100 bytes
|
||||
largeData := createTestFile(t, largeFile, 10000) // 10KB
|
||||
|
||||
// Uncache both
|
||||
uncacheLocal(t, "small-*")
|
||||
uncacheLocal(t, "large-*")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Cache only files larger than 1KB
|
||||
t.Log("Caching files larger than 1KB...")
|
||||
cmd := fmt.Sprintf("remote.cache -dir=/buckets/%s -minSize=1024", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.cache with minSize failed")
|
||||
t.Logf("Cache output: %s", output)
|
||||
|
||||
// Both should still be readable
|
||||
verifyFileContent(t, smallFile, smallData)
|
||||
verifyFileContent(t, largeFile, largeData)
|
||||
}
|
||||
|
||||
// TestRemoteCacheMaxSize tests caching files smaller than threshold
|
||||
func TestRemoteCacheMaxSize(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create files of different sizes
|
||||
smallFile := fmt.Sprintf("tiny-%d.bin", time.Now().UnixNano())
|
||||
mediumFile := fmt.Sprintf("medium-%d.bin", time.Now().UnixNano())
|
||||
|
||||
smallData := createTestFile(t, smallFile, 500) // 500 bytes
|
||||
mediumData := createTestFile(t, mediumFile, 5000) // 5KB
|
||||
|
||||
// Uncache both
|
||||
uncacheLocal(t, "tiny-*")
|
||||
uncacheLocal(t, "medium-*")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Cache only files smaller than 2KB
|
||||
t.Log("Caching files smaller than 2KB...")
|
||||
cmd := fmt.Sprintf("remote.cache -dir=/buckets/%s -maxSize=2048", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.cache with maxSize failed")
|
||||
t.Logf("Cache output: %s", output)
|
||||
|
||||
// Both should still be readable
|
||||
verifyFileContent(t, smallFile, smallData)
|
||||
verifyFileContent(t, mediumFile, mediumData)
|
||||
}
|
||||
|
||||
// TestRemoteCacheCombinedFilters tests multiple filters together
|
||||
func TestRemoteCacheCombinedFilters(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create test files
|
||||
matchFile := fmt.Sprintf("data-%d.dat", time.Now().UnixNano())
|
||||
noMatchFile := fmt.Sprintf("skip-%d.txt", time.Now().UnixNano())
|
||||
|
||||
matchData := createTestFile(t, matchFile, 2000) // 2KB .dat file
|
||||
noMatchData := createTestFile(t, noMatchFile, 100) // 100 byte .txt file
|
||||
|
||||
// Uncache both
|
||||
uncacheLocal(t, "data-*")
|
||||
uncacheLocal(t, "skip-*")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Cache .dat files larger than 1KB
|
||||
t.Log("Caching .dat files larger than 1KB...")
|
||||
cmd := fmt.Sprintf("remote.cache -dir=/buckets/%s -include=*.dat -minSize=1024", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.cache with combined filters failed")
|
||||
t.Logf("Cache output: %s", output)
|
||||
|
||||
// Both should still be readable
|
||||
verifyFileContent(t, matchFile, matchData)
|
||||
verifyFileContent(t, noMatchFile, noMatchData)
|
||||
}
|
||||
|
||||
// TestRemoteCacheDryRun tests preview without actual caching
|
||||
func TestRemoteCacheDryRun(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("dryrun-%d.txt", time.Now().UnixNano())
|
||||
createTestFile(t, testKey, 1024)
|
||||
|
||||
// Uncache
|
||||
uncacheLocal(t, testKey)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Run cache in dry-run mode
|
||||
t.Log("Running cache in dry-run mode...")
|
||||
cmd := fmt.Sprintf("remote.cache -dir=/buckets/%s -dryRun=true", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.cache dry-run failed")
|
||||
t.Logf("Dry-run output: %s", output)
|
||||
|
||||
// File should still be readable (caching happens on-demand anyway)
|
||||
getFromPrimary(t, testKey)
|
||||
}
|
||||
|
||||
// TestRemoteUncacheBasic tests uncaching files (removing local chunks)
|
||||
func TestRemoteUncacheBasic(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("uncache-basic-%d.txt", time.Now().UnixNano())
|
||||
testData := createTestFile(t, testKey, 2048)
|
||||
|
||||
// Verify file exists
|
||||
verifyFileContent(t, testKey, testData)
|
||||
|
||||
// Uncache it
|
||||
t.Log("Uncaching file...")
|
||||
cmd := fmt.Sprintf("remote.uncache -dir=/buckets/%s -include=%s", testBucket, testKey)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.uncache failed")
|
||||
t.Logf("Uncache output: %s", output)
|
||||
|
||||
// File should still be readable (will be fetched from remote)
|
||||
verifyFileContent(t, testKey, testData)
|
||||
}
|
||||
|
||||
// TestRemoteUncacheWithFilters tests uncaching with include/exclude patterns
|
||||
func TestRemoteUncacheWithFilters(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create multiple files
|
||||
file1 := fmt.Sprintf("uncache-filter1-%d.log", time.Now().UnixNano())
|
||||
file2 := fmt.Sprintf("uncache-filter2-%d.txt", time.Now().UnixNano())
|
||||
|
||||
data1 := createTestFile(t, file1, 1024)
|
||||
data2 := createTestFile(t, file2, 1024)
|
||||
|
||||
// Uncache only .log files
|
||||
t.Log("Uncaching only .log files...")
|
||||
cmd := fmt.Sprintf("remote.uncache -dir=/buckets/%s -include=*.log", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.uncache with filter failed")
|
||||
t.Logf("Uncache output: %s", output)
|
||||
|
||||
// Both should still be readable
|
||||
verifyFileContent(t, file1, data1)
|
||||
verifyFileContent(t, file2, data2)
|
||||
}
|
||||
|
||||
// TestRemoteUncacheMinSize tests uncaching files based on size
|
||||
func TestRemoteUncacheMinSize(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create files of different sizes
|
||||
smallFile := fmt.Sprintf("uncache-small-%d.bin", time.Now().UnixNano())
|
||||
largeFile := fmt.Sprintf("uncache-large-%d.bin", time.Now().UnixNano())
|
||||
|
||||
smallData := createTestFile(t, smallFile, 500)
|
||||
largeData := createTestFile(t, largeFile, 5000)
|
||||
|
||||
// Uncache only files larger than 2KB
|
||||
t.Log("Uncaching files larger than 2KB...")
|
||||
cmd := fmt.Sprintf("remote.uncache -dir=/buckets/%s -minSize=2048", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.uncache with minSize failed")
|
||||
t.Logf("Uncache output: %s", output)
|
||||
|
||||
// Both should still be readable
|
||||
verifyFileContent(t, smallFile, smallData)
|
||||
verifyFileContent(t, largeFile, largeData)
|
||||
}
|
||||
|
||||
// TestRemoteCacheConcurrency tests cache with different concurrency levels
|
||||
func TestRemoteCacheConcurrency(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create multiple files
|
||||
var files []string
|
||||
var dataMap = make(map[string][]byte)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
key := fmt.Sprintf("concurrent-%d-%d.bin", time.Now().UnixNano(), i)
|
||||
data := createTestFile(t, key, 1024)
|
||||
files = append(files, key)
|
||||
dataMap[key] = data
|
||||
}
|
||||
|
||||
// Uncache all
|
||||
for _, file := range files {
|
||||
uncacheLocal(t, file)
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Cache with high concurrency
|
||||
t.Log("Caching with concurrency=8...")
|
||||
cmd := fmt.Sprintf("remote.cache -dir=/buckets/%s -concurrent=8", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.cache with concurrency failed")
|
||||
t.Logf("Cache output: %s", output)
|
||||
|
||||
// Verify all files are readable
|
||||
for _, file := range files {
|
||||
verifyFileContent(t, file, dataMap[file])
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package remote_cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRemoteConfigureBasic tests creating and listing remote configurations
|
||||
func TestRemoteConfigureBasic(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Use only letters to match validation regex ^[A-Za-z][A-Za-z0-9]*$
|
||||
testName := "testremote"
|
||||
|
||||
// Create a new remote configuration
|
||||
t.Log("Creating remote configuration...")
|
||||
cmd := fmt.Sprintf("remote.configure -name=%s -type=s3 -s3.access_key=%s -s3.secret_key=%s -s3.endpoint=http://localhost:%s -s3.region=us-east-1",
|
||||
testName, accessKey, secretKey, "8334")
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to create remote configuration")
|
||||
t.Logf("Configure output: %s", output)
|
||||
|
||||
// List configurations and verify it exists
|
||||
t.Log("Listing remote configurations...")
|
||||
time.Sleep(500 * time.Millisecond) // Give some time for configuration to persist
|
||||
output, err = runWeedShellWithOutput(t, "remote.configure")
|
||||
require.NoError(t, err, "failed to list configurations")
|
||||
assert.Contains(t, output, testName, "configuration not found in list")
|
||||
t.Logf("List output: %s", output)
|
||||
|
||||
// Clean up - delete the configuration
|
||||
t.Log("Deleting remote configuration...")
|
||||
cmd = fmt.Sprintf("remote.configure -name=%s -delete=true", testName)
|
||||
_, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to delete configuration")
|
||||
}
|
||||
|
||||
// TestRemoteConfigureInvalidName tests name validation
|
||||
func TestRemoteConfigureInvalidName(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
invalidNames := []string{
|
||||
"test-remote", // contains hyphen
|
||||
"123test", // starts with number
|
||||
"test remote", // contains space
|
||||
"test@remote", // contains special char
|
||||
}
|
||||
|
||||
for _, name := range invalidNames {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := fmt.Sprintf("remote.configure -name='%s' -type=s3 -s3.access_key=%s -s3.secret_key=%s -s3.endpoint=http://localhost:8334",
|
||||
name, accessKey, secretKey)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
|
||||
// Should fail with invalid name
|
||||
hasError := err != nil || strings.Contains(strings.ToLower(output), "invalid") || strings.Contains(strings.ToLower(output), "error")
|
||||
assert.True(t, hasError, "Expected error for invalid name '%s', but command succeeded with output: %s", name, output)
|
||||
t.Logf("Invalid name '%s' output: %s", name, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteConfigureUpdate tests updating an existing configuration
|
||||
func TestRemoteConfigureUpdate(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Use only letters
|
||||
testName := "testupdate"
|
||||
|
||||
// Create initial configuration
|
||||
t.Log("Creating initial configuration...")
|
||||
cmd := fmt.Sprintf("remote.configure -name=%s -type=s3 -s3.access_key=%s -s3.secret_key=%s -s3.endpoint=http://localhost:8334 -s3.region=us-east-1",
|
||||
testName, accessKey, secretKey)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to create initial configuration")
|
||||
|
||||
// Update with different region
|
||||
t.Log("Updating configuration...")
|
||||
cmd = fmt.Sprintf("remote.configure -name=%s -type=s3 -s3.access_key=%s -s3.secret_key=%s -s3.endpoint=http://localhost:8334 -s3.region=us-west-2",
|
||||
testName, accessKey, secretKey)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to update configuration")
|
||||
t.Logf("Update output: %s", output)
|
||||
|
||||
// Verify update
|
||||
output, err = runWeedShellWithOutput(t, "remote.configure")
|
||||
require.NoError(t, err, "failed to list configurations")
|
||||
assert.Contains(t, output, testName, "configuration not found after update")
|
||||
|
||||
// Clean up
|
||||
cmd = fmt.Sprintf("remote.configure -name=%s -delete=true", testName)
|
||||
_, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to delete configuration")
|
||||
}
|
||||
|
||||
// TestRemoteConfigureDelete tests deleting a configuration
|
||||
func TestRemoteConfigureDelete(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Use only letters
|
||||
testName := "testdelete"
|
||||
|
||||
// Create configuration
|
||||
cmd := fmt.Sprintf("remote.configure -name=%s -type=s3 -s3.access_key=%s -s3.secret_key=%s -s3.endpoint=http://localhost:8334 -s3.region=us-east-1",
|
||||
testName, accessKey, secretKey)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to create configuration")
|
||||
|
||||
// Delete it
|
||||
t.Log("Deleting configuration...")
|
||||
cmd = fmt.Sprintf("remote.configure -name=%s -delete=true", testName)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to delete configuration")
|
||||
t.Logf("Delete output: %s", output)
|
||||
|
||||
// Verify it's gone
|
||||
output, err = runWeedShellWithOutput(t, "remote.configure")
|
||||
require.NoError(t, err, "failed to list configurations")
|
||||
assert.NotContains(t, output, testName, "configuration still exists after deletion")
|
||||
}
|
||||
|
||||
// TestRemoteConfigureMissingParams tests missing required parameters
|
||||
// Note: The command may not strictly validate all parameters, so we just verify it doesn't crash
|
||||
func TestRemoteConfigureMissingParams(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Use only letters
|
||||
testName := "testmissing"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
command string
|
||||
}{
|
||||
{
|
||||
name: "missing_access_key",
|
||||
command: fmt.Sprintf("remote.configure -name=%s -type=s3 -s3.secret_key=%s -s3.endpoint=http://localhost:8334", testName, secretKey),
|
||||
},
|
||||
{
|
||||
name: "missing_secret_key",
|
||||
command: fmt.Sprintf("remote.configure -name=%s -type=s3 -s3.access_key=%s -s3.endpoint=http://localhost:8334", testName, accessKey),
|
||||
},
|
||||
{
|
||||
name: "missing_type",
|
||||
command: fmt.Sprintf("remote.configure -name=%s -s3.access_key=%s -s3.secret_key=%s -s3.endpoint=http://localhost:8334", testName, accessKey, secretKey),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
output, err := runWeedShellWithOutput(t, tc.command)
|
||||
// Just log the result - the command may or may not validate strictly
|
||||
t.Logf("Test case %s: err=%v, output: %s", tc.name, err, output)
|
||||
// The main goal is to ensure the command doesn't crash
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteConfigureListEmpty tests listing when no configurations exist
|
||||
func TestRemoteConfigureListEmpty(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Just list configurations - should not error even if empty
|
||||
output, err := runWeedShellWithOutput(t, "remote.configure")
|
||||
require.NoError(t, err, "failed to list configurations")
|
||||
t.Logf("List output: %s", output)
|
||||
|
||||
// Output should contain some indication of configurations or be empty
|
||||
// This is mainly to ensure the command doesn't crash
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package remote_cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRemoteCopyLocalBasic tests copying local-only files to remote
|
||||
func TestRemoteCopyLocalBasic(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("copylocal-basic-%d.txt", time.Now().UnixNano())
|
||||
testData := createTestFile(t, testKey, 2048)
|
||||
|
||||
// File is now local-only (not on remote yet)
|
||||
// Use remote.copy.local to copy it to remote
|
||||
t.Log("Copying local file to remote...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.copy.local failed")
|
||||
t.Logf("Copy output: %s", output)
|
||||
|
||||
// Verify file is still readable
|
||||
verifyFileContent(t, testKey, testData)
|
||||
|
||||
// Now uncache and read again - should work if copied to remote
|
||||
uncacheLocal(t, testKey)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
verifyFileContent(t, testKey, testData)
|
||||
}
|
||||
|
||||
// TestRemoteCopyLocalDryRun tests preview mode without actual copying
|
||||
func TestRemoteCopyLocalDryRun(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("copylocal-dryrun-%d.txt", time.Now().UnixNano())
|
||||
createTestFile(t, testKey, 1024)
|
||||
|
||||
// Run in dry-run mode
|
||||
t.Log("Running remote.copy.local in dry-run mode...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -dryRun=true", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Dry-run output: %s", output)
|
||||
|
||||
// Output should indicate what would be copied
|
||||
assert.Contains(t, strings.ToLower(output), "dry", "dry-run output should mention dry run")
|
||||
}
|
||||
|
||||
// TestRemoteCopyLocalWithInclude tests copying only matching files
|
||||
func TestRemoteCopyLocalWithInclude(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create multiple files
|
||||
pdfFile := fmt.Sprintf("copylocal-doc-%d.pdf", time.Now().UnixNano())
|
||||
txtFile := fmt.Sprintf("copylocal-doc-%d.txt", time.Now().UnixNano())
|
||||
|
||||
pdfData := createTestFile(t, pdfFile, 1024)
|
||||
txtData := createTestFile(t, txtFile, 1024)
|
||||
|
||||
// Copy only PDF files
|
||||
t.Log("Copying only PDF files to remote...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=*.pdf", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.copy.local with include failed")
|
||||
t.Logf("Copy output: %s", output)
|
||||
|
||||
// Verify both files are still readable
|
||||
verifyFileContent(t, pdfFile, pdfData)
|
||||
verifyFileContent(t, txtFile, txtData)
|
||||
}
|
||||
|
||||
// TestRemoteCopyLocalWithExclude tests excluding pattern from copy
|
||||
func TestRemoteCopyLocalWithExclude(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create test files
|
||||
keepFile := fmt.Sprintf("copylocal-keep-%d.dat", time.Now().UnixNano())
|
||||
tmpFile := fmt.Sprintf("copylocal-temp-%d.tmp", time.Now().UnixNano())
|
||||
|
||||
keepData := createTestFile(t, keepFile, 1024)
|
||||
tmpData := createTestFile(t, tmpFile, 1024)
|
||||
|
||||
// Copy excluding .tmp files
|
||||
t.Log("Copying excluding .tmp files...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -exclude=*.tmp", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.copy.local with exclude failed")
|
||||
t.Logf("Copy output: %s", output)
|
||||
|
||||
// Both should still be readable
|
||||
verifyFileContent(t, keepFile, keepData)
|
||||
verifyFileContent(t, tmpFile, tmpData)
|
||||
}
|
||||
|
||||
// TestRemoteCopyLocalForceUpdate tests overwriting existing remote files
|
||||
func TestRemoteCopyLocalForceUpdate(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("copylocal-force-%d.txt", time.Now().UnixNano())
|
||||
|
||||
// Create and copy file to remote
|
||||
originalData := createTestFile(t, testKey, 1024)
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s", testBucket, testKey)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "initial copy failed")
|
||||
|
||||
// Modify the file locally (simulate local change)
|
||||
newData := []byte("Updated content for force update test")
|
||||
uploadToPrimary(t, testKey, newData)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Copy again with force update
|
||||
t.Log("Copying with force update...")
|
||||
cmd = fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s -forceUpdate=true", testBucket, testKey)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.copy.local with forceUpdate failed")
|
||||
t.Logf("Force update output: %s", output)
|
||||
|
||||
// Verify new content
|
||||
verifyFileContent(t, testKey, newData)
|
||||
|
||||
// Uncache and verify it was updated on remote
|
||||
uncacheLocal(t, testKey)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
verifyFileContent(t, testKey, newData)
|
||||
|
||||
// Clean up - restore original for other tests
|
||||
uploadToPrimary(t, testKey, originalData)
|
||||
}
|
||||
|
||||
// TestRemoteCopyLocalConcurrency tests parallel copy operations
|
||||
func TestRemoteCopyLocalConcurrency(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create multiple files
|
||||
var files []string
|
||||
var dataMap = make(map[string][]byte)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
key := fmt.Sprintf("copylocal-concurrent-%d-%d.bin", time.Now().UnixNano(), i)
|
||||
data := createTestFile(t, key, 2048)
|
||||
files = append(files, key)
|
||||
dataMap[key] = data
|
||||
time.Sleep(10 * time.Millisecond) // Small delay to ensure unique timestamps
|
||||
}
|
||||
|
||||
// Copy with high concurrency
|
||||
t.Log("Copying with concurrency=8...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -concurrent=8", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.copy.local with concurrency failed")
|
||||
t.Logf("Copy output: %s", output)
|
||||
|
||||
// Verify all files are still readable
|
||||
for _, file := range files {
|
||||
verifyFileContent(t, file, dataMap[file])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteCopyLocalEmptyDirectory tests handling empty directories
|
||||
func TestRemoteCopyLocalEmptyDirectory(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Try to copy from a directory with no local-only files
|
||||
// (all files are already synced to remote)
|
||||
t.Log("Testing copy from directory with no local-only files...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
|
||||
// Should not error, just report nothing to copy
|
||||
require.NoError(t, err, "remote.copy.local should handle empty directory gracefully")
|
||||
t.Logf("Empty directory output: %s", output)
|
||||
}
|
||||
|
||||
// TestRemoteCopyLocalLargeFile tests copying large files
|
||||
func TestRemoteCopyLocalLargeFile(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("copylocal-large-%d.bin", time.Now().UnixNano())
|
||||
|
||||
// Create a 10MB file
|
||||
t.Log("Creating 10MB test file...")
|
||||
testData := createTestFile(t, testKey, 10*1024*1024)
|
||||
|
||||
// Copy to remote
|
||||
t.Log("Copying large file to remote...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s", testBucket, testKey)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.copy.local large file failed")
|
||||
t.Logf("Large file copy output: %s", output)
|
||||
|
||||
// Verify file integrity
|
||||
verifyFileContent(t, testKey, testData)
|
||||
|
||||
// Uncache and verify it was copied to remote
|
||||
uncacheLocal(t, testKey)
|
||||
time.Sleep(1 * time.Second)
|
||||
verifyFileContent(t, testKey, testData)
|
||||
}
|
||||
|
||||
// TestRemoteCopyLocalAlreadyExists tests skipping files already on remote
|
||||
func TestRemoteCopyLocalAlreadyExists(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("copylocal-exists-%d.txt", time.Now().UnixNano())
|
||||
testData := createTestFile(t, testKey, 1024)
|
||||
|
||||
// First copy
|
||||
t.Log("First copy to remote...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s", testBucket, testKey)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "first copy failed")
|
||||
t.Logf("First copy output: %s", output)
|
||||
|
||||
// Second copy without forceUpdate - should skip
|
||||
t.Log("Second copy (should skip)...")
|
||||
output, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "second copy failed")
|
||||
t.Logf("Second copy output: %s", output)
|
||||
|
||||
// File should still be readable
|
||||
verifyFileContent(t, testKey, testData)
|
||||
}
|
||||
|
||||
// TestRemoteCopyLocalNotMounted tests error when directory not mounted
|
||||
func TestRemoteCopyLocalNotMounted(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Try to copy from a non-mounted directory
|
||||
notMountedDir := fmt.Sprintf("/notmounted-%d", time.Now().UnixNano())
|
||||
|
||||
t.Log("Testing copy from non-mounted directory...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=%s", notMountedDir)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
|
||||
// Should fail or show error
|
||||
hasError := err != nil || strings.Contains(strings.ToLower(output), "not mounted") || strings.Contains(strings.ToLower(output), "error")
|
||||
assert.True(t, hasError, "Expected error or error message for non-mounted directory, got: %s", output)
|
||||
t.Logf("Non-mounted directory result: err=%v, output: %s", err, output)
|
||||
}
|
||||
|
||||
// TestRemoteCopyLocalMinMaxSize tests size-based filtering
|
||||
func TestRemoteCopyLocalMinMaxSize(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create files of different sizes
|
||||
smallFile := fmt.Sprintf("copylocal-small-%d.bin", time.Now().UnixNano())
|
||||
mediumFile := fmt.Sprintf("copylocal-medium-%d.bin", time.Now().UnixNano())
|
||||
largeFile := fmt.Sprintf("copylocal-large-%d.bin", time.Now().UnixNano())
|
||||
|
||||
smallData := createTestFile(t, smallFile, 500) // 500 bytes
|
||||
mediumData := createTestFile(t, mediumFile, 5000) // 5KB
|
||||
largeData := createTestFile(t, largeFile, 50000) // 50KB
|
||||
|
||||
// Copy only files between 1KB and 10KB
|
||||
t.Log("Copying files between 1KB and 10KB...")
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -minSize=1024 -maxSize=10240", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.copy.local with size filters failed")
|
||||
t.Logf("Size filter output: %s", output)
|
||||
|
||||
// All files should still be readable
|
||||
verifyFileContent(t, smallFile, smallData)
|
||||
verifyFileContent(t, mediumFile, mediumData)
|
||||
verifyFileContent(t, largeFile, largeData)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package remote_cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRemoteMetaSyncBasic tests syncing metadata from remote
|
||||
func TestRemoteMetaSyncBasic(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Sync metadata from remote
|
||||
t.Log("Syncing metadata from remote...")
|
||||
cmd := fmt.Sprintf("remote.meta.sync -dir=/buckets/%s", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.meta.sync failed")
|
||||
t.Logf("Meta sync output: %s", output)
|
||||
|
||||
// Should complete without errors
|
||||
assert.NotContains(t, strings.ToLower(output), "failed", "sync should not fail")
|
||||
}
|
||||
|
||||
// TestRemoteMetaSyncNewFiles tests detecting new files on remote
|
||||
func TestRemoteMetaSyncNewFiles(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("metasync-new-%d.txt", time.Now().UnixNano())
|
||||
testData := createTestFile(t, testKey, 1024)
|
||||
|
||||
// Copy to remote
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s", testBucket, testKey)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to copy file to remote")
|
||||
|
||||
// Uncache to remove local chunks
|
||||
uncacheLocal(t, testKey)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Sync metadata - should detect the file
|
||||
t.Log("Syncing metadata to detect new file...")
|
||||
cmd = fmt.Sprintf("remote.meta.sync -dir=/buckets/%s", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.meta.sync failed")
|
||||
t.Logf("Meta sync output: %s", output)
|
||||
|
||||
// File should be readable
|
||||
verifyFileContent(t, testKey, testData)
|
||||
}
|
||||
|
||||
// TestRemoteMetaSyncSubdirectory tests syncing specific subdirectory
|
||||
func TestRemoteMetaSyncSubdirectory(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Sync just the mounted directory
|
||||
t.Log("Syncing subdirectory metadata...")
|
||||
cmd := fmt.Sprintf("remote.meta.sync -dir=/buckets/%s", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.meta.sync subdirectory failed")
|
||||
t.Logf("Subdirectory sync output: %s", output)
|
||||
}
|
||||
|
||||
// TestRemoteMetaSyncNotMounted tests error when directory not mounted
|
||||
func TestRemoteMetaSyncNotMounted(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Try to sync a non-mounted directory
|
||||
notMountedDir := fmt.Sprintf("/notmounted-%d", time.Now().UnixNano())
|
||||
|
||||
t.Log("Testing sync on non-mounted directory...")
|
||||
cmd := fmt.Sprintf("remote.meta.sync -dir=%s", notMountedDir)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
|
||||
// Should fail or show error
|
||||
hasError := err != nil || strings.Contains(strings.ToLower(output), "not mounted") || strings.Contains(strings.ToLower(output), "error")
|
||||
assert.True(t, hasError, "Expected error for non-mounted directory, got: %s", output)
|
||||
t.Logf("Non-mounted directory result: err=%v, output: %s", err, output)
|
||||
}
|
||||
|
||||
// TestRemoteMetaSyncRepeated tests running sync multiple times
|
||||
func TestRemoteMetaSyncRepeated(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Run sync multiple times - should be idempotent
|
||||
for i := 0; i < 3; i++ {
|
||||
t.Logf("Running sync iteration %d...", i+1)
|
||||
cmd := fmt.Sprintf("remote.meta.sync -dir=/buckets/%s", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "remote.meta.sync iteration %d failed", i+1)
|
||||
t.Logf("Iteration %d output: %s", i+1, output)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteMetaSyncAfterRemoteChange tests detecting changes on remote
|
||||
func TestRemoteMetaSyncAfterRemoteChange(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("metasync-change-%d.txt", time.Now().UnixNano())
|
||||
|
||||
// Create and sync file
|
||||
originalData := createTestFile(t, testKey, 1024)
|
||||
cmd := fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s", testBucket, testKey)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to copy file to remote")
|
||||
|
||||
// First sync
|
||||
cmd = fmt.Sprintf("remote.meta.sync -dir=/buckets/%s", testBucket)
|
||||
_, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "first sync failed")
|
||||
|
||||
// Simulate remote change by updating the file and copying again
|
||||
newData := []byte("Updated content after remote change")
|
||||
uploadToPrimary(t, testKey, newData)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
cmd = fmt.Sprintf("remote.copy.local -dir=/buckets/%s -include=%s -forceUpdate=true", testBucket, testKey)
|
||||
_, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to update remote file")
|
||||
|
||||
// Sync again - should detect the change
|
||||
t.Log("Syncing after remote change...")
|
||||
cmd = fmt.Sprintf("remote.meta.sync -dir=/buckets/%s", testBucket)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "sync after change failed")
|
||||
t.Logf("Sync after change output: %s", output)
|
||||
|
||||
// Restore original for cleanup
|
||||
uploadToPrimary(t, testKey, originalData)
|
||||
}
|
||||
|
||||
// TestRemoteMetaSyncEmptyRemote tests syncing when remote is empty
|
||||
func TestRemoteMetaSyncEmptyRemote(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Create a new mount point for testing
|
||||
testDir := fmt.Sprintf("/buckets/testempty%d", time.Now().UnixNano()%1000000)
|
||||
|
||||
// Mount the remote bucket to new directory
|
||||
cmd := fmt.Sprintf("remote.mount -dir=%s -remote=seaweedremote/remotesourcebucket -nonempty=true", testDir)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
if err != nil {
|
||||
t.Skip("Could not create test mount for empty remote test")
|
||||
}
|
||||
|
||||
// Sync metadata
|
||||
t.Log("Syncing metadata from potentially empty remote...")
|
||||
cmd = fmt.Sprintf("remote.meta.sync -dir=%s", testDir)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "sync on empty remote failed")
|
||||
t.Logf("Empty remote sync output: %s", output)
|
||||
|
||||
// Clean up
|
||||
cmd = fmt.Sprintf("remote.unmount -dir=%s", testDir)
|
||||
_, err = runWeedShellWithOutput(t, cmd)
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to unmount test directory: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package remote_cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRemoteMountBasic tests mounting a remote bucket to a local directory
|
||||
func TestRemoteMountBasic(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testDir := fmt.Sprintf("/buckets/testmount%d", time.Now().UnixNano()%1000000)
|
||||
|
||||
// Mount the remote bucket
|
||||
t.Logf("Mounting remote bucket to %s...", testDir)
|
||||
cmd := fmt.Sprintf("remote.mount -dir=%s -remote=seaweedremote/remotesourcebucket", testDir)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to mount remote")
|
||||
t.Logf("Mount output: %s", output)
|
||||
|
||||
// Verify mount exists in list
|
||||
output, err = runWeedShellWithOutput(t, "remote.mount")
|
||||
require.NoError(t, err, "failed to list mounts")
|
||||
assert.Contains(t, output, testDir, "mount not found in list")
|
||||
|
||||
// Clean up - unmount
|
||||
t.Logf("Unmounting %s...", testDir)
|
||||
cmd = fmt.Sprintf("remote.unmount -dir=%s", testDir)
|
||||
_, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to unmount")
|
||||
}
|
||||
|
||||
// TestRemoteMountNonEmpty tests mounting with -nonempty flag
|
||||
func TestRemoteMountNonEmpty(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testDir := fmt.Sprintf("/buckets/testnonempty%d", time.Now().UnixNano()%1000000)
|
||||
testFile := fmt.Sprintf("testfile-%d.txt", time.Now().UnixNano())
|
||||
|
||||
// First mount to create the directory
|
||||
cmd := fmt.Sprintf("remote.mount -dir=%s -remote=seaweedremote/remotesourcebucket", testDir)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to initial mount")
|
||||
|
||||
// Upload a file to make it non-empty
|
||||
uploadToPrimary(t, testFile, []byte("test data"))
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Unmount
|
||||
cmd = fmt.Sprintf("remote.unmount -dir=%s", testDir)
|
||||
_, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to unmount")
|
||||
|
||||
// Try to mount again with -nonempty flag (directory may have residual data)
|
||||
t.Logf("Mounting with -nonempty flag...")
|
||||
cmd = fmt.Sprintf("remote.mount -dir=%s -remote=seaweedremote/remotesourcebucket -nonempty=true", testDir)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to mount with -nonempty")
|
||||
t.Logf("Mount output: %s", output)
|
||||
|
||||
// Clean up
|
||||
cmd = fmt.Sprintf("remote.unmount -dir=%s", testDir)
|
||||
_, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to unmount")
|
||||
}
|
||||
|
||||
// TestRemoteMountInvalidRemote tests mounting with non-existent remote configuration
|
||||
func TestRemoteMountInvalidRemote(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testDir := fmt.Sprintf("/buckets/testinvalid%d", time.Now().UnixNano()%1000000)
|
||||
invalidRemote := fmt.Sprintf("nonexistent%d/bucket", time.Now().UnixNano())
|
||||
|
||||
// Try to mount with invalid remote
|
||||
cmd := fmt.Sprintf("remote.mount -dir=%s -remote=%s", testDir, invalidRemote)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
|
||||
// Should fail with invalid remote
|
||||
hasError := err != nil || strings.Contains(strings.ToLower(output), "invalid") || strings.Contains(strings.ToLower(output), "error") || strings.Contains(strings.ToLower(output), "not found")
|
||||
assert.True(t, hasError, "Expected error for invalid remote, got: %s", output)
|
||||
t.Logf("Invalid remote result: err=%v, output: %s", err, output)
|
||||
}
|
||||
|
||||
// TestRemoteMountList tests listing all mounts
|
||||
func TestRemoteMountList(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// List all mounts
|
||||
output, err := runWeedShellWithOutput(t, "remote.mount")
|
||||
require.NoError(t, err, "failed to list mounts")
|
||||
t.Logf("Mount list: %s", output)
|
||||
|
||||
// Should contain the default mount from setup
|
||||
assert.Contains(t, output, "remotemounted", "default mount not found")
|
||||
}
|
||||
|
||||
// TestRemoteUnmountBasic tests unmounting and verifying cleanup
|
||||
func TestRemoteUnmountBasic(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testDir := fmt.Sprintf("/buckets/testunmount%d", time.Now().UnixNano()%1000000)
|
||||
|
||||
// Mount first
|
||||
cmd := fmt.Sprintf("remote.mount -dir=%s -remote=seaweedremote/remotesourcebucket", testDir)
|
||||
_, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to mount")
|
||||
|
||||
// Verify it's mounted
|
||||
output, err := runWeedShellWithOutput(t, "remote.mount")
|
||||
require.NoError(t, err, "failed to list mounts")
|
||||
assert.Contains(t, output, testDir, "mount not found before unmount")
|
||||
|
||||
// Unmount
|
||||
t.Logf("Unmounting %s...", testDir)
|
||||
cmd = fmt.Sprintf("remote.unmount -dir=%s", testDir)
|
||||
output, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to unmount")
|
||||
t.Logf("Unmount output: %s", output)
|
||||
|
||||
// Verify it's no longer mounted
|
||||
output, err = runWeedShellWithOutput(t, "remote.mount")
|
||||
require.NoError(t, err, "failed to list mounts after unmount")
|
||||
assert.NotContains(t, output, testDir, "mount still exists after unmount")
|
||||
}
|
||||
|
||||
// TestRemoteUnmountNotMounted tests unmounting a non-mounted directory
|
||||
func TestRemoteUnmountNotMounted(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
testDir := fmt.Sprintf("/buckets/notmounted%d", time.Now().UnixNano()%1000000)
|
||||
|
||||
// Try to unmount a directory that's not mounted
|
||||
cmd := fmt.Sprintf("remote.unmount -dir=%s", testDir)
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
|
||||
// Should fail or show error
|
||||
hasError := err != nil || strings.Contains(strings.ToLower(output), "not mounted") || strings.Contains(strings.ToLower(output), "error")
|
||||
assert.True(t, hasError, "Expected error for unmounting non-mounted directory, got: %s", output)
|
||||
t.Logf("Unmount non-mounted result: err=%v, output: %s", err, output)
|
||||
}
|
||||
|
||||
// TestRemoteMountBucketsBasic tests mounting all buckets from remote
|
||||
func TestRemoteMountBucketsBasic(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// List buckets in dry-run mode (without -apply)
|
||||
t.Log("Listing buckets without -apply flag...")
|
||||
cmd := "remote.mount.buckets -remote=seaweedremote"
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to list buckets")
|
||||
t.Logf("Bucket list output: %s", output)
|
||||
|
||||
// Should show the remote bucket
|
||||
assert.Contains(t, output, "remotesourcebucket", "remote bucket not found in list")
|
||||
}
|
||||
|
||||
// TestRemoteMountBucketsWithPattern tests mounting with bucket pattern filter
|
||||
func TestRemoteMountBucketsWithPattern(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Test with pattern matching
|
||||
t.Log("Testing bucket pattern matching...")
|
||||
cmd := "remote.mount.buckets -remote=seaweedremote -bucketPattern=remote*"
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to list buckets with pattern")
|
||||
t.Logf("Pattern match output: %s", output)
|
||||
|
||||
// Should show matching buckets
|
||||
assert.Contains(t, output, "remotesourcebucket", "matching bucket not found")
|
||||
|
||||
// Test with non-matching pattern
|
||||
cmd = "remote.mount.buckets -remote=seaweedremote -bucketPattern=nonexistent*"
|
||||
output, err = runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to list buckets with non-matching pattern")
|
||||
t.Logf("Non-matching pattern output: %s", output)
|
||||
}
|
||||
|
||||
// TestRemoteMountBucketsDryRun tests dry run mode (no -apply flag)
|
||||
func TestRemoteMountBucketsDryRun(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
|
||||
// Get initial mount list
|
||||
initialOutput, err := runWeedShellWithOutput(t, "remote.mount")
|
||||
require.NoError(t, err, "failed to get initial mount list")
|
||||
|
||||
// Run mount.buckets without -apply (dry run)
|
||||
t.Log("Running mount.buckets in dry-run mode...")
|
||||
cmd := "remote.mount.buckets -remote=seaweedremote"
|
||||
output, err := runWeedShellWithOutput(t, cmd)
|
||||
require.NoError(t, err, "failed to run dry-run mount.buckets")
|
||||
t.Logf("Dry-run output: %s", output)
|
||||
|
||||
// Get mount list after dry run
|
||||
afterOutput, err := runWeedShellWithOutput(t, "remote.mount")
|
||||
require.NoError(t, err, "failed to get mount list after dry-run")
|
||||
|
||||
// Mount list should be unchanged (dry run doesn't actually mount)
|
||||
assert.Equal(t, initialOutput, afterOutput, "mount list changed after dry-run")
|
||||
}
|
||||
@@ -70,33 +70,19 @@ func createS3Client(endpoint string) *s3.S3 {
|
||||
return s3.New(sess)
|
||||
}
|
||||
|
||||
// checkServersRunning ensures the servers are running and fails if they aren't
|
||||
func checkServersRunning(t *testing.T) {
|
||||
// skipIfNotRunning skips the test if the servers aren't running
|
||||
func skipIfNotRunning(t *testing.T) {
|
||||
resp, err := http.Get(primaryEndpoint)
|
||||
require.NoErrorf(t, err, "Primary SeaweedFS not running at %s", primaryEndpoint)
|
||||
if err != nil {
|
||||
t.Skipf("Primary SeaweedFS not running at %s: %v", primaryEndpoint, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = http.Get(remoteEndpoint)
|
||||
require.NoErrorf(t, err, "Remote SeaweedFS not running at %s", remoteEndpoint)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// stripLogs removes SeaweedFS log lines from the output
|
||||
func stripLogs(output string) string {
|
||||
lines := strings.Split(output, "\n")
|
||||
var filtered []string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if len(trimmed) > 0 && (trimmed[0] == 'I' || trimmed[0] == 'W' || trimmed[0] == 'E' || trimmed[0] == 'F') && len(trimmed) > 5 && isDigit(trimmed[1]) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, line)
|
||||
if err != nil {
|
||||
t.Skipf("Remote SeaweedFS not running at %s: %v", remoteEndpoint, err)
|
||||
}
|
||||
return strings.Join(filtered, "\n")
|
||||
}
|
||||
|
||||
func isDigit(b byte) bool {
|
||||
return b >= '0' && b <= '9'
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// runWeedShell executes a weed shell command
|
||||
@@ -104,62 +90,11 @@ func runWeedShell(t *testing.T, command string) (string, error) {
|
||||
cmd := exec.Command(weedBinary, "shell", "-master=localhost:"+primaryMasterPort)
|
||||
cmd.Stdin = strings.NewReader(command + "\nexit\n")
|
||||
output, err := cmd.CombinedOutput()
|
||||
result := stripLogs(string(output))
|
||||
if err != nil {
|
||||
t.Logf("weed shell command '%s' failed: %v, output: %s", command, err, result)
|
||||
return result, err
|
||||
t.Logf("weed shell command '%s' failed: %v, output: %s", command, err, string(output))
|
||||
return string(output), err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// runWeedShellWithOutput executes a weed shell command and returns output even on error
|
||||
func runWeedShellWithOutput(t *testing.T, command string) (output string, err error) {
|
||||
cmd := exec.Command(weedBinary, "shell", "-master=localhost:"+primaryMasterPort)
|
||||
cmd.Stdin = strings.NewReader(command + "\nexit\n")
|
||||
outputBytes, err := cmd.CombinedOutput()
|
||||
output = stripLogs(string(outputBytes))
|
||||
if err != nil {
|
||||
t.Logf("weed shell command '%s' output: %s", command, output)
|
||||
}
|
||||
return output, err
|
||||
}
|
||||
|
||||
// createTestFile creates a test file with specific content via S3
|
||||
func createTestFile(t *testing.T, key string, size int) []byte {
|
||||
data := make([]byte, size)
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
uploadToPrimary(t, key, data)
|
||||
return data
|
||||
}
|
||||
|
||||
// verifyFileContent verifies file content matches expected data
|
||||
func verifyFileContent(t *testing.T, key string, expected []byte) {
|
||||
actual := getFromPrimary(t, key)
|
||||
assert.Equal(t, expected, actual, "file content mismatch for %s", key)
|
||||
}
|
||||
|
||||
// waitForCondition waits for a condition to be true with timeout
|
||||
func waitForCondition(t *testing.T, condition func() bool, timeout time.Duration, message string) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if condition() {
|
||||
return true
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Logf("Timeout waiting for: %s", message)
|
||||
return false
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists via S3
|
||||
func fileExists(t *testing.T, key string) bool {
|
||||
_, err := getPrimaryClient().HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(testBucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return err == nil
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// uploadToPrimary uploads an object to the primary SeaweedFS (local write)
|
||||
@@ -202,7 +137,7 @@ func uncacheLocal(t *testing.T, pattern string) {
|
||||
// 2. Uncache (push to remote, remove local chunks)
|
||||
// 3. Read (triggers caching from remote)
|
||||
func TestRemoteCacheBasic(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
skipIfNotRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("test-basic-%d.txt", time.Now().UnixNano())
|
||||
testData := []byte("Hello, this is test data for remote caching!")
|
||||
@@ -243,7 +178,7 @@ func TestRemoteCacheBasic(t *testing.T) {
|
||||
// TestRemoteCacheConcurrent tests that concurrent reads of the same
|
||||
// remote object only trigger ONE caching operation (singleflight deduplication)
|
||||
func TestRemoteCacheConcurrent(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
skipIfNotRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("test-concurrent-%d.txt", time.Now().UnixNano())
|
||||
// Use larger data to make caching take measurable time
|
||||
@@ -322,7 +257,7 @@ func TestRemoteCacheConcurrent(t *testing.T) {
|
||||
|
||||
// TestRemoteCacheLargeObject tests caching of larger objects
|
||||
func TestRemoteCacheLargeObject(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
skipIfNotRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("test-large-%d.bin", time.Now().UnixNano())
|
||||
// 5MB object
|
||||
@@ -358,7 +293,7 @@ func TestRemoteCacheLargeObject(t *testing.T) {
|
||||
|
||||
// TestRemoteCacheRangeRequest tests that range requests work after caching
|
||||
func TestRemoteCacheRangeRequest(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
skipIfNotRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("test-range-%d.txt", time.Now().UnixNano())
|
||||
testData := []byte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
@@ -390,7 +325,7 @@ func TestRemoteCacheRangeRequest(t *testing.T) {
|
||||
|
||||
// TestRemoteCacheNotFound tests that non-existent objects return proper errors
|
||||
func TestRemoteCacheNotFound(t *testing.T) {
|
||||
checkServersRunning(t)
|
||||
skipIfNotRunning(t)
|
||||
|
||||
testKey := fmt.Sprintf("non-existent-object-%d", time.Now().UnixNano())
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"identities": [
|
||||
{
|
||||
"name": "testuser",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "some_access_key1",
|
||||
"secretKey": "some_secret_key1"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
"Read",
|
||||
"Write",
|
||||
"List",
|
||||
"Tagging",
|
||||
"Admin"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 5 {
|
||||
log.Fatalf("Usage: %s <endpoint> <accessKey> <secretKey> <bucket>", os.Args[0])
|
||||
}
|
||||
endpoint := os.Args[1]
|
||||
accessKey := os.Args[2]
|
||||
secretKey := os.Args[3]
|
||||
bucket := os.Args[4]
|
||||
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Endpoint: aws.String(endpoint),
|
||||
Region: aws.String("us-east-1"),
|
||||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
svc := s3.New(sess)
|
||||
_, err = svc.CreateBucket(&s3.CreateBucketInput{
|
||||
Bucket: aws.String(bucket),
|
||||
})
|
||||
if err != nil {
|
||||
// BucketAlreadyExists/BucketAlreadyOwnedByYou are acceptable
|
||||
if strings.Contains(err.Error(), "BucketAlreadyOwnedByYou") ||
|
||||
strings.Contains(err.Error(), "BucketAlreadyExists") {
|
||||
fmt.Printf("Bucket %s already exists\n", bucket)
|
||||
} else {
|
||||
log.Fatalf("Failed to create bucket: %v", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Bucket %s created successfully on %s\n", bucket, endpoint)
|
||||
}
|
||||
}
|
||||
@@ -157,18 +157,26 @@ func (metaBackup *FilerMetaBackupOptions) shouldInclude(fullpath string) bool {
|
||||
}
|
||||
|
||||
func (metaBackup *FilerMetaBackupOptions) traverseMetadata() (err error) {
|
||||
return filer_pb.TraverseBfs(context.Background(), metaBackup, util.FullPath(*metaBackup.filerDirectory), func(parentPath util.FullPath, entry *filer_pb.Entry) error {
|
||||
var saveErr error
|
||||
|
||||
traverseErr := filer_pb.TraverseBfs(metaBackup, util.FullPath(*metaBackup.filerDirectory), func(parentPath util.FullPath, entry *filer_pb.Entry) {
|
||||
fullpath := string(parentPath.Child(entry.Name))
|
||||
if !metaBackup.shouldInclude(fullpath) {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
println("+", fullpath)
|
||||
if err := metaBackup.store.InsertEntry(context.Background(), filer.FromPbEntry(string(parentPath), entry)); err != nil {
|
||||
return fmt.Errorf("insert entry error: %w", err)
|
||||
saveErr = fmt.Errorf("insert entry error: %w\n", err)
|
||||
return
|
||||
}
|
||||
return nil
|
||||
|
||||
})
|
||||
|
||||
if traverseErr != nil {
|
||||
return fmt.Errorf("traverse: %w", traverseErr)
|
||||
}
|
||||
return saveErr
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -186,14 +186,6 @@ hosts = [
|
||||
]
|
||||
username = ""
|
||||
password = ""
|
||||
# Set the CA certificate path
|
||||
ssl_ca_path = ""
|
||||
# Set the client certificate path
|
||||
ssl_cert_path = ""
|
||||
# Set the client private key path
|
||||
ssl_key_path = ""
|
||||
# Check host name in the certificate
|
||||
ssl_enable_host_verification = true
|
||||
# This changes the data layout. Only add new directories. Removing/Updating will cause data loss.
|
||||
superLargeDirectories = []
|
||||
# Name of the datacenter local to this filer, used as host selection fallback.
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
"github.com/gocql/gocql"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
"github.com/gocql/gocql"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
"github.com/gocql/gocql"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
@@ -31,23 +29,14 @@ func (store *Cassandra2Store) GetName() string {
|
||||
}
|
||||
|
||||
func (store *Cassandra2Store) Initialize(configuration util.Configuration, prefix string) (err error) {
|
||||
enableHostVerification := true
|
||||
if val := configuration.GetString(prefix + "ssl_enable_host_verification"); val != "" {
|
||||
enableHostVerification = configuration.GetBool(prefix + "ssl_enable_host_verification")
|
||||
}
|
||||
|
||||
return store.initialize(
|
||||
configuration.GetString(prefix+"keyspace"),
|
||||
configuration.GetStringSlice(prefix+"hosts"),
|
||||
configuration.GetString(prefix+"username"),
|
||||
configuration.GetString(prefix+"password"),
|
||||
configuration.GetString(prefix+"ssl_ca_path"),
|
||||
configuration.GetString(prefix+"ssl_cert_path"),
|
||||
configuration.GetString(prefix+"ssl_key_path"),
|
||||
configuration.GetStringSlice(prefix+"superLargeDirectories"),
|
||||
configuration.GetString(prefix+"localDC"),
|
||||
configuration.GetInt(prefix+"connection_timeout_millisecond"),
|
||||
enableHostVerification,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,50 +45,11 @@ func (store *Cassandra2Store) isSuperLargeDirectory(dir string) (dirHash string,
|
||||
return
|
||||
}
|
||||
|
||||
func (store *Cassandra2Store) initialize(keyspace string, hosts []string, username string, password string, sslCaPath string, sslCertPath string, sslKeyPath string, superLargeDirectories []string, localDC string, timeout int, enableHostVerification bool) (err error) {
|
||||
func (store *Cassandra2Store) initialize(keyspace string, hosts []string, username string, password string, superLargeDirectories []string, localDC string, timeout int) (err error) {
|
||||
store.cluster = gocql.NewCluster(hosts...)
|
||||
if username != "" && password != "" {
|
||||
store.cluster.Authenticator = gocql.PasswordAuthenticator{Username: username, Password: password}
|
||||
}
|
||||
if sslCaPath != "" || sslCertPath != "" || sslKeyPath != "" {
|
||||
if (sslCertPath != "" && sslKeyPath == "") || (sslCertPath == "" && sslKeyPath != "") {
|
||||
return fmt.Errorf("both ssl_cert_path and ssl_key_path must be provided for mTLS, or neither")
|
||||
}
|
||||
|
||||
for _, path := range []string{sslCaPath, sslCertPath, sslKeyPath} {
|
||||
if path != "" {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("ssl file %s not found: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store.cluster.SslOpts = &gocql.SslOptions{
|
||||
CaPath: sslCaPath,
|
||||
CertPath: sslCertPath,
|
||||
KeyPath: sslKeyPath,
|
||||
EnableHostVerification: enableHostVerification,
|
||||
}
|
||||
|
||||
// check if port is already specified in hosts
|
||||
hasPort := false
|
||||
for _, host := range hosts {
|
||||
if strings.Contains(host, ":") {
|
||||
hasPort = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPort {
|
||||
// standard cassandra port is 9042, but AWS keyspaces uses 9142
|
||||
store.cluster.Port = 9142
|
||||
}
|
||||
|
||||
if sslCertPath != "" {
|
||||
glog.V(0).Infof("TLS enabled: mTLS with cert %s", sslCertPath)
|
||||
} else {
|
||||
glog.V(0).Infof("TLS enabled: server-verification with ca %s", sslCaPath)
|
||||
}
|
||||
}
|
||||
store.cluster.Keyspace = keyspace
|
||||
store.cluster.Timeout = time.Duration(timeout) * time.Millisecond
|
||||
glog.V(0).Infof("timeout = %d", timeout)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||
"github.com/gocql/gocql"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestPolicyVariableSubstitution(t *testing.T) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -102,7 +102,7 @@ func TestConditionWithNumericComparison(t *testing.T) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
|
||||
@@ -421,7 +421,7 @@ func TestTrustPolicyWildcardPrincipal(t *testing.T) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -440,7 +440,7 @@ func TestTrustPolicyWildcardPrincipal(t *testing.T) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": []interface{}{"specific-provider", "test-oidc"},
|
||||
"Federated": []interface{}{"specific-provider", "https://test-issuer.com"},
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -646,7 +646,7 @@ func setupTestPoliciesAndRoles(t *testing.T, manager *IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
|
||||
@@ -13,11 +13,6 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
)
|
||||
|
||||
// maxPoliciesForEvaluation defines an upper bound on the number of policies that
|
||||
// will be evaluated for a single request. This protects against pathological or
|
||||
// malicious inputs that attempt to create extremely large policy lists.
|
||||
const maxPoliciesForEvaluation = 1024
|
||||
|
||||
// IAMManager orchestrates all IAM components
|
||||
type IAMManager struct {
|
||||
stsService *sts.STSService
|
||||
@@ -235,27 +230,6 @@ func (m *IAMManager) CreateRole(ctx context.Context, filerAddress string, roleNa
|
||||
return m.roleStore.StoreRole(ctx, "", roleName, roleDef)
|
||||
}
|
||||
|
||||
// UpdateBucketPolicy updates the policy for a bucket
|
||||
func (m *IAMManager) UpdateBucketPolicy(ctx context.Context, bucketName string, policyJSON []byte) error {
|
||||
if !m.initialized {
|
||||
return fmt.Errorf("IAM manager not initialized")
|
||||
}
|
||||
|
||||
if bucketName == "" {
|
||||
return fmt.Errorf("bucket name cannot be empty")
|
||||
}
|
||||
|
||||
// Parse the policy document handled by the IAM policy engine
|
||||
var policyDoc policy.PolicyDocument
|
||||
if err := json.Unmarshal(policyJSON, &policyDoc); err != nil {
|
||||
return fmt.Errorf("invalid policy JSON: %w", err)
|
||||
}
|
||||
|
||||
// Store the policy with a special prefix to distinguish from IAM policies
|
||||
policyName := "bucket-policy:" + bucketName
|
||||
return m.policyEngine.AddPolicy(m.getFilerAddress(), policyName, &policyDoc)
|
||||
}
|
||||
|
||||
// AssumeRoleWithWebIdentity assumes a role using web identity (OIDC)
|
||||
func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) (*sts.AssumeRoleResponse, error) {
|
||||
if !m.initialized {
|
||||
@@ -327,58 +301,9 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
RequestContext: request.RequestContext,
|
||||
}
|
||||
|
||||
// Ensure RequestContext exists and populate with principal info
|
||||
if evalCtx.RequestContext == nil {
|
||||
evalCtx.RequestContext = make(map[string]interface{})
|
||||
}
|
||||
// Add principal to context for policy matching
|
||||
// The PolicyEngine checks RequestContext["principal"] or RequestContext["aws:PrincipalArn"]
|
||||
evalCtx.RequestContext["principal"] = request.Principal
|
||||
evalCtx.RequestContext["aws:PrincipalArn"] = request.Principal
|
||||
|
||||
// Parse principal ARN to extract details for context variables (e.g. ${aws:username})
|
||||
arnInfo := utils.ParsePrincipalARN(request.Principal)
|
||||
if arnInfo.RoleName != "" {
|
||||
// For assumed roles, AWS docs say aws:username IS the role name.
|
||||
// However, for user isolation in these tests, we typically map the session name (the user who assumed the role) to aws:username.
|
||||
// arn:aws:sts::account:assumed-role/RoleName/SessionName
|
||||
awsUsername := arnInfo.RoleName
|
||||
if idx := strings.LastIndex(request.Principal, "/"); idx != -1 && idx < len(request.Principal)-1 {
|
||||
awsUsername = request.Principal[idx+1:]
|
||||
}
|
||||
|
||||
evalCtx.RequestContext["aws:username"] = awsUsername
|
||||
evalCtx.RequestContext["aws:userid"] = arnInfo.RoleName
|
||||
}
|
||||
if arnInfo.AccountID != "" {
|
||||
evalCtx.RequestContext["aws:PrincipalAccount"] = arnInfo.AccountID
|
||||
}
|
||||
|
||||
// Determine if there is a bucket policy to evaluate
|
||||
var bucketPolicyName string
|
||||
if strings.HasPrefix(request.Resource, "arn:aws:s3:::") {
|
||||
resourcePath := request.Resource[13:] // remove "arn:aws:s3:::"
|
||||
parts := strings.SplitN(resourcePath, "/", 2)
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
bucketPolicyName = "bucket-policy:" + parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
// If explicit policy names are provided (e.g. from user identity), evaluate them directly
|
||||
if len(request.PolicyNames) > 0 {
|
||||
policies := request.PolicyNames
|
||||
if bucketPolicyName != "" {
|
||||
// Enforce an upper bound on the number of policies to avoid excessive allocations
|
||||
if len(policies) >= maxPoliciesForEvaluation {
|
||||
return false, fmt.Errorf("too many policies for evaluation: %d >= %d", len(policies), maxPoliciesForEvaluation)
|
||||
}
|
||||
// Create a new slice to avoid modifying the request and append the bucket policy
|
||||
copied := make([]string, len(policies))
|
||||
copy(copied, policies)
|
||||
policies = append(copied, bucketPolicyName)
|
||||
}
|
||||
|
||||
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
|
||||
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, request.PolicyNames)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("policy evaluation failed: %w", err)
|
||||
}
|
||||
@@ -398,19 +323,7 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
}
|
||||
|
||||
// Evaluate policies attached to the role
|
||||
policies := roleDef.AttachedPolicies
|
||||
if bucketPolicyName != "" {
|
||||
// Enforce an upper bound on the number of policies to avoid excessive allocations
|
||||
if len(policies) >= maxPoliciesForEvaluation {
|
||||
return false, fmt.Errorf("too many policies for evaluation: %d >= %d", len(policies), maxPoliciesForEvaluation)
|
||||
}
|
||||
// Create a new slice to avoid modifying the role definition and append the bucket policy
|
||||
copied := make([]string, len(policies))
|
||||
copy(copied, policies)
|
||||
policies = append(copied, bucketPolicyName)
|
||||
}
|
||||
|
||||
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
|
||||
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, roleDef.AttachedPolicies)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("policy evaluation failed: %w", err)
|
||||
}
|
||||
@@ -433,7 +346,7 @@ func (m *IAMManager) ValidateTrustPolicy(ctx context.Context, roleArn, provider,
|
||||
if principal, ok := statement.Principal.(map[string]interface{}); ok {
|
||||
if federated, ok := principal["Federated"].(string); ok {
|
||||
// For OIDC, check against issuer URL
|
||||
if provider == "oidc" && federated == "test-oidc" {
|
||||
if provider == "oidc" && federated == "https://test-issuer.com" {
|
||||
return true
|
||||
}
|
||||
// For LDAP, check against test-ldap
|
||||
@@ -478,24 +391,8 @@ func (m *IAMManager) validateTrustPolicyForWebIdentity(ctx context.Context, role
|
||||
|
||||
// The issuer is the federated provider for OIDC
|
||||
if iss, ok := tokenClaims["iss"].(string); ok {
|
||||
// Default to issuer URL
|
||||
requestContext["aws:FederatedProvider"] = iss
|
||||
requestContext["oidc:iss"] = iss
|
||||
|
||||
// Try to resolve provider name from issuer for better policy matching
|
||||
// This allows policies to reference the provider name (e.g. "keycloak") instead of the full issuer URL
|
||||
if m.stsService != nil {
|
||||
for name, provider := range m.stsService.GetProviders() {
|
||||
if oidcProvider, ok := provider.(interface{ GetIssuer() string }); ok {
|
||||
confIssuer := oidcProvider.GetIssuer()
|
||||
|
||||
if confIssuer == iss {
|
||||
requestContext["aws:FederatedProvider"] = name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sub, ok := tokenClaims["sub"].(string); ok {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
)
|
||||
|
||||
// ValidateTrustPolicyForPrincipal validates if a principal is allowed to assume a role
|
||||
func (m *IAMManager) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
||||
if !m.initialized {
|
||||
return fmt.Errorf("IAM manager not initialized")
|
||||
}
|
||||
|
||||
// Extract role name from ARN
|
||||
roleName := utils.ExtractRoleNameFromArn(roleArn)
|
||||
|
||||
// Get role definition
|
||||
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get role %s: %w", roleName, err)
|
||||
}
|
||||
|
||||
if roleDef.TrustPolicy == nil {
|
||||
return fmt.Errorf("role has no trust policy")
|
||||
}
|
||||
|
||||
// Create evaluation context
|
||||
evalCtx := &policy.EvaluationContext{
|
||||
Principal: principalArn,
|
||||
Action: "sts:AssumeRole",
|
||||
Resource: roleArn,
|
||||
}
|
||||
|
||||
// Evaluate the trust policy
|
||||
if !m.evaluateTrustPolicy(roleDef.TrustPolicy, evalCtx) {
|
||||
return fmt.Errorf("trust policy denies access to principal: %s", principalArn)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// LDAPConfig holds configuration for LDAP provider
|
||||
type LDAPConfig struct {
|
||||
// Server is the LDAP server URL (ldap:// or ldaps://)
|
||||
Server string `json:"server"`
|
||||
|
||||
// BindDN is the DN used to bind for searches (optional for anonymous bind)
|
||||
BindDN string `json:"bindDN,omitempty"`
|
||||
|
||||
// BindPassword is the password for the bind DN
|
||||
BindPassword string `json:"bindPassword,omitempty"`
|
||||
|
||||
// BaseDN is the base DN for user searches
|
||||
BaseDN string `json:"baseDN"`
|
||||
|
||||
// UserFilter is the filter to find users (use %s for username placeholder)
|
||||
// Example: "(uid=%s)" or "(cn=%s)" or "(&(objectClass=person)(uid=%s))"
|
||||
UserFilter string `json:"userFilter"`
|
||||
|
||||
// GroupFilter is the filter to find user groups (use %s for user DN placeholder)
|
||||
// Example: "(member=%s)" or "(memberUid=%s)"
|
||||
GroupFilter string `json:"groupFilter,omitempty"`
|
||||
|
||||
// GroupBaseDN is the base DN for group searches (defaults to BaseDN)
|
||||
GroupBaseDN string `json:"groupBaseDN,omitempty"`
|
||||
|
||||
// Attributes to retrieve from LDAP
|
||||
Attributes LDAPAttributes `json:"attributes,omitempty"`
|
||||
|
||||
// UseTLS enables StartTLS
|
||||
UseTLS bool `json:"useTLS,omitempty"`
|
||||
|
||||
// InsecureSkipVerify skips TLS certificate verification
|
||||
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
|
||||
|
||||
// ConnectionTimeout is the connection timeout
|
||||
ConnectionTimeout time.Duration `json:"connectionTimeout,omitempty"`
|
||||
|
||||
// PoolSize is the number of connections in the pool (default: 10)
|
||||
PoolSize int `json:"poolSize,omitempty"`
|
||||
|
||||
// Audience is the expected audience for tokens (optional)
|
||||
Audience string `json:"audience,omitempty"`
|
||||
}
|
||||
|
||||
// LDAPAttributes maps LDAP attribute names
|
||||
type LDAPAttributes struct {
|
||||
Email string `json:"email,omitempty"` // Default: mail
|
||||
DisplayName string `json:"displayName,omitempty"` // Default: cn
|
||||
Groups string `json:"groups,omitempty"` // Default: memberOf
|
||||
UID string `json:"uid,omitempty"` // Default: uid
|
||||
}
|
||||
|
||||
// connectionPool manages a pool of LDAP connections for reuse
|
||||
type connectionPool struct {
|
||||
conns chan *ldap.Conn
|
||||
mu sync.Mutex
|
||||
size int
|
||||
closed uint32 // atomic flag: 1 if closed, 0 if open
|
||||
}
|
||||
|
||||
// LDAPProvider implements the IdentityProvider interface for LDAP
|
||||
type LDAPProvider struct {
|
||||
name string
|
||||
config *LDAPConfig
|
||||
initialized bool
|
||||
mu sync.RWMutex
|
||||
pool *connectionPool
|
||||
}
|
||||
|
||||
// NewLDAPProvider creates a new LDAP provider
|
||||
func NewLDAPProvider(name string) *LDAPProvider {
|
||||
return &LDAPProvider{
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the provider name
|
||||
func (p *LDAPProvider) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
// Initialize initializes the provider with configuration
|
||||
func (p *LDAPProvider) Initialize(config interface{}) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.initialized {
|
||||
return fmt.Errorf("LDAP provider already initialized")
|
||||
}
|
||||
|
||||
cfg := &LDAPConfig{}
|
||||
|
||||
// Check if input is already the correct struct type
|
||||
if c, ok := config.(*LDAPConfig); ok {
|
||||
cfg = c
|
||||
} else {
|
||||
// Parse from map using mapstructure with weak typing and time duration hook
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
),
|
||||
Result: cfg,
|
||||
TagName: "json",
|
||||
WeaklyTypedInput: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config decoder: %w", err)
|
||||
}
|
||||
|
||||
if err := decoder.Decode(config); err != nil {
|
||||
return fmt.Errorf("failed to decode LDAP configuration: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if cfg.Server == "" {
|
||||
return fmt.Errorf("LDAP server URL is required")
|
||||
}
|
||||
if cfg.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP base DN is required")
|
||||
}
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(cn=%s)" // Default filter
|
||||
}
|
||||
|
||||
// Warn if BindDN is configured but BindPassword is empty
|
||||
if cfg.BindDN != "" && cfg.BindPassword == "" {
|
||||
glog.Warningf("LDAP provider '%s' configured with BindDN but no BindPassword", p.name)
|
||||
}
|
||||
|
||||
// Warn if InsecureSkipVerify is enabled
|
||||
if cfg.InsecureSkipVerify {
|
||||
glog.Warningf("LDAP provider '%s' has InsecureSkipVerify enabled. Do not use in production.", p.name)
|
||||
}
|
||||
|
||||
// Set default attributes
|
||||
if cfg.Attributes.Email == "" {
|
||||
cfg.Attributes.Email = "mail"
|
||||
}
|
||||
if cfg.Attributes.DisplayName == "" {
|
||||
cfg.Attributes.DisplayName = "cn"
|
||||
}
|
||||
if cfg.Attributes.Groups == "" {
|
||||
cfg.Attributes.Groups = "memberOf"
|
||||
}
|
||||
if cfg.Attributes.UID == "" {
|
||||
cfg.Attributes.UID = "uid"
|
||||
}
|
||||
if cfg.GroupBaseDN == "" {
|
||||
cfg.GroupBaseDN = cfg.BaseDN
|
||||
}
|
||||
if cfg.ConnectionTimeout == 0 {
|
||||
cfg.ConnectionTimeout = 10 * time.Second
|
||||
}
|
||||
|
||||
p.config = cfg
|
||||
|
||||
// Initialize connection pool (default size: 10 connections)
|
||||
poolSize := 10
|
||||
if cfg.PoolSize > 0 {
|
||||
poolSize = cfg.PoolSize
|
||||
}
|
||||
p.pool = &connectionPool{
|
||||
conns: make(chan *ldap.Conn, poolSize),
|
||||
size: poolSize,
|
||||
}
|
||||
|
||||
p.initialized = true
|
||||
|
||||
glog.V(1).Infof("LDAP provider '%s' initialized: server=%s, baseDN=%s",
|
||||
p.name, cfg.Server, cfg.BaseDN)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getConnection gets a connection from the pool or creates a new one
|
||||
func (p *LDAPProvider) getConnection() (*ldap.Conn, error) {
|
||||
// Try to get a connection from the pool (non-blocking)
|
||||
select {
|
||||
case conn := <-p.pool.conns:
|
||||
// Test if connection is still alive
|
||||
if conn != nil && conn.IsClosing() {
|
||||
conn.Close()
|
||||
// Connection is dead, create a new one
|
||||
return p.createConnection()
|
||||
}
|
||||
return conn, nil
|
||||
default:
|
||||
// Pool is empty, create a new connection
|
||||
return p.createConnection()
|
||||
}
|
||||
}
|
||||
|
||||
// returnConnection returns a connection to the pool
|
||||
func (p *LDAPProvider) returnConnection(conn *ldap.Conn) {
|
||||
if conn == nil || conn.IsClosing() {
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if pool is closed before attempting to send
|
||||
if atomic.LoadUint32(&p.pool.closed) == 1 {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Try to return to pool (non-blocking)
|
||||
select {
|
||||
case p.pool.conns <- conn:
|
||||
// Successfully returned to pool
|
||||
default:
|
||||
// Pool is full, close the connection
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// createConnection establishes a new connection to the LDAP server
|
||||
func (p *LDAPProvider) createConnection() (*ldap.Conn, error) {
|
||||
var conn *ldap.Conn
|
||||
var err error
|
||||
|
||||
// Create dialer with timeout
|
||||
dialer := &net.Dialer{Timeout: p.config.ConnectionTimeout}
|
||||
|
||||
// Parse server URL
|
||||
if strings.HasPrefix(p.config.Server, "ldaps://") {
|
||||
// LDAPS connection
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: p.config.InsecureSkipVerify,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
conn, err = ldap.DialURL(p.config.Server, ldap.DialWithDialer(dialer), ldap.DialWithTLSConfig(tlsConfig))
|
||||
} else {
|
||||
// LDAP connection
|
||||
conn, err = ldap.DialURL(p.config.Server, ldap.DialWithDialer(dialer))
|
||||
if err == nil && p.config.UseTLS {
|
||||
// StartTLS
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: p.config.InsecureSkipVerify,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
if err = conn.StartTLS(tlsConfig); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to start TLS: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to LDAP server: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Close closes all connections in the pool
|
||||
func (p *LDAPProvider) Close() error {
|
||||
if p.pool == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Atomically mark pool as closed to prevent new connections being returned
|
||||
if !atomic.CompareAndSwapUint32(&p.pool.closed, 0, 1) {
|
||||
// Already closed
|
||||
return nil
|
||||
}
|
||||
|
||||
p.pool.mu.Lock()
|
||||
defer p.pool.mu.Unlock()
|
||||
|
||||
// Now safe to close the channel since closed flag prevents new sends
|
||||
close(p.pool.conns)
|
||||
for conn := range p.pool.conns {
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user with username:password credentials
|
||||
func (p *LDAPProvider) Authenticate(ctx context.Context, credentials string) (*providers.ExternalIdentity, error) {
|
||||
p.mu.RLock()
|
||||
if !p.initialized {
|
||||
p.mu.RUnlock()
|
||||
return nil, fmt.Errorf("LDAP provider not initialized")
|
||||
}
|
||||
config := p.config
|
||||
p.mu.RUnlock()
|
||||
|
||||
// Parse credentials (username:password format)
|
||||
parts := strings.SplitN(credentials, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid credentials format (expected username:password)")
|
||||
}
|
||||
username, password := parts[0], parts[1]
|
||||
|
||||
if username == "" || password == "" {
|
||||
return nil, fmt.Errorf("username and password are required")
|
||||
}
|
||||
|
||||
// Get connection from pool
|
||||
conn, err := p.getConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Note: defer returnConnection moved to after rebinding to service account
|
||||
|
||||
// First, bind with service account to search for user
|
||||
if config.BindDN != "" {
|
||||
err = conn.Bind(config.BindDN, config.BindPassword)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("LDAP service bind failed: %v", err)
|
||||
conn.Close() // Close on error, don't return to pool
|
||||
return nil, fmt.Errorf("LDAP service bind failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Search for the user
|
||||
userFilter := fmt.Sprintf(config.UserFilter, ldap.EscapeFilter(username))
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
config.BaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
1, // Size limit
|
||||
int(config.ConnectionTimeout.Seconds()),
|
||||
false,
|
||||
userFilter,
|
||||
[]string{"dn", config.Attributes.Email, config.Attributes.DisplayName, config.Attributes.UID, config.Attributes.Groups},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("LDAP user search failed: %v", err)
|
||||
conn.Close() // Close on error
|
||||
return nil, fmt.Errorf("LDAP user search failed: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Entries) == 0 {
|
||||
conn.Close() // Close on error
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
if len(result.Entries) > 1 {
|
||||
conn.Close() // Close on error
|
||||
return nil, fmt.Errorf("multiple users found")
|
||||
}
|
||||
|
||||
userEntry := result.Entries[0]
|
||||
userDN := userEntry.DN
|
||||
|
||||
// Bind as the user to verify password
|
||||
err = conn.Bind(userDN, password)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("LDAP user bind failed for %s: %v", username, err)
|
||||
conn.Close() // Close on error, don't return to pool
|
||||
return nil, fmt.Errorf("authentication failed: invalid credentials")
|
||||
}
|
||||
|
||||
// Rebind to service account before returning connection to pool
|
||||
// This prevents pool corruption from authenticated user binds
|
||||
if config.BindDN != "" {
|
||||
if err = conn.Bind(config.BindDN, config.BindPassword); err != nil {
|
||||
glog.V(2).Infof("LDAP rebind to service account failed: %v", err)
|
||||
conn.Close() // Close on error, don't return to pool
|
||||
return nil, fmt.Errorf("LDAP service account rebind failed after successful user authentication (check bindDN %q and its credentials): %w", config.BindDN, err)
|
||||
}
|
||||
}
|
||||
// Now safe to defer return to pool with clean service account binding
|
||||
defer p.returnConnection(conn)
|
||||
|
||||
// Build identity from LDAP attributes
|
||||
identity := &providers.ExternalIdentity{
|
||||
UserID: username,
|
||||
Email: userEntry.GetAttributeValue(config.Attributes.Email),
|
||||
DisplayName: userEntry.GetAttributeValue(config.Attributes.DisplayName),
|
||||
Groups: userEntry.GetAttributeValues(config.Attributes.Groups),
|
||||
Provider: p.name,
|
||||
Attributes: map[string]string{
|
||||
"dn": userDN,
|
||||
"uid": userEntry.GetAttributeValue(config.Attributes.UID),
|
||||
},
|
||||
}
|
||||
|
||||
// If no groups from memberOf, try group search
|
||||
if len(identity.Groups) == 0 && config.GroupFilter != "" {
|
||||
groups, err := p.searchUserGroups(conn, userDN, config)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Group search failed for %s: %v", username, err)
|
||||
} else {
|
||||
identity.Groups = groups
|
||||
}
|
||||
}
|
||||
|
||||
glog.V(2).Infof("LDAP authentication successful for user: %s, groups: %v", username, identity.Groups)
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// searchUserGroups searches for groups the user belongs to
|
||||
func (p *LDAPProvider) searchUserGroups(conn *ldap.Conn, userDN string, config *LDAPConfig) ([]string, error) {
|
||||
groupFilter := fmt.Sprintf(config.GroupFilter, ldap.EscapeFilter(userDN))
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
config.GroupBaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
int(config.ConnectionTimeout.Seconds()),
|
||||
false,
|
||||
groupFilter,
|
||||
[]string{"cn", "dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var groups []string
|
||||
for _, entry := range result.Entries {
|
||||
cn := entry.GetAttributeValue("cn")
|
||||
if cn != "" {
|
||||
groups = append(groups, cn)
|
||||
}
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// GetUserInfo retrieves user information by user ID
|
||||
func (p *LDAPProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) {
|
||||
p.mu.RLock()
|
||||
if !p.initialized {
|
||||
p.mu.RUnlock()
|
||||
return nil, fmt.Errorf("LDAP provider not initialized")
|
||||
}
|
||||
config := p.config
|
||||
p.mu.RUnlock()
|
||||
|
||||
// Get connection from pool
|
||||
conn, err := p.getConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Note: defer returnConnection moved to after bind
|
||||
|
||||
// Bind with service account
|
||||
if config.BindDN != "" {
|
||||
err = conn.Bind(config.BindDN, config.BindPassword)
|
||||
if err != nil {
|
||||
conn.Close() // Close on bind failure
|
||||
return nil, fmt.Errorf("LDAP service bind failed: %w", err)
|
||||
}
|
||||
}
|
||||
defer p.returnConnection(conn)
|
||||
|
||||
// Search for the user
|
||||
userFilter := fmt.Sprintf(config.UserFilter, ldap.EscapeFilter(userID))
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
config.BaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
1,
|
||||
int(config.ConnectionTimeout.Seconds()),
|
||||
false,
|
||||
userFilter,
|
||||
[]string{"dn", config.Attributes.Email, config.Attributes.DisplayName, config.Attributes.UID, config.Attributes.Groups},
|
||||
nil,
|
||||
)
|
||||
|
||||
result, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LDAP user search failed: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Entries) == 0 {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
if len(result.Entries) > 1 {
|
||||
return nil, fmt.Errorf("multiple users found")
|
||||
}
|
||||
|
||||
userEntry := result.Entries[0]
|
||||
identity := &providers.ExternalIdentity{
|
||||
UserID: userID,
|
||||
Email: userEntry.GetAttributeValue(config.Attributes.Email),
|
||||
DisplayName: userEntry.GetAttributeValue(config.Attributes.DisplayName),
|
||||
Groups: userEntry.GetAttributeValues(config.Attributes.Groups),
|
||||
Provider: p.name,
|
||||
Attributes: map[string]string{
|
||||
"dn": userEntry.DN,
|
||||
"uid": userEntry.GetAttributeValue(config.Attributes.UID),
|
||||
},
|
||||
}
|
||||
|
||||
// If no groups from memberOf, try group search
|
||||
if len(identity.Groups) == 0 && config.GroupFilter != "" {
|
||||
groups, err := p.searchUserGroups(conn, userEntry.DN, config)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("Group search failed for %s: %v", userID, err)
|
||||
} else {
|
||||
identity.Groups = groups
|
||||
}
|
||||
}
|
||||
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// ValidateToken validates credentials (username:password format) and returns claims
|
||||
func (p *LDAPProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) {
|
||||
identity, err := p.Authenticate(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
config := p.config
|
||||
p.mu.RUnlock()
|
||||
|
||||
// If audience is configured, validate it (consistent with OIDC approach)
|
||||
audience := p.name
|
||||
if config.Audience != "" {
|
||||
audience = config.Audience
|
||||
}
|
||||
|
||||
// Populate standard TokenClaims fields for interface compliance
|
||||
now := time.Now()
|
||||
ttl := 1 * time.Hour // Default TTL for LDAP tokens
|
||||
|
||||
return &providers.TokenClaims{
|
||||
Subject: identity.UserID,
|
||||
Issuer: p.name,
|
||||
Audience: audience,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
Claims: map[string]interface{}{
|
||||
"email": identity.Email,
|
||||
"name": identity.DisplayName,
|
||||
"groups": identity.Groups,
|
||||
"dn": identity.Attributes["dn"],
|
||||
"provider": p.name,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsInitialized returns whether the provider is initialized
|
||||
func (p *LDAPProvider) IsInitialized() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.initialized
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
@@ -23,40 +22,8 @@ const (
|
||||
|
||||
// Package-level regex cache for performance optimization
|
||||
var (
|
||||
regexCache = make(map[string]*regexp.Regexp)
|
||||
regexCacheMu sync.RWMutex
|
||||
policyVariablePattern = regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||
safePolicyVariables = map[string]bool{
|
||||
// AWS standard identity variables
|
||||
"aws:username": true,
|
||||
"aws:userid": true,
|
||||
"aws:PrincipalArn": true,
|
||||
"aws:PrincipalAccount": true,
|
||||
"aws:principaltype": true,
|
||||
"aws:FederatedProvider": true,
|
||||
"aws:PrincipalServiceName": true,
|
||||
// SAML identity variables
|
||||
"saml:username": true,
|
||||
"saml:sub": true,
|
||||
"saml:aud": true,
|
||||
"saml:iss": true,
|
||||
// OIDC/JWT identity variables
|
||||
"oidc:sub": true,
|
||||
"oidc:aud": true,
|
||||
"oidc:iss": true,
|
||||
// JWT identity variables
|
||||
"jwt:preferred_username": true,
|
||||
"jwt:sub": true,
|
||||
"jwt:iss": true,
|
||||
"jwt:aud": true,
|
||||
// AWS request context (not from headers)
|
||||
"aws:SourceIp": true,
|
||||
"aws:SecureTransport": true,
|
||||
"aws:CurrentTime": true,
|
||||
"s3:prefix": true,
|
||||
"s3:delimiter": true,
|
||||
"s3:max-keys": true,
|
||||
}
|
||||
regexCache = make(map[string]*regexp.Regexp)
|
||||
regexCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// PolicyEngine evaluates policies against requests
|
||||
@@ -105,39 +72,21 @@ type Statement struct {
|
||||
NotPrincipal interface{} `json:"NotPrincipal,omitempty"`
|
||||
|
||||
// Action specifies the actions this statement applies to
|
||||
Action StringList `json:"Action"`
|
||||
Action []string `json:"Action"`
|
||||
|
||||
// NotAction specifies actions this statement does NOT apply to
|
||||
NotAction StringList `json:"NotAction,omitempty"`
|
||||
NotAction []string `json:"NotAction,omitempty"`
|
||||
|
||||
// Resource specifies the resources this statement applies to
|
||||
Resource StringList `json:"Resource"`
|
||||
Resource []string `json:"Resource"`
|
||||
|
||||
// NotResource specifies resources this statement does NOT apply to
|
||||
NotResource StringList `json:"NotResource,omitempty"`
|
||||
NotResource []string `json:"NotResource,omitempty"`
|
||||
|
||||
// Condition specifies conditions for when this statement applies
|
||||
Condition map[string]map[string]interface{} `json:"Condition,omitempty"`
|
||||
}
|
||||
|
||||
// StringList handles fields that can be a string or a list of strings
|
||||
type StringList []string
|
||||
|
||||
// UnmarshalJSON implements custom unmarshalling for StringList
|
||||
func (sl *StringList) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
*sl = []string{s}
|
||||
return nil
|
||||
}
|
||||
var sa []string
|
||||
if err := json.Unmarshal(data, &sa); err == nil {
|
||||
*sl = sa
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid string list")
|
||||
}
|
||||
|
||||
// EvaluationContext provides context for policy evaluation
|
||||
type EvaluationContext struct {
|
||||
// Principal making the request (e.g., "user:alice", "role:admin")
|
||||
@@ -490,12 +439,8 @@ func (e *PolicyEngine) statementMatches(statement *Statement, evalCtx *Evaluatio
|
||||
}
|
||||
|
||||
// Check resource match (optional for trust policies)
|
||||
// For STS trust policy evaluations (AssumeRole*), resource matching should be skipped
|
||||
// Trust policies typically don't include Resource, and enforcing resource matching
|
||||
// here may cause valid trust statements to be rejected.
|
||||
if strings.HasPrefix(evalCtx.Action, "sts:") {
|
||||
// Skip resource checks for trust policy evaluation
|
||||
} else if len(statement.Resource) > 0 {
|
||||
// Trust policies don't have Resource fields, so skip if empty
|
||||
if len(statement.Resource) > 0 {
|
||||
if !e.matchesResources(statement.Resource, evalCtx.Resource, evalCtx) {
|
||||
return false
|
||||
}
|
||||
@@ -689,14 +634,12 @@ func (e *PolicyEngine) evaluateConditionBlock(conditionType string, block map[st
|
||||
return e.EvaluateStringCondition(block, evalCtx, false, false)
|
||||
case "StringLike":
|
||||
return e.EvaluateStringCondition(block, evalCtx, true, true)
|
||||
case "StringNotLike":
|
||||
return e.EvaluateStringCondition(block, evalCtx, false, true)
|
||||
case "StringEqualsIgnoreCase":
|
||||
return e.evaluateStringConditionIgnoreCase(block, evalCtx, true, false)
|
||||
case "StringNotEqualsIgnoreCase":
|
||||
return e.evaluateStringConditionIgnoreCase(block, evalCtx, false, false)
|
||||
case "StringNotLikeIgnoreCase":
|
||||
return e.evaluateStringConditionIgnoreCase(block, evalCtx, false, true)
|
||||
case "StringLikeIgnoreCase":
|
||||
return e.evaluateStringConditionIgnoreCase(block, evalCtx, true, true)
|
||||
|
||||
// Numeric conditions
|
||||
case "NumericEquals":
|
||||
@@ -742,7 +685,7 @@ func (e *PolicyEngine) evaluateConditionBlock(conditionType string, block map[st
|
||||
|
||||
// evaluateIPCondition evaluates IP address conditions
|
||||
func (e *PolicyEngine) evaluateIPCondition(block map[string]interface{}, evalCtx *EvaluationContext, shouldMatch bool) bool {
|
||||
sourceIP, exists := evalCtx.RequestContext["aws:SourceIp"]
|
||||
sourceIP, exists := evalCtx.RequestContext["sourceIP"]
|
||||
if !exists {
|
||||
return !shouldMatch // If no IP in context, condition fails for positive match
|
||||
}
|
||||
@@ -1004,28 +947,24 @@ func expandPolicyVariables(pattern string, evalCtx *EvaluationContext) string {
|
||||
return pattern
|
||||
}
|
||||
|
||||
// Use pre-compiled regexp for efficient single-pass substitution
|
||||
result := policyVariablePattern.ReplaceAllStringFunc(pattern, func(match string) string {
|
||||
// Extract variable name from ${variable}
|
||||
variable := match[2 : len(match)-1]
|
||||
expanded := pattern
|
||||
|
||||
// Only substitute if variable is in the safe allowlist
|
||||
if !safePolicyVariables[variable] {
|
||||
return match // Leave unsafe variables as-is
|
||||
// Common AWS policy variables that might be used in SeaweedFS
|
||||
variableMap := map[string]string{
|
||||
"${aws:username}": getContextValue(evalCtx, "aws:username", ""),
|
||||
"${saml:username}": getContextValue(evalCtx, "saml:username", ""),
|
||||
"${oidc:sub}": getContextValue(evalCtx, "oidc:sub", ""),
|
||||
"${aws:userid}": getContextValue(evalCtx, "aws:userid", ""),
|
||||
"${aws:principaltype}": getContextValue(evalCtx, "aws:principaltype", ""),
|
||||
}
|
||||
|
||||
for variable, value := range variableMap {
|
||||
if value != "" {
|
||||
expanded = strings.ReplaceAll(expanded, variable, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Get value from request context
|
||||
if value, exists := evalCtx.RequestContext[variable]; exists {
|
||||
if str, ok := value.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
// Variable not found or not a string, leave as-is
|
||||
return match
|
||||
})
|
||||
|
||||
return result
|
||||
return expanded
|
||||
}
|
||||
|
||||
// getContextValue safely gets a value from the evaluation context
|
||||
|
||||
@@ -184,7 +184,7 @@ func TestPolicyEvaluation(t *testing.T) {
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::public-bucket/file.txt",
|
||||
RequestContext: map[string]interface{}{
|
||||
"aws:SourceIp": "192.168.1.100",
|
||||
"sourceIP": "192.168.1.100",
|
||||
},
|
||||
},
|
||||
policies: []string{"read-policy"},
|
||||
@@ -274,7 +274,7 @@ func TestConditionEvaluation(t *testing.T) {
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::mybucket/file.txt",
|
||||
RequestContext: map[string]interface{}{
|
||||
"aws:SourceIp": "192.168.1.100",
|
||||
"sourceIP": "192.168.1.100",
|
||||
},
|
||||
},
|
||||
want: EffectAllow,
|
||||
@@ -286,7 +286,7 @@ func TestConditionEvaluation(t *testing.T) {
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::mybucket/file.txt",
|
||||
RequestContext: map[string]interface{}{
|
||||
"aws:SourceIp": "8.8.8.8",
|
||||
"sourceIP": "8.8.8.8",
|
||||
},
|
||||
},
|
||||
want: EffectDeny,
|
||||
@@ -298,7 +298,7 @@ func TestConditionEvaluation(t *testing.T) {
|
||||
Action: "s3:PutObject",
|
||||
Resource: "arn:aws:s3:::mybucket/newfile.txt",
|
||||
RequestContext: map[string]interface{}{
|
||||
"aws:SourceIp": "10.1.2.3",
|
||||
"sourceIP": "10.1.2.3",
|
||||
},
|
||||
},
|
||||
want: EffectAllow,
|
||||
|
||||
@@ -127,16 +127,16 @@ func TestCrossInstanceTokenUsage(t *testing.T) {
|
||||
sessionId := TestSessionID
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
tokenFromA, err := instanceA.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
tokenFromA, err := instanceA.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err, "Instance A should generate token")
|
||||
|
||||
// Validate token on Instance B
|
||||
claimsFromB, err := instanceB.GetTokenGenerator().ValidateSessionToken(tokenFromA)
|
||||
claimsFromB, err := instanceB.tokenGenerator.ValidateSessionToken(tokenFromA)
|
||||
require.NoError(t, err, "Instance B should validate token from Instance A")
|
||||
assert.Equal(t, sessionId, claimsFromB.SessionId, "Session ID should match")
|
||||
|
||||
// Validate same token on Instance C
|
||||
claimsFromC, err := instanceC.GetTokenGenerator().ValidateSessionToken(tokenFromA)
|
||||
claimsFromC, err := instanceC.tokenGenerator.ValidateSessionToken(tokenFromA)
|
||||
require.NoError(t, err, "Instance C should validate token from Instance A")
|
||||
assert.Equal(t, sessionId, claimsFromC.SessionId, "Session ID should match")
|
||||
|
||||
@@ -295,15 +295,15 @@ func TestSTSDistributedConfigurationRequirements(t *testing.T) {
|
||||
// Generate token on Instance A
|
||||
sessionId := "test-session"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
tokenFromA, err := instanceA.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
tokenFromA, err := instanceA.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Instance A should validate its own token
|
||||
_, err = instanceA.GetTokenGenerator().ValidateSessionToken(tokenFromA)
|
||||
_, err = instanceA.tokenGenerator.ValidateSessionToken(tokenFromA)
|
||||
assert.NoError(t, err, "Instance A should validate own token")
|
||||
|
||||
// Instance B should REJECT token due to different signing key
|
||||
_, err = instanceB.GetTokenGenerator().ValidateSessionToken(tokenFromA)
|
||||
_, err = instanceB.tokenGenerator.ValidateSessionToken(tokenFromA)
|
||||
assert.Error(t, err, "Instance B should reject token with different signing key")
|
||||
assert.Contains(t, err.Error(), "invalid token", "Should be signature validation error")
|
||||
})
|
||||
@@ -339,11 +339,11 @@ func TestSTSDistributedConfigurationRequirements(t *testing.T) {
|
||||
// Generate token on Instance A
|
||||
sessionId := "test-session"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
tokenFromA, err := instanceA.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
tokenFromA, err := instanceA.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Instance B should REJECT token due to different issuer
|
||||
_, err = instanceB.GetTokenGenerator().ValidateSessionToken(tokenFromA)
|
||||
_, err = instanceB.tokenGenerator.ValidateSessionToken(tokenFromA)
|
||||
assert.Error(t, err, "Instance B should reject token with different issuer")
|
||||
assert.Contains(t, err.Error(), "invalid issuer", "Should be issuer validation error")
|
||||
})
|
||||
@@ -368,12 +368,12 @@ func TestSTSDistributedConfigurationRequirements(t *testing.T) {
|
||||
// Generate token on Instance 0
|
||||
sessionId := "multi-instance-test"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
token, err := instances[0].GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
token, err := instances[0].tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// All other instances should validate the token
|
||||
for i := 1; i < 5; i++ {
|
||||
claims, err := instances[i].GetTokenGenerator().ValidateSessionToken(token)
|
||||
claims, err := instances[i].tokenGenerator.ValidateSessionToken(token)
|
||||
require.NoError(t, err, "Instance %d should validate token", i)
|
||||
assert.Equal(t, sessionId, claims.SessionId, "Instance %d should extract correct session ID", i)
|
||||
}
|
||||
@@ -486,10 +486,10 @@ func TestSTSRealWorldDistributedScenarios(t *testing.T) {
|
||||
assert.True(t, sessionInfo3.ExpiresAt.After(time.Now()), "Session should not be expired")
|
||||
|
||||
// Step 5: Token should be identical when parsed
|
||||
claims2, err := gateway2.GetTokenGenerator().ValidateSessionToken(sessionToken)
|
||||
claims2, err := gateway2.tokenGenerator.ValidateSessionToken(sessionToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
claims3, err := gateway3.GetTokenGenerator().ValidateSessionToken(sessionToken)
|
||||
claims3, err := gateway3.tokenGenerator.ValidateSessionToken(sessionToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, claims2.SessionId, claims3.SessionId, "Session IDs should match")
|
||||
|
||||
@@ -109,9 +109,9 @@ func TestDistributedSTSService(t *testing.T) {
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
// Generate tokens from different instances
|
||||
token1, err1 := instance1.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
token2, err2 := instance2.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
token3, err3 := instance3.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
token1, err1 := instance1.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
token2, err2 := instance2.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
token3, err3 := instance3.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
|
||||
require.NoError(t, err1, "Instance 1 token generation should succeed")
|
||||
require.NoError(t, err2, "Instance 2 token generation should succeed")
|
||||
@@ -130,13 +130,13 @@ func TestDistributedSTSService(t *testing.T) {
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
|
||||
// Generate token on instance 1
|
||||
token, err := instance1.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
token, err := instance1.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate on all instances
|
||||
claims1, err1 := instance1.GetTokenGenerator().ValidateSessionToken(token)
|
||||
claims2, err2 := instance2.GetTokenGenerator().ValidateSessionToken(token)
|
||||
claims3, err3 := instance3.GetTokenGenerator().ValidateSessionToken(token)
|
||||
claims1, err1 := instance1.tokenGenerator.ValidateSessionToken(token)
|
||||
claims2, err2 := instance2.tokenGenerator.ValidateSessionToken(token)
|
||||
claims3, err3 := instance3.tokenGenerator.ValidateSessionToken(token)
|
||||
|
||||
require.NoError(t, err1, "Instance 1 should validate token from instance 1")
|
||||
require.NoError(t, err2, "Instance 2 should validate token from instance 1")
|
||||
@@ -216,15 +216,15 @@ func TestSTSConfigurationValidation(t *testing.T) {
|
||||
// Generate token on instance 1
|
||||
sessionId := "test-session"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
token, err := instance1.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
token, err := instance1.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Instance 1 should validate its own token
|
||||
_, err = instance1.GetTokenGenerator().ValidateSessionToken(token)
|
||||
_, err = instance1.tokenGenerator.ValidateSessionToken(token)
|
||||
assert.NoError(t, err, "Instance 1 should validate its own token")
|
||||
|
||||
// Instance 2 should reject token from instance 1 (different signing key)
|
||||
_, err = instance2.GetTokenGenerator().ValidateSessionToken(token)
|
||||
_, err = instance2.tokenGenerator.ValidateSessionToken(token)
|
||||
assert.Error(t, err, "Instance 2 should reject token with different signing key")
|
||||
})
|
||||
|
||||
@@ -258,12 +258,12 @@ func TestSTSConfigurationValidation(t *testing.T) {
|
||||
// Generate token on instance 1
|
||||
sessionId := "test-session"
|
||||
expiresAt := time.Now().Add(time.Hour)
|
||||
token, err := instance1.GetTokenGenerator().GenerateSessionToken(sessionId, expiresAt)
|
||||
token, err := instance1.tokenGenerator.GenerateSessionToken(sessionId, expiresAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Instance 2 should reject token due to issuer mismatch
|
||||
// (Even though signing key is the same, issuer validation will fail)
|
||||
_, err = instance2.GetTokenGenerator().ValidateSessionToken(token)
|
||||
_, err = instance2.tokenGenerator.ValidateSessionToken(token)
|
||||
assert.Error(t, err, "Instance 2 should reject token with different issuer")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
@@ -67,11 +66,8 @@ func (f *ProviderFactory) createOIDCProvider(config *ProviderConfig) (providers.
|
||||
|
||||
// createLDAPProvider creates an LDAP provider from configuration
|
||||
func (f *ProviderFactory) createLDAPProvider(config *ProviderConfig) (providers.IdentityProvider, error) {
|
||||
provider := ldap.NewLDAPProvider(config.Name)
|
||||
if err := provider.Initialize(config.Config); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LDAP provider: %w", err)
|
||||
}
|
||||
return provider, nil
|
||||
// TODO: Implement LDAP provider when available
|
||||
return nil, fmt.Errorf("LDAP provider not implemented yet")
|
||||
}
|
||||
|
||||
// createSAMLProvider creates a SAML provider from configuration
|
||||
@@ -321,12 +317,7 @@ func (f *ProviderFactory) validateOIDCConfig(config map[string]interface{}) erro
|
||||
|
||||
// validateLDAPConfig validates LDAP provider configuration
|
||||
func (f *ProviderFactory) validateLDAPConfig(config map[string]interface{}) error {
|
||||
if _, ok := config["server"]; !ok {
|
||||
return fmt.Errorf("LDAP provider requires 'server' field")
|
||||
}
|
||||
if _, ok := config["baseDN"]; !ok {
|
||||
return fmt.Errorf("LDAP provider requires 'baseDN' field")
|
||||
}
|
||||
// TODO: Implement when LDAP provider is available
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -93,9 +93,7 @@ func (c *STSSessionClaims) ToSessionInfo() *SessionInfo {
|
||||
ExternalUserId: c.ExternalUserId,
|
||||
ProviderIssuer: c.ProviderIssuer,
|
||||
RequestContext: c.RequestContext,
|
||||
// Provide the Subject (sub) from registered claims
|
||||
Subject: c.Subject,
|
||||
Credentials: credentials,
|
||||
Credentials: credentials,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,12 +81,6 @@ type STSService struct {
|
||||
trustPolicyValidator TrustPolicyValidator // Interface for trust policy validation
|
||||
}
|
||||
|
||||
// GetTokenGenerator returns the token generator used by the STS service.
|
||||
// This keeps the underlying field unexported while still allowing read-only access.
|
||||
func (s *STSService) GetTokenGenerator() *TokenGenerator {
|
||||
return s.tokenGenerator
|
||||
}
|
||||
|
||||
// STSConfig holds STS service configuration
|
||||
type STSConfig struct {
|
||||
// TokenDuration is the default duration for issued tokens
|
||||
@@ -101,10 +95,6 @@ type STSConfig struct {
|
||||
// SigningKey is used to sign session tokens
|
||||
SigningKey []byte `json:"signingKey"`
|
||||
|
||||
// AccountId is the AWS account ID used for federated user ARNs
|
||||
// Defaults to "111122223333" if not specified
|
||||
AccountId string `json:"accountId,omitempty"`
|
||||
|
||||
// Providers configuration - enables automatic provider loading
|
||||
Providers []*ProviderConfig `json:"providers,omitempty"`
|
||||
}
|
||||
@@ -817,7 +807,7 @@ func (s *STSService) calculateSessionDuration(durationSeconds *int64, tokenExpir
|
||||
|
||||
// extractSessionIdFromToken extracts session ID from JWT session token
|
||||
func (s *STSService) extractSessionIdFromToken(sessionToken string) string {
|
||||
// Validate JWT and extract session claims
|
||||
// Parse JWT and extract session ID from claims
|
||||
claims, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken)
|
||||
if err != nil {
|
||||
// For test compatibility, also handle direct session IDs
|
||||
@@ -872,7 +862,7 @@ func (s *STSService) ExpireSessionForTesting(ctx context.Context, sessionToken s
|
||||
return fmt.Errorf("session token cannot be empty")
|
||||
}
|
||||
|
||||
// Just validate the signature
|
||||
// Validate JWT token format
|
||||
_, err := s.tokenGenerator.ValidateJWTWithClaims(sessionToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid session token format: %w", err)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -90,8 +89,7 @@ func (t *TokenGenerator) ValidateSessionToken(tokenString string) (*SessionToken
|
||||
|
||||
// ValidateJWTWithClaims validates and extracts comprehensive session claims from a JWT token
|
||||
func (t *TokenGenerator) ValidateJWTWithClaims(tokenString string) (*STSSessionClaims, error) {
|
||||
// 1. Parse into MapClaims to capture ALL claims including custom ones
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &STSSessionClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
@@ -106,35 +104,11 @@ func (t *TokenGenerator) ValidateJWTWithClaims(tokenString string) (*STSSessionC
|
||||
return nil, fmt.Errorf(ErrTokenNotValid)
|
||||
}
|
||||
|
||||
mapClaims, ok := token.Claims.(jwt.MapClaims)
|
||||
claims, ok := token.Claims.(*STSSessionClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(ErrInvalidTokenClaims)
|
||||
}
|
||||
|
||||
// 2. Decode into STSSessionClaims using JSON round-trip to respect tags
|
||||
jsonBytes, err := json.Marshal(mapClaims)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal claims: %v", err)
|
||||
}
|
||||
|
||||
claims := &STSSessionClaims{}
|
||||
if err := json.Unmarshal(jsonBytes, claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal claims: %v", err)
|
||||
}
|
||||
|
||||
// 3. Ensure RequestContext contains all claims for policy evaluation
|
||||
// This preserves custom claims (like jwt:preferred_username) that are not in the struct
|
||||
if claims.RequestContext == nil {
|
||||
claims.RequestContext = make(map[string]interface{})
|
||||
}
|
||||
for k, v := range mapClaims {
|
||||
// Add valid claim values to RequestContext
|
||||
// We don't overwrite existing RequestContext keys if they were explicitly set
|
||||
if _, exists := claims.RequestContext[k]; !exists {
|
||||
claims.RequestContext[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Validate issuer
|
||||
if claims.Issuer != t.issuer {
|
||||
return nil, fmt.Errorf(ErrInvalidIssuer)
|
||||
|
||||
@@ -12,93 +12,58 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
func TraverseBfs(ctx context.Context, filerClient FilerClient, parentPath util.FullPath, fn func(parentPath util.FullPath, entry *Entry) error) (err error) {
|
||||
func TraverseBfs(filerClient FilerClient, parentPath util.FullPath, fn func(parentPath util.FullPath, entry *Entry)) (err error) {
|
||||
K := 5
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var jobQueueWg sync.WaitGroup
|
||||
queue := util.NewQueue[util.FullPath]()
|
||||
var pending sync.WaitGroup
|
||||
pending.Add(1)
|
||||
jobQueueWg.Add(1)
|
||||
queue.Enqueue(parentPath)
|
||||
terminates := make([]chan bool, K)
|
||||
|
||||
var once sync.Once
|
||||
var firstErr error
|
||||
|
||||
enqueue := func(p util.FullPath) bool {
|
||||
// Stop expanding traversal once canceled (e.g. first error encountered).
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
pending.Add(1)
|
||||
queue.Enqueue(p)
|
||||
return true
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var workers sync.WaitGroup
|
||||
for i := 0; i < K; i++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
terminates[i] = make(chan bool)
|
||||
go func(j int) {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
case <-terminates[j]:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
dir := queue.Dequeue()
|
||||
if dir == "" {
|
||||
// queue is empty for now
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
t := queue.Dequeue()
|
||||
if t == "" {
|
||||
time.Sleep(329 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Always mark the directory as done so the closer can finish.
|
||||
if ctx.Err() == nil {
|
||||
processErr := processOneDirectory(ctx, filerClient, dir, enqueue, fn)
|
||||
dir := t
|
||||
processErr := processOneDirectory(filerClient, dir, queue, &jobQueueWg, fn)
|
||||
if processErr != nil {
|
||||
once.Do(func() {
|
||||
firstErr = processErr
|
||||
cancel()
|
||||
})
|
||||
err = processErr
|
||||
}
|
||||
jobQueueWg.Done()
|
||||
}
|
||||
pending.Done()
|
||||
}
|
||||
}()
|
||||
}(i)
|
||||
}
|
||||
|
||||
pending.Wait()
|
||||
close(done)
|
||||
|
||||
workers.Wait()
|
||||
|
||||
return firstErr
|
||||
jobQueueWg.Wait()
|
||||
for i := 0; i < K; i++ {
|
||||
close(terminates[i])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func processOneDirectory(ctx context.Context, filerClient FilerClient, parentPath util.FullPath, enqueue func(p util.FullPath) bool, fn func(parentPath util.FullPath, entry *Entry) error) (err error) {
|
||||
func processOneDirectory(filerClient FilerClient, parentPath util.FullPath, queue *util.Queue[util.FullPath], jobQueueWg *sync.WaitGroup, fn func(parentPath util.FullPath, entry *Entry)) (err error) {
|
||||
|
||||
return ReadDirAllEntries(ctx, filerClient, parentPath, "", func(entry *Entry, isLast bool) error {
|
||||
return ReadDirAllEntries(context.Background(), filerClient, parentPath, "", func(entry *Entry, isLast bool) error {
|
||||
|
||||
if err := fn(parentPath, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
fn(parentPath, entry)
|
||||
|
||||
if entry.IsDirectory {
|
||||
subDir := fmt.Sprintf("%s/%s", parentPath, entry.Name)
|
||||
if parentPath == "/" {
|
||||
subDir = "/" + entry.Name
|
||||
}
|
||||
if !enqueue(util.FullPath(subDir)) {
|
||||
return ctx.Err()
|
||||
}
|
||||
jobQueueWg.Add(1)
|
||||
queue.Enqueue(util.FullPath(subDir))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/iterator"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
@@ -56,27 +54,7 @@ func (s gcsRemoteStorageMaker) Make(conf *remote_pb.RemoteConf) (remote_storage.
|
||||
|
||||
googleApplicationCredentials = util.ResolvePath(googleApplicationCredentials)
|
||||
|
||||
var clientOpts []option.ClientOption
|
||||
if googleApplicationCredentials != "" {
|
||||
var data []byte
|
||||
var err error
|
||||
if strings.HasPrefix(googleApplicationCredentials, "{") {
|
||||
data = []byte(googleApplicationCredentials)
|
||||
} else {
|
||||
data, err = os.ReadFile(googleApplicationCredentials)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read credentials file %s: %w", googleApplicationCredentials, err)
|
||||
}
|
||||
}
|
||||
creds, err := google.CredentialsFromJSON(context.Background(), data, storage.ScopeFullControl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse credentials: %w", err)
|
||||
}
|
||||
httpClient := oauth2.NewClient(context.Background(), creds.TokenSource)
|
||||
clientOpts = append(clientOpts, option.WithHTTPClient(httpClient), option.WithoutAuthentication())
|
||||
}
|
||||
|
||||
c, err := storage.NewClient(context.Background(), clientOpts...)
|
||||
c, err := storage.NewClient(context.Background(), option.WithCredentialsFile(googleApplicationCredentials))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
@@ -73,10 +73,9 @@ type Identity struct {
|
||||
Account *Account
|
||||
Credentials []*Credential
|
||||
Actions []Action
|
||||
PolicyNames []string // Attached IAM policy names
|
||||
PrincipalArn string // ARN for IAM authorization (e.g., "arn:aws:iam::account-id:user/username")
|
||||
Disabled bool // User status: false = enabled (default), true = disabled
|
||||
Claims map[string]interface{} // JWT claims for policy substitution
|
||||
PolicyNames []string // Attached IAM policy names
|
||||
PrincipalArn string // ARN for IAM authorization (e.g., "arn:aws:iam::account-id:user/username")
|
||||
Disabled bool // User status: false = enabled (default), true = disabled
|
||||
}
|
||||
|
||||
// Account represents a system user, a system user can
|
||||
@@ -273,32 +272,14 @@ func NewIdentityAccessManagementWithStore(option *S3ApiServerOption, explicitSto
|
||||
iam.emailAccount[AccountAdmin.EmailAddress] = &AccountAdmin
|
||||
iam.emailAccount[AccountAnonymous.EmailAddress] = &AccountAnonymous
|
||||
}
|
||||
|
||||
// Enable auth if we have identities
|
||||
iam.isAuthEnabled = true
|
||||
}
|
||||
|
||||
iam.m.Unlock()
|
||||
}
|
||||
|
||||
// Determine whether to enable S3 authentication based on configuration
|
||||
// For "weed mini" without any S3 config, default to allowing all access (isAuthEnabled = false)
|
||||
// If any credentials are configured (via file, filer, or env vars), enable authentication
|
||||
iam.m.Lock()
|
||||
iam.isAuthEnabled = len(iam.identities) > 0
|
||||
iam.m.Unlock()
|
||||
|
||||
if iam.isAuthEnabled {
|
||||
// Credentials were configured - enable authentication
|
||||
glog.V(1).Infof("S3 authentication enabled (%d identities configured)", len(iam.identities))
|
||||
} else {
|
||||
// No credentials configured
|
||||
if startConfigFile != "" {
|
||||
// Config file was specified but contained no identities - this is unusual, log a warning
|
||||
glog.Warningf("S3 config file %s specified but no identities loaded - authentication disabled", startConfigFile)
|
||||
} else {
|
||||
// No config file and no identities - this is the normal allow-all case
|
||||
glog.V(1).Infof("S3 authentication disabled - no credentials configured (allowing all access)")
|
||||
}
|
||||
}
|
||||
|
||||
return iam
|
||||
}
|
||||
|
||||
@@ -476,14 +457,10 @@ func (iam *IdentityAccessManagement) replaceS3ApiConfiguration(config *iam_pb.S3
|
||||
iam.emailAccount = emailAccount
|
||||
iam.accessKeyIdent = accessKeyIdent
|
||||
iam.nameToIdentity = nameToIdentity
|
||||
// Update authentication state based on whether identities exist
|
||||
// Once enabled, keep it enabled (one-way toggle)
|
||||
authJustEnabled := iam.updateAuthenticationState(len(identities))
|
||||
iam.m.Unlock()
|
||||
|
||||
if authJustEnabled {
|
||||
glog.V(1).Infof("S3 authentication enabled - credentials were added dynamically")
|
||||
if !iam.isAuthEnabled { // one-directional, no toggling
|
||||
iam.isAuthEnabled = len(identities) > 0
|
||||
}
|
||||
iam.m.Unlock()
|
||||
|
||||
// Log configuration summary
|
||||
glog.V(1).Infof("Loaded %d identities, %d accounts, %d access keys. Auth enabled: %v",
|
||||
@@ -696,14 +673,10 @@ func (iam *IdentityAccessManagement) mergeS3ApiConfiguration(config *iam_pb.S3Ap
|
||||
iam.emailAccount = emailAccount
|
||||
iam.accessKeyIdent = accessKeyIdent
|
||||
iam.nameToIdentity = nameToIdentity
|
||||
// Update authentication state based on whether identities exist
|
||||
// Once enabled, keep it enabled (one-way toggle)
|
||||
authJustEnabled := iam.updateAuthenticationState(len(identities))
|
||||
iam.m.Unlock()
|
||||
|
||||
if authJustEnabled {
|
||||
glog.V(1).Infof("S3 authentication enabled because credentials were added dynamically")
|
||||
if !iam.isAuthEnabled {
|
||||
iam.isAuthEnabled = len(identities) > 0
|
||||
}
|
||||
iam.m.Unlock()
|
||||
|
||||
// Log configuration summary
|
||||
staticCount := len(staticNames)
|
||||
@@ -725,28 +698,8 @@ func (iam *IdentityAccessManagement) mergeS3ApiConfiguration(config *iam_pb.S3Ap
|
||||
return nil
|
||||
}
|
||||
|
||||
// isEnabled reports whether S3 auth should be enforced for this server.
|
||||
//
|
||||
// Auth is considered enabled if either:
|
||||
// - we have any locally managed identities/credentials (iam.isAuthEnabled), or
|
||||
// - an external IAM integration has been configured (iam.iamIntegration != nil).
|
||||
//
|
||||
// The iamIntegration check is intentionally included so that when an external
|
||||
// IAM provider is configured (and the server relies solely on it), auth is
|
||||
// still treated as enabled even if there are no local identities yet or
|
||||
// before any sync logic flips isAuthEnabled to true. Removing this check or
|
||||
// relying only on isAuthEnabled would change when auth is enforced and could
|
||||
// unintentionally allow unauthenticated access in integration-only setups.
|
||||
func (iam *IdentityAccessManagement) isEnabled() bool {
|
||||
return iam.isAuthEnabled || iam.iamIntegration != nil
|
||||
}
|
||||
|
||||
func (iam *IdentityAccessManagement) updateAuthenticationState(identitiesCount int) bool {
|
||||
if !iam.isAuthEnabled && identitiesCount > 0 {
|
||||
iam.isAuthEnabled = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return iam.isAuthEnabled
|
||||
}
|
||||
|
||||
func (iam *IdentityAccessManagement) IsStaticConfig() bool {
|
||||
@@ -955,12 +908,6 @@ func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, ac
|
||||
var found bool
|
||||
var amzAuthType string
|
||||
|
||||
// SECURITY: Prevent clients from spoofing internal IAM headers
|
||||
// These headers are only set by the server after successful JWT authentication
|
||||
// Clearing them here prevents privilege escalation via header injection
|
||||
r.Header.Del("X-SeaweedFS-Principal")
|
||||
r.Header.Del("X-SeaweedFS-Session-Token")
|
||||
|
||||
reqAuthType := getRequestAuthType(r)
|
||||
|
||||
switch reqAuthType {
|
||||
@@ -1007,6 +954,7 @@ func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, ac
|
||||
return identity, s3Err, reqAuthType
|
||||
}
|
||||
|
||||
glog.V(4).Infof("user name: %v actions: %v, action: %v", identity.Name, identity.Actions, action)
|
||||
bucket, object := s3_constants.GetBucketAndObject(r)
|
||||
prefix := s3_constants.GetPrefix(r)
|
||||
|
||||
@@ -1034,15 +982,11 @@ func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, ac
|
||||
// - Explicit ALLOW in bucket policy → grant access (bypass IAM checks)
|
||||
// - No policy or indeterminate → fall through to IAM checks
|
||||
if iam.policyEngine != nil && bucket != "" {
|
||||
principal := buildPrincipalARN(identity, r)
|
||||
principal := buildPrincipalARN(identity)
|
||||
// Phase 1: Evaluate bucket policy without object entry.
|
||||
// Tag-based conditions (s3:ExistingObjectTag) are re-checked by handlers
|
||||
// after fetching the entry, which is the Phase 2 check.
|
||||
var claims map[string]interface{}
|
||||
if identity != nil {
|
||||
claims = identity.Claims
|
||||
}
|
||||
allowed, evaluated, err := iam.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, claims, nil)
|
||||
allowed, evaluated, err := iam.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, nil)
|
||||
|
||||
if err != nil {
|
||||
// SECURITY: Fail-close on policy evaluation errors
|
||||
@@ -1204,16 +1148,7 @@ func (identity *Identity) isAdmin() bool {
|
||||
}
|
||||
|
||||
// buildPrincipalARN builds an ARN for an identity to use in bucket policy evaluation
|
||||
// It first checks if a principal ARN was set by JWT authentication in request headers
|
||||
func buildPrincipalARN(identity *Identity, r *http.Request) string {
|
||||
// Check if principal ARN was already set by JWT authentication
|
||||
if r != nil {
|
||||
if principalARN := r.Header.Get("X-SeaweedFS-Principal"); principalARN != "" {
|
||||
glog.V(4).Infof("buildPrincipalARN: Using principal ARN from header: %s", principalARN)
|
||||
return principalARN
|
||||
}
|
||||
}
|
||||
|
||||
func buildPrincipalARN(identity *Identity) string {
|
||||
if identity == nil {
|
||||
return "*" // Anonymous
|
||||
}
|
||||
@@ -1323,7 +1258,6 @@ func (iam *IdentityAccessManagement) authenticateJWTWithIAM(r *http.Request) (*I
|
||||
Account: iamIdentity.Account,
|
||||
Actions: []Action{}, // Empty - authorization handled by policy engine
|
||||
PolicyNames: iamIdentity.PolicyNames,
|
||||
Claims: iamIdentity.Claims,
|
||||
}
|
||||
|
||||
// Store session info in request headers for later authorization
|
||||
|
||||
@@ -66,7 +66,7 @@ func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry,
|
||||
|
||||
// Handle deletion: reset to empty config
|
||||
if newEntry == nil && oldEntry != nil && oldEntry.Name == filer.IamIdentityFile {
|
||||
glog.V(1).Infof("IAM config file deleted, clearing identities")
|
||||
glog.V(0).Infof("IAM config file deleted, clearing identities")
|
||||
if err := s3a.iam.LoadS3ApiConfigurationFromBytes([]byte{}); err != nil {
|
||||
glog.Warningf("failed to clear IAM config on deletion: %v", err)
|
||||
return err
|
||||
@@ -92,7 +92,7 @@ func (s3a *S3ApiServer) onCircuitBreakerConfigChange(dir string, oldEntry *filer
|
||||
|
||||
// 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")
|
||||
glog.V(0).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
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -16,9 +16,8 @@ import (
|
||||
|
||||
// MockIAMIntegration is a mock implementation of IAM integration for testing
|
||||
type MockIAMIntegration struct {
|
||||
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
|
||||
authCalled bool
|
||||
authorizeFunc func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode
|
||||
authCalled bool
|
||||
}
|
||||
|
||||
func (m *MockIAMIntegration) AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
|
||||
@@ -37,13 +36,6 @@ func (m *MockIAMIntegration) ValidateSessionToken(ctx context.Context, token str
|
||||
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
|
||||
}
|
||||
|
||||
// TestVerifyV4SignatureWithSTSIdentity tests that verifyV4Signature properly handles STS identities
|
||||
// by falling back to IAM authorization when shouldCheckPermissions is true
|
||||
func TestVerifyV4SignatureWithSTSIdentity(t *testing.T) {
|
||||
|
||||
@@ -79,7 +79,7 @@ func (r *BucketRegistry) init() error {
|
||||
glog.Errorf("BucketRegistry.init: failed to list buckets: %v", err)
|
||||
return err
|
||||
}
|
||||
glog.V(1).Infof("BucketRegistry.init: warmed config cache for %d buckets", bucketCount)
|
||||
glog.V(0).Infof("BucketRegistry.init: warmed config cache for %d buckets", bucketCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
)
|
||||
|
||||
// ConvertPolicyDocumentToPolicyEngine converts a policy.PolicyDocument to policy_engine.PolicyDocument
|
||||
// This function provides type-safe conversion with explicit field mapping and error handling.
|
||||
// It handles the differences between the two types:
|
||||
// - Converts []string fields to StringOrStringSlice
|
||||
// - Maps Condition types with type validation
|
||||
// - Converts Principal fields with support for AWS principals only
|
||||
// - Handles optional fields (Id, NotPrincipal, NotAction, NotResource are ignored in policy_engine)
|
||||
//
|
||||
// Returns an error if the policy contains unsupported types or malformed data.
|
||||
func ConvertPolicyDocumentToPolicyEngine(src *policy.PolicyDocument) (*policy_engine.PolicyDocument, error) {
|
||||
if src == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Warn if the policy document Id is being dropped
|
||||
if src.Id != "" {
|
||||
glog.Warningf("policy document Id %q is not supported and will be ignored", src.Id)
|
||||
}
|
||||
|
||||
dest := &policy_engine.PolicyDocument{
|
||||
Version: src.Version,
|
||||
Statement: make([]policy_engine.PolicyStatement, len(src.Statement)),
|
||||
}
|
||||
|
||||
for i := range src.Statement {
|
||||
stmt, err := convertStatement(&src.Statement[i])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert statement %d: %w", i, err)
|
||||
}
|
||||
dest.Statement[i] = stmt
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// convertStatement converts a policy.Statement to policy_engine.PolicyStatement
|
||||
func convertStatement(src *policy.Statement) (policy_engine.PolicyStatement, error) {
|
||||
// Check for unsupported fields that would fundamentally change policy semantics
|
||||
// These fields invert the logic and ignoring them could create security holes
|
||||
if len(src.NotAction) > 0 {
|
||||
return policy_engine.PolicyStatement{}, fmt.Errorf("statement %q: NotAction is not supported (would invert action logic, creating potential security risk)", src.Sid)
|
||||
}
|
||||
if len(src.NotResource) > 0 {
|
||||
return policy_engine.PolicyStatement{}, fmt.Errorf("statement %q: NotResource is not supported (would invert resource logic, creating potential security risk)", src.Sid)
|
||||
}
|
||||
if src.NotPrincipal != nil {
|
||||
return policy_engine.PolicyStatement{}, fmt.Errorf("statement %q: NotPrincipal is not supported (would invert principal logic, creating potential security risk)", src.Sid)
|
||||
}
|
||||
|
||||
stmt := policy_engine.PolicyStatement{
|
||||
Sid: src.Sid,
|
||||
Effect: policy_engine.PolicyEffect(src.Effect),
|
||||
}
|
||||
|
||||
// Convert Action ([]string to StringOrStringSlice)
|
||||
if len(src.Action) > 0 {
|
||||
stmt.Action = policy_engine.NewStringOrStringSlice(src.Action...)
|
||||
}
|
||||
|
||||
// Convert Resource ([]string to StringOrStringSlice)
|
||||
if len(src.Resource) > 0 {
|
||||
stmt.Resource = policy_engine.NewStringOrStringSlice(src.Resource...)
|
||||
}
|
||||
|
||||
// Convert Principal (interface{} to *StringOrStringSlice)
|
||||
if src.Principal != nil {
|
||||
principal, err := convertPrincipal(src.Principal)
|
||||
if err != nil {
|
||||
return policy_engine.PolicyStatement{}, fmt.Errorf("statement %q: failed to convert principal: %w", src.Sid, err)
|
||||
}
|
||||
stmt.Principal = principal
|
||||
}
|
||||
|
||||
// Convert Condition (map[string]map[string]interface{} to PolicyConditions)
|
||||
if len(src.Condition) > 0 {
|
||||
condition, err := convertCondition(src.Condition)
|
||||
if err != nil {
|
||||
return policy_engine.PolicyStatement{}, fmt.Errorf("statement %q: failed to convert condition: %w", src.Sid, err)
|
||||
}
|
||||
stmt.Condition = condition
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
// convertPrincipal converts a Principal field to *StringOrStringSlice
|
||||
func convertPrincipal(principal interface{}) (*policy_engine.StringOrStringSlice, error) {
|
||||
if principal == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch p := principal.(type) {
|
||||
case string:
|
||||
if p == "" {
|
||||
return nil, fmt.Errorf("principal string cannot be empty")
|
||||
}
|
||||
result := policy_engine.NewStringOrStringSlice(p)
|
||||
return &result, nil
|
||||
case []string:
|
||||
if len(p) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
for _, s := range p {
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("principal string in slice cannot be empty")
|
||||
}
|
||||
}
|
||||
result := policy_engine.NewStringOrStringSlice(p...)
|
||||
return &result, nil
|
||||
case []interface{}:
|
||||
strs := make([]string, 0, len(p))
|
||||
for _, v := range p {
|
||||
if v != nil {
|
||||
str, err := convertToString(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert principal array item: %w", err)
|
||||
}
|
||||
if str == "" {
|
||||
return nil, fmt.Errorf("principal string in slice cannot be empty")
|
||||
}
|
||||
strs = append(strs, str)
|
||||
}
|
||||
}
|
||||
if len(strs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := policy_engine.NewStringOrStringSlice(strs...)
|
||||
return &result, nil
|
||||
case map[string]interface{}:
|
||||
// Handle AWS-style principal with service/user keys
|
||||
// Example: {"AWS": "arn:aws:iam::123456789012:user/Alice"}
|
||||
// Only AWS principals are supported for now. Other types like Service or Federated need special handling.
|
||||
|
||||
awsPrincipals, ok := p["AWS"]
|
||||
if !ok || len(p) != 1 {
|
||||
glog.Warningf("unsupported principal map, only a single 'AWS' key is supported: %v", p)
|
||||
return nil, fmt.Errorf("unsupported principal map, only a single 'AWS' key is supported, got keys: %v", getMapKeys(p))
|
||||
}
|
||||
|
||||
// Recursively convert the AWS principal value
|
||||
res, err := convertPrincipal(awsPrincipals)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid 'AWS' principal value: %w", err)
|
||||
}
|
||||
return res, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported principal type: %T", p)
|
||||
}
|
||||
}
|
||||
|
||||
// convertCondition converts policy conditions to PolicyConditions
|
||||
func convertCondition(src map[string]map[string]interface{}) (policy_engine.PolicyConditions, error) {
|
||||
if len(src) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
dest := make(policy_engine.PolicyConditions)
|
||||
for condType, condBlock := range src {
|
||||
destBlock := make(map[string]policy_engine.StringOrStringSlice)
|
||||
for key, value := range condBlock {
|
||||
condValue, err := convertConditionValue(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert condition %s[%s]: %w", condType, key, err)
|
||||
}
|
||||
destBlock[key] = condValue
|
||||
}
|
||||
dest[condType] = destBlock
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// convertConditionValue converts a condition value to StringOrStringSlice
|
||||
func convertConditionValue(value interface{}) (policy_engine.StringOrStringSlice, error) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return policy_engine.NewStringOrStringSlice(v), nil
|
||||
case []string:
|
||||
return policy_engine.NewStringOrStringSlice(v...), nil
|
||||
case []interface{}:
|
||||
strs := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if item != nil {
|
||||
str, err := convertToString(item)
|
||||
if err != nil {
|
||||
return policy_engine.StringOrStringSlice{}, fmt.Errorf("failed to convert condition array item: %w", err)
|
||||
}
|
||||
strs = append(strs, str)
|
||||
}
|
||||
}
|
||||
return policy_engine.NewStringOrStringSlice(strs...), nil
|
||||
default:
|
||||
// For non-string types, convert to string
|
||||
// This handles numbers, booleans, etc.
|
||||
str, err := convertToString(v)
|
||||
if err != nil {
|
||||
return policy_engine.StringOrStringSlice{}, err
|
||||
}
|
||||
return policy_engine.NewStringOrStringSlice(str), nil
|
||||
}
|
||||
}
|
||||
|
||||
// convertToString converts any value to string representation
|
||||
// Returns an error for unsupported types to prevent silent data corruption
|
||||
func convertToString(value interface{}) (string, error) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
case bool,
|
||||
int, int8, int16, int32, int64,
|
||||
uint, uint8, uint16, uint32, uint64,
|
||||
float32, float64:
|
||||
// Use fmt.Sprint for supported primitive types
|
||||
return fmt.Sprint(v), nil
|
||||
default:
|
||||
glog.Warningf("unsupported type in policy conversion: %T", v)
|
||||
return "", fmt.Errorf("unsupported type in policy conversion: %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// getMapKeys returns the keys of a map as a slice (helper for error messages)
|
||||
func getMapKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
)
|
||||
|
||||
func TestConvertPolicyDocumentWithMixedTypes(t *testing.T) {
|
||||
// Test that numeric and boolean values in arrays are properly converted
|
||||
src := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "TestMixedTypes",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
Principal: []interface{}{"user1", 123, true}, // Mixed types
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"NumericEquals": {
|
||||
"s3:max-keys": []interface{}{100, 200, "300"}, // Mixed types
|
||||
},
|
||||
"StringEquals": {
|
||||
"s3:prefix": []interface{}{"test", 123, false}, // Mixed types
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Convert
|
||||
dest, err := ConvertPolicyDocumentToPolicyEngine(src)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify document structure
|
||||
if dest == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
if dest.Version != "2012-10-17" {
|
||||
t.Errorf("Expected version '2012-10-17', got '%s'", dest.Version)
|
||||
}
|
||||
if len(dest.Statement) != 1 {
|
||||
t.Fatalf("Expected 1 statement, got %d", len(dest.Statement))
|
||||
}
|
||||
|
||||
stmt := dest.Statement[0]
|
||||
|
||||
// Verify Principal conversion (should have 3 items converted to strings)
|
||||
if stmt.Principal == nil {
|
||||
t.Fatal("Expected non-nil Principal")
|
||||
}
|
||||
principals := stmt.Principal.Strings()
|
||||
if len(principals) != 3 {
|
||||
t.Errorf("Expected 3 principals, got %d", len(principals))
|
||||
}
|
||||
// Check that numeric and boolean were converted
|
||||
expectedPrincipals := []string{"user1", "123", "true"}
|
||||
for i, expected := range expectedPrincipals {
|
||||
if principals[i] != expected {
|
||||
t.Errorf("Principal[%d]: expected '%s', got '%s'", i, expected, principals[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Condition conversion
|
||||
if len(stmt.Condition) != 2 {
|
||||
t.Errorf("Expected 2 condition blocks, got %d", len(stmt.Condition))
|
||||
}
|
||||
|
||||
// Check NumericEquals condition
|
||||
numericCond, ok := stmt.Condition["NumericEquals"]
|
||||
if !ok {
|
||||
t.Fatal("Expected NumericEquals condition")
|
||||
}
|
||||
maxKeys, ok := numericCond["s3:max-keys"]
|
||||
if !ok {
|
||||
t.Fatal("Expected s3:max-keys in NumericEquals")
|
||||
}
|
||||
maxKeysStrs := maxKeys.Strings()
|
||||
expectedMaxKeys := []string{"100", "200", "300"}
|
||||
if len(maxKeysStrs) != len(expectedMaxKeys) {
|
||||
t.Errorf("Expected %d max-keys values, got %d", len(expectedMaxKeys), len(maxKeysStrs))
|
||||
}
|
||||
for i, expected := range expectedMaxKeys {
|
||||
if maxKeysStrs[i] != expected {
|
||||
t.Errorf("max-keys[%d]: expected '%s', got '%s'", i, expected, maxKeysStrs[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Check StringEquals condition
|
||||
stringCond, ok := stmt.Condition["StringEquals"]
|
||||
if !ok {
|
||||
t.Fatal("Expected StringEquals condition")
|
||||
}
|
||||
prefix, ok := stringCond["s3:prefix"]
|
||||
if !ok {
|
||||
t.Fatal("Expected s3:prefix in StringEquals")
|
||||
}
|
||||
prefixStrs := prefix.Strings()
|
||||
expectedPrefix := []string{"test", "123", "false"}
|
||||
if len(prefixStrs) != len(expectedPrefix) {
|
||||
t.Errorf("Expected %d prefix values, got %d", len(expectedPrefix), len(prefixStrs))
|
||||
}
|
||||
for i, expected := range expectedPrefix {
|
||||
if prefixStrs[i] != expected {
|
||||
t.Errorf("prefix[%d]: expected '%s', got '%s'", i, expected, prefixStrs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalWithMapAndMixedTypes(t *testing.T) {
|
||||
// Test AWS-style principal map with mixed types
|
||||
principalMap := map[string]interface{}{
|
||||
"AWS": []interface{}{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
456, // User ID as number
|
||||
true, // Some boolean value
|
||||
},
|
||||
}
|
||||
|
||||
result, err := convertPrincipal(principalMap)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
|
||||
strs := result.Strings()
|
||||
if len(strs) != 3 {
|
||||
t.Errorf("Expected 3 values, got %d", len(strs))
|
||||
}
|
||||
|
||||
expectedValues := []string{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
"456",
|
||||
"true",
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if strs[i] != expected {
|
||||
t.Errorf("Value[%d]: expected '%s', got '%s'", i, expected, strs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertConditionValueWithMixedTypes(t *testing.T) {
|
||||
// Test []interface{} with mixed types
|
||||
mixedValues := []interface{}{
|
||||
"string",
|
||||
123,
|
||||
true,
|
||||
456.78,
|
||||
}
|
||||
|
||||
result, err := convertConditionValue(mixedValues)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
strs := result.Strings()
|
||||
|
||||
expectedValues := []string{"string", "123", "true", "456.78"}
|
||||
if len(strs) != len(expectedValues) {
|
||||
t.Errorf("Expected %d values, got %d", len(expectedValues), len(strs))
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if strs[i] != expected {
|
||||
t.Errorf("Value[%d]: expected '%s', got '%s'", i, expected, strs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPolicyDocumentNil(t *testing.T) {
|
||||
result, err := ConvertPolicyDocumentToPolicyEngine(nil)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for nil input: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for nil input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalNil(t *testing.T) {
|
||||
result, err := convertPrincipal(nil)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for nil input: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for nil input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalEmptyArray(t *testing.T) {
|
||||
// Empty array should return nil
|
||||
result, err := convertPrincipal([]interface{}{})
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for empty array: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for empty array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalUnknownType(t *testing.T) {
|
||||
// Unknown types should return an error
|
||||
result, err := convertPrincipal(12345) // Just a number, not valid principal
|
||||
if err == nil {
|
||||
t.Error("Expected error for unknown type")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalWithNilValues(t *testing.T) {
|
||||
// Test that nil values in arrays are skipped for security
|
||||
principalArray := []interface{}{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
nil, // Should be skipped
|
||||
"arn:aws:iam::123456789012:user/Bob",
|
||||
nil, // Should be skipped
|
||||
}
|
||||
|
||||
result, err := convertPrincipal(principalArray)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
|
||||
strs := result.Strings()
|
||||
// Should only have 2 values (nil values skipped)
|
||||
if len(strs) != 2 {
|
||||
t.Errorf("Expected 2 values (nil values skipped), got %d", len(strs))
|
||||
}
|
||||
|
||||
expectedValues := []string{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
"arn:aws:iam::123456789012:user/Bob",
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if strs[i] != expected {
|
||||
t.Errorf("Value[%d]: expected '%s', got '%s'", i, expected, strs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertConditionValueWithNilValues(t *testing.T) {
|
||||
// Test that nil values in condition arrays are skipped
|
||||
mixedValues := []interface{}{
|
||||
"string",
|
||||
nil, // Should be skipped
|
||||
123,
|
||||
nil, // Should be skipped
|
||||
true,
|
||||
}
|
||||
|
||||
result, err := convertConditionValue(mixedValues)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
strs := result.Strings()
|
||||
|
||||
// Should only have 3 values (nil values skipped)
|
||||
expectedValues := []string{"string", "123", "true"}
|
||||
if len(strs) != len(expectedValues) {
|
||||
t.Errorf("Expected %d values (nil values skipped), got %d", len(expectedValues), len(strs))
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if strs[i] != expected {
|
||||
t.Errorf("Value[%d]: expected '%s', got '%s'", i, expected, strs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalMapWithNilValues(t *testing.T) {
|
||||
// Test AWS-style principal map with nil values
|
||||
principalMap := map[string]interface{}{
|
||||
"AWS": []interface{}{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
nil, // Should be skipped
|
||||
"arn:aws:iam::123456789012:user/Bob",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := convertPrincipal(principalMap)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
|
||||
strs := result.Strings()
|
||||
// Should only have 2 values (nil value skipped)
|
||||
if len(strs) != 2 {
|
||||
t.Errorf("Expected 2 values (nil value skipped), got %d", len(strs))
|
||||
}
|
||||
|
||||
expectedValues := []string{
|
||||
"arn:aws:iam::123456789012:user/Alice",
|
||||
"arn:aws:iam::123456789012:user/Bob",
|
||||
}
|
||||
|
||||
for i, expected := range expectedValues {
|
||||
if strs[i] != expected {
|
||||
t.Errorf("Value[%d]: expected '%s', got '%s'", i, expected, strs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToStringUnsupportedType(t *testing.T) {
|
||||
// Test that unsupported types (e.g., nested maps/slices) return empty string
|
||||
// This should trigger a warning log and return an error
|
||||
|
||||
type customStruct struct {
|
||||
Field string
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "nested map",
|
||||
input: map[string]interface{}{"key": "value"},
|
||||
expected: "", // Unsupported, returns empty string
|
||||
},
|
||||
{
|
||||
name: "nested slice",
|
||||
input: []int{1, 2, 3},
|
||||
expected: "", // Unsupported, returns empty string
|
||||
},
|
||||
{
|
||||
name: "custom struct",
|
||||
input: customStruct{Field: "test"},
|
||||
expected: "", // Unsupported, returns empty string
|
||||
},
|
||||
{
|
||||
name: "function",
|
||||
input: func() {},
|
||||
expected: "", // Unsupported, returns empty string
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := convertToString(tc.input)
|
||||
// For unsupported types, we expect an error
|
||||
if err == nil {
|
||||
t.Error("Expected error for unsupported type")
|
||||
}
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToStringSupportedTypes(t *testing.T) {
|
||||
// Test that all supported types convert correctly
|
||||
testCases := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
expected string
|
||||
}{
|
||||
{"string", "test", "test"},
|
||||
{"bool true", true, "true"},
|
||||
{"bool false", false, "false"},
|
||||
{"int", 123, "123"},
|
||||
{"int8", int8(127), "127"},
|
||||
{"int16", int16(32767), "32767"},
|
||||
{"int32", int32(2147483647), "2147483647"},
|
||||
{"int64", int64(9223372036854775807), "9223372036854775807"},
|
||||
{"uint", uint(123), "123"},
|
||||
{"uint8", uint8(255), "255"},
|
||||
{"uint16", uint16(65535), "65535"},
|
||||
{"uint32", uint32(4294967295), "4294967295"},
|
||||
{"uint64", uint64(18446744073709551615), "18446744073709551615"},
|
||||
{"float32", float32(3.14), "3.14"},
|
||||
{"float64", float64(3.14159265359), "3.14159265359"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := convertToString(tc.input)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for supported type %s: %v", tc.name, err)
|
||||
}
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalUnsupportedTypes(t *testing.T) {
|
||||
// Test that unsupported principal types return errors
|
||||
testCases := []struct {
|
||||
name string
|
||||
principal interface{}
|
||||
}{
|
||||
{
|
||||
name: "Service principal",
|
||||
principal: map[string]interface{}{"Service": "s3.amazonaws.com"},
|
||||
},
|
||||
{
|
||||
name: "Federated principal",
|
||||
principal: map[string]interface{}{"Federated": "arn:aws:iam::123456789012:saml-provider/ExampleProvider"},
|
||||
},
|
||||
{
|
||||
name: "Multiple keys",
|
||||
principal: map[string]interface{}{"AWS": "arn:...", "Service": "s3.amazonaws.com"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := convertPrincipal(tc.principal)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unsupported principal type")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for unsupported principal type")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPrincipalEmptyStrings(t *testing.T) {
|
||||
// Test that empty string principals are rejected for security
|
||||
testCases := []struct {
|
||||
name string
|
||||
principal interface{}
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "Empty string principal",
|
||||
principal: "",
|
||||
wantError: "principal string cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty string in array",
|
||||
principal: []string{"arn:aws:iam::123456789012:user/Alice", "", "arn:aws:iam::123456789012:user/Bob"},
|
||||
wantError: "principal string in slice cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty string in interface array",
|
||||
principal: []interface{}{"arn:aws:iam::123456789012:user/Alice", ""},
|
||||
wantError: "principal string in slice cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty string in AWS map",
|
||||
principal: map[string]interface{}{
|
||||
"AWS": "",
|
||||
},
|
||||
wantError: "principal string cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Empty string in AWS map array",
|
||||
principal: map[string]interface{}{
|
||||
"AWS": []string{"arn:aws:iam::123456789012:user/Alice", ""},
|
||||
},
|
||||
wantError: "principal string in slice cannot be empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := convertPrincipal(tc.principal)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty principal string")
|
||||
} else if !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Errorf("Expected error containing %q, got: %v", tc.wantError, err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("Expected nil result for empty principal string")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertStatementWithUnsupportedFields(t *testing.T) {
|
||||
// Test that errors are returned for unsupported fields
|
||||
// These fields are critical for policy semantics and ignoring them would be a security risk
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
statement *policy.Statement
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "NotAction field",
|
||||
statement: &policy.Statement{
|
||||
Sid: "TestNotAction",
|
||||
Effect: "Deny",
|
||||
Action: []string{"s3:GetObject"},
|
||||
NotAction: []string{"s3:PutObject", "s3:DeleteObject"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
},
|
||||
wantError: "NotAction is not supported",
|
||||
},
|
||||
{
|
||||
name: "NotResource field",
|
||||
statement: &policy.Statement{
|
||||
Sid: "TestNotResource",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:*"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
NotResource: []string{"arn:aws:s3:::bucket/secret/*"},
|
||||
},
|
||||
wantError: "NotResource is not supported",
|
||||
},
|
||||
{
|
||||
name: "NotPrincipal field",
|
||||
statement: &policy.Statement{
|
||||
Sid: "TestNotPrincipal",
|
||||
Effect: "Deny",
|
||||
Action: []string{"s3:*"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
NotPrincipal: map[string]interface{}{"AWS": "arn:aws:iam::123456789012:user/Admin"},
|
||||
},
|
||||
wantError: "NotPrincipal is not supported",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// The conversion should fail with an error for security reasons
|
||||
result, err := convertStatement(tc.statement)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unsupported field, got nil")
|
||||
} else if !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Errorf("Expected error containing %q, got: %v", tc.wantError, err)
|
||||
}
|
||||
|
||||
// Verify zero-value struct is returned on error
|
||||
if result.Sid != "" || result.Effect != "" {
|
||||
t.Error("Expected zero-value struct on error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertStatementSuccess(t *testing.T) {
|
||||
// Test successful conversion without unsupported fields
|
||||
statement := &policy.Statement{
|
||||
Sid: "AllowGetObject",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "arn:aws:iam::123456789012:user/Alice",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := convertStatement(statement)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Sid != statement.Sid {
|
||||
t.Errorf("Expected Sid %q, got %q", statement.Sid, result.Sid)
|
||||
}
|
||||
if string(result.Effect) != statement.Effect {
|
||||
t.Errorf("Expected Effect %q, got %q", statement.Effect, result.Effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPolicyDocumentWithId(t *testing.T) {
|
||||
// Test that policy document Id field triggers a warning
|
||||
src := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Id: "MyPolicyId",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "AllowGetObject",
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::bucket/*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// The conversion should succeed but log a warning about Id
|
||||
dest, err := ConvertPolicyDocumentToPolicyEngine(src)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if dest == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
|
||||
// Verify basic conversion worked
|
||||
if dest.Version != src.Version {
|
||||
t.Errorf("Expected Version %q, got %q", src.Version, dest.Version)
|
||||
}
|
||||
if len(dest.Statement) != 1 {
|
||||
t.Errorf("Expected 1 statement, got %d", len(dest.Statement))
|
||||
}
|
||||
}
|
||||
@@ -735,8 +735,7 @@ func getConditionContextValue(key string, contextValues map[string][]string, obj
|
||||
|
||||
// EvaluateConditions evaluates all conditions in a policy statement
|
||||
// objectEntry is the object's metadata from entry.Extended (can be nil)
|
||||
// claims are JWT claims for jwt:* policy variables (can be nil)
|
||||
func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]string, objectEntry map[string][]byte, claims map[string]interface{}) bool {
|
||||
func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]string, objectEntry map[string][]byte) bool {
|
||||
if len(conditions) == 0 {
|
||||
return true // No conditions means always true
|
||||
}
|
||||
@@ -750,17 +749,7 @@ func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]
|
||||
|
||||
for key, value := range conditionMap {
|
||||
contextVals := getConditionContextValue(key, contextValues, objectEntry)
|
||||
|
||||
// Substitute variables in expected values
|
||||
expectedValues := value.Strings()
|
||||
substitutedValues := make([]string, len(expectedValues))
|
||||
for i, v := range expectedValues {
|
||||
substitutedValues[i] = SubstituteVariables(v, contextValues, claims)
|
||||
}
|
||||
|
||||
// Pass substituted values (casted to interface{} to match signature if needed, or update evaluators to accept []string)
|
||||
// The evaluators take interface{}, but getCachedNormalizedValues handles []string.
|
||||
if !conditionEvaluator.Evaluate(substitutedValues, contextVals) {
|
||||
if !conditionEvaluator.Evaluate(value.Strings(), contextVals) {
|
||||
return false // If any condition fails, the whole condition block fails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (engine *PolicyEngine) SetBucketPolicy(bucketName string, policyJSON string
|
||||
}
|
||||
|
||||
engine.contexts[bucketName] = context
|
||||
glog.V(4).Infof("SetBucketPolicy: Successfully cached policy for bucket=%s, statements=%d", bucketName, len(compiled.Statements))
|
||||
glog.V(2).Infof("Set bucket policy for %s", bucketName)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -106,12 +106,9 @@ func (engine *PolicyEngine) EvaluatePolicy(bucketName string, args *PolicyEvalua
|
||||
engine.mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
glog.V(4).Infof("EvaluatePolicy: No policy found for bucket=%s (PolicyResultIndeterminate)", bucketName)
|
||||
return PolicyResultIndeterminate
|
||||
}
|
||||
|
||||
glog.V(4).Infof("EvaluatePolicy: Found policy for bucket=%s, evaluating with action=%s resource=%s principal=%s",
|
||||
bucketName, args.Action, args.Resource, args.Principal)
|
||||
return engine.evaluateCompiledPolicy(context.policy, args)
|
||||
}
|
||||
|
||||
@@ -125,7 +122,7 @@ func (engine *PolicyEngine) evaluateCompiledPolicy(policy *CompiledPolicy, args
|
||||
hasExplicitAllow := false
|
||||
|
||||
for _, stmt := range policy.Statements {
|
||||
if engine.evaluateStatement(stmt, args) {
|
||||
if engine.evaluateStatement(&stmt, args) {
|
||||
if stmt.Statement.Effect == PolicyEffectDeny {
|
||||
return PolicyResultDeny // Explicit deny trumps everything
|
||||
}
|
||||
@@ -144,74 +141,28 @@ func (engine *PolicyEngine) evaluateCompiledPolicy(policy *CompiledPolicy, args
|
||||
return PolicyResultIndeterminate
|
||||
}
|
||||
|
||||
// matchesDynamicPatterns checks if a value matches any of the dynamic patterns after variable substitution
|
||||
func (engine *PolicyEngine) matchesDynamicPatterns(patterns []string, value string, args *PolicyEvaluationArgs) bool {
|
||||
for _, pattern := range patterns {
|
||||
substituted := SubstituteVariables(pattern, args.Conditions, args.Claims)
|
||||
if FastMatchesWildcard(substituted, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// evaluateStatement evaluates a single policy statement
|
||||
func (engine *PolicyEngine) evaluateStatement(stmt *CompiledStatement, args *PolicyEvaluationArgs) bool {
|
||||
// Check if action matches
|
||||
matchedAction := engine.matchesPatterns(stmt.ActionPatterns, args.Action)
|
||||
if !matchedAction {
|
||||
matchedAction = engine.matchesDynamicPatterns(stmt.DynamicActionPatterns, args.Action, args)
|
||||
}
|
||||
if !matchedAction {
|
||||
if !engine.matchesPatterns(stmt.ActionPatterns, args.Action) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if resource matches
|
||||
hasResource := len(stmt.ResourcePatterns) > 0 || len(stmt.DynamicResourcePatterns) > 0
|
||||
hasNotResource := len(stmt.NotResourcePatterns) > 0 || len(stmt.DynamicNotResourcePatterns) > 0
|
||||
if hasResource {
|
||||
matchedResource := engine.matchesPatterns(stmt.ResourcePatterns, args.Resource)
|
||||
if !matchedResource {
|
||||
matchedResource = engine.matchesDynamicPatterns(stmt.DynamicResourcePatterns, args.Resource, args)
|
||||
}
|
||||
if !matchedResource {
|
||||
return false
|
||||
}
|
||||
if !engine.matchesPatterns(stmt.ResourcePatterns, args.Resource) {
|
||||
return false
|
||||
}
|
||||
|
||||
if hasNotResource {
|
||||
matchedNotResource := false
|
||||
for _, matcher := range stmt.NotResourceMatchers {
|
||||
if matcher.Match(args.Resource) {
|
||||
matchedNotResource = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matchedNotResource {
|
||||
matchedNotResource = engine.matchesDynamicPatterns(stmt.DynamicNotResourcePatterns, args.Resource, args)
|
||||
}
|
||||
|
||||
if matchedNotResource {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check if principal matches
|
||||
if len(stmt.PrincipalPatterns) > 0 || len(stmt.DynamicPrincipalPatterns) > 0 {
|
||||
matchedPrincipal := engine.matchesPatterns(stmt.PrincipalPatterns, args.Principal)
|
||||
if !matchedPrincipal {
|
||||
matchedPrincipal = engine.matchesDynamicPatterns(stmt.DynamicPrincipalPatterns, args.Principal, args)
|
||||
}
|
||||
if !matchedPrincipal {
|
||||
// Check if principal matches (if specified)
|
||||
if len(stmt.PrincipalPatterns) > 0 {
|
||||
if !engine.matchesPatterns(stmt.PrincipalPatterns, args.Principal) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check conditions
|
||||
if len(stmt.Statement.Condition) > 0 {
|
||||
match := EvaluateConditions(stmt.Statement.Condition, args.Conditions, args.ObjectEntry, args.Claims)
|
||||
if !match {
|
||||
if !EvaluateConditions(stmt.Statement.Condition, args.Conditions, args.ObjectEntry) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -229,153 +180,6 @@ func (engine *PolicyEngine) matchesPatterns(patterns []*regexp.Regexp, value str
|
||||
return false
|
||||
}
|
||||
|
||||
// SubstituteVariables replaces ${variable} in a pattern with values from context and claims
|
||||
// Supports:
|
||||
// - Standard context variables (aws:SourceIp, s3:prefix, etc.)
|
||||
// - JWT claims (jwt:preferred_username, jwt:sub, jwt:*)
|
||||
// - LDAP claims (ldap:username, ldap:dn, ldap:*)
|
||||
func SubstituteVariables(pattern string, context map[string][]string, claims map[string]interface{}) string {
|
||||
result := PolicyVariableRegex.ReplaceAllStringFunc(pattern, func(match string) string {
|
||||
// match is like "${aws:username}"
|
||||
// extract variable name "aws:username"
|
||||
variable := match[2 : len(match)-1]
|
||||
|
||||
// Check standard context first
|
||||
if values, ok := context[variable]; ok && len(values) > 0 {
|
||||
return values[0]
|
||||
}
|
||||
|
||||
// Check JWT claims for jwt:* variables
|
||||
if strings.HasPrefix(variable, "jwt:") {
|
||||
claimName := variable[4:] // Remove "jwt:" prefix
|
||||
if claimValue, ok := claims[claimName]; ok {
|
||||
switch v := claimValue.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
// JWT numbers are often float64
|
||||
if v == float64(int64(v)) {
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
}
|
||||
return fmt.Sprintf("%g", v)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check LDAP claims for ldap:* variables
|
||||
// FALLBACK MECHANISM: Try both prefixed and unprefixed keys
|
||||
// Some LDAP providers store claims with the "ldap:" prefix (e.g., "ldap:username")
|
||||
// while others store them without the prefix (e.g., "username").
|
||||
// We check the prefixed key first for consistency, then fall back to unprefixed.
|
||||
if strings.HasPrefix(variable, "ldap:") {
|
||||
claimName := variable[5:] // Remove "ldap:" prefix
|
||||
// Try prefixed key first (e.g., "ldap:username"), then unprefixed
|
||||
var claimValue interface{}
|
||||
var ok bool
|
||||
if claimValue, ok = claims[variable]; !ok {
|
||||
claimValue, ok = claims[claimName]
|
||||
}
|
||||
if ok {
|
||||
switch v := claimValue.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
if v == float64(int64(v)) {
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
}
|
||||
return fmt.Sprintf("%g", v)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Variable not found, leave as-is to avoid unexpected matching
|
||||
return match
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ExtractPrincipalVariables extracts policy variables from a principal ARN
|
||||
func ExtractPrincipalVariables(principal string) map[string][]string {
|
||||
vars := make(map[string][]string)
|
||||
|
||||
// Handle non-ARN principals (e.g., "*" or simple usernames)
|
||||
if !strings.HasPrefix(principal, "arn:aws:") {
|
||||
return vars
|
||||
}
|
||||
|
||||
// Parse ARN: arn:aws:service::account:resource
|
||||
parts := strings.Split(principal, ":")
|
||||
if len(parts) < 6 {
|
||||
return vars
|
||||
}
|
||||
|
||||
account := parts[4] // account ID
|
||||
resourcePart := parts[5] // user/username or assumed-role/role/session
|
||||
|
||||
// Set aws:PrincipalAccount if account is present
|
||||
if account != "" {
|
||||
vars["aws:PrincipalAccount"] = []string{account}
|
||||
}
|
||||
|
||||
resourceParts := strings.Split(resourcePart, "/")
|
||||
if len(resourceParts) < 2 {
|
||||
return vars
|
||||
}
|
||||
|
||||
resourceType := resourceParts[0] // "user", "role", "assumed-role"
|
||||
|
||||
// Set aws:principaltype and extract username/userid based on resource type
|
||||
switch resourceType {
|
||||
case "user":
|
||||
vars["aws:principaltype"] = []string{"IAMUser"}
|
||||
// For users with paths like "user/path/to/username", use the last segment
|
||||
username := resourceParts[len(resourceParts)-1]
|
||||
vars["aws:username"] = []string{username}
|
||||
vars["aws:userid"] = []string{username} // In SeaweedFS, userid is same as username
|
||||
case "role":
|
||||
vars["aws:principaltype"] = []string{"IAMRole"}
|
||||
// For roles with paths like "role/path/to/rolename", use the last segment
|
||||
// Note: IAM Roles do NOT have aws:userid, but aws:PrincipalAccount is kept for condition evaluations
|
||||
if len(resourceParts) >= 2 {
|
||||
roleName := resourceParts[len(resourceParts)-1]
|
||||
vars["aws:username"] = []string{roleName}
|
||||
}
|
||||
case "assumed-role":
|
||||
vars["aws:principaltype"] = []string{"AssumedRole"}
|
||||
// For assumed roles: assumed-role/RoleName/SessionName or assumed-role/path/to/RoleName/SessionName
|
||||
// The session name is always the last segment
|
||||
if len(resourceParts) >= 3 {
|
||||
sessionName := resourceParts[len(resourceParts)-1]
|
||||
vars["aws:username"] = []string{sessionName}
|
||||
vars["aws:userid"] = []string{sessionName}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: principaltype is already set correctly in the switch above based on resource type
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
// ExtractConditionValuesFromRequest extracts condition values from HTTP request
|
||||
func ExtractConditionValuesFromRequest(r *http.Request) map[string][]string {
|
||||
values := make(map[string][]string)
|
||||
@@ -609,12 +413,6 @@ func (engine *PolicyEngine) EvaluatePolicyForRequest(bucketName, objectName, act
|
||||
actionName := BuildActionName(action)
|
||||
conditions := ExtractConditionValuesFromRequest(r)
|
||||
|
||||
// Extract principal information for variables
|
||||
principalVars := ExtractPrincipalVariables(principal)
|
||||
for k, v := range principalVars {
|
||||
conditions[k] = v
|
||||
}
|
||||
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: actionName,
|
||||
Resource: resource,
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPrincipalVariables(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
principal string
|
||||
expected map[string][]string
|
||||
}{
|
||||
{
|
||||
name: "IAM User ARN",
|
||||
principal: "arn:aws:iam::123456789012:user/alice",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
"aws:username": {"alice"},
|
||||
"aws:userid": {"alice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Assumed Role ARN",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/MyRole/session-alice",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"AssumedRole"},
|
||||
"aws:username": {"session-alice"},
|
||||
"aws:userid": {"session-alice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IAM Role ARN",
|
||||
principal: "arn:aws:iam::123456789012:role/MyRole",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMRole"},
|
||||
"aws:username": {"MyRole"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Non-ARN principal",
|
||||
principal: "user:alice",
|
||||
expected: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "Wildcard principal",
|
||||
principal: "*",
|
||||
expected: map[string][]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExtractPrincipalVariables(tt.principal)
|
||||
|
||||
// Check that all expected keys are present with correct values
|
||||
for key, expectedValues := range tt.expected {
|
||||
actualValues, ok := result[key]
|
||||
if !ok {
|
||||
t.Errorf("Expected key %s not found in result", key)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(actualValues) != len(expectedValues) {
|
||||
t.Errorf("For key %s: expected %d values, got %d", key, len(expectedValues), len(actualValues))
|
||||
continue
|
||||
}
|
||||
|
||||
for i, expectedValue := range expectedValues {
|
||||
if actualValues[i] != expectedValue {
|
||||
t.Errorf("For key %s[%d]: expected %s, got %s", key, i, expectedValue, actualValues[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check that there are no unexpected keys
|
||||
for key := range result {
|
||||
if _, ok := tt.expected[key]; !ok {
|
||||
t.Errorf("Unexpected key %s in result", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariablesWithClaims(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
context map[string][]string
|
||||
claims map[string]interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Standard context variable",
|
||||
pattern: "arn:aws:s3:::bucket/${aws:username}/*",
|
||||
context: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
claims: nil,
|
||||
expected: "arn:aws:s3:::bucket/alice/*",
|
||||
},
|
||||
{
|
||||
name: "JWT claim substitution",
|
||||
pattern: "arn:aws:s3:::bucket/${jwt:preferred_username}/*",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{
|
||||
"preferred_username": "bob",
|
||||
},
|
||||
expected: "arn:aws:s3:::bucket/bob/*",
|
||||
},
|
||||
{
|
||||
name: "Mixed variables",
|
||||
pattern: "arn:aws:s3:::bucket/${jwt:sub}/files/${aws:principaltype}",
|
||||
context: map[string][]string{
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
},
|
||||
claims: map[string]interface{}{
|
||||
"sub": "user123",
|
||||
},
|
||||
expected: "arn:aws:s3:::bucket/user123/files/IAMUser",
|
||||
},
|
||||
{
|
||||
name: "Variable not found",
|
||||
pattern: "arn:aws:s3:::bucket/${jwt:missing}/*",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{},
|
||||
expected: "arn:aws:s3:::bucket/${jwt:missing}/*",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SubstituteVariables(tt.pattern, tt.context, tt.claims)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyVariablesWithPrincipalType(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy that requires specific principal type
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "arn:aws:s3:::bucket/*",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:principaltype": "IAMUser"
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Test with IAM User - should allow
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
"aws:username": {"alice"},
|
||||
"aws:userid": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow for IAMUser principal, got %v", result)
|
||||
}
|
||||
|
||||
// Test with AssumedRole - should return Indeterminate (condition doesn't match)
|
||||
args.Principal = "arn:aws:sts::123456789012:assumed-role/MyRole/session"
|
||||
args.Conditions["aws:principaltype"] = []string{"AssumedRole"}
|
||||
|
||||
result = engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for AssumedRole principal, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyVariablesWithJWTClaims(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy using JWT claim in resource
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:*",
|
||||
"Resource": "arn:aws:s3:::bucket/${jwt:preferred_username}/*"
|
||||
}]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Test with matching JWT claim
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/alice/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{},
|
||||
Claims: map[string]interface{}{
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow when JWT claim matches resource, got %v", result)
|
||||
}
|
||||
|
||||
// Test with mismatched JWT claim
|
||||
args.Resource = "arn:aws:s3:::bucket/bob/file.txt"
|
||||
|
||||
result = engine.EvaluatePolicy("bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate when JWT claim doesn't match resource, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrincipalVariablesWithAccount(t *testing.T) {
|
||||
principal := "arn:aws:iam::123456789012:user/alice"
|
||||
vars := ExtractPrincipalVariables(principal)
|
||||
|
||||
if account, ok := vars["aws:PrincipalAccount"]; !ok {
|
||||
t.Errorf("Expected aws:PrincipalAccount to be present")
|
||||
} else if len(account) == 0 {
|
||||
t.Errorf("Expected aws:PrincipalAccount to have values")
|
||||
} else if account[0] != "123456789012" {
|
||||
t.Errorf("Expected aws:PrincipalAccount=123456789012, got %v", account[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariablesWithLDAP(t *testing.T) {
|
||||
pattern := "arn:aws:s3:::bucket/${ldap:username}/*"
|
||||
context := map[string][]string{}
|
||||
claims := map[string]interface{}{
|
||||
"username": "jdoe",
|
||||
}
|
||||
|
||||
result := SubstituteVariables(pattern, context, claims)
|
||||
expected := "arn:aws:s3:::bucket/jdoe/*"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, result)
|
||||
}
|
||||
|
||||
// Test ldap:dn
|
||||
pattern = "arn:aws:s3:::bucket/${ldap:dn}/*"
|
||||
claims = map[string]interface{}{
|
||||
"dn": "uid=jdoe,ou=people,dc=example,dc=com",
|
||||
}
|
||||
result = SubstituteVariables(pattern, context, claims)
|
||||
expected = "arn:aws:s3:::bucket/uid=jdoe,ou=people,dc=example,dc=com/*"
|
||||
if result != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteVariablesSpecialChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
context map[string][]string
|
||||
claims map[string]interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Comparison operators in claims/vars",
|
||||
pattern: "resource/${jwt:scope}",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{
|
||||
"scope": "read/write",
|
||||
},
|
||||
expected: "resource/read/write",
|
||||
},
|
||||
{
|
||||
name: "Path traversal attempt (should just substitute text)",
|
||||
pattern: "bucket/${jwt:user}",
|
||||
context: map[string][]string{},
|
||||
claims: map[string]interface{}{
|
||||
"user": "../../../etc/passwd",
|
||||
},
|
||||
expected: "bucket/../../../etc/passwd",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SubstituteVariables(tt.pattern, tt.context, tt.claims)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsolationPolicy(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
bucketName := "test-isolation"
|
||||
|
||||
policyJSON := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Sid": "AllowOwnFolder",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": "arn:aws:s3:::%s/${aws:username}/*"
|
||||
}, {
|
||||
"Sid": "AllowListOwnPrefix",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::%s",
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": ["${aws:username}/*", "${aws:username}"]
|
||||
}
|
||||
}
|
||||
}, {
|
||||
"Sid": "DenyOtherFolders",
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"NotResource": "arn:aws:s3:::%s/${aws:username}/*"
|
||||
}]
|
||||
}`, bucketName, bucketName, bucketName)
|
||||
|
||||
err := engine.SetBucketPolicy(bucketName, policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Case 1: Alice accesses her own folder -> should be ALLOWED
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/alice/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
result := engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Alice should be ALLOWED to her own folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 2: Alice accesses Bob's folder -> should be DENIED
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/bob/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
result = engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Alice should be DENIED access to Bob's folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 3: Bob accesses Bob's folder -> should be ALLOWED
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/bob/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/bob",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"bob"},
|
||||
},
|
||||
}
|
||||
result = engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Bob should be ALLOWED to his own folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 4: Bob accesses Alice's folder -> should be DENIED
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/alice/data.txt", bucketName),
|
||||
Principal: "arn:aws:sts::123456789012:assumed-role/TestReadOnlyRole/bob",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"bob"},
|
||||
},
|
||||
}
|
||||
result = engine.EvaluatePolicy(bucketName, args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Bob should be DENIED access to Alice's folder, got %v", result)
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNotResourceWithVariables(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy mirroring the isolation test
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowOwnFolder",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::bucket/${aws:username}/*"
|
||||
},
|
||||
{
|
||||
"Sid": "DenyOtherFolders",
|
||||
"Effect": "Deny",
|
||||
"Action": "s3:GetObject",
|
||||
"NotResource": "arn:aws:s3:::bucket/${aws:username}/*"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Case 1: Alice accesses her own folder -> should match Allow, but NOT match Deny statement
|
||||
// (because Deny says NotResource is own folder, and she IS accessing her own folder, so NotResource check fails, statement doesn't apply)
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/alice/data.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Alice should be allowed to her own folder, got %v", result)
|
||||
}
|
||||
|
||||
// Case 2: Alice accesses Bob's folder -> should NOT match Allow, and SHOULD match Deny statement
|
||||
// (because Deny says NotResource is own folder, and she is NOT accessing her own folder, so NotResource matches, statement applies)
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::bucket/bob/data.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/alice",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Alice should be denied access to Bob folder, got %v", result)
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExtractPrincipalVariablesWithPaths tests ARN parsing with IAM path components
|
||||
func TestExtractPrincipalVariablesWithPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
principal string
|
||||
expected map[string][]string
|
||||
}{
|
||||
{
|
||||
name: "IAM User with path",
|
||||
principal: "arn:aws:iam::123456789012:user/division/team/alice",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMUser"},
|
||||
"aws:username": {"alice"},
|
||||
"aws:userid": {"alice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IAM Role with path",
|
||||
principal: "arn:aws:iam::123456789012:role/service-role/MyRole",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"IAMRole"},
|
||||
"aws:username": {"MyRole"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Assumed Role with path",
|
||||
principal: "arn:aws:sts::123456789012:assumed-role/service-role/MyRole/session-name",
|
||||
expected: map[string][]string{
|
||||
"aws:PrincipalAccount": {"123456789012"},
|
||||
"aws:principaltype": {"AssumedRole"},
|
||||
"aws:username": {"session-name"},
|
||||
"aws:userid": {"session-name"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExtractPrincipalVariables(tt.principal)
|
||||
|
||||
// Check that all expected keys are present with correct values
|
||||
for key, expectedValues := range tt.expected {
|
||||
actualValues, ok := result[key]
|
||||
if !ok {
|
||||
t.Errorf("Expected key %s not found in result", key)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(actualValues) != len(expectedValues) {
|
||||
t.Errorf("For key %s: expected %d values, got %d", key, len(expectedValues), len(actualValues))
|
||||
continue
|
||||
}
|
||||
|
||||
for i, expectedValue := range expectedValues {
|
||||
if actualValues[i] != expectedValue {
|
||||
t.Errorf("For key %s[%d]: expected %s, got %s", key, i, expectedValue, actualValues[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check that there are no unexpected keys
|
||||
for key := range result {
|
||||
if _, ok := tt.expected[key]; !ok {
|
||||
t.Errorf("Unexpected key %s in result", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package policy_engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPolicyVariables(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy with variables in Resource and Condition
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowUserHomeDirectory",
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:ListBucket",
|
||||
"Resource": "arn:aws:s3:::test-bucket",
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": ["${aws:username}/*"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "AllowUserObjectAccess",
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:GetObject", "s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::test-bucket/${aws:username}/*"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// Case 1: Matching username for resource access
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/johndoe/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow for matching username in resource, got %v", result)
|
||||
}
|
||||
|
||||
// Case 2: Mismatched username for resource access
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/janedoe/file.txt",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for mismatched username in resource, got %v", result)
|
||||
}
|
||||
|
||||
// Case 3: ListBucket with matching prefix condition
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:ListBucket",
|
||||
Resource: "arn:aws:s3:::test-bucket",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
"s3:prefix": {"johndoe/docs"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow for matching prefix condition, got %v", result)
|
||||
}
|
||||
|
||||
// Case 4: ListBucket with mismatched prefix condition
|
||||
args = &PolicyEvaluationArgs{
|
||||
Action: "s3:ListBucket",
|
||||
Resource: "arn:aws:s3:::test-bucket",
|
||||
Principal: "arn:aws:iam::123456789012:user/johndoe",
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"johndoe"},
|
||||
"s3:prefix": {"janedoe/docs"},
|
||||
},
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for mismatched prefix condition, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePolicyForRequestVariables(t *testing.T) {
|
||||
engine := NewPolicyEngine()
|
||||
|
||||
// Policy using aws:username
|
||||
policyJSON := `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::test-bucket/${aws:username}/*"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
err := engine.SetBucketPolicy("test-bucket", policyJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set bucket policy: %v", err)
|
||||
}
|
||||
|
||||
// We need to mock the request but the EvaluatePolicyForRequest mostly runs on args extraction
|
||||
// The key thing is that EvaluatePolicyForRequest should populate "aws:username" from principal
|
||||
|
||||
// Since we cannot easily pass a full http.Request that matches everything, we will test the extraction logic
|
||||
// by simulating what EvaluatePolicyForRequest does: calling EvaluatePolicy with populated Conditions
|
||||
|
||||
principal := "arn:aws:iam::123456789012:user/alice"
|
||||
// Should extract "alice"
|
||||
|
||||
// Create args manually as if extracted
|
||||
args := &PolicyEvaluationArgs{
|
||||
Action: "s3:GetObject",
|
||||
Resource: "arn:aws:s3:::test-bucket/alice/data.txt",
|
||||
Principal: principal,
|
||||
Conditions: map[string][]string{
|
||||
"aws:username": {"alice"},
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultAllow {
|
||||
t.Errorf("Expected Allow when aws:username is populated, got %v", result)
|
||||
}
|
||||
|
||||
// Now with wrong resource
|
||||
args.Resource = "arn:aws:s3:::test-bucket/bob/data.txt"
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate when resource doesn't match variable, got %v", result)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -32,11 +31,6 @@ const (
|
||||
PolicyVersion2012_10_17 = "2012-10-17"
|
||||
)
|
||||
|
||||
var (
|
||||
// PolicyVariableRegex detects AWS IAM policy variables like ${aws:username}
|
||||
PolicyVariableRegex = regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||
)
|
||||
|
||||
// StringOrStringSlice represents a value that can be either a string or []string
|
||||
type StringOrStringSlice struct {
|
||||
values []string
|
||||
@@ -90,13 +84,12 @@ type PolicyDocument struct {
|
||||
|
||||
// PolicyStatement represents a single policy statement
|
||||
type PolicyStatement struct {
|
||||
Sid string `json:"Sid,omitempty"`
|
||||
Effect PolicyEffect `json:"Effect"`
|
||||
Principal *StringOrStringSlice `json:"Principal,omitempty"`
|
||||
Action StringOrStringSlice `json:"Action"`
|
||||
Resource StringOrStringSlice `json:"Resource,omitempty"`
|
||||
NotResource StringOrStringSlice `json:"NotResource,omitempty"`
|
||||
Condition PolicyConditions `json:"Condition,omitempty"`
|
||||
Sid string `json:"Sid,omitempty"`
|
||||
Effect PolicyEffect `json:"Effect"`
|
||||
Principal *StringOrStringSlice `json:"Principal,omitempty"`
|
||||
Action StringOrStringSlice `json:"Action"`
|
||||
Resource StringOrStringSlice `json:"Resource"`
|
||||
Condition PolicyConditions `json:"Condition,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyEffect represents Allow or Deny
|
||||
@@ -118,8 +111,6 @@ type PolicyEvaluationArgs struct {
|
||||
// Tags are stored with s3_constants.AmzObjectTaggingPrefix (X-Amz-Tagging-) prefix.
|
||||
// Can be nil for bucket-level operations or when object doesn't exist.
|
||||
ObjectEntry map[string][]byte
|
||||
// Claims are JWT claims for jwt:* policy variables (can be nil)
|
||||
Claims map[string]interface{}
|
||||
}
|
||||
|
||||
// PolicyCache for caching compiled policies
|
||||
@@ -131,7 +122,7 @@ type PolicyCache struct {
|
||||
// CompiledPolicy represents a policy that has been compiled for efficient evaluation
|
||||
type CompiledPolicy struct {
|
||||
Document *PolicyDocument
|
||||
Statements []*CompiledStatement
|
||||
Statements []CompiledStatement
|
||||
}
|
||||
|
||||
// CompiledStatement represents a compiled policy statement
|
||||
@@ -144,16 +135,6 @@ type CompiledStatement struct {
|
||||
ActionPatterns []*regexp.Regexp
|
||||
ResourcePatterns []*regexp.Regexp
|
||||
PrincipalPatterns []*regexp.Regexp
|
||||
|
||||
// dynamic patterns that require variable substitution before matching
|
||||
DynamicActionPatterns []string
|
||||
DynamicResourcePatterns []string
|
||||
DynamicPrincipalPatterns []string
|
||||
|
||||
// NotResource patterns (resource should NOT match these)
|
||||
NotResourcePatterns []*regexp.Regexp
|
||||
NotResourceMatchers []*WildcardMatcher
|
||||
DynamicNotResourcePatterns []string
|
||||
}
|
||||
|
||||
// NewPolicyCache creates a new policy cache
|
||||
@@ -173,8 +154,8 @@ func ValidatePolicy(policyDoc *PolicyDocument) error {
|
||||
return fmt.Errorf("policy must contain at least one statement")
|
||||
}
|
||||
|
||||
for i := range policyDoc.Statement {
|
||||
if err := validateStatement(&policyDoc.Statement[i]); err != nil {
|
||||
for i, stmt := range policyDoc.Statement {
|
||||
if err := validateStatement(&stmt); err != nil {
|
||||
return fmt.Errorf("invalid statement %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
@@ -192,8 +173,8 @@ func validateStatement(stmt *PolicyStatement) error {
|
||||
return fmt.Errorf("action is required")
|
||||
}
|
||||
|
||||
if len(stmt.Resource.Strings()) == 0 && len(stmt.NotResource.Strings()) == 0 {
|
||||
return fmt.Errorf("statement must specify Resource or NotResource")
|
||||
if len(stmt.Resource.Strings()) == 0 {
|
||||
return fmt.Errorf("resource is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -217,16 +198,15 @@ func ParsePolicy(policyJSON string) (*PolicyDocument, error) {
|
||||
func CompilePolicy(policy *PolicyDocument) (*CompiledPolicy, error) {
|
||||
compiled := &CompiledPolicy{
|
||||
Document: policy,
|
||||
Statements: make([]*CompiledStatement, len(policy.Statement)),
|
||||
Statements: make([]CompiledStatement, len(policy.Statement)),
|
||||
}
|
||||
|
||||
for i := range policy.Statement {
|
||||
stmt := &policy.Statement[i]
|
||||
compiledStmt, err := compileStatement(stmt)
|
||||
for i, stmt := range policy.Statement {
|
||||
compiledStmt, err := compileStatement(&stmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile statement %d: %v", i, err)
|
||||
}
|
||||
compiled.Statements[i] = compiledStmt
|
||||
compiled.Statements[i] = *compiledStmt
|
||||
}
|
||||
|
||||
return compiled, nil
|
||||
@@ -234,51 +214,12 @@ func CompilePolicy(policy *PolicyDocument) (*CompiledPolicy, error) {
|
||||
|
||||
// compileStatement compiles a single policy statement
|
||||
func compileStatement(stmt *PolicyStatement) (*CompiledStatement, error) {
|
||||
resStrings := slices.Clone(stmt.Resource.Strings())
|
||||
notResStrings := slices.Clone(stmt.NotResource.Strings())
|
||||
compiled := &CompiledStatement{
|
||||
Statement: &PolicyStatement{
|
||||
Sid: stmt.Sid,
|
||||
Effect: stmt.Effect,
|
||||
Action: stmt.Action,
|
||||
},
|
||||
}
|
||||
|
||||
// Deep clone Principal if present
|
||||
if stmt.Principal != nil {
|
||||
principalClone := *stmt.Principal
|
||||
principalClone.values = slices.Clone(stmt.Principal.values)
|
||||
compiled.Statement.Principal = &principalClone
|
||||
}
|
||||
|
||||
// Deep clone Resource/NotResource into the internal statement as well for completeness
|
||||
compiled.Statement.Resource.values = slices.Clone(stmt.Resource.values)
|
||||
compiled.Statement.NotResource.values = slices.Clone(stmt.NotResource.values)
|
||||
compiled.Statement.Action.values = slices.Clone(stmt.Action.values)
|
||||
|
||||
// Deep clone Condition map
|
||||
if stmt.Condition != nil {
|
||||
compiled.Statement.Condition = make(PolicyConditions)
|
||||
for k, v := range stmt.Condition {
|
||||
innerMap := make(map[string]StringOrStringSlice)
|
||||
for ik, iv := range v {
|
||||
innerMap[ik] = StringOrStringSlice{values: slices.Clone(iv.values)}
|
||||
}
|
||||
compiled.Statement.Condition[k] = innerMap
|
||||
}
|
||||
Statement: stmt,
|
||||
}
|
||||
|
||||
// Compile action patterns and matchers
|
||||
for _, action := range stmt.Action.Strings() {
|
||||
if action == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(action) {
|
||||
compiled.DynamicActionPatterns = append(compiled.DynamicActionPatterns, action)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile action pattern %s: %v", action, err)
|
||||
@@ -293,16 +234,7 @@ func compileStatement(stmt *PolicyStatement) (*CompiledStatement, error) {
|
||||
}
|
||||
|
||||
// Compile resource patterns and matchers
|
||||
for _, resource := range resStrings {
|
||||
if resource == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(resource) {
|
||||
compiled.DynamicResourcePatterns = append(compiled.DynamicResourcePatterns, resource)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, resource := range stmt.Resource.Strings() {
|
||||
pattern, err := compilePattern(resource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile resource pattern %s: %v", resource, err)
|
||||
@@ -319,15 +251,6 @@ func compileStatement(stmt *PolicyStatement) (*CompiledStatement, error) {
|
||||
// Compile principal patterns and matchers if present
|
||||
if stmt.Principal != nil && len(stmt.Principal.Strings()) > 0 {
|
||||
for _, principal := range stmt.Principal.Strings() {
|
||||
if principal == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(principal) {
|
||||
compiled.DynamicPrincipalPatterns = append(compiled.DynamicPrincipalPatterns, principal)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(principal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile principal pattern %s: %v", principal, err)
|
||||
@@ -342,35 +265,6 @@ func compileStatement(stmt *PolicyStatement) (*CompiledStatement, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Compile NotResource patterns (resource should NOT match these)
|
||||
if len(notResStrings) > 0 {
|
||||
for _, notResource := range notResStrings {
|
||||
if notResource == "" {
|
||||
continue
|
||||
}
|
||||
// Check for dynamic variables
|
||||
if PolicyVariableRegex.MatchString(notResource) {
|
||||
compiled.DynamicNotResourcePatterns = append(compiled.DynamicNotResourcePatterns, notResource)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern, err := compilePattern(notResource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile NotResource pattern %s: %v", notResource, err)
|
||||
}
|
||||
compiled.NotResourcePatterns = append(compiled.NotResourcePatterns, pattern)
|
||||
|
||||
matcher, err := NewWildcardMatcher(notResource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create NotResource matcher %s: %v", notResource, err)
|
||||
}
|
||||
compiled.NotResourceMatchers = append(compiled.NotResourceMatchers, matcher)
|
||||
|
||||
// Debug log
|
||||
// fmt.Printf("Compiled NotResource: %s\n", notResource)
|
||||
}
|
||||
}
|
||||
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -477,7 +477,7 @@ func setupS3ReadOnlyRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -521,7 +521,7 @@ func setupS3AdminRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -565,7 +565,7 @@ func setupS3WriteRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -614,7 +614,7 @@ func setupS3IPRestrictedRole(ctx context.Context, manager *integration.IAMManage
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
|
||||
+68
-167
@@ -18,32 +18,11 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// privateNetworks contains pre-parsed private IP ranges for efficient lookups
|
||||
var privateNetworks []*net.IPNet
|
||||
|
||||
func init() {
|
||||
// Private IPv4 ranges (RFC1918) and IPv6 Unique Local Addresses (ULA)
|
||||
privateRanges := []string{
|
||||
"10.0.0.0/8", // IPv4 private
|
||||
"172.16.0.0/12", // IPv4 private
|
||||
"192.168.0.0/16", // IPv4 private
|
||||
"fc00::/7", // IPv6 Unique Local Addresses (ULA)
|
||||
}
|
||||
|
||||
for _, cidr := range privateRanges {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err == nil {
|
||||
privateNetworks = append(privateNetworks, network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IAMIntegration defines the interface for IAM integration
|
||||
type IAMIntegration interface {
|
||||
AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode)
|
||||
AuthorizeAction(ctx context.Context, identity *IAMIdentity, action Action, bucket string, objectKey string, r *http.Request) s3err.ErrorCode
|
||||
ValidateSessionToken(ctx context.Context, token string) (*sts.SessionInfo, error)
|
||||
ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error
|
||||
}
|
||||
|
||||
// S3IAMIntegration provides IAM integration for S3 API
|
||||
@@ -93,21 +72,14 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// SECURITY NOTE: ParseJWTToken parses without cryptographic verification
|
||||
// This is SAFE because we only use the unverified claims to route to the correct
|
||||
// verification method. All code paths below perform full cryptographic verification:
|
||||
// - OIDC tokens: validated via validateExternalOIDCToken (line 98)
|
||||
// - STS tokens: validated via ValidateSessionToken (line 156)
|
||||
// The unverified issuer claim is only used for routing, never for authorization.
|
||||
tokenClaims, err := ParseUnverifiedJWTToken(sessionToken)
|
||||
// Try to parse as STS session token first
|
||||
tokenClaims, err := parseJWTToken(sessionToken)
|
||||
if err != nil {
|
||||
glog.V(3).Infof("Failed to parse JWT token: %v", err)
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Determine token type by issuer claim (more robust than checking role claim)
|
||||
// We use the unverified claims ONLY for routing to the correct verification method.
|
||||
// We DO NOT use these claims for building the identity.
|
||||
issuer, issuerOk := tokenClaims["iss"].(string)
|
||||
if !issuerOk {
|
||||
glog.V(3).Infof("Token missing issuer claim - invalid JWT")
|
||||
@@ -143,47 +115,59 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ
|
||||
EmailAddress: identity.UserID + "@oidc.local",
|
||||
Id: identity.UserID,
|
||||
},
|
||||
Claims: map[string]interface{}{
|
||||
"sub": identity.UserID,
|
||||
"role": identity.RoleArn,
|
||||
},
|
||||
}, s3err.ErrNone
|
||||
}
|
||||
|
||||
// This is an STS-issued token - validate with STS service
|
||||
// ValidateSessionToken performs cryptographic verification and extraction of trusted claims
|
||||
sessionInfo, err := s3iam.stsService.ValidateSessionToken(ctx, sessionToken)
|
||||
// This is an STS-issued token - extract STS session information
|
||||
|
||||
// Extract role claim from STS token
|
||||
roleName, roleOk := tokenClaims["role"].(string)
|
||||
if !roleOk || roleName == "" {
|
||||
glog.V(3).Infof("STS token missing role claim")
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
sessionName, ok := tokenClaims["snam"].(string)
|
||||
if !ok || sessionName == "" {
|
||||
sessionName = "jwt-session" // Default fallback
|
||||
}
|
||||
|
||||
subject, ok := tokenClaims["sub"].(string)
|
||||
if !ok || subject == "" {
|
||||
subject = "jwt-user" // Default fallback
|
||||
}
|
||||
|
||||
// Use the principal ARN directly from token claims, or build it if not available
|
||||
principalArn, ok := tokenClaims["principal"].(string)
|
||||
if !ok || principalArn == "" {
|
||||
// Fallback: extract role name from role ARN and build principal ARN
|
||||
roleNameOnly := roleName
|
||||
if strings.Contains(roleName, "/") {
|
||||
parts := strings.Split(roleName, "/")
|
||||
roleNameOnly = parts[len(parts)-1]
|
||||
}
|
||||
principalArn = fmt.Sprintf("arn:aws:sts::assumed-role/%s/%s", roleNameOnly, sessionName)
|
||||
}
|
||||
|
||||
// Validate the JWT token directly using STS service (avoid circular dependency)
|
||||
// Note: We don't call IsActionAllowed here because that would create a circular dependency
|
||||
// Authentication should only validate the token, authorization happens later
|
||||
_, err = s3iam.stsService.ValidateSessionToken(ctx, sessionToken)
|
||||
if err != nil {
|
||||
glog.V(3).Infof("STS session validation failed: %v", err)
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Create claims map starting with request context (which holds custom claims)
|
||||
claims := make(map[string]interface{})
|
||||
if sessionInfo.RequestContext != nil {
|
||||
for k, v := range sessionInfo.RequestContext {
|
||||
claims[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Add standard claims
|
||||
claims["sub"] = sessionInfo.Subject
|
||||
claims["role"] = sessionInfo.RoleArn
|
||||
claims["principal"] = sessionInfo.Principal
|
||||
claims["snam"] = sessionInfo.SessionName
|
||||
|
||||
// Create IAM identity from VALIDATED session info
|
||||
// We use the trusted data returned by the STS service, not the unverified token claims
|
||||
// Create IAM identity from validated token
|
||||
identity := &IAMIdentity{
|
||||
Name: sessionInfo.Subject,
|
||||
Principal: sessionInfo.Principal,
|
||||
Name: subject,
|
||||
Principal: principalArn,
|
||||
SessionToken: sessionToken,
|
||||
Account: &Account{
|
||||
DisplayName: sessionInfo.SessionName,
|
||||
EmailAddress: sessionInfo.Subject + "@seaweedfs.local",
|
||||
Id: sessionInfo.Subject,
|
||||
DisplayName: roleName,
|
||||
EmailAddress: subject + "@seaweedfs.local",
|
||||
Id: subject,
|
||||
},
|
||||
Claims: claims,
|
||||
}
|
||||
|
||||
glog.V(3).Infof("JWT authentication successful for principal: %s", identity.Principal)
|
||||
@@ -214,35 +198,6 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA
|
||||
// Extract request context for policy conditions
|
||||
requestContext := extractRequestContext(r)
|
||||
|
||||
// Add s3:prefix to request context based on object key
|
||||
// This ensures that policy conditions referencing s3:prefix (like StringLike)
|
||||
// work correctly for both ListObjects (where objectKey is the prefix) and
|
||||
// object operations (where we treat the object key as the prefix for matching)
|
||||
if objectKey != "" && objectKey != "/" {
|
||||
requestContext["s3:prefix"] = objectKey
|
||||
}
|
||||
|
||||
// Add identity claims to request context for policy variables
|
||||
// Only add claim keys if they don't already exist (to avoid overwriting request-derived context)
|
||||
if identity.Claims != nil {
|
||||
for k, v := range identity.Claims {
|
||||
// Only add the claim if this key doesn't already exist in request context
|
||||
if _, exists := requestContext[k]; !exists {
|
||||
requestContext[k] = v
|
||||
}
|
||||
|
||||
// If the claim doesn't have a namespace prefix (e.g. "email"), add "jwt:" prefix
|
||||
// This allows ${jwt:email} or ${jwt:preferred_username} to work
|
||||
// Only add namespaced version if it doesn't already exist
|
||||
if !strings.Contains(k, ":") {
|
||||
jwtKey := "jwt:" + k
|
||||
if _, exists := requestContext[jwtKey]; !exists {
|
||||
requestContext[jwtKey] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the specific S3 action based on the HTTP request details
|
||||
specificAction := ResolveS3Action(r, string(action), bucket, objectKey)
|
||||
|
||||
@@ -269,14 +224,6 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA
|
||||
return s3err.ErrNone
|
||||
}
|
||||
|
||||
// ValidateTrustPolicyForPrincipal delegates to IAMManager to validate trust policy
|
||||
func (s3iam *S3IAMIntegration) ValidateTrustPolicyForPrincipal(ctx context.Context, roleArn, principalArn string) error {
|
||||
if s3iam.iamManager == nil {
|
||||
return fmt.Errorf("IAM manager not available")
|
||||
}
|
||||
return s3iam.iamManager.ValidateTrustPolicyForPrincipal(ctx, roleArn, principalArn)
|
||||
}
|
||||
|
||||
// IAMIdentity represents an authenticated identity with session information
|
||||
type IAMIdentity struct {
|
||||
Name string
|
||||
@@ -284,7 +231,6 @@ type IAMIdentity struct {
|
||||
SessionToken string
|
||||
Account *Account
|
||||
PolicyNames []string
|
||||
Claims map[string]interface{}
|
||||
}
|
||||
|
||||
// IsAdmin checks if the identity has admin privileges
|
||||
@@ -451,10 +397,9 @@ func extractRequestContext(r *http.Request) map[string]interface{} {
|
||||
context := make(map[string]interface{})
|
||||
|
||||
// Extract source IP for IP-based conditions
|
||||
// Use AWS-compatible key name for policy variable substitution
|
||||
sourceIP := extractSourceIP(r)
|
||||
if sourceIP != "" {
|
||||
context["aws:SourceIp"] = sourceIP
|
||||
context["sourceIP"] = sourceIP
|
||||
}
|
||||
|
||||
// Extract user agent
|
||||
@@ -474,86 +419,42 @@ func extractRequestContext(r *http.Request) map[string]interface{} {
|
||||
}
|
||||
|
||||
// extractSourceIP extracts the real source IP from the request
|
||||
// SECURITY: Prioritizes RemoteAddr over client-controlled headers to prevent spoofing
|
||||
// Only trusts X-Forwarded-For/X-Real-IP if RemoteAddr appears to be from a trusted proxy
|
||||
func extractSourceIP(r *http.Request) string {
|
||||
// Always start with RemoteAddr as the most trustworthy source
|
||||
remoteIP := r.RemoteAddr
|
||||
if ip, _, err := net.SplitHostPort(remoteIP); err == nil {
|
||||
remoteIP = ip
|
||||
}
|
||||
|
||||
// NOTE: The current heuristic of using isPrivateIP assumes reverse proxies are on a
|
||||
// private/local network. This may be insufficient for some cloud, CDN, or multi-tier
|
||||
// proxy deployments where proxies terminate connections from public IPs. In such
|
||||
// environments, deployment-specific controls (e.g., network ACLs or proxy configs)
|
||||
// should be used to ensure only trusted components can set forwarding headers.
|
||||
// Future enhancements may introduce an explicit, configurable trusted proxy CIDR list.
|
||||
isTrustedProxy := isPrivateIP(remoteIP)
|
||||
|
||||
if isTrustedProxy {
|
||||
// Check X-Real-IP header first (single IP, more reliable than X-Forwarded-For)
|
||||
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||
return strings.TrimSpace(realIP)
|
||||
}
|
||||
|
||||
// Check X-Forwarded-For header (can contain multiple IPs, take the first one)
|
||||
if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" {
|
||||
if ips := strings.Split(forwardedFor, ","); len(ips) > 0 {
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
// Check X-Forwarded-For header (most common for proxied requests)
|
||||
if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" {
|
||||
// X-Forwarded-For can contain multiple IPs, take the first one
|
||||
if ips := strings.Split(forwardedFor, ","); len(ips) > 0 {
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr (most secure)
|
||||
return remoteIP
|
||||
// Check X-Real-IP header
|
||||
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||
return strings.TrimSpace(realIP)
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr
|
||||
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return ip
|
||||
}
|
||||
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// isPrivateIP checks if an IP is in a private range (localhost or RFC1918)
|
||||
func isPrivateIP(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for localhost and link-local addresses (IPv4/IPv6)
|
||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check against pre-parsed private CIDR ranges
|
||||
for _, network := range privateNetworks {
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseUnverifiedJWTToken parses a JWT token and returns its claims WITHOUT cryptographic verification
|
||||
//
|
||||
// SECURITY WARNING: This function does NOT validate the token signature!
|
||||
// It should ONLY be used for:
|
||||
// 1. Routing tokens to the appropriate verification method (e.g., checking issuer to determine STS vs OIDC)
|
||||
// 2. Extracting claims for logging/debugging AFTER the token has been cryptographically verified
|
||||
//
|
||||
// NEVER use the returned claims for authorization decisions without first calling a proper
|
||||
// verification function like ValidateSessionToken() or validateExternalOIDCToken().
|
||||
func ParseUnverifiedJWTToken(tokenString string) (jwt.MapClaims, error) {
|
||||
// Parse token without verification to get claims
|
||||
// This token IS NOT VERIFIED at this stage.
|
||||
// It is only used to peek at claims (like issuer) to determine which verification key/strategy to use.
|
||||
// parseJWTToken parses a JWT token and returns its claims without verification
|
||||
// Note: This is for extracting claims only. Verification is done by the IAM system.
|
||||
func parseJWTToken(tokenString string) (jwt.MapClaims, error) {
|
||||
token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to parse JWT token: %v", err)
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok {
|
||||
return claims, nil
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// minInt returns the minimum of two integers
|
||||
|
||||
@@ -405,8 +405,6 @@ func TestExtractSourceIP(t *testing.T) {
|
||||
setupReq: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", http.NoBody)
|
||||
req.Header.Set("X-Forwarded-For", "192.168.1.100, 10.0.0.1")
|
||||
// Set RemoteAddr to private IP to simulate trusted proxy
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
return req
|
||||
},
|
||||
expectedIP: "192.168.1.100",
|
||||
@@ -416,8 +414,6 @@ func TestExtractSourceIP(t *testing.T) {
|
||||
setupReq: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", http.NoBody)
|
||||
req.Header.Set("X-Real-IP", "192.168.1.200")
|
||||
// Set RemoteAddr to private IP to simulate trusted proxy
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
return req
|
||||
},
|
||||
expectedIP: "192.168.1.200",
|
||||
@@ -431,17 +427,6 @@ func TestExtractSourceIP(t *testing.T) {
|
||||
},
|
||||
expectedIP: "192.168.1.300",
|
||||
},
|
||||
{
|
||||
name: "Untrusted proxy - public RemoteAddr ignores X-Forwarded-For",
|
||||
setupReq: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", http.NoBody)
|
||||
req.Header.Set("X-Forwarded-For", "192.168.1.100")
|
||||
// Public IP - headers should NOT be trusted
|
||||
req.RemoteAddr = "8.8.8.8:12345"
|
||||
return req
|
||||
},
|
||||
expectedIP: "8.8.8.8", // Should use RemoteAddr, not X-Forwarded-For
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -171,8 +171,6 @@ func TestRequestContextExtraction(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", http.NoBody)
|
||||
req.Header.Set("X-Forwarded-For", "192.168.1.100")
|
||||
req.Header.Set("User-Agent", "aws-sdk-go/1.0")
|
||||
// Set RemoteAddr to private IP to simulate trusted proxy
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
return req
|
||||
},
|
||||
expectedIP: "192.168.1.100",
|
||||
@@ -184,8 +182,6 @@ func TestRequestContextExtraction(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", http.NoBody)
|
||||
req.Header.Set("X-Real-IP", "10.0.0.1")
|
||||
req.Header.Set("User-Agent", "boto3/1.0")
|
||||
// Set RemoteAddr to private IP to simulate trusted proxy
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
return req
|
||||
},
|
||||
expectedIP: "10.0.0.1",
|
||||
@@ -201,7 +197,7 @@ func TestRequestContextExtraction(t *testing.T) {
|
||||
context := extractRequestContext(req)
|
||||
|
||||
if tt.expectedIP != "" {
|
||||
assert.Equal(t, tt.expectedIP, context["aws:SourceIp"])
|
||||
assert.Equal(t, tt.expectedIP, context["sourceIP"])
|
||||
}
|
||||
|
||||
if tt.expectedUA != "" {
|
||||
@@ -259,8 +255,6 @@ func TestIPBasedPolicyEnforcement(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/restricted-bucket/file.txt", http.NoBody)
|
||||
req.Header.Set("Authorization", "Bearer "+response.Credentials.SessionToken)
|
||||
req.Header.Set("X-Forwarded-For", tt.sourceIP)
|
||||
// Set RemoteAddr to private IP to simulate trusted proxy
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
|
||||
// Create IAM identity for testing
|
||||
identity := &IAMIdentity{
|
||||
@@ -393,7 +387,7 @@ func setupTestReadOnlyRole(ctx context.Context, manager *integration.IAMManager)
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -411,7 +405,7 @@ func setupTestReadOnlyRole(ctx context.Context, manager *integration.IAMManager)
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -455,7 +449,7 @@ func setupTestAdminRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -473,7 +467,7 @@ func setupTestAdminRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -516,7 +510,7 @@ func setupTestIPRestrictedRole(ctx context.Context, manager *integration.IAMMana
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
|
||||
@@ -568,7 +568,7 @@ func setupTestRolesForMultipart(ctx context.Context, manager *integration.IAMMan
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -586,7 +586,7 @@ func setupTestRolesForMultipart(ctx context.Context, manager *integration.IAMMan
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
|
||||
@@ -70,21 +70,47 @@ func (iam *IdentityAccessManagement) ValidatePresignedURLWithIAM(r *http.Request
|
||||
return s3err.ErrNone
|
||||
}
|
||||
|
||||
// Create a temporary cloned request with Authorization header to reuse the secure AuthenticateJWT logic
|
||||
// This ensures we use the same robust validation (STS vs OIDC, signature verification, etc.)
|
||||
// as standard requests, preventing security regressions.
|
||||
authReq := r.Clone(ctx)
|
||||
authReq.Header.Set("Authorization", "Bearer "+sessionToken)
|
||||
// Parse JWT token to extract role and session information
|
||||
tokenClaims, err := parseJWTToken(sessionToken)
|
||||
if err != nil {
|
||||
glog.V(3).Infof("Failed to parse JWT token in presigned URL: %v", err)
|
||||
return s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Authenticate the token using the centralized IAM integration
|
||||
iamIdentity, errCode := iam.iamIntegration.AuthenticateJWT(ctx, authReq)
|
||||
if errCode != s3err.ErrNone {
|
||||
glog.V(3).Infof("JWT authentication failed for presigned URL: %v", errCode)
|
||||
return errCode
|
||||
// Extract role information from token claims
|
||||
roleName, ok := tokenClaims["role"].(string)
|
||||
if !ok || roleName == "" {
|
||||
glog.V(3).Info("No role found in JWT token for presigned URL")
|
||||
return s3err.ErrAccessDenied
|
||||
}
|
||||
|
||||
sessionName, ok := tokenClaims["snam"].(string)
|
||||
if !ok || sessionName == "" {
|
||||
sessionName = "presigned-session" // Default fallback
|
||||
}
|
||||
|
||||
// Use the principal ARN directly from token claims, or build it if not available
|
||||
principalArn, ok := tokenClaims["principal"].(string)
|
||||
if !ok || principalArn == "" {
|
||||
// Fallback: extract role name from role ARN and build principal ARN
|
||||
roleNameOnly := roleName
|
||||
if strings.Contains(roleName, "/") {
|
||||
parts := strings.Split(roleName, "/")
|
||||
roleNameOnly = parts[len(parts)-1]
|
||||
}
|
||||
principalArn = fmt.Sprintf("arn:aws:sts::assumed-role/%s/%s", roleNameOnly, sessionName)
|
||||
}
|
||||
|
||||
// Create IAM identity for authorization using extracted information
|
||||
iamIdentity := &IAMIdentity{
|
||||
Name: identity.Name,
|
||||
Principal: principalArn,
|
||||
SessionToken: sessionToken,
|
||||
Account: identity.Account,
|
||||
}
|
||||
|
||||
// Authorize using IAM
|
||||
errCode = iam.iamIntegration.AuthorizeAction(ctx, iamIdentity, action, bucket, object, r)
|
||||
errCode := iam.iamIntegration.AuthorizeAction(ctx, iamIdentity, action, bucket, object, r)
|
||||
if errCode != s3err.ErrNone {
|
||||
glog.V(3).Infof("IAM authorization failed for presigned URL: principal=%s action=%s bucket=%s object=%s",
|
||||
iamIdentity.Principal, action, bucket, object)
|
||||
|
||||
@@ -521,7 +521,7 @@ func setupTestRolesForPresigned(ctx context.Context, manager *integration.IAMMan
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -557,7 +557,7 @@ func setupTestRolesForPresigned(ctx context.Context, manager *integration.IAMMan
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
@@ -575,7 +575,7 @@ func setupTestRolesForPresigned(ctx context.Context, manager *integration.IAMMan
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"Federated": "test-oidc",
|
||||
"Federated": "https://test-issuer.com",
|
||||
},
|
||||
Action: []string{"sts:AssumeRoleWithWebIdentity"},
|
||||
},
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/kms"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/cors"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
@@ -32,9 +32,9 @@ type BucketConfig struct {
|
||||
Owner string
|
||||
IsPublicRead bool // Cached flag to avoid JSON parsing on every request
|
||||
CORS *cors.CORSConfiguration
|
||||
ObjectLockConfig *ObjectLockConfiguration // Cached parsed Object Lock configuration
|
||||
BucketPolicy *policy_engine.PolicyDocument // Cached bucket policy for performance
|
||||
KMSKeyCache *BucketKMSCache // Per-bucket KMS key cache for SSE-KMS operations
|
||||
ObjectLockConfig *ObjectLockConfiguration // Cached parsed Object Lock configuration
|
||||
BucketPolicy *policy.PolicyDocument // Cached bucket policy for performance
|
||||
KMSKeyCache *BucketKMSCache // Per-bucket KMS key cache for SSE-KMS operations
|
||||
LastModified time.Time
|
||||
Entry *filer_pb.Entry
|
||||
}
|
||||
@@ -321,7 +321,7 @@ func (bcc *BucketConfigCache) RemoveNegativeCache(bucket string) {
|
||||
}
|
||||
|
||||
// loadBucketPolicyFromExtended loads and parses bucket policy from entry extended attributes
|
||||
func loadBucketPolicyFromExtended(entry *filer_pb.Entry, bucket string) *policy_engine.PolicyDocument {
|
||||
func loadBucketPolicyFromExtended(entry *filer_pb.Entry, bucket string) *policy.PolicyDocument {
|
||||
if entry.Extended == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func loadBucketPolicyFromExtended(entry *filer_pb.Entry, bucket string) *policy_
|
||||
return nil
|
||||
}
|
||||
|
||||
var policyDoc policy_engine.PolicyDocument
|
||||
var policyDoc policy.PolicyDocument
|
||||
if err := json.Unmarshal(policyJSON, &policyDoc); err != nil {
|
||||
glog.Errorf("loadBucketPolicyFromExtended: failed to parse bucket policy for %s: %v", bucket, err)
|
||||
return nil
|
||||
|
||||
@@ -638,7 +638,7 @@ func (s3a *S3ApiServer) AuthWithPublicRead(handler http.HandlerFunc, action Acti
|
||||
// Check bucket policy for anonymous access using the policy engine
|
||||
principal := "*" // Anonymous principal
|
||||
// Evaluate bucket policy (objectEntry nil - not yet fetched)
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, nil, nil)
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, nil)
|
||||
if err != nil {
|
||||
// SECURITY: Fail-close on policy evaluation errors
|
||||
// If we can't evaluate the policy, deny access rather than falling through to IAM
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestBuildPrincipalARN(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildPrincipalARN(tt.identity, nil)
|
||||
result := buildPrincipalARN(tt.identity)
|
||||
if result != tt.expected {
|
||||
t.Errorf("buildPrincipalARN() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
)
|
||||
@@ -47,17 +48,25 @@ func (bpe *BucketPolicyEngine) LoadBucketPolicy(bucket string, entry *filer_pb.E
|
||||
|
||||
// LoadBucketPolicyFromCache loads a bucket policy from a cached BucketConfig
|
||||
//
|
||||
// This function loads the policy directly into the engine
|
||||
func (bpe *BucketPolicyEngine) LoadBucketPolicyFromCache(bucket string, policyDoc *policy_engine.PolicyDocument) error {
|
||||
// This function uses a type-safe conversion function to convert between
|
||||
// policy.PolicyDocument and policy_engine.PolicyDocument with explicit field mapping and error handling.
|
||||
func (bpe *BucketPolicyEngine) LoadBucketPolicyFromCache(bucket string, policyDoc *policy.PolicyDocument) error {
|
||||
if policyDoc == nil {
|
||||
// No policy for this bucket - remove it if it exists
|
||||
bpe.engine.DeleteBucketPolicy(bucket)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Policy is already in correct format, just load it
|
||||
// We need to re-marshal to string because SetBucketPolicy expects JSON string
|
||||
policyJSON, err := json.Marshal(policyDoc)
|
||||
// Convert policy.PolicyDocument to policy_engine.PolicyDocument without a JSON round-trip
|
||||
// This removes the prior intermediate marshal/unmarshal and adds type safety
|
||||
enginePolicyDoc, err := ConvertPolicyDocumentToPolicyEngine(policyDoc)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to convert bucket policy for %s: %v", bucket, err)
|
||||
return fmt.Errorf("failed to convert bucket policy: %w", err)
|
||||
}
|
||||
|
||||
// Marshal the converted policy to JSON for storage in the engine
|
||||
policyJSON, err := json.Marshal(enginePolicyDoc)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to marshal bucket policy for %s: %v", bucket, err)
|
||||
return err
|
||||
@@ -98,7 +107,7 @@ func (bpe *BucketPolicyEngine) HasPolicyForBucket(bucket string) bool {
|
||||
// - allowed: whether the policy allows the action
|
||||
// - evaluated: whether a policy was found and evaluated (false = no policy exists)
|
||||
// - error: any error during evaluation
|
||||
func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal string, r *http.Request, claims map[string]interface{}, objectEntry map[string][]byte) (allowed bool, evaluated bool, err error) {
|
||||
func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal string, r *http.Request, objectEntry map[string][]byte) (allowed bool, evaluated bool, err error) {
|
||||
// Validate required parameters
|
||||
if bucket == "" {
|
||||
return false, false, fmt.Errorf("bucket cannot be empty")
|
||||
@@ -125,41 +134,18 @@ func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal
|
||||
ObjectEntry: objectEntry,
|
||||
}
|
||||
|
||||
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: bucket=%s, resource=%s, action=%s, principal=%s",
|
||||
// bucket, resource, s3Action, principal)
|
||||
|
||||
// Extract conditions and claims from request if available
|
||||
if r != nil {
|
||||
args.Conditions = policy_engine.ExtractConditionValuesFromRequest(r)
|
||||
|
||||
// Extract principal-related variables (aws:username, etc.) from principal ARN
|
||||
principalVars := policy_engine.ExtractPrincipalVariables(principal)
|
||||
for k, v := range principalVars {
|
||||
args.Conditions[k] = v
|
||||
}
|
||||
|
||||
// Extract JWT claims if authenticated via JWT or STS
|
||||
if claims != nil {
|
||||
args.Claims = claims
|
||||
} else {
|
||||
// If claims were not provided directly, try to get them from context Identity?
|
||||
// But the caller is responsible for passing them.
|
||||
// Falling back to empty claims if not provided.
|
||||
}
|
||||
}
|
||||
|
||||
result := bpe.engine.EvaluatePolicy(bucket, args)
|
||||
|
||||
switch result {
|
||||
case policy_engine.PolicyResultAllow:
|
||||
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: ALLOW - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
|
||||
glog.V(3).Infof("EvaluatePolicy: ALLOW - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
|
||||
return true, true, nil
|
||||
case policy_engine.PolicyResultDeny:
|
||||
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: DENY - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
|
||||
glog.V(3).Infof("EvaluatePolicy: DENY - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
|
||||
return false, true, nil
|
||||
case policy_engine.PolicyResultIndeterminate:
|
||||
// No policy exists for this bucket
|
||||
// glog.V(4).Infof("EvaluatePolicy [Wrapper]: INDETERMINATE (no policy) - bucket=%s", bucket)
|
||||
glog.V(4).Infof("EvaluatePolicy: INDETERMINATE (no policy) - bucket=%s", bucket)
|
||||
return false, false, nil
|
||||
default:
|
||||
return false, false, fmt.Errorf("unknown policy result: %v", result)
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
@@ -82,16 +82,16 @@ func (s3a *S3ApiServer) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Re
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse and validate policy document
|
||||
var policyDoc policy_engine.PolicyDocument
|
||||
var policyDoc policy.PolicyDocument
|
||||
if err := json.Unmarshal(body, &policyDoc); err != nil {
|
||||
glog.Errorf("Failed to parse bucket policy JSON: %v", err)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPolicy)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate core policy structure (Effect, Action, etc.)
|
||||
if err := policy_engine.ValidatePolicy(&policyDoc); err != nil {
|
||||
glog.Errorf("Policy validation failed: %v", err)
|
||||
// Validate policy document structure
|
||||
if err := policy.ValidatePolicyDocument(&policyDoc); err != nil {
|
||||
glog.Errorf("Invalid bucket policy document: %v", err)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPolicyDocument)
|
||||
return
|
||||
}
|
||||
@@ -190,10 +190,9 @@ func (s3a *S3ApiServer) DeleteBucketPolicyHandler(w http.ResponseWriter, r *http
|
||||
// Helper functions for bucket policy storage and retrieval
|
||||
|
||||
// getBucketPolicy retrieves a bucket policy from filer metadata
|
||||
// getBucketPolicy retrieves the bucket policy from filer
|
||||
func (s3a *S3ApiServer) getBucketPolicy(bucket string) (*policy_engine.PolicyDocument, error) {
|
||||
func (s3a *S3ApiServer) getBucketPolicy(bucket string) (*policy.PolicyDocument, error) {
|
||||
|
||||
var policyDoc policy_engine.PolicyDocument
|
||||
var policyDoc policy.PolicyDocument
|
||||
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: s3a.option.BucketsPath,
|
||||
@@ -228,7 +227,7 @@ func (s3a *S3ApiServer) getBucketPolicy(bucket string) (*policy_engine.PolicyDoc
|
||||
}
|
||||
|
||||
// setBucketPolicy stores a bucket policy in filer metadata
|
||||
func (s3a *S3ApiServer) setBucketPolicy(bucket string, policyDoc *policy_engine.PolicyDocument) error {
|
||||
func (s3a *S3ApiServer) setBucketPolicy(bucket string, policyDoc *policy.PolicyDocument) error {
|
||||
// Serialize policy to JSON
|
||||
policyJSON, err := json.Marshal(policyDoc)
|
||||
if err != nil {
|
||||
@@ -294,7 +293,7 @@ func (s3a *S3ApiServer) deleteBucketPolicy(bucket string) error {
|
||||
}
|
||||
|
||||
// validateBucketPolicy performs bucket-specific policy validation
|
||||
func (s3a *S3ApiServer) validateBucketPolicy(policyDoc *policy_engine.PolicyDocument, bucket string) error {
|
||||
func (s3a *S3ApiServer) validateBucketPolicy(policyDoc *policy.PolicyDocument, bucket string) error {
|
||||
if policyDoc.Version != "2012-10-17" {
|
||||
return fmt.Errorf("unsupported policy version: %s (must be 2012-10-17)", policyDoc.Version)
|
||||
}
|
||||
@@ -310,21 +309,14 @@ func (s3a *S3ApiServer) validateBucketPolicy(policyDoc *policy_engine.PolicyDocu
|
||||
}
|
||||
|
||||
// Validate resources refer to this bucket
|
||||
for _, resource := range statement.Resource.Strings() {
|
||||
for _, resource := range statement.Resource {
|
||||
if !s3a.validateResourceForBucket(resource, bucket) {
|
||||
return fmt.Errorf("statement %d: resource %s does not match bucket %s", i, resource, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate NotResources refer to this bucket
|
||||
for _, notResource := range statement.NotResource.Strings() {
|
||||
if !s3a.validateResourceForBucket(notResource, bucket) {
|
||||
return fmt.Errorf("statement %d: NotResource %s does not match bucket %s", i, notResource, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate actions are S3 actions
|
||||
for _, action := range statement.Action.Strings() {
|
||||
for _, action := range statement.Action {
|
||||
if !strings.HasPrefix(action, "s3:") {
|
||||
return fmt.Errorf("statement %d: bucket policies only support S3 actions, got %s", i, action)
|
||||
}
|
||||
@@ -366,23 +358,13 @@ func (s3a *S3ApiServer) validateResourceForBucket(resource, bucket string) bool
|
||||
// IAM integration functions
|
||||
|
||||
// updateBucketPolicyInIAM updates the IAM system with the new bucket policy
|
||||
func (s3a *S3ApiServer) updateBucketPolicyInIAM(bucket string, policyDoc *policy_engine.PolicyDocument) error {
|
||||
// Update IAM integration with new bucket policy
|
||||
if s3a.iam.iamIntegration != nil {
|
||||
// Type assert to access the concrete implementation which has access to iamManager
|
||||
if s3Integration, ok := s3a.iam.iamIntegration.(*S3IAMIntegration); ok {
|
||||
if s3Integration.iamManager != nil {
|
||||
glog.V(2).Infof("Updated bucket policy for %s in IAM system", bucket)
|
||||
func (s3a *S3ApiServer) updateBucketPolicyInIAM(bucket string, policyDoc *policy.PolicyDocument) error {
|
||||
// This would integrate with our advanced IAM system
|
||||
// For now, we'll just log that the policy was updated
|
||||
glog.V(2).Infof("Updated bucket policy for %s in IAM system", bucket)
|
||||
|
||||
policyJSON, err := json.Marshal(policyDoc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal policy: %w", err)
|
||||
}
|
||||
|
||||
return s3Integration.iamManager.UpdateBucketPolicy(context.Background(), bucket, policyJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Integrate with IAM manager to store resource-based policies
|
||||
// s3a.iam.iamIntegration.iamManager.SetBucketPolicy(bucket, policyDoc)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,12 +90,6 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Check bucket policy
|
||||
if errCode, _ := s3a.checkPolicyWithEntry(r, bucket, object, string(s3_constants.ACTION_WRITE), "", nil); errCode != s3err.ErrNone {
|
||||
s3err.WriteErrorResponse(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("Cache-Control") != "" {
|
||||
if _, err = cacheobject.ParseRequestCacheControl(r.Header.Get("Cache-Control")); err != nil {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidDigest)
|
||||
|
||||
+33
-88
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
. "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
@@ -129,12 +128,11 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
FilerGroup: option.FilerGroup,
|
||||
DiscoveryInterval: 5 * time.Minute,
|
||||
})
|
||||
|
||||
glog.V(1).Infof("S3 API initialized FilerClient with %d filer(s) and discovery enabled (group: %s, masters: %v)",
|
||||
glog.V(0).Infof("S3 API initialized FilerClient with %d filer(s) and discovery enabled (group: %s, masters: %v)",
|
||||
len(option.Filers), option.FilerGroup, option.Masters)
|
||||
} else {
|
||||
filerClient = wdclient.NewFilerClient(option.Filers, option.GrpcDialOption, option.DataCenter)
|
||||
glog.V(1).Infof("S3 API initialized FilerClient with %d filer(s) (no discovery)", len(option.Filers))
|
||||
glog.V(0).Infof("S3 API initialized FilerClient with %d filer(s) (no discovery)", len(option.Filers))
|
||||
}
|
||||
|
||||
// Update credential store to use FilerClient's current filer for HA
|
||||
@@ -180,7 +178,6 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to load IAM configuration: %v", err)
|
||||
} else {
|
||||
glog.V(1).Infof("IAM Manager loaded, creating integration")
|
||||
// Create S3 IAM integration with the loaded IAM manager
|
||||
// filerAddress not actually used, just for backward compatibility
|
||||
s3iam := NewS3IAMIntegration(iamManager, "")
|
||||
@@ -193,7 +190,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
|
||||
// Initialize STS HTTP handlers for AssumeRoleWithWebIdentity endpoint
|
||||
if stsService := iamManager.GetSTSService(); stsService != nil {
|
||||
s3ApiServer.stsHandlers = NewSTSHandlers(stsService, iam)
|
||||
s3ApiServer.stsHandlers = NewSTSHandlers(stsService)
|
||||
glog.V(1).Infof("STS HTTP handlers initialized for AssumeRoleWithWebIdentity")
|
||||
}
|
||||
|
||||
@@ -204,7 +201,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
// Initialize embedded IAM API if enabled
|
||||
if option.EnableIam {
|
||||
s3ApiServer.embeddedIam = NewEmbeddedIamApi(s3ApiServer.credentialManager, iam)
|
||||
glog.V(1).Infof("Embedded IAM API initialized (use -iam=false to disable)")
|
||||
glog.V(0).Infof("Embedded IAM API initialized (use -iam=false to disable)")
|
||||
}
|
||||
|
||||
if option.Config != "" {
|
||||
@@ -264,7 +261,7 @@ func (s3a *S3ApiServer) getFilerAddress() pb.ServerAddress {
|
||||
// syncBucketPolicyToEngine syncs a bucket policy to the policy engine
|
||||
// This helper method centralizes the logic for loading bucket policies into the engine
|
||||
// to avoid duplication and ensure consistent error handling
|
||||
func (s3a *S3ApiServer) syncBucketPolicyToEngine(bucket string, policyDoc *policy_engine.PolicyDocument) {
|
||||
func (s3a *S3ApiServer) syncBucketPolicyToEngine(bucket string, policyDoc *policy.PolicyDocument) {
|
||||
if s3a.policyEngine == nil {
|
||||
return
|
||||
}
|
||||
@@ -292,30 +289,11 @@ func (s3a *S3ApiServer) checkPolicyWithEntry(r *http.Request, bucket, object, ac
|
||||
}
|
||||
|
||||
// Skip if no policy for this bucket
|
||||
hasPolicy := s3a.policyEngine.HasPolicyForBucket(bucket)
|
||||
// glog.V(4).Infof("checkPolicyWithEntry: bucket=%s hasPolicy=%v", bucket, hasPolicy)
|
||||
if !hasPolicy {
|
||||
if !s3a.policyEngine.HasPolicyForBucket(bucket) {
|
||||
return s3err.ErrNone, false
|
||||
}
|
||||
|
||||
identityRaw := GetIdentityFromContext(r)
|
||||
var identity *Identity
|
||||
if identityRaw != nil {
|
||||
if id, ok := identityRaw.(*Identity); ok {
|
||||
identity = id
|
||||
}
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if identity != nil {
|
||||
claims = identity.Claims
|
||||
}
|
||||
|
||||
if principal == "" {
|
||||
principal = buildPrincipalARN(identity, r)
|
||||
}
|
||||
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, action, principal, r, claims, objectEntry)
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, action, principal, r, objectEntry)
|
||||
if err != nil {
|
||||
glog.Errorf("checkPolicyWithEntry: error evaluating policy for %s/%s: %v", bucket, object, err)
|
||||
return s3err.ErrInternalError, true
|
||||
@@ -349,7 +327,7 @@ func (s3a *S3ApiServer) recheckPolicyWithObjectEntry(r *http.Request, bucket, ob
|
||||
return s3err.ErrInternalError
|
||||
}
|
||||
}
|
||||
principal := buildPrincipalARN(identity, r)
|
||||
principal := buildPrincipalARN(identity)
|
||||
errCode, _ := s3a.checkPolicyWithEntry(r, bucket, object, action, principal, objectEntry)
|
||||
return errCode
|
||||
}
|
||||
@@ -415,9 +393,6 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
apiRouter.Methods(http.MethodGet).Path("/status").HandlerFunc(s3a.StatusHandler)
|
||||
apiRouter.Methods(http.MethodGet).Path("/healthz").HandlerFunc(s3a.StatusHandler)
|
||||
|
||||
// Object path pattern with (?s) flag to match newlines in object keys
|
||||
const objectPath = "/{object:(?s).+}"
|
||||
|
||||
var routers []*mux.Router
|
||||
if s3a.option.DomainName != "" {
|
||||
domainNames := strings.Split(s3a.option.DomainName, ",")
|
||||
@@ -456,63 +431,63 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
// objects with query
|
||||
|
||||
// CopyObjectPart
|
||||
bucket.Methods(http.MethodPut).Path(objectPath).HeadersRegexp("X-Amz-Copy-Source", `.*?(\/|%2F).*?`).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.CopyObjectPartHandler, ACTION_WRITE)), "PUT")).Queries("partNumber", "{partNumber:[0-9]+}", "uploadId", "{uploadId:.*}")
|
||||
bucket.Methods(http.MethodPut).Path("/{object:.+}").HeadersRegexp("X-Amz-Copy-Source", `.*?(\/|%2F).*?`).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.CopyObjectPartHandler, ACTION_WRITE)), "PUT")).Queries("partNumber", "{partNumber:[0-9]+}", "uploadId", "{uploadId:.*}")
|
||||
// PutObjectPart
|
||||
bucket.Methods(http.MethodPut).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectPartHandler, ACTION_WRITE)), "PUT")).Queries("partNumber", "{partNumber:[0-9]+}", "uploadId", "{uploadId:.*}")
|
||||
bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectPartHandler, ACTION_WRITE)), "PUT")).Queries("partNumber", "{partNumber:[0-9]+}", "uploadId", "{uploadId:.*}")
|
||||
// CompleteMultipartUpload
|
||||
bucket.Methods(http.MethodPost).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.CompleteMultipartUploadHandler, ACTION_WRITE)), "POST")).Queries("uploadId", "{uploadId:.*}")
|
||||
bucket.Methods(http.MethodPost).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.CompleteMultipartUploadHandler, ACTION_WRITE)), "POST")).Queries("uploadId", "{uploadId:.*}")
|
||||
// NewMultipartUpload
|
||||
bucket.Methods(http.MethodPost).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.NewMultipartUploadHandler, ACTION_WRITE)), "POST")).Queries("uploads", "")
|
||||
bucket.Methods(http.MethodPost).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.NewMultipartUploadHandler, ACTION_WRITE)), "POST")).Queries("uploads", "")
|
||||
// AbortMultipartUpload
|
||||
bucket.Methods(http.MethodDelete).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.AbortMultipartUploadHandler, ACTION_WRITE)), "DELETE")).Queries("uploadId", "{uploadId:.*}")
|
||||
bucket.Methods(http.MethodDelete).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.AbortMultipartUploadHandler, ACTION_WRITE)), "DELETE")).Queries("uploadId", "{uploadId:.*}")
|
||||
// ListObjectParts
|
||||
bucket.Methods(http.MethodGet).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.ListObjectPartsHandler, ACTION_READ)), "GET")).Queries("uploadId", "{uploadId:.*}")
|
||||
bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.ListObjectPartsHandler, ACTION_READ)), "GET")).Queries("uploadId", "{uploadId:.*}")
|
||||
// ListMultipartUploads
|
||||
bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.ListMultipartUploadsHandler, ACTION_READ)), "GET")).Queries("uploads", "")
|
||||
|
||||
// GetObjectTagging
|
||||
bucket.Methods(http.MethodGet).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectTaggingHandler, ACTION_READ)), "GET")).Queries("tagging", "")
|
||||
bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectTaggingHandler, ACTION_READ)), "GET")).Queries("tagging", "")
|
||||
// PutObjectTagging
|
||||
bucket.Methods(http.MethodPut).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectTaggingHandler, ACTION_TAGGING)), "PUT")).Queries("tagging", "")
|
||||
bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectTaggingHandler, ACTION_TAGGING)), "PUT")).Queries("tagging", "")
|
||||
// DeleteObjectTagging
|
||||
bucket.Methods(http.MethodDelete).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.DeleteObjectTaggingHandler, ACTION_TAGGING)), "DELETE")).Queries("tagging", "")
|
||||
bucket.Methods(http.MethodDelete).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.DeleteObjectTaggingHandler, ACTION_TAGGING)), "DELETE")).Queries("tagging", "")
|
||||
|
||||
// PutObjectACL
|
||||
bucket.Methods(http.MethodPut).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectAclHandler, ACTION_WRITE_ACP)), "PUT")).Queries("acl", "")
|
||||
bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectAclHandler, ACTION_WRITE_ACP)), "PUT")).Queries("acl", "")
|
||||
// PutObjectRetention
|
||||
bucket.Methods(http.MethodPut).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectRetentionHandler, ACTION_WRITE)), "PUT")).Queries("retention", "")
|
||||
bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectRetentionHandler, ACTION_WRITE)), "PUT")).Queries("retention", "")
|
||||
// PutObjectLegalHold
|
||||
bucket.Methods(http.MethodPut).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLegalHoldHandler, ACTION_WRITE)), "PUT")).Queries("legal-hold", "")
|
||||
bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLegalHoldHandler, ACTION_WRITE)), "PUT")).Queries("legal-hold", "")
|
||||
|
||||
// GetObjectACL
|
||||
bucket.Methods(http.MethodGet).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectAclHandler, ACTION_READ_ACP)), "GET")).Queries("acl", "")
|
||||
bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectAclHandler, ACTION_READ_ACP)), "GET")).Queries("acl", "")
|
||||
// GetObjectRetention
|
||||
bucket.Methods(http.MethodGet).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectRetentionHandler, ACTION_READ)), "GET")).Queries("retention", "")
|
||||
bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectRetentionHandler, ACTION_READ)), "GET")).Queries("retention", "")
|
||||
// GetObjectLegalHold
|
||||
bucket.Methods(http.MethodGet).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectLegalHoldHandler, ACTION_READ)), "GET")).Queries("legal-hold", "")
|
||||
bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectLegalHoldHandler, ACTION_READ)), "GET")).Queries("legal-hold", "")
|
||||
|
||||
// objects with query
|
||||
|
||||
// raw objects
|
||||
|
||||
// HeadObject
|
||||
bucket.Methods(http.MethodHead).Path(objectPath).HandlerFunc(track(s3a.AuthWithPublicRead(func(w http.ResponseWriter, r *http.Request) {
|
||||
bucket.Methods(http.MethodHead).Path("/{object:.+}").HandlerFunc(track(s3a.AuthWithPublicRead(func(w http.ResponseWriter, r *http.Request) {
|
||||
limitedHandler, _ := s3a.cb.Limit(s3a.HeadObjectHandler, ACTION_READ)
|
||||
limitedHandler(w, r)
|
||||
}, ACTION_READ), "GET"))
|
||||
|
||||
// GetObject, but directory listing is not supported
|
||||
bucket.Methods(http.MethodGet).Path(objectPath).HandlerFunc(track(s3a.AuthWithPublicRead(func(w http.ResponseWriter, r *http.Request) {
|
||||
bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.AuthWithPublicRead(func(w http.ResponseWriter, r *http.Request) {
|
||||
limitedHandler, _ := s3a.cb.Limit(s3a.GetObjectHandler, ACTION_READ)
|
||||
limitedHandler(w, r)
|
||||
}, ACTION_READ), "GET"))
|
||||
|
||||
// CopyObject
|
||||
bucket.Methods(http.MethodPut).Path(objectPath).HeadersRegexp("X-Amz-Copy-Source", ".*?(\\/|%2F).*?").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.CopyObjectHandler, ACTION_WRITE)), "COPY"))
|
||||
bucket.Methods(http.MethodPut).Path("/{object:.+}").HeadersRegexp("X-Amz-Copy-Source", ".*?(\\/|%2F).*?").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.CopyObjectHandler, ACTION_WRITE)), "COPY"))
|
||||
// PutObject
|
||||
bucket.Methods(http.MethodPut).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectHandler, ACTION_WRITE)), "PUT"))
|
||||
bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectHandler, ACTION_WRITE)), "PUT"))
|
||||
// DeleteObject
|
||||
bucket.Methods(http.MethodDelete).Path(objectPath).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.DeleteObjectHandler, ACTION_WRITE)), "DELETE"))
|
||||
bucket.Methods(http.MethodDelete).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.DeleteObjectHandler, ACTION_WRITE)), "DELETE"))
|
||||
|
||||
// raw objects
|
||||
|
||||
@@ -647,16 +622,7 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
// 1. Explicit query param match (highest priority)
|
||||
apiRouter.Methods(http.MethodPost).Path("/").Queries("Action", "AssumeRoleWithWebIdentity").
|
||||
HandlerFunc(track(s3a.stsHandlers.HandleSTSRequest, "STS"))
|
||||
|
||||
// AssumeRole - requires SigV4 authentication
|
||||
apiRouter.Methods(http.MethodPost).Path("/").Queries("Action", "AssumeRole").
|
||||
HandlerFunc(track(s3a.stsHandlers.HandleSTSRequest, "STS-AssumeRole"))
|
||||
|
||||
// AssumeRoleWithLDAPIdentity - uses LDAP credentials
|
||||
apiRouter.Methods(http.MethodPost).Path("/").Queries("Action", "AssumeRoleWithLDAPIdentity").
|
||||
HandlerFunc(track(s3a.stsHandlers.HandleSTSRequest, "STS-LDAP"))
|
||||
|
||||
glog.V(1).Infof("STS API enabled on S3 port (AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithLDAPIdentity)")
|
||||
glog.V(0).Infof("STS API enabled on S3 port (AssumeRoleWithWebIdentity)")
|
||||
}
|
||||
|
||||
// Embedded IAM API endpoint
|
||||
@@ -665,36 +631,15 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
if s3a.embeddedIam != nil {
|
||||
// 2. Authenticated IAM requests
|
||||
// Only match if the request appears to be authenticated (AWS Signature)
|
||||
// AND is not an STS request (which should be handled by STS handlers)
|
||||
// This prevents unauthenticated STS requests (like AssumeRoleWithWebIdentity in body)
|
||||
// from being captured by the IAM handler which would reject them.
|
||||
iamMatcher := func(r *http.Request, rm *mux.RouteMatch) bool {
|
||||
if getRequestAuthType(r) == authTypeAnonymous {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check Action parameter in both form data and query string
|
||||
// We iterate ParseForm but ignore errors to ensure we attempt to parse the body
|
||||
// even if it's malformed, then check FormValue which covers both body and query.
|
||||
// This guards against misrouting STS requests if the body is invalid.
|
||||
r.ParseForm()
|
||||
action := r.FormValue("Action")
|
||||
|
||||
// If FormValue yielded nothing (possibly due to ParseForm failure failing to populate Form),
|
||||
// explicitly fallback to Query string to be safe.
|
||||
if action == "" {
|
||||
action = r.URL.Query().Get("Action")
|
||||
}
|
||||
|
||||
// Exclude STS actions - let them be handled by STS handlers
|
||||
if action == "AssumeRole" || action == "AssumeRoleWithWebIdentity" || action == "AssumeRoleWithLDAPIdentity" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return getRequestAuthType(r) != authTypeAnonymous
|
||||
}
|
||||
|
||||
apiRouter.Methods(http.MethodPost).Path("/").MatcherFunc(iamMatcher).
|
||||
HandlerFunc(track(s3a.embeddedIam.AuthIam(s3a.cb.Limit(s3a.embeddedIam.DoActions, ACTION_WRITE)), "IAM"))
|
||||
glog.V(1).Infof("Embedded IAM API enabled on S3 port")
|
||||
glog.V(0).Infof("Embedded IAM API enabled on S3 port")
|
||||
}
|
||||
|
||||
// 3. Fallback STS handler (lowest priority)
|
||||
|
||||
@@ -150,8 +150,8 @@ func TestRouting_IAMMatcherLogic(t *testing.T) {
|
||||
name: "AWS4 signature with STS action in body",
|
||||
authHeader: "AWS4-HMAC-SHA256 Credential=AKIA.../...",
|
||||
queryParams: "",
|
||||
expectsIAM: false,
|
||||
description: "Authenticated STS action should route to STS handler (STS handlers handle their own auth)",
|
||||
expectsIAM: true,
|
||||
description: "Authenticated STS action should still route to IAM (auth takes precedence)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+25
-404
@@ -5,8 +5,6 @@ package s3api
|
||||
// AWS SDKs to obtain temporary credentials using OIDC/JWT tokens.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -15,9 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/ldap"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
@@ -32,61 +28,18 @@ const (
|
||||
stsDurationSeconds = "DurationSeconds"
|
||||
|
||||
// STS Action names
|
||||
actionAssumeRole = "AssumeRole"
|
||||
actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity"
|
||||
actionAssumeRoleWithLDAPIdentity = "AssumeRoleWithLDAPIdentity"
|
||||
|
||||
// LDAP parameter names
|
||||
stsLDAPUsername = "LDAPUsername"
|
||||
stsLDAPPassword = "LDAPPassword"
|
||||
stsLDAPProviderName = "LDAPProviderName"
|
||||
actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity"
|
||||
)
|
||||
|
||||
// STS duration constants (AWS specification)
|
||||
const (
|
||||
minDurationSeconds = int64(900) // 15 minutes
|
||||
maxDurationSeconds = int64(43200) // 12 hours
|
||||
|
||||
// Default account ID for federated users
|
||||
defaultAccountId = "111122223333"
|
||||
)
|
||||
|
||||
// parseDurationSeconds parses and validates the DurationSeconds parameter
|
||||
// Returns nil if the parameter is not provided, or a pointer to the parsed value
|
||||
func parseDurationSeconds(r *http.Request) (*int64, STSErrorCode, error) {
|
||||
dsStr := r.FormValue("DurationSeconds")
|
||||
if dsStr == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
ds, err := strconv.ParseInt(dsStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, STSErrInvalidParameterValue, fmt.Errorf("invalid DurationSeconds: %w", err)
|
||||
}
|
||||
|
||||
if ds < minDurationSeconds || ds > maxDurationSeconds {
|
||||
return nil, STSErrInvalidParameterValue,
|
||||
fmt.Errorf("DurationSeconds must be between %d and %d seconds", minDurationSeconds, maxDurationSeconds)
|
||||
}
|
||||
|
||||
return &ds, "", nil
|
||||
}
|
||||
|
||||
// Removed generateSecureCredentials - now using STS service's JWT token generation
|
||||
// The STS service generates proper JWT tokens with embedded claims that can be validated
|
||||
// across distributed instances without shared state.
|
||||
|
||||
// STSHandlers provides HTTP handlers for STS operations
|
||||
type STSHandlers struct {
|
||||
stsService *sts.STSService
|
||||
iam *IdentityAccessManagement
|
||||
}
|
||||
|
||||
// NewSTSHandlers creates a new STSHandlers instance
|
||||
func NewSTSHandlers(stsService *sts.STSService, iam *IdentityAccessManagement) *STSHandlers {
|
||||
func NewSTSHandlers(stsService *sts.STSService) *STSHandlers {
|
||||
return &STSHandlers{
|
||||
stsService: stsService,
|
||||
iam: iam,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +62,8 @@ func (h *STSHandlers) HandleSTSRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// Route based on action
|
||||
action := r.Form.Get(stsAction)
|
||||
switch action {
|
||||
case actionAssumeRole:
|
||||
h.handleAssumeRole(w, r)
|
||||
case actionAssumeRoleWithWebIdentity:
|
||||
h.handleAssumeRoleWithWebIdentity(w, r)
|
||||
case actionAssumeRoleWithLDAPIdentity:
|
||||
h.handleAssumeRoleWithLDAPIdentity(w, r)
|
||||
default:
|
||||
h.writeSTSErrorResponse(w, r, STSErrInvalidAction,
|
||||
fmt.Errorf("unsupported action: %s", action))
|
||||
@@ -149,11 +98,29 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate DurationSeconds using helper
|
||||
durationSeconds, errCode, err := parseDurationSeconds(r)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, errCode, err)
|
||||
return
|
||||
// Parse and validate DurationSeconds
|
||||
var durationSeconds *int64
|
||||
if dsStr := r.FormValue("DurationSeconds"); dsStr != "" {
|
||||
ds, err := strconv.ParseInt(dsStr, 10, 64)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
|
||||
fmt.Errorf("invalid DurationSeconds: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce AWS STS-compatible duration range for AssumeRoleWithWebIdentity
|
||||
// AWS allows 900 seconds (15 minutes) to 43200 seconds (12 hours)
|
||||
const (
|
||||
minDurationSeconds = int64(900)
|
||||
maxDurationSeconds = int64(43200)
|
||||
)
|
||||
if ds < minDurationSeconds || ds > maxDurationSeconds {
|
||||
h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue,
|
||||
fmt.Errorf("DurationSeconds must be between %d and %d seconds", minDurationSeconds, maxDurationSeconds))
|
||||
return
|
||||
}
|
||||
|
||||
durationSeconds = &ds
|
||||
}
|
||||
|
||||
// Check if STS service is initialized
|
||||
@@ -212,322 +179,6 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r *
|
||||
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
||||
}
|
||||
|
||||
// handleAssumeRole handles the AssumeRole API action
|
||||
// This requires AWS Signature V4 authentication
|
||||
func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract parameters from form
|
||||
roleArn := r.FormValue("RoleArn")
|
||||
roleSessionName := r.FormValue("RoleSessionName")
|
||||
|
||||
// Validate required parameters
|
||||
if roleArn == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleArn is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if roleSessionName == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleSessionName is required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate DurationSeconds using helper
|
||||
durationSeconds, errCode, err := parseDurationSeconds(r)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, errCode, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if STS service is initialized
|
||||
if h.stsService == nil || !h.stsService.IsInitialized() {
|
||||
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
||||
fmt.Errorf("STS service not initialized"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if IAM is available for SigV4 verification
|
||||
if h.iam == nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
||||
fmt.Errorf("IAM not configured for STS"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate AWS SigV4 authentication
|
||||
identity, _, _, _, sigErrCode := h.iam.verifyV4Signature(r, false)
|
||||
if sigErrCode != s3err.ErrNone {
|
||||
glog.V(2).Infof("AssumeRole SigV4 verification failed: %v", sigErrCode)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("invalid AWS signature: %v", sigErrCode))
|
||||
return
|
||||
}
|
||||
|
||||
if identity == nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("unable to identify caller"))
|
||||
return
|
||||
}
|
||||
|
||||
glog.V(2).Infof("AssumeRole: caller identity=%s, roleArn=%s, sessionName=%s",
|
||||
identity.Name, roleArn, roleSessionName)
|
||||
|
||||
// Check if the caller is authorized to assume the role (sts:AssumeRole permission)
|
||||
// This validates that the caller has a policy allowing sts:AssumeRole on the target role
|
||||
if authErr := h.iam.VerifyActionPermission(r, identity, Action("sts:AssumeRole"), "", roleArn); authErr != s3err.ErrNone {
|
||||
glog.V(2).Infof("AssumeRole: caller %s is not authorized to assume role %s", identity.Name, roleArn)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("user %s is not authorized to assume role %s", identity.Name, roleArn))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that the target role trusts the caller (Trust Policy)
|
||||
// This ensures the role's trust policy explicitly allows the principal to assume it
|
||||
if err := h.iam.ValidateTrustPolicyForPrincipal(r.Context(), roleArn, identity.PrincipalArn); err != nil {
|
||||
glog.V(2).Infof("AssumeRole: trust policy validation failed for %s to assume %s: %v", identity.Name, roleArn, err)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("trust policy denies access"))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate common STS components
|
||||
stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, identity.PrincipalArn, durationSeconds, nil)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build and return response
|
||||
xmlResponse := &AssumeRoleResponse{
|
||||
Result: AssumeRoleResult{
|
||||
Credentials: stsCreds,
|
||||
AssumedRoleUser: assumedUser,
|
||||
},
|
||||
}
|
||||
xmlResponse.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
||||
}
|
||||
|
||||
// handleAssumeRoleWithLDAPIdentity handles the AssumeRoleWithLDAPIdentity API action
|
||||
func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract parameters from form
|
||||
roleArn := r.FormValue("RoleArn")
|
||||
roleSessionName := r.FormValue("RoleSessionName")
|
||||
ldapUsername := r.FormValue(stsLDAPUsername)
|
||||
ldapPassword := r.FormValue(stsLDAPPassword)
|
||||
|
||||
// Validate required parameters
|
||||
if roleArn == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleArn is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if roleSessionName == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("RoleSessionName is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if ldapUsername == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("LDAPUsername is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if ldapPassword == "" {
|
||||
h.writeSTSErrorResponse(w, r, STSErrMissingParameter,
|
||||
fmt.Errorf("LDAPPassword is required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and validate DurationSeconds using helper
|
||||
durationSeconds, errCode, err := parseDurationSeconds(r)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, errCode, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if STS service is initialized
|
||||
if h.stsService == nil || !h.stsService.IsInitialized() {
|
||||
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
||||
fmt.Errorf("STS service not initialized"))
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: specific LDAP provider name
|
||||
ldapProviderName := r.FormValue(stsLDAPProviderName)
|
||||
|
||||
// Find an LDAP provider from the registered providers
|
||||
var ldapProvider *ldap.LDAPProvider
|
||||
ldapProvidersFound := 0
|
||||
for _, provider := range h.stsService.GetProviders() {
|
||||
// Check if this is an LDAP provider by type assertion
|
||||
if p, ok := provider.(*ldap.LDAPProvider); ok {
|
||||
if ldapProviderName != "" && p.Name() == ldapProviderName {
|
||||
ldapProvider = p
|
||||
break
|
||||
} else if ldapProviderName == "" && ldapProvider == nil {
|
||||
ldapProvider = p
|
||||
}
|
||||
ldapProvidersFound++
|
||||
}
|
||||
}
|
||||
|
||||
if ldapProvidersFound > 1 && ldapProviderName == "" {
|
||||
glog.Warningf("Multiple LDAP providers found (%d). Using the first one found (non-deterministic). Consider specifying LDAPProviderName.", ldapProvidersFound)
|
||||
}
|
||||
|
||||
if ldapProvider == nil {
|
||||
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: no LDAP provider configured")
|
||||
h.writeSTSErrorResponse(w, r, STSErrSTSNotReady,
|
||||
fmt.Errorf("no LDAP provider configured - please add an LDAP provider to IAM configuration"))
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate with LDAP provider
|
||||
// The provider expects credentials in "username:password" format
|
||||
credentials := ldapUsername + ":" + ldapPassword
|
||||
identity, err := ldapProvider.Authenticate(r.Context(), credentials)
|
||||
if err != nil {
|
||||
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: LDAP authentication failed for user %s: %v", ldapUsername, err)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied,
|
||||
fmt.Errorf("authentication failed"))
|
||||
return
|
||||
}
|
||||
|
||||
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: user %s authenticated successfully, groups=%v",
|
||||
ldapUsername, identity.Groups)
|
||||
|
||||
// Verify that the identity is allowed to assume the role
|
||||
// We create a temporary identity to represent the LDAP user for permission checking
|
||||
// The checking logic will verify if the role's trust policy allows this principal
|
||||
// Use configured account ID or default to "111122223333" for federated users
|
||||
accountId := defaultAccountId
|
||||
if h.stsService != nil && h.stsService.Config != nil && h.stsService.Config.AccountId != "" {
|
||||
accountId = h.stsService.Config.AccountId
|
||||
}
|
||||
|
||||
ldapUserIdentity := &Identity{
|
||||
Name: identity.UserID,
|
||||
Account: &Account{
|
||||
DisplayName: identity.DisplayName,
|
||||
EmailAddress: identity.Email,
|
||||
Id: identity.UserID,
|
||||
},
|
||||
PrincipalArn: fmt.Sprintf("arn:aws:iam::%s:user/%s", accountId, identity.UserID),
|
||||
}
|
||||
|
||||
// Verify that the identity is allowed to assume the role by checking the Trust Policy
|
||||
// The LDAP user doesn't have identity policies, so we strictly check if the Role trusts this principal.
|
||||
if err := h.iam.ValidateTrustPolicyForPrincipal(r.Context(), roleArn, ldapUserIdentity.PrincipalArn); err != nil {
|
||||
glog.V(2).Infof("AssumeRoleWithLDAPIdentity: trust policy validation failed for %s to assume %s: %v", ldapUsername, roleArn, err)
|
||||
h.writeSTSErrorResponse(w, r, STSErrAccessDenied, fmt.Errorf("trust policy denies access"))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate common STS components with LDAP-specific claims
|
||||
modifyClaims := func(claims *sts.STSSessionClaims) {
|
||||
claims.WithIdentityProvider("ldap", identity.UserID, identity.Provider)
|
||||
}
|
||||
|
||||
stsCreds, assumedUser, err := h.prepareSTSCredentials(roleArn, roleSessionName, ldapUserIdentity.PrincipalArn, durationSeconds, modifyClaims)
|
||||
if err != nil {
|
||||
h.writeSTSErrorResponse(w, r, STSErrInternalError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build and return response
|
||||
xmlResponse := &AssumeRoleWithLDAPIdentityResponse{
|
||||
Result: LDAPIdentityResult{
|
||||
Credentials: stsCreds,
|
||||
AssumedRoleUser: assumedUser,
|
||||
},
|
||||
}
|
||||
xmlResponse.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse)
|
||||
}
|
||||
|
||||
// prepareSTSCredentials extracts common shared logic for credential generation
|
||||
func (h *STSHandlers) prepareSTSCredentials(roleArn, roleSessionName, principalArn string,
|
||||
durationSeconds *int64, modifyClaims func(*sts.STSSessionClaims)) (STSCredentials, *AssumedRoleUser, error) {
|
||||
|
||||
// Calculate duration
|
||||
duration := time.Hour // Default 1 hour
|
||||
if durationSeconds != nil {
|
||||
duration = time.Duration(*durationSeconds) * time.Second
|
||||
}
|
||||
|
||||
// Generate session ID
|
||||
sessionId, err := sts.GenerateSessionId()
|
||||
if err != nil {
|
||||
return STSCredentials{}, nil, fmt.Errorf("failed to generate session ID: %w", err)
|
||||
}
|
||||
|
||||
expiration := time.Now().Add(duration)
|
||||
|
||||
// Extract role name from ARN for proper response formatting
|
||||
roleName := utils.ExtractRoleNameFromArn(roleArn)
|
||||
if roleName == "" {
|
||||
roleName = roleArn // Fallback to full ARN if extraction fails
|
||||
}
|
||||
|
||||
// Create session claims with role information
|
||||
claims := sts.NewSTSSessionClaims(sessionId, h.stsService.Config.Issuer, expiration).
|
||||
WithSessionName(roleSessionName).
|
||||
WithRoleInfo(roleArn, fmt.Sprintf("%s:%s", roleName, roleSessionName), principalArn)
|
||||
|
||||
// Apply custom claims if provided (e.g., LDAP identity)
|
||||
if modifyClaims != nil {
|
||||
modifyClaims(claims)
|
||||
}
|
||||
|
||||
// Generate JWT session token
|
||||
sessionToken, err := h.stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
|
||||
if err != nil {
|
||||
return STSCredentials{}, nil, fmt.Errorf("failed to generate session token: %w", err)
|
||||
}
|
||||
|
||||
// Generate temporary credentials (cryptographically secure)
|
||||
// AccessKeyId: ASIA + 16 chars hex
|
||||
// SecretAccessKey: 40 chars base64
|
||||
randBytes := make([]byte, 30) // Sufficient for both
|
||||
if _, err := rand.Read(randBytes); err != nil {
|
||||
return STSCredentials{}, nil, fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Generate AccessKeyId (ASIA + 16 upper-hex chars)
|
||||
// We use 8 bytes (16 hex chars)
|
||||
accessKeyId := "ASIA" + fmt.Sprintf("%X", randBytes[:8])
|
||||
|
||||
// Generate SecretAccessKey: 30 random bytes, base64-encoded to a 40-character string
|
||||
secretBytes := make([]byte, 30)
|
||||
if _, err := rand.Read(secretBytes); err != nil {
|
||||
return STSCredentials{}, nil, fmt.Errorf("failed to generate secret bytes: %w", err)
|
||||
}
|
||||
secretAccessKey := base64.StdEncoding.EncodeToString(secretBytes)
|
||||
|
||||
// Get account ID from STS config or use default
|
||||
accountId := defaultAccountId
|
||||
if h.stsService != nil && h.stsService.Config != nil && h.stsService.Config.AccountId != "" {
|
||||
accountId = h.stsService.Config.AccountId
|
||||
}
|
||||
|
||||
stsCreds := STSCredentials{
|
||||
AccessKeyId: accessKeyId,
|
||||
SecretAccessKey: secretAccessKey,
|
||||
SessionToken: sessionToken,
|
||||
Expiration: expiration.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
assumedUser := &AssumedRoleUser{
|
||||
AssumedRoleId: fmt.Sprintf("%s:%s", roleName, roleSessionName),
|
||||
Arn: fmt.Sprintf("arn:aws:sts::%s:assumed-role/%s/%s", accountId, roleName, roleSessionName),
|
||||
}
|
||||
|
||||
return stsCreds, assumedUser, nil
|
||||
}
|
||||
|
||||
// STS Response types for XML marshaling
|
||||
|
||||
// AssumeRoleWithWebIdentityResponse is the response for AssumeRoleWithWebIdentity
|
||||
@@ -560,36 +211,6 @@ type AssumedRoleUser struct {
|
||||
Arn string `xml:"Arn"`
|
||||
}
|
||||
|
||||
// AssumeRoleResponse is the response for AssumeRole
|
||||
type AssumeRoleResponse struct {
|
||||
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleResponse"`
|
||||
Result AssumeRoleResult `xml:"AssumeRoleResult"`
|
||||
ResponseMetadata struct {
|
||||
RequestId string `xml:"RequestId,omitempty"`
|
||||
} `xml:"ResponseMetadata,omitempty"`
|
||||
}
|
||||
|
||||
// AssumeRoleResult contains the result of AssumeRole
|
||||
type AssumeRoleResult struct {
|
||||
Credentials STSCredentials `xml:"Credentials"`
|
||||
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
||||
}
|
||||
|
||||
// AssumeRoleWithLDAPIdentityResponse is the response for AssumeRoleWithLDAPIdentity
|
||||
type AssumeRoleWithLDAPIdentityResponse struct {
|
||||
XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithLDAPIdentityResponse"`
|
||||
Result LDAPIdentityResult `xml:"AssumeRoleWithLDAPIdentityResult"`
|
||||
ResponseMetadata struct {
|
||||
RequestId string `xml:"RequestId,omitempty"`
|
||||
} `xml:"ResponseMetadata,omitempty"`
|
||||
}
|
||||
|
||||
// LDAPIdentityResult contains the result of AssumeRoleWithLDAPIdentity
|
||||
type LDAPIdentityResult struct {
|
||||
Credentials STSCredentials `xml:"Credentials"`
|
||||
AssumedRoleUser *AssumedRoleUser `xml:"AssumedRoleUser,omitempty"`
|
||||
}
|
||||
|
||||
// STS Error types
|
||||
|
||||
// STSErrorCode represents STS error codes
|
||||
|
||||
@@ -24,8 +24,6 @@ type SeaweedFileIdClaims struct {
|
||||
// Right now, it only contains the standard claims; but this might be extended later
|
||||
// for more fine-grained permissions.
|
||||
type SeaweedFilerClaims struct {
|
||||
AllowedPrefixes []string `json:"allowed_prefixes,omitempty"`
|
||||
AllowedMethods []string `json:"allowed_methods,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -58,7 +56,7 @@ func GenJwtForFilerServer(signingKey SigningKey, expiresAfterSec int) EncodedJwt
|
||||
}
|
||||
|
||||
claims := SeaweedFilerClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{},
|
||||
jwt.RegisteredClaims{},
|
||||
}
|
||||
if expiresAfterSec > 0 {
|
||||
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(expiresAfterSec)))
|
||||
|
||||
@@ -153,12 +153,7 @@ func (fs *FilerServer) CreateEntry(ctx context.Context, req *filer_pb.CreateEntr
|
||||
}
|
||||
newEntry := filer.FromPbEntry(req.Directory, req.Entry)
|
||||
newEntry.Chunks = chunks
|
||||
// Don't apply TTL to remote entries - they're managed by remote storage
|
||||
if newEntry.Remote == nil {
|
||||
newEntry.TtlSec = so.TtlSeconds
|
||||
} else {
|
||||
newEntry.TtlSec = 0
|
||||
}
|
||||
newEntry.TtlSec = so.TtlSeconds
|
||||
|
||||
createErr := fs.filer.CreateEntry(ctx, newEntry, req.OExcl, req.IsFromOtherCluster, req.Signatures, req.SkipCheckParentDirectory, so.MaxFileNameLength)
|
||||
|
||||
@@ -190,11 +185,6 @@ func (fs *FilerServer) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntr
|
||||
newEntry := filer.FromPbEntry(req.Directory, req.Entry)
|
||||
newEntry.Chunks = chunks
|
||||
|
||||
// Don't apply TTL to remote entries - they're managed by remote storage
|
||||
if newEntry.Remote != nil {
|
||||
newEntry.TtlSec = 0
|
||||
}
|
||||
|
||||
if filer.EqualEntry(entry, newEntry) {
|
||||
return &filer_pb.UpdateEntryResponse{}, err
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
package weed_server
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
)
|
||||
|
||||
func TestFilerServer_maybeCheckJwtAuthorization_Scoped(t *testing.T) {
|
||||
signingKey := "secret"
|
||||
filerGuard := security.NewGuard(nil, signingKey, 0, signingKey, 0)
|
||||
fs := &FilerServer{
|
||||
filerGuard: filerGuard,
|
||||
}
|
||||
|
||||
// Helper to generate token
|
||||
genToken := func(allowedPrefixes []string, allowedMethods []string) string {
|
||||
claims := security.SeaweedFilerClaims{
|
||||
AllowedPrefixes: allowedPrefixes,
|
||||
AllowedMethods: allowedMethods,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
str, err := token.SignedString([]byte(signingKey))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign token: %v", err)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
method string
|
||||
path string
|
||||
isWrite bool
|
||||
expectAuthorized bool
|
||||
}{
|
||||
{
|
||||
name: "no restrictions",
|
||||
token: genToken(nil, nil),
|
||||
method: "GET",
|
||||
path: "/data/test",
|
||||
isWrite: false,
|
||||
expectAuthorized: true,
|
||||
},
|
||||
{
|
||||
name: "allowed prefix match",
|
||||
token: genToken([]string{"/data"}, nil),
|
||||
method: "GET",
|
||||
path: "/data/test",
|
||||
isWrite: false,
|
||||
expectAuthorized: true,
|
||||
},
|
||||
{
|
||||
name: "allowed prefix mismatch",
|
||||
token: genToken([]string{"/private"}, nil),
|
||||
method: "GET",
|
||||
path: "/data/test",
|
||||
isWrite: false,
|
||||
expectAuthorized: false,
|
||||
},
|
||||
{
|
||||
name: "allowed method match",
|
||||
token: genToken(nil, []string{"GET"}),
|
||||
method: "GET",
|
||||
path: "/data/test",
|
||||
isWrite: false,
|
||||
expectAuthorized: true,
|
||||
},
|
||||
{
|
||||
name: "allowed method mismatch",
|
||||
token: genToken(nil, []string{"POST"}),
|
||||
method: "GET",
|
||||
path: "/data/test",
|
||||
isWrite: false,
|
||||
expectAuthorized: false,
|
||||
},
|
||||
{
|
||||
name: "both match",
|
||||
token: genToken([]string{"/data"}, []string{"GET"}),
|
||||
method: "GET",
|
||||
path: "/data/test",
|
||||
isWrite: false,
|
||||
expectAuthorized: true,
|
||||
},
|
||||
{
|
||||
name: "prefix match, method mismatch",
|
||||
token: genToken([]string{"/data"}, []string{"POST"}),
|
||||
method: "GET",
|
||||
path: "/data/test",
|
||||
isWrite: false,
|
||||
expectAuthorized: false,
|
||||
},
|
||||
{
|
||||
name: "multiple prefixes match",
|
||||
token: genToken([]string{"/other", "/data"}, nil),
|
||||
method: "GET",
|
||||
path: "/data/test",
|
||||
isWrite: false,
|
||||
expectAuthorized: true,
|
||||
},
|
||||
{
|
||||
name: "write operation with method restriction",
|
||||
token: genToken(nil, []string{"POST", "PUT"}),
|
||||
method: "POST",
|
||||
path: "/data/upload",
|
||||
isWrite: true,
|
||||
expectAuthorized: true,
|
||||
},
|
||||
{
|
||||
name: "root path with prefix restriction",
|
||||
token: genToken([]string{"/data"}, nil),
|
||||
method: "GET",
|
||||
path: "/",
|
||||
isWrite: false,
|
||||
expectAuthorized: false,
|
||||
},
|
||||
{
|
||||
name: "exact prefix match",
|
||||
token: genToken([]string{"/data"}, nil),
|
||||
method: "GET",
|
||||
path: "/data",
|
||||
isWrite: false,
|
||||
expectAuthorized: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tt.token)
|
||||
if authorized := fs.maybeCheckJwtAuthorization(req, tt.isWrite); authorized != tt.expectAuthorized {
|
||||
t.Errorf("expected authorized=%v, got %v", tt.expectAuthorized, authorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -147,7 +148,7 @@ func (fs *FilerServer) readonlyFilerHandler(w http.ResponseWriter, r *http.Reque
|
||||
statusRecorder := stats.NewStatusResponseWriter(w)
|
||||
w = statusRecorder
|
||||
|
||||
glog.V(4).Infof("Request: %s %s", r.Method, r.URL.Path)
|
||||
os.Stdout.WriteString("Request: " + r.Method + " " + r.URL.String() + "\n")
|
||||
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
@@ -241,42 +242,9 @@ func (fs *FilerServer) maybeCheckJwtAuthorization(r *http.Request, isWrite bool)
|
||||
if !token.Valid {
|
||||
glog.V(1).Infof("jwt invalid from %s: %v", r.RemoteAddr, tokenStr)
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*security.SeaweedFilerClaims)
|
||||
if !ok {
|
||||
glog.V(1).Infof("jwt claims not of type *SeaweedFilerClaims from %s", r.RemoteAddr)
|
||||
return false
|
||||
}
|
||||
|
||||
if len(claims.AllowedPrefixes) > 0 {
|
||||
hasPrefix := false
|
||||
for _, prefix := range claims.AllowedPrefixes {
|
||||
if strings.HasPrefix(r.URL.Path, prefix) {
|
||||
hasPrefix = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPrefix {
|
||||
glog.V(1).Infof("jwt path not allowed from %s: %v", r.RemoteAddr, r.URL.Path)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if len(claims.AllowedMethods) > 0 {
|
||||
hasMethod := false
|
||||
for _, method := range claims.AllowedMethods {
|
||||
if method == r.Method {
|
||||
hasMethod = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMethod {
|
||||
glog.V(1).Infof("jwt method not allowed from %s: %v", r.RemoteAddr, r.Method)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (fs *FilerServer) filerHealthzHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+65
-370
@@ -809,48 +809,24 @@ func countShardsByRack(vid needle.VolumeId, locations []*EcNode, diskType types.
|
||||
})
|
||||
}
|
||||
|
||||
// shardsByType is a generic helper that counts data and parity shards per group
|
||||
func shardsByType(vid needle.VolumeId, locations []*EcNode, diskType types.DiskType, dataShards int, keyExtractor func(*EcNode) string) (dataPerGroup, parityPerGroup map[string][]erasure_coding.ShardId) {
|
||||
dataPerGroup = make(map[string][]erasure_coding.ShardId)
|
||||
parityPerGroup = make(map[string][]erasure_coding.ShardId)
|
||||
// shardsByTypePerRack counts data shards (< dataShards) and parity shards (>= dataShards) per rack
|
||||
func shardsByTypePerRack(vid needle.VolumeId, locations []*EcNode, diskType types.DiskType, dataShards int) (dataPerRack, parityPerRack map[string][]erasure_coding.ShardId) {
|
||||
dataPerRack = make(map[string][]erasure_coding.ShardId)
|
||||
parityPerRack = make(map[string][]erasure_coding.ShardId)
|
||||
for _, ecNode := range locations {
|
||||
si := findEcVolumeShardsInfo(ecNode, vid, diskType)
|
||||
groupKey := keyExtractor(ecNode)
|
||||
rackId := string(ecNode.rack)
|
||||
for _, shardId := range si.Ids() {
|
||||
if int(shardId) < dataShards {
|
||||
dataPerGroup[groupKey] = append(dataPerGroup[groupKey], shardId)
|
||||
dataPerRack[rackId] = append(dataPerRack[rackId], shardId)
|
||||
} else {
|
||||
parityPerGroup[groupKey] = append(parityPerGroup[groupKey], shardId)
|
||||
parityPerRack[rackId] = append(parityPerRack[rackId], shardId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// shardsByTypePerRack counts data shards (< dataShards) and parity shards (>= dataShards) per rack
|
||||
func shardsByTypePerRack(vid needle.VolumeId, locations []*EcNode, diskType types.DiskType, dataShards int) (dataPerRack, parityPerRack map[string][]erasure_coding.ShardId) {
|
||||
return shardsByType(vid, locations, diskType, dataShards, func(ecNode *EcNode) string {
|
||||
return string(ecNode.rack)
|
||||
})
|
||||
}
|
||||
|
||||
// shardsByTypePerNode counts data shards and parity shards per node
|
||||
func shardsByTypePerNode(vid needle.VolumeId, locations []*EcNode, diskType types.DiskType, dataShards int) (dataPerNode, parityPerNode map[string][]erasure_coding.ShardId) {
|
||||
return shardsByType(vid, locations, diskType, dataShards, func(ecNode *EcNode) string {
|
||||
return ecNode.info.Id
|
||||
})
|
||||
}
|
||||
|
||||
func countShardsByNode(vid needle.VolumeId, locations []*EcNode, diskType types.DiskType) map[string]int {
|
||||
return groupByCount(locations, func(ecNode *EcNode) (id string, count int) {
|
||||
id = ecNode.info.Id
|
||||
if si := findEcVolumeShardsInfo(ecNode, vid, diskType); si != nil {
|
||||
count = si.Count()
|
||||
}
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (ecb *ecBalancer) doBalanceEcShardsAcrossRacks(collection string, vid needle.VolumeId, locations []*EcNode) error {
|
||||
racks := ecb.racks()
|
||||
numRacks := len(racks)
|
||||
@@ -876,30 +852,20 @@ func (ecb *ecBalancer) doBalanceEcShardsAcrossRacks(collection string, vid needl
|
||||
rackToShardCount := countShardsByRack(vid, locations, ecb.diskType)
|
||||
|
||||
// First pass: Balance data shards across racks
|
||||
if err := ecb.balanceShardTypeAcrossRacks(collection, vid, racks, rackEcNodesWithVid, dataPerRack, rackToShardCount, maxDataPerRack, "data", nil); err != nil {
|
||||
if err := ecb.balanceShardTypeAcrossRacks(collection, vid, racks, rackEcNodesWithVid, dataPerRack, rackToShardCount, maxDataPerRack, "data"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh locations after data shard moves and get parity distribution
|
||||
locations = ecb.collectVolumeIdToEcNodes(collection)[vid]
|
||||
dataPerRack, parityPerRack := shardsByTypePerRack(vid, locations, ecb.diskType, dataShardCount)
|
||||
_, parityPerRack := shardsByTypePerRack(vid, locations, ecb.diskType, dataShardCount)
|
||||
rackEcNodesWithVid = groupBy(locations, func(ecNode *EcNode) string {
|
||||
return string(ecNode.rack)
|
||||
})
|
||||
rackToShardCount = countShardsByRack(vid, locations, ecb.diskType)
|
||||
|
||||
// Identify racks containing data shards to avoid for parity placement.
|
||||
// We call this "antiAffinityRacks" because we want parity shards to have anti-affinity
|
||||
// with racks that hold data shards, to ensure better fault tolerance.
|
||||
antiAffinityRacks := make(map[string]bool)
|
||||
for rackId, shards := range dataPerRack {
|
||||
if len(shards) > 0 {
|
||||
antiAffinityRacks[rackId] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: Balance parity shards across racks, ignoring racks with data shards if possible
|
||||
if err := ecb.balanceShardTypeAcrossRacks(collection, vid, racks, rackEcNodesWithVid, parityPerRack, rackToShardCount, maxParityPerRack, "parity", antiAffinityRacks); err != nil {
|
||||
// Second pass: Balance parity shards across racks
|
||||
if err := ecb.balanceShardTypeAcrossRacks(collection, vid, racks, rackEcNodesWithVid, parityPerRack, rackToShardCount, maxParityPerRack, "parity"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -916,7 +882,6 @@ func (ecb *ecBalancer) balanceShardTypeAcrossRacks(
|
||||
rackToShardCount map[string]int,
|
||||
maxPerRack int,
|
||||
shardType string,
|
||||
antiAffinityRacks map[string]bool,
|
||||
) error {
|
||||
// Find racks with too many shards of this type
|
||||
shardsToMove := make(map[erasure_coding.ShardId]*EcNode)
|
||||
@@ -943,7 +908,7 @@ func (ecb *ecBalancer) balanceShardTypeAcrossRacks(
|
||||
// Move shards to racks that have fewer than maxPerRack of this type
|
||||
for shardId, ecNode := range shardsToMove {
|
||||
// Find destination rack with room for this shard type
|
||||
destRackId, err := ecb.pickRackForShardType(racks, shardsPerRack, maxPerRack, rackToShardCount, antiAffinityRacks)
|
||||
destRackId, err := ecb.pickRackForShardType(racks, shardsPerRack, maxPerRack, rackToShardCount)
|
||||
if err != nil {
|
||||
fmt.Printf("ec %s shard %d.%d at %s can not find a destination rack:\n%s\n", shardType, vid, shardId, ecNode.info.Id, err.Error())
|
||||
continue
|
||||
@@ -977,145 +942,41 @@ func (ecb *ecBalancer) balanceShardTypeAcrossRacks(
|
||||
return nil
|
||||
}
|
||||
|
||||
// twoPassSelector implements two-pass selection with anti-affinity
|
||||
// Pass 1: Select from candidates NOT in antiAffinity set
|
||||
// Pass 2: Fallback to any valid candidate if Pass 1 yields no results
|
||||
type twoPassSelector[T any] struct {
|
||||
candidates []T
|
||||
shardsPerTarget map[string][]erasure_coding.ShardId
|
||||
maxPerTarget int
|
||||
targetToShardCount map[string]int
|
||||
antiAffinity map[string]bool
|
||||
|
||||
// Functions to extract info from candidate
|
||||
getKey func(T) string
|
||||
hasFreeSlots func(T) bool
|
||||
checkLimit func(T) bool // replica placement or other limits
|
||||
}
|
||||
|
||||
func (s *twoPassSelector[T]) selectCandidate() (T, error) {
|
||||
var selected []T
|
||||
minShards := s.maxPerTarget + 1
|
||||
|
||||
// Pass 1: Try candidates NOT in anti-affinity set
|
||||
for _, candidate := range s.candidates {
|
||||
if !s.hasFreeSlots(candidate) {
|
||||
continue
|
||||
}
|
||||
key := s.getKey(candidate)
|
||||
currentCount := len(s.shardsPerTarget[key])
|
||||
if currentCount >= s.maxPerTarget {
|
||||
continue
|
||||
}
|
||||
if !s.checkLimit(candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip anti-affinity targets in pass 1
|
||||
if s.antiAffinity != nil && s.antiAffinity[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentCount < minShards {
|
||||
selected = nil
|
||||
minShards = currentCount
|
||||
}
|
||||
if currentCount == minShards {
|
||||
selected = append(selected, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Fallback if no candidates found
|
||||
if len(selected) == 0 {
|
||||
minShards = s.maxPerTarget + 1
|
||||
for _, candidate := range s.candidates {
|
||||
if !s.hasFreeSlots(candidate) {
|
||||
continue
|
||||
}
|
||||
key := s.getKey(candidate)
|
||||
currentCount := len(s.shardsPerTarget[key])
|
||||
if currentCount >= s.maxPerTarget {
|
||||
continue
|
||||
}
|
||||
if !s.checkLimit(candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentCount < minShards {
|
||||
selected = nil
|
||||
minShards = currentCount
|
||||
}
|
||||
if currentCount == minShards {
|
||||
selected = append(selected, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(selected) == 0 {
|
||||
var zero T
|
||||
return zero, errors.New("no valid candidate available")
|
||||
}
|
||||
return selected[rand.IntN(len(selected))], nil
|
||||
}
|
||||
|
||||
// pickRackForShardType selects a rack that has room for more shards of a specific type
|
||||
func (ecb *ecBalancer) pickRackForShardType(
|
||||
rackToEcNodes map[RackId]*EcRack,
|
||||
shardsPerRack map[string][]erasure_coding.ShardId,
|
||||
maxPerRack int,
|
||||
rackToShardCount map[string]int,
|
||||
antiAffinityRacks map[string]bool,
|
||||
) (RackId, error) {
|
||||
// Convert map to slice for iteration
|
||||
var rackCandidates []struct {
|
||||
id RackId
|
||||
rack *EcRack
|
||||
}
|
||||
for id, rack := range rackToEcNodes {
|
||||
rackCandidates = append(rackCandidates, struct {
|
||||
id RackId
|
||||
rack *EcRack
|
||||
}{id, rack})
|
||||
var candidates []RackId
|
||||
minShards := maxPerRack + 1
|
||||
|
||||
for rackId, rack := range rackToEcNodes {
|
||||
if rack.freeEcSlot <= 0 {
|
||||
continue
|
||||
}
|
||||
currentCount := len(shardsPerRack[string(rackId)])
|
||||
if currentCount >= maxPerRack {
|
||||
continue
|
||||
}
|
||||
// For EC shards, replica placement constraint only applies when DiffRackCount > 0.
|
||||
if ecb.replicaPlacement != nil && ecb.replicaPlacement.DiffRackCount > 0 && rackToShardCount[string(rackId)] >= ecb.replicaPlacement.DiffRackCount {
|
||||
continue
|
||||
}
|
||||
if currentCount < minShards {
|
||||
candidates = nil
|
||||
minShards = currentCount
|
||||
}
|
||||
if currentCount == minShards {
|
||||
candidates = append(candidates, rackId)
|
||||
}
|
||||
}
|
||||
|
||||
selector := &twoPassSelector[struct {
|
||||
id RackId
|
||||
rack *EcRack
|
||||
}]{
|
||||
candidates: rackCandidates,
|
||||
shardsPerTarget: shardsPerRack,
|
||||
maxPerTarget: maxPerRack,
|
||||
targetToShardCount: rackToShardCount,
|
||||
antiAffinity: antiAffinityRacks,
|
||||
getKey: func(c struct {
|
||||
id RackId
|
||||
rack *EcRack
|
||||
}) string {
|
||||
return string(c.id)
|
||||
},
|
||||
hasFreeSlots: func(c struct {
|
||||
id RackId
|
||||
rack *EcRack
|
||||
}) bool {
|
||||
return c.rack.freeEcSlot > 0
|
||||
},
|
||||
checkLimit: func(c struct {
|
||||
id RackId
|
||||
rack *EcRack
|
||||
}) bool {
|
||||
// For EC shards, replica placement constraint only applies when DiffRackCount > 0.
|
||||
if ecb.replicaPlacement != nil && ecb.replicaPlacement.DiffRackCount > 0 {
|
||||
return rackToShardCount[string(c.id)] < ecb.replicaPlacement.DiffRackCount
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
selected, err := selector.selectCandidate()
|
||||
if err != nil {
|
||||
if len(candidates) == 0 {
|
||||
return "", errors.New("no rack available for shard type balancing")
|
||||
}
|
||||
return selected.id, nil
|
||||
return candidates[rand.IntN(len(candidates))], nil
|
||||
}
|
||||
|
||||
func (ecb *ecBalancer) pickRackToBalanceShardsInto(rackToEcNodes map[RackId]*EcRack, rackToShardCount map[string]int) (RackId, error) {
|
||||
@@ -1171,74 +1032,51 @@ func (ecb *ecBalancer) balanceEcShardsWithinRacks(collection string) error {
|
||||
|
||||
// see the volume's shards are in how many racks, and how many in each rack
|
||||
rackToShardCount := countShardsByRack(vid, locations, ecb.diskType)
|
||||
rackEcNodesWithVid := groupBy(locations, func(ecNode *EcNode) string {
|
||||
return string(ecNode.rack)
|
||||
})
|
||||
|
||||
for rackId, _ := range rackToShardCount {
|
||||
|
||||
for rackId := range rackToShardCount {
|
||||
var possibleDestinationEcNodes []*EcNode
|
||||
for _, n := range racks[RackId(rackId)].ecNodes {
|
||||
if _, found := n.info.DiskInfos[string(ecb.diskType)]; found {
|
||||
possibleDestinationEcNodes = append(possibleDestinationEcNodes, n)
|
||||
}
|
||||
}
|
||||
sourceEcNodes := rackEcNodesWithVid[rackId]
|
||||
averageShardsPerEcNode := ceilDivide(rackToShardCount[rackId], len(possibleDestinationEcNodes))
|
||||
ewg.Add(func() error {
|
||||
return ecb.doBalanceEcShardsWithinOneRack(collection, vid, possibleDestinationEcNodes)
|
||||
return ecb.doBalanceEcShardsWithinOneRack(averageShardsPerEcNode, collection, vid, sourceEcNodes, possibleDestinationEcNodes)
|
||||
})
|
||||
}
|
||||
}
|
||||
return ewg.Wait()
|
||||
}
|
||||
|
||||
func (ecb *ecBalancer) doBalanceEcShardsWithinOneRack(collection string, vid needle.VolumeId, possibleDestinationEcNodes []*EcNode) error {
|
||||
// Use configured EC scheme
|
||||
dataShardCount := ecb.getDataShardCount()
|
||||
func (ecb *ecBalancer) doBalanceEcShardsWithinOneRack(averageShardsPerEcNode int, collection string, vid needle.VolumeId, existingLocations, possibleDestinationEcNodes []*EcNode) error {
|
||||
for _, ecNode := range existingLocations {
|
||||
|
||||
// Get current distribution of data shards per node
|
||||
dataPerNode, parityPerNode := shardsByTypePerNode(vid, possibleDestinationEcNodes, ecb.diskType, dataShardCount)
|
||||
si := findEcVolumeShardsInfo(ecNode, vid, ecb.diskType)
|
||||
overLimitCount := si.Count() - averageShardsPerEcNode
|
||||
|
||||
// Calculate max shards per node for each type
|
||||
numNodes := len(possibleDestinationEcNodes)
|
||||
if numNodes == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, shardId := range si.Ids() {
|
||||
|
||||
// Calculate totals based on actual shards present in the rack (subset of all shards)
|
||||
totalData := 0
|
||||
for _, shards := range dataPerNode {
|
||||
totalData += len(shards)
|
||||
}
|
||||
totalParity := 0
|
||||
for _, shards := range parityPerNode {
|
||||
totalParity += len(shards)
|
||||
}
|
||||
if overLimitCount <= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
maxDataPerNode := ceilDivide(totalData, numNodes)
|
||||
maxParityPerNode := ceilDivide(totalParity, numNodes)
|
||||
fmt.Printf("%s has %d overlimit, moving ec shard %d.%d\n", ecNode.info.Id, overLimitCount, vid, shardId)
|
||||
|
||||
// Track total shard count per node
|
||||
nodeToShardCount := countShardsByNode(vid, possibleDestinationEcNodes, ecb.diskType)
|
||||
err := ecb.pickOneEcNodeAndMoveOneShard(ecNode, collection, vid, shardId, possibleDestinationEcNodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// First pass: Balance data shards across nodes
|
||||
if err := ecb.balanceShardTypeAcrossNodes(collection, vid, possibleDestinationEcNodes, dataPerNode, nodeToShardCount, maxDataPerNode, "data", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh locations after data shard moves
|
||||
// We need to re-scan because moving shards changes node states
|
||||
dataPerNode, parityPerNode = shardsByTypePerNode(vid, possibleDestinationEcNodes, ecb.diskType, dataShardCount)
|
||||
nodeToShardCount = countShardsByNode(vid, possibleDestinationEcNodes, ecb.diskType)
|
||||
|
||||
// Identify nodes containing data shards to avoid for parity placement
|
||||
antiAffinityNodes := make(map[string]bool)
|
||||
for nodeId, shards := range dataPerNode {
|
||||
if len(shards) > 0 {
|
||||
antiAffinityNodes[nodeId] = true
|
||||
overLimitCount--
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: Balance parity shards across nodes, avoiding nodes with data shards if possible
|
||||
if err := ecb.balanceShardTypeAcrossNodes(collection, vid, possibleDestinationEcNodes, parityPerNode, nodeToShardCount, maxParityPerNode, "parity", antiAffinityNodes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1306,9 +1144,7 @@ func (ecb *ecBalancer) doBalanceEcRack(ecRack *EcRack) error {
|
||||
for _, shardId := range si.Ids() {
|
||||
vid := needle.VolumeId(shards.Id)
|
||||
// For balancing, strictly require matching disk type
|
||||
// For balancing, strictly require matching disk type and apply anti-affinity
|
||||
dataShardCount := ecb.getDataShardCount()
|
||||
destDiskId := pickBestDiskOnNode(emptyNode, vid, ecb.diskType, true, shardId, dataShardCount)
|
||||
destDiskId := pickBestDiskOnNode(emptyNode, vid, ecb.diskType, true)
|
||||
|
||||
if destDiskId > 0 {
|
||||
fmt.Printf("%s moves ec shards %d.%d to %s (disk %d)\n", fullNode.info.Id, shards.Id, shardId, emptyNode.info.Id, destDiskId)
|
||||
@@ -1429,11 +1265,8 @@ func diskDistributionScore(ecNode *EcNode, vid needle.VolumeId) int {
|
||||
|
||||
// pickBestDiskOnNode selects the best disk on a node for placing a new EC shard
|
||||
// It prefers disks of the specified type with fewer shards and more free slots
|
||||
// When shardId is provided and dataShardCount > 0, it applies anti-affinity:
|
||||
// - For data shards (shardId < dataShardCount): prefer disks without parity shards
|
||||
// - For parity shards (shardId >= dataShardCount): prefer disks without data shards
|
||||
// If strictDiskType is false, it will fall back to other disk types if no matching disk is found
|
||||
func pickBestDiskOnNode(ecNode *EcNode, vid needle.VolumeId, diskType types.DiskType, strictDiskType bool, shardId erasure_coding.ShardId, dataShardCount int) uint32 {
|
||||
func pickBestDiskOnNode(ecNode *EcNode, vid needle.VolumeId, diskType types.DiskType, strictDiskType bool) uint32 {
|
||||
if len(ecNode.disks) == 0 {
|
||||
return 0 // No disk info available, let the server decide
|
||||
}
|
||||
@@ -1443,47 +1276,21 @@ func pickBestDiskOnNode(ecNode *EcNode, vid needle.VolumeId, diskType types.Disk
|
||||
var fallbackDiskId uint32
|
||||
fallbackScore := -1
|
||||
|
||||
// Determine if we're placing a data or parity shard
|
||||
isDataShard := dataShardCount > 0 && int(shardId) < dataShardCount
|
||||
|
||||
for diskId, disk := range ecNode.disks {
|
||||
if disk.freeEcSlots <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check existing shards on this disk for this volume
|
||||
// Check if this volume already has shards on this disk
|
||||
existingShards := 0
|
||||
hasDataShards := false
|
||||
hasParityShards := false
|
||||
if si, ok := disk.ecShards[vid]; ok {
|
||||
existingShards = si.Count()
|
||||
// Check what type of shards are on this disk
|
||||
if dataShardCount > 0 {
|
||||
for _, existingShardId := range si.Ids() {
|
||||
if int(existingShardId) < dataShardCount {
|
||||
hasDataShards = true
|
||||
} else {
|
||||
hasParityShards = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Score: prefer disks with fewer total shards and fewer shards of this volume
|
||||
// Lower score is better
|
||||
score := disk.ecShardCount*10 + existingShards*100
|
||||
|
||||
// Apply anti-affinity penalty if applicable
|
||||
if dataShardCount > 0 {
|
||||
if isDataShard && hasParityShards {
|
||||
// Penalize placing data shard on disk with parity shards
|
||||
score += 1000
|
||||
} else if !isDataShard && hasDataShards {
|
||||
// Penalize placing parity shard on disk with data shards
|
||||
score += 1000
|
||||
}
|
||||
}
|
||||
|
||||
if disk.diskType == string(diskType) {
|
||||
// Matching disk type - this is preferred
|
||||
if bestScore == -1 || score < bestScore {
|
||||
@@ -1507,20 +1314,19 @@ func pickBestDiskOnNode(ecNode *EcNode, vid needle.VolumeId, diskType types.Disk
|
||||
}
|
||||
|
||||
// pickEcNodeAndDiskToBalanceShardsInto picks both a destination node and specific disk
|
||||
func (ecb *ecBalancer) pickEcNodeAndDiskToBalanceShardsInto(vid needle.VolumeId, shardId erasure_coding.ShardId, existingLocation *EcNode, possibleDestinations []*EcNode) (*EcNode, uint32, error) {
|
||||
func (ecb *ecBalancer) pickEcNodeAndDiskToBalanceShardsInto(vid needle.VolumeId, existingLocation *EcNode, possibleDestinations []*EcNode) (*EcNode, uint32, error) {
|
||||
node, err := ecb.pickEcNodeToBalanceShardsInto(vid, existingLocation, possibleDestinations)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// For balancing, strictly require matching disk type and apply anti-affinity
|
||||
dataShardCount := ecb.getDataShardCount()
|
||||
diskId := pickBestDiskOnNode(node, vid, ecb.diskType, true, shardId, dataShardCount)
|
||||
// For balancing, strictly require matching disk type
|
||||
diskId := pickBestDiskOnNode(node, vid, ecb.diskType, true)
|
||||
return node, diskId, nil
|
||||
}
|
||||
|
||||
func (ecb *ecBalancer) pickOneEcNodeAndMoveOneShard(existingLocation *EcNode, collection string, vid needle.VolumeId, shardId erasure_coding.ShardId, possibleDestinationEcNodes []*EcNode) error {
|
||||
destNode, destDiskId, err := ecb.pickEcNodeAndDiskToBalanceShardsInto(vid, shardId, existingLocation, possibleDestinationEcNodes)
|
||||
destNode, destDiskId, err := ecb.pickEcNodeAndDiskToBalanceShardsInto(vid, existingLocation, possibleDestinationEcNodes)
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING: Could not find suitable target node for %d.%d:\n%s", vid, shardId, err.Error())
|
||||
return nil
|
||||
@@ -1635,114 +1441,3 @@ func compileCollectionPattern(pattern string) (*regexp.Regexp, error) {
|
||||
}
|
||||
return regexp.Compile(pattern)
|
||||
}
|
||||
|
||||
// balanceShardTypeAcrossNodes spreads shards of a specific type (data or parity) evenly across nodes
|
||||
func (ecb *ecBalancer) balanceShardTypeAcrossNodes(
|
||||
collection string,
|
||||
vid needle.VolumeId,
|
||||
possibleDestinationEcNodes []*EcNode,
|
||||
shardsPerNode map[string][]erasure_coding.ShardId,
|
||||
nodeToShardCount map[string]int,
|
||||
maxPerNode int,
|
||||
shardType string,
|
||||
antiAffinityNodes map[string]bool,
|
||||
) error {
|
||||
// Map ID to EcNode for lookup
|
||||
nodeMap := make(map[string]*EcNode)
|
||||
for _, n := range possibleDestinationEcNodes {
|
||||
nodeMap[n.info.Id] = n
|
||||
}
|
||||
|
||||
// Find nodes with too many shards of this type
|
||||
shardsToMove := make(map[erasure_coding.ShardId]*EcNode)
|
||||
for nodeId, shards := range shardsPerNode {
|
||||
if len(shards) <= maxPerNode {
|
||||
continue
|
||||
}
|
||||
// Pick excess shards to move
|
||||
excess := len(shards) - maxPerNode
|
||||
ecNode := nodeMap[nodeId]
|
||||
if ecNode == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := 0; i < excess && i < len(shards); i++ {
|
||||
shardId := shards[i]
|
||||
// Verify node has this shard
|
||||
si := findEcVolumeShardsInfo(ecNode, vid, ecb.diskType)
|
||||
if si.Has(shardId) {
|
||||
shardsToMove[shardId] = ecNode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move shards to nodes that have fewer than maxPerNode of this type
|
||||
for shardId, ecNode := range shardsToMove {
|
||||
// Find destination node with room for this shard type
|
||||
destNode, err := ecb.pickNodeForShardType(possibleDestinationEcNodes, shardsPerNode, maxPerNode, nodeToShardCount, antiAffinityNodes)
|
||||
if err != nil {
|
||||
fmt.Printf("ec %s shard %d.%d at %s can not find a destination node:\n%s\n", shardType, vid, shardId, ecNode.info.Id, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
err = ecb.pickOneEcNodeAndMoveOneShard(ecNode, collection, vid, shardId, []*EcNode{destNode})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update tracking
|
||||
destNodeId := destNode.info.Id
|
||||
shardsPerNode[destNodeId] = append(shardsPerNode[destNodeId], shardId)
|
||||
|
||||
// Remove from source node
|
||||
srcNodeId := ecNode.info.Id
|
||||
for i, s := range shardsPerNode[srcNodeId] {
|
||||
if s == shardId {
|
||||
shardsPerNode[srcNodeId] = append(shardsPerNode[srcNodeId][:i], shardsPerNode[srcNodeId][i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
nodeToShardCount[destNodeId] += 1
|
||||
nodeToShardCount[srcNodeId] -= 1
|
||||
destNode.freeEcSlot -= 1
|
||||
ecNode.freeEcSlot += 1
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickNodeForShardType selects a node that has room for more shards of a specific type
|
||||
func (ecb *ecBalancer) pickNodeForShardType(
|
||||
nodes []*EcNode,
|
||||
shardsPerNode map[string][]erasure_coding.ShardId,
|
||||
maxPerNode int,
|
||||
nodeToShardCount map[string]int,
|
||||
antiAffinityNodes map[string]bool,
|
||||
) (*EcNode, error) {
|
||||
selector := &twoPassSelector[*EcNode]{
|
||||
candidates: nodes,
|
||||
shardsPerTarget: shardsPerNode,
|
||||
maxPerTarget: maxPerNode,
|
||||
targetToShardCount: nodeToShardCount,
|
||||
antiAffinity: antiAffinityNodes,
|
||||
getKey: func(n *EcNode) string {
|
||||
return n.info.Id
|
||||
},
|
||||
hasFreeSlots: func(n *EcNode) bool {
|
||||
return n.freeEcSlot > 0
|
||||
},
|
||||
checkLimit: func(n *EcNode) bool {
|
||||
// For EC shards, replica placement constraint only applies when SameRackCount > 0.
|
||||
if ecb.replicaPlacement != nil && ecb.replicaPlacement.SameRackCount > 0 {
|
||||
return nodeToShardCount[n.info.Id] < ecb.replicaPlacement.SameRackCount+1
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
selected, err := selector.selectCandidate()
|
||||
if err != nil {
|
||||
return nil, errors.New("no node available for shard type balancing")
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
)
|
||||
|
||||
func TestPickRackForShardType_AntiAffinityRacks(t *testing.T) {
|
||||
// Setup topology with 3 racks, each with 1 node, enough free slots
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "test_topo",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
buildRackWithEcShards("rack0", "node0:8080", 100, nil),
|
||||
buildRackWithEcShards("rack1", "node1:8080", 100, nil),
|
||||
buildRackWithEcShards("rack2", "node2:8080", 100, nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ecNodes, _ := collectEcVolumeServersByDc(topo, "", types.HardDriveType)
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: ecNodes,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
racks := ecb.racks()
|
||||
rackToShardCount := make(map[string]int)
|
||||
shardsPerRack := make(map[string][]erasure_coding.ShardId)
|
||||
maxPerRack := 2
|
||||
|
||||
// Case 1: Avoid rack0
|
||||
antiAffinityRacks := map[string]bool{"rack0": true}
|
||||
|
||||
// Try multiple times to ensure randomness doesn't accidentally pass
|
||||
for i := 0; i < 20; i++ {
|
||||
picked, err := ecb.pickRackForShardType(racks, shardsPerRack, maxPerRack, rackToShardCount, antiAffinityRacks)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if picked == "rack0" {
|
||||
t.Errorf("picked avoided rack rack0")
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Fallback - avoid all racks
|
||||
avoidAll := map[string]bool{"rack0": true, "rack1": true, "rack2": true}
|
||||
picked, err := ecb.pickRackForShardType(racks, shardsPerRack, maxPerRack, rackToShardCount, avoidAll)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback failed: %v", err)
|
||||
}
|
||||
if picked == "" {
|
||||
t.Errorf("expected some rack to be picked in fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRackForShardType_EdgeCases(t *testing.T) {
|
||||
t.Run("NoFreeSlots", func(t *testing.T) {
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "test_topo",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
buildRackWithEcShards("rack0", "node0:8080", 0, nil), // maxVolumes=0
|
||||
buildRackWithEcShards("rack1", "node1:8080", 0, nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ecNodes, _ := collectEcVolumeServersByDc(topo, "", types.HardDriveType)
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: ecNodes,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
racks := ecb.racks()
|
||||
_, err := ecb.pickRackForShardType(racks, make(map[string][]erasure_coding.ShardId), 2, make(map[string]int), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error when no free slots, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AllRacksAtMaxCapacity", func(t *testing.T) {
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "test_topo",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
buildRackWithEcShards("rack0", "node0:8080", 100, nil),
|
||||
buildRackWithEcShards("rack1", "node1:8080", 100, nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ecNodes, _ := collectEcVolumeServersByDc(topo, "", types.HardDriveType)
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: ecNodes,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
racks := ecb.racks()
|
||||
shardsPerRack := map[string][]erasure_coding.ShardId{
|
||||
"rack0": {0, 1}, // 2 shards
|
||||
"rack1": {2, 3}, // 2 shards
|
||||
}
|
||||
maxPerRack := 2
|
||||
|
||||
_, err := ecb.pickRackForShardType(racks, shardsPerRack, maxPerRack, make(map[string]int), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error when all racks at max capacity, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ReplicaPlacementLimit", func(t *testing.T) {
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "test_topo",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
buildRackWithEcShards("rack0", "node0:8080", 100, nil),
|
||||
buildRackWithEcShards("rack1", "node1:8080", 100, nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ecNodes, _ := collectEcVolumeServersByDc(topo, "", types.HardDriveType)
|
||||
rp, _ := super_block.NewReplicaPlacementFromString("012") // DiffRackCount = 1
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: ecNodes,
|
||||
diskType: types.HardDriveType,
|
||||
replicaPlacement: rp,
|
||||
}
|
||||
|
||||
racks := ecb.racks()
|
||||
rackToShardCount := map[string]int{
|
||||
"rack0": 1, // At limit
|
||||
"rack1": 0,
|
||||
}
|
||||
|
||||
picked, err := ecb.pickRackForShardType(racks, make(map[string][]erasure_coding.ShardId), 5, rackToShardCount, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if picked != "rack1" {
|
||||
t.Errorf("expected rack1 (not at limit), got %v", picked)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PreferFewerShards", func(t *testing.T) {
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "test_topo",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
buildRackWithEcShards("rack0", "node0:8080", 100, nil),
|
||||
buildRackWithEcShards("rack1", "node1:8080", 100, nil),
|
||||
buildRackWithEcShards("rack2", "node2:8080", 100, nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ecNodes, _ := collectEcVolumeServersByDc(topo, "", types.HardDriveType)
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: ecNodes,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
racks := ecb.racks()
|
||||
shardsPerRack := map[string][]erasure_coding.ShardId{
|
||||
"rack0": {0, 1}, // 2 shards
|
||||
"rack1": {2}, // 1 shard
|
||||
"rack2": {}, // 0 shards
|
||||
}
|
||||
|
||||
// Should pick rack2 (fewest shards)
|
||||
picked, err := ecb.pickRackForShardType(racks, shardsPerRack, 5, make(map[string]int), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if picked != "rack2" {
|
||||
t.Errorf("expected rack2 (fewest shards), got %v", picked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPickNodeForShardType_AntiAffinityNodes(t *testing.T) {
|
||||
// Setup topology with 1 rack, 3 nodes
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "test_topo",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
Id: "rack0",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
buildDataNode("node0:8080", 100),
|
||||
buildDataNode("node1:8080", 100),
|
||||
buildDataNode("node2:8080", 100),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ecNodes, _ := collectEcVolumeServersByDc(topo, "", types.HardDriveType)
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: ecNodes,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
nodeToShardCount := make(map[string]int)
|
||||
shardsPerNode := make(map[string][]erasure_coding.ShardId)
|
||||
maxPerNode := 2
|
||||
|
||||
// Case 1: Avoid node0
|
||||
antiAffinityNodes := map[string]bool{"node0:8080": true}
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
picked, err := ecb.pickNodeForShardType(ecNodes, shardsPerNode, maxPerNode, nodeToShardCount, antiAffinityNodes)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if picked.info.Id == "node0:8080" {
|
||||
t.Errorf("picked avoided node node0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickNodeForShardType_EdgeCases(t *testing.T) {
|
||||
t.Run("NoFreeSlots", func(t *testing.T) {
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "test_topo",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
Id: "rack0",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
buildDataNode("node0:8080", 0), // No capacity
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ecNodes, _ := collectEcVolumeServersByDc(topo, "", types.HardDriveType)
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: ecNodes,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
_, err := ecb.pickNodeForShardType(ecNodes, make(map[string][]erasure_coding.ShardId), 2, make(map[string]int), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error when no free slots, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ReplicaPlacementSameRackLimit", func(t *testing.T) {
|
||||
topo := &master_pb.TopologyInfo{
|
||||
Id: "test_topo",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
Id: "rack0",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
buildDataNode("node0:8080", 100),
|
||||
buildDataNode("node1:8080", 100),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ecNodes, _ := collectEcVolumeServersByDc(topo, "", types.HardDriveType)
|
||||
rp, _ := super_block.NewReplicaPlacementFromString("021") // SameRackCount = 1
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: ecNodes,
|
||||
diskType: types.HardDriveType,
|
||||
replicaPlacement: rp,
|
||||
}
|
||||
|
||||
nodeToShardCount := map[string]int{
|
||||
"node0:8080": 3, // Exceeds SameRackCount + 1
|
||||
"node1:8080": 0,
|
||||
}
|
||||
|
||||
picked, err := ecb.pickNodeForShardType(ecNodes, make(map[string][]erasure_coding.ShardId), 5, nodeToShardCount, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if picked.info.Id != "node1:8080" {
|
||||
t.Errorf("expected node1 (not at limit), got %v", picked.info.Id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShardsByType(t *testing.T) {
|
||||
vid := needle.VolumeId(123)
|
||||
|
||||
// Create mock nodes with shards
|
||||
nodes := []*EcNode{
|
||||
{
|
||||
info: &master_pb.DataNodeInfo{
|
||||
Id: "node1",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
string(types.HardDriveType): {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{
|
||||
Id: uint32(vid),
|
||||
EcIndexBits: uint32((1 << 0) | (1 << 1) | (1 << 10) | (1 << 11)), // data: 0,1 parity: 10,11
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rack: "rack1",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("Standard10Plus4", func(t *testing.T) {
|
||||
dataPerRack, parityPerRack := shardsByTypePerRack(vid, nodes, types.HardDriveType, 10)
|
||||
|
||||
if len(dataPerRack["rack1"]) != 2 {
|
||||
t.Errorf("expected 2 data shards, got %d", len(dataPerRack["rack1"]))
|
||||
}
|
||||
if len(parityPerRack["rack1"]) != 2 {
|
||||
t.Errorf("expected 2 parity shards, got %d", len(parityPerRack["rack1"]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NodeGrouping", func(t *testing.T) {
|
||||
dataPerNode, parityPerNode := shardsByTypePerNode(vid, nodes, types.HardDriveType, 10)
|
||||
|
||||
if len(dataPerNode["node1"]) != 2 {
|
||||
t.Errorf("expected 2 data shards on node1, got %d", len(dataPerNode["node1"]))
|
||||
}
|
||||
if len(parityPerNode["node1"]) != 2 {
|
||||
t.Errorf("expected 2 parity shards on node1, got %d", len(parityPerNode["node1"]))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func buildDataNode(nodeId string, maxVolumes int64) *master_pb.DataNodeInfo {
|
||||
return &master_pb.DataNodeInfo{
|
||||
Id: nodeId,
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
string(types.HardDriveType): {
|
||||
Type: string(types.HardDriveType),
|
||||
MaxVolumeCount: maxVolumes,
|
||||
VolumeCount: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -109,9 +109,9 @@ func (c *commandFsMergeVolumes) Do(args []string, commandEnv *CommandEnv, writer
|
||||
defer util_http.GetGlobalHttpClient().CloseIdleConnections()
|
||||
|
||||
return commandEnv.WithFilerClient(false, func(filerClient filer_pb.SeaweedFilerClient) error {
|
||||
return filer_pb.TraverseBfs(context.Background(), commandEnv, util.FullPath(dir), func(parentPath util.FullPath, entry *filer_pb.Entry) error {
|
||||
return filer_pb.TraverseBfs(commandEnv, util.FullPath(dir), func(parentPath util.FullPath, entry *filer_pb.Entry) {
|
||||
if entry.IsDirectory {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
for _, chunk := range entry.Chunks {
|
||||
chunkVolumeId := needle.VolumeId(chunk.Fid.VolumeId)
|
||||
@@ -141,7 +141,6 @@ func (c *commandFsMergeVolumes) Do(args []string, commandEnv *CommandEnv, writer
|
||||
fmt.Printf("failed to update %s: %v\n", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -75,13 +75,13 @@ func (c *commandFsMetaChangeVolumeId) Do(args []string, commandEnv *CommandEnv,
|
||||
}
|
||||
|
||||
return commandEnv.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
return filer_pb.TraverseBfs(context.Background(), commandEnv, util.FullPath(*dir), func(parentPath util.FullPath, entry *filer_pb.Entry) error {
|
||||
return filer_pb.TraverseBfs(commandEnv, util.FullPath(*dir), func(parentPath util.FullPath, entry *filer_pb.Entry) {
|
||||
if !entry.IsDirectory {
|
||||
var hasChanges bool
|
||||
for _, chunk := range entry.Chunks {
|
||||
if chunk.IsChunkManifest {
|
||||
fmt.Printf("Change volume id for large file is not implemented yet: %s/%s\n", parentPath, entry.Name)
|
||||
return nil
|
||||
return
|
||||
}
|
||||
chunkVolumeId := chunk.Fid.VolumeId
|
||||
if toVolumeId, found := mapping[needle.VolumeId(chunkVolumeId)]; found {
|
||||
@@ -102,7 +102,6 @@ func (c *commandFsMetaChangeVolumeId) Do(args []string, commandEnv *CommandEnv,
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -52,7 +51,7 @@ func (c *commandFsMetaNotify) Do(args []string, commandEnv *CommandEnv, writer i
|
||||
|
||||
var dirCount, fileCount uint64
|
||||
|
||||
err = filer_pb.TraverseBfs(context.Background(), commandEnv, util.FullPath(path), func(parentPath util.FullPath, entry *filer_pb.Entry) error {
|
||||
err = filer_pb.TraverseBfs(commandEnv, util.FullPath(path), func(parentPath util.FullPath, entry *filer_pb.Entry) {
|
||||
|
||||
if entry.IsDirectory {
|
||||
dirCount++
|
||||
@@ -70,7 +69,7 @@ func (c *commandFsMetaNotify) Do(args []string, commandEnv *CommandEnv, writer i
|
||||
if notifyErr != nil {
|
||||
fmt.Fprintf(writer, "fail to notify new entry event for %s: %v\n", parentPath.Child(entry.Name), notifyErr)
|
||||
}
|
||||
return nil
|
||||
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
|
||||
@@ -118,21 +118,14 @@ func (c *commandFsMetaSave) Do(args []string, commandEnv *CommandEnv, writer io.
|
||||
|
||||
outputChan <- bytes
|
||||
return nil
|
||||
}, func(outputChan chan interface{}) error {
|
||||
}, func(outputChan chan interface{}) {
|
||||
sizeBuf := make([]byte, 4)
|
||||
for item := range outputChan {
|
||||
b := item.([]byte)
|
||||
util.Uint32toBytes(sizeBuf, uint32(len(b)))
|
||||
_, err := dst.Write(sizeBuf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dst.Write(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst.Write(sizeBuf)
|
||||
dst.Write(b)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
@@ -143,21 +136,17 @@ func (c *commandFsMetaSave) Do(args []string, commandEnv *CommandEnv, writer io.
|
||||
|
||||
}
|
||||
|
||||
func doTraverseBfsAndSaving(filerClient filer_pb.FilerClient, writer io.Writer, path string, verbose bool, genFn func(entry *filer_pb.FullEntry, outputChan chan interface{}) error, saveFn func(outputChan chan interface{}) error) error {
|
||||
func doTraverseBfsAndSaving(filerClient filer_pb.FilerClient, writer io.Writer, path string, verbose bool, genFn func(entry *filer_pb.FullEntry, outputChan chan interface{}) error, saveFn func(outputChan chan interface{})) error {
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
outputChan := make(chan interface{}, 1024)
|
||||
saveErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
saveErrChan <- saveFn(outputChan)
|
||||
saveFn(outputChan)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
var dirCount, fileCount uint64
|
||||
var once sync.Once
|
||||
var firstErr error
|
||||
var hasErr atomic.Bool
|
||||
|
||||
// also save the directory itself (path) if it exists in the filer
|
||||
if e, getErr := filer_pb.GetEntry(context.Background(), filerClient, util.FullPath(path)); getErr != nil {
|
||||
@@ -171,13 +160,8 @@ func doTraverseBfsAndSaving(filerClient filer_pb.FilerClient, writer io.Writer,
|
||||
Dir: parentDir,
|
||||
Entry: e,
|
||||
}
|
||||
|
||||
if genErr := genFn(protoMessage, outputChan); genErr != nil {
|
||||
once.Do(func() {
|
||||
firstErr = genErr
|
||||
hasErr.Store(true)
|
||||
})
|
||||
return genErr
|
||||
fmt.Fprintf(writer, "marshall error: %v\n", genErr)
|
||||
} else {
|
||||
if e.IsDirectory {
|
||||
atomic.AddUint64(&dirCount, 1)
|
||||
@@ -187,13 +171,10 @@ func doTraverseBfsAndSaving(filerClient filer_pb.FilerClient, writer io.Writer,
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
err := filer_pb.TraverseBfs(ctx, filerClient, util.FullPath(path), func(parentPath util.FullPath, entry *filer_pb.Entry) error {
|
||||
err := filer_pb.TraverseBfs(filerClient, util.FullPath(path), func(parentPath util.FullPath, entry *filer_pb.Entry) {
|
||||
|
||||
parent := string(parentPath)
|
||||
if parent == filer.SystemLogDir || strings.HasPrefix(parent, filer.SystemLogDir+"/") {
|
||||
return nil
|
||||
if strings.HasPrefix(string(parentPath), filer.SystemLogDir) {
|
||||
return
|
||||
}
|
||||
|
||||
protoMessage := &filer_pb.FullEntry{
|
||||
@@ -201,17 +182,9 @@ func doTraverseBfsAndSaving(filerClient filer_pb.FilerClient, writer io.Writer,
|
||||
Entry: entry,
|
||||
}
|
||||
|
||||
if hasErr.Load() {
|
||||
// fail-fast: stop traversal once an error is observed.
|
||||
return firstErr
|
||||
}
|
||||
if genErr := genFn(protoMessage, outputChan); genErr != nil {
|
||||
once.Do(func() {
|
||||
firstErr = genErr
|
||||
hasErr.Store(true)
|
||||
cancel()
|
||||
})
|
||||
return genErr
|
||||
if err := genFn(protoMessage, outputChan); err != nil {
|
||||
fmt.Fprintf(writer, "marshall error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry.IsDirectory {
|
||||
@@ -224,23 +197,14 @@ func doTraverseBfsAndSaving(filerClient filer_pb.FilerClient, writer io.Writer,
|
||||
println(parentPath.Child(entry.Name))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
close(outputChan)
|
||||
|
||||
wg.Wait()
|
||||
saveErr := <-saveErrChan
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
|
||||
if writer != nil {
|
||||
if err == nil && writer != nil {
|
||||
fmt.Fprintf(writer, "total %d directories, %d files\n", dirCount, fileCount)
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ func (c *commandFsVerify) verifyTraverseBfs(path string) (fileCount uint64, errC
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(outputChan chan interface{}) error {
|
||||
func(outputChan chan interface{}) {
|
||||
var wg sync.WaitGroup
|
||||
itemErrCount := atomic.NewUint64(0)
|
||||
for itemEntry := range outputChan {
|
||||
@@ -315,6 +315,5 @@ func (c *commandFsVerify) verifyTraverseBfs(path string) (fileCount uint64, errC
|
||||
}
|
||||
wg.Wait()
|
||||
errCount = itemErrCount.Load()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Commands = append(Commands, &commandRemoteCopyLocal{})
|
||||
}
|
||||
|
||||
type commandRemoteCopyLocal struct {
|
||||
}
|
||||
|
||||
func (c *commandRemoteCopyLocal) Name() string {
|
||||
return "remote.copy.local"
|
||||
}
|
||||
|
||||
func (c *commandRemoteCopyLocal) Help() string {
|
||||
return `copy local files to remote storage
|
||||
|
||||
# assume a remote storage is configured to name "cloud1"
|
||||
remote.configure -name=cloud1 -type=s3 -s3.access_key=xxx -s3.secret_key=yyy
|
||||
# mount and pull one bucket
|
||||
remote.mount -dir=/xxx -remote=cloud1/bucket
|
||||
|
||||
# copy local files to remote storage
|
||||
remote.copy.local -dir=/xxx # copy all local-only files
|
||||
remote.copy.local -dir=/xxx -concurrent=16 # with custom concurrency
|
||||
remote.copy.local -dir=/xxx -include=*.pdf # only copy PDF files
|
||||
remote.copy.local -dir=/xxx -exclude=*.tmp # exclude temporary files
|
||||
remote.copy.local -dir=/xxx -dryRun=true # show what would be done without making changes
|
||||
remote.copy.local -dir=/xxx -forceUpdate=true # force update even if remote exists
|
||||
|
||||
This command will:
|
||||
1. Find local files that don't exist on remote storage
|
||||
2. Copy these files to remote storage
|
||||
3. Update local metadata with remote information
|
||||
|
||||
This is useful when:
|
||||
- You deleted filer logs and need to copy existing files
|
||||
- You have local files that were never copied to remote
|
||||
- You want to ensure all local files are backed up to remote
|
||||
|
||||
`
|
||||
}
|
||||
|
||||
func (c *commandRemoteCopyLocal) HasTag(CommandTag) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *commandRemoteCopyLocal) Do(args []string, commandEnv *CommandEnv, writer io.Writer) (err error) {
|
||||
|
||||
remoteCopyLocalCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
|
||||
|
||||
dir := remoteCopyLocalCommand.String("dir", "", "a directory in filer")
|
||||
concurrency := remoteCopyLocalCommand.Int("concurrent", 16, "concurrent file operations")
|
||||
dryRun := remoteCopyLocalCommand.Bool("dryRun", false, "show what would be done without making changes")
|
||||
forceUpdate := remoteCopyLocalCommand.Bool("forceUpdate", false, "force update even if remote exists")
|
||||
fileFilter := newFileFilter(remoteCopyLocalCommand)
|
||||
|
||||
if err = remoteCopyLocalCommand.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *dir == "" {
|
||||
return fmt.Errorf("need to specify -dir option")
|
||||
}
|
||||
|
||||
mappings, localMountedDir, remoteStorageMountedLocation, remoteStorageConf, detectErr := detectMountInfo(commandEnv, writer, *dir)
|
||||
if detectErr != nil {
|
||||
jsonPrintln(writer, mappings)
|
||||
return detectErr
|
||||
}
|
||||
|
||||
// perform local to remote copy
|
||||
return c.doLocalToRemoteCopy(commandEnv, writer, util.FullPath(localMountedDir), remoteStorageMountedLocation, util.FullPath(*dir), remoteStorageConf, *concurrency, *dryRun, *forceUpdate, fileFilter)
|
||||
}
|
||||
|
||||
func (c *commandRemoteCopyLocal) doLocalToRemoteCopy(commandEnv *CommandEnv, writer io.Writer, localMountedDir util.FullPath, remoteMountedLocation *remote_pb.RemoteStorageLocation, dirToCopy util.FullPath, remoteConf *remote_pb.RemoteConf, concurrency int, dryRun bool, forceUpdate bool, fileFilter *FileFilter) error {
|
||||
|
||||
// Get remote storage client
|
||||
remoteStorage, err := remote_storage.GetRemoteStorage(remoteConf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get remote storage: %w", err)
|
||||
}
|
||||
|
||||
remote := filer.MapFullPathToRemoteStorageLocation(localMountedDir, remoteMountedLocation, dirToCopy)
|
||||
|
||||
// Step 1: Collect all local files that are part of the remote mount
|
||||
localFiles := make(map[string]*filer_pb.Entry)
|
||||
err = recursivelyTraverseDirectory(commandEnv, dirToCopy, func(dir util.FullPath, entry *filer_pb.Entry) bool {
|
||||
// Only consider files that are part of remote mount
|
||||
if isInMountedDirectory(dir, localMountedDir) {
|
||||
fullPath := string(dir.Child(entry.Name))
|
||||
localFiles[fullPath] = entry
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to traverse local directory: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(writer, "Found %d files/directories in local storage\n", len(localFiles))
|
||||
|
||||
// Step 2: Check which files exist on remote storage
|
||||
remoteFiles := make(map[string]bool)
|
||||
err = remoteStorage.Traverse(remote, func(remoteDir, name string, isDirectory bool, remoteEntry *filer_pb.RemoteEntry) error {
|
||||
localDir := filer.MapRemoteStorageLocationPathToFullPath(localMountedDir, remoteMountedLocation, remoteDir)
|
||||
fullPath := string(localDir.Child(name))
|
||||
remoteFiles[fullPath] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to traverse remote storage: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(writer, "Found %d files/directories in remote storage\n", len(remoteFiles))
|
||||
|
||||
// Step 3: Determine files to copy
|
||||
var filesToCopy []string
|
||||
for localPath, localEntry := range localFiles {
|
||||
// Skip directories
|
||||
if localEntry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply file filter
|
||||
if !fileFilter.matches(localEntry) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if file needs copying
|
||||
needsCopy := false
|
||||
if !remoteFiles[localPath] {
|
||||
// File doesn't exist on remote
|
||||
needsCopy = true
|
||||
} else if forceUpdate {
|
||||
// Force update requested and file exists on remote
|
||||
needsCopy = true
|
||||
}
|
||||
|
||||
if needsCopy {
|
||||
filesToCopy = append(filesToCopy, localPath)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(writer, "Files to copy: %d\n", len(filesToCopy))
|
||||
|
||||
if dryRun {
|
||||
fmt.Fprintf(writer, "DRY RUN - showing what would be done:\n")
|
||||
for _, path := range filesToCopy {
|
||||
fmt.Fprintf(writer, "COPY: %s\n", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 4: Copy files to remote storage
|
||||
if len(filesToCopy) == 0 {
|
||||
fmt.Fprintf(writer, "No files to copy\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
limitedConcurrentExecutor := util.NewLimitedConcurrentExecutor(concurrency)
|
||||
var firstErr error
|
||||
var errOnce sync.Once
|
||||
var successCount atomic.Int64
|
||||
var outputMu sync.Mutex
|
||||
|
||||
for _, pathToCopy := range filesToCopy {
|
||||
wg.Add(1)
|
||||
localPath := pathToCopy // Capture for closure
|
||||
limitedConcurrentExecutor.Execute(func() {
|
||||
defer wg.Done()
|
||||
|
||||
localEntry := localFiles[localPath]
|
||||
if localEntry == nil {
|
||||
outputMu.Lock()
|
||||
fmt.Fprintf(writer, "Warning: skipping copy for %s (local entry not found)\n", localPath)
|
||||
outputMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
outputMu.Lock()
|
||||
fmt.Fprintf(writer, "Copying %s... ", localPath)
|
||||
outputMu.Unlock()
|
||||
|
||||
dir, _ := util.FullPath(localPath).DirAndName()
|
||||
remoteLocation := filer.MapFullPathToRemoteStorageLocation(localMountedDir, remoteMountedLocation, util.FullPath(localPath))
|
||||
|
||||
// Copy the file to remote storage
|
||||
err := syncFileToRemote(commandEnv, remoteStorage, remoteConf, remoteLocation, util.FullPath(dir), localEntry)
|
||||
if err != nil {
|
||||
outputMu.Lock()
|
||||
fmt.Fprintf(writer, "failed: %v\n", err)
|
||||
outputMu.Unlock()
|
||||
errOnce.Do(func() {
|
||||
firstErr = err
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
successCount.Add(1)
|
||||
outputMu.Lock()
|
||||
fmt.Fprintf(writer, "done\n")
|
||||
outputMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
return firstErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(writer, "Successfully copied %d files to remote storage\n", successCount.Load())
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncFileToRemote(commandEnv *CommandEnv, remoteStorage remote_storage.RemoteStorageClient, remoteConf *remote_pb.RemoteConf, remoteLocation *remote_pb.RemoteStorageLocation, dir util.FullPath, localEntry *filer_pb.Entry) error {
|
||||
|
||||
// Upload to remote storage using the same approach as filer_remote_sync
|
||||
var remoteEntry *filer_pb.RemoteEntry
|
||||
var err error
|
||||
|
||||
err = util.Retry("writeFile", func() error {
|
||||
// Create a reader for the file content
|
||||
reader := filer.NewFileReader(commandEnv, localEntry)
|
||||
|
||||
remoteEntry, err = remoteStorage.WriteFile(remoteLocation, localEntry, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload to remote storage: %w", err)
|
||||
}
|
||||
|
||||
// Update local entry with remote information
|
||||
return commandEnv.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Update local entry with remote information
|
||||
localEntry.RemoteEntry = remoteEntry
|
||||
|
||||
// Update the entry
|
||||
_, updateErr := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||
Directory: string(dir),
|
||||
Entry: localEntry,
|
||||
})
|
||||
if updateErr != nil {
|
||||
return fmt.Errorf("failed to update local entry: %w", updateErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func isInMountedDirectory(dir util.FullPath, mountedDir util.FullPath) bool {
|
||||
if string(dir) == string(mountedDir) {
|
||||
return true
|
||||
}
|
||||
// Ensure mountedDir ends with separator to avoid matching sibling directories
|
||||
// e.g., "/mnt/remote2" should not match "/mnt/remote"
|
||||
mountedDirStr := string(mountedDir)
|
||||
if !strings.HasSuffix(mountedDirStr, "/") {
|
||||
mountedDirStr += "/"
|
||||
}
|
||||
return strings.HasPrefix(string(dir)+"/", mountedDirStr)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user